feat(dx): gen:context generator (WP-44)

npm run gen:context scaffolds a bounded context: folders + starter page, the @<ctx>/*
tsconfig alias, a dependency-cruiser boundary entry, and a lazy authGuard route.

Refactors .dependency-cruiser.js's per-context contextRule calls into a single
CONTEXT_ALLOWED map that every rule derives from, so adding a context is really one
config entry (verified behavior-preserving: same dep:check counts, same graph output).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
eho
2026-07-27 14:18:43 +02:00
co-authored by Claude Sonnet 5
parent 7ef8ac7409
commit 7b6cabfc4a
8 changed files with 165 additions and 45 deletions
+15 -20
View File
@@ -14,26 +14,21 @@ shared/reusable code is English. The context name is the ubiquitous language ter
## Steps ## Steps
1. **Folders**`src/app/<ctx>/{domain,application,infrastructure,ui}` (`contracts/` 1. Run `npm run gen:context` (WP-44) and give it the lowercase context name. It
only once it gets a wire seam). Empty layers can wait; don't scaffold placeholders. mechanises the manual edits below in one shot:
2. **Path alias** — add `"@<ctx>/*": ["src/app/<ctx>/*"]` to `tsconfig.json` `paths`. - Folders — `src/app/<ctx>/{domain,application,infrastructure,contracts}/.gitkeep` +
Aliases are direction statements; always import cross-context via the alias. a starter `ui/<ctx>.page.ts` (replace with the real first feature).
3. **Boundaries** (`.dependency-cruiser.js`, WP-38 — the single declarative source; - Path alias — `"@<ctx>/*": ["src/app/<ctx>/*"]` added to `tsconfig.json` `paths`.
dependencies point inward and toward `shared` only). Add ONE `contextRule(...)` entry - Boundary entry — one new key in `.dependency-cruiser.js`'s `CONTEXT_ALLOWED` map
for the new context listing the contexts it may **not** import (copy the `brief` leaf (WP-38's single declarative source; every context's forbidden list is _derived_
example), and add the new context to the forbidden list of any context that must not from that map, so adding one key is enough — nothing else to hand-edit). List the
depend on it. The layer rules (`domain/` framework-free, `contracts/` import-nothing, OTHER contexts the new one may import (usually `[]` — a leaf, like `brief`).
ApiClient confinement, `ui ↛ infrastructure`) match by glob and cover it automatically. - Route — a lazy child under the persistent shell in `app.routes.ts`, gated by
Verify with `npm run dep:check`; regenerate the graph with `npm run dep:graph`. (Boundaries `authGuard`.
are no longer in `eslint.config.mjs` — that now holds only `no-explicit-any` + template a11y.) 2. Verify: `npm run dep:check && npm run lint && npm run build` (regenerate the graph
4. **Route** — lazy child under the persistent shell in `app.routes.ts`: with `npm run dep:graph` if you want the committed diagram to reflect it too).
3. Build the first feature slice with the **new-feature** skill — replace the
```ts generated placeholder page.
{ path: '<ctx>', canActivate: [authGuard],
loadComponent: () => import('@<ctx>/ui/<ctx>.page').then(m => m.CtxPage) }
```
5. Build the first feature slice with the **new-feature** skill.
## Worked example ## Worked example
+31 -19
View File
@@ -8,16 +8,37 @@
// Allowed cross-context edges: everyone → shared; herregistratie → registratie; showcase → * // Allowed cross-context edges: everyone → shared; herregistratie → registratie; showcase → *
// (the sanctioned teaching page). Nobody imports showcase. // (the sanctioned teaching page). Nobody imports showcase.
const FEATURES = 'auth|registratie|herregistratie|brief|beheer|showcase'; // Single source of truth for bounded-context boundaries: each entry maps a context name to the
// OTHER contexts it may additionally import (besides itself + shared). `showcase` maps to `null`
// — sanctioned to import every context (the teaching page); nothing else may import it. Add a
// context here — nowhere else — when scaffolding one (see `gen:context`, WP-44); FEATURES and
// every contextRule below are derived from this object.
const CONTEXT_ALLOWED = {
auth: [],
registratie: [],
herregistratie: ['registratie'], // the one sanctioned cross-feature edge
brief: [],
beheer: [],
showcase: null,
};
/** A context may import shared + itself; this lists the OTHER contexts it may NOT import. */ const FEATURES = Object.keys(CONTEXT_ALLOWED).join('|');
const contextRule = (name, from, forbiddenContexts) => ({
name, /** A context may import shared + itself + its allowed list; forbidden = every other context. */
comment: `${from} may depend only on its allowed contexts (+ shared). See CLAUDE.md §1.`, const contextRule = (from) => {
severity: 'error', const allowed = CONTEXT_ALLOWED[from];
from: { path: `^src/app/${from}/` }, if (allowed === null) return null; // unrestricted (showcase) — no rule to generate
to: { path: `^src/app/(${forbiddenContexts})/` }, const forbidden = Object.keys(CONTEXT_ALLOWED)
}); .filter((name) => name !== from && !allowed.includes(name))
.join('|');
return {
name: `${from}-scope`,
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/(${forbidden})/` },
};
};
module.exports = { module.exports = {
forbidden: [ forbidden: [
@@ -29,16 +50,7 @@ module.exports = {
from: { path: '^src/app/shared/', pathNot: '^src/app/shared/ui/debug-state/' }, from: { path: '^src/app/shared/', pathNot: '^src/app/shared/ui/debug-state/' },
to: { path: `^src/app/(${FEATURES})/` }, to: { path: `^src/app/(${FEATURES})/` },
}, },
contextRule('auth-only-shared', 'auth', 'registratie|herregistratie|brief|beheer|showcase'), ...Object.keys(CONTEXT_ALLOWED).map(contextRule).filter(Boolean),
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 // showcase/ is exempt (reads every context by design); nothing imports it — covered by the
// rules above each forbidding `→ showcase`. // rules above each forbidding `→ showcase`.
+1 -1
View File
@@ -93,7 +93,7 @@ for its existing violations, so every WP ends green.
| [WP-41](WP-41-persisted-authz-audit.md) | Persisted, queryable authz/PII-reveal audit (no PII) | 8 · platform/DX/showcase | done | | [WP-41](WP-41-persisted-authz-audit.md) | Persisted, queryable authz/PII-reveal audit (no PII) | 8 · platform/DX/showcase | done |
| [WP-42](WP-42-privacy-security-showcase.md) | Privacy & security showcase page (mask + no-PII log) | 8 · platform/DX/showcase | done | | [WP-42](WP-42-privacy-security-showcase.md) | Privacy & security showcase page (mask + no-PII log) | 8 · platform/DX/showcase | done |
| [WP-43](WP-43-scaffold-generators.md) | Runnable generators: value-object / form-machine (plop; ui-component/bff = skills) | 8 · platform/DX/showcase | done | | [WP-43](WP-43-scaffold-generators.md) | Runnable generators: value-object / form-machine (plop; ui-component/bff = skills) | 8 · platform/DX/showcase | done |
| [WP-44](WP-44-context-generator.md) | Runnable generator: `gen:context` | 8 · platform/DX/showcase | todo | | [WP-44](WP-44-context-generator.md) | Runnable generator: `gen:context` | 8 · platform/DX/showcase | done |
| [WP-45](WP-45-create-ssp-generator.md) | `create-ssp` bootstrap generator (mechanise new-ssp) | 8 · platform/DX/showcase | todo | | [WP-45](WP-45-create-ssp-generator.md) | `create-ssp` bootstrap generator (mechanise new-ssp) | 8 · platform/DX/showcase | todo |
| [WP-46](WP-46-vitest-coverage.md) | Vitest coverage (report + report-only thresholds) | 8 · platform/DX/showcase | done | | [WP-46](WP-46-vitest-coverage.md) | Vitest coverage (report + report-only thresholds) | 8 · platform/DX/showcase | done |
| [WP-47](WP-47-feature-flags.md) | Runtime feature flags (catalog-in-code, admin toggle, FE+backend) | 8 · platform/DX/showcase | done | | [WP-47](WP-47-feature-flags.md) | Runtime feature flags (catalog-in-code, admin toggle, FE+backend) | 8 · platform/DX/showcase | done |
@@ -1,10 +1,32 @@
# WP-44 — `gen:context` generator # WP-44 — `gen:context` generator
Status: todo Status: done
Phase: 8 — platform/DX/showcase Phase: 8 — platform/DX/showcase
Priority: P3 Priority: P3
Depends on: WP-38, WP-43 Depends on: WP-38, WP-43
## Outcome
`npm run gen:context` (plop, extends WP-43's `plopfile.mjs`) prompts for a lowercase context name
and emits: `src/app/<ctx>/{domain,application,infrastructure,contracts}/.gitkeep` + a starter
`ui/<ctx>.page.ts` (a `PageShellComponent` wrapper — replace with the real first feature slice);
the `@<ctx>/*` tsconfig alias; one new key in `.dependency-cruiser.js`'s `CONTEXT_ALLOWED` map; and
a lazy, `authGuard`-gated route in `app.routes.ts` inserted before the catch-all.
**Refactored `.dependency-cruiser.js` to make "one config entry" literally true.** The pre-WP file
hand-duplicated each context's forbidden-imports list as a separate `contextRule(name, from,
forbidden)` call — adding a context meant editing N existing calls to add it to their forbidden
list, not adding one entry. Replaced with a single `CONTEXT_ALLOWED` map (context → contexts it may
additionally import) that every rule + the `FEATURES` string is _derived_ from; `showcase` maps to
`null` (unrestricted — the one exempt case) and is skipped when generating rules. Verified
behavior-preserving: `npm run dep:check` reports the same module/dependency counts before and after,
`npm run dep:graph`'s committed output is byte-identical, and a planted cross-context violation
(`auth` importing `@herregistratie`, type-only) is still caught under the new `auth-scope` rule name.
Smoke-tested by generating a real `vergunning` context end-to-end (`dep:check`, `lint`, `build` all
green, including the new lazy chunk), then removed the demo output. `.claude/skills/new-context/
SKILL.md` now points at the generator as step 1.
## Why ## Why
Adding a bounded context is currently a manual multi-file edit (folders + tsconfig alias + copied Adding a bounded context is currently a manual multi-file edit (folders + tsconfig alias + copied
@@ -26,7 +48,7 @@ ESLint boundary block + lazy route) — the `new-context` skill's most error-pro
## Acceptance criteria ## Acceptance criteria
- [ ] `npm run gen:context <name>` produces a context that lints clean (boundaries recognised) and - [x] `npm run gen:context <name>` produces a context that lints clean (boundaries recognised) and
routes lazily. routes lazily.
- [ ] Boundary tool (WP-38) validates the new context's allowed edges. - [x] Boundary tool (WP-38) validates the new context's allowed edges.
- [ ] `npm run ci` green. - [x] `npm run ci` green.
+1
View File
@@ -23,6 +23,7 @@
"gen": "plop", "gen": "plop",
"gen:value-object": "plop value-object", "gen:value-object": "plop value-object",
"gen:form-machine": "plop form-machine", "gen:form-machine": "plop form-machine",
"gen:context": "plop context",
"serve:i18n": "ng build --configuration development --localize && node scripts/serve-i18n.mjs", "serve:i18n": "ng build --configuration development --localize && node scripts/serve-i18n.mjs",
"ci": "bash scripts/ci-local.sh", "ci": "bash scripts/ci-local.sh",
"e2e": "playwright test", "e2e": "playwright test",
+17
View File
@@ -0,0 +1,17 @@
import { Component } from '@angular/core';
import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component';
/**
* Scaffolded by `gen:context` (WP-44) — replace with the `{{dashCase name}}` context's first
* feature slice (the `new-feature` skill: domain first, then infrastructure/application, UI last).
*/
@Component({
selector: 'app-{{dashCase name}}-page',
imports: [PageShellComponent],
template: `
<app-page-shell heading="{{titleCase name}}">
<p>Scaffolded by gen:context — build the first feature here.</p>
</app-page-shell>
`,
})
export class {{pascalCase name}}Page {}
View File
+74 -1
View File
@@ -6,7 +6,8 @@
* Generators here are the pure-TS patterns (no Angular-template escaping): value objects and * Generators here are the pure-TS patterns (no Angular-template escaping): value objects and
* form/wizard state machines. The UI-component and bff-endpoint patterns stay skill-driven — * form/wizard state machines. The UI-component and bff-endpoint patterns stay skill-driven —
* they span the template `{{ }}` syntax / the C# backend + `gen:api` regen, where a generator * they span the template `{{ }}` syntax / the C# backend + `gen:api` regen, where a generator
* adds little over the recipe. * adds little over the recipe. `context` (WP-44) mechanises the `new-context` skill's manual
* multi-file edit: folders + tsconfig alias + boundary entry + lazy route.
* *
* Positional args skip the prompts, e.g. `npx plop value-object registratie KvkNummer`. * Positional args skip the prompts, e.g. `npx plop value-object registratie KvkNummer`.
*/ */
@@ -60,4 +61,76 @@ export default function (plop) {
remindEnXlf, remindEnXlf,
], ],
}); });
plop.setGenerator('context', {
description:
'Scaffold a bounded context: folders + tsconfig alias + boundary entry + lazy route',
prompts: [
{
type: 'input',
name: 'name',
message: 'Context name (lowercase, single Dutch ubiquitous term, e.g. vergunning):',
},
],
actions: [
{
type: 'add',
path: 'src/app/{{kebabCase name}}/ui/{{kebabCase name}}.page.ts',
templateFile: 'plop-templates/context.page.hbs',
},
{
type: 'add',
path: 'src/app/{{kebabCase name}}/domain/.gitkeep',
templateFile: 'plop-templates/gitkeep.hbs',
},
{
type: 'add',
path: 'src/app/{{kebabCase name}}/application/.gitkeep',
templateFile: 'plop-templates/gitkeep.hbs',
},
{
type: 'add',
path: 'src/app/{{kebabCase name}}/infrastructure/.gitkeep',
templateFile: 'plop-templates/gitkeep.hbs',
},
{
type: 'add',
path: 'src/app/{{kebabCase name}}/contracts/.gitkeep',
templateFile: 'plop-templates/gitkeep.hbs',
},
{
// tsconfig path alias — inserted right after the `"paths": {` line so it's stable
// across repeated runs regardless of what's already been added.
type: 'modify',
path: 'tsconfig.json',
pattern: /"paths": \{\n/,
template: '"paths": {\n "@{{kebabCase name}}/*": ["src/app/{{kebabCase name}}/*"],\n',
},
{
// Boundary entry (WP-38's single source of truth) — inserted right before the
// `showcase: null` line, which never moves.
type: 'modify',
path: '.dependency-cruiser.js',
pattern: /(\s*)showcase: null,/,
template: "$1'{{kebabCase name}}': [],$1showcase: null,",
},
{
// Lazy route — inserted right before the catch-all, which never moves.
type: 'modify',
path: 'src/app/app.routes.ts',
pattern: " { path: '**', redirectTo: 'login' },",
template: ` {
path: '{{kebabCase name}}',
canActivate: [authGuard],
loadComponent: () =>
import('@{{kebabCase name}}/ui/{{kebabCase name}}.page').then(
(m) => m.{{pascalCase name}}Page,
),
},
{ path: '**', redirectTo: 'login' },`,
},
() =>
'Next: npm run dep:check && npm run lint && npm run build to verify, then build the first feature with the new-feature skill.',
],
});
} }