Skip to content

Commit adfd20d

Browse files
authored
Merge pull request #12 from andrewmolyuk/components-index-required
feat: implement components-index-required rule to enforce index file in components directories
2 parents a7c1160 + 0131088 commit adfd20d

File tree

4 files changed

+162
-17
lines changed

4 files changed

+162
-17
lines changed

README.md

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -107,18 +107,18 @@ This plugin provides rules to enforce modular architecture boundaries in Vue.js
107107

108108
> The list of rules is a work in progress. Implemented rules are linked below; unimplemented rules are listed as plain names.
109109
>
110-
> ![Progress](https://progress-bar.xyz/5/?scale=92&width=500&title=5%20of%2092%20rules%20completed)
110+
> ![Progress](https://progress-bar.xyz/6/?scale=92&width=500&title=6%20of%2092%20rules%20completed)
111111
112112
### File Organization Rules
113113

114-
| Rule | Description |
115-
| -------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- |
116-
| [file-component-naming](./docs/rules/file-component-naming.md) | All Vue components must use PascalCase naming (e.g., `UserForm.vue`, `ProductList.vue`). |
117-
| [file-ts-naming](./docs/rules/file-ts-naming.md) | All TypeScript files must use camelCase naming (e.g., `useAuth.ts`, `userApi.ts`). |
118-
| [folder-kebab-case](./docs/rules/folder-kebab-case.md) | All folders must use kebab-case naming (e.g., `user-management/`, `auth/`). |
119-
| [feature-index-required](./docs/rules/feature-index-required.md) | Each feature folder must contain an `index.ts` file as its public API. |
120-
| components-index-required | All `components/` folders must contain an `index.ts` (or configured filename) file for component exports. |
121-
| [shared-ui-index-required](./docs/rules/shared-ui-index-required.md) | The `shared/ui/` folder must contain an `index.ts` file for UI component exports. |
114+
| Rule | Description |
115+
| ---------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- |
116+
| [file-component-naming](./docs/rules/file-component-naming.md) | All Vue components must use PascalCase naming (e.g., `UserForm.vue`, `ProductList.vue`). |
117+
| [file-ts-naming](./docs/rules/file-ts-naming.md) | All TypeScript files must use camelCase naming (e.g., `useAuth.ts`, `userApi.ts`). |
118+
| [folder-kebab-case](./docs/rules/folder-kebab-case.md) | All folders must use kebab-case naming (e.g., `user-management/`, `auth/`). |
119+
| [feature-index-required](./docs/rules/feature-index-required.md) | Each feature folder must contain an `index.ts` file as its public API. |
120+
| [components-index-required](./docs/rules/components-index-required.md) | All `components/` folders must contain an `index.ts` (or configured filename) file for component exports. |
121+
| [shared-ui-index-required](./docs/rules/shared-ui-index-required.md) | The `shared/ui/` folder must contain an `index.ts` file for UI component exports. |
122122

123123
### Dependency Rules
124124

docs/rules/components-index-required.md

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,31 +4,32 @@ Enforce a components folder index file (for example `features/<feature>/componen
44

55
## Rule Details
66

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.
7+
This rule scans the project's source tree (starting at the configured project `rootPath`, default: `src`) for `components` directories and verifies they contain a public API file. A stable components index makes imports shorter, centralizes re-exports, and reduces accidental deep imports.
88

99
Default index filename: `index.ts`.
1010

1111
## Options
1212

1313
The rule accepts an options object with the following properties:
1414

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.
15+
- `ignores` (string[], default: `[]`) — minimatch-style patterns matched against directory paths to skip certain folders from scanning.
1716
- `index` (string, default: `"index.ts"`) — filename to look for as the components public API (for example `index.js` or `main.ts`).
1817

18+
Note: project-wide paths such as `rootPath` are read from plugin settings (`settings['vue-modular']`) and merged with defaults.
19+
1920
### Example configuration
2021

2122
```js
22-
// Use defaults (scan `components`, expect `index.ts`)
23+
// Use defaults (scan project src tree, expect `index.ts` in each components folder)
2324
{
2425
"vue-modular/components-index-required": ["error"]
2526
}
2627

27-
// Custom configuration
28+
// Custom configuration (override index filename or add ignores)
2829
{
2930
"vue-modular/components-index-required": [
3031
"error",
31-
{ "components": "components", "ignore": ["shared"], "index": "main.ts" }
32+
{ "ignores": ["**/shared/**"], "index": "main.ts" }
3233
]
3334
}
3435
```
@@ -51,8 +52,7 @@ export { default as Icon } from './Icon.vue'
5152
## Usage Notes
5253

5354
- 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`).
55+
- Use the `ignores` option or update project `rootPath` via `settings['vue-modular']` when your repository places component folders in non-standard locations (for example monorepos).
5656

5757
## When Not To Use
5858

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import fs from 'fs'
2+
import path from 'path'
3+
import { parseRuleOptions, runOnce, createRule, isIgnored, parseProjectOptions } from '../utils'
4+
import type { VueModularRuleModule } from '../types'
5+
6+
const defaultOptions = {
7+
ignores: [],
8+
index: 'index.ts',
9+
}
10+
11+
export const componentsIndexRequired = createRule<VueModularRuleModule>({
12+
create(context) {
13+
if (!runOnce('components-index-required')) return {}
14+
15+
const options = parseRuleOptions(context, defaultOptions)
16+
const projectOptions = parseProjectOptions(context)
17+
18+
return {
19+
Program(node) {
20+
try {
21+
const dirs = fs
22+
.readdirSync(projectOptions.rootPath, { recursive: true })
23+
.filter((d) => fs.statSync(d).isDirectory())
24+
.filter((d) => !isIgnored(String(d), options.ignores)) as string[]
25+
26+
dirs.forEach((dir) => {
27+
const indexPath = path.join(dir, options.index)
28+
const isExists = fs.existsSync(indexPath)
29+
30+
if (!isExists) {
31+
context.report({
32+
node,
33+
messageId: 'missingIndex',
34+
data: { indexPath: path.relative(process.cwd(), indexPath), index: options.index },
35+
})
36+
}
37+
})
38+
} catch {
39+
// ignore fs errors
40+
}
41+
},
42+
}
43+
},
44+
name: 'components-index-required',
45+
recommended: true,
46+
level: 'error',
47+
meta: {
48+
type: 'suggestion',
49+
docs: {
50+
description: 'Require a components/index.ts to expose the components public API',
51+
category: 'File Organization',
52+
recommended: false,
53+
},
54+
defaultOptions: [defaultOptions],
55+
schema: [
56+
{
57+
type: 'object',
58+
properties: {
59+
ignores: { type: 'array', items: { type: 'string' } },
60+
index: { type: 'string' },
61+
},
62+
additionalProperties: false,
63+
},
64+
],
65+
messages: {
66+
missingIndex: "Components folder is missing a public API file '{{indexPath}}'. Add '{{index}}' to expose the component public API.",
67+
},
68+
},
69+
})
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import { describe, it, expect, vi, beforeEach } from 'vitest'
2+
import { runRule, setupTest } from '../test-utils'
3+
import { componentsIndexRequired } from '../../src/rules/components-index-required'
4+
import fs from 'fs'
5+
6+
describe('components-index-required rule', () => {
7+
beforeEach(() => setupTest())
8+
9+
it('reports a missing index file for a components directory that has files but no index', () => {
10+
vi.spyOn(fs, 'readdirSync').mockReturnValue(['components-without-index', 'components-with-index'] as unknown as fs.Dirent<
11+
Buffer<ArrayBufferLike>
12+
>[])
13+
vi.spyOn(fs, 'statSync').mockReturnValue({ isDirectory: () => true } as unknown as fs.Stats)
14+
vi.spyOn(fs, 'existsSync').mockImplementation((p) => String(p).includes('components-with-index'))
15+
16+
const context = runRule(componentsIndexRequired, 'filename.ts')
17+
18+
expect(context.report).toHaveBeenCalledTimes(1)
19+
expect(context.report).toHaveBeenCalledWith(
20+
expect.objectContaining({
21+
messageId: 'missingIndex',
22+
data: expect.objectContaining({ indexPath: 'components-without-index/index.ts', index: 'index.ts' }),
23+
}),
24+
)
25+
})
26+
27+
it('does not report when every components directory contains the expected index file', () => {
28+
vi.spyOn(fs, 'readdirSync').mockReturnValue(['components-a', 'components-b'] as unknown as fs.Dirent<Buffer<ArrayBufferLike>>[])
29+
vi.spyOn(fs, 'statSync').mockReturnValue({ isDirectory: () => true } as unknown as fs.Stats)
30+
vi.spyOn(fs, 'existsSync').mockReturnValue(true)
31+
32+
const context = runRule(componentsIndexRequired, 'filename.ts')
33+
34+
expect(context.report).not.toHaveBeenCalled()
35+
})
36+
37+
it('respects ignore option and does not report for ignored components dirs', () => {
38+
vi.spyOn(fs, 'readdirSync').mockReturnValue(['ignored-components', 'normal-components'] as unknown as fs.Dirent<
39+
Buffer<ArrayBufferLike>
40+
>[])
41+
vi.spyOn(fs, 'statSync').mockReturnValue({ isDirectory: () => true } as unknown as fs.Stats)
42+
vi.spyOn(fs, 'existsSync').mockReturnValue(false)
43+
44+
const context = runRule(componentsIndexRequired, 'filename.ts', [{ ignores: ['ignored-components'], index: 'index.ts' }])
45+
46+
expect(context.report).toHaveBeenCalledTimes(1)
47+
expect(context.report).toHaveBeenCalledWith(
48+
expect.objectContaining({
49+
messageId: 'missingIndex',
50+
data: expect.objectContaining({ indexPath: 'normal-components/index.ts', index: 'index.ts' }),
51+
}),
52+
)
53+
})
54+
55+
it('does nothing when runOnce returns false', () => {
56+
vi.spyOn(fs, 'readdirSync').mockReturnValue(['components-without-index'] as unknown as fs.Dirent<Buffer<ArrayBufferLike>>[])
57+
vi.spyOn(fs, 'statSync').mockReturnValue({ isDirectory: () => true } as unknown as fs.Stats)
58+
vi.spyOn(fs, 'existsSync').mockReturnValue(false)
59+
60+
const context1 = runRule(componentsIndexRequired, 'filename1.ts')
61+
const context2 = runRule(componentsIndexRequired, 'filename2.ts')
62+
63+
expect(context1.report).toHaveBeenCalledTimes(1)
64+
expect(context2.report).not.toHaveBeenCalled()
65+
})
66+
67+
it('handles fs errors gracefully (does not throw)', () => {
68+
vi.spyOn(fs, 'readdirSync').mockImplementation(() => {
69+
throw new Error('boom')
70+
})
71+
72+
expect(() => {
73+
runRule(componentsIndexRequired, 'filename.ts')
74+
}).not.toThrow()
75+
})
76+
})

0 commit comments

Comments
 (0)