From 7d2a36ff2216f8cdf254c71d40786a180764398f Mon Sep 17 00:00:00 2001 From: Edwin van den Houdt Date: Thu, 23 Jul 2026 13:51:04 +0200 Subject: [PATCH] =?UTF-8?q?feat(arch):=20WP-38=20=E2=80=94=20dependency=20?= =?UTF-8?q?graph=20+=20declarative=20boundaries=20(dependency-cruiser)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .claude/skills/new-context/SKILL.md | 26 +- .dependency-cruiser.js | 106 ++++++++ .github/workflows/ci.yml | 2 + docs/project/backlog/README.md | 2 +- .../WP-38-dependency-graph-boundaries.md | 21 +- docs/reference/architecture/dependencies.md | 57 +++++ .../architecture/dependency-graph.md | 187 ++++++++++++++ eslint.config.mjs | 234 +----------------- package-lock.json | 223 ++++++++++++++++- package.json | 3 + scripts/ci-local.sh | 1 + scripts/dep-graph.sh | 27 ++ 12 files changed, 641 insertions(+), 248 deletions(-) create mode 100644 .dependency-cruiser.js create mode 100644 docs/reference/architecture/dependencies.md create mode 100644 docs/reference/architecture/dependency-graph.md create mode 100644 scripts/dep-graph.sh diff --git a/.claude/skills/new-context/SKILL.md b/.claude/skills/new-context/SKILL.md index cd43d38..be94888 100644 --- a/.claude/skills/new-context/SKILL.md +++ b/.claude/skills/new-context/SKILL.md @@ -1,6 +1,6 @@ --- name: new-context -description: Scaffold a new DDD bounded context (folders, path alias, eslint boundary rules, lazy route). Use when adding a new business capability that doesn't belong in an existing context. +description: Scaffold a new DDD bounded context (folders, path alias, boundary rules, lazy route). Use when adding a new business capability that doesn't belong in an existing context. --- # New bounded context @@ -18,16 +18,14 @@ shared/reusable code is English. The context name is the ubiquitous language ter only once it gets a wire seam). Empty layers can wait; don't scaffold placeholders. 2. **Path alias** — add `"@/*": ["src/app//*"]` to `tsconfig.json` `paths`. Aliases are direction statements; always import cross-context via the alias. -3. **eslint boundaries** (`eslint.config.mjs`) — dependencies point inward and - toward `shared` only. Copy the existing per-context block (the `brief` block is - the minimal leaf-context example) and: - - add a block for `src/app//**/*.ts` banning imports from every context it - may **not** depend on; - - add `@/*` to the ban lists of `shared/**` and every context that must not - depend on the new one (grep the config for `@brief/*` to find all lists); - - the generic blocks (`domain/**` framework-free, `contracts/**` import-nothing, - ApiClient confinement, `ui/**` never imports `infrastructure`) match by glob - and cover the new context automatically. +3. **Boundaries** (`.dependency-cruiser.js`, WP-38 — the single declarative source; + dependencies point inward and toward `shared` only). Add ONE `contextRule(...)` entry + for the new context listing the contexts it may **not** import (copy the `brief` leaf + example), and add the new context to the forbidden list of any context that must not + depend on it. The layer rules (`domain/` framework-free, `contracts/` import-nothing, + ApiClient confinement, `ui ↛ infrastructure`) match by glob and cover it automatically. + Verify with `npm run dep:check`; regenerate the graph with `npm run dep:graph`. (Boundaries + are no longer in `eslint.config.mjs` — that now holds only `no-explicit-any` + template a11y.) 4. **Route** — lazy child under the persistent shell in `app.routes.ts`: ```ts @@ -40,12 +38,12 @@ shared/reusable code is English. The context name is the ubiquitous language ter ## Worked example `src/app/brief/` — an independent leaf context (depends only on shared): see its -folder layout and its eslint block in `eslint.config.mjs`. +folder layout and its `contextRule` entry in `.dependency-cruiser.js`. ## Verify ```bash -npm run lint && npm run build +npm run dep:check && npm run lint && npm run build # prove the fence works: add a forbidden import (e.g. new ctx → @herregistratie/*), -# confirm lint fails, remove it. +# confirm `npm run dep:check` fails, remove it. ``` diff --git a/.dependency-cruiser.js b/.dependency-cruiser.js new file mode 100644 index 0000000..48e76b6 --- /dev/null +++ b/.dependency-cruiser.js @@ -0,0 +1,106 @@ +// Dependency-cruiser (WP-38): the single declarative source for the app's bounded-context +// + atomic-layer boundaries — and the graph you can SEE (`npm run dep:graph`). Replaces the +// hand-duplicated `no-restricted-imports` blocks that had to be copied per context (and that +// left `herregistratie` without one). ESLint keeps only the rules dep-cruiser can't express +// (no-explicit-any, template a11y). +// +// Contexts: shared (base) · auth · registratie · herregistratie · brief · beheer · showcase. +// Allowed cross-context edges: everyone → shared; herregistratie → registratie; showcase → * +// (the sanctioned teaching page). Nobody imports showcase. + +const FEATURES = 'auth|registratie|herregistratie|brief|beheer|showcase'; + +/** A context may import shared + itself; this lists the OTHER contexts it may NOT import. */ +const contextRule = (name, from, forbiddenContexts) => ({ + name, + comment: `${from} may depend only on its allowed contexts (+ shared). See CLAUDE.md §1.`, + severity: 'error', + from: { path: `^src/app/${from}/` }, + to: { path: `^src/app/(${forbiddenContexts})/` }, +}); + +module.exports = { + forbidden: [ + // --- Bounded-context direction (the "dependencies point inward" spine) --- + { + name: 'shared-no-features', + comment: 'shared/ is the base — it must not import any feature context.', + severity: 'error', + from: { path: '^src/app/shared/', pathNot: '^src/app/shared/ui/debug-state/' }, + to: { path: `^src/app/(${FEATURES})/` }, + }, + contextRule('auth-only-shared', 'auth', 'registratie|herregistratie|brief|beheer|showcase'), + contextRule( + 'registratie-only-shared', + 'registratie', + 'auth|herregistratie|brief|beheer|showcase', + ), + // herregistratie MAY import registratie (+ shared) — the one sanctioned cross-feature edge. + contextRule('herregistratie-scope', 'herregistratie', 'auth|brief|beheer|showcase'), + contextRule('brief-only-shared', 'brief', 'auth|registratie|herregistratie|beheer|showcase'), + contextRule('beheer-only-shared', 'beheer', 'auth|registratie|herregistratie|brief|showcase'), + // showcase/ is exempt (reads every context by design); nothing imports it — covered by the + // rules above each forbidding `→ showcase`. + + // --- 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: '/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: '/contracts/' }, + to: { pathNot: '/contracts/', path: '^(src/app/|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: '(/ui/|/layout/)', + pathNot: '\\.stories\\.ts$|\\.spec\\.ts$|^src/app/shared/ui/debug-state/', + }, + to: { path: '/infrastructure/', dependencyTypesNot: ['type-only'] }, + }, + { + name: 'apiclient-infrastructure-only', + comment: + 'The generated ApiClient is a value only inside infrastructure/ (+ shared/upload); elsewhere type-only.', + severity: 'error', + from: { pathNot: '/infrastructure/|^src/app/shared/upload/' }, + to: { + path: '^src/app/shared/infrastructure/api-client\\.ts$', + dependencyTypesNot: ['type-only'], + }, + }, + + // --- 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: 'tsconfig.json' }, // resolves @shared/@registratie/… path aliases + tsPreCompilationDeps: true, // needed so `type-only` imports are distinguished + enhancedResolveOptions: { + exportsFields: ['exports'], + conditionNames: ['import', 'require', 'node', 'default'], + }, + reporterOptions: { + // Context-level architecture graph for `npm run dep:graph` (mermaid — no graphviz needed). + archi: { collapsePattern: '^src/app/[^/]+/[^/]+' }, + }, + }, +}; diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 136cba7..16855c7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,6 +27,8 @@ jobs: cache: npm - run: npm ci --prefer-offline --no-audit --no-fund - run: npm run lint + # Bounded-context + atomic-layer boundaries (WP-38, dependency-cruiser). + - run: npm run dep:check - run: npm run format:check - run: npm run check:tokens - run: npm test diff --git a/docs/project/backlog/README.md b/docs/project/backlog/README.md index bc5cf2c..f7e725d 100644 --- a/docs/project/backlog/README.md +++ b/docs/project/backlog/README.md @@ -82,7 +82,7 @@ for its existing violations, so every WP ends green. | [WP-35](WP-35-one-concept-per-type.md) | One Concept per case type (server-enforced) | 7 · refinements | done | | [WP-36](WP-36-admin-cases.md) | Admin cases page + admin delete | 7 · refinements | done | | [WP-37](WP-37-dev-switcher-reset.md) | Dev-switcher reset fix (scenario/role URL param) | 8 · platform/DX/showcase | done | -| [WP-38](WP-38-dependency-graph-boundaries.md) | Dependency graph + declarative boundaries (visualize + enforce) | 8 · platform/DX/showcase | todo | +| [WP-38](WP-38-dependency-graph-boundaries.md) | Dependency graph + declarative boundaries (visualize + enforce) | 8 · platform/DX/showcase | done | | [WP-39](WP-39-showcase-snippets-animations.md) | Showcase: linked code snippets + teaching animations | 8 · platform/DX/showcase | todo | | [WP-40](WP-40-pii-kernel.md) | PII kernel: branded `Bsn` VO (elfproef) + masked-value atom | 8 · platform/DX/showcase | todo | | [WP-41](WP-41-persisted-authz-audit.md) | Persisted, queryable authz/PII-reveal audit (no PII) | 8 · platform/DX/showcase | todo | diff --git a/docs/project/backlog/WP-38-dependency-graph-boundaries.md b/docs/project/backlog/WP-38-dependency-graph-boundaries.md index eab2487..02f3a01 100644 --- a/docs/project/backlog/WP-38-dependency-graph-boundaries.md +++ b/docs/project/backlog/WP-38-dependency-graph-boundaries.md @@ -1,9 +1,20 @@ # WP-38 — Dependency graph + declarative boundaries -Status: todo +Status: done Phase: 8 — platform/DX/showcase Priority: P1 +## Outcome + +Adopted **dependency-cruiser**. `.dependency-cruiser.js` is the single declarative source for +context + layer boundaries (incl. the previously-missing `herregistratie` scope + no-circular); +`npm run dep:check` enforces (wired into `ci-local.sh` + the `frontend` CI job), `npm run dep:graph` +emits a mermaid context×layer graph to `docs/reference/architecture/dependency-graph.md`. The +per-context `no-restricted-imports` blocks were **removed** from `eslint.config.mjs` (now only +`no-explicit-any` + template a11y remain); parity verified by planting violations (domain→Angular, +beheer→registratie incl. type-only) and confirming `dep:check` flags them. Doc: +`docs/reference/architecture/dependencies.md`; `new-context` skill updated to the single source. + ## Why Bounded-context + atomic-layer boundaries are enforced only by hand-duplicated @@ -32,7 +43,7 @@ showcase`; layers `domain/application/infrastructure/contracts/ui`); **fix the h ## Acceptance criteria -- [ ] One declarative config expresses all allowed context/layer edges; herregistratie included. -- [ ] `npm run graph` produces an architecture graph (SVG/HTML); validate runs in `npm run ci`. -- [ ] A deliberately-illegal import fails the validate step (proven, then reverted). -- [ ] No loss of enforcement vs the old ESLint blocks; `npm run ci` green. +- [x] One declarative config expresses all allowed context/layer edges; herregistratie included. +- [x] `npm run dep:graph` produces a committed mermaid architecture graph; `dep:check` runs in `npm run ci`. +- [x] A deliberately-illegal import fails the validate step (proven, then reverted). +- [x] No loss of enforcement vs the old ESLint blocks; `npm run ci` green. diff --git a/docs/reference/architecture/dependencies.md b/docs/reference/architecture/dependencies.md new file mode 100644 index 0000000..84c3a69 --- /dev/null +++ b/docs/reference/architecture/dependencies.md @@ -0,0 +1,57 @@ +# Dependencies & boundaries + +How the app's **bounded-context** and **atomic-layer** boundaries are declared, enforced, and +visualized (WP-38). One declarative source — `.dependency-cruiser.js` — both **guards** the edges +and **draws** the graph, replacing the per-context `no-restricted-imports` blocks that previously +had to be hand-copied (and that had left `herregistratie` uncovered). + +## The rules (single source: `.dependency-cruiser.js`) + +**Bounded-context direction** — dependencies point inward; everyone may use `shared`, nothing +imports `showcase`: + +| Context | May import | +| ---------------- | ------------------------------------- | +| `shared` | (base — no feature context) | +| `auth` | `shared` | +| `registratie` | `shared` | +| `herregistratie` | `registratie`, `shared` | +| `brief` | `shared` | +| `beheer` | `shared` | +| `showcase` | everything (sanctioned teaching page) | + +**Atomic-layer rules:** `domain/` is framework-free (no Angular); `contracts/` import nothing +(pure wire DTOs, ADR-0001); `ui/` + `layout/` never import `infrastructure/` directly (reach data +through an application store/command — type-only DTO imports are fine); the generated `ApiClient` +is a value only inside `infrastructure/` (+ `shared/upload`). Plus **no circular** dependencies. +Sanctioned exceptions: `shared/ui/debug-state` (dev panel) and `showcase`. + +## See the graph + +```bash +npm run dep:graph # regenerates docs/reference/architecture/dependency-graph.md (mermaid) +``` + +[dependency-graph.md](./dependency-graph.md) is the generated, committed view — contexts × atomic +layers, edges are real imports. It renders on the git host; regenerate + commit after a structural +change. + +## Enforce + +```bash +npm run dep:check # fails on any forbidden edge; part of `npm run ci` and CI +``` + +A violation prints the offending `from → to` and the rule name. `dep:check` runs in the local gate +(`scripts/ci-local.sh`) and the `frontend` CI job. + +## What still lives in ESLint + +Only the non-dependency rules: `@typescript-eslint/no-explicit-any` and the angular-eslint template +accessibility bundle (see `eslint.config.mjs`). Everything about _who may import whom_ is in +dependency-cruiser. + +## Adding a context + +Add one `contextRule(...)` entry in `.dependency-cruiser.js` (and the tsconfig path alias + lazy +route) — no more hand-copying ESLint blocks. The `new-context` skill covers the full checklist. diff --git a/docs/reference/architecture/dependency-graph.md b/docs/reference/architecture/dependency-graph.md new file mode 100644 index 0000000..edcb642 --- /dev/null +++ b/docs/reference/architecture/dependency-graph.md @@ -0,0 +1,187 @@ +# Dependency graph + +_Generated by `npm run dep:graph` — do not edit by hand._ Nodes are context × atomic +layer (`src/app//`); edges are real imports. The allowed edges are +enforced by `npm run dep:check` (dependency-cruiser); see [dependencies.md](./dependencies.md). + +```mermaid +flowchart LR + +subgraph 0["src"] +subgraph 1["app"] +2["app.config.ts"] +3["app.routes.ts"] +4["app.ts"] +subgraph 5["auth"] +6["application"] +7["auth.guard.spec.ts"] +8["auth.guard.ts"] +9["domain"] +A["infrastructure"] +B["ui"] +end +subgraph C["beheer"] +D["application"] +E["contracts"] +F["domain"] +G["infrastructure"] +H["ui"] +end +subgraph I["brief"] +J["application"] +K["domain"] +L["infrastructure"] +M["ui"] +end +subgraph N["herregistratie"] +O["application"] +P["domain"] +Q["infrastructure"] +R["ui"] +end +subgraph S["registratie"] +T["application"] +U["contracts"] +V["domain"] +W["infrastructure"] +X["ui"] +end +subgraph Y["shared"] +Z["application"] +10["domain"] +11["infrastructure"] +12["kernel"] +13["layout"] +14["ui"] +15["upload"] +end +subgraph 16["showcase"] +17["concepts.page.ts"] +end +end +end +2-->3 +2-->6 +2-->Z +2-->11 +2-->13 +3-->17 +3-->8 +3-->B +3-->H +3-->M +3-->R +3-->X +3-->Z +3-->13 +6-->9 +6-->A +6-->12 +7-->6 +7-->8 +7-->Z +8-->6 +8-->Z +8-->10 +A-->9 +A-->12 +B-->6 +B-->13 +B-->14 +D-->F +D-->G +D-->Z +D-->12 +F-->12 +G-->F +G-->Z +G-->11 +G-->12 +H-->D +H-->Z +H-->13 +H-->14 +H-->F +J-->K +J-->L +J-->Z +J-->12 +J-->15 +K-->12 +K-->15 +L-->K +L-->Z +L-->11 +L-->12 +M-->J +M-->13 +M-->14 +M-->K +M-->12 +M-->Z +M-->15 +O-->P +O-->Q +P-->V +P-->12 +P-->15 +Q-->P +Q-->11 +Q-->12 +R-->P +R-->T +R-->Z +R-->12 +R-->13 +R-->14 +R-->15 +R-->O +R-->V +R-->11 +T-->U +T-->V +T-->W +T-->Z +T-->11 +T-->12 +V-->12 +V-->15 +W-->V +W-->11 +W-->12 +W-->U +X-->V +X-->14 +X-->T +X-->13 +X-->Z +X-->12 +X-->U +X-->15 +X-->11 +Z-->12 +Z-->11 +Z-->10 +11-->10 +11-->12 +13-->14 +13-->10 +13-->Z +14-->15 +14-->Z +14-->12 +14-->6 +14-->9 +14-->T +14-->10 +14-->11 +14-->V +15-->12 +15-->Z +15-->11 +17-->R +17-->V +17-->X +17-->13 +17-->14 +``` diff --git a/eslint.config.mjs b/eslint.config.mjs index 5eb5f52..48190c5 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -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' }, - }, ]; diff --git a/package-lock.json b/package-lock.json index ef75c96..74090f7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -36,6 +36,7 @@ "angular-eslint": "^22.0.0", "axe-playwright": "^2.2.2", "concurrently": "^10.0.3", + "dependency-cruiser": "^18.1.0", "eslint": "^10.6.0", "http-server": "^14.1.1", "jsdom": "^29.0.0", @@ -10872,6 +10873,39 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/acorn-jsx-walk": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/acorn-jsx-walk/-/acorn-jsx-walk-2.0.0.tgz", + "integrity": "sha512-uuo6iJj4D4ygkdzd6jPtcxs8vZgDX9YFIkqczGImoypX2fQ4dVImmu3UzA4ynixCIMTrEOWW+95M2HuBaCEOVA==", + "dev": true, + "license": "MIT" + }, + "node_modules/acorn-loose": { + "version": "8.5.2", + "resolved": "https://registry.npmjs.org/acorn-loose/-/acorn-loose-8.5.2.tgz", + "integrity": "sha512-PPvV6g8UGMGgjrMu+n/f9E/tCSkNQ2Y97eFvuVdJfG11+xdIeDcLyNdC8SHcrHbRqkfwLASdplyR6B6sKM1U4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.15.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-walk": { + "version": "8.3.5", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz", + "integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.11.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/adjust-sourcemap-loader": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/adjust-sourcemap-loader/-/adjust-sourcemap-loader-4.0.0.tgz", @@ -13254,6 +13288,77 @@ "node": ">= 0.8" } }, + "node_modules/dependency-cruiser": { + "version": "18.1.0", + "resolved": "https://registry.npmjs.org/dependency-cruiser/-/dependency-cruiser-18.1.0.tgz", + "integrity": "sha512-pbsH0gQ15BxXi97SCuMeVgsh8VzYpziGIVaMdnALbVu+TYjSZrW9/nOvsPU+NjHLmJSF9R1UijjoACq6Ne/ybQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "8.17.0", + "acorn-jsx": "5.3.2", + "acorn-jsx-walk": "2.0.0", + "acorn-loose": "8.5.2", + "acorn-walk": "8.3.5", + "commander": "15.0.0", + "enhanced-resolve": "5.24.2", + "ignore": "7.0.6", + "interpret": "3.1.1", + "is-installed-globally": "1.0.0", + "json5": "2.2.3", + "picomatch": "4.0.5", + "prompts": "2.4.2", + "rechoir": "0.8.0", + "safe-regex": "2.1.1", + "semver": "7.8.5", + "tsconfig-paths-webpack-plugin": "4.2.0", + "watskeburt": "6.0.0" + }, + "bin": { + "depcruise": "bin/dependency-cruise.mjs", + "depcruise-baseline": "bin/depcruise-baseline.mjs", + "depcruise-fmt": "bin/depcruise-fmt.mjs", + "depcruise-wrap-stream-in-html": "bin/wrap-stream-in-html.mjs", + "dependency-cruise": "bin/dependency-cruise.mjs", + "dependency-cruiser": "bin/dependency-cruise.mjs" + }, + "engines": { + "node": "^22||^24||>=26" + } + }, + "node_modules/dependency-cruiser/node_modules/commander": { + "version": "15.0.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-15.0.0.tgz", + "integrity": "sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/dependency-cruiser/node_modules/ignore": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.6.tgz", + "integrity": "sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/dependency-cruiser/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/dequal": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", @@ -13639,9 +13744,9 @@ } }, "node_modules/enhanced-resolve": { - "version": "5.24.1", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.1.tgz", - "integrity": "sha512-7DdUaTjmNwMcH2gLr1qycesKII3BK4RLy/mdAb7x10Lq7bR4aNKHt1BR1ZALSv0rPM/hF5wYF0PhGop/rJm8vw==", + "version": "5.24.2", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.2.tgz", + "integrity": "sha512-rpsZEGT1jFuve6QlpyRp9ckQ+kN61hvF9BzCPyMdaKTm8UJce96KBn3sorXOFXlzjPrs3Vc4T1NsSroZ3PxlFw==", "dev": true, "license": "MIT", "dependencies": { @@ -15299,6 +15404,32 @@ "dev": true, "license": "BSD-2-Clause" }, + "node_modules/global-directory": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/global-directory/-/global-directory-4.0.1.tgz", + "integrity": "sha512-wHTUcDUoZ1H5/0iVqEudYW4/kAlN5cZ3j/bXn0Dpbizl9iaUVeWSHqiOjsgk6OW2bkLclbBjzewBz6weQ1zA2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ini": "4.1.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/global-directory/node_modules/ini": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/ini/-/ini-4.1.1.tgz", + "integrity": "sha512-QQnnxNyfvmHFIsj7gkPcYymR8Jdw/o7mp5ZFihxn6h8Ci6fh3Dx4E1gPjpQEpIuPo9XVNY/ZUwh4BPMjGyL01g==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, "node_modules/global-modules": { "version": "0.2.3", "resolved": "https://registry.npmjs.org/global-modules/-/global-modules-0.2.3.tgz", @@ -16123,6 +16254,16 @@ "node": "^20.17.0 || >=22.9.0" } }, + "node_modules/interpret": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/interpret/-/interpret-3.1.1.tgz", + "integrity": "sha512-6xwYfHbajpoF0xLW+iwLkhwgvLoZDfjYfoFNu8ftMoXINzwuymNLd9u/KmwtdT2GbR+/Cz66otEGEVVUHX9QLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/ip-address": { "version": "10.2.0", "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", @@ -16276,6 +16417,23 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-installed-globally": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-installed-globally/-/is-installed-globally-1.0.0.tgz", + "integrity": "sha512-K55T22lfpQ63N4KEN57jZUAaAYqYHEe8veb/TycJRk9DdSCLLcovXz/mL6mOnhQaZsQGwPhuFopdQIlqGSEjiQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "global-directory": "^4.0.1", + "is-path-inside": "^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-interactive": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-2.0.0.tgz", @@ -16312,6 +16470,19 @@ "node": ">=0.12.0" } }, + "node_modules/is-path-inside": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-4.0.0.tgz", + "integrity": "sha512-lJJV/5dYS+RcL8uQdBDW9c9uWFLLBNRyFhnAKXw5tVqLlKZ4RMGZKv+YQ/IA3OhD+RpbJa1LLFM1FQPGyIXvOA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-plain-obj": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", @@ -22199,6 +22370,19 @@ "node": ">=0.10.0" } }, + "node_modules/rechoir": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rechoir/-/rechoir-0.8.0.tgz", + "integrity": "sha512-/vxpCXddiX8NGfGO/mTafwjq4aFa/71pvamip0++IQk3zG8cbCj0fifNPrjjF1XMXUne91jL9OoxmdykoEtifQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve": "^1.20.0" + }, + "engines": { + "node": ">= 10.13.0" + } + }, "node_modules/redent": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", @@ -22247,6 +22431,16 @@ "dev": true, "license": "MIT" }, + "node_modules/regexp-tree": { + "version": "0.1.27", + "resolved": "https://registry.npmjs.org/regexp-tree/-/regexp-tree-0.1.27.tgz", + "integrity": "sha512-iETxpjK6YoRWJG5o6hXLwvjYAoW+FEZn9os0PD/b6AP6xQwsa/Y7lCVgIixBbUPMfhu+i2LtdeAqVTgGlQarfA==", + "dev": true, + "license": "MIT", + "bin": { + "regexp-tree": "bin/regexp-tree" + } + }, "node_modules/regexpu-core": { "version": "6.4.0", "resolved": "https://registry.npmjs.org/regexpu-core/-/regexpu-core-6.4.0.tgz", @@ -22878,6 +23072,16 @@ "dev": true, "license": "MIT" }, + "node_modules/safe-regex": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/safe-regex/-/safe-regex-2.1.1.tgz", + "integrity": "sha512-rx+x8AMzKb5Q5lQ95Zoi6ZbJqwCLkqi3XuJXp5P3rT8OEc6sZCJG5AE5dU3lsgRr/F4Bs31jSlVN+j5KrsGu9A==", + "dev": true, + "license": "MIT", + "dependencies": { + "regexp-tree": "~0.1.1" + } + }, "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", @@ -25802,6 +26006,19 @@ "node": ">=10.13.0" } }, + "node_modules/watskeburt": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/watskeburt/-/watskeburt-6.0.0.tgz", + "integrity": "sha512-jfiuDABaxSkC71T6oZ3vCS99roYkSHm/+As+G0Dz8taAHQb+SJBvLEm5RlsgG71XdfAj3rv7eudUBTgwcQUPlQ==", + "dev": true, + "license": "MIT", + "bin": { + "watskeburt": "dist/run-cli.js" + }, + "engines": { + "node": "^22.13||^24||>=26" + } + }, "node_modules/wbuf": { "version": "1.7.3", "resolved": "https://registry.npmjs.org/wbuf/-/wbuf-1.7.3.tgz", diff --git a/package.json b/package.json index f115e1e..ef54059 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,8 @@ "test-storybook": "test-storybook", "test-storybook:ci": "concurrently -k -s first -n sb,axe \"http-server storybook-static -p 6006 --silent\" \"wait-on tcp:127.0.0.1:6006 && test-storybook --url http://127.0.0.1:6006\"", "check:tokens": "bash scripts/check-tokens.sh", + "dep:check": "depcruise src/app --config .dependency-cruiser.js", + "dep:graph": "bash scripts/dep-graph.sh", "ci": "bash scripts/ci-local.sh", "e2e": "playwright test", "extract-i18n": "ng extract-i18n --output-path src/locale" @@ -51,6 +53,7 @@ "angular-eslint": "^22.0.0", "axe-playwright": "^2.2.2", "concurrently": "^10.0.3", + "dependency-cruiser": "^18.1.0", "eslint": "^10.6.0", "http-server": "^14.1.1", "jsdom": "^29.0.0", diff --git a/scripts/ci-local.sh b/scripts/ci-local.sh index b93d09f..62f6035 100755 --- a/scripts/ci-local.sh +++ b/scripts/ci-local.sh @@ -12,6 +12,7 @@ cd "$(dirname "$0")/.." step() { printf '\n\033[1;36m▶ %s\033[0m\n' "$1"; } step "lint"; npm run lint +step "dependency boundaries"; npm run dep:check step "format:check (prettier)"; npm run format:check step "check:tokens"; npm run check:tokens step "test (vitest)"; npm test diff --git a/scripts/dep-graph.sh b/scripts/dep-graph.sh new file mode 100644 index 0000000..2843d14 --- /dev/null +++ b/scripts/dep-graph.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +# Regenerate the architecture dependency graph (WP-38) as a mermaid diagram embedded in a +# markdown file, so it renders on the git host and can be linked from the docs. Contexts × +# atomic layers; edges are real imports. Boundaries are enforced separately by `npm run +# dep:check`. Run this after a structural change; commit the result. +set -euo pipefail +cd "$(dirname "$0")/.." + +OUT=docs/reference/architecture/dependency-graph.md + +{ + echo "# Dependency graph" + echo + echo "_Generated by \`npm run dep:graph\` — do not edit by hand._ Nodes are context × atomic" + echo "layer (\`src/app//\`); edges are real imports. The allowed edges are" + echo "enforced by \`npm run dep:check\` (dependency-cruiser); see [dependencies.md](./dependencies.md)." + echo + echo '```mermaid' + npx depcruise src/app \ + --config .dependency-cruiser.js \ + --include-only "^src/app/" \ + --collapse "^src/app/[^/]+/[^/]+" \ + --output-type mermaid + echo '```' +} >"$OUT" + +echo "wrote $OUT"