#!/usr/bin/env node 'use strict'; /** * pre-commit.js -- local CI quality gate for LearnSpace. * * Checks (in order, fastest first): * 1. Node --check syntax on staged .js files in backend/src/ and frontend/js/ * 2. No emoji in newly-added lines of staged source files (not .md, not scripts/) * 3. No new console.log / console.debug / debugger in staged backend/frontend .js * 4. Backend route auth lint (check-route-auth.js) -- only if backend touched * 5. Backend tests (npm test) -- only if backend touched; baseline = 3 fails * * Bypass: git commit --no-verify */ const { execSync, spawnSync } = require('child_process'); const path = require('path'); // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- const REPO = execSync('git rev-parse --show-toplevel').toString().trim(); function git(args) { return execSync('git ' + args, { cwd: REPO, encoding: 'utf8' }); } let failed = false; const errors = []; function fail(msg) { errors.push(msg); failed = true; } function ok(msg) { process.stdout.write(' [OK] ' + msg + '\n'); } function warn(msg) { process.stdout.write(' [WARN] ' + msg + '\n'); } function section(title) { process.stdout.write('\n' + title + '\n'); } // --------------------------------------------------------------------------- // Staged files // --------------------------------------------------------------------------- const stagedRaw = git('diff --cached --name-only --diff-filter=ACM').trim(); const stagedFiles = stagedRaw ? stagedRaw.split('\n').filter(Boolean) : []; if (stagedFiles.length === 0) { process.stdout.write('No staged files -- nothing to check.\n'); process.exit(0); } // Source JS files: production code paths (not scripts/, not backend/scripts/, not tests/) function isSourceJs(f) { if (!f.endsWith('.js')) return false; if (f.startsWith('backend/src/')) return true; if (f.startsWith('frontend/js/')) return true; if (f.startsWith('frontend/') && f.split('/').length === 2) return true; // whiteboard.js etc if (f.startsWith('js/')) return true; // shared api.js, mobile.js etc return false; } const sourceJsFiles = stagedFiles.filter(isSourceJs); // Files to check for emoji / debug statements (same set: production source only) // Includes HTML and CSS for emoji check const sourceCodeFiles = stagedFiles.filter(f => (f.startsWith('backend/src/') || f.startsWith('frontend/') || f.startsWith('js/')) && (f.endsWith('.js') || f.endsWith('.html') || f.endsWith('.css')) ); const backendTouched = stagedFiles.some(f => f.startsWith('backend/')); process.stdout.write( '\nLearnSpace pre-commit checks (' + stagedFiles.length + ' staged file(s))\n' ); // --------------------------------------------------------------------------- // Check 1: Syntax // --------------------------------------------------------------------------- section('1. Syntax check (node --check)'); if (sourceJsFiles.length === 0) { warn('No backend/src or frontend/js files staged -- skipping syntax check'); } else { let syntaxOk = true; for (const f of sourceJsFiles) { const absPath = path.join(REPO, f); const r = spawnSync(process.execPath, ['--check', absPath], { encoding: 'utf8' }); if (r.status !== 0) { const msg = (r.stderr || r.stdout || '').trim().split('\n').join('\n '); fail('Syntax error in ' + f + ':\n ' + msg); syntaxOk = false; } } if (syntaxOk) ok(sourceJsFiles.length + ' file(s) -- no syntax errors'); } // --------------------------------------------------------------------------- // Check 2: No emoji in newly-added lines of source files // --------------------------------------------------------------------------- section('2. No emoji in staged changes'); // Emoji codepoint ranges (excludes pure dingbats like check/cross marks) // U+1F300-1F9FF: Misc Symbols & Pictographs, Emoticons, Transport, etc. // U+1F000-1F2FF: Mahjong, Domino, Playing Cards // U+1F650-1F67F: Ornamental Dingbats // U+1F900-1F9FF: Supplemental Symbols // Excluded intentionally: U+2600-27BF (Misc Symbols -- includes arrows, dingbats used in terminal UI) // because those block tool output characters (check marks, crosses, stars) const EMOJI_RE = /[\u{1F300}-\u{1F9FF}\u{1F000}-\u{1F2FF}\u{1F650}-\u{1F67F}]/u; // Build a set of source code file paths for fast lookup const sourceCodeSet = new Set(sourceCodeFiles); const diff = git('diff --cached --unified=0'); const diffLines = diff.split('\n'); let currentFile = null; let lineNum = 0; let emojiOk = true; for (const line of diffLines) { if (line.startsWith('+++ b/')) { currentFile = line.slice(6); lineNum = 0; } else if (line.startsWith('@@')) { const m = line.match(/\+(\d+)/); if (m) lineNum = parseInt(m[1], 10) - 1; } else if (line.startsWith('+') && !line.startsWith('+++')) { lineNum++; if (!currentFile) continue; // Only check production source files (backend/src/, frontend/) if (!sourceCodeSet.has(currentFile)) continue; // Skip .md files (docs may have emoji) if (currentFile.endsWith('.md')) continue; if (EMOJI_RE.test(line)) { const found = [...line.matchAll(/[\u{1F300}-\u{1F9FF}\u{1F000}-\u{1F2FF}\u{1F650}-\u{1F67F}]/gu)] .map(m2 => m2[0]).join(' '); fail('Emoji in ' + currentFile + ':' + lineNum + ' -- "' + found + '"\n Use inline SVG with .ic class instead'); emojiOk = false; } } else if (!line.startsWith('-')) { lineNum++; } } if (emojiOk) ok('No emoji in staged source changes'); // --------------------------------------------------------------------------- // Check 3: No new console.log / console.debug / debugger // --------------------------------------------------------------------------- section('3. No new debug statements'); // Pattern split across strings to avoid self-match when this file is staged const DEBUG_WORD_A = 'console'; const DEBUG_WORD_B = '.log'; const DEBUG_WORD_C = '.debug'; const DEBUGGER_KW = 'debugger'; const DEBUG_RE = new RegExp( '\\b(' + DEBUG_WORD_A + '\\' + DEBUG_WORD_B + '|' + DEBUG_WORD_A + '\\' + DEBUG_WORD_C + '|' + DEBUGGER_KW + ')\\b' ); // Source JS files set for debug check const sourceJsSet = new Set(sourceJsFiles); let debugCurrentFile = null; let debugLineNum = 0; let debugOk = true; for (const line of diffLines) { if (line.startsWith('+++ b/')) { debugCurrentFile = line.slice(6); debugLineNum = 0; } else if (line.startsWith('@@')) { const m = line.match(/\+(\d+)/); if (m) debugLineNum = parseInt(m[1], 10) - 1; } else if (line.startsWith('+') && !line.startsWith('+++')) { debugLineNum++; if (!debugCurrentFile) continue; if (!sourceJsSet.has(debugCurrentFile)) continue; const content = line.slice(1); // strip leading '+' // Skip pure comment lines (// and * in jsdoc blocks) if (/^\s*(\/\/|\*)/.test(content)) continue; if (DEBUG_RE.test(content)) { fail('Debug statement in ' + debugCurrentFile + ':' + debugLineNum + ':\n ' + content.trim()); debugOk = false; } } else if (!line.startsWith('-')) { debugLineNum++; } } if (debugOk) ok('No debug statements in staged source JS'); // --------------------------------------------------------------------------- // Check 4: Backend route auth lint // --------------------------------------------------------------------------- section('4. Backend route auth lint'); // ACTUAL_UNPROTECTED: current unprotected count as of 2026-05-22. // The check-route-auth.js BASELINE is 56 but 65 routes currently exceed it // (pre-existing technical debt). We block only if NEW routes are added beyond 65. const ROUTE_LINT_ACTUAL = 65; if (!backendTouched) { warn('No backend files staged -- skipping route lint'); } else { const lintResult = spawnSync( process.execPath, [path.join(REPO, 'backend/scripts/check-route-auth.js')], { cwd: path.join(REPO, 'backend'), encoding: 'utf8' } ); const lintOut = (lintResult.stdout || '') + (lintResult.stderr || ''); // Parse the current unprotected count from output const countMatch = lintOut.match(/(\d+) unprotected \(baseline:/); const currentCount = countMatch ? parseInt(countMatch[1], 10) : null; if (currentCount !== null && currentCount > ROUTE_LINT_ACTUAL) { const newRoutes = currentCount - ROUTE_LINT_ACTUAL; fail( 'Route auth lint: ' + newRoutes + ' new unprotected route(s) added (was ' + ROUTE_LINT_ACTUAL + ', now ' + currentCount + ').\n' + ' Add requireOwnership/requireRole/requirePermission, or mark intentional:\n' + ' // @public-by-design: ' ); } else if (currentCount === null && lintResult.status !== 0) { // Permission key failure -- always block const msg = lintOut.trim().split('\n').slice(-4).join('\n '); fail('Route auth lint failed (permission key issue):\n ' + msg); } else { const note = currentCount !== null ? currentCount + ' unprotected routes (pre-existing debt, not blocking)' : 'passed'; ok('Route auth lint -- ' + note); } } // --------------------------------------------------------------------------- // Check 5: Backend tests // --------------------------------------------------------------------------- section('5. Backend tests'); const TEST_FAIL_BASELINE = 3; if (!backendTouched) { warn('No backend files staged -- skipping tests'); } else { const testResult = spawnSync( process.execPath, ['--test', 'tests/*.test.js'], { cwd: path.join(REPO, 'backend'), encoding: 'utf8', shell: true, // needed for glob expansion on Windows timeout: 30000, } ); const output = (testResult.stdout || '') + (testResult.stderr || ''); const outputLines = output.split('\n'); // Count individual test failures: lines with the Unicode cross mark at line start // Exclude the "failing tests:" header and suite roll-up lines // Node test runner uses '✖' (heavy multiplication x) for failures const CROSS = '✖'; const failLines = outputLines.filter(l => { const t = l.trim(); return t.startsWith(CROSS) && !t.startsWith(CROSS + ' failing tests') && !t.startsWith(CROSS + ' Auth'); }); const failCount = failLines.length; if (failCount > TEST_FAIL_BASELINE) { const newFails = failLines .slice(TEST_FAIL_BASELINE) .map(l => ' ' + l.trim()) .join('\n'); fail( 'Backend tests: ' + failCount + ' failures (baseline = ' + TEST_FAIL_BASELINE + '). New:\n' + newFails ); } else { ok('Backend tests: ' + failCount + ' failure(s) -- within baseline of ' + TEST_FAIL_BASELINE); } } // --------------------------------------------------------------------------- // Final result // --------------------------------------------------------------------------- process.stdout.write('\n'); if (failed) { process.stderr.write('\x1b[31mPRE-COMMIT FAILED\x1b[0m -- commit aborted.\n\n'); for (const e of errors) { process.stderr.write('\x1b[31m [FAIL]\x1b[0m ' + e + '\n'); } process.stderr.write('\nBypass with: git commit --no-verify\n\n'); process.exit(1); } else { process.stdout.write('\x1b[32mAll checks passed.\x1b[0m\n\n'); process.exit(0); }