#!/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; 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 { const testMap = new Map(); 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 { 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(); const testedFunctionNames = new Set(); 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(); 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();