Skip to content

Commit 76a2d53

Browse files
committed
feat: add public-api rule to enforce consistent public API structure in FSD slices; include tests and documentation
1 parent 787d16e commit 76a2d53

File tree

6 files changed

+796
-1
lines changed

6 files changed

+796
-1
lines changed

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ The plugin provides the rules to enforce [Feature-Sliced Design](https://feature
6666
| -------------------------------------------------------- | ------------------------------------------------------------------------- |
6767
| [fsd-layers](./docs/rules/fsd-layers.md) | Enforce consistent layer structure in feature-sliced design. |
6868
| [no-processes-layer](./docs/rules/no-processes-layer.md) | Ensure deprecated processes layer is not used. |
69+
| [public-api](./docs/rules/public-api.md) | Enforce consistent public API structure in FSD slices. |
6970
| [sfc-sections-order](./docs/rules/sfc-sections-order.md) | Enforce consistent order of top-level sections in single-file components. |
7071

7172
## Roadmap
@@ -76,7 +77,6 @@ As the plugin evolves, we plan to implement the following rules:
7677
- no-cross-slice-imports: Forbid cross-imports between slices on the same layer.
7778
- no-layer-public-api: Forbid exposing public APIs from a layer.
7879
- no-segments-without-slices: Forbid segments without slices.
79-
- public-api: Enforce consistent public API on slices.
8080
- no-ui-in-app: Forbid using UI segment in the app layer.
8181
- no-direct-imports: Forbid direct imports from outside the slice.
8282
- slice-relative-path: Imports within one slice should be relative.

docs/rules/public-api.md

Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
# public-api
2+
3+
Enforce consistent public API structure in FSD slices.
4+
5+
This rule ensures that each slice in Feature-Sliced Design layers has a proper public API file. It checks for the existence of the specified filename (by default `index.ts`) in each slice directory and reports violations when slices are missing their public API or have incorrect API files.
6+
7+
## Rule Details
8+
9+
This rule enforces FSD public API conventions by:
10+
11+
- Checking that each slice has the expected public API file (e.g., `index.ts`)
12+
- Reporting slices that are missing their public API files
13+
- Reporting invalid public API files (e.g., `index.js` when expecting `index.ts`)
14+
- Supporting custom filenames for public API files
15+
- Allowing certain slices to be ignored from validation
16+
- Running filesystem checks only once per lint session for performance
17+
18+
**Default Behavior**: With no configuration, the rule checks for `index.ts` files in all standard FSD layers (`app`, `pages`, `widgets`, `features`, `entities`, `shared`).
19+
20+
## Options
21+
22+
```json
23+
{
24+
"vue-fsd/public-api": [
25+
"error",
26+
{
27+
"src": "src",
28+
"layers": ["features", "entities", "shared"],
29+
"filename": "index.ts",
30+
"ignore": ["temp", "legacy"]
31+
}
32+
]
33+
}
34+
```
35+
36+
### `src` (string)
37+
38+
The source directory path to check for FSD structure. Defaults to `"src"`.
39+
40+
### `layers` (array of strings)
41+
42+
List of FSD layers to check for public API files. Only slices within these layers will be validated.
43+
44+
Default: `["app", "pages", "widgets", "features", "entities", "shared"]`
45+
46+
### `filename` (string)
47+
48+
The expected filename for public API files in each slice.
49+
50+
Default: `"index.ts"`
51+
52+
### `ignore` (array of strings)
53+
54+
List of slice names to ignore when checking for public API files. Useful for temporary slices or legacy code that doesn't follow the convention.
55+
56+
Default: `[]` (no ignores)
57+
58+
## Examples
59+
60+
### ✅ Correct
61+
62+
#### Default behavior (no configuration required)
63+
64+
```json
65+
{
66+
"vue-fsd/public-api": "error"
67+
}
68+
```
69+
70+
```text
71+
src/
72+
├── features/
73+
│ ├── auth/
74+
│ │ ├── index.ts ✅ has public API
75+
│ │ └── model.ts
76+
│ └── profile/
77+
│ ├── index.ts ✅ has public API
78+
│ └── api.ts
79+
└── entities/
80+
└── user/
81+
├── index.ts ✅ has public API
82+
└── model.ts
83+
```
84+
85+
#### Custom filename
86+
87+
```json
88+
{
89+
"vue-fsd/public-api": [
90+
"error",
91+
{
92+
"filename": "public-api.js"
93+
}
94+
]
95+
}
96+
```
97+
98+
```text
99+
src/
100+
└── features/
101+
└── auth/
102+
├── public-api.js ✅ custom public API filename
103+
└── model.js
104+
```
105+
106+
### ❌ Incorrect
107+
108+
#### Missing public API file
109+
110+
```text
111+
src/
112+
└── features/
113+
└── auth/
114+
├── model.ts ❌ missing index.ts
115+
└── ui.tsx
116+
```
117+
118+
```text
119+
Slice "auth" in layer "features" is missing a public API file (index.ts).
120+
```
121+
122+
#### Invalid public API file
123+
124+
```text
125+
src/
126+
└── features/
127+
└── auth/
128+
├── index.ts ✅ correct public API
129+
├── index.js ❌ invalid additional index file
130+
└── model.ts
131+
```
132+
133+
```text
134+
Slice "auth" in layer "features" has an invalid public API file "index.js". Expected index.ts.
135+
```
136+
137+
## Typical FSD Configuration
138+
139+
For a standard Feature-Sliced Design project:
140+
141+
```json
142+
{
143+
"vue-fsd/public-api": [
144+
"error",
145+
{
146+
"src": "src",
147+
"layers": ["features", "entities", "shared"],
148+
"filename": "index.ts",
149+
"ignore": ["__tests__", "temp"]
150+
}
151+
]
152+
}
153+
```
154+
155+
This configuration:
156+
157+
- Checks `features`, `entities`, and `shared` layers for public API files
158+
- Expects `index.ts` as the public API filename
159+
- Ignores test directories and temporary slices
160+
161+
## When Not To Use
162+
163+
- If your project doesn't follow Feature-Sliced Design architecture
164+
- If you don't want to enforce public API conventions
165+
- If your slices use different public API patterns that aren't file-based
166+
167+
## Related Rules
168+
169+
- [`fsd-layers`](./fsd-layers.md) - Enforces overall FSD layer structure
170+
- [`no-processes-layer`](./no-processes-layer.md) - Prevents usage of deprecated processes layer
171+
172+
## Performance Notes
173+
174+
This rule performs filesystem operations and is designed to run only once per ESLint session using the `runOnce` utility. This prevents redundant directory scans when linting multiple files in the same project.

src/configs.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ export const getConfigs = (plugin) => {
33
'vue-fsd/no-processes-layer': 'error',
44
'vue-fsd/sfc-sections-order': 'error',
55
'vue-fsd/fsd-layers': 'error',
6+
'vue-fsd/public-api': 'error',
67
}
78
const allRules = { ...recommendedRules }
89

src/index.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import meta from './meta.js'
22
import noProcessesLayer from './rules/no-processes-layer.js'
33
import sfcSectionsOrder from './rules/sfc-sections-order.js'
44
import fsdLayers from './rules/fsd-layers.js'
5+
import publicApi from './rules/public-api.js'
56
import { getConfigs } from './configs.js'
67

78
const plugin = {
@@ -10,6 +11,7 @@ const plugin = {
1011
'no-processes-layer': noProcessesLayer,
1112
'sfc-sections-order': sfcSectionsOrder,
1213
'fsd-layers': fsdLayers,
14+
'public-api': publicApi,
1315
},
1416
processors: {},
1517
configs: {},

src/rules/public-api.js

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
import { runOnce, parseRuleOptions } from '../utils.js'
2+
import fs from 'fs'
3+
import path from 'path'
4+
5+
const defaultOptions = {
6+
src: 'src',
7+
layers: ['app', 'pages', 'widgets', 'features', 'entities', 'shared'],
8+
filename: 'index.ts',
9+
ignore: [],
10+
}
11+
12+
export default {
13+
meta: {
14+
type: 'problem',
15+
docs: {
16+
description: 'Enforce consistent public API structure in FSD slices.',
17+
recommended: true,
18+
},
19+
schema: [
20+
{
21+
type: 'object',
22+
properties: {
23+
src: {
24+
description: 'The src path to check for FSD structure (string).',
25+
type: 'string',
26+
},
27+
layers: {
28+
description: 'List of FSD layers to check for public API (array of strings).',
29+
type: 'array',
30+
items: {
31+
type: 'string',
32+
},
33+
},
34+
filename: {
35+
description: 'Expected filename for public API files (string).',
36+
type: 'string',
37+
},
38+
ignore: {
39+
description: 'List of slice names to ignore when checking (array of strings).',
40+
type: 'array',
41+
items: {
42+
type: 'string',
43+
},
44+
},
45+
},
46+
additionalProperties: false,
47+
},
48+
],
49+
defaultOptions: [defaultOptions],
50+
messages: {
51+
missingPublicApi: 'Slice "{{slice}}" in layer "{{layer}}" is missing a public API file ({{filename}}).',
52+
invalidPublicApi: 'Slice "{{slice}}" in layer "{{layer}}" has an invalid public API file "{{file}}". Expected {{filename}}.',
53+
},
54+
},
55+
56+
create(context) {
57+
// allow filesystem check to run only once per lint session
58+
const allowFsCheck = runOnce('public-api')
59+
const { src, layers, filename, ignore } = parseRuleOptions(context, defaultOptions)
60+
61+
return {
62+
Program(node) {
63+
if (!allowFsCheck) return
64+
65+
try {
66+
// Check if src directory exists
67+
if (!fs.existsSync(src) || !fs.statSync(src).isDirectory()) {
68+
// it should be checked in fsd-layers rule
69+
return
70+
}
71+
72+
// Check each layer
73+
for (const layer of layers) {
74+
const layerPath = path.join(src, layer)
75+
76+
if (!fs.existsSync(layerPath) || !fs.statSync(layerPath).isDirectory()) {
77+
continue
78+
}
79+
80+
const slices = fs.readdirSync(layerPath).filter((entry) => {
81+
const slicePath = path.join(layerPath, entry)
82+
try {
83+
return fs.statSync(slicePath).isDirectory() && !ignore.includes(entry)
84+
} catch {
85+
return false
86+
}
87+
})
88+
89+
// Check each slice for public API
90+
for (const slice of slices) {
91+
const slicePath = path.join(layerPath, slice)
92+
const sliceEntries = fs.readdirSync(slicePath)
93+
94+
// Look for the specific filename
95+
const hasPublicApi = sliceEntries.includes(filename)
96+
const otherIndexFiles = sliceEntries.filter((entry) => {
97+
const basename = path.parse(entry).name
98+
return basename === 'index' && entry !== filename
99+
})
100+
101+
if (!hasPublicApi) {
102+
// No public API found
103+
context.report({
104+
node,
105+
messageId: 'missingPublicApi',
106+
data: { slice, layer, filename },
107+
})
108+
}
109+
110+
// Report any other index files as invalid
111+
for (const invalidFile of otherIndexFiles) {
112+
context.report({
113+
node,
114+
messageId: 'invalidPublicApi',
115+
data: { slice, layer, file: invalidFile, filename },
116+
})
117+
}
118+
}
119+
}
120+
} catch {
121+
// ignore filesystem errors
122+
}
123+
},
124+
}
125+
},
126+
}

0 commit comments

Comments
 (0)