Files

144 lines
4.3 KiB
JavaScript

#!/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);
});
}