chore(audit): module complexity reports + workbench map

Adds four audit scripts (module health, inter-module coupling, per-function
cognitive complexity, D3 treemap) with generated reports under docs/ and
an iframe-embedded workbench app at /admin/complexity. Reports regenerate
weekly via the module-health GitHub Action.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-14 19:47:42 +02:00
parent b857063120
commit 7c1c6cd54c
12 changed files with 1453 additions and 0 deletions

View file

@ -0,0 +1,175 @@
#!/usr/bin/env node
// Lightweight per-function complexity audit. No deps.
// Heuristic: counts decision points (if / else if / for / while / switch case / catch / ternary / && / ||) per function body.
// Not as rigorous as SonarJS cognitive complexity, but finds the same outliers.
// Output: docs/complexity-hotspots.md — top 50 functions.
import { readdirSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
import { join, relative, extname } from 'node:path';
const ROOT = new URL('..', import.meta.url).pathname.replace(/\/$/, '');
const SCAN_ROOTS = ['apps/mana/apps/web/src', 'apps/api/src', 'services', 'packages'];
const CODE_EXT = new Set(['.ts', '.tsx', '.js', '.mjs', '.svelte']);
const IGNORE = new Set(['node_modules', '.svelte-kit', 'dist', 'build', 'coverage', '.turbo']);
function walk(dir) {
const out = [];
let entries;
try {
entries = readdirSync(dir, { withFileTypes: true });
} catch {
return out;
}
for (const e of entries) {
if (IGNORE.has(e.name)) continue;
const p = join(dir, e.name);
if (e.isDirectory()) out.push(...walk(p));
else if (e.isFile() && CODE_EXT.has(extname(e.name))) out.push(p);
}
return out;
}
// Strip /* */ and // comments and string contents to avoid false matches.
function sanitize(src) {
return src
.replace(/\/\*[\s\S]*?\*\//g, '')
.replace(/\/\/[^\n]*/g, '')
.replace(/`[^`\\]*(?:\\.[^`\\]*)*`/g, '``')
.replace(/'[^'\\\n]*(?:\\.[^'\\\n]*)*'/g, "''")
.replace(/"[^"\\\n]*(?:\\.[^"\\\n]*)*"/g, '""');
}
// For .svelte: extract <script> block(s) for function scanning, but also scan whole file for inline event handlers.
function extractJS(path, src) {
if (extname(path) !== '.svelte') return src;
const blocks = [];
const re = /<script[^>]*>([\s\S]*?)<\/script>/g;
let m;
while ((m = re.exec(src)) !== null) blocks.push(m[1]);
return blocks.join('\n');
}
// Find function starts and their body (best-effort brace matching).
function findFunctions(src) {
const out = [];
// Simpler approach: regex-based function heads, then count decision tokens in next ~200 lines or until matching brace.
const headRe =
/(?:export\s+)?(?:async\s+)?function\s+([a-zA-Z_$][\w$]*)\s*\([^)]*\)\s*\{|(?:const|let)\s+([a-zA-Z_$][\w$]*)\s*[:=][^={\n]*?=>\s*\{|([a-zA-Z_$][\w$]*)\s*\([^)]*\)\s*\{(?=\s*(?:\/\/|\n|\/\*|[a-z]))/g;
let m;
while ((m = headRe.exec(src)) !== null) {
const name = m[1] || m[2] || m[3];
if (
!name ||
name === 'if' ||
name === 'for' ||
name === 'while' ||
name === 'switch' ||
name === 'catch' ||
name === 'return'
)
continue;
// Find matching closing brace
let depth = 0;
const start = src.indexOf('{', m.index);
if (start < 0) continue;
let end = start;
for (let i = start; i < src.length; i++) {
const c = src[i];
if (c === '{') depth++;
else if (c === '}') {
depth--;
if (depth === 0) {
end = i;
break;
}
}
}
if (end <= start) continue;
const body = src.slice(start, end + 1);
const lines = body.split('\n').length;
out.push({ name, body, lines, offset: start });
}
return out;
}
function complexity(body) {
// Count decision points. Each adds 1.
const counts = {
if: (body.match(/\bif\s*\(/g) || []).length,
elseIf: (body.match(/\belse\s+if\s*\(/g) || []).length, // already counted by `if`; don't double
for: (body.match(/\bfor\s*\(/g) || []).length,
while: (body.match(/\bwhile\s*\(/g) || []).length,
case: (body.match(/\bcase\s+[^:]+:/g) || []).length,
catch: (body.match(/\bcatch\s*\(/g) || []).length,
ternary: (body.match(/\?[^?:]*:/g) || []).length,
and: (body.match(/&&/g) || []).length,
or: (body.match(/\|\|/g) || []).length,
coalesce: (body.match(/\?\?/g) || []).length,
};
const total =
counts.if +
counts.for +
counts.while +
counts.case +
counts.catch +
counts.ternary +
counts.and +
counts.or +
counts.coalesce;
return total;
}
const results = [];
for (const r of SCAN_ROOTS) {
const abs = join(ROOT, r);
const files = walk(abs);
for (const f of files) {
let src;
try {
src = readFileSync(f, 'utf8');
} catch {
continue;
}
const js = sanitize(extractJS(f, src));
if (!js.trim()) continue;
const funcs = findFunctions(js);
for (const fn of funcs) {
const c = complexity(fn.body);
if (c >= 10) {
results.push({
file: relative(ROOT, f),
name: fn.name,
complexity: c,
lines: fn.lines,
});
}
}
}
}
results.sort((a, b) => b.complexity - a.complexity);
const top = results.slice(0, 100);
const md = [
'# Cognitive Complexity Hotspots',
'',
`_Generated ${new Date().toISOString().slice(0, 10)} — heuristic scan (no ESLint deps)_`,
'',
'Complexity = sum of decision points per function (`if`, `for`, `while`, `case`, `catch`, ternary, `&&`, `||`, `??`). Threshold ≥ 10.',
'',
`**${results.length} functions** exceed threshold across the scanned tree. Showing top ${top.length}.`,
'',
'| # | Complexity | Lines | Function | File |',
'|---:|---:|---:|---|---|',
...top.map(
(r, i) => `| ${i + 1} | ${r.complexity} | ${r.lines} | \`${r.name}\` | \`${r.file}\` |`
),
'',
].join('\n');
const outDir = join(ROOT, 'docs');
mkdirSync(outDir, { recursive: true });
const outPath = join(outDir, 'complexity-hotspots.md');
writeFileSync(outPath, md);
console.log(`Wrote ${relative(ROOT, outPath)}${results.length} hotspots (≥10)`);

View file

@ -0,0 +1,109 @@
#!/usr/bin/env node
// Cross-module coupling audit. For each frontend module, count how many OTHER
// modules import from it (fan-in) and how many other modules it imports (fan-out).
// Writes docs/module-coupling.md.
import { readdirSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
import { join, relative, extname } from 'node:path';
const ROOT = new URL('..', import.meta.url).pathname.replace(/\/$/, '');
const MODULES_ROOT = join(ROOT, 'apps/mana/apps/web/src/lib/modules');
const CODE_EXT = new Set(['.ts', '.svelte']);
const IGNORE_DIRS = new Set(['node_modules', '.svelte-kit', 'dist']);
function walk(dir) {
const out = [];
for (const e of readdirSync(dir, { withFileTypes: true })) {
if (IGNORE_DIRS.has(e.name)) continue;
const p = join(dir, e.name);
if (e.isDirectory()) out.push(...walk(p));
else if (e.isFile() && CODE_EXT.has(extname(e.name))) out.push(p);
}
return out;
}
const modules = readdirSync(MODULES_ROOT, { withFileTypes: true })
.filter((e) => e.isDirectory())
.map((e) => e.name);
// Build: module -> set of files
const filesByModule = new Map();
for (const m of modules) {
filesByModule.set(m, walk(join(MODULES_ROOT, m)));
}
// For each file, scan imports; detect cross-module imports (paths containing `/modules/<other>/`)
const importRe = /(?:from\s+['"]|import\(['"])([^'"]+)['"]/g;
const fanIn = Object.fromEntries(modules.map((m) => [m, new Set()])); // who imports me
const fanOut = Object.fromEntries(modules.map((m) => [m, new Set()])); // who do I import
for (const [mod, files] of filesByModule.entries()) {
for (const f of files) {
let src;
try {
src = readFileSync(f, 'utf8');
} catch {
continue;
}
let m;
importRe.lastIndex = 0;
while ((m = importRe.exec(src)) !== null) {
const spec = m[1];
const match = spec.match(/modules\/([a-z0-9_-]+)\//i);
if (!match) continue;
const other = match[1];
if (other === mod || !modules.includes(other)) continue;
fanOut[mod].add(other);
fanIn[other].add(mod);
}
}
}
const rows = modules.map((m) => ({
module: m,
fanIn: fanIn[m].size,
fanOut: fanOut[m].size,
inList: [...fanIn[m]].sort(),
outList: [...fanOut[m]].sort(),
}));
const md = [
'# Module Coupling Report',
'',
`_Generated ${new Date().toISOString().slice(0, 10)}_`,
'',
'- **fan-in** = how many other modules import from this module (high = shared / core)',
'- **fan-out** = how many other modules this module imports from (high = tightly coupled / leaky)',
'',
'Ideal: most modules have fan-in ≤ 2 and fan-out ≤ 2. Outliers are refactor candidates.',
'',
'## Ranked by fan-in (shared modules)',
'',
'| Module | fan-in | fan-out | Imported by |',
'|---|---:|---:|---|',
...[...rows]
.sort((a, b) => b.fanIn - a.fanIn)
.map(
(r) =>
`| \`${r.module}\` | ${r.fanIn} | ${r.fanOut} | ${r.inList.map((x) => `\`${x}\``).join(', ') || '—'} |`
),
'',
'## Ranked by fan-out (leaky modules)',
'',
'| Module | fan-out | fan-in | Imports from |',
'|---|---:|---:|---|',
...[...rows]
.sort((a, b) => b.fanOut - a.fanOut)
.map(
(r) =>
`| \`${r.module}\` | ${r.fanOut} | ${r.fanIn} | ${r.outList.map((x) => `\`${x}\``).join(', ') || '—'} |`
),
'',
].join('\n');
const outDir = join(ROOT, 'docs');
mkdirSync(outDir, { recursive: true });
const outPath = join(outDir, 'module-coupling.md');
writeFileSync(outPath, md);
console.log(`Wrote ${relative(ROOT, outPath)}${rows.length} modules`);

178
scripts/audit-modules.mjs Normal file
View file

@ -0,0 +1,178 @@
#!/usr/bin/env node
// Module complexity audit. Writes docs/module-health.md.
// Usage: node scripts/audit-modules.mjs [--since=6.months]
import { readdirSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
import { join, relative, extname } from 'node:path';
import { execSync } from 'node:child_process';
const ROOT = new URL('..', import.meta.url).pathname.replace(/\/$/, '');
const SINCE = (process.argv.find((a) => a.startsWith('--since=')) || '--since=6.months').split(
'='
)[1];
const CODE_EXT = new Set(['.ts', '.tsx', '.js', '.mjs', '.svelte', '.go', '.py']);
const IGNORE_DIRS = new Set([
'node_modules',
'.turbo',
'.svelte-kit',
'dist',
'build',
'.next',
'coverage',
'__snapshots__',
]);
const TARGETS = [
{ label: 'web', root: 'apps/mana/apps/web/src/lib/modules' },
{ label: 'api', root: 'apps/api/src/modules' },
{ label: 'service', root: 'services' },
];
function walk(dir) {
const out = [];
let entries;
try {
entries = readdirSync(dir, { withFileTypes: true });
} catch {
return out;
}
for (const e of entries) {
if (IGNORE_DIRS.has(e.name)) continue;
const p = join(dir, e.name);
if (e.isDirectory()) out.push(...walk(p));
else if (e.isFile() && CODE_EXT.has(extname(e.name))) out.push(p);
}
return out;
}
function countLines(path) {
try {
return readFileSync(path, 'utf8').split('\n').length;
} catch {
return 0;
}
}
function gitChangeCount(path) {
try {
const out = execSync(
`git log --since=${SINCE} --pretty=format:%H -- "${path}" 2>/dev/null | wc -l`,
{ cwd: ROOT }
)
.toString()
.trim();
return Number(out) || 0;
} catch {
return 0;
}
}
function gitLastChanged(path) {
try {
const out = execSync(`git log -1 --format=%ar -- "${path}" 2>/dev/null`, { cwd: ROOT })
.toString()
.trim();
return out || '—';
} catch {
return '—';
}
}
function auditModule(absPath, label) {
const files = walk(absPath);
if (files.length === 0) return null;
let loc = 0;
let maxFile = { path: '', loc: 0 };
for (const f of files) {
const l = countLines(f);
loc += l;
if (l > maxFile.loc) maxFile = { path: relative(ROOT, f), loc: l };
}
const changes = gitChangeCount(relative(ROOT, absPath));
const lastChanged = gitLastChanged(relative(ROOT, absPath));
// score: LOC * log(changes+1) — hotspot heuristic
const score = Math.round(loc * Math.log2(changes + 2));
return {
label,
name: absPath.split('/').pop(),
loc,
files: files.length,
maxFile: maxFile.path.replace(/^.*\/modules\//, '').replace(/^.*\/services\//, ''),
maxFileLoc: maxFile.loc,
changes,
lastChanged,
score,
};
}
function collect() {
const rows = [];
for (const t of TARGETS) {
const rootAbs = join(ROOT, t.root);
let entries;
try {
entries = readdirSync(rootAbs, { withFileTypes: true });
} catch {
continue;
}
for (const e of entries) {
if (!e.isDirectory()) continue;
if (IGNORE_DIRS.has(e.name)) continue;
const r = auditModule(join(rootAbs, e.name), t.label);
if (r) rows.push(r);
}
}
return rows;
}
function fmt(n) {
return n.toLocaleString('en-US');
}
function renderMarkdown(rows) {
const byLabel = (l) => rows.filter((r) => r.label === l);
const section = (title, list) => {
const sorted = [...list].sort((a, b) => b.score - a.score);
const lines = [
`## ${title}`,
'',
'| Module | LOC | Files | Largest file (LOC) | Changes (6mo) | Last changed | Score |',
'|---|---:|---:|---|---:|---|---:|',
...sorted.map(
(r) =>
`| \`${r.name}\` | ${fmt(r.loc)} | ${r.files} | \`${r.maxFile}\` (${r.maxFileLoc}) | ${r.changes} | ${r.lastChanged} | ${fmt(r.score)} |`
),
'',
];
return lines.join('\n');
};
const totals = {
web: byLabel('web').reduce((s, r) => s + r.loc, 0),
api: byLabel('api').reduce((s, r) => s + r.loc, 0),
service: byLabel('service').reduce((s, r) => s + r.loc, 0),
};
return [
'# Module Health Report',
'',
`_Generated ${new Date().toISOString().slice(0, 10)} — git window: ${SINCE}_`,
'',
'**Score** = `LOC × log₂(changes + 2)`. High score = big *and* churny = refactor candidate.',
'',
`**Totals:** web \`${fmt(totals.web)}\` · api \`${fmt(totals.api)}\` · services \`${fmt(totals.service)}\` LOC`,
'',
section('Frontend modules (`apps/mana/apps/web/src/lib/modules`)', byLabel('web')),
section('API modules (`apps/api/src/modules`)', byLabel('api')),
section('Services (`services/`)', byLabel('service')),
].join('\n');
}
const rows = collect();
const md = renderMarkdown(rows);
const outDir = join(ROOT, 'docs');
mkdirSync(outDir, { recursive: true });
const outPath = join(outDir, 'module-health.md');
writeFileSync(outPath, md);
console.log(`Wrote ${relative(ROOT, outPath)}${rows.length} modules`);

View file

@ -0,0 +1,226 @@
#!/usr/bin/env node
// Generates docs/complexity-map.html — interactive D3 treemap.
// Area = LOC per file. Color = git change frequency (last 6 months).
// Groups: frontend modules, API modules, services.
import { readdirSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
import { join, relative, extname } from 'node:path';
import { execSync } from 'node:child_process';
const ROOT = new URL('..', import.meta.url).pathname.replace(/\/$/, '');
const SINCE = '6.months';
const CODE_EXT = new Set(['.ts', '.tsx', '.js', '.mjs', '.svelte', '.go', '.py']);
const IGNORE = new Set([
'node_modules',
'.turbo',
'.svelte-kit',
'dist',
'build',
'.next',
'coverage',
'__snapshots__',
]);
const TARGETS = [
{ label: 'web', root: 'apps/mana/apps/web/src/lib/modules' },
{ label: 'api', root: 'apps/api/src/modules' },
{ label: 'services', root: 'services' },
];
function walk(dir) {
const out = [];
let entries;
try {
entries = readdirSync(dir, { withFileTypes: true });
} catch {
return out;
}
for (const e of entries) {
if (IGNORE.has(e.name)) continue;
const p = join(dir, e.name);
if (e.isDirectory()) out.push(...walk(p));
else if (e.isFile() && CODE_EXT.has(extname(e.name))) out.push(p);
}
return out;
}
function loc(path) {
try {
return readFileSync(path, 'utf8').split('\n').length;
} catch {
return 0;
}
}
// Batch git log across many files: one call per module (fast enough).
function changeCountForFile(relPath) {
try {
const out = execSync(`git log --since=${SINCE} --pretty=format:%H -- "${relPath}" | wc -l`, {
cwd: ROOT,
})
.toString()
.trim();
return Number(out) || 0;
} catch {
return 0;
}
}
const tree = { name: 'mana', children: [] };
for (const t of TARGETS) {
const group = { name: t.label, children: [] };
const rootAbs = join(ROOT, t.root);
let modules;
try {
modules = readdirSync(rootAbs, { withFileTypes: true });
} catch {
continue;
}
for (const m of modules) {
if (!m.isDirectory() || IGNORE.has(m.name)) continue;
const modAbs = join(rootAbs, m.name);
const files = walk(modAbs);
if (files.length === 0) continue;
const modNode = { name: m.name, children: [] };
for (const f of files) {
const l = loc(f);
if (l === 0) continue;
const rel = relative(ROOT, f);
modNode.children.push({
name: f.split('/').slice(-2).join('/'),
path: rel,
value: l,
changes: changeCountForFile(rel),
});
}
if (modNode.children.length > 0) group.children.push(modNode);
}
tree.children.push(group);
}
const html = `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Mana Complexity Map</title>
<style>
:root { color-scheme: dark; }
html, body { margin: 0; padding: 0; background: #0b0d10; color: #e8e8e8; font: 13px/1.4 system-ui, sans-serif; }
header { padding: 12px 16px; border-bottom: 1px solid #1f2329; display: flex; gap: 16px; align-items: center; flex-wrap: wrap; }
header h1 { margin: 0; font-size: 14px; font-weight: 600; }
header .meta { color: #888; font-size: 12px; }
header label { color: #aaa; font-size: 12px; }
header select { background: #1a1d22; color: #e8e8e8; border: 1px solid #2a2f36; padding: 4px 8px; border-radius: 4px; }
#chart { position: fixed; inset: 48px 0 0 0; }
.cell { stroke: #0b0d10; stroke-width: 1; cursor: pointer; }
.cell:hover { stroke: #fff; stroke-width: 2; }
.label { fill: #fff; font-size: 11px; pointer-events: none; font-weight: 500; text-shadow: 0 1px 2px rgba(0,0,0,0.8); }
.tip { position: fixed; background: #1a1d22; border: 1px solid #2a2f36; padding: 8px 10px; border-radius: 6px; font-size: 12px; pointer-events: none; display: none; max-width: 360px; z-index: 10; }
.tip b { color: #fff; } .tip .k { color: #888; }
.legend { display: flex; gap: 8px; align-items: center; }
.legend .bar { width: 160px; height: 10px; border-radius: 3px; background: linear-gradient(to right, #1e3a5f, #2b6cb0, #d97706, #dc2626); }
</style>
</head>
<body>
<header>
<h1>Mana Complexity Map</h1>
<span class="meta">Area = LOC · Color = git changes (last ${SINCE})</span>
<label>Group:
<select id="group">
<option value="all">all</option>
<option value="web">web</option>
<option value="api">api</option>
<option value="services">services</option>
</select>
</label>
<span class="legend"><span class="k" style="color:#888">cold</span><span class="bar"></span><span class="k" style="color:#888">hot</span></span>
<span class="meta" id="stats"></span>
</header>
<div id="chart"></div>
<div class="tip" id="tip"></div>
<script src="https://cdn.jsdelivr.net/npm/d3@7/dist/d3.min.js"></script>
<script>
const DATA = ${JSON.stringify(tree)};
const tip = document.getElementById('tip');
const sel = document.getElementById('group');
const stats = document.getElementById('stats');
function filtered(group) {
if (group === 'all') return DATA;
return { name: 'mana', children: DATA.children.filter(c => c.name === group) };
}
function maxChanges(root) {
let max = 0;
root.each(d => { if (d.data.changes && d.data.changes > max) max = d.data.changes; });
return max || 1;
}
function render() {
const group = sel.value;
const container = document.getElementById('chart');
container.innerHTML = '';
const w = container.clientWidth, h = container.clientHeight;
const root = d3.hierarchy(filtered(group)).sum(d => d.value || 0).sort((a,b) => b.value - a.value);
d3.treemap().size([w, h]).paddingInner(1).paddingTop(d => d.depth === 1 ? 18 : d.depth === 2 ? 14 : 1).round(true)(root);
const max = maxChanges(root);
const color = d3.scaleSequential([0, Math.log2(max + 1)], d3.interpolateInferno);
const totalLOC = root.value;
const fileCount = root.leaves().length;
stats.textContent = \`\${fileCount} files · \${totalLOC.toLocaleString()} LOC\`;
const svg = d3.select(container).append('svg').attr('width', w).attr('height', h);
// group labels (depth 1 and 2)
svg.selectAll('g.group').data(root.descendants().filter(d => d.depth > 0 && d.depth < 3))
.join('g').attr('class', 'group')
.each(function(d) {
const g = d3.select(this);
g.append('rect').attr('x', d.x0).attr('y', d.y0).attr('width', d.x1-d.x0).attr('height', d.depth === 1 ? 18 : 14)
.attr('fill', d.depth === 1 ? '#141820' : '#1a1f27');
g.append('text').attr('x', d.x0 + 6).attr('y', d.y0 + (d.depth === 1 ? 13 : 10))
.attr('class', 'label').attr('font-weight', d.depth === 1 ? 700 : 500)
.text(\`\${d.data.name} (\${d.value.toLocaleString()})\`);
});
svg.selectAll('rect.cell').data(root.leaves()).join('rect')
.attr('class', 'cell')
.attr('x', d => d.x0).attr('y', d => d.y0)
.attr('width', d => Math.max(0, d.x1-d.x0)).attr('height', d => Math.max(0, d.y1-d.y0))
.attr('fill', d => color(Math.log2((d.data.changes || 0) + 1)))
.on('mousemove', (e, d) => {
tip.style.display = 'block';
tip.style.left = Math.min(e.clientX + 12, window.innerWidth - 370) + 'px';
tip.style.top = (e.clientY + 12) + 'px';
tip.innerHTML = \`<b>\${d.data.path}</b><br>
<span class="k">LOC:</span> \${d.data.value.toLocaleString()}<br>
<span class="k">Changes (\${'${SINCE}'}):</span> \${d.data.changes || 0}\`;
})
.on('mouseleave', () => { tip.style.display = 'none'; });
svg.selectAll('text.leaf').data(root.leaves().filter(d => (d.x1-d.x0) > 60 && (d.y1-d.y0) > 18))
.join('text').attr('class', 'label leaf')
.attr('x', d => d.x0 + 4).attr('y', d => d.y0 + 14)
.text(d => d.data.name.split('/').pop());
}
sel.addEventListener('change', render);
window.addEventListener('resize', render);
render();
</script>
</body>
</html>`;
const outputs = [
join(ROOT, 'docs', 'complexity-map.html'),
join(ROOT, 'apps/mana/apps/web/static/admin/complexity-map.html'),
];
for (const p of outputs) {
mkdirSync(join(p, '..'), { recursive: true });
writeFileSync(p, html);
console.log(`Wrote ${relative(ROOT, p)}`);
}