diff --git a/.changeset/mean-pianos-cover.md b/.changeset/mean-pianos-cover.md new file mode 100644 index 0000000..82d4a5a --- /dev/null +++ b/.changeset/mean-pianos-cover.md @@ -0,0 +1,6 @@ +--- +'@feature-sliced/steiger-plugin': minor +'steiger': minor +--- + +Add no-segments-on-sliced-layers rule diff --git a/README.md b/README.md index aa2be9a..28e71af 100644 --- a/README.md +++ b/README.md @@ -66,6 +66,7 @@ Currently, Steiger is not extendable with more rules, though that will change in no-public-api-sidestep Forbid going around the public API of a slice to import directly from an internal module in a slice. no-reserved-folder-names Forbid subfolders in segments that have the same name as other conventional segments. no-segmentless-slices Forbid slices that don't have any segments. + no-segments-on-sliced-layers Forbid segments (like ui, lib, api ...) that appear directly in sliced layer folders (entities, features, ...) public-api Require slices (and segments on sliceless layers like Shared) to have a public API definition. repetitive-naming Ensure that all entities are named consistently in terms of pluralization. segments-by-purpose Discourage the use of segment names that group code by its essence, and instead encourage grouping by purpose diff --git a/packages/steiger-plugin-fsd/src/index.ts b/packages/steiger-plugin-fsd/src/index.ts index 6e63f8b..8382722 100644 --- a/packages/steiger-plugin-fsd/src/index.ts +++ b/packages/steiger-plugin-fsd/src/index.ts @@ -7,6 +7,7 @@ import noLayerPublicApi from './no-layer-public-api/index.js' import noPublicApiSidestep from './no-public-api-sidestep/index.js' import noReservedFolderNames from './no-reserved-folder-names/index.js' import noSegmentlessSlices from './no-segmentless-slices/index.js' +import noSegmentsOnSlicedLayers from './no-segments-on-sliced-layers/index.js' import publicApi from './public-api/index.js' import repetitiveNaming from './repetitive-naming/index.js' import segmentsByPurpose from './segments-by-purpose/index.js' @@ -23,6 +24,7 @@ export default [ noPublicApiSidestep, noReservedFolderNames, noSegmentlessSlices, + noSegmentsOnSlicedLayers, publicApi, repetitiveNaming, segmentsByPurpose, diff --git a/packages/steiger-plugin-fsd/src/no-segments-on-sliced-layers/README.md b/packages/steiger-plugin-fsd/src/no-segments-on-sliced-layers/README.md new file mode 100644 index 0000000..b1f3042 --- /dev/null +++ b/packages/steiger-plugin-fsd/src/no-segments-on-sliced-layers/README.md @@ -0,0 +1,47 @@ +# `no-segmentless-slices` + +Forbid segments that appear in direct children of sliced layers. + +Examples of project structures that pass this rule: + +``` +📂 shared + 📂 ui + 📄 index.ts + 📂 lib + 📄 index.ts +📂 entities + 📂 user + 📂 ui + 📂 model + 📄 index.ts +📂 pages + 📂 home + 📂 ui + 📄 index.ts +``` + +Examples of project structures that fail this rule: + +``` +📂 shared + 📂 ui + 📄 index.ts + 📂 lib + 📄 index.ts +📂 entities + 📂 user + 📂 ui + 📂 model + 📄 index.ts + 📂 api // ❌ + 📄 index.ts +📂 pages + 📂 home + 📂 ui + 📄 index.ts +``` + +## Rationale + +Several folder names like `ui` or `api` are conventionally understood in FSD as segments. When you have segments as direct children, it's either because that's the name you chose for your slice, or because some code ended up unsliced. In the first case, where that is the name of your slice, such a name is likely to cause confusion among developers who are familiar with FSD. In the second case, where a segment on a layer is because there was no slice to put it in, it's a violation of FSD structure, which decreases the benefits of FSD and also causes confusion. diff --git a/packages/steiger-plugin-fsd/src/no-segments-on-sliced-layers/index.spec.ts b/packages/steiger-plugin-fsd/src/no-segments-on-sliced-layers/index.spec.ts new file mode 100644 index 0000000..113110a --- /dev/null +++ b/packages/steiger-plugin-fsd/src/no-segments-on-sliced-layers/index.spec.ts @@ -0,0 +1,99 @@ +import { describe, expect, it } from 'vitest' + +import noSegmentsOnSlicedLayers from './index.js' +import { joinFromRoot, parseIntoFsdRoot, compareMessages } from '../_lib/prepare-test.js' + +describe('no-segments-on-sliced-layers rule', () => { + it('reports no errors on a project where the sliced layers has no segments in direct children', () => { + const root = parseIntoFsdRoot(` + 📂 shared + 📂 ui + 📄 index.ts + 📂 i18n + 📄 index.ts + 📂 entities + 📂 user + 📂 ui + 📄 Name.tsx + 📂 api + 📄 useCurrentUser.ts + 📄 index.ts + 📂 document + 📂 api + 📄 useDocument.ts + 📂 pages + 📂 home + 📂 ui + 📄 index.ts + `) + + expect(noSegmentsOnSlicedLayers.check(root)).toEqual({ diagnostics: [] }) + }) + + it('reports errors on a project where a sliced layer has segments among its direct children', () => { + const root = parseIntoFsdRoot(` + 📂 shared + 📂 ui + 📄 index.ts + 📂 i18n + 📄 index.ts + 📂 entities + 📂 user + 📄 index.ts + 📄 Name.tsx + 📂 ui + 📄 index.ts + 📂 features + 📂 user + 📂 ui + 📄 LogIn.tsx + 📄 index.ts + 📄 index.ts + 📂 api + 📄 index.ts + 📂 widgets + 📂 footer + 📂 ui + 📄 Footer.tsx + 📄 index.ts + 📄 index.ts + 📂 config + 📄 index.ts + 📂 pages + 📂 home + 📂 ui + 📄 index.ts + 📂 settings + 📂 profile + 📄 ProfilePage.tsx + 📄 index.ts + 📂 lib + 📄 index.ts + `) + + const diagnostics = noSegmentsOnSlicedLayers.check(root).diagnostics.sort(compareMessages) + + expect(diagnostics).toEqual([ + { + message: + 'Conventional segment "api" should not be a direct child of a sliced layer. Consider moving it inside a slice or, if that is a slice, consider a different name for it to avoid confusion with segments.', + location: { path: joinFromRoot('features', 'api') }, + }, + { + message: + 'Conventional segment "config" should not be a direct child of a sliced layer. Consider moving it inside a slice or, if that is a slice, consider a different name for it to avoid confusion with segments.', + location: { path: joinFromRoot('widgets', 'config') }, + }, + { + message: + 'Conventional segment "lib" should not be a direct child of a sliced layer. Consider moving it inside a slice or, if that is a slice, consider a different name for it to avoid confusion with segments.', + location: { path: joinFromRoot('pages', 'lib') }, + }, + { + message: + 'Conventional segment "ui" should not be a direct child of a sliced layer. Consider moving it inside a slice or, if that is a slice, consider a different name for it to avoid confusion with segments.', + location: { path: joinFromRoot('entities', 'ui') }, + }, + ]) + }) +}) 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 new file mode 100644 index 0000000..b04a46f --- /dev/null +++ b/packages/steiger-plugin-fsd/src/no-segments-on-sliced-layers/index.ts @@ -0,0 +1,34 @@ +import { basename } from 'node:path' + +import { getLayers, isSliced, conventionalSegmentNames } from '@feature-sliced/filesystem' +import type { Diagnostic, Rule } from '@steiger/types' + +const noSegmentsOnSlicedLayers = { + name: 'no-segments-on-sliced-layers', + check(root) { + const diagnostics: Array = [] + const layers = Object.values(getLayers(root)) + + for (const layer of layers) { + if (isSliced(layer)) { + for (const directChild of layer.children) { + if (directChild.type === 'folder') { + const folderName = basename(directChild.path) + const isConventionalSegment = folderName && conventionalSegmentNames.includes(folderName) + + if (isConventionalSegment) { + diagnostics.push({ + message: `Conventional segment "${folderName}" should not be a direct child of a sliced layer. Consider moving it inside a slice or, if that is a slice, consider a different name for it to avoid confusion with segments.`, + location: { path: directChild.path }, + }) + } + } + } + } + } + + return { diagnostics } + }, +} satisfies Rule + +export default noSegmentsOnSlicedLayers diff --git a/packages/steiger/README.md b/packages/steiger/README.md index aa2be9a..28e71af 100644 --- a/packages/steiger/README.md +++ b/packages/steiger/README.md @@ -66,6 +66,7 @@ Currently, Steiger is not extendable with more rules, though that will change in no-public-api-sidestep Forbid going around the public API of a slice to import directly from an internal module in a slice. no-reserved-folder-names Forbid subfolders in segments that have the same name as other conventional segments. no-segmentless-slices Forbid slices that don't have any segments. + no-segments-on-sliced-layers Forbid segments (like ui, lib, api ...) that appear directly in sliced layer folders (entities, features, ...) public-api Require slices (and segments on sliceless layers like Shared) to have a public API definition. repetitive-naming Ensure that all entities are named consistently in terms of pluralization. segments-by-purpose Discourage the use of segment names that group code by its essence, and instead encourage grouping by purpose diff --git a/packages/steiger/src/models/config.ts b/packages/steiger/src/models/config.ts index c9aee7b..8241e07 100644 --- a/packages/steiger/src/models/config.ts +++ b/packages/steiger/src/models/config.ts @@ -14,6 +14,7 @@ export const schema = z.object({ 'no-public-api-sidestep', 'no-reserved-folder-names', 'no-segmentless-slices', + 'no-segments-on-sliced-layers', 'public-api', 'repetitive-naming', 'segments-by-purpose',