Files
atomic-design-poc/docs/project/refactor-backlog-setup/refactor-backlog/tools/baseline-scan.mjs
T
ehoandClaude Opus 5 664a43bf2d docs: refactoring-backlog workspace — baseline + 3 Phase 1 agents
Runs the multi-agent refactoring-backlog pipeline in docs/project/
refactor-backlog-setup/ up to and including three of the seven Phase 1
agents.

00-baseline.md establishes the metrics every later agent must cite, using
only tooling already in the repo (vitest lcov, coverlet cobertura, ESLint's
core `complexity` rule at threshold 0 for a full distribution, depcruise
--metrics). Duplication and C# complexity had no tooling, so
tools/baseline-scan.mjs adds a deterministic ~200-line text scan rather
than a new dependency; the approximations are labelled as such.

Headline: FE 75.1% line coverage but only over the 98 of 220 source files a
spec loads; BE 97.6% line / 79.6% branch; 0 layering violations; 7.1%
duplication; 25 of 2085 TS functions over CC 10.

Then 02-testability, 04-cqrs-light and 06-adr-conformance (27 findings).
01/03/05 were skipped deliberately — the baseline shows little for them to
find; 07 (BIO2) and 08 (consolidation) are still open.

Each agent corrected a baseline observation of mine, and in every case the
error was in something derived rather than measured:

- BL-007 counted ~13 adapter "mutations" from the `runSubmit` helper name;
  5 of those call sites are reads. It also missed 3 real mutations that
  reach the raw ApiClient and never return a Result.
- BL-002 diagnosed the 100%-duplicated auth folders as ADR-0002's
  divergence prediction failing. It never had a chance to fail: §3's
  `Principal` union was never built.
- BL-004 named libs/shared/domain and libs/beheer/contracts as coverage
  gaps; both are pure type declarations where 0% is unimprovable.

All three corrections are recorded inline in 00-baseline.md §10, so agent
08 does not inherit the bad numbers.

.prettierignore excludes the agent prompt directories — reflowing their
markdown would edit the prompt text itself.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 16:44:32 +02:00

