Skip to content

Commit 6c5b52c

Browse files
committed
feat: add components-index-required rule to enforce components folder index file and implement related tests
1 parent db917e1 commit 6c5b52c

File tree

7 files changed

+230
-8
lines changed

7 files changed

+230
-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](./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. |
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](./docs/rules/components-index-required.md) | All `components/` folders must contain an `index.ts` (or configured filename) 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: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
# vue-modular/components-index-required
2+
3+
Enforce a components folder index file (for example `features/<feature>/components/index.ts`) so component consumers import from the components root instead of deep-importing implementation files.
4+
5+
## Rule Details
6+
7+
This rule verifies that a `components` directory inside a feature (or any configured segment) exposes a public API file. A stable components index makes imports shorter, centralizes re-exports, and reduces accidental deep imports.
8+
9+
Default index filename: `index.ts`.
10+
11+
## Options
12+
13+
The rule accepts an options object with the following properties:
14+
15+
- `components` (string, default: `"components"`) — the path segment used to locate component directories under a feature.
16+
- `ignore` (string[], default: `[]`) — minimatch-style patterns matched against the parent folder name to skip certain features.
17+
- `index` (string, default: `"index.ts"`) — filename to look for as the components public API (for example `index.js` or `main.ts`).
18+
19+
### Example configuration
20+
21+
```js
22+
// Use defaults (scan `components`, expect `index.ts`)
23+
{
24+
"vue-modular/components-index-required": ["error"]
25+
}
26+
27+
// Custom configuration
28+
{
29+
"vue-modular/components-index-required": [
30+
"error",
31+
{ "components": "components", "ignore": ["shared"], "index": "main.ts" }
32+
]
33+
}
34+
```
35+
36+
## Incorrect
37+
38+
```text
39+
// File: src/features/auth/components/Button.vue
40+
// There is no src/features/auth/components/index.ts
41+
```
42+
43+
## Correct
44+
45+
```ts
46+
// File: src/features/auth/components/index.ts
47+
export { default as Button } from './Button.vue'
48+
export { default as Icon } from './Icon.vue'
49+
```
50+
51+
## Usage Notes
52+
53+
- The rule executes a single scan per ESLint process using the plugin's `runOnce` utility — this avoids duplicate reports when linting many files.
54+
- The `ignore` option matches against the feature (parent) folder name and uses `minimatch` semantics.
55+
- If your repository places component folders at non-standard locations, change the `components` option (for example `shared/components` or `app/components`).
56+
57+
## When Not To Use
58+
59+
- Do not enable this rule if your project intentionally relies on deep imports or uses a different component export strategy.
60+
61+
## Further Reading
62+
63+
- Centralized re-exports and module boundaries help reduce coupling and improve refactorability.

src/configs.js

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

89
const allRules = {

src/index.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ 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'
77
import featureIndexRequired from './rules/feature-index-required.js'
8+
import componentsIndexRequired from './rules/components-index-required.js'
89

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

0 commit comments

Comments
 (0)