import fs from "fs"; import path from "path"; export default { meta: { type: "suggestion", docs: { description: "Enforce file extensions in import statements", }, fixable: "code", schema: [ { type: "object", properties: { ignorePaths: { type: "array", items: { type: "string" }, }, includePaths: { type: "array", items: { type: "string" }, description: "Path patterns to include (e.g., '$lib/')", }, tsToJs: { type: "boolean", description: "Convert .ts files to .js when importing", }, aliases: { type: "object", description: "Map of path aliases to their actual paths (e.g., {'$lib': 'src/lib'})", }, }, additionalProperties: false, }, ], messages: { missingExtension: "Import should include a file extension", noFileFound: "Import is missing extension and no matching file was found", }, }, create(context) { const options = context.options[0] || {}; const ignorePaths = options.ignorePaths || []; const includePaths = options.includePaths || []; const tsToJs = options.tsToJs !== undefined ? options.tsToJs : true; // Default to true const aliases = options.aliases || {}; // Get the project root directory const projectRoot = process.cwd(); // Utility function to resolve file paths function resolveImportPath(importPath, currentFilePath) { // Handle relative paths if (importPath.startsWith("./") || importPath.startsWith("../")) { return path.resolve(path.dirname(currentFilePath), importPath); } // Handle aliased paths for (const [alias, aliasPath] of Object.entries(aliases)) { // Check if the import starts with this alias if (importPath === alias || importPath.startsWith(`${alias}/`)) { // Replace the alias with the actual path const relativePath = importPath === alias ? "" : importPath.slice(alias.length + 1); // +1 for the slash // Convert the aliasPath to an absolute path let absoluteAliasPath = aliasPath; if (!path.isAbsolute(absoluteAliasPath)) { absoluteAliasPath = path.resolve(projectRoot, aliasPath); } return path.join(absoluteAliasPath, relativePath); } } return null; } // Find the file extension by checking which file exists function findActualFile(basePath) { if (!basePath) return null; try { // Get the directory and base name const dir = path.dirname(basePath); const base = path.basename(basePath); // If the directory doesn't exist, return early if (!fs.existsSync(dir)) { return null; } // Read all files in the directory const files = fs.readdirSync(dir); // Look for files that match our base name plus any extension for (const file of files) { const fileParts = path.parse(file); // If we find a file that matches our base name if (fileParts.name === base) { // Handle TypeScript to JavaScript conversion if (tsToJs && fileParts.ext === ".ts") { return { actualPath: path.join(dir, file), importExt: ".js", // Import as .js even though it's a .ts file }; } // Otherwise use the actual extension return { actualPath: path.join(dir, file), importExt: fileParts.ext, }; } } } catch (error) { // If there's an error checking file existence, return null console.error("Error checking files:", error); } return null; } return { ImportDeclaration(node) { const source = node.source.value; // Check if it's a relative import or matches a manually specified include path const isRelativeImport = source.startsWith("./") || source.startsWith("../"); const isAliasedPath = Object.keys(aliases).some(alias => source === alias || source.startsWith(`${alias}/`)); const isIncludedPath = includePaths.some(pattern => source.startsWith(pattern)); // Skip if it's not a relative import, aliased path, or included path if (!isRelativeImport && !isAliasedPath && !isIncludedPath) { return; } // Skip ignored paths if (ignorePaths.some(path => source.includes(path))) { return; } // Check if the import already has an extension const hasExtension = path.extname(source) !== ""; if (!hasExtension) { // Get current file path to resolve the import const currentFilePath = context.getFilename(); // Try to determine the correct file by checking what exists const resolvedPath = resolveImportPath(source, currentFilePath); const fileInfo = findActualFile(resolvedPath); context.report({ node, messageId: fileInfo ? "missingExtension" : "noFileFound", fix(fixer) { // Only provide a fix if we found a file if (fileInfo) { // Replace the string literal with one that includes the extension return fixer.replaceText(node.source, `"${source}${fileInfo.importExt}"`); } // Otherwise, don't try to fix return null; }, }); } }, }; }, };