Skip to content

Commit db917e1

Browse files
committed
feat: add feature-index-required rule to enforce public API entry file for features and implement related tests
1 parent e080987 commit db917e1

File tree

6 files changed

+266
-8
lines changed

6 files changed

+266
-8
lines changed

README.md

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -88,14 +88,14 @@ This plugin provides rules to enforce modular architecture boundaries in Vue.js
8888
8989
### File Organization Rules
9090

91-
| Rule | Description |
92-
| -------------------------------------------------------------- | ---------------------------------------------------------------------------------------- |
93-
| [file-component-naming](./docs/rules/file-component-naming.md) | All Vue components must use PascalCase naming (e.g., `UserForm.vue`, `ProductList.vue`). |
94-
| [file-ts-naming](./docs/rules/file-ts-naming.md) | All TypeScript files must use camelCase naming (e.g., `useAuth.ts`, `userApi.ts`). |
95-
| [folder-kebab-case](./docs/rules/folder-kebab-case.md) | All folders must use kebab-case naming (e.g., `user-management/`, `auth/`). |
96-
| feature-index-required | Each feature folder must contain an `index.ts` file as its public API. |
97-
| components-index-required | All `components/` folders must contain an `index.ts` file for component exports. |
98-
| shared-ui-index-required | The `shared/ui/` folder must contain an `index.ts` file for UI component exports. |
91+
| Rule | Description |
92+
| ---------------------------------------------------------------- | ---------------------------------------------------------------------------------------- |
93+
| [file-component-naming](./docs/rules/file-component-naming.md) | All Vue components must use PascalCase naming (e.g., `UserForm.vue`, `ProductList.vue`). |
94+
| [file-ts-naming](./docs/rules/file-ts-naming.md) | All TypeScript files must use camelCase naming (e.g., `useAuth.ts`, `userApi.ts`). |
95+
| [folder-kebab-case](./docs/rules/folder-kebab-case.md) | All folders must use kebab-case naming (e.g., `user-management/`, `auth/`). |
96+
| [feature-index-required](./docs/rules/feature-index-required.md) | Each feature folder must contain an `index.ts` file as its public API. |
97+
| components-index-required | All `components/` folders must contain an `index.ts` file for component exports. |
98+
| shared-ui-index-required | The `shared/ui/` folder must contain an `index.ts` file for UI component exports. |
9999

100100
### Dependency Rules
101101

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
# vue-modular/feature-index-required
2+
3+
Require a public feature entry file (for example `src/features/<feature>/index.ts`) so consumers import from the feature root instead of deep-importing internal implementation files.
4+
5+
## Rule Details
6+
7+
This rule verifies that each feature folder exposes a public API index file. Keeping a small, stable public surface at the feature root helps encapsulate implementation details and makes refactors safer.
8+
9+
By default the rule applies when the linted file path contains the configured `features` segment (default: `src/features`). If a feature contains implementation files but no supported index file exists, the rule reports a problem.
10+
11+
Default index filename: `index.ts`, rule still accepts any filename via the `index` option.
12+
13+
## Options
14+
15+
The rule accepts an options object with the following properties:
16+
17+
- `features` (string, default: `"src/features"`) — path segment used to detect the features root in file paths. If this segment is not present in the filename the rule ignores the file.
18+
- `ignore` (string[], default: `[]`) — array of feature-name patterns to skip. Patterns use `minimatch` semantics and are matched against the feature folder name (the single path segment after the features segment).
19+
- `index` (string, default: `"index.ts"`) — filename to look for as the feature public API. Use this to change the expected index file name (for example `index.js` or `main.ts`). The rule checks for this exact filename at the feature root.
20+
21+
### Example configuration
22+
23+
```js
24+
// Use defaults (scan `src/features` and no ignores)
25+
{
26+
"vue-modular/feature-index-required": ["error"]
27+
}
28+
29+
// Custom features folder and ignores
30+
{
31+
"vue-modular/feature-index-required": [
32+
"error",
33+
{ "features": "app/features", "ignore": ["shared-.*", "legacy"], "index": "main.ts" }
34+
]
35+
}
36+
```
37+
38+
## Examples
39+
40+
### Default index (index.ts)
41+
42+
Incorrect (feature has files but no `index.ts`):
43+
44+
```text
45+
// File: src/features/auth/components/LoginForm.vue
46+
// There is no src/features/auth/index.ts
47+
```
48+
49+
Correct (add `index.ts` at feature root):
50+
51+
```ts
52+
// File: src/features/auth/index.ts
53+
export * from './components'
54+
export * from './composables'
55+
```
56+
57+
### Custom index filename (main.ts)
58+
59+
If your project uses a different entry filename, configure `index` accordingly.
60+
61+
Incorrect (rule configured with `index: 'main.ts'` but `main.ts` is missing):
62+
63+
```text
64+
// File: app/features/auth/components/LoginForm.vue
65+
// There is no app/features/auth/main.ts (rule expects 'main.ts')
66+
```
67+
68+
Correct (provide `main.ts` at feature root):
69+
70+
```ts
71+
// File: app/features/auth/main.ts
72+
export * from './components'
73+
export * from './composables'
74+
```
75+
76+
## Usage Notes
77+
78+
- The rule executes a single scan per ESLint process (the plugin `runOnce` pattern) to avoid duplicate reports when linting multiple files.
79+
- The rule only reports when the inspected feature directory contains implementation files other than the configured index; empty folders or features fully covered by `ignore` are not reported.
80+
- The `ignore` option is matched against the feature folder name only (not the whole path) and uses `minimatch` semantics.
81+
- In monorepos or non-standard layouts, set the `features` option to an appropriate segment (for example `packages/*/src/features` or `app/features`) to avoid false positives.
82+
83+
## When Not To Use
84+
85+
- Do not enable this rule if your project intentionally relies on deep imports or follows a different public API strategy for features.
86+
87+
## Further Reading
88+
89+
- Modular design: prefer small public surfaces for modules/features to improve encapsulation and simplify refactors.

