Files
ehoandClaude Sonnet 5 dd11eafe50 refactor: strip WP-/RB- ticket refs from apps and libs (RD-18)
204 WP-NN/RB-NN comments named a closed ticket instead of the code they
sit next to. git blame already records history and stays correct when
code moves; the comment does not. This sweep removes the reference and
keeps the sentence, across 95 files in apps/ and libs/ plus the
behaviour-spec generator's header text.

Eleven references stay: five story files justify an a11y disable per
the README's rule, and one line in a11y.mdx documents that convention.
Two sentences needed a rewrite, not a deletion, so the reference's
meaning survives its removal. behaviour-spec.mdx is regenerated, not
hand-edited.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 21:23:07 +02:00

219 lines
9.1 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/\{/g, '&#123;')
.replace(/\}/g, '&#125;');
}
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 the TypeScript compiler 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)`,
);