From a1d7f55772fd93b710704597731a0fe37108eb53 Mon Sep 17 00:00:00 2001 From: Christian Hood Date: Fri, 20 Mar 2026 03:08:20 -0400 Subject: [PATCH] Add metrics instrumentation coverage analyzer script Scans server/src/routes/*.js with Node.js built-ins to count route handlers and measure console.* logging coverage per file and in aggregate. Supports JSON (default) and --format=text table output. Nightshift-Task: metrics-coverage Nightshift-Ref: https://github.com/marcus/nightshift --- scripts/metrics-coverage.js | 251 ++++++++++++++++++++++++++++++++++++ 1 file changed, 251 insertions(+) create mode 100644 scripts/metrics-coverage.js diff --git a/scripts/metrics-coverage.js b/scripts/metrics-coverage.js new file mode 100644 index 0000000..b2604f5 --- /dev/null +++ b/scripts/metrics-coverage.js @@ -0,0 +1,251 @@ +#!/usr/bin/env node +/** + * metrics-coverage.js — Static analysis script for metrics/logging instrumentation coverage. + * + * Scans all Express route files in server/src/routes/*.js and app.js to measure + * how many route handlers contain logging calls (console.error/console.warn/console.log). + * + * Usage: + * node scripts/metrics-coverage.js # JSON output (default) + * node scripts/metrics-coverage.js --format=text # Human-readable table + * + * Sample output (captured 2026-03-20): + * { + * "files": [ + * { "file": "actuals.js", "total": 5, "logged": 5, "unlogged": 0, "coverage": 100 }, + * { "file": "bills.js", "total": 6, "logged": 6, "unlogged": 0, "coverage": 100 }, + * { "file": "config.js", "total": 2, "logged": 2, "unlogged": 0, "coverage": 100 }, + * { "file": "financing.js", "total": 6, "logged": 6, "unlogged": 0, "coverage": 100 }, + * { "file": "health.js", "total": 1, "logged": 0, "unlogged": 1, "coverage": 0 }, + * { "file": "one-time-expenses.js", "total": 3, "logged": 3, "unlogged": 0, "coverage": 100 }, + * { "file": "paychecks.js", "total": 6, "logged": 6, "unlogged": 0, "coverage": 100 }, + * { "file": "summary.js", "total": 2, "logged": 2, "unlogged": 0, "coverage": 100 } + * ], + * "app": { + * "has_request_timing_middleware": false, + * "has_error_handling_middleware": false, + * "middleware_count": 11 + * }, + * "aggregate": { + * "total_handlers": 31, + * "logged_handlers": 30, + * "unlogged_handlers": 1, + * "coverage_pct": 96.77 + * } + * } + */ + +'use strict'; + +const fs = require('fs'); +const path = require('path'); + +const ROUTES_DIR = path.resolve(__dirname, '../server/src/routes'); +const APP_FILE = path.resolve(__dirname, '../server/src/app.js'); + +// Regex patterns for route handler definitions. +// Matches: router.get/post/put/patch/delete( and app.get/post/put/patch/delete( +const ROUTE_DEF_RE = /\b(?:router|app)\.(get|post|put|patch|delete)\s*\(/g; + +// Logging call patterns +const LOG_RE = /\bconsole\.(error|warn|log)\s*\(/; + +/** + * Extract individual route handler bodies from source. + * Strategy: find each route definition, then walk forward counting + * braces to find the closing of the outermost async/function callback. + */ +function extractHandlerBodies(src) { + const handlers = []; + let match; + ROUTE_DEF_RE.lastIndex = 0; + + while ((match = ROUTE_DEF_RE.exec(src)) !== null) { + const startIdx = match.index; + // Find the opening paren of the route call + const parenOpen = src.indexOf('(', startIdx); + if (parenOpen === -1) continue; + + // Walk from the paren open, tracking paren depth to find the matching close. + // The handler callback body will be inside the outer parens. + let depth = 0; + let bodyStart = -1; + let bodyEnd = -1; + let inString = false; + let stringChar = ''; + let i = parenOpen; + + while (i < src.length) { + const ch = src[i]; + + // Basic string tracking (skip contents of string literals) + if (!inString && (ch === '"' || ch === "'" || ch === '`')) { + inString = true; + stringChar = ch; + i++; + continue; + } + if (inString) { + if (ch === '\\') { i += 2; continue; } // skip escape + if (ch === stringChar) inString = false; + i++; + continue; + } + + if (ch === '(') { + depth++; + if (depth === 1) { + // This is the opening of the route call args + } + } else if (ch === ')') { + depth--; + if (depth === 0) { + bodyEnd = i; + break; + } + } else if (ch === '{' && depth >= 1 && bodyStart === -1) { + // First brace inside the outer parens — start of the handler body + bodyStart = i; + } + + i++; + } + + if (bodyStart !== -1 && bodyEnd !== -1) { + handlers.push(src.slice(bodyStart, bodyEnd)); + } + } + + return handlers; +} + +/** + * Analyse a single route file. + */ +function analyseRouteFile(filePath) { + const src = fs.readFileSync(filePath, 'utf8'); + const handlers = extractHandlerBodies(src); + + const logged = handlers.filter(body => LOG_RE.test(body)); + + return { + file: path.basename(filePath), + total: handlers.length, + logged: logged.length, + unlogged: handlers.length - logged.length, + coverage: handlers.length === 0 + ? null + : Math.round((logged.length / handlers.length) * 10000) / 100, + }; +} + +/** + * Analyse app.js for middleware-level instrumentation. + */ +function analyseApp(filePath) { + const src = fs.readFileSync(filePath, 'utf8'); + + // Request timing: morgan, custom middleware checking req.method, Date.now() at top-level use() + const hasRequestTiming = + /\brequire\s*\(\s*['"]morgan['"]\s*\)/.test(src) || + /app\.use\s*\(.*Date\.now\(\)/.test(src) || + /app\.use\s*\(.*req,\s*res,\s*next/.test(src) && /Date\.now|performance\.now/.test(src); + + // Error handling middleware: app.use((err, req, res, next) => ...) + const hasErrorHandling = /app\.use\s*\(\s*(?:\S+\s*,\s*)?\(\s*err\s*,/.test(src); + + // Count top-level app.use() calls (middleware registrations) + const middlewareMatches = src.match(/app\.use\s*\(/g) || []; + + return { + has_request_timing_middleware: hasRequestTiming, + has_error_handling_middleware: hasErrorHandling, + middleware_count: middlewareMatches.length, + }; +} + +function run() { + const format = process.argv.includes('--format=text') ? 'text' : 'json'; + + // Analyse all route files + const routeFiles = fs.readdirSync(ROUTES_DIR) + .filter(f => f.endsWith('.js')) + .sort(); + + const fileResults = routeFiles.map(f => + analyseRouteFile(path.join(ROUTES_DIR, f)) + ); + + // Aggregate + const totalHandlers = fileResults.reduce((s, r) => s + r.total, 0); + const loggedHandlers = fileResults.reduce((s, r) => s + r.logged, 0); + + const aggregate = { + total_handlers: totalHandlers, + logged_handlers: loggedHandlers, + unlogged_handlers: totalHandlers - loggedHandlers, + coverage_pct: totalHandlers === 0 + ? null + : Math.round((loggedHandlers / totalHandlers) * 10000) / 100, + }; + + const appInfo = analyseApp(APP_FILE); + + const result = { + files: fileResults, + app: appInfo, + aggregate, + }; + + if (format === 'json') { + console.log(JSON.stringify(result, null, 2)); + return; + } + + // Text table + const COL_FILE = 28; + const COL_TOTAL = 7; + const COL_LOGGED = 8; + const COL_COVER = 10; + + const pad = (s, n) => String(s).padEnd(n); + const lpad = (s, n) => String(s).padStart(n); + + const hr = '-'.repeat(COL_FILE + COL_TOTAL + COL_LOGGED + COL_COVER + 6); + + console.log('\nMetrics Instrumentation Coverage\n'); + console.log( + pad('Route File', COL_FILE) + + lpad('Handlers', COL_TOTAL) + + lpad('Logged', COL_LOGGED) + + lpad('Coverage', COL_COVER) + ); + console.log(hr); + + for (const r of fileResults) { + const cov = r.coverage === null ? 'N/A' : `${r.coverage}%`; + console.log( + pad(r.file, COL_FILE) + + lpad(r.total, COL_TOTAL) + + lpad(r.logged, COL_LOGGED) + + lpad(cov, COL_COVER) + ); + } + + console.log(hr); + const aggCov = aggregate.coverage_pct === null ? 'N/A' : `${aggregate.coverage_pct}%`; + console.log( + pad('TOTAL', COL_FILE) + + lpad(aggregate.total_handlers, COL_TOTAL) + + lpad(aggregate.logged_handlers, COL_LOGGED) + + lpad(aggCov, COL_COVER) + ); + + console.log('\napp.js middleware:'); + console.log(` Request timing middleware : ${appInfo.has_request_timing_middleware}`); + console.log(` Error handling middleware : ${appInfo.has_error_handling_middleware}`); + console.log(` app.use() call count : ${appInfo.middleware_count}`); + console.log(''); +} + +run();