es-guard
Version:
A tool to check JavaScript compatibility with target environments
391 lines • 17.3 kB
JavaScript
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