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

@ -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,
};