Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
252 changes: 210 additions & 42 deletions scripts/check-coverage.mjs
Original file line number Diff line number Diff line change
@@ -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}`);
Expand All @@ -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.');
}
78 changes: 78 additions & 0 deletions tests/unit/scripts/check-coverage.test.js
Original file line number Diff line number Diff line change
@@ -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`');
});
});
30 changes: 30 additions & 0 deletions vitest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down