diff --git a/.changeset/lazy-windows-sip.md b/.changeset/lazy-windows-sip.md new file mode 100644 index 00000000..5c5fea15 --- /dev/null +++ b/.changeset/lazy-windows-sip.md @@ -0,0 +1,5 @@ +--- +'@feature-sliced/steiger-plugin': minor +--- + +Add FSD layer aliases through `createFsdPlugin({ layerAliases })`. diff --git a/README.md b/README.md index f58e6101..b5fef101 100644 --- a/README.md +++ b/README.md @@ -105,6 +105,26 @@ export default defineConfig([ [You can see more examples here](CONFIG_EXAMPLES.md) +### Layer aliases + +Some projects use FSD semantics with project-specific folder names during migrations or while integrating existing codebases. + +Use `createFsdPlugin` to configure these aliases: + +```js +// ./steiger.config.js +import { defineConfig } from 'steiger' +import { createFsdPlugin } from '@feature-sliced/steiger-plugin' + +const fsd = createFsdPlugin({ + layerAliases: { + pages: 'screens', + }, +}) + +export default defineConfig([...fsd.configs.recommended]) +``` + ### Migration from 0.4.0 Version 0.5.0 introduced a new config file format. Follow the [instructions](MIGRATION_GUIDE.md) to migrate your config file. diff --git a/packages/steiger-plugin-fsd/src/_lib/index-source-files.ts b/packages/steiger-plugin-fsd/src/_lib/index-source-files.ts index 904f039b..97a98477 100644 --- a/packages/steiger-plugin-fsd/src/_lib/index-source-files.ts +++ b/packages/steiger-plugin-fsd/src/_lib/index-source-files.ts @@ -1,6 +1,7 @@ import { getIndexes, getLayers, getSegments, getSlices, isSliced, type LayerName } from '@feature-sliced/filesystem' import type { File, Folder } from '@steiger/toolkit' import { joinFromRoot, parseIntoFolder as parseIntoFsdRoot } from '@steiger/toolkit/test' +import type { LayerConvention } from '@feature-sliced/filesystem' type SourceFile = { file: File @@ -14,7 +15,7 @@ type SourceFile = { * * @returns A mapping of source file paths to their file object and location information. */ -export function indexSourceFiles(root: Folder): Record { +export function indexSourceFiles(root: Folder, layerConvention?: LayerConvention): Record { const index = {} as Record function walk(node: File | Folder, metadata: Pick) { if (node.type === 'file') { @@ -26,7 +27,7 @@ export function indexSourceFiles(root: Folder): Record { } } - for (const [layerName, layer] of Object.entries(getLayers(root))) { + for (const [layerName, layer] of Object.entries(getLayers(root, layerConvention))) { // Even though files that are directly inside a layer are not encouraged by the FSD and are forbidden in most cases // (except for an index/root file for the app layer as an entry point to the application), users can still add them. // So, we need to index all files directly inside a layer to find errors. @@ -34,7 +35,7 @@ export function indexSourceFiles(root: Folder): Record { .filter((child) => child.type === 'file') .forEach((file) => walk(file, { layerName: layerName as LayerName, sliceName: null, segmentName: null })) - if (!isSliced(layer)) { + if (!isSliced(layer, layerConvention)) { for (const [segmentName, segment] of Object.entries(getSegments(layer))) { walk(segment, { layerName: layerName as LayerName, sliceName: null, segmentName }) } diff --git a/packages/steiger-plugin-fsd/src/ambiguous-slice-names/index.ts b/packages/steiger-plugin-fsd/src/ambiguous-slice-names/index.ts index 37ca03cc..362e108f 100644 --- a/packages/steiger-plugin-fsd/src/ambiguous-slice-names/index.ts +++ b/packages/steiger-plugin-fsd/src/ambiguous-slice-names/index.ts @@ -2,14 +2,15 @@ import { basename, sep } from 'node:path' import { getAllSlices, getLayers, getSegments, type LayerName } from '@feature-sliced/filesystem' import type { PartialDiagnostic, Folder, Rule } from '@steiger/toolkit' import { NAMESPACE } from '../constants.js' +import type { FsdRuleOptions } from '../fsd-options.js' /** Forbid slice names that match some segment’s name in shared (e.g., theme, i18n) */ const ambiguousSliceNames = { name: `${NAMESPACE}/ambiguous-slice-names` as const, - check(root) { + check(root, ruleOptions: FsdRuleOptions = {}) { const diagnostics: Array = [] - const layers = getLayers(root) + const layers = getLayers(root, ruleOptions.layerConvention) const sharedLayer = layers.shared if (sharedLayer === undefined) { @@ -18,7 +19,7 @@ const ambiguousSliceNames = { const segmentNamesInShared = Object.keys(getSegments(sharedLayer)) - for (const [sliceName, slice] of Object.entries(getAllSlices(root))) { + for (const [sliceName, slice] of Object.entries(getAllSlices(root, [], ruleOptions.layerConvention))) { const pathSegments = sliceName.split(sep) const matchingSegment = segmentNamesInShared.find((segmentName) => pathSegments.includes(segmentName)) @@ -68,6 +69,6 @@ const ambiguousSliceNames = { return { diagnostics } }, -} satisfies Rule +} satisfies Rule export default ambiguousSliceNames diff --git a/packages/steiger-plugin-fsd/src/excessive-slicing/index.ts b/packages/steiger-plugin-fsd/src/excessive-slicing/index.ts index 98850241..5fb725ec 100644 --- a/packages/steiger-plugin-fsd/src/excessive-slicing/index.ts +++ b/packages/steiger-plugin-fsd/src/excessive-slicing/index.ts @@ -4,6 +4,7 @@ import type { PartialDiagnostic, Rule } from '@steiger/toolkit' import { groupSlices } from '../_lib/group-slices.js' import { NAMESPACE } from '../constants.js' +import { getLayerDisplayName, type FsdRuleOptions } from '../fsd-options.js' const THRESHOLDS = { entities: 20, @@ -15,11 +16,11 @@ const THRESHOLDS = { /** Warn about excessive amounts of ungrouped entities/features/widgets/pages. */ const excessiveSlicing = { name: `${NAMESPACE}/excessive-slicing` as const, - check(root) { + check(root, ruleOptions: FsdRuleOptions = {}) { const diagnostics: Array = [] - for (const [layerName, layer] of Object.entries(getLayers(root))) { - if (!isSliced(layer) || !(layerName in THRESHOLDS)) { + for (const [layerName, layer] of Object.entries(getLayers(root, ruleOptions.layerConvention))) { + if (!isSliced(layer, ruleOptions.layerConvention) || !(layerName in THRESHOLDS)) { continue } @@ -33,7 +34,7 @@ const excessiveSlicing = { if (group === '') { diagnostics.push({ - message: `Layer "${layerName}" has ${slices.length} ungrouped slices, which is above the recommended threshold of ${threshold}. Consider grouping them or moving the code inside to the layer where it's used.`, + message: `Layer "${getLayerDisplayName(root, layerName as keyof typeof THRESHOLDS, ruleOptions.layerConvention)}" has ${slices.length} ungrouped slices, which is above the recommended threshold of ${threshold}. Consider grouping them or moving the code inside to the layer where it's used.`, location: { path: layer.path }, }) } else { @@ -47,6 +48,6 @@ const excessiveSlicing = { return { diagnostics } }, -} satisfies Rule +} satisfies Rule export default excessiveSlicing diff --git a/packages/steiger-plugin-fsd/src/forbidden-imports/index.ts b/packages/steiger-plugin-fsd/src/forbidden-imports/index.ts index 15eb4f38..a52f9a94 100644 --- a/packages/steiger-plugin-fsd/src/forbidden-imports/index.ts +++ b/packages/steiger-plugin-fsd/src/forbidden-imports/index.ts @@ -1,5 +1,4 @@ import * as fs from 'node:fs' -import { join } from 'node:path' import { layerSequence, isCrossImportPublicApi } from '@feature-sliced/filesystem' import { parse as parseNearestTsConfig } from 'tsconfck' import type { PartialDiagnostic, Rule } from '@steiger/toolkit' @@ -9,14 +8,15 @@ import { collectRelatedTsConfigs } from '../_lib/collect-related-ts-configs.js' import { resolveDependency } from '../_lib/resolve-dependency.js' import { extractDependencies, getSourceType } from '../_language-tools/index.js' import { NAMESPACE } from '../constants.js' +import { getLayerDisplayName, getLayerPath, type FsdRuleOptions } from '../fsd-options.js' const forbiddenImports = { name: `${NAMESPACE}/forbidden-imports` as const, - async check(root) { + async check(root, ruleOptions: FsdRuleOptions = {}) { const diagnostics: Array = [] const parseResult = await parseNearestTsConfig(root.children[0]?.path ?? root.path) const tsConfigs = collectRelatedTsConfigs(parseResult) - const sourceFileIndex = indexSourceFiles(root) + const sourceFileIndex = indexSourceFiles(root, ruleOptions.layerConvention) for (const sourceFile of Object.values(sourceFileIndex)) { const sourceType = getSourceType(sourceFile.file.path) @@ -51,7 +51,7 @@ const forbiddenImports = { !isCrossImportPublicApi(dependencyLocation.file, { inSlice: dependencyLocation.sliceName, forSlice: sourceFile.sliceName, - layerPath: join(root.path, dependencyLocation.layerName), + layerPath: getLayerPath(root, dependencyLocation.layerName, ruleOptions.layerConvention), }) ) { diagnostics.push({ @@ -65,7 +65,7 @@ const forbiddenImports = { if (thisLayerIndex < dependencyLayerIndex) { diagnostics.push({ - message: `Forbidden import from higher layer "${dependencyLocation.layerName}".`, + message: `Forbidden import from higher layer "${getLayerDisplayName(root, dependencyLocation.layerName, ruleOptions.layerConvention)}".`, location: { path: sourceFile.file.path }, }) } @@ -75,6 +75,6 @@ const forbiddenImports = { return { diagnostics } }, -} satisfies Rule +} satisfies Rule export default forbiddenImports diff --git a/packages/steiger-plugin-fsd/src/fsd-options.ts b/packages/steiger-plugin-fsd/src/fsd-options.ts new file mode 100644 index 00000000..b2072434 --- /dev/null +++ b/packages/steiger-plugin-fsd/src/fsd-options.ts @@ -0,0 +1,55 @@ +import type { LayerAliases, LayerConvention } from '@feature-sliced/filesystem' +import { basename } from 'node:path' +import { getLayers, type Folder, type LayerName } from '@feature-sliced/filesystem' +import type { Rule } from '@steiger/toolkit' + +export type FsdPluginOptions = { + /** + * Treat project-specific top-level folders as canonical FSD layers. + * + * @example + * ```ts + * createFsdPlugin({ layerAliases: { pages: 'screens' } }) + * ``` + */ + layerAliases?: LayerAliases +} + +export type FsdRuleOptions = { + layerConvention?: LayerConvention +} + +export function createLayerConvention(options: FsdPluginOptions): LayerConvention | undefined { + if (!options.layerAliases || Object.keys(options.layerAliases).length === 0) { + return undefined + } + + return { + layerAliases: options.layerAliases, + } +} + +export function withFsdOptions, Name extends string>( + rule: Rule, + ruleOptions: FsdRuleOptions, +): Rule { + return { + ...rule, + check(this: Context, root, options) { + return rule.check.call(this, root, { + ...options, + ...ruleOptions, + } as Options) + }, + } +} + +export type { LayerAliases, LayerConvention } + +export function getLayerDisplayName(root: Folder, layerName: LayerName, layerConvention?: LayerConvention): string { + return basename(getLayers(root, layerConvention)[layerName]?.path ?? layerName) +} + +export function getLayerPath(root: Folder, layerName: LayerName, layerConvention?: LayerConvention): string { + return getLayers(root, layerConvention)[layerName]?.path ?? `${root.path}/${layerName}` +} diff --git a/packages/steiger-plugin-fsd/src/import-locality/index.ts b/packages/steiger-plugin-fsd/src/import-locality/index.ts index fde311c1..57fd9f12 100644 --- a/packages/steiger-plugin-fsd/src/import-locality/index.ts +++ b/packages/steiger-plugin-fsd/src/import-locality/index.ts @@ -7,14 +7,15 @@ import { collectRelatedTsConfigs } from '../_lib/collect-related-ts-configs.js' import { resolveDependency } from '../_lib/resolve-dependency.js' import { NAMESPACE } from '../constants.js' import { extractDependencies, getSourceType } from '../_language-tools/index.js' +import type { FsdRuleOptions } from '../fsd-options.js' const importLocality = { name: `${NAMESPACE}/import-locality`, - async check(root) { + async check(root, ruleOptions: FsdRuleOptions = {}) { const diagnostics: Array = [] const parseResult = await parseNearestTsConfig(root.children[0]?.path ?? root.path) const tsConfigs = collectRelatedTsConfigs(parseResult) - const sourceFileIndex = indexSourceFiles(root) + const sourceFileIndex = indexSourceFiles(root, ruleOptions.layerConvention) for (const sourceFile of Object.values(sourceFileIndex)) { const sourceType = getSourceType(sourceFile.file.path) @@ -58,6 +59,6 @@ const importLocality = { return { diagnostics } }, -} satisfies Rule +} satisfies Rule export default importLocality diff --git a/packages/steiger-plugin-fsd/src/inconsistent-naming/index.ts b/packages/steiger-plugin-fsd/src/inconsistent-naming/index.ts index 92086988..acd82042 100644 --- a/packages/steiger-plugin-fsd/src/inconsistent-naming/index.ts +++ b/packages/steiger-plugin-fsd/src/inconsistent-naming/index.ts @@ -7,16 +7,17 @@ import type { PartialDiagnostic, Rule } from '@steiger/toolkit' import { groupSlices } from '../_lib/group-slices.js' import { NAMESPACE } from '../constants.js' +import type { FsdRuleOptions } from '../fsd-options.js' const neutralWords = new Set(['k8s', 'kubernetes', 'media']) /** Detect inconsistent naming of slices on layers (singular vs plural) */ const inconsistentNaming = { name: `${NAMESPACE}/inconsistent-naming` as const, - check(root) { + check(root, ruleOptions: FsdRuleOptions = {}) { const diagnostics: Array = [] - const { entities } = getLayers(root) + const { entities } = getLayers(root, ruleOptions.layerConvention) if (entities === undefined) { return { diagnostics } } @@ -56,7 +57,7 @@ const inconsistentNaming = { return { diagnostics } }, -} satisfies Rule +} satisfies Rule export default inconsistentNaming diff --git a/packages/steiger-plugin-fsd/src/index.ts b/packages/steiger-plugin-fsd/src/index.ts index 3ebffcaf..66365908 100644 --- a/packages/steiger-plugin-fsd/src/index.ts +++ b/packages/steiger-plugin-fsd/src/index.ts @@ -20,6 +20,7 @@ import packageJson from '../package.json' with { type: 'json' } import noCrossImports from './no-cross-imports/index.js' import noHigherLevelImports from './no-higher-level-imports/index.js' import importLocality from './import-locality/index.js' +import { createLayerConvention, type FsdPluginOptions, withFsdOptions } from './fsd-options.js' const enabledRules = [ ambiguousSliceNames, @@ -42,23 +43,30 @@ const enabledRules = [ ] const disabledRules = [noCrossImports, noHigherLevelImports, importLocality] -const rules = [...enabledRules, ...disabledRules] +export function createFsdPlugin(options: FsdPluginOptions = {}) { + const ruleOptions = { layerConvention: createLayerConvention(options) } + const enabledRuleDefinitions = enabledRules.map((rule) => withFsdOptions(rule, ruleOptions)) + const disabledRuleDefinitions = disabledRules.map((rule) => withFsdOptions(rule, ruleOptions)) -const plugin = createPlugin({ - meta: { - name: '@feature-sliced/steiger-plugin', - version: packageJson.version, - }, - ruleDefinitions: rules, -}) + const plugin = createPlugin({ + meta: { + name: '@feature-sliced/steiger-plugin', + version: packageJson.version, + }, + ruleDefinitions: [...enabledRuleDefinitions, ...disabledRuleDefinitions], + }) -const configs = createConfigs({ - recommended: enableSpecificRules(plugin, enabledRules), -}) + const configs = createConfigs({ + recommended: enableSpecificRules(plugin, enabledRuleDefinitions), + }) -export default { - plugin, - configs, + return { + plugin, + configs, + } } -export type FSDConfigObject = ConfigObjectOf +export default createFsdPlugin() + +export type FSDConfigObject = ConfigObjectOf['plugin']> +export type { FsdPluginOptions, LayerAliases } from './fsd-options.js' diff --git a/packages/steiger-plugin-fsd/src/insignificant-slice/index.ts b/packages/steiger-plugin-fsd/src/insignificant-slice/index.ts index 092d4b57..a9644ed0 100644 --- a/packages/steiger-plugin-fsd/src/insignificant-slice/index.ts +++ b/packages/steiger-plugin-fsd/src/insignificant-slice/index.ts @@ -9,24 +9,32 @@ import { collectRelatedTsConfigs } from '../_lib/collect-related-ts-configs.js' import { resolveDependency } from '../_lib/resolve-dependency.js' import { NAMESPACE } from '../constants.js' import { extractDependencies, getSourceType } from '../_language-tools/index.js' +import { getLayerDisplayName, type FsdRuleOptions, type LayerConvention } from '../fsd-options.js' + +type SliceReferences = Map< + string, + { + sourceLayerName: LayerName + targetLocations: Map + } +> const insignificantSlice = { name: `${NAMESPACE}/insignificant-slice` as const, - async check(root) { + async check(root, ruleOptions: FsdRuleOptions = {}) { const diagnostics: Array = [] - const references = await traceSliceReferences(root) + const references = await traceSliceReferences(root, ruleOptions.layerConvention) - for (const [sourceLocationKey, targetLocationKeys] of references) { - const [sourceLayerName] = sourceLocationKey.split(sep, 2) as [LayerName, string | undefined] - if (!isSliced(sourceLayerName) || sourceLayerName === 'pages') { + for (const [sourceLocationKey, { sourceLayerName, targetLocations }] of references) { + if (!isSliced(sourceLayerName, ruleOptions.layerConvention) || sourceLayerName === 'pages') { continue } - if (targetLocationKeys.size === 1) { - const referenceLocationKey = [...targetLocationKeys][0] - if (unslicedLayers.includes(referenceLocationKey)) { - if (referenceLocationKey !== 'app') { + if (targetLocations.size === 1) { + const [referenceLocationKey, referenceLayerName] = [...targetLocations][0] + if (unslicedLayers.includes(referenceLayerName)) { + if (referenceLayerName !== 'app') { diagnostics.push({ message: `This slice has only one reference on layer "${referenceLocationKey}". Consider moving this code to "${referenceLocationKey}".`, location: { path: join(root.path, sourceLocationKey) }, @@ -38,7 +46,7 @@ const insignificantSlice = { location: { path: join(root.path, sourceLocationKey) }, }) } - } else if (targetLocationKeys.size === 0) { + } else if (targetLocations.size === 0) { diagnostics.push({ message: `This slice has no references. Consider removing it.`, location: { path: join(root.path, sourceLocationKey) }, @@ -48,18 +56,20 @@ const insignificantSlice = { return { diagnostics } }, -} satisfies Rule +} satisfies Rule export default insignificantSlice -async function traceSliceReferences(root: Folder) { - const sourceFileIndex = indexSourceFiles(root) +async function traceSliceReferences(root: Folder, layerConvention?: LayerConvention): Promise { + const sourceFileIndex = indexSourceFiles(root, layerConvention) const parseResult = await parseNearestTsConfig(root.children[0]?.path ?? root.path) const tsConfigs = collectRelatedTsConfigs(parseResult) - const references = new Map>() + const references: SliceReferences = new Map() for (const sourceFile of Object.values(sourceFileIndex)) { - const thisFileLocationKey = [sourceFile.layerName, sourceFile.sliceName].filter(Boolean).join(sep) + const thisFileLocationKey = [getLayerDisplayName(root, sourceFile.layerName, layerConvention), sourceFile.sliceName] + .filter(Boolean) + .join(sep) const sourceType = getSourceType(sourceFile.file.path) if (!sourceType) continue @@ -82,20 +92,29 @@ async function traceSliceReferences(root: Folder) { continue } - const dependencyLocationKey = [dependencyLocation.layerName, dependencyLocation.sliceName] + const dependencyLocationKey = [ + getLayerDisplayName(root, dependencyLocation.layerName, layerConvention), + dependencyLocation.sliceName, + ] .filter(Boolean) .join(sep) if (thisFileLocationKey !== dependencyLocationKey) { if (!references.has(dependencyLocationKey)) { - references.set(dependencyLocationKey, new Set()) + references.set(dependencyLocationKey, { + sourceLayerName: dependencyLocation.layerName, + targetLocations: new Map(), + }) } - references.get(dependencyLocationKey)?.add(thisFileLocationKey) + references.get(dependencyLocationKey)?.targetLocations.set(thisFileLocationKey, sourceFile.layerName) } } if (!references.has(thisFileLocationKey)) { - references.set(thisFileLocationKey, new Set()) + references.set(thisFileLocationKey, { + sourceLayerName: sourceFile.layerName, + targetLocations: new Map(), + }) } } diff --git a/packages/steiger-plugin-fsd/src/layer-aliases.spec.ts b/packages/steiger-plugin-fsd/src/layer-aliases.spec.ts new file mode 100644 index 00000000..a1431a64 --- /dev/null +++ b/packages/steiger-plugin-fsd/src/layer-aliases.spec.ts @@ -0,0 +1,157 @@ +import { expect, it, vi } from 'vitest' +import { joinFromRoot, parseIntoFolder as parseIntoFsdRoot } from '@steiger/toolkit/test' + +import fsd, { createFsdPlugin } from './index.js' + +vi.mock('tsconfck', async (importOriginal) => { + return { + ...(await importOriginal()), + parse: vi.fn(() => + Promise.resolve({ + tsconfig: { + compilerOptions: { + baseUrl: '/src/', + paths: { + '@/*': ['./*'], + }, + }, + }, + }), + ), + } +}) + +vi.mock('node:fs', async (importOriginal) => { + const originalFs = await importOriginal() + const { createFsMocks } = await import('@steiger/toolkit/test') + + return createFsMocks( + { + '/src/features/comments/ui/CommentCard.tsx': 'import { styles } from "@/screens/editor"', + '/src/features/comments/index.ts': '', + '/src/entities/export-encoding/model/index.ts': '', + '/src/entities/export-encoding/index.ts': '', + '/src/screens/editor/ui/styles.ts': '', + '/src/screens/editor/index.ts': '', + '/src/screens/export-worker/ui/ExportWorker.tsx': 'import { encode } from "@/entities/export-encoding"', + '/src/screens/export-worker/index.ts': '', + }, + originalFs, + ) +}) + +it('keeps the default pages layer behavior unchanged', async () => { + const root = parseIntoFsdRoot(` + πŸ“‚ pages + πŸ“‚ settings + πŸ“‚ profile + πŸ“„ index.ts + `) + + const rule = fsd.plugin.ruleDefinitions.find((rule) => rule.name === 'fsd/no-segmentless-slices') + + expect(await Promise.resolve(rule?.check(root, {}))).toEqual({ + diagnostics: [ + { + message: 'This slice has no segments. Consider dividing the code inside into segments.', + location: { path: joinFromRoot('pages', 'settings', 'profile') }, + }, + ], + }) +}) + +it('applies layer aliases to import rules', async () => { + const root = parseIntoFsdRoot( + ` + πŸ“‚ features + πŸ“‚ comments + πŸ“‚ ui + πŸ“„ CommentCard.tsx + πŸ“„ index.ts + πŸ“‚ screens + πŸ“‚ editor + πŸ“‚ ui + πŸ“„ styles.ts + πŸ“„ index.ts + `, + joinFromRoot('src'), + ) + const fsdWithScreens = createFsdPlugin({ layerAliases: { pages: 'screens' } }) + const rule = fsdWithScreens.plugin.ruleDefinitions.find((rule) => rule.name === 'fsd/forbidden-imports') + + expect(await Promise.resolve(rule?.check(root, {}))).toEqual({ + diagnostics: [ + { + message: 'Forbidden import from higher layer "screens".', + location: { path: joinFromRoot('src', 'features', 'comments', 'ui', 'CommentCard.tsx') }, + }, + ], + }) +}) + +it('supports using a custom folder as an alias for a canonical FSD layer', async () => { + const root = parseIntoFsdRoot(` + πŸ“‚ screens + πŸ“‚ settings + πŸ“‚ profile + πŸ“„ index.ts + `) + const fsdWithScreens = createFsdPlugin({ layerAliases: { pages: 'screens' } }) + const rule = fsdWithScreens.plugin.ruleDefinitions.find((rule) => rule.name === 'fsd/no-segmentless-slices') + + expect(await Promise.resolve(rule?.check(root, {}))).toEqual({ + diagnostics: [ + { + message: 'This slice has no segments. Consider dividing the code inside into segments.', + location: { path: joinFromRoot('screens', 'settings', 'profile') }, + }, + ], + }) +}) + +it('reports aliased layer names in diagnostics', async () => { + const root = parseIntoFsdRoot(` + πŸ“‚ screens + πŸ“„ index.ts + `) + const fsdWithScreens = createFsdPlugin({ layerAliases: { pages: 'screens' } }) + const rule = fsdWithScreens.plugin.ruleDefinitions.find((rule) => rule.name === 'fsd/no-layer-public-api') + + expect(await Promise.resolve(rule?.check(root, {}))).toEqual({ + diagnostics: [ + { + message: 'Layer "screens" should not have an index file', + location: { path: joinFromRoot('screens', 'index.ts') }, + }, + ], + }) +}) + +it('reports aliased layer names in slice references', async () => { + const root = parseIntoFsdRoot( + ` + πŸ“‚ entities + πŸ“‚ export-encoding + πŸ“‚ model + πŸ“„ index.ts + πŸ“„ index.ts + πŸ“‚ screens + πŸ“‚ export-worker + πŸ“‚ ui + πŸ“„ ExportWorker.tsx + πŸ“„ index.ts + `, + joinFromRoot('src'), + ) + const fsdWithScreens = createFsdPlugin({ layerAliases: { pages: 'screens' } }) + const rule = fsdWithScreens.plugin.ruleDefinitions.find((rule) => rule.name === 'fsd/insignificant-slice') + + await expect(rule?.check(root, {})).resolves.toEqual({ + diagnostics: [ + { + message: 'This slice has only one reference in slice "screens/export-worker". Consider merging them.', + location: { path: joinFromRoot('src', 'entities', 'export-encoding') }, + }, + ], + }) +}) diff --git a/packages/steiger-plugin-fsd/src/no-cross-imports/index.ts b/packages/steiger-plugin-fsd/src/no-cross-imports/index.ts index daad00df..e2ebf676 100644 --- a/packages/steiger-plugin-fsd/src/no-cross-imports/index.ts +++ b/packages/steiger-plugin-fsd/src/no-cross-imports/index.ts @@ -1,5 +1,4 @@ import * as fs from 'node:fs' -import { join } from 'node:path' import { isCrossImportPublicApi } from '@feature-sliced/filesystem' import { parse as parseNearestTsConfig } from 'tsconfck' import type { PartialDiagnostic, Rule } from '@steiger/toolkit' @@ -9,14 +8,15 @@ import { collectRelatedTsConfigs } from '../_lib/collect-related-ts-configs.js' import { resolveDependency } from '../_lib/resolve-dependency.js' import { NAMESPACE } from '../constants.js' import { extractDependencies, getSourceType } from '../_language-tools/index.js' +import { getLayerPath, type FsdRuleOptions } from '../fsd-options.js' const noCrossImports = { name: `${NAMESPACE}/no-cross-imports` as const, - async check(root) { + async check(root, ruleOptions: FsdRuleOptions = {}) { const diagnostics: Array = [] const parseResult = await parseNearestTsConfig(root.children[0]?.path ?? root.path) const tsConfigs = collectRelatedTsConfigs(parseResult) - const sourceFileIndex = indexSourceFiles(root) + const sourceFileIndex = indexSourceFiles(root, ruleOptions.layerConvention) for (const sourceFile of Object.values(sourceFileIndex)) { const sourceType = getSourceType(sourceFile.file.path) @@ -51,7 +51,7 @@ const noCrossImports = { !isCrossImportPublicApi(dependencyLocation.file, { inSlice: dependencyLocation.sliceName, forSlice: sourceFile.sliceName, - layerPath: join(root.path, dependencyLocation.layerName), + layerPath: getLayerPath(root, dependencyLocation.layerName, ruleOptions.layerConvention), }) ) { diagnostics.push({ @@ -65,6 +65,6 @@ const noCrossImports = { return { diagnostics } }, -} satisfies Rule +} satisfies Rule export default noCrossImports diff --git a/packages/steiger-plugin-fsd/src/no-file-segments/index.ts b/packages/steiger-plugin-fsd/src/no-file-segments/index.ts index 60b739c8..1d834b94 100644 --- a/packages/steiger-plugin-fsd/src/no-file-segments/index.ts +++ b/packages/steiger-plugin-fsd/src/no-file-segments/index.ts @@ -2,14 +2,15 @@ import { basename } from 'node:path' import { getLayers, getSlices, isSliced } from '@feature-sliced/filesystem' import type { PartialDiagnostic, Rule } from '@steiger/toolkit' import { NAMESPACE } from '../constants.js' +import type { FsdRuleOptions } from '../fsd-options.js' const noFileSegments = { name: `${NAMESPACE}/no-file-segments`, - check(root) { + check(root, ruleOptions: FsdRuleOptions = {}) { const diagnostics: Array = [] - for (const layer of Object.values(getLayers(root))) { - if (!isSliced(layer)) { + for (const layer of Object.values(getLayers(root, ruleOptions.layerConvention))) { + if (!isSliced(layer, ruleOptions.layerConvention)) { for (const child of layer.children) { if (child.type === 'file') { diagnostics.push({ @@ -34,7 +35,7 @@ const noFileSegments = { return { diagnostics } }, -} satisfies Rule +} satisfies Rule export default noFileSegments diff --git a/packages/steiger-plugin-fsd/src/no-higher-level-imports/index.ts b/packages/steiger-plugin-fsd/src/no-higher-level-imports/index.ts index d886a7d5..831ee18f 100644 --- a/packages/steiger-plugin-fsd/src/no-higher-level-imports/index.ts +++ b/packages/steiger-plugin-fsd/src/no-higher-level-imports/index.ts @@ -8,14 +8,15 @@ import { collectRelatedTsConfigs } from '../_lib/collect-related-ts-configs.js' import { resolveDependency } from '../_lib/resolve-dependency.js' import { NAMESPACE } from '../constants.js' import { extractDependencies, getSourceType } from '../_language-tools/index.js' +import { getLayerDisplayName, type FsdRuleOptions } from '../fsd-options.js' const noHigherLevelImports = { name: `${NAMESPACE}/no-higher-level-imports` as const, - async check(root) { + async check(root, ruleOptions: FsdRuleOptions = {}) { const diagnostics: Array = [] const parseResult = await parseNearestTsConfig(root.children[0]?.path ?? root.path) const tsConfigs = collectRelatedTsConfigs(parseResult) - const sourceFileIndex = indexSourceFiles(root) + const sourceFileIndex = indexSourceFiles(root, ruleOptions.layerConvention) for (const sourceFile of Object.values(sourceFileIndex)) { const sourceType = getSourceType(sourceFile.file.path) @@ -45,7 +46,7 @@ const noHigherLevelImports = { if (thisLayerIndex < dependencyLayerIndex) { diagnostics.push({ - message: `Forbidden import from higher layer "${dependencyLocation.layerName}".`, + message: `Forbidden import from higher layer "${getLayerDisplayName(root, dependencyLocation.layerName, ruleOptions.layerConvention)}".`, location: { path: sourceFile.file.path }, }) } @@ -54,6 +55,6 @@ const noHigherLevelImports = { return { diagnostics } }, -} satisfies Rule +} satisfies Rule export default noHigherLevelImports diff --git a/packages/steiger-plugin-fsd/src/no-layer-public-api/index.ts b/packages/steiger-plugin-fsd/src/no-layer-public-api/index.ts index 1073352f..fbceacde 100644 --- a/packages/steiger-plugin-fsd/src/no-layer-public-api/index.ts +++ b/packages/steiger-plugin-fsd/src/no-layer-public-api/index.ts @@ -1,6 +1,7 @@ -import { getIndexes, getLayers } from '@feature-sliced/filesystem' +import { getIndexes, getLayers, type LayerName } from '@feature-sliced/filesystem' import type { PartialDiagnostic, Rule } from '@steiger/toolkit' import { NAMESPACE } from '../constants.js' +import { getLayerDisplayName, type FsdRuleOptions } from '../fsd-options.js' /** Layers that are allowed to have an index file. */ const exceptionLayers = ['app'] @@ -8,17 +9,17 @@ const exceptionLayers = ['app'] /** Forbid index files on layer level. */ const noLayerPublicApi = { name: `${NAMESPACE}/no-layer-public-api` as const, - check(root) { + check(root, ruleOptions: FsdRuleOptions = {}) { const diagnostics: Array = [] - for (const [layerName, layer] of Object.entries(getLayers(root))) { + for (const [layerName, layer] of Object.entries(getLayers(root, ruleOptions.layerConvention))) { const indexes = getIndexes(layer) const notAmongExceptions = !exceptionLayers.includes(layerName) if (notAmongExceptions) { for (const index of indexes) { diagnostics.push({ - message: `Layer "${layerName}" should not have an index file`, + message: `Layer "${getLayerDisplayName(root, layerName as LayerName, ruleOptions.layerConvention)}" should not have an index file`, location: { path: index.path }, }) } @@ -27,6 +28,6 @@ const noLayerPublicApi = { return { diagnostics } }, -} satisfies Rule +} satisfies Rule export default noLayerPublicApi diff --git a/packages/steiger-plugin-fsd/src/no-public-api-sidestep/index.ts b/packages/steiger-plugin-fsd/src/no-public-api-sidestep/index.ts index b42cfa22..76e8dd2d 100644 --- a/packages/steiger-plugin-fsd/src/no-public-api-sidestep/index.ts +++ b/packages/steiger-plugin-fsd/src/no-public-api-sidestep/index.ts @@ -9,15 +9,16 @@ import { collectRelatedTsConfigs } from '../_lib/collect-related-ts-configs.js' import { resolveDependency } from '../_lib/resolve-dependency.js' import { NAMESPACE } from '../constants.js' import { extractDependencies, getSourceType } from '../_language-tools/index.js' +import type { FsdRuleOptions } from '../fsd-options.js' /** Restrict imports that go inside the slice, sidestepping the public API. */ const noPublicApiSidestep = { name: `${NAMESPACE}/no-public-api-sidestep` as const, - async check(root) { + async check(root, ruleOptions: FsdRuleOptions = {}) { const diagnostics: Array = [] const parseResult = await parseNearestTsConfig(root.children[0]?.path ?? root.path) const tsConfigs = collectRelatedTsConfigs(parseResult) - const sourceFileIndex = indexSourceFiles(root) + const sourceFileIndex = indexSourceFiles(root, ruleOptions.layerConvention) for (const sourceFile of Object.values(sourceFileIndex)) { const sourceType = getSourceType(sourceFile.file.path) @@ -44,12 +45,13 @@ const noPublicApiSidestep = { // This rule only concerns imports from other slices if ( sourceFile.layerName === dependencyLocation.layerName && - (!isSliced(dependencyLocation.layerName) || sourceFile.sliceName === dependencyLocation.sliceName) + (!isSliced(dependencyLocation.layerName, ruleOptions.layerConvention) || + sourceFile.sliceName === dependencyLocation.sliceName) ) { continue } - if (isSliced(dependencyLocation.layerName)) { + if (isSliced(dependencyLocation.layerName, ruleOptions.layerConvention)) { if (dependencyLocation.segmentName !== null && dependencyLocation.segmentName !== crossReferenceToken) { diagnostics.push({ message: `Forbidden sidestep of public API when importing from "${dependency}".`, @@ -57,7 +59,7 @@ const noPublicApiSidestep = { }) } } else if (dependencyLocation.segmentName !== null) { - const layer = getLayers(root)[dependencyLocation.layerName] + const layer = getLayers(root, ruleOptions.layerConvention)[dependencyLocation.layerName] if (layer === undefined) { continue } @@ -104,6 +106,6 @@ const noPublicApiSidestep = { return { diagnostics } }, -} satisfies Rule +} satisfies Rule export default noPublicApiSidestep diff --git a/packages/steiger-plugin-fsd/src/no-reserved-folder-names/index.ts b/packages/steiger-plugin-fsd/src/no-reserved-folder-names/index.ts index 7c53af3c..a0910ed9 100644 --- a/packages/steiger-plugin-fsd/src/no-reserved-folder-names/index.ts +++ b/packages/steiger-plugin-fsd/src/no-reserved-folder-names/index.ts @@ -3,14 +3,15 @@ import { getAllSegments, conventionalSegmentNames, crossReferenceToken } from '@ import { findAllRecursively, type PartialDiagnostic, type Rule } from '@steiger/toolkit' import { NAMESPACE } from '../constants.js' +import type { FsdRuleOptions } from '../fsd-options.js' /** Forbid subfolders in segments that have names of common segments (e.g., shared/lib/ui). */ const noReservedFolderNames = { name: `${NAMESPACE}/no-reserved-folder-names` as const, - check(root) { + check(root, ruleOptions: FsdRuleOptions = {}) { const diagnostics: Array = [] - for (const { segment } of getAllSegments(root)) { + for (const { segment } of getAllSegments(root, ruleOptions.layerConvention)) { if (segment.type === 'file') { continue } @@ -44,6 +45,6 @@ const noReservedFolderNames = { return { diagnostics } }, -} satisfies Rule +} satisfies Rule export default noReservedFolderNames diff --git a/packages/steiger-plugin-fsd/src/no-segmentless-slices/index.ts b/packages/steiger-plugin-fsd/src/no-segmentless-slices/index.ts index 4a23394e..23c61340 100644 --- a/packages/steiger-plugin-fsd/src/no-segmentless-slices/index.ts +++ b/packages/steiger-plugin-fsd/src/no-segmentless-slices/index.ts @@ -1,14 +1,15 @@ import { getLayers, isSlice, isSliced } from '@feature-sliced/filesystem' import type { Folder, PartialDiagnostic, Rule } from '@steiger/toolkit' import { NAMESPACE } from '../constants.js' +import type { FsdRuleOptions } from '../fsd-options.js' const noSegmentlessSlices = { name: `${NAMESPACE}/no-segmentless-slices` as const, - check(root) { + check(root, ruleOptions: FsdRuleOptions = {}) { const diagnostics: Array = [] - for (const layer of Object.values(getLayers(root))) { - if (!isSliced(layer)) { + for (const layer of Object.values(getLayers(root, ruleOptions.layerConvention))) { + if (!isSliced(layer, ruleOptions.layerConvention)) { continue } @@ -36,7 +37,7 @@ const noSegmentlessSlices = { return { diagnostics } }, -} satisfies Rule +} satisfies Rule export default noSegmentlessSlices diff --git a/packages/steiger-plugin-fsd/src/no-segments-on-sliced-layers/index.ts b/packages/steiger-plugin-fsd/src/no-segments-on-sliced-layers/index.ts index 8a0d95be..43c78792 100644 --- a/packages/steiger-plugin-fsd/src/no-segments-on-sliced-layers/index.ts +++ b/packages/steiger-plugin-fsd/src/no-segments-on-sliced-layers/index.ts @@ -3,15 +3,16 @@ import { basename } from 'node:path' import { getLayers, isSliced, conventionalSegmentNames } from '@feature-sliced/filesystem' import type { PartialDiagnostic, Rule } from '@steiger/toolkit' import { NAMESPACE } from '../constants.js' +import type { FsdRuleOptions } from '../fsd-options.js' const noSegmentsOnSlicedLayers = { name: `${NAMESPACE}/no-segments-on-sliced-layers` as const, - check(root) { + check(root, ruleOptions: FsdRuleOptions = {}) { const diagnostics: Array = [] - const layers = Object.values(getLayers(root)) + const layers = Object.values(getLayers(root, ruleOptions.layerConvention)) for (const layer of layers) { - if (isSliced(layer)) { + if (isSliced(layer, ruleOptions.layerConvention)) { for (const directChild of layer.children) { if (directChild.type === 'folder') { const folderName = basename(directChild.path) @@ -30,6 +31,6 @@ const noSegmentsOnSlicedLayers = { return { diagnostics } }, -} satisfies Rule +} satisfies Rule export default noSegmentsOnSlicedLayers diff --git a/packages/steiger-plugin-fsd/src/no-ui-in-app/index.ts b/packages/steiger-plugin-fsd/src/no-ui-in-app/index.ts index 62287fa9..97b91b97 100644 --- a/packages/steiger-plugin-fsd/src/no-ui-in-app/index.ts +++ b/packages/steiger-plugin-fsd/src/no-ui-in-app/index.ts @@ -1,13 +1,14 @@ import type { PartialDiagnostic, Rule } from '@steiger/toolkit' import { NAMESPACE } from '../constants.js' import { getLayers, getSegments } from '@feature-sliced/filesystem' +import type { FsdRuleOptions } from '../fsd-options.js' const noUiInApp = { name: `${NAMESPACE}/no-ui-in-app` as const, - check(root) { + check(root, ruleOptions: FsdRuleOptions = {}) { const diagnostics: Array = [] - const layers = getLayers(root) + const layers = getLayers(root, ruleOptions.layerConvention) if (layers.app !== undefined) { const segments = getSegments(layers.app) @@ -22,6 +23,6 @@ const noUiInApp = { return { diagnostics } }, -} satisfies Rule +} satisfies Rule export default noUiInApp diff --git a/packages/steiger-plugin-fsd/src/public-api/index.ts b/packages/steiger-plugin-fsd/src/public-api/index.ts index 20ee75f9..7e4113c5 100644 --- a/packages/steiger-plugin-fsd/src/public-api/index.ts +++ b/packages/steiger-plugin-fsd/src/public-api/index.ts @@ -2,15 +2,16 @@ import { join } from 'node:path' import { getLayers, getSegments, isSliced, getIndexes, getSlices } from '@feature-sliced/filesystem' import type { PartialDiagnostic, Rule } from '@steiger/toolkit' import { NAMESPACE } from '../constants.js' +import type { FsdRuleOptions } from '../fsd-options.js' /** Require slices (or segments on sliceless layers) to have a public API. */ const publicApi = { name: `${NAMESPACE}/public-api` as const, - check(root) { + check(root, ruleOptions: FsdRuleOptions = {}) { const diagnostics: Array = [] - for (const [layerName, layer] of Object.entries(getLayers(root))) { - if (!isSliced(layer)) { + for (const [layerName, layer] of Object.entries(getLayers(root, ruleOptions.layerConvention))) { + if (!isSliced(layer, ruleOptions.layerConvention)) { if (layerName === 'app') { // The app layer is the top-level layer, there's no need for public API. continue @@ -71,6 +72,6 @@ const publicApi = { return { diagnostics } }, -} satisfies Rule +} satisfies Rule export default publicApi diff --git a/packages/steiger-plugin-fsd/src/repetitive-naming/index.ts b/packages/steiger-plugin-fsd/src/repetitive-naming/index.ts index e2e95b29..b7b76771 100644 --- a/packages/steiger-plugin-fsd/src/repetitive-naming/index.ts +++ b/packages/steiger-plugin-fsd/src/repetitive-naming/index.ts @@ -4,6 +4,7 @@ import type { PartialDiagnostic, Rule } from '@steiger/toolkit' import { NAMESPACE } from '../constants.js' import { groupSlices } from '../_lib/group-slices.js' +import type { FsdRuleOptions } from '../fsd-options.js' /** * Pattern that matches one word in different naming conventions. @@ -20,11 +21,11 @@ const wordPattern = /(?:[A-Z]+|[a-z]+)[a-z]*/g /** Warn about repetitive parts in slice names (e.g. adding page to every slice on Pages) */ const repetitiveNaming = { name: `${NAMESPACE}/repetitive-naming` as const, - check(root) { + check(root, ruleOptions: FsdRuleOptions = {}) { const diagnostics: Array = [] - for (const layer of Object.values(getLayers(root))) { - if (!isSliced(layer)) { + for (const layer of Object.values(getLayers(root, ruleOptions.layerConvention))) { + if (!isSliced(layer, ruleOptions.layerConvention)) { continue } @@ -56,6 +57,6 @@ const repetitiveNaming = { return { diagnostics } }, -} satisfies Rule +} satisfies Rule export default repetitiveNaming diff --git a/packages/steiger-plugin-fsd/src/segments-by-purpose/index.ts b/packages/steiger-plugin-fsd/src/segments-by-purpose/index.ts index 344ed79c..730ca7ce 100644 --- a/packages/steiger-plugin-fsd/src/segments-by-purpose/index.ts +++ b/packages/steiger-plugin-fsd/src/segments-by-purpose/index.ts @@ -1,6 +1,7 @@ import { getLayers, getSegments, getSlices, isSliced } from '@feature-sliced/filesystem' import type { PartialDiagnostic, Rule } from '@steiger/toolkit' import { NAMESPACE } from '../constants.js' +import type { FsdRuleOptions } from '../fsd-options.js' const BAD_NAMES_GENERIC = [ 'component', @@ -79,15 +80,15 @@ const BAD_NAMES = new Set([ /** Discourage the use of segment names that group code by its essence, and instead encourage grouping by purpose. */ const segmentsByPurpose = { name: `${NAMESPACE}/segments-by-purpose` as const, - check(root) { + check(root, ruleOptions: FsdRuleOptions = {}) { const diagnostics: Array = [] - for (const layer of Object.values(getLayers(root))) { + for (const layer of Object.values(getLayers(root, ruleOptions.layerConvention))) { if (layer === null) { continue } - if (!isSliced(layer)) { + if (!isSliced(layer, ruleOptions.layerConvention)) { for (const [segmentName, segment] of Object.entries(getSegments(layer))) { if (BAD_NAMES.has(segmentName)) { diagnostics.push({ @@ -112,6 +113,6 @@ const segmentsByPurpose = { return { diagnostics } }, -} satisfies Rule +} satisfies Rule export default segmentsByPurpose diff --git a/packages/steiger-plugin-fsd/src/shared-lib-grouping/index.ts b/packages/steiger-plugin-fsd/src/shared-lib-grouping/index.ts index bf7fec66..70ea9e90 100644 --- a/packages/steiger-plugin-fsd/src/shared-lib-grouping/index.ts +++ b/packages/steiger-plugin-fsd/src/shared-lib-grouping/index.ts @@ -1,16 +1,17 @@ import { getLayers, getSegments } from '@feature-sliced/filesystem' import type { PartialDiagnostic, Rule } from '@steiger/toolkit' import { NAMESPACE } from '../constants.js' +import type { FsdRuleOptions } from '../fsd-options.js' const THRESHOLD = 15 /** Warn about too much stuff in shared/lib. */ const sharedLibGrouping = { name: `${NAMESPACE}/shared-lib-grouping` as const, - check(root) { + check(root, ruleOptions: FsdRuleOptions = {}) { const diagnostics: Array = [] - const { shared } = getLayers(root) + const { shared } = getLayers(root, ruleOptions.layerConvention) if (!shared) { return { diagnostics } } @@ -29,6 +30,6 @@ const sharedLibGrouping = { return { diagnostics } }, -} satisfies Rule +} satisfies Rule export default sharedLibGrouping diff --git a/packages/steiger-plugin-fsd/src/typo-in-layer-name/index.ts b/packages/steiger-plugin-fsd/src/typo-in-layer-name/index.ts index 93d9aa68..6e8a282c 100644 --- a/packages/steiger-plugin-fsd/src/typo-in-layer-name/index.ts +++ b/packages/steiger-plugin-fsd/src/typo-in-layer-name/index.ts @@ -3,13 +3,15 @@ import { NAMESPACE } from '../constants.js' import { LayerName, layerSequence } from '@feature-sliced/filesystem' import { distance } from 'fastest-levenshtein' import { basename, join } from 'node:path' +import type { FsdRuleOptions } from '../fsd-options.js' const LEVENSHTEIN_DISTANCE_UPPER_BOUND = 3 const typoInLayerName = { name: `${NAMESPACE}/typo-in-layer-name` as const, - check(root) { + check(root, ruleOptions: FsdRuleOptions = {}) { const diagnostics: Array = [] + const layerAliases = new Set(Object.values(ruleOptions.layerConvention?.layerAliases ?? {})) // construct list of suggestions, like [{ input: 'shraed', suggestion: 'shared', distance: 2 }, ...], // limit Levenshtein distance upper bound to 3, @@ -18,6 +20,9 @@ const typoInLayerName = { .filter((child) => child.type === 'folder') .flatMap((child) => { const layer = basename(child.path) + if (layerAliases.has(layer)) { + return [] + } return layerSequence .map((sequenceLayer) => ({ @@ -62,6 +67,6 @@ const typoInLayerName = { return { diagnostics } }, -} satisfies Rule +} satisfies Rule export default typoInLayerName diff --git a/packages/steiger/README.md b/packages/steiger/README.md index 72870bb4..aa27b81f 100644 --- a/packages/steiger/README.md +++ b/packages/steiger/README.md @@ -92,6 +92,24 @@ export default defineConfig([ ]) ``` +### Layer aliases + +If your project uses a different folder name for a canonical FSD layer, create a configured FSD plugin instance and use its recommended config: + +```javascript +// ./steiger.config.js +import { defineConfig } from 'steiger' +import { createFsdPlugin } from '@feature-sliced/steiger-plugin' + +const fsd = createFsdPlugin({ + layerAliases: { + pages: 'screens', + }, +}) + +export default defineConfig([...fsd.configs.recommended]) +``` + > [!TIP] > If you want Steiger to ignore certain files, add an object like this to the config array: >