#!/usr/bin/env node /** * Preflight JS Syntax Validation (5B.4) * * Parses all JavaScript files in web/ as ESM modules to catch syntax errors * before runtime. Uses acorn parser for accurate ESM parsing. * * Usage: npm run preflight:js * Exit code: 0 = success, 1 = syntax errors found */ import { readdir, readFile } from 'fs/promises'; import { join, relative } from 'path'; import { fileURLToPath, pathToFileURL } from 'url'; import * as acorn from 'acorn'; const __dirname = fileURLToPath(new URL('.', import.meta.url)); const WEB_DIR = join(__dirname, '..', 'web'); export const NODE_POLICY = { minMajor: 18, maxMajorExclusive: 26, label: 'Node.js >=18 <26', }; export function parseNodeMajor(version) { const raw = String(version || '').trim().replace(/^v/, ''); const [majorText] = raw.split('.'); const major = Number(majorText); return Number.isInteger(major) ? major : null; } export function isSupportedNodeMajor(major) { return Number.isInteger(major) && major >= NODE_POLICY.minMajor && major < NODE_POLICY.maxMajorExclusive; } export function getUnsupportedNodeVersionMessage(version) { const major = parseNodeMajor(version); if (!isSupportedNodeMajor(major)) { return [ `FAIL Node.js ${version || 'unknown'} detected. This project requires ${NODE_POLICY.label}.`, 'Use a Doctor-supported Node runtime from Node 18 through Node 25.', 'Node 26+ is blocked until validated against Doctor\'s Playwright harness.', ].join(' '); } return null; } function ensureSupportedNodeVersion() { const message = getUnsupportedNodeVersionMessage(process.versions.node); if (message) { console.error(message); process.exit(1); } } // Recursively find all .js files async function findJsFiles(dir) { const files = []; const entries = await readdir(dir, { withFileTypes: true }); for (const entry of entries) { const fullPath = join(dir, entry.name); if (entry.isDirectory()) { // Skip node_modules and lib (vendor files) if (entry.name === 'node_modules' || entry.name === 'lib') continue; files.push(...await findJsFiles(fullPath)); } else if (entry.name.endsWith('.js')) { files.push(fullPath); } } return files; } // Validate a single JS file as ESM async function validateFile(filePath) { const content = await readFile(filePath, 'utf-8'); const relativePath = relative(join(__dirname, '..'), filePath); try { acorn.parse(content, { ecmaVersion: 'latest', sourceType: 'module', locations: true }); return { file: relativePath, valid: true }; } catch (error) { return { file: relativePath, valid: false, error: error.message, line: error.loc?.line || null, column: error.loc?.column || null }; } } // Main execution async function main() { ensureSupportedNodeVersion(); console.log('INFO: Preflight JS Syntax Check (acorn)\n'); console.log(`Scanning: ${WEB_DIR}\n`); const files = await findJsFiles(WEB_DIR); console.log(`Found ${files.length} JavaScript files\n`); const results = await Promise.all(files.map(validateFile)); const errors = results.filter(r => !r.valid); const valid = results.filter(r => r.valid); // Report valid files for (const r of valid) { console.log(`PASS ${r.file}`); } // Report errors if (errors.length > 0) { console.log('\nFAIL Syntax Errors Found:\n'); for (const r of errors) { console.log(` FAIL ${r.file}`); console.log(` ${r.error}`); if (r.line) console.log(` Line: ${r.line}, Column: ${r.column}`); console.log(''); } console.log(`\nABORT: ${errors.length} file(s) with syntax errors`); process.exit(1); } console.log(`\nPASS All ${valid.length} files passed syntax check`); process.exit(0); } const isCliEntrypoint = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href; if (isCliEntrypoint) { main().catch(err => { console.error('Preflight check failed:', err); process.exit(1); }); }