UNPKG

es-guard

Version:

A tool to check JavaScript compatibility with target environments

391 lines 17.3 kB
import { codeFrameColumns } from "@babel/code-frame"; import chalk from "chalk"; import { ESLint } from "eslint"; import * as fs from "fs"; import * as path from "path"; import { SourceMapConsumer } from "source-map"; import { createESLintConfig } from "./createESLintConfig.js"; /** * Extracts a horizontal slice of a long line around the error column for minified code. * Returns the modified content and adjusted column number. */ function extractMinifiedSlice(raw, lineNum, columnNum, contextBefore = 80, contextAfter = 80) { const lines = raw.split(/\r?\n/); if (lineNum < 1 || lineNum > lines.length) { return null; } const line = lines[lineNum - 1]; if (!line || line.length <= contextBefore + contextAfter) { return null; } // Calculate slice boundaries const start = Math.max(0, columnNum - contextBefore - 1); const end = Math.min(line.length, columnNum + contextAfter); const slice = line.slice(start, end); const adjustedColumn = columnNum - start; // Create modified content with the slice const modifiedLines = [...lines]; modifiedLines[lineNum - 1] = slice; return { content: modifiedLines.join("\n"), adjustedColumn, }; } /** * Generates a code frame from a file path, handling both minified and non-minified code. */ function generateCodeFrame(filePath, lineNum, columnNum) { try { const raw = fs.readFileSync(filePath, "utf-8"); const lines = raw.split(/\r?\n/); const isMinified = lines.length === 1 || (lineNum <= lines.length && lines[lineNum - 1] && lines[lineNum - 1].length > 300); if (isMinified && lineNum <= lines.length) { const slice = extractMinifiedSlice(raw, lineNum, columnNum); if (slice) { return ("\n" + codeFrameColumns(slice.content, { start: { line: lineNum, column: slice.adjustedColumn } }, { highlightCode: true, linesAbove: 0, linesBelow: 0 })); } } else if (!isMinified) { return ("\n" + codeFrameColumns(raw, { start: { line: lineNum, column: columnNum } }, { highlightCode: true, linesAbove: 1, linesBelow: 1 })); } } catch { // ignore } return ""; } async function getSourceMapForFile(jsFile) { // Try to find a .map file next to the JS file const mapFile = jsFile + ".map"; if (fs.existsSync(mapFile)) { const raw = fs.readFileSync(mapFile, "utf-8"); const map = JSON.parse(raw); return await new SourceMapConsumer(map); } // Try to find a sourceMappingURL comment in the JS file const content = fs.readFileSync(jsFile, "utf-8"); const match = content.match(/\/\/[#@] sourceMappingURL=([^\s]+)/); if (match) { let mapPath = match[1]; if (!mapPath.endsWith(".map")) { return null; } // If relative, resolve from jsFile if (!path.isAbsolute(mapPath)) { mapPath = path.resolve(path.dirname(jsFile), mapPath); } if (fs.existsSync(mapPath)) { const raw = fs.readFileSync(mapPath, "utf-8"); const map = JSON.parse(raw); return await new SourceMapConsumer(map); } } return null; } /** * Checks if a file path is a build tool protocol (webpack://, turbopack://, etc.) */ function checkIsBuildToolProtocol(filePath) { return filePath.startsWith("webpack://") || filePath.startsWith("turbopack://"); } /** * Extracts the file path from a build tool protocol URL. * Handles both webpack:// and turbopack:// formats. */ function extractPathFromBuildToolProtocol(originalFile) { if (originalFile.startsWith("turbopack://")) { // Turbopack format: turbopack:///[project]/path/to/file // Extract path after [project]/ const turbopackMatch = originalFile.match(/turbopack:\/\/\/\[project\]\/(.+)$/); if (turbopackMatch) { return turbopackMatch[1]; } // Fallback: try to extract path after protocol const fallbackMatch = originalFile.match(/turbopack:\/\/\/[^/]*\/(.+)$/); if (fallbackMatch) { return fallbackMatch[1]; } } else if (originalFile.startsWith("webpack://")) { // Webpack format: webpack:///./path/to/file or webpack:///path/to/file const webpackMatch = originalFile.match(/webpack:\/\/\/[^/]*\/(.+)$/); if (webpackMatch) { return webpackMatch[1]; } } return null; } /** * Tries to resolve a file path by checking multiple possible locations. * @param extractedPath The path extracted from the build tool protocol * @param baseDir Base directory (usually project root) * @param compiledFilePath Optional path to the compiled file, used to find project root */ function resolveFilePath(extractedPath, baseDir, compiledFilePath) { // Remove leading ./ if present (common in webpack paths) const cleanPath = extractedPath.replace(/^\.\//, ""); // First, try direct path from baseDir (most common case - project root) // This is the fastest and most reliable check const baseDirPath = path.join(baseDir, cleanPath); if (fs.existsSync(baseDirPath)) { return baseDirPath; } // Second, try relative to the compiled file's directory (for Next.js .next output) // Walk up from .next/static/chunks/ to find project root if (compiledFilePath) { let currentDir = path.dirname(compiledFilePath); const maxDepth = 10; // Prevent infinite loops let depth = 0; while (depth < maxDepth && currentDir !== path.dirname(currentDir)) { const testPath = path.join(currentDir, cleanPath); if (fs.existsSync(testPath)) { return testPath; } // Stop if we've gone up too far (reached a drive root on Windows or / on Unix) const parentDir = path.dirname(currentDir); if (parentDir === currentDir) break; currentDir = parentDir; depth++; } } return null; } /** * Recursively follows source map chains to find the original source file. * This handles cases where there are multiple layers of source maps. */ async function getOriginalSourceMap(jsFile) { let currentFile = jsFile; let consumer; let originalFile; // Follow the source map chain while (true) { consumer = await getSourceMapForFile(currentFile); if (!consumer) break; // Get the first source file from this source map // Note: SourceMapConsumer doesn't expose sources directly, so we'll use a different approach // We'll try to get the original position for the first line to find the source const firstPosition = consumer.originalPositionFor({ line: 1, column: 0 }); if (firstPosition && firstPosition.source) { originalFile = firstPosition.source; // If this source file is a build tool protocol URL, we've reached the end if (checkIsBuildToolProtocol(originalFile)) { break; } // If this source file exists and is a JS file, continue following the chain const resolvedPath = path.isAbsolute(originalFile) ? originalFile : path.resolve(path.dirname(currentFile), originalFile); if (fs.existsSync(resolvedPath) && resolvedPath.endsWith(".js")) { // Continue following the chain if (consumer.destroy) consumer.destroy(); currentFile = resolvedPath; continue; } } // If we can't find a valid source to follow, stop here break; } return consumer ? { consumer, originalFile } : null; } export const checkCompatibility = async (config) => { // Set BROWSERSLIST env variable to override Browserslist file detection const originalBrowserslistEnv = process.env.BROWSERSLIST; if (config.browsers) { process.env.BROWSERSLIST = config.browsers; } const eslint = new ESLint(createESLintConfig(config.target, config.browsers, config.skipCompatWarnings)); const errors = []; const warnings = []; try { // Let ESLint handle directory traversal directly const results = await eslint.lintFiles([config.dir]); if (results.length === 0) { console.log(`No JavaScript files found in directory: ${config.dir}`); return { errors: [], warnings: [] }; } for (const result of results) { // Filter out warnings about noInlineConfig having no effect // Also only include compat/compat messages and parsing errors (ruleId: null) const isRelevantMessage = (m) => { // Filter out noInlineConfig messages if (m.message.includes("has no effect because you have 'noInlineConfig'")) { return false; } // Only include compat/compat messages or parsing errors (ruleId: null) return m.ruleId === "compat/compat" || m.ruleId === null; }; const errorMessages = result.messages.filter((m) => m.severity === 2 && isRelevantMessage(m)); const warningMessages = result.messages.filter((m) => m.severity === 1 && isRelevantMessage(m)); // Try to remap error/warning locations using sourcemap let sourceMappedErrors = undefined; let sourceMappedWarnings = undefined; let sourceMap = null; let originalFile = undefined; if (errorMessages.length > 0 || warningMessages.length > 0) { const sourceMapResult = await getOriginalSourceMap(result.filePath); if (sourceMapResult) { sourceMap = sourceMapResult.consumer; originalFile = sourceMapResult.originalFile; } } if (sourceMap) { // Helper to map messages to source-mapped messages const mapMessagesToSourceMapped = (messages) => { return messages.map((msg) => { if (msg.line != null && msg.column != null) { const orig = sourceMap.originalPositionFor({ line: msg.line, column: msg.column, }); return { ...msg, originalFile: orig.source || originalFile || undefined, originalLine: orig.line || undefined, originalColumn: orig.column || undefined, }; } return msg; }); }; if (errorMessages.length > 0) { sourceMappedErrors = mapMessagesToSourceMapped(errorMessages); } if (warningMessages.length > 0) { sourceMappedWarnings = mapMessagesToSourceMapped(warningMessages); } if (sourceMap.destroy) sourceMap.destroy(); } if (errorMessages.length > 0) { errors.push({ file: result.filePath, messages: errorMessages, sourceMappedMessages: sourceMappedErrors, }); } if (warningMessages.length > 0) { warnings.push({ file: result.filePath, messages: warningMessages, sourceMappedMessages: sourceMappedWarnings, }); } } } catch (error) { console.warn(`Warning: Could not lint directory ${config.dir}:`, error); } finally { // Restore original BROWSERSLIST env variable if (originalBrowserslistEnv !== undefined) { process.env.BROWSERSLIST = originalBrowserslistEnv; } else { delete process.env.BROWSERSLIST; } } return { errors, warnings }; }; /** * Formats a violation message with source map information and creates a link to the actual source file. * @param message The lint message * @param sourceMappedMessage Optional source mapped message with original file information * @param baseDir Base directory for resolving relative paths * @param explicitFilePath Explicit file path for code frame * @returns Formatted message string */ export const formatViolationMessage = (message, sourceMappedMessage, baseDir = process.cwd(), explicitFilePath) => { // Store the compiled file path for path resolution const compiledFilePath = explicitFilePath; const lineCol = chalk.gray(`${message.line}:${message.column}`); const ruleInfo = message.ruleId ? chalk.dim(` (${message.ruleId})`) : ""; let codeFrame = ""; let filePath; let line; let column; let canReadSource = false; // Prioritize source-mapped file over compiled file if available if (sourceMappedMessage?.originalFile && sourceMappedMessage?.originalLine) { const originalFile = sourceMappedMessage.originalFile; line = sourceMappedMessage.originalLine; column = sourceMappedMessage.originalColumn || 0; // Handle build tool protocol URLs (webpack://, turbopack://) - try to extract the actual file path if (checkIsBuildToolProtocol(originalFile)) { const extractedPath = extractPathFromBuildToolProtocol(originalFile); if (extractedPath) { const resolvedPath = resolveFilePath(extractedPath, baseDir, compiledFilePath); filePath = resolvedPath ?? undefined; canReadSource = resolvedPath !== null; } } else { filePath = path.isAbsolute(originalFile) ? originalFile : path.join(baseDir, originalFile.replace(/^\.\//, "")); canReadSource = fs.existsSync(filePath); } } else if (explicitFilePath) { // Fallback to compiled file if no source map available filePath = explicitFilePath; line = message.line; column = message.column; canReadSource = fs.existsSync(filePath); } else if (message.line && message.column) { filePath = undefined; line = message.line; column = message.column; } // Try to print a code frame if we have a readable file path if (filePath && canReadSource && line) { codeFrame = generateCodeFrame(filePath, line, column || 1); } // Fallback: try to show code frame from the compiled JS file if (!codeFrame && explicitFilePath && fs.existsSync(explicitFilePath) && message.line) { codeFrame = generateCodeFrame(explicitFilePath, message.line, message.column || 1); } // Helper to get error/warning label const getLabel = () => { return message.severity === 2 ? chalk.red.bold("ERROR") : chalk.yellow.bold("WARNING"); }; // If we have source-mapped info, show the original source if (sourceMappedMessage?.originalFile && sourceMappedMessage?.originalLine) { const originalFile = sourceMappedMessage.originalFile; const originalLine = sourceMappedMessage.originalLine; const originalCol = sourceMappedMessage.originalColumn || 0; const isBuildToolProtocol = checkIsBuildToolProtocol(originalFile); // Determine link indicator and path const linkIndicator = isBuildToolProtocol ? canReadSource ? "[src]" : "[map]" : canReadSource && filePath ? "[src]" : "[file]"; const linkPath = canReadSource && filePath ? filePath : undefined; // Format original display let originalDisplay; if (isBuildToolProtocol) { // For build tool protocols, always show the original import path originalDisplay = `${chalk.magenta(linkIndicator)} ${chalk.bold("Original:")} ${chalk.cyan(originalFile)}:${chalk.yellow(originalLine)}:${chalk.yellow(originalCol)}`; } else if (linkPath && fs.existsSync(linkPath)) { // For regular files, show the resolved path as clickable const fileUri = `file://${linkPath.replace(/\\/g, "/")}`; originalDisplay = `${chalk.magenta(linkIndicator)} ${chalk.bold("Original:")} ${chalk.cyan(fileUri)}:${chalk.yellow(originalLine)}:${chalk.yellow(originalCol)}`; } else { // Fallback: show the original file path originalDisplay = `${chalk.magenta(linkIndicator)} ${chalk.bold("Original:")} ${chalk.cyan(originalFile)}:${chalk.yellow(originalLine)}:${chalk.yellow(originalCol)}`; } return `${lineCol} ${getLabel()}: ${chalk.bold(message.message)}${ruleInfo}\n ${originalDisplay}\n ${codeFrame}`; } // No source map info return `${lineCol} ${getLabel()}: ${chalk.bold(message.message)}${ruleInfo}\n ${codeFrame}`; }; //# sourceMappingURL=checkCompatiblity.js.map