Skip to content

Commit ed3c0d7

Browse files
committed
feat: add no-layer-public-api rule to prevent root-level public API files in layers
1 parent 2867106 commit ed3c0d7

File tree

6 files changed

+332
-7
lines changed

6 files changed

+332
-7
lines changed

README.md

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -62,13 +62,14 @@ module.exports = {
6262

6363
The plugin provides the rules to enforce [Feature-Sliced Design](https://feature-sliced.design/) principles in [Vue.js](https://vuejs.org/) projects.
6464

65-
| Rule | Description |
66-
| -------------------------------------------------------- | ------------------------------------------------------------------------- |
67-
| [fsd-layers](./docs/rules/fsd-layers.md) | Enforce consistent layer structure in feature-sliced design. |
68-
| [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. |
70-
| [sfc-sections-order](./docs/rules/sfc-sections-order.md) | Enforce consistent order of top-level sections in single-file components. |
71-
| [no-ui-in-app](./docs/rules/no-ui-in-app.md) | Forbid placing `ui` segment directly inside the `app` layer. |
65+
| Rule | Description |
66+
| ---------------------------------------------------------- | -------------------------------------------------------------------------------------- |
67+
| [fsd-layers](./docs/rules/fsd-layers.md) | Enforce consistent layer structure in feature-sliced design. |
68+
| [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. |
70+
| [sfc-sections-order](./docs/rules/sfc-sections-order.md) | Enforce consistent order of top-level sections in single-file components. |
71+
| [no-ui-in-app](./docs/rules/no-ui-in-app.md) | Forbid placing `ui` segment directly inside the `app` layer. |
72+
| [no-layer-public-api](./docs/rules/no-layer-public-api.md) | Forbid placing a layer-level public API file (e.g. `index.ts`) at the root of a layer. |
7273

7374
## Roadmap
7475

docs/rules/no-layer-public-api.md

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
# no-layer-public-api
2+
3+
Disallow a layer-level public API file (for example `index.ts`) at the root of layers.
4+
5+
## Why
6+
7+
In a layered Feature-Sliced Design repository each top-level layer (for example `app`, `pages`, `widgets`, `entities`, etc.) should expose a public API at the slice level, not via a root-level `index` file inside the layer folder. Placing a root-level public API file can encourage leaking cross-layer imports and reduce encapsulation.
8+
9+
This rule scans the configured `src` path and reports when a configured `filename` exists directly under a layer directory (for example `src/app/index.ts`). To keep noise low the rule reports at most once per linting session.
10+
11+
## Rule details
12+
13+
- Rule name: `vue-fsd/no-layer-public-api`
14+
- Default messageId: `forbidden`
15+
- Type: `problem`
16+
17+
The rule checks each top-level directory inside the configured `src` for the presence of the configured `filename`. If a file with that name exists and is a regular file at the layer root, the rule reports once with a message pointing to the offending layer and filename.
18+
19+
## Options
20+
21+
The rule accepts a single options object with the following properties:
22+
23+
- `src` (string) — the path or alias to your project's source root. Default: `"src"`.
24+
- `filename` (string) — the filename to treat as a layer-level public API. Default: `"index.ts"`.
25+
- `ignore` (string[]) — array of layer names to ignore (for example `['app']`). Default: `[]`.
26+
27+
Example (ESLint config):
28+
29+
```json
30+
{
31+
"plugins": ["vue-fsd"],
32+
"rules": {
33+
"vue-fsd/no-layer-public-api": ["error", { "src": "src", "filename": "index.ts", "ignore": ["app"] }]
34+
}
35+
}
36+
```
37+
38+
## Examples
39+
40+
Bad:
41+
42+
```text
43+
// filesystem: src/app/index.ts
44+
src/app/index.ts <-- reported
45+
src/app/page-a/index.ts <-- fine (slice-level public API)
46+
```
47+
48+
Good:
49+
50+
```text
51+
// keep public API at slice level only
52+
src/app/page-a/index.ts
53+
src/widgets/button/index.ts
54+
```
55+
56+
## When not to use
57+
58+
- When your project intentionally exposes a single root-level API for a layer (legacy codebases or libraries). Use the `ignore` option or set the rule to `off` during migration.
59+
- When the filename you want to detect differs from `index.ts` and you prefer another pattern — use the `filename` option.
60+
61+
## References
62+
63+
- [ESLint Documentation](https://eslint.org/docs/user-guide/configuring)
64+
- [Feature-Sliced Design](https://feature-sliced.design/)

src/configs.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ export const getConfigs = (plugin) => {
22
const recommendedRules = {
33
'vue-fsd/no-processes-layer': 'error',
44
'vue-fsd/no-ui-in-app': 'error',
5+
'vue-fsd/no-layer-public-api': 'error',
56
'vue-fsd/sfc-sections-order': 'error',
67
'vue-fsd/fsd-layers': 'error',
78
'vue-fsd/public-api': 'error',

src/index.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import sfcSectionsOrder from './rules/sfc-sections-order.js'
44
import fsdLayers from './rules/fsd-layers.js'
55
import publicApi from './rules/public-api.js'
66
import noUiInApp from './rules/no-ui-in-app.js'
7+
import noLayerPublicApi from './rules/no-layer-public-api.js'
78
import { getConfigs } from './configs.js'
89

910
const plugin = {
@@ -14,6 +15,7 @@ const plugin = {
1415
'fsd-layers': fsdLayers,
1516
'public-api': publicApi,
1617
'no-ui-in-app': noUiInApp,
18+
'no-layer-public-api': noLayerPublicApi,
1719
},
1820
processors: {},
1921
configs: {},

src/rules/no-layer-public-api.js

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
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+
filename: 'index.ts',
8+
ignore: [],
9+
}
10+
11+
export default {
12+
meta: {
13+
type: 'problem',
14+
docs: {
15+
description: 'Forbid layer-level public API files (index.ts) at the root of layers.',
16+
recommended: true,
17+
},
18+
schema: [
19+
{
20+
type: 'object',
21+
properties: {
22+
src: { type: 'string' },
23+
filename: { type: 'string' },
24+
ignore: { type: 'array', items: { type: 'string' } },
25+
},
26+
additionalProperties: false,
27+
},
28+
],
29+
defaultOptions: [defaultOptions],
30+
messages: {
31+
forbidden: 'Do not place a layer-level public API file "{{filename}}" inside layer "{{layer}}".',
32+
},
33+
},
34+
35+
create(context) {
36+
const allowFsCheck = runOnce('no-layer-public-api')
37+
const { src, filename, ignore } = parseRuleOptions(context, defaultOptions)
38+
39+
function isLayerDir(layerPath) {
40+
try {
41+
return fs.statSync(layerPath).isDirectory()
42+
} catch {
43+
return false
44+
}
45+
}
46+
47+
function checkIndexInLayer(entry, node) {
48+
try {
49+
const layerPath = path.join(src, entry)
50+
if (!isLayerDir(layerPath)) return
51+
if (Array.isArray(ignore) && ignore.includes(entry)) return
52+
53+
const indexPath = path.join(layerPath, filename)
54+
if (!fs.existsSync(indexPath)) return
55+
56+
try {
57+
const stat = fs.statSync(indexPath)
58+
if (stat && typeof stat.isFile === 'function' && stat.isFile()) {
59+
context.report({ node, messageId: 'forbidden', data: { layer: entry, filename } })
60+
}
61+
} catch {
62+
// ignore stat errors on the index file
63+
}
64+
} catch {
65+
// ignore errors per-entry
66+
}
67+
}
68+
69+
return {
70+
Program(node) {
71+
if (!allowFsCheck) return
72+
73+
try {
74+
if (!fs.existsSync(src) || !fs.statSync(src).isDirectory()) return
75+
76+
const entries = fs.readdirSync(src)
77+
for (const entry of entries) checkIndexInLayer(entry, node)
78+
} catch {
79+
// ignore filesystem errors
80+
}
81+
},
82+
}
83+
},
84+
}
Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
import { describe, it, beforeEach, expect, vi } from 'vitest'
2+
import fs from 'fs'
3+
import path from 'path'
4+
import rule from '../../src/rules/no-layer-public-api.js'
5+
import { setupTest, runRule, createContext } from '../test-utils.js'
6+
7+
describe('no-layer-public-api rule', () => {
8+
beforeEach(setupTest)
9+
10+
it('does not report when no index.ts in layers', () => {
11+
vi.spyOn(fs, 'readdirSync').mockReturnValue(['app', 'pages'])
12+
vi.spyOn(fs, 'existsSync').mockReturnValue(false)
13+
14+
const ctx = runRule(rule)
15+
expect(ctx.report).not.toHaveBeenCalled()
16+
})
17+
18+
it('skips ignored entry early (covers ignore check)', () => {
19+
vi.spyOn(fs, 'readdirSync').mockReturnValue(['app'])
20+
vi.spyOn(fs, 'existsSync').mockImplementation((p) => {
21+
if (p === 'src' || p.endsWith(path.sep + 'src')) return true
22+
if (p.endsWith('index.ts')) return true
23+
return false
24+
})
25+
vi.spyOn(fs, 'statSync').mockImplementation((p) => {
26+
if (p === 'src' || p.endsWith(path.sep + 'src')) return { isDirectory: () => true }
27+
if (p.endsWith(path.sep + 'app')) return { isDirectory: () => true }
28+
return { isDirectory: () => false, isFile: () => false }
29+
})
30+
31+
const ctx = createContext('file.js', [{ src: 'src', ignore: ['app'] }])
32+
const res = runRule(rule, ctx)
33+
expect(res.report).not.toHaveBeenCalled()
34+
})
35+
36+
it('skips when index file is missing for a layer (covers existsSync check)', () => {
37+
vi.spyOn(fs, 'readdirSync').mockReturnValue(['app'])
38+
vi.spyOn(fs, 'existsSync').mockImplementation((p) => {
39+
if (p === 'src' || p.endsWith(path.sep + 'src')) return true
40+
return false
41+
})
42+
vi.spyOn(fs, 'statSync').mockImplementation((p) => {
43+
if (p === 'src' || p.endsWith(path.sep + 'src')) return { isDirectory: () => true }
44+
if (p.endsWith(path.sep + 'app')) return { isDirectory: () => true }
45+
return { isDirectory: () => false, isFile: () => false }
46+
})
47+
48+
const ctx = runRule(rule)
49+
expect(ctx.report).not.toHaveBeenCalled()
50+
})
51+
52+
it('reports when index.ts exists at layer root', () => {
53+
vi.spyOn(fs, 'readdirSync').mockReturnValue(['app', 'pages'])
54+
// Ensure src exists and index file exists
55+
vi.spyOn(fs, 'existsSync').mockImplementation((p) => {
56+
if (p.endsWith('index.ts')) return true
57+
// treat src path as existing
58+
if (p === 'src' || p.endsWith(path.sep + 'src')) return true
59+
return false
60+
})
61+
62+
vi.spyOn(fs, 'statSync').mockImplementation((p) => {
63+
if (p === 'src' || p.endsWith(path.sep + 'src')) return { isDirectory: () => true }
64+
if (p.endsWith(path.sep + 'app')) return { isDirectory: () => true }
65+
if (p.endsWith('index.ts')) return { isFile: () => true }
66+
return { isDirectory: () => false, isFile: () => false }
67+
})
68+
69+
const ctx = runRule(rule)
70+
expect(ctx.report).toHaveBeenCalledWith(expect.objectContaining({ messageId: 'forbidden' }))
71+
})
72+
73+
it('respects ignore option', () => {
74+
vi.spyOn(fs, 'readdirSync').mockReturnValue(['app', 'pages'])
75+
vi.spyOn(fs, 'existsSync').mockImplementation((p) => p.endsWith('index.ts'))
76+
vi.spyOn(fs, 'statSync').mockImplementation((p) => {
77+
void p
78+
return { isDirectory: () => true, isFile: () => true }
79+
})
80+
81+
const ctx = createContext('file.js', [{ src: 'src', ignore: ['app'] }])
82+
const res = runRule(rule, ctx)
83+
expect(res.report).not.toHaveBeenCalled()
84+
})
85+
86+
it('runs only once per session', () => {
87+
vi.spyOn(fs, 'readdirSync').mockReturnValue(['app'])
88+
vi.spyOn(fs, 'existsSync').mockReturnValue(true)
89+
vi.spyOn(fs, 'statSync').mockImplementation((p) => {
90+
void p
91+
return { isDirectory: () => true, isFile: () => true }
92+
})
93+
94+
const first = runRule(rule)
95+
const second = runRule(rule)
96+
97+
expect(first.report).toHaveBeenCalled()
98+
expect(second.report).not.toHaveBeenCalled()
99+
})
100+
101+
it('ignores stat errors on the index file', () => {
102+
vi.spyOn(fs, 'readdirSync').mockReturnValue(['app'])
103+
vi.spyOn(fs, 'existsSync').mockImplementation((p) => {
104+
if (p === 'src' || p.endsWith(path.sep + 'src')) return true
105+
if (p.endsWith('index.ts')) return true
106+
return false
107+
})
108+
109+
vi.spyOn(fs, 'statSync').mockImplementation((p) => {
110+
if (p === 'src' || p.endsWith(path.sep + 'src')) return { isDirectory: () => true }
111+
if (p.endsWith(path.sep + 'app')) return { isDirectory: () => true }
112+
// throw when trying to stat the index file
113+
if (p.endsWith('index.ts')) throw new Error('stat failed')
114+
return { isDirectory: () => false, isFile: () => false }
115+
})
116+
117+
const ctx = runRule(rule)
118+
expect(ctx.report).not.toHaveBeenCalled()
119+
})
120+
121+
it('ignores errors from readdirSync', () => {
122+
vi.spyOn(fs, 'existsSync').mockImplementation((p) => p === 'src' || p.endsWith(path.sep + 'src'))
123+
vi.spyOn(fs, 'statSync').mockImplementation((p) => {
124+
if (p === 'src' || p.endsWith(path.sep + 'src')) return { isDirectory: () => true }
125+
return { isDirectory: () => false, isFile: () => false }
126+
})
127+
128+
vi.spyOn(fs, 'readdirSync').mockImplementation(() => {
129+
throw new Error('readdir failed')
130+
})
131+
132+
const ctx = runRule(rule)
133+
expect(ctx.report).not.toHaveBeenCalled()
134+
})
135+
136+
it('ignores stat errors when checking the layer directory', () => {
137+
vi.spyOn(fs, 'readdirSync').mockReturnValue(['app'])
138+
vi.spyOn(fs, 'existsSync').mockImplementation((p) => {
139+
if (p === 'src' || p.endsWith(path.sep + 'src')) return true
140+
if (p.endsWith('index.ts')) return true
141+
return false
142+
})
143+
144+
vi.spyOn(fs, 'statSync').mockImplementation((p) => {
145+
if (p === 'src' || p.endsWith(path.sep + 'src')) return { isDirectory: () => true }
146+
// simulate stat throwing for the layer path
147+
if (p.endsWith(path.sep + 'app')) throw new Error('layer stat failed')
148+
if (p.endsWith('index.ts')) return { isFile: () => true }
149+
return { isDirectory: () => false, isFile: () => false }
150+
})
151+
152+
const ctx = runRule(rule)
153+
expect(ctx.report).not.toHaveBeenCalled()
154+
})
155+
156+
it('ignores errors thrown by existsSync when checking index path', () => {
157+
vi.spyOn(fs, 'readdirSync').mockReturnValue(['app'])
158+
vi.spyOn(fs, 'existsSync').mockImplementation((p) => {
159+
if (p === 'src' || p.endsWith(path.sep + 'src')) return true
160+
if (p.endsWith('index.ts')) throw new Error('exists failed')
161+
return false
162+
})
163+
164+
vi.spyOn(fs, 'statSync').mockImplementation((p) => {
165+
if (p === 'src' || p.endsWith(path.sep + 'src')) return { isDirectory: () => true }
166+
if (p.endsWith(path.sep + 'app')) return { isDirectory: () => true }
167+
return { isDirectory: () => false, isFile: () => false }
168+
})
169+
170+
const ctx = runRule(rule)
171+
expect(ctx.report).not.toHaveBeenCalled()
172+
})
173+
})

0 commit comments

Comments
 (0)