Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/lazy-windows-sip.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@feature-sliced/steiger-plugin': minor
---

Add FSD layer aliases through `createFsdPlugin({ layerAliases })`.
20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,26 @@ export default defineConfig([
[You can see more examples here](CONFIG_EXAMPLES.md)
</details>

### 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.
Expand Down
7 changes: 4 additions & 3 deletions packages/steiger-plugin-fsd/src/_lib/index-source-files.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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<string, SourceFile> {
export function indexSourceFiles(root: Folder, layerConvention?: LayerConvention): Record<string, SourceFile> {
const index = {} as Record<string, SourceFile>
function walk(node: File | Folder, metadata: Pick<SourceFile, 'layerName' | 'sliceName' | 'segmentName'>) {
if (node.type === 'file') {
Expand All @@ -26,15 +27,15 @@ export function indexSourceFiles(root: Folder): Record<string, SourceFile> {
}
}

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.
layer.children
.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 })
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<PartialDiagnostic> = []

const layers = getLayers(root)
const layers = getLayers(root, ruleOptions.layerConvention)
const sharedLayer = layers.shared

if (sharedLayer === undefined) {
Expand All @@ -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))

Expand Down Expand Up @@ -68,6 +69,6 @@ const ambiguousSliceNames = {

return { diagnostics }
},
} satisfies Rule
} satisfies Rule<unknown, FsdRuleOptions>

export default ambiguousSliceNames
11 changes: 6 additions & 5 deletions packages/steiger-plugin-fsd/src/excessive-slicing/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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<PartialDiagnostic> = []

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
}

Expand All @@ -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 {
Expand All @@ -47,6 +48,6 @@ const excessiveSlicing = {

return { diagnostics }
},
} satisfies Rule
} satisfies Rule<unknown, FsdRuleOptions>

export default excessiveSlicing
12 changes: 6 additions & 6 deletions packages/steiger-plugin-fsd/src/forbidden-imports/index.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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<PartialDiagnostic> = []
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)
Expand Down Expand Up @@ -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({
Expand All @@ -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 },
})
}
Expand All @@ -75,6 +75,6 @@ const forbiddenImports = {

return { diagnostics }
},
} satisfies Rule
} satisfies Rule<unknown, FsdRuleOptions>

export default forbiddenImports
55 changes: 55 additions & 0 deletions packages/steiger-plugin-fsd/src/fsd-options.ts
Original file line number Diff line number Diff line change
@@ -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<Context, Options extends Record<string, unknown>, Name extends string>(
rule: Rule<Context, Options, Name>,
ruleOptions: FsdRuleOptions,
): Rule<Context, Options & FsdRuleOptions, Name> {
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}`
}
7 changes: 4 additions & 3 deletions packages/steiger-plugin-fsd/src/import-locality/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<PartialDiagnostic> = []
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)
Expand Down Expand Up @@ -58,6 +59,6 @@ const importLocality = {

return { diagnostics }
},
} satisfies Rule
} satisfies Rule<unknown, FsdRuleOptions>

export default importLocality
7 changes: 4 additions & 3 deletions packages/steiger-plugin-fsd/src/inconsistent-naming/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<PartialDiagnostic> = []

const { entities } = getLayers(root)
const { entities } = getLayers(root, ruleOptions.layerConvention)
if (entities === undefined) {
return { diagnostics }
}
Expand Down Expand Up @@ -56,7 +57,7 @@ const inconsistentNaming = {

return { diagnostics }
},
} satisfies Rule
} satisfies Rule<unknown, FsdRuleOptions>

export default inconsistentNaming

Expand Down
38 changes: 23 additions & 15 deletions packages/steiger-plugin-fsd/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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<typeof plugin>
export default createFsdPlugin()

export type FSDConfigObject = ConfigObjectOf<ReturnType<typeof createFsdPlugin>['plugin']>
export type { FsdPluginOptions, LayerAliases } from './fsd-options.js'
Loading