diff --git a/.changeset/quick-eggs-exist.md b/.changeset/quick-eggs-exist.md new file mode 100644 index 0000000..73d6e37 --- /dev/null +++ b/.changeset/quick-eggs-exist.md @@ -0,0 +1,5 @@ +--- +'@feature-sliced/steiger-plugin': patch +--- + +Make sure forbidden-imports rule checks files directly inside layers 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 1974b03..dd6d4fa 100644 --- a/packages/steiger-plugin-fsd/src/_lib/index-source-files.ts +++ b/packages/steiger-plugin-fsd/src/_lib/index-source-files.ts @@ -28,6 +28,13 @@ export function indexSourceFiles(root: Folder): Record { } for (const [layerName, layer] of Object.entries(getLayers(root))) { + // 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)) { for (const [segmentName, segment] of Object.entries(getSegments(layer))) { walk(segment, { layerName: layerName as LayerName, sliceName: null, segmentName }) @@ -70,6 +77,11 @@ if (import.meta.vitest) { 📄 EditorPage.tsx 📄 Editor.tsx 📄 index.ts + 📂 app + 📂 ui + 📄 index.ts + 📄 root.ts + 📄 index.ts `) expect(indexSourceFiles(root)).toEqual({ @@ -163,6 +175,33 @@ if (import.meta.vitest) { segmentName: 'ui', sliceName: null, }, + [joinFromRoot('app', 'ui', 'index.ts')]: { + file: { + path: joinFromRoot('app', 'ui', 'index.ts'), + type: 'file', + }, + layerName: 'app', + segmentName: 'ui', + sliceName: null, + }, + [joinFromRoot('app', 'root.ts')]: { + file: { + path: joinFromRoot('app', 'root.ts'), + type: 'file', + }, + layerName: 'app', + segmentName: 'root', + sliceName: null, + }, + [joinFromRoot('app', 'index.ts')]: { + file: { + path: joinFromRoot('app', 'index.ts'), + type: 'file', + }, + layerName: 'app', + segmentName: null, + sliceName: null, + }, }) }) } diff --git a/packages/steiger-plugin-fsd/src/_lib/prepare-test.ts b/packages/steiger-plugin-fsd/src/_lib/prepare-test.ts index cb84c08..3d6f98a 100644 --- a/packages/steiger-plugin-fsd/src/_lib/prepare-test.ts +++ b/packages/steiger-plugin-fsd/src/_lib/prepare-test.ts @@ -4,8 +4,11 @@ import type { FsdRoot } from '@feature-sliced/filesystem' import type { Folder, File, Diagnostic } from '@steiger/types' import { vi } from 'vitest' -/** Parse a multi-line indented string with emojis for files and folders into an FSD root. */ -export function parseIntoFsdRoot(fsMarkup: string): FsdRoot { +/** Parse a multi-line indented string with emojis for files and folders into an FSD root. + * @param fsMarkup - a file system tree represented in markup using file and folder emojis + * @param mountTo - virtually make the passed markup a subtree of the mountTo folder + * */ +export function parseIntoFsdRoot(fsMarkup: string, mountTo?: string): FsdRoot { function parseFolder(lines: Array, path: string): Folder { const children: Array = [] @@ -33,7 +36,7 @@ export function parseIntoFsdRoot(fsMarkup: string): FsdRoot { .map((line, _i, lines) => line.slice(lines[0].search(/\S/))) .filter(Boolean) - return parseFolder(lines, joinFromRoot()) + return parseFolder(lines, mountTo ?? joinFromRoot()) } export function compareMessages(a: Diagnostic, b: Diagnostic): number { @@ -149,4 +152,84 @@ if (import.meta.vitest) { ], }) }) + + test('it should return a nested root folder when the optional rootPath argument is passed', () => { + const markup = ` + 📂 entities + 📂 users + 📂 ui + 📄 index.ts + 📂 posts + 📂 ui + 📄 index.ts + 📂 shared + 📂 ui + 📄 index.ts + 📄 Button.tsx + ` + const root = parseIntoFsdRoot(markup, joinFromRoot('src')) + + expect(root).toEqual({ + type: 'folder', + path: joinFromRoot('src'), + children: [ + { + type: 'folder', + path: joinFromRoot('src', 'entities'), + children: [ + { + type: 'folder', + path: joinFromRoot('src', 'entities', 'users'), + children: [ + { + type: 'folder', + path: joinFromRoot('src', 'entities', 'users', 'ui'), + children: [], + }, + { + type: 'file', + path: joinFromRoot('src', 'entities', 'users', 'index.ts'), + }, + ], + }, + { + type: 'folder', + path: joinFromRoot('src', 'entities', 'posts'), + children: [ + { + type: 'folder', + path: joinFromRoot('src', 'entities', 'posts', 'ui'), + children: [], + }, + { + type: 'file', + path: joinFromRoot('src', 'entities', 'posts', 'index.ts'), + }, + ], + }, + ], + }, + { + type: 'folder', + path: joinFromRoot('src', 'shared'), + children: [ + { + type: 'folder', + path: joinFromRoot('src', 'shared', 'ui'), + children: [ + { + type: 'file', + path: joinFromRoot('src', 'shared', 'ui', 'index.ts'), + }, + { + type: 'file', + path: joinFromRoot('src', 'shared', 'ui', 'Button.tsx'), + }, + ], + }, + ], + }, + ], + }) + }) } diff --git a/packages/steiger-plugin-fsd/src/forbidden-imports/index.spec.ts b/packages/steiger-plugin-fsd/src/forbidden-imports/index.spec.ts index 9f10e17..3fef643 100644 --- a/packages/steiger-plugin-fsd/src/forbidden-imports/index.spec.ts +++ b/packages/steiger-plugin-fsd/src/forbidden-imports/index.spec.ts @@ -6,7 +6,18 @@ import forbiddenImports from './index.js' vi.mock('tsconfck', async (importOriginal) => { return { ...(await importOriginal()), - parse: vi.fn(() => Promise.resolve({ tsconfig: { compilerOptions: { paths: { '@/*': ['/*'] } } } })), + parse: vi.fn(() => + Promise.resolve({ + tsconfig: { + compilerOptions: { + baseUrl: '/src/', + paths: { + '@/*': ['./*'], + }, + }, + }, + }), + ), } }) @@ -16,103 +27,166 @@ vi.mock('node:fs', async (importOriginal) => { return createFsMocks( { - '/shared/ui/styles.ts': '', - '/shared/ui/Button.tsx': 'import styles from "./styles";', - '/shared/ui/TextField.tsx': 'import styles from "./styles";', - '/shared/ui/index.ts': '', - '/entities/user/ui/UserAvatar.tsx': 'import { Button } from "@/shared/ui"', - '/entities/user/index.ts': '', - '/entities/product/ui/ProductCard.tsx': 'import { UserAvatar } from "@/entities/user"', - '/entities/product/index.ts': '', - '/features/comments/ui/CommentCard.tsx': 'import { styles } from "@/pages/editor"', - '/features/comments/index.ts': '', - '/pages/editor/ui/styles.ts': '', - '/pages/editor/ui/EditorPage.tsx': 'import { Button } from "@/shared/ui"; import { Editor } from "./Editor"', - '/pages/editor/ui/Editor.tsx': 'import { TextField } from "@/shared/ui"', - '/pages/editor/index.ts': '', + '/src/shared/ui/styles.ts': '', + '/src/shared/ui/Button.tsx': 'import styles from "./styles";', + '/src/shared/ui/TextField.tsx': 'import styles from "./styles";', + '/src/shared/ui/index.ts': '', + '/src/entities/user/ui/UserAvatar.tsx': 'import { Button } from "@/shared/ui"', + '/src/entities/user/index.ts': '', + '/src/entities/product/ui/ProductCard.tsx': 'import { UserAvatar } from "@/entities/user"', + '/src/entities/product/index.ts': '', + '/src/entities/cart/ui/SmallCart.tsx': 'import { App } from "@/app"', + '/src/entities/cart/lib/count-cart-items.ts': 'import root from "@/app/root.ts"', + '/src/entities/cart/lib/index.ts': '', + '/src/entities/cart/index.ts': '', + '/src/features/comments/ui/CommentCard.tsx': 'import { styles } from "@/pages/editor"', + '/src/features/comments/index.ts': '', + '/src/pages/editor/ui/styles.ts': '', + '/src/pages/editor/ui/EditorPage.tsx': 'import { Button } from "@/shared/ui"; import { Editor } from "./Editor"', + '/src/pages/editor/ui/Editor.tsx': 'import { TextField } from "@/shared/ui"', + '/src/pages/editor/index.ts': '', + '/src/app': '', + '/src/app/ui/index.ts': '', + '/src/app/index.ts': '', + '/src/app/root.ts': '', }, originalFs, ) }) it('reports no errors on a project with only correct imports', async () => { - const root = parseIntoFsdRoot(` - 📂 shared - 📂 ui - 📄 styles.ts - 📄 Button.tsx - 📄 TextField.tsx - 📄 index.ts - 📂 pages - 📂 editor + const root = parseIntoFsdRoot( + ` + 📂 shared 📂 ui - 📄 EditorPage.tsx - 📄 Editor.tsx - 📄 index.ts - `) + 📄 styles.ts + 📄 Button.tsx + 📄 TextField.tsx + 📄 index.ts + 📂 pages + 📂 editor + 📂 ui + 📄 EditorPage.tsx + 📄 Editor.tsx + 📄 index.ts + `, + joinFromRoot('src'), + ) expect((await forbiddenImports.check(root)).diagnostics).toEqual([]) }) it('reports errors on a project with cross-imports in entities', async () => { - const root = parseIntoFsdRoot(` - 📂 shared - 📂 ui - 📄 styles.ts - 📄 Button.tsx - 📄 TextField.tsx - 📄 index.ts - 📂 entities - 📂 user - 📂 ui - 📄 UserAvatar.tsx - 📄 index.ts - 📂 product + const root = parseIntoFsdRoot( + ` + 📂 shared 📂 ui - 📄 ProductCard.tsx - 📄 index.ts - 📂 pages - 📂 editor - 📂 ui - 📄 EditorPage.tsx - 📄 Editor.tsx - 📄 index.ts - `) + 📄 styles.ts + 📄 Button.tsx + 📄 TextField.tsx + 📄 index.ts + 📂 entities + 📂 user + 📂 ui + 📄 UserAvatar.tsx + 📄 index.ts + 📂 product + 📂 ui + 📄 ProductCard.tsx + 📄 index.ts + 📂 pages + 📂 editor + 📂 ui + 📄 EditorPage.tsx + 📄 Editor.tsx + 📄 index.ts + `, + joinFromRoot('src'), + ) expect((await forbiddenImports.check(root)).diagnostics).toEqual([ { message: `Forbidden cross-import from slice "user".`, - location: { path: joinFromRoot('entities', 'product', 'ui', 'ProductCard.tsx') }, + location: { path: joinFromRoot('src', 'entities', 'product', 'ui', 'ProductCard.tsx') }, }, ]) }) it('reports errors on a project where a feature imports from a page', async () => { - const root = parseIntoFsdRoot(` - 📂 shared - 📂 ui - 📄 styles.ts - 📄 Button.tsx - 📄 TextField.tsx - 📄 index.ts - 📂 features - 📂 comments + const root = parseIntoFsdRoot( + ` + 📂 shared 📂 ui - 📄 CommentCard.tsx - 📄 index.ts - 📂 pages - 📂 editor + 📄 styles.ts + 📄 Button.tsx + 📄 TextField.tsx + 📄 index.ts + 📂 features + 📂 comments + 📂 ui + 📄 CommentCard.tsx + 📄 index.ts + 📂 pages + 📂 editor + 📂 ui + 📄 styles.ts + 📄 EditorPage.tsx + 📄 Editor.tsx + 📄 index.ts + `, + joinFromRoot('src'), + ) + + expect((await forbiddenImports.check(root)).diagnostics.sort()).toEqual([ + { + message: `Forbidden import from higher layer "pages".`, + location: { path: joinFromRoot('src', 'features', 'comments', 'ui', 'CommentCard.tsx') }, + }, + ]) +}) + +it('reports errors in a project where a lower level imports from files that are direct children of a higher level', async () => { + const root = parseIntoFsdRoot( + ` + 📂 shared 📂 ui 📄 styles.ts - 📄 EditorPage.tsx - 📄 Editor.tsx + 📄 Button.tsx + 📄 TextField.tsx + 📄 index.ts + 📂 entities + 📂 cart + 📄 index.ts + 📂 lib + 📄 count-cart-items.ts + 📄 index.ts + 📂 ui + 📄 SmallCart.tsx + 📂 pages + 📂 editor + 📂 ui + 📄 styles.ts + 📄 EditorPage.tsx + 📄 Editor.tsx + 📄 index.ts + 📂 app + 📂 ui + 📄 index.ts 📄 index.ts - `) + 📄 root.ts + `, + joinFromRoot('src'), + ) - expect((await forbiddenImports.check(root)).diagnostics).toEqual([ + const diagnostics = (await forbiddenImports.check(root)).diagnostics + expect(diagnostics).toEqual([ { - message: `Forbidden import from higher layer "pages".`, - location: { path: joinFromRoot('features', 'comments', 'ui', 'CommentCard.tsx') }, + message: `Forbidden import from higher layer "app".`, + location: { path: joinFromRoot('src', 'entities', 'cart', 'lib', 'count-cart-items.ts') }, + }, + { + message: `Forbidden import from higher layer "app".`, + location: { path: joinFromRoot('src', 'entities', 'cart', 'ui', 'SmallCart.tsx') }, }, ]) })