#!/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();