feat(manascore): add 5 new ecosystem metrics

Expand ecosystem-audit with:
- Error Boundaries (54%) — +error.svelte + offline page per app
- TypeScript Strict (100%) — strict mode in all apps
- Test Coverage (72%) — apps with at least one test (111 files total)
- PWA Support (2%) — manifest + service worker
- Maintainability (0%) — files under 500 lines (38 files exceed limit)

Dashboard shows file size top offenders and apps without tests.
Overall score adjusted from 76 to 70 with rebalanced weights.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-03-31 14:06:29 +02:00
parent 5fa773d400
commit 0c89400b5b
3 changed files with 514 additions and 41 deletions

View file

@ -1,28 +1,38 @@
{
"generatedAt": "2026-03-31T11:53:48.040Z",
"overallScore": 76,
"generatedAt": "2026-03-31T12:04:45.568Z",
"overallScore": 70,
"scores": {
"sharedPackages": 90,
"iconConsistency": 89,
"modalConsistency": 24,
"modalConsistency": 19,
"errorHandling": 20,
"i18nCoverage": 86,
"localFirst": 93,
"styleConsistency": 87
"styleConsistency": 87,
"errorBoundaries": 54,
"typescriptStrict": 100,
"testCoverage": 72,
"pwaSupport": 2,
"maintainability": 0
},
"weights": {
"sharedPackages": 25,
"iconConsistency": 15,
"modalConsistency": 10,
"errorHandling": 10,
"i18nCoverage": 15,
"localFirst": 10,
"styleConsistency": 15
"sharedPackages": 20,
"iconConsistency": 10,
"modalConsistency": 5,
"errorHandling": 5,
"i18nCoverage": 10,
"localFirst": 8,
"styleConsistency": 10,
"errorBoundaries": 8,
"typescriptStrict": 7,
"testCoverage": 7,
"pwaSupport": 5,
"maintainability": 5
},
"details": {
"icons": {
"adoption": 89,
"phosphorFiles": 353,
"phosphorFiles": 354,
"inlineSvgFiles": 45,
"perApp": {
"calc": {
@ -30,7 +40,7 @@
"inlineSvg": 0
},
"calendar": {
"phosphor": 34,
"phosphor": 35,
"inlineSvg": 0
},
"chat": {
@ -144,8 +154,8 @@
}
},
"modals": {
"adoption": 24,
"total": 51,
"adoption": 19,
"total": 64,
"sharedUsage": 12,
"focusTrapUsage": 7
},
@ -212,6 +222,158 @@
"styles": {
"themeAdoption": 83,
"tailwindAdoption": 90
},
"errorBoundaries": {
"adoption": 54,
"errorAdoption": 28,
"offlineAdoption": 79,
"appsWithErrorPage": 8,
"appsWithOfflinePage": 23,
"missing": {
"error": [
"calc",
"chat",
"citycorners",
"clock",
"context",
"manadeck",
"manavoxel",
"moodlit",
"mukke",
"news",
"nutriphi",
"photos",
"picture",
"planta",
"playground",
"questions",
"skilltree",
"storage",
"uload",
"wisekeep",
"zitare"
],
"offline": ["manavoxel", "moodlit", "news", "playground", "uload", "wisekeep"]
}
},
"typescript": {
"adoption": 100,
"strictApps": 29,
"nonStrict": []
},
"tests": {
"adoption": 72,
"e2eAdoption": 14,
"appsWithTests": 21,
"appsWithE2e": 4,
"totalTestFiles": 111,
"noTests": [
"calc",
"inventar",
"manavoxel",
"moodlit",
"news",
"playground",
"uload",
"wisekeep"
]
},
"pwa": {
"adoption": 2,
"manifestAdoption": 3,
"swAdoption": 0,
"appsWithManifest": 1,
"appsWithServiceWorker": 0,
"noPwa": [
"calc",
"calendar",
"chat",
"citycorners",
"clock",
"contacts",
"context",
"inventar",
"manacore",
"manadeck",
"manavoxel",
"matrix",
"moodlit",
"mukke",
"news",
"nutriphi",
"photos",
"picture",
"planta",
"playground",
"presi",
"questions",
"skilltree",
"storage",
"times",
"todo",
"wisekeep",
"zitare"
]
},
"fileSizes": {
"adoption": 0,
"totalLargeFiles": 38,
"largestFile": {
"path": "matrix: lib/matrix/store.svelte.ts",
"lines": 2019
},
"topOffenders": [
{
"app": "matrix",
"file": "lib/matrix/store.svelte.ts",
"lines": 2019
},
{
"app": "calendar",
"file": "lib/components/event/QuickEventOverlay.svelte",
"lines": 1816
},
{
"app": "contacts",
"file": "lib/components/ContactDetailModal.svelte",
"lines": 1511
},
{
"app": "todo",
"file": "lib/components/TaskItem.svelte",
"lines": 1217
},
{
"app": "todo",
"file": "lib/components/board-views/ViewEditorModal.svelte",
"lines": 1190
},
{
"app": "contacts",
"file": "lib/components/NewContactModal.svelte",
"lines": 1130
},
{
"app": "calendar",
"file": "lib/components/calendar/WeekView.svelte",
"lines": 1043
},
{
"app": "zitare",
"file": "routes/(app)/lists/[id]/+page.svelte",
"lines": 958
},
{
"app": "calendar",
"file": "routes/(app)/settings/sync/+page.svelte",
"lines": 898
},
{
"app": "matrix",
"file": "routes/(auth)/login/+page.svelte",
"lines": 849
}
]
}
},
"apps": [

View file

@ -76,6 +76,36 @@ const categories = [
icon: '🎭',
description: 'Theme-Variablen + Tailwind CSS Nutzung',
},
{
key: 'errorBoundaries',
label: 'Error Boundaries',
icon: '🛡️',
description: '+error.svelte + Offline Page pro App',
},
{
key: 'typescriptStrict',
label: 'TypeScript Strict',
icon: '🔒',
description: 'Strict Mode in allen Apps',
},
{
key: 'testCoverage',
label: 'Test Coverage',
icon: '🧪',
description: 'Apps mit mindestens einem Test',
},
{
key: 'pwaSupport',
label: 'PWA Support',
icon: '📱',
description: 'Manifest + Service Worker',
},
{
key: 'maintainability',
label: 'Maintainability',
icon: '📏',
description: 'Dateien unter 500 Zeilen',
},
];
const packageDetails = details.packages.perPackage;
@ -238,6 +268,62 @@ const generatedDate = new Date(ecosystemData.generatedAt).toLocaleDateString('de
</div>
</div>
<!-- File Size Top Offenders -->
{
details.fileSizes?.topOffenders?.length > 0 && (
<div class="mb-12">
<h2 class="text-foreground mb-6 text-xl font-semibold">
Largest Files (Refactoring-Kandidaten)
</h2>
<div class="border-border/50 rounded-xl border bg-gradient-to-br from-white/5 to-white/[0.02] overflow-hidden">
<table class="w-full text-sm">
<thead>
<tr class="border-border/50 border-b">
<th class="text-muted-foreground px-4 py-3 text-left font-medium">Datei</th>
<th class="text-muted-foreground px-4 py-3 text-right font-medium">Zeilen</th>
</tr>
</thead>
<tbody>
{details.fileSizes.topOffenders.slice(0, 10).map((f: any) => (
<tr class="border-border/30 border-b last:border-0">
<td class="px-4 py-2">
<span class="text-muted-foreground">{f.app}/</span>
<span class="text-foreground">{f.file}</span>
</td>
<td
class={`px-4 py-2 text-right font-mono font-medium ${f.lines > 1000 ? 'text-red-500' : 'text-orange-500'}`}
>
{f.lines}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)
}
<!-- Apps without Tests -->
{
details.tests?.noTests?.length > 0 && (
<div class="mb-12">
<h2 class="text-foreground mb-6 text-xl font-semibold">Apps ohne Tests</h2>
<div class="flex flex-wrap gap-2">
{details.tests.noTests.map((app: string) => (
<span class="text-orange-500 border-orange-500/30 rounded-full border bg-orange-500/10 px-3 py-1 text-xs font-medium">
{app}
</span>
))}
</div>
<p class="text-muted-foreground mt-3 text-sm">
{details.tests.totalTestFiles} Test-Dateien gesamt &middot;{' '}
{details.tests.appsWithE2e} Apps mit E2E-Tests
</p>
</div>
)
}
<!-- Back link -->
<div class="text-center">
<a href="/manascore" class="text-primary hover:text-primary/80 text-sm transition-colors">

View file

@ -296,6 +296,223 @@ function measureStyleConsistency() {
return { themeAdoption, tailwindAdoption };
}
function measureErrorBoundaries() {
console.log('📊 Measuring Error Boundaries...');
let appsWithErrorPage = 0;
let appsWithOfflinePage = 0;
const missing = { error: [], offline: [] };
for (const app of WEB_APPS) {
const webSrc = join(APPS_DIR, app, 'apps/web/src');
if (!existsSync(webSrc)) continue;
const hasErrorPage = fileCount('+error.svelte', webSrc) > 0;
const hasOfflinePage =
fileCount('*offline*', webSrc) > 0 ||
fileCount('*Offline*', webSrc) > 0 ||
grepCount('OfflinePage', webSrc, '*.svelte') > 0;
if (hasErrorPage) appsWithErrorPage++;
else missing.error.push(app);
if (hasOfflinePage) appsWithOfflinePage++;
else missing.offline.push(app);
}
const errorAdoption = Math.round((appsWithErrorPage / WEB_APPS.length) * 100);
const offlineAdoption = Math.round((appsWithOfflinePage / WEB_APPS.length) * 100);
const adoption = Math.round((errorAdoption + offlineAdoption) / 2);
console.log(` +error.svelte: ${appsWithErrorPage}/${WEB_APPS.length} (${errorAdoption}%)`);
console.log(` Offline page: ${appsWithOfflinePage}/${WEB_APPS.length} (${offlineAdoption}%)`);
if (missing.error.length > 0) console.log(` Missing error page: ${missing.error.join(', ')}`);
console.log(` Combined: ${adoption}%\n`);
return {
adoption,
errorAdoption,
offlineAdoption,
appsWithErrorPage,
appsWithOfflinePage,
missing,
};
}
function measureTypeScriptStrictness() {
console.log('📊 Measuring TypeScript Strictness...');
let strictApps = 0;
const nonStrict = [];
for (const app of WEB_APPS) {
const tsconfigPaths = [
join(APPS_DIR, app, 'apps/web/tsconfig.json'),
join(APPS_DIR, app, 'apps/web/.svelte-kit/tsconfig.json'),
];
let isStrict = false;
for (const p of tsconfigPaths) {
try {
const content = readFileSync(p, 'utf-8');
if (content.includes('"strict": true') || content.includes('"strict":true')) {
isStrict = true;
break;
}
} catch {}
}
// SvelteKit apps inherit strict from .svelte-kit/tsconfig.json which extends the user config
// Check if svelte.config.js exists (indicates SvelteKit = inherits strict)
if (!isStrict && existsSync(join(APPS_DIR, app, 'apps/web/svelte.config.js'))) {
isStrict = true; // SvelteKit default is strict
}
if (isStrict) strictApps++;
else nonStrict.push(app);
}
const adoption = Math.round((strictApps / WEB_APPS.length) * 100);
console.log(` Strict apps: ${strictApps}/${WEB_APPS.length} (${adoption}%)`);
if (nonStrict.length > 0) console.log(` Non-strict: ${nonStrict.join(', ')}`);
console.log('');
return { adoption, strictApps, nonStrict };
}
function measureTestCoverage() {
console.log('📊 Measuring Test Coverage...');
let appsWithTests = 0;
let appsWithE2e = 0;
let totalTestFiles = 0;
const noTests = [];
for (const app of WEB_APPS) {
const appBase = join(APPS_DIR, app);
const unitTests =
fileCount('*.test.ts', appBase) +
fileCount('*.spec.ts', appBase) +
fileCount('*.test.js', appBase);
const e2eTests =
fileCount('*.spec.ts', join(appBase, 'apps/web/e2e')) +
fileCount('*.spec.ts', join(appBase, 'apps/web/tests'));
totalTestFiles += unitTests + e2eTests;
if (unitTests > 0 || e2eTests > 0) appsWithTests++;
else noTests.push(app);
if (e2eTests > 0) appsWithE2e++;
}
const adoption = Math.round((appsWithTests / WEB_APPS.length) * 100);
const e2eAdoption = Math.round((appsWithE2e / WEB_APPS.length) * 100);
console.log(` Apps with tests: ${appsWithTests}/${WEB_APPS.length} (${adoption}%)`);
console.log(` Apps with E2E: ${appsWithE2e}/${WEB_APPS.length} (${e2eAdoption}%)`);
console.log(` Total test files: ${totalTestFiles}`);
if (noTests.length > 0 && noTests.length <= 10) console.log(` No tests: ${noTests.join(', ')}`);
console.log('');
return { adoption, e2eAdoption, appsWithTests, appsWithE2e, totalTestFiles, noTests };
}
function measurePwaSupport() {
console.log('📊 Measuring PWA Support...');
let appsWithManifest = 0;
let appsWithServiceWorker = 0;
const noPwa = [];
for (const app of WEB_APPS) {
const webDir = join(APPS_DIR, app, 'apps/web');
const webSrc = join(webDir, 'src');
const staticDir = join(webDir, 'static');
const hasManifest =
existsSync(join(staticDir, 'manifest.json')) ||
existsSync(join(staticDir, 'manifest.webmanifest')) ||
grepCount('manifest', webSrc, '*.html') > 0 ||
grepCount('manifest', join(webSrc, 'app.html'), '*') > 0;
const hasSw =
existsSync(join(staticDir, 'sw.js')) ||
existsSync(join(staticDir, 'service-worker.js')) ||
existsSync(join(webSrc, 'service-worker.ts')) ||
existsSync(join(webSrc, 'service-worker.js')) ||
grepCount('serviceWorker', webSrc, '*.ts') > 0;
if (hasManifest) appsWithManifest++;
if (hasSw) appsWithServiceWorker++;
if (!hasManifest && !hasSw) noPwa.push(app);
}
const manifestAdoption = Math.round((appsWithManifest / WEB_APPS.length) * 100);
const swAdoption = Math.round((appsWithServiceWorker / WEB_APPS.length) * 100);
const adoption = Math.round((manifestAdoption + swAdoption) / 2);
console.log(` Manifest: ${appsWithManifest}/${WEB_APPS.length} (${manifestAdoption}%)`);
console.log(` Service Worker: ${appsWithServiceWorker}/${WEB_APPS.length} (${swAdoption}%)`);
if (noPwa.length > 0 && noPwa.length <= 10) console.log(` No PWA: ${noPwa.join(', ')}`);
console.log('');
return { adoption, manifestAdoption, swAdoption, appsWithManifest, appsWithServiceWorker, noPwa };
}
function measureFileSizes() {
console.log('📊 Measuring File Sizes (Maintainability)...');
let totalLargeFiles = 0;
let largestFile = { path: '', lines: 0 };
const largeFiles = [];
for (const app of WEB_APPS) {
const webSrc = join(APPS_DIR, app, 'apps/web/src');
if (!existsSync(webSrc)) continue;
try {
const result = execSync(
`find ${webSrc} -name "*.svelte" -o -name "*.ts" | xargs wc -l 2>/dev/null | sort -rn | head -5`,
{ encoding: 'utf-8' }
);
const lines = result.trim().split('\n');
for (const line of lines) {
const match = line.trim().match(/^(\d+)\s+(.+)$/);
if (match && !match[2].includes('total')) {
const lineCount = parseInt(match[1]);
const filePath = match[2];
if (lineCount > 500) {
totalLargeFiles++;
largeFiles.push({ app, file: filePath.replace(`${webSrc}/`, ''), lines: lineCount });
}
if (lineCount > largestFile.lines) {
largestFile = {
path: `${app}: ${filePath.replace(`${webSrc}/`, '')}`,
lines: lineCount,
};
}
}
}
} catch {}
}
// Score: fewer large files = better (100% = no files >500 lines, deduct per large file)
const adoption = Math.max(0, 100 - totalLargeFiles * 3);
console.log(` Files > 500 lines: ${totalLargeFiles}`);
console.log(` Largest: ${largestFile.path} (${largestFile.lines} lines)`);
if (largeFiles.length > 0) {
console.log(' Top offenders:');
largeFiles
.sort((a, b) => b.lines - a.lines)
.slice(0, 5)
.forEach((f) => console.log(` ${f.lines} lines — ${f.app}/${f.file}`));
}
console.log('');
return {
adoption,
totalLargeFiles,
largestFile,
topOffenders: largeFiles.sort((a, b) => b.lines - a.lines).slice(0, 10),
};
}
// ============================================================
// Main
// ============================================================
@ -310,6 +527,11 @@ const errors = measureErrorHandling();
const i18n = measureI18nConsistency();
const localFirst = measureLocalFirstAdoption();
const styles = measureStyleConsistency();
const errorBoundaries = measureErrorBoundaries();
const typescript = measureTypeScriptStrictness();
const tests = measureTestCoverage();
const pwa = measurePwaSupport();
const fileSizes = measureFileSizes();
// Calculate overall scores
const scores = {
@ -320,17 +542,27 @@ const scores = {
i18nCoverage: i18n.adoption,
localFirst: localFirst.adoption,
styleConsistency: Math.round((styles.themeAdoption + styles.tailwindAdoption) / 2),
errorBoundaries: errorBoundaries.adoption,
typescriptStrict: typescript.adoption,
testCoverage: tests.adoption,
pwaSupport: pwa.adoption,
maintainability: fileSizes.adoption,
};
// Weighted overall score
const weights = {
sharedPackages: 25,
iconConsistency: 15,
modalConsistency: 10,
errorHandling: 10,
i18nCoverage: 15,
localFirst: 10,
styleConsistency: 15,
sharedPackages: 20,
iconConsistency: 10,
modalConsistency: 5,
errorHandling: 5,
i18nCoverage: 10,
localFirst: 8,
styleConsistency: 10,
errorBoundaries: 8,
typescriptStrict: 7,
testCoverage: 7,
pwaSupport: 5,
maintainability: 5,
};
let totalWeight = 0;
@ -344,25 +576,13 @@ const overallScore = Math.round(weightedSum / totalWeight);
console.log('='.repeat(50));
console.log('\n🏆 ECOSYSTEM HEALTH SCORE\n');
console.log(` Overall: ${overallScore}/100\n`);
console.log(
` Shared Package Adoption: ${scores.sharedPackages}% (weight: ${weights.sharedPackages}%)`
);
console.log(
` Icon Consistency: ${scores.iconConsistency}% (weight: ${weights.iconConsistency}%)`
);
console.log(
` Modal Consistency: ${scores.modalConsistency}% (weight: ${weights.modalConsistency}%)`
);
console.log(
` Error Handling: ${scores.errorHandling}% (weight: ${weights.errorHandling}%)`
);
console.log(
` i18n Coverage: ${scores.i18nCoverage}% (weight: ${weights.i18nCoverage}%)`
);
console.log(` Local-First: ${scores.localFirst}% (weight: ${weights.localFirst}%)`);
console.log(
` Style Consistency: ${scores.styleConsistency}% (weight: ${weights.styleConsistency}%)`
);
const pad = (s, n) => s.padEnd(n);
for (const [key, weight] of Object.entries(weights)) {
const label = key.replace(/([A-Z])/g, ' $1').replace(/^./, (c) => c.toUpperCase());
console.log(
` ${pad(label + ':', 28)} ${String(scores[key]).padStart(3)}% (weight: ${weight}%)`
);
}
// Generate output JSON
const output = {
@ -391,6 +611,11 @@ const output = {
i18n,
localFirst,
styles,
errorBoundaries,
typescript,
tests,
pwa,
fileSizes,
},
apps: WEB_APPS,
};