From 23acb34b40579ab5374946d5b42fb13444aee614 Mon Sep 17 00:00:00 2001 From: Youssef Dakkak Date: Fri, 16 Oct 2026 17:27:05 +0000 Subject: [PATCH] test(coverage): enforce per-file coverage thresholds for critical Stellar modules (#1003) Co-authored-by: Cursor --- scripts/check-coverage.mjs | 252 ++++++++++++++++++---- tests/unit/scripts/check-coverage.test.js | 78 +++++++ vitest.config.js | 30 +++ 3 files changed, 318 insertions(+), 42 deletions(-) create mode 100644 tests/unit/scripts/check-coverage.test.js diff --git a/scripts/check-coverage.mjs b/scripts/check-coverage.mjs index e230b0e5..c1f3a8c6 100644 --- a/scripts/check-coverage.mjs +++ b/scripts/check-coverage.mjs @@ -1,15 +1,184 @@ #!/usr/bin/env node /** - * Enforce coverage thresholds from testing/coverage-thresholds.json. - * Exits 0 when coverage meets global floor; warns on per-file targets. + * Enforce global and per-file coverage thresholds from vitest.config.js. + * Supports critical-module pattern matching (e.g., src/lib/stellar*, + * src/lib/transactionBuilder.ts, src/lib/wallet/**, src/lib/multisig*) + * and emits a Markdown summary table to GITHUB_STEP_SUMMARY when running in CI. */ -import { readFileSync, existsSync } from 'node:fs'; +import { readFileSync, appendFileSync, existsSync } from 'node:fs'; import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; import { coverageThresholds } from '../vitest.config.js'; const SUMMARY_PATH = join(process.cwd(), 'coverage', 'coverage-summary.json'); +/** + * Normalizes a file path to forward slashes so coverage keys match across OSes. + */ +export function normalizeCoverageKey(filePath) { + return String(filePath).replace(/\\/g, '/'); +} + +/** + * Matches a normalized coverage key against a critical module glob pattern + * (`src/lib/stellar*`, `src/lib/wallet/**`, exact suffix, etc.). + */ +export function matchesCriticalPattern(key, pattern) { + const normKey = normalizeCoverageKey(key); + const normPattern = normalizeCoverageKey(pattern); + + if (normKey.includes('/__tests__/') || /\.test\.[jt]sx?$/.test(normKey)) { + return false; + } + + const idx = normKey.indexOf('src/'); + const relKey = idx >= 0 ? normKey.slice(idx) : normKey; + + if (normPattern.endsWith('/**')) { + const prefix = normPattern.slice(0, -3); + return relKey.startsWith(`${prefix}/`); + } + + if (normPattern.includes('*')) { + const regexStr = + '^' + + normPattern + .replace(/[.+?^${}()|[\]\\]/g, '\\$&') + .replace(/\*/g, '[^/]*') + + '$'; + return new RegExp(regexStr).test(relKey); + } + + return relKey === normPattern || normKey.endsWith(normPattern); +} + +/** + * Evaluates a coverage summary object against threshold rules. + */ +export function evaluateCoverageSummary(summary, thresholds = coverageThresholds, options = {}) { + const strictCritical = + options.strictCritical ?? process.env.ENFORCE_CRITICAL_COVERAGE === '1'; + + if (!summary || !summary.total) { + throw new Error('No total coverage summary found.'); + } + + const total = summary.total; + const globalResults = []; + const fileViolations = []; + let failed = false; + + for (const metric of ['lines', 'functions', 'branches', 'statements']) { + const pct = total[metric]?.pct ?? 0; + const min = thresholds[metric] ?? 0; + const passed = pct >= min; + if (!passed) { + failed = true; + } + globalResults.push({ metric, pct, min, passed }); + } + + if (thresholds.targetPaths) { + for (const [filePath, fileThresholds] of Object.entries(thresholds.targetPaths)) { + const key = Object.keys(summary).find((k) => + normalizeCoverageKey(k).endsWith(normalizeCoverageKey(filePath)) + ); + if (!key) { + fileViolations.push({ + file: filePath, + metric: 'missing', + pct: 0, + target: 100, + severity: 'warn', + }); + continue; + } + const fileCov = summary[key]; + for (const metric of ['lines', 'functions', 'branches', 'statements']) { + const target = fileThresholds[metric]; + if (target == null) continue; + const pct = fileCov[metric]?.pct ?? 0; + if (pct < target) { + fileViolations.push({ + file: filePath, + metric, + pct, + target, + severity: thresholds.perFile ? 'fail' : 'warn', + }); + if (thresholds.perFile) { + failed = true; + } + } + } + } + } + + if (Array.isArray(thresholds.criticalPatterns)) { + const summaryKeys = Object.keys(summary).filter((k) => k !== 'total'); + for (const rule of thresholds.criticalPatterns) { + const matchedKeys = summaryKeys.filter((k) => matchesCriticalPattern(k, rule.pattern)); + for (const key of matchedKeys) { + const fileCov = summary[key]; + const normKey = normalizeCoverageKey(key); + const relFile = normKey.includes('src/') + ? normKey.slice(normKey.indexOf('src/')) + : normKey; + + for (const metric of ['lines', 'branches', 'functions', 'statements']) { + const target = rule[metric]; + if (target == null) continue; + const pct = fileCov[metric]?.pct ?? 0; + if (pct < target) { + const severity = strictCritical ? 'fail' : 'warn'; + fileViolations.push({ + file: relFile, + pattern: rule.pattern, + metric, + pct, + target, + severity, + }); + if (strictCritical) { + failed = true; + } + } + } + } + } + } + + return { + passed: !failed, + globalResults, + fileViolations, + }; +} + +/** + * Formats a Markdown report suitable for GITHUB_STEP_SUMMARY. + */ +export function formatCoverageMarkdownSummary(evaluation) { + const lines = ['### Coverage Threshold Report', '']; + lines.push('| Scope | Metric | Actual | Target | Status |'); + lines.push('| --- | --- | --- | --- | --- |'); + + for (const g of evaluation.globalResults) { + lines.push( + `| Global | ${g.metric} | ${g.pct}% | ${g.min}% | ${g.passed ? 'PASS' : 'FAIL'} |` + ); + } + + for (const v of evaluation.fileViolations) { + lines.push( + `| \`${v.file}\` | ${v.metric} | ${v.pct}% | ${v.target}% | ${v.severity.toUpperCase()} |` + ); + } + + return lines.join('\n') + '\n'; +} + function loadJson(path) { if (!existsSync(path)) { console.error(`Missing file: ${path}`); @@ -18,51 +187,50 @@ function loadJson(path) { return JSON.parse(readFileSync(path, 'utf8')); } -const summary = loadJson(SUMMARY_PATH); -const thresholds = coverageThresholds; -const total = summary.total; - -if (!total) { - console.error('No total coverage summary found.'); - process.exit(1); -} +const isMain = + process.argv[1] && + normalizeCoverageKey(fileURLToPath(import.meta.url)) === + normalizeCoverageKey(process.argv[1]); -let failed = false; +if (isMain) { + const summary = loadJson(SUMMARY_PATH); + const evaluation = evaluateCoverageSummary(summary, coverageThresholds); -for (const metric of ['lines', 'functions', 'branches', 'statements']) { - const pct = total[metric]?.pct ?? 0; - const min = thresholds[metric] ?? 0; - if (pct < min) { - console.error(`FAIL: Global ${metric} coverage ${pct}% < ${min}%`); - failed = true; - } else { - console.log(`PASS: Global ${metric} coverage ${pct}% >= ${min}%`); + for (const g of evaluation.globalResults) { + if (!g.passed) { + console.error(`FAIL: Global ${g.metric} coverage ${g.pct}% < ${g.min}%`); + } else { + console.log(`PASS: Global ${g.metric} coverage ${g.pct}% >= ${g.min}%`); + } } -} -if (thresholds.targetPaths) { - for (const [filePath, fileThresholds] of Object.entries(thresholds.targetPaths)) { - const key = Object.keys(summary).find((k) => k.endsWith(filePath)); - if (!key) { - console.warn(`WARN: No coverage data for target ${filePath}`); - continue; + for (const v of evaluation.fileViolations) { + const prefix = v.severity === 'fail' ? 'FAIL' : 'WARN'; + const logger = v.severity === 'fail' ? console.error : console.warn; + if (v.metric === 'missing') { + logger(`${prefix}: No coverage data for target ${v.file}`); + } else { + logger( + `${prefix}: ${v.file} ${v.metric} ${v.pct}% < target ${v.target}%` + ); } - const fileCov = summary[key]; - for (const metric of ['lines', 'functions', 'branches', 'statements']) { - const target = fileThresholds[metric]; - if (target == null) continue; - const pct = fileCov[metric]?.pct ?? 0; - if (pct < target) { - console.warn( - `WARN: ${filePath} ${metric} ${pct}% < target ${target}% (aspirational)` - ); - } + } + + if (process.env.GITHUB_STEP_SUMMARY) { + try { + appendFileSync( + process.env.GITHUB_STEP_SUMMARY, + formatCoverageMarkdownSummary(evaluation), + 'utf8' + ); + } catch { + // Ignore step summary write errors outside CI } } -} -if (failed) { - process.exit(1); -} + if (!evaluation.passed) { + process.exit(1); + } -console.log('Coverage thresholds met.'); + console.log('Coverage thresholds met.'); +} diff --git a/tests/unit/scripts/check-coverage.test.js b/tests/unit/scripts/check-coverage.test.js new file mode 100644 index 00000000..b22d1a93 --- /dev/null +++ b/tests/unit/scripts/check-coverage.test.js @@ -0,0 +1,78 @@ +import { describe, it, expect } from 'vitest'; +import { coverageThresholds } from '../../../vitest.config.js'; +import { + matchesCriticalPattern, + evaluateCoverageSummary, + formatCoverageMarkdownSummary, +} from '../../../scripts/check-coverage.mjs'; + +describe('per-file coverage threshold enforcement (#1003)', () => { + it('declares >=90% lines and branches for critical Stellar, builder, wallet, and multisig patterns', () => { + expect(Array.isArray(coverageThresholds.criticalPatterns)).toBe(true); + const patterns = coverageThresholds.criticalPatterns.map((p) => p.pattern); + expect(patterns).toContain('src/lib/stellar*'); + expect(patterns).toContain('src/lib/transactionBuilder.ts'); + expect(patterns).toContain('src/lib/wallet/**'); + expect(patterns).toContain('src/lib/multisig*'); + + for (const rule of coverageThresholds.criticalPatterns) { + expect(rule.lines).toBeGreaterThanOrEqual(90); + expect(rule.branches).toBeGreaterThanOrEqual(90); + } + }); + + it('matches critical module globs while excluding unit test files', () => { + expect(matchesCriticalPattern('/repo/src/lib/stellar.ts', 'src/lib/stellar*')).toBe(true); + expect(matchesCriticalPattern('C:\\repo\\src\\lib\\wallet\\freighter.ts', 'src/lib/wallet/**')).toBe(true); + expect(matchesCriticalPattern('/repo/src/lib/wallet/__tests__/ledger.test.ts', 'src/lib/wallet/**')).toBe(false); + expect(matchesCriticalPattern('/repo/src/lib/multisig.ts', 'src/lib/multisig*')).toBe(true); + expect(matchesCriticalPattern('/repo/src/components/Button.tsx', 'src/lib/stellar*')).toBe(false); + }); + + it('passes when critical modules meet the 90% boundary and fails in strict mode when below', () => { + const passingSummary = { + total: { + lines: { pct: 92 }, + functions: { pct: 90 }, + branches: { pct: 91 }, + statements: { pct: 92 }, + }, + '/workspace/src/lib/stellar.ts': { + lines: { pct: 90 }, + functions: { pct: 90 }, + branches: { pct: 90 }, + statements: { pct: 90 }, + }, + }; + + const passEval = evaluateCoverageSummary(passingSummary, coverageThresholds, { + strictCritical: true, + }); + expect(passEval.passed).toBe(true); + expect(passEval.fileViolations.filter((v) => v.pattern)).toHaveLength(0); + + const failingSummary = { + ...passingSummary, + '/workspace/src/lib/transactionBuilder.ts': { + lines: { pct: 89.5 }, + functions: { pct: 90 }, + branches: { pct: 82 }, + statements: { pct: 90 }, + }, + }; + + const failEval = evaluateCoverageSummary(failingSummary, coverageThresholds, { + strictCritical: true, + }); + expect(failEval.passed).toBe(false); + expect( + failEval.fileViolations.some( + (v) => v.file === 'src/lib/transactionBuilder.ts' && v.metric === 'branches' + ) + ).toBe(true); + + const markdown = formatCoverageMarkdownSummary(failEval); + expect(markdown).toContain('### Coverage Threshold Report'); + expect(markdown).toContain('`src/lib/transactionBuilder.ts`'); + }); +}); diff --git a/vitest.config.js b/vitest.config.js index 839d12d3..26515890 100644 --- a/vitest.config.js +++ b/vitest.config.js @@ -14,6 +14,36 @@ export const coverageThresholds = { 'src/utils/security.js': { lines: 55, functions: 40, branches: 45, statements: 55 }, 'src/lib/import.js': { lines: 70, functions: 70, branches: 60, statements: 70 }, }, + criticalPatterns: [ + { + pattern: 'src/lib/stellar*', + lines: 90, + branches: 90, + functions: 85, + statements: 90, + }, + { + pattern: 'src/lib/transactionBuilder.ts', + lines: 90, + branches: 90, + functions: 85, + statements: 90, + }, + { + pattern: 'src/lib/wallet/**', + lines: 90, + branches: 90, + functions: 85, + statements: 90, + }, + { + pattern: 'src/lib/multisig*', + lines: 90, + branches: 90, + functions: 85, + statements: 90, + }, + ], }; export default defineConfig({