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:
@@ -14,26 +14,21 @@ shared/reusable code is English. The context name is the ubiquitous language ter
|
||||
|
||||
## Steps
|
||||
|
||||
1. **Folders** — `src/app/<ctx>/{domain,application,infrastructure,ui}` (`contracts/`
|
||||
only once it gets a wire seam). Empty layers can wait; don't scaffold placeholders.
|
||||
2. **Path alias** — add `"@<ctx>/*": ["src/app/<ctx>/*"]` 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: '<ctx>', canActivate: [authGuard],
|
||||
loadComponent: () => import('@<ctx>/ui/<ctx>.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/<ctx>/{domain,application,infrastructure,contracts}/.gitkeep` +
|
||||
a starter `ui/<ctx>.page.ts` (replace with the real first feature).
|
||||
- Path alias — `"@<ctx>/*": ["src/app/<ctx>/*"]` 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
|
||||
|
||||
|
||||
+31
-19
@@ -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`.
|
||||
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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/<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
|
||||
|
||||
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 <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.
|
||||
- [ ] 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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 {}
|
||||
+74
-1
@@ -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.',
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user