src/configs.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ const recommendedRules = {
22
'vue-modular/file-component-naming': 'error',
33
'vue-modular/file-ts-naming': 'error',
44
'vue-modular/folder-kebab-case': 'error',
5+
'vue-modular/feature-index-required': 'error',
56
}
67

78
const allRules = {

src/index.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,15 @@ import meta from './meta.js'
44
import fileComponentNaming from './rules/file-component-naming.js'
55
import fileTsNaming from './rules/file-ts-naming.js'
66
import folderKebabCase from './rules/folder-kebab-case.js'
7+
import featureIndexRequired from './rules/feature-index-required.js'
78

89
const plugin = {
910
meta,
1011
rules: {
1112
'file-component-naming': fileComponentNaming,
1213
'file-ts-naming': fileTsNaming,
1314
'folder-kebab-case': folderKebabCase,
15+
'feature-index-required': featureIndexRequired,
1416
},
1517
processors: {},
1618
configs: {},
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
/**
2+
* @fileoverview Require features/{feature}/index.ts public API file
3+
*/
4+
5+
import fs from 'fs'
6+
import path from 'path'
7+
import { parseRuleOptions, isFileIgnored, runOnce } from '../utils.js'
8+
9+
const defaultOptions = {
10+
features: 'src/features',
11+
ignore: [],
12+
index: 'index.ts',
13+
}
14+
15+
export default {
16+
meta: {
17+
type: 'suggestion',
18+
docs: {
19+
description: 'Require a feature public API file features/{feature}/index.ts when a feature contains files',
20+
category: 'Best Practices',
21+
recommended: false,
22+
},
23+
defaultOptions: [defaultOptions],
24+
schema: [
25+
{
26+
type: 'object',
27+
properties: {
28+
features: { type: 'string' },
29+
ignore: { type: 'array', items: { type: 'string' } },
30+
index: { type: 'string' },
31+
},
32+
additionalProperties: false,
33+
},
34+
],
35+
messages: {
36+
missingIndex:
37+
"Feature '{{feature}}' is missing a public API file '{{indexPath}}' (expected '{{index}}'). Add '{{index}}' to expose the feature public API.",
38+
},
39+
},
40+
41+
create(context) {
42+
const { features, ignore, index } = parseRuleOptions(context, defaultOptions)
43+
44+
return {
45+
Program(node) {
46+
// run once per eslint execution
47+
if (!runOnce('vue-modular/feature-index-required')) return
48+
49+
const filename = context.getFilename()
50+
if (!filename || filename === '<input>' || filename === '<text>') return
51+
52+
const normalized = path.normalize(filename)
53+
const parts = normalized.split(path.sep)
54+
const idx = parts.lastIndexOf(features)
55+
if (idx === -1) return
56+
57+
const featureName = parts[idx + 1]
58+
if (!featureName) return
59+
if (isFileIgnored(featureName, ignore)) return
60+
61+
const featureKey = parts.slice(0, idx + 2).join(path.sep)
62+
const indexPath = path.join(featureKey, index)
63+
const exists = fs.existsSync(indexPath)
64+
65+
if (!exists) {
66+
context.report({ node, messageId: 'missingIndex', data: { feature: featureName, indexPath, index } })
67+
}
68+
},
69+
}
70+
},
71+
}
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import fs from 'fs'
2+
import path from 'path'
3+
import { describe, it, expect, beforeEach, vi } from 'vitest'
4+
import { setupTest, runRule } from '../utils.js'
5+
import rule from '../../src/rules/feature-index-required.js'
6+
7+
const mockFileSystem = (hasIndex = true, indexFilename = 'index.ts') => {
8+
// make existsSync return true for directories and conditionally for index files
9+
vi.spyOn(fs, 'existsSync').mockImplementation((p) => {
10+
const s = String(p)
11+
// simulate feature folder path containing '/features/auth'
12+
if (s.includes(`${path.sep}features${path.sep}auth`)) {
13+
if (s.endsWith(indexFilename)) {
14+
return hasIndex
15+
}
16+
return true
17+
}
18+
return false
19+
})
20+
}
21+
22+
describe('vue-modular/feature-index-required', () => {
23+
beforeEach(setupTest)
24+
25+
it('reports when index.ts missing', () => {
26+
mockFileSystem(false)
27+
const filename = path.join(process.cwd(), 'src', 'features', 'auth', 'components', 'LoginForm.vue')
28+
const ctx = runRule(rule, filename, [{ features: 'features', ignore: [] }])
29+
expect(ctx.report).toHaveBeenCalled()
30+
})
31+
32+
it('does not report when index.ts present', () => {
33+
mockFileSystem(true)
34+
const filename = path.join(process.cwd(), 'src', 'features', 'auth', 'components', 'LoginForm.vue')
35+
const ctx = runRule(rule, filename, [{ features: 'features', ignore: [] }])
36+
expect(ctx.report).not.toHaveBeenCalled()
37+
})
38+
39+
it('runs only once per session', () => {
40+
mockFileSystem(false)
41+
const filename = path.join(process.cwd(), 'src', 'features', 'auth', 'components', 'LoginForm.vue')
42+
const first = runRule(rule, filename, [{ features: 'features', ignore: [] }])
43+
const second = runRule(rule, filename, [{ features: 'features', ignore: [] }])
44+
45+
expect(first.report).toHaveBeenCalled()
46+
expect(second.report).not.toHaveBeenCalled()
47+
})
48+
49+
it('does nothing for virtual filenames', () => {
50+
mockFileSystem(false)
51+
const ctx = runRule(rule, '<input>', [{ features: 'features', ignore: [] }])
52+
expect(ctx.report).not.toHaveBeenCalled()
53+
})
54+
55+
it('returns early when featureName missing (path ends with features)', () => {
56+
mockFileSystem(false)
57+
const filename = path.join(process.cwd(), 'src', 'features')
58+
const ctx = runRule(rule, filename, [{ features: 'features', ignore: [] }])
59+
expect(ctx.report).not.toHaveBeenCalled()
60+
})
61+
62+
it('respects ignore option', () => {
63+
mockFileSystem(false)
64+
const filename = path.join(process.cwd(), 'src', 'features', 'auth', 'components', 'LoginForm.vue')
65+
const ctx = runRule(rule, filename, [{ features: 'features', ignore: ['auth'] }])
66+
expect(ctx.report).not.toHaveBeenCalled()
67+
})
68+
69+
it('does nothing when configured features folder is not in filename path', () => {
70+
mockFileSystem(false)
71+
const filename = path.join(process.cwd(), 'src', 'other', 'auth', 'components', 'LoginForm.vue')
72+
const ctx = runRule(rule, filename, [{ features: 'features', ignore: [] }])
73+
expect(ctx.report).not.toHaveBeenCalled()
74+
})
75+
76+
it('respects custom index filename option', () => {
77+
// simulate that 'main.ts' is present and default index.ts is missing
78+
mockFileSystem(true, 'main.ts')
79+
const filename = path.join(process.cwd(), 'src', 'features', 'auth', 'components', 'LoginForm.vue')
80+
// configure the rule to expect 'main.ts' as the feature index
81+
const ctx = runRule(rule, filename, [{ features: 'features', ignore: [], index: 'main.ts' }])
82+
// when main.ts exists, there should be no report
83+
expect(ctx.report).not.toHaveBeenCalled()
84+
})
85+
86+
it('reports when configured custom index is missing', () => {
87+
// simulate that 'main.ts' is missing
88+
mockFileSystem(false, 'main.ts')
89+
const filename = path.join(process.cwd(), 'src', 'features', 'auth', 'components', 'LoginForm.vue')
90+
// configure the rule to expect 'main.ts' as the feature index
91+
const ctx = runRule(rule, filename, [{ features: 'features', ignore: [], index: 'main.ts' }])
92+
// when main.ts does not exist, the rule should report
93+
expect(ctx.report).toHaveBeenCalled()
94+
})
95+
})

0 commit comments

Comments
 (0)