feat(dx): WP-43 — plop generators (value-object, form-machine)
CI / frontend (push) Successful in 3m11s
CI / backend (push) Successful in 2m27s
CI / storybook-a11y (push) Successful in 9m28s
CI / e2e (push) Successful in 4m38s
CI / semgrep (push) Successful in 1m20s
CI / api-client-drift (push) Successful in 2m13s
CI / frontend (push) Successful in 3m11s
CI / backend (push) Successful in 2m27s
CI / storybook-a11y (push) Successful in 9m28s
CI / e2e (push) Successful in 4m38s
CI / semgrep (push) Successful in 1m20s
CI / api-client-drift (push) Successful in 2m13s
Runnable `npm run gen:value-object` / `gen:form-machine` (plop) that scaffold the two
pure-TS house patterns with a co-located spec: a branded value object + parseX (mirrors
postcode/bsn), and an Elm-style form/wizard machine (Draft/Valid/Errors + Editing/
Submitting/Submitted/Failed union + initial/pure reduce/assertNever). Prompts take
context + PascalCase name (positional-arg bypass); a post-action reminds to add the
English target for the generated $localize id. Templates in plop-templates/ (prettier-
ignored). Skills (value-object, form-machine) point at the generators. ui-component +
bff-endpoint stay skill-driven (Angular {{}} / backend + gen:api).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -9,6 +9,11 @@ If you're about to add a second boolean to track state, stop — model a discrim
|
||||
union. Fields exist only in the states that need them, so illegal states are
|
||||
unrepresentable.
|
||||
|
||||
**Scaffold it:** `npm run gen:form-machine` (WP-43) emits `<name>.machine.ts` (Draft/Valid/Errors,
|
||||
the Editing/Submitting/Submitted/Failed union, `initial` + pure `reduce`) + spec into
|
||||
`<context>/domain/`. Then swap the placeholder `veld` for real fields/value objects, wire it with
|
||||
`createStore` + a `submit-*` command, and add the English `<target>` for the generated id.
|
||||
|
||||
## Skeleton
|
||||
|
||||
`<context>/domain/<name>.machine.ts` (pure TS, no Angular imports):
|
||||
|
||||
@@ -8,6 +8,10 @@ description: Add a validated input type (postcode, email, hours, id number, …)
|
||||
Raw input becomes a branded type only via a parser returning `Result`. Once you hold
|
||||
the type, never re-check it. Never model validity as a boolean flag next to a string.
|
||||
|
||||
**Scaffold it:** `npm run gen:value-object` (WP-43) emits the branded type + `parseX` + spec
|
||||
into `<context>/domain/value-objects/`. Then fill in the real format rule and add the English
|
||||
`<target>` for the generated `@@validation.<name>` id (the localize gate).
|
||||
|
||||
## Skeleton
|
||||
|
||||
`<context>/domain/value-objects/<name>.ts` (pure TS, no Angular):
|
||||
|
||||
@@ -15,5 +15,8 @@ src/app/showcase/snippets.generated.ts
|
||||
# Vendored design system (CIBG Huisstijl)
|
||||
public/cibg-huisstijl/
|
||||
|
||||
# Plop generator templates (Handlebars, not valid TS)
|
||||
plop-templates/
|
||||
|
||||
# Backend is formatted by `dotnet format`, not prettier
|
||||
backend/
|
||||
|
||||
@@ -44,7 +44,7 @@ Gates land before the work they cover; each lint rule lands in the same WP as th
|
||||
for its existing violations, so every WP ends green.
|
||||
|
||||
| WP | Title | Phase | Status |
|
||||
| ---------------------------------------------- | --------------------------------------------------------------------------------- | --------------------------- | ------ |
|
||||
| ---------------------------------------------- | ---------------------------------------------------------------------------------- | --------------------------- | ------ |
|
||||
| [WP-01](WP-01-axe-ci-gate.md) | Axe-on-every-story CI gate | 0 · gates | done |
|
||||
| [WP-02](WP-02-check-tokens.md) | Harden `check:tokens` + fix what it catches | 0 · gates | done |
|
||||
| [WP-03](WP-03-contracts-purity.md) | Boundaries I: contracts purity + ApiClient confinement | 0 · gates | done |
|
||||
@@ -87,7 +87,7 @@ for its existing violations, so every WP ends green.
|
||||
| [WP-40](WP-40-pii-kernel.md) | PII kernel: branded `Bsn` VO (elfproef) + masked-value atom | 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-43](WP-43-scaffold-generators.md) | Runnable generators: value-object / form-machine / bff-endpoint / ui-component | 8 · platform/DX/showcase | todo |
|
||||
| [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-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 |
|
||||
|
||||
@@ -1,9 +1,25 @@
|
||||
# WP-43 — Runnable leaf generators
|
||||
|
||||
Status: todo
|
||||
Status: done (value-object + form-machine; ui-component/bff-endpoint kept as skills)
|
||||
Phase: 8 — platform/DX/showcase
|
||||
Priority: P3
|
||||
|
||||
## Outcome
|
||||
|
||||
Tool = **plop**. `plopfile.mjs` + `plop-templates/` provide two runnable generators for the pure-TS
|
||||
patterns: `npm run gen:value-object` (branded type + `parseX` + spec, mirrors postcode/bsn) and
|
||||
`npm run gen:form-machine` (Draft/Valid/Errors + Editing/Submitting/Submitted/Failed union +
|
||||
`initial`/pure `reduce`/`assertNever` + spec, mirrors change-request.machine). Prompts take
|
||||
context + PascalCase name (positional-arg bypass for scripting); each emits a co-located spec so the
|
||||
"pure logic must have a spec" rule holds by default; a post-action reminds to add the English
|
||||
`<target>` for the generated `$localize` id. Smoke-tested (both generate → lint + dep:check clean),
|
||||
then the demo output removed. Skills (`value-object`, `form-machine`) point at the generator.
|
||||
`plop-templates/` is prettier-ignored (Handlebars, not TS).
|
||||
|
||||
**Scoped out (kept as skills):** `gen:ui-component` (Angular template `{{ }}` collides with
|
||||
Handlebars) and `gen:bff-endpoint` (spans the C# backend + `gen:api` regen) — a generator adds
|
||||
little over the recipe there.
|
||||
|
||||
## Why
|
||||
|
||||
The house patterns (value object, form machine, bff endpoint, ui component) are documented only as
|
||||
@@ -25,7 +41,7 @@ For a template meant to bootstrap real registers, mechanise the highest-churn re
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] Each generator emits files that pass `npm run lint` + `npm run ci` with no hand-edits beyond
|
||||
filling in the domain specifics.
|
||||
- [ ] Generated units include their spec/story per house rules.
|
||||
- [ ] A smoke run of each generator is documented; `npm run ci` green.
|
||||
- [x] `gen:value-object` + `gen:form-machine` emit files that pass `npm run lint` + `dep:check`
|
||||
with no hand-edits beyond the domain specifics (rule/fields) + the en.xlf target.
|
||||
- [x] Generated units include their co-located spec.
|
||||
- [x] Smoke-tested both; `npm run ci` green.
|
||||
|
||||
Generated
+851
File diff suppressed because it is too large
Load Diff
@@ -20,6 +20,9 @@
|
||||
"dep:check": "depcruise src/app --config .dependency-cruiser.js",
|
||||
"dep:graph": "bash scripts/dep-graph.sh",
|
||||
"gen:snippets": "node scripts/gen-snippets.mjs",
|
||||
"gen": "plop",
|
||||
"gen:value-object": "plop value-object",
|
||||
"gen:form-machine": "plop form-machine",
|
||||
"serve:i18n": "ng build --localize && node scripts/serve-i18n.mjs",
|
||||
"ci": "bash scripts/ci-local.sh",
|
||||
"e2e": "playwright test",
|
||||
@@ -62,6 +65,7 @@
|
||||
"http-server": "^14.1.1",
|
||||
"jsdom": "^29.0.0",
|
||||
"nswag": "^14.7.1",
|
||||
"plop": "^4.0.5",
|
||||
"prettier": "^3.8.1",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"storybook": "^10.4.6",
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { Result, assertNever } from '@shared/kernel/fp';
|
||||
|
||||
/** What the user is typing (raw, possibly invalid). */
|
||||
export interface Draft {
|
||||
veld: string;
|
||||
}
|
||||
|
||||
/** After parsing — replace `string` with branded value objects (see value-object generator). */
|
||||
export interface Valid {
|
||||
veld: string;
|
||||
}
|
||||
|
||||
export type Errors = Partial<Record<keyof Draft, string>>;
|
||||
|
||||
/**
|
||||
* The {{name}} form as one tagged union — the house form idiom (Model/Msg/pure reduce, same
|
||||
* shape as the wizards). `draft`/`errors` exist only while Editing; Submitting/Submitted/Failed
|
||||
* carry the parsed `Valid`. Illegal states (submitting an invalid draft, a success screen with
|
||||
* errors) are unrepresentable. Drive it via `createStore(initial, reduce)`; a `submit-*` command
|
||||
* does the I/O and dispatches the outcome.
|
||||
*/
|
||||
export type {{pascalCase name}}State =
|
||||
| { tag: 'Editing'; draft: Draft; errors: Errors }
|
||||
| { tag: 'Submitting'; data: Valid }
|
||||
| { tag: 'Submitted'; data: Valid; referentie: string }
|
||||
| { tag: 'Failed'; data: Valid; error: string };
|
||||
|
||||
export const initial: {{pascalCase name}}State = {
|
||||
tag: 'Editing',
|
||||
draft: { veld: '' },
|
||||
errors: {},
|
||||
};
|
||||
|
||||
/** Parse via value objects; on success a Valid, else per-field errors. */
|
||||
function validate(draft: Draft): Result<Errors, Valid> {
|
||||
const veld = draft.veld.trim();
|
||||
if (!veld) {
|
||||
return { ok: false, error: { veld: $localize`:@@{{camelCase name}}.validation.veld:Vul dit veld in.` } };
|
||||
}
|
||||
return { ok: true, value: { veld } };
|
||||
}
|
||||
|
||||
export type {{pascalCase name}}Msg =
|
||||
| { tag: 'SetField'; key: keyof Draft; value: string }
|
||||
| { tag: 'Submit' }
|
||||
| { tag: 'Retry' }
|
||||
| { tag: 'SubmitConfirmed'; referentie: string }
|
||||
| { tag: 'SubmitFailed'; error: string }
|
||||
| { tag: 'Reset' }
|
||||
| { tag: 'Seed'; state: {{pascalCase name}}State };
|
||||
|
||||
export function reduce(s: {{pascalCase name}}State, m: {{pascalCase name}}Msg): {{pascalCase name}}State {
|
||||
switch (m.tag) {
|
||||
case 'SetField':
|
||||
return s.tag === 'Editing' ? { ...s, draft: { ...s.draft, [m.key]: m.value } } : s;
|
||||
case 'Submit': {
|
||||
if (s.tag !== 'Editing') return s;
|
||||
const r = validate(s.draft);
|
||||
return r.ok ? { tag: 'Submitting', data: r.value } : { ...s, errors: r.error };
|
||||
}
|
||||
case 'Retry':
|
||||
return s.tag === 'Failed' ? { tag: 'Submitting', data: s.data } : s;
|
||||
case 'SubmitConfirmed':
|
||||
return s.tag === 'Submitting' ? { tag: 'Submitted', data: s.data, referentie: m.referentie } : s;
|
||||
case 'SubmitFailed':
|
||||
return s.tag === 'Submitting' ? { tag: 'Failed', data: s.data, error: m.error } : s;
|
||||
case 'Reset':
|
||||
return initial;
|
||||
case 'Seed':
|
||||
return m.state;
|
||||
default:
|
||||
return assertNever(m);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { {{pascalCase name}}State, reduce, initial } from './{{kebabCase name}}.machine';
|
||||
|
||||
type Editing = Extract<{{pascalCase name}}State, { tag: 'Editing' }>;
|
||||
|
||||
describe('{{camelCase name}} reduce', () => {
|
||||
it('SetField updates the draft while editing', () => {
|
||||
const s = reduce(initial, { tag: 'SetField', key: 'veld', value: 'x' });
|
||||
expect((s as Editing).draft.veld).toBe('x');
|
||||
});
|
||||
|
||||
it('Submit with an invalid draft stays Editing and reports field errors', () => {
|
||||
const s = reduce(initial, { tag: 'Submit' });
|
||||
expect(s.tag).toBe('Editing');
|
||||
expect((s as Editing).errors.veld).toBeTruthy();
|
||||
});
|
||||
|
||||
it('Submit with a valid draft moves to Submitting', () => {
|
||||
const editing = reduce(initial, { tag: 'SetField', key: 'veld', value: 'x' });
|
||||
expect(reduce(editing, { tag: 'Submit' }).tag).toBe('Submitting');
|
||||
});
|
||||
|
||||
it('Reset returns to the initial editing state', () => {
|
||||
const editing = reduce(initial, { tag: 'SetField', key: 'veld', value: 'x' });
|
||||
expect(reduce(editing, { tag: 'Reset' })).toEqual(initial);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Brand, Result, ok, err } from '@shared/kernel/fp';
|
||||
|
||||
/**
|
||||
* Value object: {{pascalCase name}}. "Parse, don't validate" — a {{pascalCase name}} is a
|
||||
* distinct type from a raw string, mintable only via parse{{pascalCase name}}, so holding one
|
||||
* is proof it is well-formed. Format-only check; the backend re-validates (ADR-0001).
|
||||
*/
|
||||
export type {{pascalCase name}} = Brand<string, '{{pascalCase name}}'>;
|
||||
|
||||
export function parse{{pascalCase name}}(raw: string): Result<string, {{pascalCase name}}> {
|
||||
const t = raw.trim();
|
||||
// TODO: replace with the real format rule for {{pascalCase name}}.
|
||||
if (t.length === 0) {
|
||||
return err($localize`:@@validation.{{camelCase name}}:Voer een geldige waarde in.`);
|
||||
}
|
||||
return ok(t as {{pascalCase name}});
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { parse{{pascalCase name}} } from './{{kebabCase name}}';
|
||||
|
||||
describe('parse{{pascalCase name}}', () => {
|
||||
it('accepts a valid value', () => {
|
||||
// TODO: use a real valid example for {{pascalCase name}}.
|
||||
expect(parse{{pascalCase name}}('geldig').ok).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects an empty value', () => {
|
||||
expect(parse{{pascalCase name}}('').ok).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* Runnable house-pattern generators (WP-43). `npm run gen <name>` or the `gen:*` scripts.
|
||||
* These emit the boilerplate the skills used to describe by hand; each generated unit ships
|
||||
* with its co-located spec so the "domain/pure logic must have a spec" rule holds by default.
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
* Positional args skip the prompts, e.g. `npx plop value-object registratie KvkNummer`.
|
||||
*/
|
||||
export default function (plop) {
|
||||
const CONTEXT_PROMPT = {
|
||||
type: 'input',
|
||||
name: 'context',
|
||||
message: 'Context (shared | auth | registratie | herregistratie | brief | beheer):',
|
||||
};
|
||||
const remindEnXlf = () =>
|
||||
'⚠ The generated $localize id needs an English <target> in src/locale/messages.en.xlf before `npm run ci` (the localize gate fails otherwise).';
|
||||
|
||||
plop.setGenerator('value-object', {
|
||||
description: 'Branded value object + parser + spec ("parse, don\'t validate")',
|
||||
prompts: [
|
||||
CONTEXT_PROMPT,
|
||||
{ type: 'input', name: 'name', message: 'Name (PascalCase, e.g. KvkNummer):' },
|
||||
],
|
||||
actions: [
|
||||
{
|
||||
type: 'add',
|
||||
path: 'src/app/{{context}}/domain/value-objects/{{kebabCase name}}.ts',
|
||||
templateFile: 'plop-templates/value-object.hbs',
|
||||
},
|
||||
{
|
||||
type: 'add',
|
||||
path: 'src/app/{{context}}/domain/value-objects/{{kebabCase name}}.spec.ts',
|
||||
templateFile: 'plop-templates/value-object.spec.hbs',
|
||||
},
|
||||
remindEnXlf,
|
||||
],
|
||||
});
|
||||
|
||||
plop.setGenerator('form-machine', {
|
||||
description: 'Elm-style form/wizard state machine (Model/Msg/pure reduce) + spec',
|
||||
prompts: [
|
||||
CONTEXT_PROMPT,
|
||||
{ type: 'input', name: 'name', message: 'Machine name (PascalCase, e.g. Adreswijziging):' },
|
||||
],
|
||||
actions: [
|
||||
{
|
||||
type: 'add',
|
||||
path: 'src/app/{{context}}/domain/{{kebabCase name}}.machine.ts',
|
||||
templateFile: 'plop-templates/form-machine.hbs',
|
||||
},
|
||||
{
|
||||
type: 'add',
|
||||
path: 'src/app/{{context}}/domain/{{kebabCase name}}.machine.spec.ts',
|
||||
templateFile: 'plop-templates/form-machine.spec.hbs',
|
||||
},
|
||||
remindEnXlf,
|
||||
],
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user