eslint-plugin-fsd-lint
Version:
ESLint plugin for enforcing Feature-Sliced Design (FSD) architecture
223 lines (197 loc) • 6.66 kB
JavaScript
/**
* @fileoverview Prevents relative imports between slices. All imports should use absolute paths with aliases.
*/
import path from "path";
import { mergeConfig } from "../utils/config-utils.js";
import {
extractLayerFromPath,
extractSliceFromPath,
getLayerPattern,
isRelativePath,
isTestFile,
normalizePath,
} from "../utils/path-utils.js";
export default {
meta: {
type: "problem",
docs: {
description:
"Prevents relative imports between slices. Use absolute paths with aliases instead.",
recommended: true,
},
messages: {
noRelativeImport:
"🚨 Relative imports are not allowed. Use absolute imports with aliases instead.",
},
schema: [
{
type: "object",
properties: {
rootPath: { type: "string" },
tsconfigPath: { type: "string" },
alias: {
oneOf: [
{ type: "string" },
{
type: "object",
properties: {
value: { type: "string" },
withSlash: { type: "boolean" },
},
required: ["value"],
additionalProperties: false,
},
],
},
layers: {
type: "object",
additionalProperties: {
type: "object",
properties: {
pattern: { type: "string" },
priority: { type: "number" },
allowedToImport: {
type: "array",
items: { type: "string" },
},
},
additionalProperties: false,
},
},
folderPattern: {
type: "object",
properties: {
enabled: { type: "boolean" },
regex: { type: "string" },
extractionGroup: { type: "number" },
},
additionalProperties: false,
},
testFilesPatterns: {
type: "array",
items: { type: "string" },
},
ignoreImportPatterns: {
type: "array",
items: { type: "string" },
},
allowTypeImports: {
type: "boolean",
description: "Allow relative imports for type-only imports",
},
allowSameSlice: {
type: "boolean",
description: "Allow relative imports within the same slice",
},
},
additionalProperties: false,
},
],
},
create(context) {
// Merge user config with default config
const options = context.options[0] || {};
const config = mergeConfig(options);
// Allow type imports if configured
const allowTypeImports = options.allowTypeImports || false;
// Allow same slice imports if configured (default: true)
const allowSameSlice =
options.allowSameSlice !== undefined ? options.allowSameSlice : true;
// Helper function to check if import is within the same slice
function isSameSlice(importPath, currentFilePath) {
if (!allowSameSlice) return false;
const normalizedCurrentPath = normalizePath(currentFilePath);
// Get the base directory of the current file
const currentDir = path.posix.dirname(normalizedCurrentPath);
// Resolve the import path relative to the current file
const resolvedImportPath = path.posix.normalize(
path.posix.resolve(currentDir, importPath),
);
// Layers that don't have slices (single-layer modules)
const singleLayerModules = ["app", "shared"];
// Find layer and slice from current file path
const currentLayer = extractLayerFromPath(normalizedCurrentPath, config);
const currentSlice = extractSliceFromPath(normalizedCurrentPath, config);
// If we couldn't find a layer, we can't determine if it's same slice
if (!currentLayer) return false;
// For single-layer modules (app, shared), any import within the layer is allowed
if (singleLayerModules.includes(currentLayer)) {
return (
extractLayerFromPath(resolvedImportPath, config) === currentLayer
);
}
// Find layer and slice from resolved import path
const importLayer = extractLayerFromPath(resolvedImportPath, config);
const importSlice = extractSliceFromPath(resolvedImportPath, config);
// If we couldn't find a layer in the import, it might be within the same slice
if (!importLayer) {
const currentLayerPattern = getLayerPattern(currentLayer, config);
// Check if the resolved path is still within the current layer/slice structure
if (currentSlice) {
return resolvedImportPath.includes(
`/${currentLayerPattern}/${currentSlice}/`,
);
} else {
// For single-layer modules without slices
return resolvedImportPath.includes(`/${currentLayerPattern}/`);
}
}
// For single-layer modules, just check if layers match
if (
singleLayerModules.includes(currentLayer) ||
singleLayerModules.includes(importLayer)
) {
return currentLayer === importLayer;
}
// Check if both layer and slice match
return currentLayer === importLayer && currentSlice === importSlice;
}
function checkImport(node, importPath, { isTypeImport }) {
if (typeof importPath !== "string") {
return;
}
if (!isRelativePath(importPath)) {
return;
}
if (isTestFile(context.filename, config.testFilesPatterns)) {
return;
}
const isIgnored = config.ignoreImportPatterns.some((pattern) => {
const regex = new RegExp(pattern);
return regex.test(importPath);
});
if (isIgnored) {
return;
}
if (allowTypeImports && isTypeImport) {
return;
}
if (allowSameSlice && isSameSlice(importPath, context.filename)) {
return;
}
context.report({
node,
messageId: "noRelativeImport",
});
}
return {
ImportDeclaration(node) {
checkImport(node, node.source.value, {
isTypeImport: node.importKind === "type",
});
},
CallExpression(node) {
if (node.callee.type === "Import") {
checkImport(node, node.arguments[0]?.value, {
isTypeImport: false,
});
}
},
ImportExpression(node) {
checkImport(node, node.source?.value, {
isTypeImport: false,
});
},
};
},
};