diff --git a/.claude/skills/new-context/SKILL.md b/.claude/skills/new-context/SKILL.md index be94888..3dd5960 100644 --- a/.claude/skills/new-context/SKILL.md +++ b/.claude/skills/new-context/SKILL.md @@ -14,26 +14,21 @@ shared/reusable code is English. The context name is the ubiquitous language ter ## Steps -1. **Folders** — `src/app//{domain,application,infrastructure,ui}` (`contracts/` - 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. **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 - { path: '', canActivate: [authGuard], - loadComponent: () => import('@/ui/.page').then(m => m.CtxPage) } - ``` - -5. Build the first feature slice with the **new-feature** skill. +1. Run `npm run gen:context` (WP-44) and give it the lowercase context name. It + mechanises the manual edits below in one shot: + - Folders — `src/app//{domain,application,infrastructure,contracts}/.gitkeep` + + a starter `ui/.page.ts` (replace with the real first feature). + - Path alias — `"@/*": ["src/app//*"]` added to `tsconfig.json` `paths`. + - Boundary entry — one new key in `.dependency-cruiser.js`'s `CONTEXT_ALLOWED` map + (WP-38's single declarative source; every context's forbidden list is _derived_ + from that map, so adding one key is enough — nothing else to hand-edit). List the + OTHER contexts the new one may import (usually `[]` — a leaf, like `brief`). + - Route — a lazy child under the persistent shell in `app.routes.ts`, gated by + `authGuard`. +2. Verify: `npm run dep:check && npm run lint && npm run build` (regenerate the graph + 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 + generated placeholder page. ## Worked example diff --git a/.dependency-cruiser.js b/.dependency-cruiser.js index 48e76b6..dcca754 100644 --- a/.dependency-cruiser.js +++ b/.dependency-cruiser.js @@ -8,16 +8,37 @@ // Allowed cross-context edges: everyone → shared; herregistratie → registratie; 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 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})/` }, -}); +const FEATURES = Object.keys(CONTEXT_ALLOWED).join('|'); + +/** A context may import shared + itself + its allowed list; forbidden = every other context. */ +const contextRule = (from) => { + const allowed = CONTEXT_ALLOWED[from]; + if (allowed === null) return null; // unrestricted (showcase) — no rule to generate + 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 = { forbidden: [ @@ -29,16 +50,7 @@ module.exports = { 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'), + ...Object.keys(CONTEXT_ALLOWED).map(contextRule).filter(Boolean), // showcase/ is exempt (reads every context by design); nothing imports it — covered by the // rules above each forbidding `→ showcase`. diff --git a/docs/project/backlog/README.md b/docs/project/backlog/README.md index c8b5098..2f57f4a 100644 --- a/docs/project/backlog/README.md +++ b/docs/project/backlog/README.md @@ -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-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-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-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 | diff --git a/docs/project/backlog/WP-44-context-generator.md b/docs/project/backlog/WP-44-context-generator.md index e0c882a..c51226f 100644 --- a/docs/project/backlog/WP-44-context-generator.md +++ b/docs/project/backlog/WP-44-context-generator.md @@ -1,10 +1,32 @@ # WP-44 — `gen:context` generator -Status: todo +Status: done Phase: 8 — platform/DX/showcase Priority: P3 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//{domain,application,infrastructure,contracts}/.gitkeep` + a starter +`ui/.page.ts` (a `PageShellComponent` wrapper — replace with the real first feature slice); +the `@/*` 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 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 -- [ ] `npm run gen:context ` produces a context that lints clean (boundaries recognised) and +- [x] `npm run gen:context ` produces a context that lints clean (boundaries recognised) and routes lazily. -- [ ] Boundary tool (WP-38) validates the new context's allowed edges. -- [ ] `npm run ci` green. +- [x] Boundary tool (WP-38) validates the new context's allowed edges. +- [x] `npm run ci` green. diff --git a/package.json b/package.json index 1a9386d..44ce425 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "gen": "plop", "gen:value-object": "plop value-object", "gen:form-machine": "plop form-machine", + "gen:context": "plop context", "serve:i18n": "ng build --configuration development --localize && node scripts/serve-i18n.mjs", "ci": "bash scripts/ci-local.sh", "e2e": "playwright test", diff --git a/plop-templates/context.page.hbs b/plop-templates/context.page.hbs new file mode 100644 index 0000000..eae47b1 --- /dev/null +++ b/plop-templates/context.page.hbs @@ -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: ` + +

Scaffolded by gen:context — build the first feature here.

+
+ `, +}) +export class {{pascalCase name}}Page {} diff --git a/plop-templates/gitkeep.hbs b/plop-templates/gitkeep.hbs new file mode 100644 index 0000000..e69de29 diff --git a/plopfile.mjs b/plopfile.mjs index d1692e7..97bcd2d 100644 --- a/plopfile.mjs +++ b/plopfile.mjs @@ -6,7 +6,8 @@ * 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 — * 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`. */ @@ -60,4 +61,76 @@ export default function (plop) { 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.', + ], + }); }