|
|
|
@@ -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);
|
|
|
|
|
}
|