#!/usr/bin/env node /** * ObsiGate — Frontend import/export validator. * Runs in CI to catch missing exports, broken imports, and syntax errors. * Usage: node tests/frontend/validate-imports.mjs */ import { readFileSync, readdirSync } from 'fs'; import { join, dirname } from 'path'; import { fileURLToPath } from 'url'; const __dirname = dirname(fileURLToPath(import.meta.url)); const JS_DIR = join(__dirname, '../../frontend/js'); // ── Step 1: Collect all exports from every module ────────────────────────── const allExports = {}; function collectExports(filePath, modName) { const content = readFileSync(filePath, 'utf-8'); const lines = content.split('\n'); const exports = new Set(); // Handle multi-line export { ... } blocks let inExportBlock = false; let exportBlockText = ''; for (const line of lines) { // Start of multi-line export block if (/^export\s*\{/.test(line) && !/\}/.test(line)) { inExportBlock = true; exportBlockText = line; continue; } if (inExportBlock) { exportBlockText += ' ' + line.trim(); if (/\}/.test(line)) { inExportBlock = false; const m = exportBlockText.match(/^export\s*\{([^}]+)\}/); if (m) { for (const name of m[1].split(',')) { const n = name.trim().replace(/\s+as\s+\w+.*/, '').trim(); if (n && n !== '') exports.add(n); } } exportBlockText = ''; } continue; } // export const/let/function/async function X let m = line.match(/^export\s+(?:const|let|function|async\s+function|class)\s+(\w+)/); if (m) exports.add(m[1]); // Single-line export { a, b } m = line.match(/^export\s*\{([^}]+)\}/); if (m) { for (const name of m[1].split(',')) { const n = name.trim().replace(/\s+as\s+.*/, ''); if (n) exports.add(n); } } // export { a } from './x.js' m = line.match(/^export\s*\{([^}]+)\}\s*from\s*['"]\.\/(\w+\.js)['"]/); if (m) { const source = m[2]; for (const name of m[1].split(',')) { const n = name.trim().replace(/\s+as\s+.*/, '').trim(); if (n) exports.add(n); } // Re-exports: verify source actually exports these if (allExports[source]) { for (const name of m[1].split(',')) { const n = name.trim().replace(/\s+as\s+.*/, '').trim(); if (n && !allExports[source].has(n)) { errors.push(`${modName}: re-exports '${n}' but ${source} doesn't export it`); } } } } } allExports[modName] = exports; } // ── Step 2: Check all imports resolve to actual exports ──────────────────── const errors = []; function checkImports(filePath, modName) { const content = readFileSync(filePath, 'utf-8'); const lines = content.split('\n'); for (const line of lines) { // import { a, b } from './source.js' let m = line.match(/^import\s*\{([^}]+)\}\s*from\s*['"]\.\/(\w+\.js)['"]/); if (m) { const source = m[2]; const names = m[1].split(',').map(n => n.trim().split(/\s+as\s+/)[0].trim()); if (!allExports[source]) { errors.push(`${modName}: imports from ${source} but couldn't read that file`); continue; } for (const name of names) { if (!allExports[source].has(name)) { errors.push(`${modName}: imports '${name}' from ${source} but it's NOT exported`); } } } // import * as NS from './source.js' m = line.match(/^import\s+\*\s+as\s+(\w+)\s+from\s*['"]\.\/(\w+\.js)['"]/); if (m) { const ns = m[1]; const source = m[2]; if (!allExports[source]) { errors.push(`${modName}: namespace imports from ${source} but couldn't read that file`); continue; } // Check all NS.xxx usages in this file const nsUsage = new RegExp(`\\b${ns}\\.(\\w+)`, 'g'); let usageMatch; while ((usageMatch = nsUsage.exec(content)) !== null) { const accessed = usageMatch[1]; if (!allExports[source].has(accessed)) { errors.push(`${modName}: uses ${ns}.${accessed} but ${source} doesn't export '${accessed}'`); } } } } } // ── Main ─────────────────────────────────────────────────────────────────── const files = readdirSync(JS_DIR).filter(f => f.endsWith('.js') && f !== 'sidebar_raw.js'); // Phase 1: collect all exports for (const file of files) { collectExports(join(JS_DIR, file), file); } // Phase 2: verify all imports for (const file of files) { checkImports(join(JS_DIR, file), file); } // ── Step 3: Check for const reassignment patterns ────────────────────────── for (const file of files) { const content = readFileSync(join(JS_DIR, file), 'utf-8'); const lines = content.split('\n'); // Find all imported names const importedNames = new Set(); for (const line of lines) { const m = line.match(/^import\s*\{([^}]+)\}\s*from/); if (m) { for (const name of m[1].split(',')) { importedNames.add(name.trim().split(/\s+as\s+/)[0].trim()); } } } // Check if any imported name is reassigned for (const line of lines) { for (const name of importedNames) { // Check for: name = (not ===, !==, ==, !=) if (new RegExp(`^\\s*${name}\\s*=[^=]`).test(line) || new RegExp(`^\\s*${name}\\s*=\\s*function`).test(line) || new RegExp(`^\\s*${name}\\s*=\\s*async`).test(line) || new RegExp(`^\\s*${name}\\s*=\\s*\\{`).test(line) || new RegExp(`^\\s*${name}\\s*=\\s*\\[`).test(line)) { errors.push(`${file}:${lines.indexOf(line) + 1} — imported '${name}' is reassigned (const violation)`); } } } } // ── Report ───────────────────────────────────────────────────────────────── if (errors.length > 0) { console.error(`\n❌ ${errors.length} frontend validation error(s):\n`); for (const err of errors) { console.error(` • ${err}`); } console.error(''); process.exit(1); } else { console.log(`✅ All ${files.length} modules validated — ${Object.values(allExports).reduce((s, e) => s + e.size, 0)} exports, 0 errors`); process.exit(0); }