Files
metabuilder/tools/analyze-test-coverage.ts
2025-12-25 16:00:00 +00:00

333 lines
8.1 KiB
JavaScript

#!/usr/bin/env node
/**
* Function-to-Test Coverage Analyzer
*
* This script:
* 1. Scans all TypeScript/TSX source files for exported functions
* 2. Scans all test files for test cases
* 3. Maps functions to tests
* 4. Reports functions without tests
* 5. Generates a coverage report
*/
import * as fs from "fs";
import * as path from "path";
import { glob } from "glob";
interface FunctionDef {
name: string;
file: string;
type: "named" | "default" | "class-method";
line: number;
}
interface TestCase {
name: string;
file: string;
functions: string[];
line: number;
}
interface CoverageReport {
totalFunctions: number;
testedFunctions: number;
untested: FunctionDef[];
tested: Map<string, TestCase[]>;
coverage: number;
}
// Configuration
const CONFIG = {
srcPatterns: [
"src/**/*.ts",
"src/**/*.tsx",
"packages/**/src/**/*.ts",
"packages/**/src/**/*.tsx",
"dbal/ts/**/*.ts",
],
testPatterns: [
"src/**/*.test.ts",
"src/**/*.test.tsx",
"packages/**/tests/**/*.test.ts",
"packages/**/tests/**/*.test.tsx",
"dbal/**/*.test.ts",
],
ignorePatterns: [
"**/node_modules/**",
"**/.next/**",
"**/build/**",
"**/*.d.ts",
"**/dist/**",
],
};
// Extract function names from source code
function extractFunctions(content: string, file: string): FunctionDef[] {
const functions: FunctionDef[] = [];
const lines = content.split("\n");
lines.forEach((line, index) => {
const lineNum = index + 1;
// Named exports: export function name() or export const name = () =>
const namedFuncMatch = line.match(
/export\s+(?:function|const)\s+(\w+)\s*(?:\(|=)/
);
if (namedFuncMatch) {
functions.push({
name: namedFuncMatch[1],
file,
type: "named",
line: lineNum,
});
}
// Class methods: methodName() { or methodName = () => {
const classMethodMatch = line.match(/^\s+(\w+)\s*\(.*\)\s*[:{]/);
if (classMethodMatch && line.includes("class")) {
functions.push({
name: classMethodMatch[1],
file,
type: "class-method",
line: lineNum,
});
}
// Default exports
if (line.includes("export default") && line.includes("function")) {
const defaultMatch = line.match(/export\s+default\s+function\s+(\w+)/);
if (defaultMatch) {
functions.push({
name: defaultMatch[1],
file,
type: "default",
line: lineNum,
});
}
}
});
return functions;
}
// Extract test cases and mentioned functions
function extractTestCases(
content: string,
file: string
): Map<string, string[]> {
const testMap = new Map<string, string[]>();
const lines = content.split("\n");
let currentTestName = "";
lines.forEach((line) => {
// Detect test blocks: it(), test(), describe()
const testMatch = line.match(
/(?:it|test|describe)\s*\(\s*['"`]([^'"`]+)['"`]/
);
if (testMatch) {
currentTestName = testMatch[1];
testMap.set(currentTestName, []);
}
if (currentTestName) {
// Look for function calls within the test
const funcCalls = line.match(/\b([a-zA-Z_$][a-zA-Z0-9_$]*)\s*\(/g);
if (funcCalls) {
funcCalls.forEach((call) => {
const funcName = call.replace(/\s*\($/, "");
if (funcName && !isCommonTestHelper(funcName)) {
testMap.get(currentTestName)!.push(funcName);
}
});
}
}
});
return testMap;
}
// Filter out common test helpers
function isCommonTestHelper(name: string): boolean {
const helpers = [
"it",
"test",
"describe",
"expect",
"beforeEach",
"afterEach",
"beforeAll",
"afterAll",
"jest",
"vi",
"assert",
"eq",
"ok",
"throws",
"doesNotThrow",
"async",
"render",
"screen",
"fireEvent",
"userEvent",
"waitFor",
"within",
];
return helpers.includes(name);
}
// Main analysis function
async function analyze(): Promise<CoverageReport> {
const cwd = process.cwd();
// Get all source files
const srcFiles = await glob(CONFIG.srcPatterns, {
cwd,
ignore: CONFIG.ignorePatterns,
});
// Get all test files
const testFiles = await glob(CONFIG.testPatterns, {
cwd,
ignore: CONFIG.ignorePatterns,
});
// Extract functions from source files
const allFunctions: FunctionDef[] = [];
for (const file of srcFiles) {
try {
const content = fs.readFileSync(path.join(cwd, file), "utf-8");
const funcs = extractFunctions(content, file);
allFunctions.push(...funcs);
} catch (e) {
console.warn(`Error reading ${file}:`, (e as Error).message);
}
}
// Extract test cases and create mapping
const testMapping = new Map<string, TestCase[]>();
const testedFunctionNames = new Set<string>();
for (const file of testFiles) {
try {
const content = fs.readFileSync(path.join(cwd, file), "utf-8");
const testCases = extractTestCases(content, file);
testCases.forEach((functionNames, testName) => {
functionNames.forEach((funcName) => {
testedFunctionNames.add(funcName);
if (!testMapping.has(funcName)) {
testMapping.set(funcName, []);
}
testMapping.get(funcName)!.push({
name: testName,
file,
functions: functionNames,
line: 0,
});
});
});
} catch (e) {
console.warn(`Error reading ${file}:`, (e as Error).message);
}
}
// Identify untested functions
const untested = allFunctions.filter((f) => !testedFunctionNames.has(f.name));
// Calculate coverage
const coverage = (
((allFunctions.length - untested.length) / allFunctions.length) *
100
).toFixed(2);
return {
totalFunctions: allFunctions.length,
testedFunctions: allFunctions.length - untested.length,
untested,
tested: testMapping,
coverage: parseFloat(coverage),
};
}
// Generate report
async function generateReport() {
try {
const report = await analyze();
console.log("\n" + "=".repeat(70));
console.log("FUNCTION-TO-TEST COVERAGE ANALYSIS");
console.log("=".repeat(70));
console.log(`\nSummary:`);
console.log(` Total Functions: ${report.totalFunctions}`);
console.log(` Tested Functions: ${report.testedFunctions}`);
console.log(` Untested Functions: ${report.untested.length}`);
console.log(` Coverage: ${report.coverage}%`);
if (report.untested.length > 0) {
console.log(`\n${"─".repeat(70)}`);
console.log("UNTESTED FUNCTIONS:");
console.log(`${"─".repeat(70)}`);
// Group by file
const grouped = new Map<string, FunctionDef[]>();
report.untested.forEach((fn) => {
if (!grouped.has(fn.file)) {
grouped.set(fn.file, []);
}
grouped.get(fn.file)!.push(fn);
});
grouped.forEach((fns, file) => {
console.log(`\n📄 ${file}`);
fns.forEach((fn) => {
console.log(
` └─ ${fn.name} (${fn.type}) [line ${fn.line}]`
);
});
});
// Generate TODO items
console.log(`\n${"─".repeat(70)}`);
console.log("TODO - CREATE TESTS FOR:");
console.log(`${"─".repeat(70)}`);
report.untested.forEach((fn) => {
console.log(`- [ ] Write test for \`${fn.name}\` in ${fn.file}`);
});
}
console.log("\n" + "=".repeat(70) + "\n");
// Generate JSON report
const reportPath = path.join(process.cwd(), "coverage-report.json");
fs.writeFileSync(
reportPath,
JSON.stringify(
{
timestamp: new Date().toISOString(),
summary: {
totalFunctions: report.totalFunctions,
testedFunctions: report.testedFunctions,
untestedFunctions: report.untested.length,
coverage: report.coverage,
},
untested: report.untested,
},
null,
2
)
);
console.log(`✅ Report saved to: coverage-report.json`);
} catch (e) {
console.error("Error analyzing coverage:", e);
process.exit(1);
}
}
// Run the analysis
generateReport();