Skip to content

Commit 571bbc4

Browse files
committed
feat(steiger-plugin): add no-wildcard-exports rule and fix tsconfig resolution
1 parent cbde318 commit 571bbc4

File tree

7 files changed

+477
-2
lines changed

7 files changed

+477
-2
lines changed

packages/steiger-plugin-fsd/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@
4343
"@feature-sliced/filesystem": "^3.0.1",
4444
"fastest-levenshtein": "^1.0.16",
4545
"lodash-es": "^4.17.21",
46+
"oxc-parser": "^0.47.1",
4647
"pluralize": "^8.0.0",
4748
"precinct": "^12.2.0",
4849
"tsconfck": "^3.1.6"

packages/steiger-plugin-fsd/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import noReservedFolderNames from './no-reserved-folder-names/index.js'
1010
import noSegmentlessSlices from './no-segmentless-slices/index.js'
1111
import noSegmentsOnSlicedLayers from './no-segments-on-sliced-layers/index.js'
1212
import noUiInApp from './no-ui-in-app/index.js'
13+
import noWildcardExports from './no-wildcard-exports/index.js'
1314
import publicApi from './public-api/index.js'
1415
import repetitiveNaming from './repetitive-naming/index.js'
1516
import segmentsByPurpose from './segments-by-purpose/index.js'
@@ -32,6 +33,7 @@ const enabledRules = [
3233
noSegmentlessSlices,
3334
noSegmentsOnSlicedLayers,
3435
noUiInApp,
36+
noWildcardExports,
3537
publicApi,
3638
repetitiveNaming,
3739
segmentsByPurpose,
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
# `no-wildcard-exports`
2+
3+
Forbid wildcard exports (`export *`) in public APIs of business logic layers. Named exports and namespace exports (`export * as namespace`) are allowed.
4+
5+
**Exception:** Wildcard exports are allowed in unsliced layers (`shared` and `app`), as they serve as foundational layers with different architectural purposes.
6+
7+
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.
8+
9+
Examples of exports that pass this rule:
10+
11+
```ts
12+
// Named exports (business logic layers)
13+
export { Button } from './Button'
14+
export { UserCard, UserAvatar } from './components'
15+
16+
// Namespace exports (all layers)
17+
export * as userModel from './model'
18+
export * as positions from './tooltip-positions'
19+
20+
// Wildcard exports (unsliced layers: shared, app)
21+
// shared/ui/index.ts
22+
export * from './Button'
23+
export * from './Modal'
24+
export * from './Input'
25+
26+
// shared/api/index.ts
27+
export * from './endpoints/auth'
28+
export * from './endpoints/users'
29+
30+
// app/providers/index.ts
31+
export * from './AuthProvider'
32+
export * from './ThemeProvider'
33+
export * from './RouterProvider'
34+
```
35+
36+
Examples of exports that fail this rule:
37+
38+
```ts
39+
// ❌ Wildcard exports in business logic layers
40+
// entities/user/index.ts
41+
export * from './model'
42+
export * from './ui'
43+
44+
// features/auth/index.ts
45+
export * from './ui'
46+
export * from './api'
47+
```
48+
49+
## Rationale
50+
51+
Wildcard exports in business logic layers make it harder to track which exact entities are being exported from a module. This can lead to:
52+
53+
- Unintentionally exposing internal implementation details
54+
- Difficulty in tracking dependencies between modules
55+
- Potential naming conflicts when multiple modules use wildcard exports
56+
57+
Using named exports or namespace exports makes the public API more explicit and easier to maintain in business logic layers.
58+
59+
## Autofix
60+
61+
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'`).
Lines changed: 258 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,258 @@
1+
import { expect, it } from 'vitest'
2+
import { joinFromRoot, parseIntoFolder as parseIntoFsdRoot } from '@steiger/toolkit/test'
3+
import type { Folder, File } from '@steiger/toolkit'
4+
5+
import noWildcardExports from './index.js'
6+
7+
type FileWithContent = File & { content?: string }
8+
9+
it('reports no errors on a project with valid exports', () => {
10+
const root = parseIntoFsdRoot(`
11+
📂 shared
12+
📂 ui
13+
📄 Button.tsx
14+
📄 index.ts
15+
📂 entities
16+
📂 user
17+
📂 ui
18+
📄 UserCard.tsx
19+
📄 index.ts
20+
📄 index.ts
21+
📂 features
22+
📂 auth
23+
📂 ui
24+
📄 LoginForm.tsx
25+
📄 index.ts
26+
`)
27+
28+
function addContentToFiles(folder: Folder): void {
29+
for (const child of folder.children) {
30+
if (child.type === 'file') {
31+
const fileWithContent = child as FileWithContent
32+
if (child.path.endsWith('shared/ui/index.ts')) {
33+
fileWithContent.content = "export { Button } from './Button'"
34+
} else if (child.path.endsWith('entities/user/ui/index.ts')) {
35+
fileWithContent.content = "export { UserCard } from './UserCard'"
36+
} else if (child.path.endsWith('entities/user/index.ts')) {
37+
fileWithContent.content = "export * as userModel from './model'"
38+
} else if (child.path.endsWith('features/auth/ui/index.ts')) {
39+
fileWithContent.content = "export { LoginForm } from './LoginForm'"
40+
} else {
41+
fileWithContent.content = ''
42+
}
43+
} else if (child.type === 'folder') {
44+
addContentToFiles(child)
45+
}
46+
}
47+
}
48+
49+
addContentToFiles(root)
50+
51+
expect(noWildcardExports.check(root)).toEqual({ diagnostics: [] })
52+
})
53+
54+
it('reports errors on a project with wildcard exports', () => {
55+
const root = parseIntoFsdRoot(`
56+
📂 shared
57+
📂 ui
58+
📄 Button.tsx
59+
📄 index.ts
60+
📂 entities
61+
📂 user
62+
📂 ui
63+
📄 UserCard.tsx
64+
📄 index.ts
65+
📄 index.ts
66+
`)
67+
68+
function addContentToFiles(folder: Folder): void {
69+
for (const child of folder.children) {
70+
if (child.type === 'file') {
71+
const fileWithContent = child as FileWithContent
72+
if (child.path.endsWith('shared/ui/index.ts')) {
73+
fileWithContent.content = "export * from './Button'"
74+
} else if (child.path.endsWith('entities/user/ui/index.ts')) {
75+
fileWithContent.content = "export * from './UserCard'"
76+
} else {
77+
fileWithContent.content = ''
78+
}
79+
} else if (child.type === 'folder') {
80+
addContentToFiles(child)
81+
}
82+
}
83+
}
84+
85+
addContentToFiles(root)
86+
87+
const diagnostics = noWildcardExports.check(root).diagnostics
88+
expect(diagnostics).toEqual([
89+
{
90+
message: 'Wildcard exports are not allowed in public APIs. Use named exports instead.',
91+
location: { path: joinFromRoot('entities', 'user', 'ui', 'index.ts') },
92+
fixes: [
93+
{
94+
type: 'modify-file',
95+
path: joinFromRoot('entities', 'user', 'ui', 'index.ts'),
96+
content: '// Replace with named exports\n// Example: export { ComponentA, ComponentB } from "./components"',
97+
},
98+
],
99+
},
100+
])
101+
})
102+
103+
it('allows export * as namespace pattern', () => {
104+
const root = parseIntoFsdRoot(`
105+
📂 shared
106+
📂 ui
107+
📄 positions.ts
108+
📄 index.ts
109+
📂 entities
110+
📂 user
111+
📄 model.ts
112+
📄 index.ts
113+
`)
114+
115+
function addContentToFiles(folder: Folder): void {
116+
for (const child of folder.children) {
117+
if (child.type === 'file') {
118+
const fileWithContent = child as FileWithContent
119+
if (child.path.endsWith('shared/ui/index.ts')) {
120+
fileWithContent.content = "export * as positions from './positions'"
121+
} else if (child.path.endsWith('entities/user/index.ts')) {
122+
fileWithContent.content = "export * as userModel from './model'"
123+
} else {
124+
fileWithContent.content = ''
125+
}
126+
} else if (child.type === 'folder') {
127+
addContentToFiles(child)
128+
}
129+
}
130+
}
131+
132+
addContentToFiles(root)
133+
134+
expect(noWildcardExports.check(root)).toEqual({ diagnostics: [] })
135+
})
136+
137+
it('ignores wildcard exports in non-public files', () => {
138+
const root = parseIntoFsdRoot(`
139+
📂 shared
140+
📂 ui
141+
📄 internal.ts
142+
📄 index.ts
143+
📂 entities
144+
📂 user
145+
📂 ui
146+
📄 internal-utils.ts
147+
📄 index.ts
148+
`)
149+
150+
function addContentToFiles(folder: Folder): void {
151+
for (const child of folder.children) {
152+
if (child.type === 'file') {
153+
const fileWithContent = child as FileWithContent
154+
if (child.path.endsWith('internal.ts')) {
155+
fileWithContent.content = "export * from './components'"
156+
} else if (child.path.endsWith('internal-utils.ts')) {
157+
fileWithContent.content = "export * from './utils'"
158+
} else {
159+
fileWithContent.content = ''
160+
}
161+
} else if (child.type === 'folder') {
162+
addContentToFiles(child)
163+
}
164+
}
165+
}
166+
167+
addContentToFiles(root)
168+
169+
expect(noWildcardExports.check(root)).toEqual({ diagnostics: [] })
170+
})
171+
172+
it('ignores wildcard exports in test files', () => {
173+
const root = parseIntoFsdRoot(`
174+
📂 shared
175+
📂 ui
176+
📄 Button.test.ts
177+
📄 index.ts
178+
📂 entities
179+
📂 user
180+
📂 ui
181+
📄 UserCard.spec.ts
182+
📄 index.ts
183+
`)
184+
185+
function addContentToFiles(folder: Folder): void {
186+
for (const child of folder.children) {
187+
if (child.type === 'file') {
188+
const fileWithContent = child as FileWithContent
189+
if (child.path.endsWith('Button.test.ts')) {
190+
fileWithContent.content = "export * from './test-utils'"
191+
} else if (child.path.endsWith('UserCard.spec.ts')) {
192+
fileWithContent.content = "export * from './test-utils'"
193+
} else {
194+
fileWithContent.content = ''
195+
}
196+
} else if (child.type === 'folder') {
197+
addContentToFiles(child)
198+
}
199+
}
200+
}
201+
202+
addContentToFiles(root)
203+
204+
expect(noWildcardExports.check(root)).toEqual({ diagnostics: [] })
205+
})
206+
207+
it('allows wildcard exports in unsliced layers (shared and app)', () => {
208+
const root = parseIntoFsdRoot(`
209+
📂 shared
210+
📂 ui
211+
📄 Button.tsx
212+
📄 Modal.tsx
213+
📄 index.ts
214+
📂 api
215+
📄 client.ts
216+
📄 endpoints.ts
217+
📄 index.ts
218+
📂 app
219+
📂 providers
220+
📄 AuthProvider.tsx
221+
📄 ThemeProvider.tsx
222+
📄 index.ts
223+
📂 routes
224+
📄 index.ts
225+
📂 entities
226+
📂 user
227+
📂 ui
228+
📄 UserCard.tsx
229+
📄 index.ts
230+
`)
231+
232+
function addContentToFiles(folder: Folder): void {
233+
for (const child of folder.children) {
234+
if (child.type === 'file') {
235+
const fileWithContent = child as FileWithContent
236+
if (child.path.endsWith('shared/ui/index.ts')) {
237+
fileWithContent.content = "export * from './Button'\nexport * from './Modal'"
238+
} else if (child.path.endsWith('shared/api/index.ts')) {
239+
fileWithContent.content = "export * from './client'\nexport * from './endpoints'"
240+
} else if (child.path.endsWith('app/providers/index.ts')) {
241+
fileWithContent.content = "export * from './AuthProvider'\nexport * from './ThemeProvider'"
242+
} else if (child.path.endsWith('app/routes/index.ts')) {
243+
fileWithContent.content = "export * from './home'\nexport * from './auth'"
244+
} else if (child.path.endsWith('entities/user/ui/index.ts')) {
245+
fileWithContent.content = "export { UserCard } from './UserCard'"
246+
} else {
247+
fileWithContent.content = ''
248+
}
249+
} else if (child.type === 'folder') {
250+
addContentToFiles(child)
251+
}
252+
}
253+
}
254+
255+
addContentToFiles(root)
256+
257+
expect(noWildcardExports.check(root)).toEqual({ diagnostics: [] })
258+
})

0 commit comments

Comments
 (0)