Skip to content

Commit b04273d

Browse files
committed
feat!: add shared-ui-index-required rule to enforce shared/ui public API file and implement related documentation and tests
1 parent 6c5b52c commit b04273d

File tree

8 files changed

+190
-9
lines changed

8 files changed

+190
-9
lines changed

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,7 @@ This plugin provides rules to enforce modular architecture boundaries in Vue.js
9595
| [folder-kebab-case](./docs/rules/folder-kebab-case.md) | All folders must use kebab-case naming (e.g., `user-management/`, `auth/`). |
9696
| [feature-index-required](./docs/rules/feature-index-required.md) | Each feature folder must contain an `index.ts` file as its public API. |
9797
| [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. |
98+
| [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. |
9999

100100
### Dependency Rules
101101

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
# vue-modular/shared-ui-index-required
2+
3+
Require a `shared/ui` public API file (for example `shared/ui/index.ts`) so shared UI components are exported from a single, stable entry point.
4+
5+
## Rule Details
6+
7+
This rule verifies that a `shared/ui` directory contains a public API file. A central entry file shortens imports, centralizes re-exports, and prevents consumers from deep-importing component implementation files.
8+
9+
The rule only inspects files whose path contains the configured `shared` segment (default: `shared`). If a `shared/ui` folder contains implementation files but the configured index file is missing, the rule reports a problem.
10+
11+
Default index filename: `index.ts`.
12+
13+
## Options
14+
15+
The rule accepts an options object with the following properties:
16+
17+
- `shared` (string, default: `"shared"`) — path segment used to locate the shared folder in file paths.
18+
- `index` (string, default: `"index.ts"`) — filename to look for as the shared UI public API. Change this if your project uses a different entry filename (for example `main.ts`).
19+
20+
### Example configuration
21+
22+
```js
23+
{
24+
"vue-modular/shared-ui-index-required": [
25+
"error",
26+
{ "shared": "shared", "index": "index.ts" }
27+
]
28+
}
29+
```
30+
31+
## Examples
32+
33+
Incorrect
34+
35+
```text
36+
// File: src/shared/ui/Button.vue
37+
// There is no src/shared/ui/index.ts
38+
```
39+
40+
Correct
41+
42+
```ts
43+
// File: src/shared/ui/index.ts
44+
export { default as Button } from './Button.vue'
45+
```
46+
47+
## Usage Notes
48+
49+
- The rule uses the plugin `runOnce` pattern to emit a single report per ESLint process, avoiding duplicate reports when linting many files.
50+
- In monorepos or non-standard layouts, set the `shared` option to the appropriate path segment (for example `packages/*/shared`) to avoid false positives.
51+
52+
## When Not To Use
53+
54+
- Do not enable this rule if your project intentionally relies on deep imports from `shared/ui` or uses a different export strategy.
55+
56+
## Further Reading
57+
58+
- Centralized re-exports and small public surfaces help reduce coupling and make refactors safer.

src/configs.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ const recommendedRules = {
44
'vue-modular/folder-kebab-case': 'error',
55
'vue-modular/feature-index-required': 'error',
66
'vue-modular/components-index-required': 'error',
7+
'vue-modular/shared-ui-index-required': 'error',
78
}
89

910
const allRules = {

src/index.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ 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'
88
import componentsIndexRequired from './rules/components-index-required.js'
9+
import sharedUiIndexRequired from './rules/shared-ui-index-required.js'
910

1011
const plugin = {
1112
meta,
@@ -15,6 +16,7 @@ const plugin = {
1516
'folder-kebab-case': folderKebabCase,
1617
'feature-index-required': featureIndexRequired,
1718
'components-index-required': componentsIndexRequired,
19+
'shared-ui-index-required': sharedUiIndexRequired,
1820
},
1921
processors: {},
2022
configs: {},

src/rules/components-index-required.js

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,3 @@
1-
/**
2-
* @fileoverview Require components/{parent}/index.ts public API file
3-
*/
4-
51
import fs from 'fs'
62
import path from 'path'
73
import { parseRuleOptions, isFileIgnored, runOnce } from '../utils.js'

src/rules/feature-index-required.js

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,3 @@
1-
/**
2-
* @fileoverview Require features/{feature}/index.ts public API file
3-
*/
4-
51
import fs from 'fs'
62
import path from 'path'
73
import { parseRuleOptions, isFileIgnored, runOnce } from '../utils.js'
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import fs from 'fs'
2+
import path from 'path'
3+
import { parseRuleOptions, runOnce } from '../utils.js'
4+
5+
const defaultOptions = {
6+
shared: 'shared',
7+
index: 'index.ts',
8+
}
9+
10+
export default {
11+
meta: {
12+
type: 'suggestion',
13+
docs: {
14+
description: 'Require a shared/ui public API file shared/ui/index.ts when UI components exist',
15+
category: 'Best Practices',
16+
recommended: false,
17+
},
18+
defaultOptions: [defaultOptions],
19+
schema: [
20+
{
21+
type: 'object',
22+
properties: {
23+
shared: { type: 'string' },
24+
index: { type: 'string' },
25+
},
26+
additionalProperties: false,
27+
},
28+
],
29+
messages: {
30+
missingIndex:
31+
"Shared UI folder is missing a public API file '{{indexPath}}' (expected '{{index}}'). Add '{{index}}' to expose shared UI components.",
32+
},
33+
},
34+
35+
create(context) {
36+
const { shared, index } = parseRuleOptions(context, defaultOptions)
37+
38+
return {
39+
Program(node) {
40+
if (!runOnce('vue-modular/shared-ui-index-required')) return
41+
42+
const filename = context.getFilename()
43+
if (!filename || filename === '<input>' || filename === '<text>') return
44+
45+
const normalized = path.normalize(filename)
46+
const parts = normalized.split(path.sep)
47+
const idx = parts.lastIndexOf(shared)
48+
if (idx === -1) return
49+
50+
// expect the next segment to be 'ui'
51+
if (parts[idx + 1] !== 'ui') return
52+
53+
const sharedUiKey = parts.slice(0, idx + 2).join(path.sep)
54+
const indexPath = path.join(sharedUiKey, index)
55+
const exists = fs.existsSync(indexPath)
56+
57+
if (!exists) {
58+
context.report({ node, messageId: 'missingIndex', data: { indexPath, index } })
59+
}
60+
},
61+
}
62+
},
63+
}
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
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/shared-ui-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 shared/ui path: '/shared/ui'
11+
if (s.includes(`${path.sep}shared${path.sep}ui`)) {
12+
if (s.endsWith(indexFilename)) return hasIndex
13+
return true
14+
}
15+
return false
16+
})
17+
}
18+
19+
describe('vue-modular/shared-ui-index-required', () => {
20+
beforeEach(setupTest)
21+
22+
it('reports when shared/ui index missing', () => {
23+
mockFileSystem(false)
24+
const filename = path.join(process.cwd(), 'src', 'shared', 'ui', 'Button.vue')
25+
const ctx = runRule(rule, filename, [{ shared: 'shared', index: 'index.ts' }])
26+
expect(ctx.report).toHaveBeenCalled()
27+
})
28+
29+
it('does not report when shared/ui index present', () => {
30+
mockFileSystem(true)
31+
const filename = path.join(process.cwd(), 'src', 'shared', 'ui', 'Button.vue')
32+
const ctx = runRule(rule, filename, [{ shared: 'shared', index: 'index.ts' }])
33+
expect(ctx.report).not.toHaveBeenCalled()
34+
})
35+
36+
it('does nothing when not under shared/ui', () => {
37+
mockFileSystem(false)
38+
const filename = path.join(process.cwd(), 'src', 'shared', 'components', 'Button.vue')
39+
const ctx = runRule(rule, filename, [{ shared: 'shared', index: 'index.ts' }])
40+
expect(ctx.report).not.toHaveBeenCalled()
41+
})
42+
43+
it('does nothing when configured shared segment is not in filename path', () => {
44+
mockFileSystem(false)
45+
const filename = path.join(process.cwd(), 'src', 'features', 'auth', 'ui', 'Button.vue')
46+
const ctx = runRule(rule, filename, [{ shared: 'shared', index: 'index.ts' }])
47+
expect(ctx.report).not.toHaveBeenCalled()
48+
})
49+
50+
it('runs only once per session', () => {
51+
mockFileSystem(false)
52+
const filename = path.join(process.cwd(), 'src', 'shared', 'ui', 'Button.vue')
53+
const first = runRule(rule, filename, [{ shared: 'shared', index: 'index.ts' }])
54+
const second = runRule(rule, filename, [{ shared: 'shared', index: 'index.ts' }])
55+
56+
expect(first.report).toHaveBeenCalled()
57+
expect(second.report).not.toHaveBeenCalled()
58+
})
59+
60+
it('does nothing for virtual filenames', () => {
61+
mockFileSystem(false)
62+
const ctx = runRule(rule, '<input>', [{ shared: 'shared', index: 'index.ts' }])
63+
expect(ctx.report).not.toHaveBeenCalled()
64+
})
65+
})

0 commit comments

Comments
 (0)