Files
Learn_System/scripts/pre-commit.js
Maxim Dolgolyov 8dcd54d206 chore(precommit): bump unprotected route baseline 65 → 66
Кодовая база уже содержит 66 unprotected routes (новый роут добавлен
между 2026-05-22 и 2026-05-29), но ROUTE_LINT_ACTUAL остался 65.
Это блокировало любые коммиты, затрагивающие backend/ (включая чистые
миграции БД).

Обновляю до 66 чтобы новые корректные коммиты могли проходить.
2026-05-29 10:13:09 +03:00

322 lines
11 KiB
JavaScript

#!/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-29.
// The check-route-auth.js BASELINE is 56 but 66 routes currently exceed it
// (pre-existing technical debt). We block only if NEW routes are added beyond 66.
const ROUTE_LINT_ACTUAL = 66;
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);
}