#!/usr/bin/env node /** * ObsiGate — Comprehensive frontend validator. * Scans ALL modules and catches: * 1. Function calls without import or local definition * 2. State variables used without state. prefix * 3. Imported functions that don't exist in source module * 4. Const reassignments * Usage: node tests/frontend/audit.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'); let errors = []; let warnings = []; // ═══════════════════════════════════════════════════════════════════════════ // Browser globals allowed without import // ═══════════════════════════════════════════════════════════════════════════ const BROWSER_GLOBALS = new Set([ 'console','document','window','fetch','localStorage','sessionStorage','navigator','location','history', 'setTimeout','clearTimeout','setInterval','clearInterval','requestAnimationFrame','cancelAnimationFrame', 'addEventListener','removeEventListener','getElementById','querySelector','querySelectorAll', 'getComputedStyle','getBoundingClientRect','createElement','createElementNS','createTextNode', 'createDocumentFragment','appendChild','removeChild','insertBefore','replaceChild','cloneNode', 'setAttribute','getAttribute','removeAttribute','hasAttribute','classList','parentElement','parentNode', 'children','childNodes','firstChild','lastChild','nextSibling','previousSibling','textContent','innerHTML', 'style','className','value','checked','focus','blur','click','preventDefault','stopPropagation', 'JSON','Math','Date','Object','Array','Map','Set','WeakMap','Promise','Symbol','RegExp','Error', 'parseInt','parseFloat','isNaN','isFinite','encodeURIComponent','decodeURIComponent','atob','btoa', 'String','Number','Boolean','Intl','devicePixelRatio','URLSearchParams','FormData','Blob','FileReader', 'Image','EventSource','WebSocket','Worker','ServiceWorker','Notification','Headers','Request','Response', 'MutationObserver','IntersectionObserver','ResizeObserver','DOMParser','CustomEvent','Event', 'KeyboardEvent','MouseEvent','WheelEvent','FocusEvent','InputEvent','ClipboardEvent','TouchEvent', 'requestFullscreen','exitFullscreen','fullscreenElement','crypto','performance','alert','confirm', 'scrollTo','scrollBy','scrollIntoView','getContext','toDataURL','normalize','dispatchEvent', 'execCommand','closest','contains','toggle','replaceChildren','remove','before','after','TextDecoder', 'TextEncoder','Uint8Array','ArrayBuffer','File','FileList','DataTransfer','matchMedia', 'structuredClone','queueMicrotask','reportError', // lucide icons 'lucide','createIcons', // markdown rendering 'marked','hljs','frontmatter','mistune', // Canvas drawing 'beginPath','closePath','moveTo','lineTo','arc','fill','stroke','fillStyle','strokeStyle', 'lineWidth','font','textAlign','fillText','clearRect','measureText','setLineDash','shadowColor', 'shadowBlur','setTransform','save','restore','translate','scale','rotate','roundRect', 'getPropertyValue', ]); // ═══════════════════════════════════════════════════════════════════════════ // Phase 1: Parse all modules — collect exports, imports, local definitions // ═══════════════════════════════════════════════════════════════════════════ const modules = {}; const allExports = {}; function parseModule(fileName) { const content = readFileSync(join(JS_DIR, fileName), 'utf-8'); const lines = content.split('\n'); const info = { imports: [], // [{ name, from }] exports: new Set(), localDefs: new Set(), // function names, const names defined in this file localVars: new Set(), // let/var names content, lines, }; let inExportBlock = false; let exportBlockText = ''; let inMultiLineImport = false; let importBlockText = ''; for (let i = 0; i < lines.length; i++) { const line = lines[i]; const trimmed = line.trim(); // ─ Multi-line export { ... } ─ if (/^export\s*\{/.test(trimmed) && !/\}/.test(trimmed)) { inExportBlock = true; exportBlockText = trimmed; continue; } if (inExportBlock) { exportBlockText += ' ' + trimmed; if (/\}/.test(trimmed)) { inExportBlock = false; const m = exportBlockText.match(/\{([^}]+)\}/); if (m) { for (const name of m[1].split(',')) { const n = name.trim().replace(/\s+as\s+.*/, '').trim(); if (n) info.exports.add(n); } } } continue; } // ─ Multi-line import { ... } ─ if (/^import\s*\{/.test(trimmed) && !/from/.test(trimmed)) { inMultiLineImport = true; importBlockText = trimmed; continue; } if (inMultiLineImport) { importBlockText += ' ' + trimmed; if (/from/.test(trimmed)) { inMultiLineImport = false; processImportLine(importBlockText, info, fileName); } continue; } // ─ export const/let/function/async function X ─ let m = trimmed.match(/^export\s+(?:const|let|function|async\s+function|class)\s+(\w+)/); if (m) { info.exports.add(m[1]); info.localDefs.add(m[1]); continue; } // ─ Single-line export { a, b } ─ m = trimmed.match(/^export\s*\{([^}]+)\}/); if (m) { for (const name of m[1].split(',')) { const n = name.trim().replace(/\s+as\s+.*/, '').trim(); if (n) info.exports.add(n); } continue; } // ─ export { a } from './x.js' ─ m = trimmed.match(/^export\s*\{([^}]+)\}\s*from\s*['"]\.\/(\w+\.js)['"]/); if (m) { for (const name of m[1].split(',')) { const n = name.trim().replace(/\s+as\s+.*/, '').trim(); if (n) info.exports.add(n); } continue; } // ─ import { a, b } from './x.js' ─ m = trimmed.match(/^import\s*\{([^}]+)\}\s*from\s*['"]\.\/(\w+\.js)['"]/); if (m) processImportLine(trimmed, info, fileName); // ─ import * as NS from './x.js' ─ m = trimmed.match(/^import\s+\*\s+as\s+(\w+)\s+from\s*['"]\.\/(\w+\.js)['"]/); if (m) { info.imports.push({ ns: m[1], from: m[2] }); } // ─ function / async function X ─ m = trimmed.match(/^(?:async\s+)?function\s+(\w+)/); if (m && !trimmed.startsWith('//') && !trimmed.startsWith('*')) { info.localDefs.add(m[1]); } // ─ Object method definitions: methodName() { or methodName: function ─ m = trimmed.match(/^\s+(\w+)\s*\([^)]*\)\s*\{/); if (m && !trimmed.startsWith('//') && !trimmed.startsWith('*')) { info.localDefs.add(m[1]); } m = trimmed.match(/^\s+(\w+)\s*:\s*function/); if (m && !trimmed.startsWith('//') && !trimmed.startsWith('*')) { info.localDefs.add(m[1]); } m = trimmed.match(/^\s+(\w+)\s*:\s*async\s+function/); if (m && !trimmed.startsWith('//') && !trimmed.startsWith('*')) { info.localDefs.add(m[1]); } m = trimmed.match(/^\s+(\w+)\s*:\s*\([^)]*\)\s*=>/); if (m && !trimmed.startsWith('//') && !trimmed.startsWith('*')) { info.localDefs.add(m[1]); } // ─ const X = ─ m = trimmed.match(/^const\s+(\w+)\s*=/); if (m) { info.localDefs.add(m[1]); info.localVars.add(m[1]); } // ─ let X = ─ m = trimmed.match(/^let\s+(\w+)\s*=/); if (m) { info.localVars.add(m[1]); } } allExports[fileName] = info.exports; modules[fileName] = info; } function processImportLine(line, info, fileName) { const m = line.match(/^import\s*\{([^}]+)\}\s*from\s*['"]\.\/(\w+\.js)['"]/); if (!m) return; const source = m[2]; for (const name of m[1].split(',')) { const parts = name.trim().split(/\s+as\s+/); const importName = parts[0].trim(); const localName = parts.length > 1 ? parts[1].trim() : importName; info.imports.push({ name: localName, from: source }); info.localDefs.add(localName); } } // ═══════════════════════════════════════════════════════════════════════════ // Phase 2: Validate imports exist in source // ═══════════════════════════════════════════════════════════════════════════ function validateImports() { for (const [modName, info] of Object.entries(modules)) { for (const imp of info.imports) { if (imp.ns) { // Namespace import — check accessed properties const nsRegex = new RegExp(`\\b${imp.ns}\\.(\\w+)`, 'g'); let m; while ((m = nsRegex.exec(info.content)) !== null) { const accessed = m[1]; if (!allExports[imp.from]?.has(accessed)) { if (!BROWSER_GLOBALS.has(accessed) && !info.localDefs.has(accessed)) { errors.push(`${modName}: uses ${imp.ns}.${accessed} but ${imp.from} doesn't export '${accessed}'`); } } } } else if (imp.name && imp.from) { if (!allExports[imp.from]?.has(imp.name)) { // Check if it's a re-export from another file let found = false; for (const [otherMod, otherExports] of Object.entries(allExports)) { if (otherExports.has(imp.name)) found = true; } if (!found) { errors.push(`${modName}: imports '${imp.name}' from ${imp.from} but it's NOT exported there`); } } } } } } // ═══════════════════════════════════════════════════════════════════════════ // JavaScript keywords that look like function calls but aren't // ═══════════════════════════════════════════════════════════════════════════ const JS_KEYWORDS = new Set([ 'if','else','for','while','do','switch','case','default','break','continue', 'return','throw','try','catch','finally','async','await','function','var', 'let','const','class','extends','super','new','delete','typeof','instanceof', 'void','yield','import','export','in','of','this','debugger','with', 'true','false','null','undefined','NaN','Infinity', // French/English words that appear in comments/strings 'lien','entrant','fichier','passe','vue','function','var','widgets','unsupported', 'management','directory','directories','dot','container','storage','options', 'connect','interface','header','js','forces','bg','border','accent','muted','text', 'all','add','get','set','has','keys','values','entries','some','apply','update', 'load','show','hide','render','open','close','init','destroy','toggle', 'then','catch','finally','parse','stringify','log','warn','error','debug', 'now','random','max','min','abs','round','floor','ceil','sqrt','pow', 'cos','sin','log','exp','toString','toFixed','toISOString','toLocaleString', 'toLocaleDateString','toLocaleTimeString','toUpperCase','toLowerCase', 'search','test','exec','match','replace','split','join','trim','startsWith', 'endsWith','includes','indexOf','charAt','substring','slice','sort','reverse', 'map','filter','reduce','forEach','find','findIndex','push','pop','shift', 'unshift','splice','concat','every','some','flat','flatMap', 'getItem','setItem','removeItem','addEventListener','removeEventListener', 'dispatchEvent','querySelector','querySelectorAll','getElementById', 'getElementsByClassName','getElementsByTagName','createElement', 'createTextNode','appendChild','removeChild','insertBefore','replaceChild', 'setAttribute','getAttribute','removeAttribute','classList', 'getBoundingClientRect','getComputedStyle','requestAnimationFrame', 'parseInt','parseFloat','isNaN','isFinite', 'disconnect','observe','unobserve','compareDocumentPosition', 'preventDefault','stopPropagation','stopImmediatePropagation', 'writeText','execCommand','createIcons','getContext','toDataURL', 'setTransform','beginPath','closePath','moveTo','lineTo','arc','fill','stroke', 'save','restore','translate','scale','rotate','clearRect','fillText', 'setLineDash','measureText','getPropertyValue','focus','blur','click', 'setTimeout','clearTimeout','setInterval','clearInterval', 'encodeURIComponent','decodeURIComponent', 'getRecentEvents','getState','getConfig','getMenuData','getVaultIcon', 'patternToRegex','isTagFiltered','fmtSize','localCompare', 'fromCharCode','codePointAt','normalize','padStart','padEnd','repeat', 'createRange','surroundContents','getClientRects', 'switch','declare','module','require', ]); // ═══════════════════════════════════════════════════════════════════════════ // Phase 3: Find function calls and variable refs without import/definition // ═══════════════════════════════════════════════════════════════════════════ // State variable names that MUST use state. prefix const STATE_VARS = new Set([ 'APP_VERSION','currentVault','currentPath','allVaults','selectedContextVault', 'searchTimeout','searchAbortController','advancedSearchOffset','advancedSearchTotal', 'advancedSearchSort','advancedSearchLastQuery','suggestAbortController', 'dropdownActiveIndex','dropdownItems','currentSearchId','selectedTags', 'searchCaseSensitive','searchWholeWord','searchRegex','searchFilterVisible', 'SEARCH_HISTORY_KEY','MAX_HISTORY_ENTRIES','SUGGEST_DEBOUNCE_MS', 'ADVANCED_SEARCH_LIMIT','MIN_SEARCH_LENGTH','SEARCH_TIMEOUT_MS', 'showingSource','cachedRawSource','editorView','editorVault','editorPath', 'fallbackEditorEl','_iconDebounceTimer','outlineObserver','activeHeadingId', 'headingsCache','rightSidebarVisible','rightSidebarWidth', 'sidebarFilterCaseSensitive','activeSidebarTab','filterDebounce','vaultSettings', ]); function findUndefinedRefs(modName) { const info = modules[modName]; if (!info) return; // Build "available" set: imported names + locally defined + browser globals const available = new Set([...info.localDefs, ...BROWSER_GLOBALS]); // Also add imported namespace prefixes for (const imp of info.imports) { if (imp.ns) available.add(imp.ns); } // Scan for standalone identifiers used as function calls or variable refs const content = info.content; // Find all identifiers that look like function calls or property access // Pattern: word( const callRegex = /\b([a-zA-Z_$][\w$]*)\s*\(/g; let m; const checkedLines = new Set(); while ((m = callRegex.exec(content)) !== null) { const name = m[1]; if (available.has(name)) continue; if (JS_KEYWORDS.has(name)) continue; if (name[0] === name[0].toUpperCase() && name[0] !== '_') continue; // skip classes/types // Get the line for context const lineNum = content.substring(0, m.index).split('\n').length; if (checkedLines.has(`${name}:${lineNum}`)) continue; checkedLines.add(`${name}:${lineNum}`); // Skip if it's a method call like .name( const before = content.substring(Math.max(0, m.index - 1), m.index); if (before === '.') continue; // Skip if it's inside a string or comment const lineStart = content.lastIndexOf('\n', m.index) + 1; const lineEnd = content.indexOf('\n', m.index); const line = content.substring(lineStart, lineEnd > 0 ? lineEnd : content.length); if (line.trim().startsWith('//') || line.trim().startsWith('*')) continue; errors.push(`${modName}:${lineNum} — '${name}()' called but not imported or defined`); } // Also check for bare state variable references (without state. prefix) for (const varName of STATE_VARS) { const bareRegex = new RegExp(`(? f.endsWith('.js') && f !== 'sidebar_raw.js'); for (const file of files) { parseModule(file); } validateImports(); for (const file of files) { findUndefinedRefs(file); } // ═══════════════════════════════════════════════════════════════════════════ // Report // ═══════════════════════════════════════════════════════════════════════════ if (errors.length > 0) { console.error(`\n❌ ${errors.length} frontend error(s) found:\n`); for (const err of errors) { console.error(` • ${err}`); } console.error(`\n💡 Fix: add missing imports, exports, or state. prefixes`); process.exit(1); } else { const totalExports = Object.values(allExports).reduce((s, e) => s + e.size, 0); console.log(`\n✅ ${files.length} modules, ${totalExports} exports — 0 issues`); process.exit(0); }