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:
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user