diff --git a/packages/steiger-plugin-fsd/package.json b/packages/steiger-plugin-fsd/package.json index 6f4142d..7b80635 100644 --- a/packages/steiger-plugin-fsd/package.json +++ b/packages/steiger-plugin-fsd/package.json @@ -43,6 +43,7 @@ "@feature-sliced/filesystem": "^3.0.1", "fastest-levenshtein": "^1.0.16", "lodash-es": "^4.17.21", + "oxc-parser": "^0.47.1", "pluralize": "^8.0.0", "precinct": "^12.2.0", "tsconfck": "^3.1.6" diff --git a/packages/steiger-plugin-fsd/src/index.ts b/packages/steiger-plugin-fsd/src/index.ts index 0e7c7e3..fac9443 100644 --- a/packages/steiger-plugin-fsd/src/index.ts +++ b/packages/steiger-plugin-fsd/src/index.ts @@ -10,6 +10,7 @@ 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 noUiInApp from './no-ui-in-app/index.js' +import noWildcardExports from './no-wildcard-exports/index.js' import publicApi from './public-api/index.js' import repetitiveNaming from './repetitive-naming/index.js' import segmentsByPurpose from './segments-by-purpose/index.js' @@ -32,6 +33,7 @@ const enabledRules = [ noSegmentlessSlices, noSegmentsOnSlicedLayers, noUiInApp, + noWildcardExports, publicApi, repetitiveNaming, segmentsByPurpose, diff --git a/packages/steiger-plugin-fsd/src/no-wildcard-exports/README.md b/packages/steiger-plugin-fsd/src/no-wildcard-exports/README.md new file mode 100644 index 0000000..e92e37b --- /dev/null +++ b/packages/steiger-plugin-fsd/src/no-wildcard-exports/README.md @@ -0,0 +1,61 @@ +# `no-wildcard-exports` + +Forbid wildcard exports (`export *`) in public APIs of business logic layers. Named exports and namespace exports (`export * as namespace`) are allowed. + +**Exception:** Wildcard exports are allowed in unsliced layers (`shared` and `app`), as they serve as foundational layers with different architectural purposes. + +This rule treats files named `index.js`, `index.jsx`, `index.ts`, `index.tsx` as the public API of a folder (slice/segment root). Non-index files (including test files like `*.spec.ts`, `*.test.ts`) are ignored. + +Examples of exports that pass this rule: + +```ts +// Named exports (business logic layers) +export { Button } from './Button' +export { UserCard, UserAvatar } from './components' + +// Namespace exports (all layers) +export * as userModel from './model' +export * as positions from './tooltip-positions' + +// Wildcard exports (unsliced layers: shared, app) +// shared/ui/index.ts +export * from './Button' +export * from './Modal' +export * from './Input' + +// shared/api/index.ts +export * from './endpoints/auth' +export * from './endpoints/users' + +// app/providers/index.ts +export * from './AuthProvider' +export * from './ThemeProvider' +export * from './RouterProvider' +``` + +Examples of exports that fail this rule: + +```ts +// ❌ Wildcard exports in business logic layers +// entities/user/index.ts +export * from './model' +export * from './ui' + +// features/auth/index.ts +export * from './ui' +export * from './api' +``` + +## Rationale + +Wildcard exports in business logic layers make it harder to track which exact entities are being exported from a module. This can lead to: + +- Unintentionally exposing internal implementation details +- Difficulty in tracking dependencies between modules +- Potential naming conflicts when multiple modules use wildcard exports + +Using named exports or namespace exports makes the public API more explicit and easier to maintain in business logic layers. + +## Autofix + +This rule provides a suggested fix for wildcard exports in public API files by replacing them with a named export template (e.g. `export { ComponentA, ComponentB } from './components'`). diff --git a/packages/steiger-plugin-fsd/src/no-wildcard-exports/index.spec.ts b/packages/steiger-plugin-fsd/src/no-wildcard-exports/index.spec.ts new file mode 100644 index 0000000..a6890d2 --- /dev/null +++ b/packages/steiger-plugin-fsd/src/no-wildcard-exports/index.spec.ts @@ -0,0 +1,258 @@ +import { expect, it } from 'vitest' +import { joinFromRoot, parseIntoFolder as parseIntoFsdRoot } from '@steiger/toolkit/test' +import type { Folder, File } from '@steiger/toolkit' + +import noWildcardExports from './index.js' + +type FileWithContent = File & { content?: string } + +it('reports no errors on a project with valid exports', () => { + const root = parseIntoFsdRoot(` + 📂 shared + 📂 ui + 📄 Button.tsx + 📄 index.ts + 📂 entities + 📂 user + 📂 ui + 📄 UserCard.tsx + 📄 index.ts + 📄 index.ts + 📂 features + 📂 auth + 📂 ui + 📄 LoginForm.tsx + 📄 index.ts + `) + + function addContentToFiles(folder: Folder): void { + for (const child of folder.children) { + if (child.type === 'file') { + const fileWithContent = child as FileWithContent + if (child.path === joinFromRoot('shared', 'ui', 'index.ts')) { + fileWithContent.content = "export { Button } from './Button'" + } else if (child.path === joinFromRoot('entities', 'user', 'ui', 'index.ts')) { + fileWithContent.content = "export { UserCard } from './UserCard'" + } else if (child.path === joinFromRoot('entities', 'user', 'index.ts')) { + fileWithContent.content = "export * as userModel from './model'" + } else if (child.path === joinFromRoot('features', 'auth', 'ui', 'index.ts')) { + fileWithContent.content = "export { LoginForm } from './LoginForm'" + } else { + fileWithContent.content = '' + } + } else if (child.type === 'folder') { + addContentToFiles(child) + } + } + } + + addContentToFiles(root) + + expect(noWildcardExports.check(root)).toEqual({ diagnostics: [] }) +}) + +it('reports errors on a project with wildcard exports', () => { + const root = parseIntoFsdRoot(` + 📂 shared + 📂 ui + 📄 Button.tsx + 📄 index.ts + 📂 entities + 📂 user + 📂 ui + 📄 UserCard.tsx + 📄 index.ts + 📄 index.ts + `) + + function addContentToFiles(folder: Folder): void { + for (const child of folder.children) { + if (child.type === 'file') { + const fileWithContent = child as FileWithContent + if (child.path === joinFromRoot('shared', 'ui', 'index.ts')) { + fileWithContent.content = "export * from './Button'" + } else if (child.path === joinFromRoot('entities', 'user', 'ui', 'index.ts')) { + fileWithContent.content = "export * from './UserCard'" + } else { + fileWithContent.content = '' + } + } else if (child.type === 'folder') { + addContentToFiles(child) + } + } + } + + addContentToFiles(root) + + const diagnostics = noWildcardExports.check(root).diagnostics + expect(diagnostics).toEqual([ + { + message: 'Wildcard exports are not allowed in public APIs. Use named exports instead.', + location: { path: joinFromRoot('entities', 'user', 'ui', 'index.ts') }, + fixes: [ + { + type: 'modify-file', + path: joinFromRoot('entities', 'user', 'ui', 'index.ts'), + content: '// Replace with named exports\n// Example: export { ComponentA, ComponentB } from "./components"', + }, + ], + }, + ]) +}) + +it('allows export * as namespace pattern', () => { + const root = parseIntoFsdRoot(` + 📂 shared + 📂 ui + 📄 positions.ts + 📄 index.ts + 📂 entities + 📂 user + 📄 model.ts + 📄 index.ts + `) + + function addContentToFiles(folder: Folder): void { + for (const child of folder.children) { + if (child.type === 'file') { + const fileWithContent = child as FileWithContent + if (child.path === joinFromRoot('shared', 'ui', 'index.ts')) { + fileWithContent.content = "export * as positions from './positions'" + } else if (child.path === joinFromRoot('entities', 'user', 'index.ts')) { + fileWithContent.content = "export * as userModel from './model'" + } else { + fileWithContent.content = '' + } + } else if (child.type === 'folder') { + addContentToFiles(child) + } + } + } + + addContentToFiles(root) + + expect(noWildcardExports.check(root)).toEqual({ diagnostics: [] }) +}) + +it('ignores wildcard exports in non-public files', () => { + const root = parseIntoFsdRoot(` + 📂 shared + 📂 ui + 📄 internal.ts + 📄 index.ts + 📂 entities + 📂 user + 📂 ui + 📄 internal-utils.ts + 📄 index.ts + `) + + function addContentToFiles(folder: Folder): void { + for (const child of folder.children) { + if (child.type === 'file') { + const fileWithContent = child as FileWithContent + if (child.path.endsWith('internal.ts')) { + fileWithContent.content = "export * from './components'" + } else if (child.path.endsWith('internal-utils.ts')) { + fileWithContent.content = "export * from './utils'" + } else { + fileWithContent.content = '' + } + } else if (child.type === 'folder') { + addContentToFiles(child) + } + } + } + + addContentToFiles(root) + + expect(noWildcardExports.check(root)).toEqual({ diagnostics: [] }) +}) + +it('ignores wildcard exports in test files', () => { + const root = parseIntoFsdRoot(` + 📂 shared + 📂 ui + 📄 Button.test.ts + 📄 index.ts + 📂 entities + 📂 user + 📂 ui + 📄 UserCard.spec.ts + 📄 index.ts + `) + + function addContentToFiles(folder: Folder): void { + for (const child of folder.children) { + if (child.type === 'file') { + const fileWithContent = child as FileWithContent + if (child.path.endsWith('Button.test.ts')) { + fileWithContent.content = "export * from './test-utils'" + } else if (child.path.endsWith('UserCard.spec.ts')) { + fileWithContent.content = "export * from './test-utils'" + } else { + fileWithContent.content = '' + } + } else if (child.type === 'folder') { + addContentToFiles(child) + } + } + } + + addContentToFiles(root) + + expect(noWildcardExports.check(root)).toEqual({ diagnostics: [] }) +}) + +it('allows wildcard exports in unsliced layers (shared and app)', () => { + const root = parseIntoFsdRoot(` + 📂 shared + 📂 ui + 📄 Button.tsx + 📄 Modal.tsx + 📄 index.ts + 📂 api + 📄 client.ts + 📄 endpoints.ts + 📄 index.ts + 📂 app + 📂 providers + 📄 AuthProvider.tsx + 📄 ThemeProvider.tsx + 📄 index.ts + 📂 routes + 📄 index.ts + 📂 entities + 📂 user + 📂 ui + 📄 UserCard.tsx + 📄 index.ts + `) + + function addContentToFiles(folder: Folder): void { + for (const child of folder.children) { + if (child.type === 'file') { + const fileWithContent = child as FileWithContent + if (child.path === joinFromRoot('shared', 'ui', 'index.ts')) { + fileWithContent.content = "export * from './Button'\nexport * from './Modal'" + } else if (child.path === joinFromRoot('shared', 'api', 'index.ts')) { + fileWithContent.content = "export * from './client'\nexport * from './endpoints'" + } else if (child.path === joinFromRoot('app', 'providers', 'index.ts')) { + fileWithContent.content = "export * from './AuthProvider'\nexport * from './ThemeProvider'" + } else if (child.path === joinFromRoot('app', 'routes', 'index.ts')) { + fileWithContent.content = "export * from './home'\nexport * from './auth'" + } else if (child.path === joinFromRoot('entities', 'user', 'ui', 'index.ts')) { + fileWithContent.content = "export { UserCard } from './UserCard'" + } else { + fileWithContent.content = '' + } + } else if (child.type === 'folder') { + addContentToFiles(child) + } + } + } + + addContentToFiles(root) + + expect(noWildcardExports.check(root)).toEqual({ diagnostics: [] }) +}) diff --git a/packages/steiger-plugin-fsd/src/no-wildcard-exports/index.ts b/packages/steiger-plugin-fsd/src/no-wildcard-exports/index.ts new file mode 100644 index 0000000..c221359 --- /dev/null +++ b/packages/steiger-plugin-fsd/src/no-wildcard-exports/index.ts @@ -0,0 +1,65 @@ +import { basename } from 'node:path' +import { parseSync } from 'oxc-parser' +import type { PartialDiagnostic, Rule, File } from '@steiger/toolkit' +import { NAMESPACE } from '../constants.js' +import { indexSourceFiles } from '../_lib/index-source-files.js' + +type FileWithContent = File & { content?: string } + +const noWildcardExports = { + name: `${NAMESPACE}/no-wildcard-exports` as const, + check(root) { + const diagnostics: Array = [] + + // Index all source files according to the FSD structure + const sourceFiles = indexSourceFiles(root) + + for (const sourceFile of Object.values(sourceFiles)) { + const file = sourceFile.file + + if (!/\.(js|jsx|ts|tsx)$/.test(file.path)) continue + + // Allow wildcard exports in unsliced layers (shared, app) + if (sourceFile.layerName === 'shared' || sourceFile.layerName === 'app') continue + + // Check if this is a public API file (typically index.* files) + const fileName = basename(file.path) + const isPublicApiFile = /^index\.(js|jsx|ts|tsx)$/.test(fileName) + + // Skip files that are not public API + if (!isPublicApiFile) continue + + // Parse file content using oxc-parser + const content = (file as FileWithContent).content + if (!content) continue // Skip if file has no contents + + const parseResult = parseSync(file.path, content) + + // Inspect export statements in the AST + for (const statement of parseResult.program.body) { + // Look for "export * from '...'" patterns + if (statement.type === 'ExportAllDeclaration' && statement.source) { + // Allow "export * as namespace from '...'" patterns + if (statement.exported) continue + + // Add a diagnostic if a wildcard export is found in a public API file + diagnostics.push({ + message: 'Wildcard exports are not allowed in public APIs. Use named exports instead.', + location: { path: file.path }, + fixes: [ + { + type: 'modify-file', + path: file.path, + content: + '// Replace with named exports\n// Example: export { ComponentA, ComponentB } from "./components"', + }, + ], + }) + } + } + } + return { diagnostics } + }, +} satisfies Rule + +export default noWildcardExports diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 936d5b6..11589cb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -221,6 +221,9 @@ importers: lodash-es: specifier: ^4.17.21 version: 4.17.21 + oxc-parser: + specifier: ^0.47.1 + version: 0.47.1 pluralize: specifier: ^8.0.0 version: 8.0.0 @@ -315,7 +318,7 @@ importers: version: 10.1.8(eslint@9.32.0) tooling/tsconfig: - devDependencies: + dependencies: '@tsconfig/node18': specifier: ^18.2.4 version: 18.2.4 @@ -726,6 +729,49 @@ packages: resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} engines: {node: '>= 8'} + '@oxc-parser/binding-darwin-arm64@0.47.1': + resolution: {integrity: sha512-WxlX4VVqFciQY+atVEzvZ+4m72vcHgXGDJfoMG+vBQ19+79+pipZTUhM4mIVe73IDe+B5l8AKgaMIWyEYE/cDg==} + cpu: [arm64] + os: [darwin] + + '@oxc-parser/binding-darwin-x64@0.47.1': + resolution: {integrity: sha512-yrZIVDtpxfneTmBy3sHw8uUnVqkFhbUBEHHl4Nms/IZ/CYrA3oE8FDhwy0nF30kZBxcGk/0+32OZ6h8jZETPUA==} + cpu: [x64] + os: [darwin] + + '@oxc-parser/binding-linux-arm64-gnu@0.47.1': + resolution: {integrity: sha512-F0gsHSTp4q2+3G/cESfZ22Ri60MCMT2UDbrypIgQscgqyDCq3RXa91QPcwa3Q4SNrmWNSfGYawUcJyDrcJ3Bjw==} + cpu: [arm64] + os: [linux] + + '@oxc-parser/binding-linux-arm64-musl@0.47.1': + resolution: {integrity: sha512-CKPOjxhNxZ9pmFIQD7IGeXktS22XhRVHmQC9HHsCTWQPI4IezlNOgdfZyNjmuN9Zin47LntYTEkRrThg7s1GFQ==} + cpu: [arm64] + os: [linux] + + '@oxc-parser/binding-linux-x64-gnu@0.47.1': + resolution: {integrity: sha512-PTnTtbUxz5wXTCDh7USLO2x17FEUhdfqKd8V1xzf1T5BIMDOvGqx95v+C7+sT/hGWf4mUKsjeLXAwsXMb86Zrw==} + cpu: [x64] + os: [linux] + + '@oxc-parser/binding-linux-x64-musl@0.47.1': + resolution: {integrity: sha512-bnkI/xu6sruQnk4ee5SljFGUHzX4cucOtBbuz7vj/PI6pifp4QI9MgEkTN/LIvYbwQ1ja5yCreSoMU8uaFVgHg==} + cpu: [x64] + os: [linux] + + '@oxc-parser/binding-win32-arm64-msvc@0.47.1': + resolution: {integrity: sha512-gIgyBxgsUcZivNSRJNpVLP2vPQsjhPKgTnkeBZoWgPFFS3ZLqEEXb4Q5z84l/pmnTQLwDF2WbMeZrY3yKo4CsQ==} + cpu: [arm64] + os: [win32] + + '@oxc-parser/binding-win32-x64-msvc@0.47.1': + resolution: {integrity: sha512-BmqQoNNa71NVdQVIYDALUld/b/C/XHPVu0mTN+F8N3lC360hoFzczrngAnE9UxJ3N4YAi6elqDzqoQuS9aQk1Q==} + cpu: [x64] + os: [win32] + + '@oxc-project/types@0.47.1': + resolution: {integrity: sha512-fGa82XCkNqP/TT3z8sMb6Y+uVJBf7uvdP9nfwMzYgjZyVgdgzFwvsNMHp+iNLRP9jbM5VsGkb2/f/zG0POM2nA==} + '@pkgjs/parseargs@0.11.0': resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} @@ -1876,6 +1922,9 @@ packages: outdent@0.5.0: resolution: {integrity: sha512-/jHxFIzoMXdqPzTaCpFzAAWhpkSjZPF4Vsn6jAfNpmbH/ymsmd7Qc6VE9BGn0L6YMj6uwpQLxCECpus4ukKS9Q==} + oxc-parser@0.47.1: + resolution: {integrity: sha512-Z5TDh96dzit1i8a8GOt7cXmsiBkUPDthz4lh4yNNwdb7EPZyPrBjFpwC6Mqr+Dtq3qxTr0c4FDLFnWaIp/ncSw==} + p-filter@2.1.0: resolution: {integrity: sha512-ZBxxZ5sL2HghephhpGAQdoskxplTwr7ICaehZwLIlfL6acuVgZPm8yBNuRAFBGEqtD/hmUeq9eqLg2ys9Xr/yw==} engines: {node: '>=8'} @@ -3058,6 +3107,32 @@ snapshots: '@nodelib/fs.scandir': 2.1.5 fastq: 1.19.1 + '@oxc-parser/binding-darwin-arm64@0.47.1': + optional: true + + '@oxc-parser/binding-darwin-x64@0.47.1': + optional: true + + '@oxc-parser/binding-linux-arm64-gnu@0.47.1': + optional: true + + '@oxc-parser/binding-linux-arm64-musl@0.47.1': + optional: true + + '@oxc-parser/binding-linux-x64-gnu@0.47.1': + optional: true + + '@oxc-parser/binding-linux-x64-musl@0.47.1': + optional: true + + '@oxc-parser/binding-win32-arm64-msvc@0.47.1': + optional: true + + '@oxc-parser/binding-win32-x64-msvc@0.47.1': + optional: true + + '@oxc-project/types@0.47.1': {} + '@pkgjs/parseargs@0.11.0': optional: true @@ -4242,6 +4317,19 @@ snapshots: outdent@0.5.0: {} + oxc-parser@0.47.1: + dependencies: + '@oxc-project/types': 0.47.1 + optionalDependencies: + '@oxc-parser/binding-darwin-arm64': 0.47.1 + '@oxc-parser/binding-darwin-x64': 0.47.1 + '@oxc-parser/binding-linux-arm64-gnu': 0.47.1 + '@oxc-parser/binding-linux-arm64-musl': 0.47.1 + '@oxc-parser/binding-linux-x64-gnu': 0.47.1 + '@oxc-parser/binding-linux-x64-musl': 0.47.1 + '@oxc-parser/binding-win32-arm64-msvc': 0.47.1 + '@oxc-parser/binding-win32-x64-msvc': 0.47.1 + p-filter@2.1.0: dependencies: p-map: 2.1.0 diff --git a/tooling/tsconfig/package.json b/tooling/tsconfig/package.json index a769f6c..f3bd141 100644 --- a/tooling/tsconfig/package.json +++ b/tooling/tsconfig/package.json @@ -5,7 +5,7 @@ "files": [ "base.json" ], - "devDependencies": { + "dependencies": { "@tsconfig/node18": "^18.2.4" } }