feat(dx): pre-commit hooks for local CI (no remote needed)

Local quality gate that runs on every git commit:
- node --check syntax on staged .js files
- block on new emoji in staged .js/.html/.css (md files allowed)
- block on new console.log/debug/debugger statements
- backend route auth lint (existing npm run lint:routes)
- backend tests (baseline 3 fails, block if grew)

Install: npm run hooks:install (top-level)
Bypass: git commit --no-verify

Skips slow checks when irrelevant files changed (tests/lint only run
if backend touched).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Maxim Dolgolyov
2026-05-22 21:49:58 +03:00
parent 3135402dd7
commit 696049271f
5 changed files with 371 additions and 1 deletions
+2 -1
View File
@@ -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",
+9
View File
@@ -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"
}
}
+17
View File
@@ -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
+22
View File
@@ -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"
+321
View File
@@ -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: <reason>'
);
} 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);
}