256 lines
9.9 KiB
JavaScript
Raw 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
// Baseline scanner for 00-baseline.md: duplication % and C# cyclomatic complexity.
// Both are the same crude text scan, so they share one file.
//
// ponytail: line-window hashing, not token-based like jscpd, and regex method
// detection that does not understand C# expression-bodied members or nested
// lambdas. Deterministic and zero-install, which is what a *baseline* needs.
// Upgrade path: jscpd for duplication, a Roslyn analyzer for C# complexity —
// only if a ticket's before/after needs more precision than "did it move".
//
// Usage: node baseline-scan.mjs [--dup] [--complexity] (default: both)
import { readFileSync, readdirSync, statSync } from 'node:fs';
import { createHash } from 'node:crypto';
import { join, relative } from 'node:path';
const ROOT = new URL('../../../../..', import.meta.url).pathname.replace(/\/$/, '');
const SKIP_DIR =
/(^|\/)(node_modules|dist|coverage|bin|obj|\.angular|\.git|storybook-static.*|TestResults|public|openzaak)$/;
const SKIP_FILE = /(api-client\.ts|\.Designer\.cs|AppDbContextModelSnapshot\.cs)$/;
const SCAN = ['apps', 'libs', 'backend/src', 'backend/tests', 'e2e'];
const WINDOW = 6; // duplicate = >= 6 consecutive normalized lines seen elsewhere
function walk(dir, out = []) {
for (const e of readdirSync(dir)) {
const p = join(dir, e);
if (SKIP_DIR.test(p)) continue;
if (statSync(p).isDirectory()) walk(p, out);
else if (/\.(ts|cs)$/.test(p) && !SKIP_FILE.test(p)) out.push(p);
}
return out;
}
// Module attribution: the unit the baseline table reports on.
function moduleOf(rel) {
let m;
if ((m = rel.match(/^apps\/(ssp|behandelportal)\/src\/app\/([^/]+)\//)))
return `${m[1] === 'ssp' ? 'ssp' : 'bhp'}/${m[2]}`;
if (rel.startsWith('apps/ssp/')) return 'ssp/root';
if (rel.startsWith('apps/behandelportal/')) return 'bhp/root';
if ((m = rel.match(/^libs\/(shared|beheer)\/src\/([^/]+)\//))) return `libs/${m[1]}/${m[2]}`;
if (rel.startsWith('libs/')) return rel.split('/').slice(0, 2).join('/');
if (rel === 'backend/src/BigRegister.Api/Program.cs') return 'backend/Program.cs';
if ((m = rel.match(/^backend\/src\/BigRegister\.Api\/([^/]+)\//))) return `backend/${m[1]}`;
if (rel.startsWith('backend/tests/')) return 'backend/tests';
if (rel.startsWith('e2e/')) return 'e2e';
return 'other';
}
const files = SCAN.flatMap((d) => {
try {
return walk(join(ROOT, d));
} catch {
return [];
}
}).map((p) => ({ abs: p, rel: relative(ROOT, p) }));
// --- Duplication ------------------------------------------------------------
// Normalize away formatting/comments, hash every WINDOW-line run, and mark a run
// duplicated when its hash occurs in more than one place.
function duplication() {
const norm = new Map(); // rel -> [{line, text}]
for (const f of files) {
const kept = [];
readFileSync(f.abs, 'utf8')
.split('\n')
.forEach((raw, i) => {
const t = raw
.replace(/\/\/.*$/, '')
.replace(/\s+/g, ' ')
.trim();
if (t.length > 3) kept.push({ line: i + 1, text: t });
});
norm.set(f.rel, kept);
}
const seen = new Map(); // hash -> [{rel, idx}]
for (const [rel, lines] of norm)
for (let i = 0; i + WINDOW <= lines.length; i++) {
const h = createHash('sha1')
.update(
lines
.slice(i, i + WINDOW)
.map((l) => l.text)
.join('\n'),
)
.digest('hex');
(seen.get(h) ?? seen.set(h, []).get(h)).push({ rel, idx: i });
}
const dupLines = new Map(); // rel -> Set(line)
const clones = new Map(); // "relA|relB" -> count
for (const occ of seen.values()) {
if (occ.length < 2) continue;
for (const { rel, idx } of occ) {
const set = dupLines.get(rel) ?? dupLines.set(rel, new Set()).get(rel);
for (let k = 0; k < WINDOW; k++) set.add(norm.get(rel)[idx + k].line);
}
const pair = [...new Set(occ.map((o) => o.rel))].sort();
if (pair.length > 1) {
const key = pair.slice(0, 2).join(' | ');
clones.set(key, (clones.get(key) ?? 0) + 1);
}
}
const per = new Map(); // module -> {total, dup}
for (const [rel, lines] of norm) {
const mod = moduleOf(rel);
const e = per.get(mod) ?? per.set(mod, { total: 0, dup: 0 }).get(mod);
e.total += lines.length;
e.dup += dupLines.get(rel)?.size ?? 0;
}
console.log('## Duplication (normalized lines, window=' + WINDOW + ')\n');
console.log('| Module | Sig. lines | Duplicated | % |');
console.log('|---|---:|---:|---:|');
let T = 0,
D = 0;
for (const [mod, e] of [...per].sort((a, b) => b[1].dup / b[1].total - a[1].dup / a[1].total)) {
T += e.total;
D += e.dup;
console.log(`| ${mod} | ${e.total} | ${e.dup} | ${((100 * e.dup) / e.total).toFixed(1)}% |`);
}
console.log(`| **TOTAL** | **${T}** | **${D}** | **${((100 * D) / T).toFixed(1)}%** |`);
console.log('\n### Top clone pairs (distinct duplicated windows)\n');
for (const [pair, n] of [...clones].sort((a, b) => b[1] - a[1]).slice(0, 15))
console.log(`- ${n} ×${pair}`);
}
// --- C# cyclomatic complexity (approximate) ---------------------------------
// Two numbers per file. FILE CC (sum of decision points) is exact enough to trust.
// Per-METHOD CC uses depth-aware regex detection and is the approximate one.
const BRANCH =
/\bif\s*\(|\bwhile\s*\(|\bfor\s*\(|\bforeach\s*\(|\bcase\s+|\bcatch\s*[({]|\?\?|&&|\|\||\?\.|\bwhen\s+/g;
const TYPE_DECL =
/^\s*(?:\[[^\]]*\]\s*)*(?:public|private|internal|protected|static|sealed|abstract|partial|file|\s)*\b(?:class|record|struct|interface|enum|namespace)\b/;
const NOT_A_CALL =
/^(if|for|foreach|while|switch|catch|using|lock|return|throw|new|await|yield|else|do|fixed|checked)$/;
// A member: optional attrs/modifiers, a return type, a name, then `(`.
const SIG =
/^\s*(?:\[[^\]]*\]\s*)*(?:(?:public|private|internal|protected|static|async|override|virtual|sealed|partial|extern|new|unsafe)\s+)*[\w<>,\[\]?.]+\s+(\w+)\s*(?:<[^>()]*>)?\s*\(/;
function branchesIn(line) {
return (line.replace(/\/\/.*$/, '').match(BRANCH) ?? []).length;
}
function csComplexity() {
const rows = [];
const fileCc = [];
for (const f of files.filter((f) => f.rel.endsWith('.cs'))) {
const lines = readFileSync(f.abs, 'utf8').split('\n');
let depth = 0,
cur = null,
total = 1,
code = 0;
for (let i = 0; i < lines.length; i++) {
const l = lines[i];
const bare = l.replace(/\/\/.*$/, '');
const b = branchesIn(l);
total += b;
if (bare.trim().length > 1 && !/^\s*(\/\/|\/\*|\*)/.test(l)) code++;
const opens = (bare.match(/{/g) ?? []).length;
const closes = (bare.match(/}/g) ?? []).length;
if (cur) {
cur.lines++;
cur.cc += b;
// Expression-bodied member: `=> expr;` with no block of its own.
if (cur.depth === null && /=>/.test(bare)) {
if (/;\s*$/.test(bare) && opens === closes) {
rows.push(cur);
cur = null;
} else if (opens > closes) cur.depth = depth;
} else if (cur.depth === null && opens > closes) cur.depth = depth;
else if (cur.depth !== null && depth + opens - closes <= cur.depth) {
rows.push(cur);
cur = null;
}
} else if (!TYPE_DECL.test(l)) {
const m = SIG.exec(bare);
// `Name(` must not be a call/keyword, and the line must not be a statement.
if (
m &&
!NOT_A_CALL.test(m[1]) &&
!/^\s*(var|return|await)\b/.test(bare) &&
!/;\s*$/.test(bare.replace(/=>.*/, ''))
)
cur = {
file: f.rel,
name: m[1],
line: i + 1,
cc: 1 + b,
lines: 1,
depth: opens > closes ? depth : null,
};
}
depth += opens - closes;
}
if (cur) rows.push(cur);
fileCc.push({ file: f.rel, cc: total, code });
}
const p = (arr, q) =>
arr.slice().sort((a, b) => a - b)[Math.min(arr.length - 1, Math.floor(q * arr.length))] ?? 0;
const perFile = new Map();
for (const r of fileCc) {
const mod = moduleOf(r.file);
(perFile.get(mod) ?? perFile.set(mod, []).get(mod)).push(r);
}
console.log('\n\n## C# complexity — per module\n');
console.log(
'| Module | Files | Σ file CC | max file CC | Methods | max method CC | p90 method CC | CC>10 |',
);
console.log('|---|---:|---:|---:|---:|---:|---:|---:|');
const perMethod = new Map();
for (const r of rows) {
const mod = moduleOf(r.file);
(perMethod.get(mod) ?? perMethod.set(mod, []).get(mod)).push(r);
}
for (const [mod, fs] of [...perFile].sort(
(a, b) => Math.max(...b[1].map((r) => r.cc)) - Math.max(...a[1].map((r) => r.cc)),
)) {
const ms = perMethod.get(mod) ?? [];
const ccs = ms.map((r) => r.cc);
console.log(
`| ${mod} | ${fs.length} | ${fs.reduce((a, r) => a + r.cc, 0)} | ${Math.max(...fs.map((r) => r.cc))} | ` +
`${ms.length} | ${ccs.length ? Math.max(...ccs) : 0} | ${p(ccs, 0.9)} | ${ms.filter((r) => r.cc > 10).length} |`,
);
}
console.log('\n### C# files by CC (top 12)\n');
console.log('| File CC | Code lines | File |');
console.log('|---:|---:|---|');
for (const r of fileCc.sort((a, b) => b.cc - a.cc).slice(0, 12))
console.log(`| ${r.cc} | ${r.code} | ${r.file} |`);
console.log('\n### C# methods over CC 10 (approximate detection)\n');
console.log('| CC | Lines | Method | Location |');
console.log('|---:|---:|---|---|');
for (const r of rows.filter((r) => r.cc > 10).sort((a, b) => b.cc - a.cc))
console.log(`| ${r.cc} | ${r.lines} | \`${r.name}\` | ${r.file}:${r.line} |`);
const all = rows.map((r) => r.lines);
console.log(
`\nMethod-length distribution (n=${rows.length}): p50 ${p(all, 0.5)}, p90 ${p(all, 0.9)}, p99 ${p(all, 0.99)}, max ${Math.max(...all)}`,
);
}
const args = process.argv.slice(2);
const all = args.length === 0;
if (all || args.includes('--dup')) duplication();
if (all || args.includes('--complexity')) csComplexity();