diff --git a/backend/package.json b/backend/package.json index 198509e..036948e 100644 --- a/backend/package.json +++ b/backend/package.json @@ -13,7 +13,8 @@ "seed:permissions": "node src/db/seed-permissions.js", "lint:routes": "node scripts/check-route-auth.js", "import:content": "node scripts/import-content.js", - "test": "node --test tests/*.test.js" + "test": "node --test tests/*.test.js", + "hooks:install": "sh ../scripts/install-hooks.sh" }, "dependencies": { "bcryptjs": "^2.4.3", diff --git a/package.json b/package.json new file mode 100644 index 0000000..7499b12 --- /dev/null +++ b/package.json @@ -0,0 +1,9 @@ +{ + "name": "learnspace", + "version": "1.0.0", + "private": true, + "description": "LearnSpace monorepo root — convenience scripts only", + "scripts": { + "hooks:install": "sh scripts/install-hooks.sh" + } +} diff --git a/scripts/install-hooks.cmd b/scripts/install-hooks.cmd new file mode 100644 index 0000000..7c0f166 --- /dev/null +++ b/scripts/install-hooks.cmd @@ -0,0 +1,17 @@ +@echo off +rem install-hooks.cmd -- installs the LearnSpace pre-commit hook on Windows +rem Usage: scripts\install-hooks.cmd +rem Or via npm: npm run hooks:install + +for /f "tokens=*" %%i in ('git rev-parse --show-toplevel') do set REPO=%%i +set HOOK=%REPO%\.git\hooks\pre-commit + +( +echo #!/bin/sh +echo # LearnSpace pre-commit hook ^(auto-generated -- edit scripts/pre-commit.js^) +echo REPO="$(git rev-parse --show-toplevel)" +echo node "$REPO/scripts/pre-commit.js" ^|^| exit 1 +) > "%HOOK%" + +echo Pre-commit hook installed at: %HOOK% +echo Bypass anytime with: git commit --no-verify diff --git a/scripts/install-hooks.sh b/scripts/install-hooks.sh new file mode 100644 index 0000000..5ce907c --- /dev/null +++ b/scripts/install-hooks.sh @@ -0,0 +1,22 @@ +#!/bin/sh +# install-hooks.sh — installs the LearnSpace pre-commit hook into .git/hooks/ +# Usage: sh scripts/install-hooks.sh +# Or via npm: npm run hooks:install + +set -e + +REPO_ROOT="$(git rev-parse --show-toplevel)" +HOOKS_DIR="$REPO_ROOT/.git/hooks" +HOOK_FILE="$HOOKS_DIR/pre-commit" + +cat > "$HOOK_FILE" << 'HOOK' +#!/bin/sh +# LearnSpace pre-commit hook (auto-generated — edit scripts/pre-commit.js) +REPO="$(git rev-parse --show-toplevel)" +node "$REPO/scripts/pre-commit.js" || exit 1 +HOOK + +chmod +x "$HOOK_FILE" + +echo "Pre-commit hook installed at: $HOOK_FILE" +echo "Bypass anytime with: git commit --no-verify" diff --git a/scripts/pre-commit.js b/scripts/pre-commit.js new file mode 100644 index 0000000..d535edb --- /dev/null +++ b/scripts/pre-commit.js @@ -0,0 +1,321 @@ +#!/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); +}