docs(test): generated living behaviour spec + FE/BE seam drift check (WP-71)
Gherkin/Cucumber was considered and rejected for business-readable BDD scenarios: step-binding by runtime string match undoes the compile-time guarantees WP-70 just added, and needs two frameworks for .NET+TS with no non-technical co-author in view. Instead scripts/gen-behaviour-spec.mjs (modeled on the existing gen-snippets.mjs) extracts every describe/it and [Fact]/[Theory] name straight from the real suites into libs/shared/docs/behaviour-spec.mdx, gated for drift in CI exactly like gen-snippets/gen-api — the page can never diverge from the tests because it's generated from them, and test names stay the single source of truth. scripts/check-seam.sh guards the one FE/BE rule duplication most likely to silently diverge: IntakePolicy.cs's ScholingThreshold vs intake.machine.ts's SCHOLING_THRESHOLD_DEFAULT, two unlinked literals pinned separately in each side's own tests but never against each other. package.json/CI wiring for both (gen:behaviour-spec, check:seam) shipped in the prior commit alongside the typecheck gate, since all three touch the same few config files. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Executable
+33
@@ -0,0 +1,33 @@
|
||||
#!/usr/bin/env bash
|
||||
# WP-71 (Track E): fail if the backend's scholing-threshold policy default and the frontend's
|
||||
# offline fallback default drift apart. ADR-0001's "config value" shape means the backend is
|
||||
# the authority (GET /intake/policy) and the FE only keeps SCHOLING_THRESHOLD_DEFAULT as an
|
||||
# offline/first-paint fallback (intake.machine.ts) — but the two literals are otherwise
|
||||
# unlinked, so nothing stops them silently diverging. This is a cheap grep-based tripwire, not
|
||||
# a build-time link between the two languages.
|
||||
set -uo pipefail
|
||||
|
||||
BACKEND_FILE='backend/src/BigRegister.Api/Domain/Intake/IntakePolicy.cs'
|
||||
FRONTEND_FILE='apps/ssp/src/app/herregistratie/domain/intake.machine.ts'
|
||||
|
||||
backend_value=$(grep -oE 'ScholingThreshold\s*=\s*[0-9]+' "$BACKEND_FILE" | grep -oE '[0-9]+$')
|
||||
frontend_value=$(grep -oE 'SCHOLING_THRESHOLD_DEFAULT\s*=\s*[0-9]+' "$FRONTEND_FILE" | grep -oE '[0-9]+$')
|
||||
|
||||
if [ -z "$backend_value" ]; then
|
||||
echo "FAIL: could not find IntakePolicy.ScholingThreshold in $BACKEND_FILE"
|
||||
exit 1
|
||||
fi
|
||||
if [ -z "$frontend_value" ]; then
|
||||
echo "FAIL: could not find SCHOLING_THRESHOLD_DEFAULT in $FRONTEND_FILE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ "$backend_value" != "$frontend_value" ]; then
|
||||
echo "FAIL: FE/BE seam drift on the scholing threshold default"
|
||||
echo " $BACKEND_FILE: ScholingThreshold = $backend_value"
|
||||
echo " $FRONTEND_FILE: SCHOLING_THRESHOLD_DEFAULT = $frontend_value"
|
||||
echo 'Both literals represent the same intake policy default (ADR-0001 config value) and must match.'
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "OK: scholing threshold default matches on both sides ($backend_value)"
|
||||
@@ -0,0 +1,218 @@
|
||||
#!/usr/bin/env node
|
||||
// Generate a business-readable "behaviour spec" page FROM real test names (WP-71, Track D).
|
||||
// The team considered Cucumber/Gherkin for BDD scenarios and rejected it (runtime string
|
||||
// matching undoes the compile-time guarantees WP-70 just bought, and needs two frameworks for
|
||||
// .NET+TS). Instead: test names ARE the spec — this script only extracts and formats them, so
|
||||
// the page can never drift from the suite. Mirrors the gen-snippets.mjs pattern (pure Node,
|
||||
// reads real source files, writes ONE generated file, checked for drift in CI the same way).
|
||||
// Run: `npm run gen:behaviour-spec`.
|
||||
import { readFileSync, writeFileSync, readdirSync, statSync } from 'node:fs';
|
||||
import { join, relative, sep } from 'node:path';
|
||||
|
||||
const EXCLUDED_DIRS = new Set(['node_modules', 'dist', 'coverage', 'bin', 'obj', '.git']);
|
||||
|
||||
/** Recursively collect files under `dir` matching `pattern`, skipping excluded directories. */
|
||||
function walk(dir, pattern) {
|
||||
const out = [];
|
||||
for (const entry of readdirSync(dir)) {
|
||||
if (EXCLUDED_DIRS.has(entry)) continue;
|
||||
const full = join(dir, entry);
|
||||
const st = statSync(full);
|
||||
if (st.isDirectory()) out.push(...walk(full, pattern));
|
||||
else if (pattern.test(entry)) out.push(full);
|
||||
}
|
||||
return out.sort();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Frontend: apps/**/*.spec.ts + libs/**/*.spec.ts — describe()/it() pairs.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const QUOTED = `(?:'([^']*)'|"([^"]*)"|` + '`([^`]*)`)';
|
||||
const DESCRIBE_RE = new RegExp(`\\bdescribe(?:\\.\\w+)?\\(\\s*${QUOTED}`);
|
||||
const IT_RE = new RegExp(`\\bit(?:\\.\\w+)?\\(\\s*${QUOTED}`);
|
||||
|
||||
/** Which app/context folder a spec file belongs to, for grouping (registratie, brief, …). */
|
||||
function feContextFor(path) {
|
||||
const norm = path.split(sep).join('/');
|
||||
const appMatch = norm.match(/^apps\/(?:ssp|behandelportal)\/src\/app\/([^/]+)\//);
|
||||
if (appMatch) return appMatch[1];
|
||||
const libMatch = norm.match(/^libs\/([^/]+)\/src\//);
|
||||
if (libMatch) return libMatch[1];
|
||||
return 'other';
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract { describePath: string[], text: string } for every `it(...)` in a spec file, using
|
||||
* a brace-depth stack to track nested `describe(...)` blocks (a line-scan, not a TS parser —
|
||||
* this repo's spec files are one describe/it call per line, same precedent as gen-snippets.mjs).
|
||||
*/
|
||||
function extractSpecBehaviours(source) {
|
||||
const lines = source.split('\n');
|
||||
let depth = 0;
|
||||
const stack = []; // { name, depth }
|
||||
const results = [];
|
||||
for (const line of lines) {
|
||||
if (/^\s*\/\//.test(line)) continue; // skip commented-out lines
|
||||
const dm = line.match(DESCRIBE_RE);
|
||||
const im = !dm && line.match(IT_RE);
|
||||
if (dm) {
|
||||
stack.push({ name: dm[1] ?? dm[2] ?? dm[3], depth });
|
||||
} else if (im) {
|
||||
results.push({ describePath: stack.map((s) => s.name), text: im[1] ?? im[2] ?? im[3] });
|
||||
}
|
||||
const open = (line.match(/{/g) || []).length;
|
||||
const close = (line.match(/}/g) || []).length;
|
||||
depth += open - close;
|
||||
while (stack.length && depth <= stack[stack.length - 1].depth) stack.pop();
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
const feSpecFiles = [...walk('apps', /\.spec\.ts$/), ...walk('libs', /\.spec\.ts$/)];
|
||||
|
||||
/** @type {Map<string, Map<string, string[]>>} context -> describe-block label -> it() texts */
|
||||
const feBehaviour = new Map();
|
||||
for (const file of feSpecFiles) {
|
||||
const context = feContextFor(file);
|
||||
const relPath = relative('.', file).split(sep).join('/');
|
||||
const behaviours = extractSpecBehaviours(readFileSync(file, 'utf8'));
|
||||
for (const { describePath, text } of behaviours) {
|
||||
const label = describePath.length ? describePath.join(' › ') : `(${relPath})`;
|
||||
if (!feBehaviour.has(context)) feBehaviour.set(context, new Map());
|
||||
const byLabel = feBehaviour.get(context);
|
||||
if (!byLabel.has(label)) byLabel.set(label, []);
|
||||
byLabel.get(label).push(text);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Backend: backend/tests/BigRegister.Tests/**/*.cs — [Fact]/[Theory] methods.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const CLASS_RE = /^\s*(?:public|internal)\s+(?:sealed\s+)?class\s+(\w+)/;
|
||||
const FACT_OR_THEORY_RE = /^\s*\[(?:Fact|Theory)\b/;
|
||||
const METHOD_RE = /\b(?:void|Task(?:<[^>]*>)?)\s+(\w+)\s*\(/;
|
||||
|
||||
/** PascalCase_snake_sentence method name -> readable sentence (just spaces for underscores). */
|
||||
function toSentence(methodName) {
|
||||
return methodName.replace(/_/g, ' ');
|
||||
}
|
||||
|
||||
/** Extract { className, sentence } for every [Fact]/[Theory]-attributed method in a .cs file. */
|
||||
function extractCsBehaviours(source) {
|
||||
const lines = source.split('\n');
|
||||
let currentClass = null;
|
||||
const results = [];
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const cm = lines[i].match(CLASS_RE);
|
||||
if (cm) {
|
||||
currentClass = cm[1];
|
||||
continue;
|
||||
}
|
||||
if (!FACT_OR_THEORY_RE.test(lines[i])) continue;
|
||||
// Skip any further attribute lines (e.g. [InlineData(...)] rows on a [Theory]) and blank
|
||||
// lines to reach the method declaration itself.
|
||||
let j = i + 1;
|
||||
while (j < lines.length && (/^\s*\[/.test(lines[j]) || /^\s*$/.test(lines[j]))) j++;
|
||||
const mm = lines[j] && lines[j].match(METHOD_RE);
|
||||
if (mm && currentClass) results.push({ className: currentClass, sentence: toSentence(mm[1]) });
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
const csFiles = walk('backend/tests/BigRegister.Tests', /\.cs$/);
|
||||
|
||||
/** @type {Map<string, string[]>} class name -> sentences */
|
||||
const beBehaviour = new Map();
|
||||
for (const file of csFiles) {
|
||||
for (const { className, sentence } of extractCsBehaviours(readFileSync(file, 'utf8'))) {
|
||||
if (!beBehaviour.has(className)) beBehaviour.set(className, []);
|
||||
beBehaviour.get(className).push(sentence);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Emit libs/shared/docs/behaviour-spec.mdx
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// MDX parses markdown as JSX-in-Markdown: a bare `<tag>`/`{expr}` in test-name text (e.g.
|
||||
// "renders each field group as its own grey <fieldset>") would otherwise be read as JSX and
|
||||
// fail the build. Test names are data, not markup — escape them before embedding.
|
||||
function mdxEscape(text) {
|
||||
return text
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/\{/g, '{')
|
||||
.replace(/\}/g, '}');
|
||||
}
|
||||
|
||||
function renderFeSection(context) {
|
||||
const byLabel = feBehaviour.get(context);
|
||||
const labels = [...byLabel.keys()].sort();
|
||||
const blocks = labels.map((label) => {
|
||||
const items = byLabel
|
||||
.get(label)
|
||||
.map((t) => `- ${mdxEscape(t)}`)
|
||||
.join('\n');
|
||||
return `#### ${mdxEscape(label)}\n\n${items}`;
|
||||
});
|
||||
return `### ${mdxEscape(context)}\n\n${blocks.join('\n\n')}`;
|
||||
}
|
||||
|
||||
function renderBeSection(className) {
|
||||
const items = beBehaviour
|
||||
.get(className)
|
||||
.map((t) => `- ${mdxEscape(t)}`)
|
||||
.join('\n');
|
||||
return `### ${mdxEscape(className)}\n\n${items}`;
|
||||
}
|
||||
|
||||
const feContexts = [...feBehaviour.keys()].sort();
|
||||
const feCount = feContexts.reduce((n, c) => n + [...feBehaviour.get(c).values()].flat().length, 0);
|
||||
const beClasses = [...beBehaviour.keys()].sort();
|
||||
const beCount = beClasses.reduce((n, c) => n + beBehaviour.get(c).length, 0);
|
||||
|
||||
const feSections = feContexts.map(renderFeSection).join('\n\n');
|
||||
const beSections = beClasses.map(renderBeSection).join('\n\n');
|
||||
|
||||
const mdx = `{/* GENERATED by \`npm run gen:behaviour-spec\` (scripts/gen-behaviour-spec.mjs) — do not
|
||||
edit. Every bullet below is a real \`it()\` title or backend test method name, extracted
|
||||
verbatim from the suite. The team rejected Cucumber/Gherkin for BDD scenarios (runtime string
|
||||
matching undoes the compile-time guarantees WP-70 bought, and needs two frameworks for
|
||||
.NET+TS) — this page is the replacement: business-readable documentation generated FROM test
|
||||
names, so it can never drift from what the suite actually asserts. A test name changing (or a
|
||||
test being added/removed) is the only way this page changes; hand-editing it is pointless,
|
||||
the next \`npm run gen:behaviour-spec\` overwrites it. */}
|
||||
|
||||
import { Meta } from '@storybook/addon-docs/blocks';
|
||||
|
||||
<Meta title="Foundations/Behaviour spec" />
|
||||
|
||||
# Behaviour spec
|
||||
|
||||
_Generated by \`npm run gen:behaviour-spec\` — do not hand-edit; the next generation
|
||||
overwrites this page. See [BDD](?path=/docs/foundations-bdd--docs) for how these names are
|
||||
written, and [Testing strategy](?path=/docs/foundations-testing-strategy--docs) for what gets
|
||||
tested where._
|
||||
|
||||
Every bullet below is a real test name from the suite — an \`it()\` title (frontend) or a test
|
||||
method name (backend), read as a sentence. Nothing here is hand-written prose: this page
|
||||
**is** the suite, reshaped for a business reader. ${feCount} frontend behaviours across
|
||||
${feContexts.length} contexts; ${beCount} backend behaviours across ${beClasses.length} test
|
||||
classes.
|
||||
|
||||
## Frontend (by context)
|
||||
|
||||
${feSections}
|
||||
|
||||
## Backend (by test class)
|
||||
|
||||
${beSections}
|
||||
`;
|
||||
|
||||
writeFileSync('libs/shared/docs/behaviour-spec.mdx', mdx);
|
||||
console.log(
|
||||
`wrote libs/shared/docs/behaviour-spec.mdx (${feCount} frontend behaviours in ${feContexts.length} contexts, ${beCount} backend behaviours in ${beClasses.length} classes)`,
|
||||
);
|
||||
Reference in New Issue
Block a user