Skip to content

Commit fe012ad

Browse files
committed
feat: add no-direct-feature-imports rule to enforce encapsulation between feature folders and implement related documentation and tests
BREAKING CHANGE: we remodeled project structure and rules from the scratch
1 parent b04273d commit fe012ad

File tree

8 files changed

+333
-9
lines changed

8 files changed

+333
-9
lines changed

README.md

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -99,13 +99,13 @@ This plugin provides rules to enforce modular architecture boundaries in Vue.js
9999

100100
### Dependency Rules
101101

102-
| Rule | Description |
103-
| -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
104-
| no-direct-feature-imports | Features cannot import from other features directly. |
105-
| feature-imports-from-shared-only | Features can only import from `shared/` folder. |
106-
| no-shared-imports-from-features | `shared/` folder cannot import from `features/` or `views/`. |
107-
| app-imports | `app/` folder can import from `shared/` and `features/` (exception: `app/router.ts` may import feature route files to compose the global router). |
108-
| cross-feature-via-shared | All cross-feature communication must go through the `shared/` layer. |
102+
| Rule | Description |
103+
| ---------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
104+
| [no-direct-feature-imports](./docs/rules/no-direct-feature-imports.md) | Features cannot import from other features directly. |
105+
| feature-imports-from-shared-only | Features can only import from `shared/` folder. |
106+
| no-shared-imports-from-features | `shared/` folder cannot import from `features/` or `views/`. |
107+
| app-imports | `app/` folder can import from `shared/` and `features/` (exception: `app/router.ts` may import feature route files to compose the global router). |
108+
| cross-feature-via-shared | All cross-feature communication must go through the `shared/` layer. |
109109

110110
### Component Rules
111111

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
# vue-modular/no-direct-feature-imports
2+
3+
Disallow direct imports between feature folders. Cross-feature communication should go through the feature public API or the `shared/` layer to preserve encapsulation.
4+
5+
## Rule Details
6+
7+
This rule detects imports that reference another feature's implementation files directly (for example `src/features/payments/utils/api.js`) from inside a different feature. Direct cross-feature imports increase coupling and make refactors harder because consumers can depend on internal implementation details.
8+
9+
By default the rule considers file paths that contain the configured `features` segment (default: `src/features`) as feature sources to inspect. Import specifiers are resolved and analyzed; relative imports are resolved against the current file so `../../payments/...` correctly identifies the `payments` feature.
10+
11+
The rule ignores virtual filenames (`<input>`, `<text>`) and test files (files matched by the project's test detection utility). It also accepts an `ignore` option to skip reporting for specific target feature names.
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+
20+
### Example configuration
21+
22+
```js
23+
// Use defaults (scan `src/features` and no ignores)
24+
{
25+
"vue-modular/no-direct-feature-imports": ["error"]
26+
}
27+
28+
// Ignore a legacy feature
29+
{
30+
"vue-modular/no-direct-feature-imports": [
31+
"error",
32+
{ "features": "app/features", "ignore": ["legacy", "payments-stub"] }
33+
]
34+
}
35+
```
36+
37+
## Examples
38+
39+
Incorrect (direct import from another feature):
40+
41+
```text
42+
// File: src/features/auth/components/LoginForm.vue
43+
import { charge } from '../../payments/utils/api.js' // -> imports implementation from another feature
44+
```
45+
46+
Correct (use feature public API or shared layer):
47+
48+
```ts
49+
// File: src/features/auth/components/LoginForm.vue
50+
import { charge } from 'features/payments' // public API at src/features/payments/index.ts
51+
// or
52+
import { charge } from 'shared/services/paymentClient' // shared layer
53+
```
54+
55+
## Notes
56+
57+
- The rule resolves relative import paths against the linted file to correctly map `..` segments to absolute filesystem paths.
58+
- Test files and virtual filenames are ignored to avoid false positives during tests and non-file sources.
59+
- Use the `ignore` option to whitelist feature names that should not be reported.
60+
61+
## When Not To Use
62+
63+
- Disable this rule if your project intentionally allows deep cross-feature imports or during migration phases where you need temporary exceptions.

src/configs.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ const recommendedRules = {
55
'vue-modular/feature-index-required': 'error',
66
'vue-modular/components-index-required': 'error',
77
'vue-modular/shared-ui-index-required': 'error',
8+
'vue-modular/no-direct-feature-imports': 'error',
89
}
910

1011
const allRules = {

src/index.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ 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'
99
import sharedUiIndexRequired from './rules/shared-ui-index-required.js'
10+
import noDirectFeatureImports from './rules/no-direct-feature-imports.js'
1011

1112
const plugin = {
1213
meta,
@@ -17,6 +18,7 @@ const plugin = {
1718
'feature-index-required': featureIndexRequired,
1819
'components-index-required': componentsIndexRequired,
1920
'shared-ui-index-required': sharedUiIndexRequired,
21+
'no-direct-feature-imports': noDirectFeatureImports,
2022
},
2123
processors: {},
2224
configs: {},
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import path from 'path'
2+
import { parseRuleOptions, isTestFile, isFileIgnored } from '../utils.js'
3+
4+
const defaultOptions = {
5+
features: 'src/features',
6+
ignore: [],
7+
}
8+
9+
export default {
10+
meta: {
11+
type: 'problem',
12+
docs: {
13+
description: 'Disallow direct imports from other feature folders (use shared layer or feature public API)',
14+
category: 'Best Practices',
15+
recommended: false,
16+
},
17+
defaultOptions: [defaultOptions],
18+
schema: [
19+
{
20+
type: 'object',
21+
properties: {
22+
features: { type: 'string' },
23+
ignore: { type: 'array', items: { type: 'string' } },
24+
},
25+
additionalProperties: false,
26+
},
27+
],
28+
messages: {
29+
forbidden:
30+
"Direct import from another feature '{{targetFeature}}' is not allowed. Import from the feature public API or from 'shared/'.",
31+
},
32+
},
33+
34+
create(context) {
35+
const { features, ignore } = parseRuleOptions(context, defaultOptions)
36+
37+
return {
38+
Program() {
39+
// compute per-file context needed by ImportDeclaration visitor
40+
const filename = context.getFilename()
41+
if (!filename || filename === '<input>' || filename === '<text>') {
42+
// mark as no-op
43+
context.__noDirectFeatureSource = null
44+
return
45+
}
46+
if (isTestFile(filename)) {
47+
context.__noDirectFeatureSource = null
48+
return
49+
}
50+
51+
const normalized = path.normalize(filename)
52+
const parts = normalized.split(path.sep)
53+
const featuresIdx = parts.lastIndexOf(features)
54+
if (featuresIdx === -1) {
55+
context.__noDirectFeatureSource = null
56+
return
57+
}
58+
59+
const sourceFeature = parts[featuresIdx + 1]
60+
if (!sourceFeature) {
61+
context.__noDirectFeatureSource = null
62+
return
63+
}
64+
65+
// store sourceFeature and filename on context for ImportDeclaration to use
66+
context.__noDirectFeatureSource = { sourceFeature, filename }
67+
},
68+
69+
ImportDeclaration(node) {
70+
const state = context.__noDirectFeatureSource
71+
if (!state) return
72+
73+
const { sourceFeature, filename } = state
74+
const spec = node && node.source && node.source.value
75+
if (!spec || typeof spec !== 'string') return
76+
77+
// Resolve relative imports against the current file so './utils' -> absolute path
78+
let impPath = spec
79+
if (impPath.startsWith('.')) {
80+
impPath = path.join(path.dirname(filename), impPath)
81+
}
82+
83+
const impParts = path.normalize(impPath).split(path.sep)
84+
const impFeaturesIdx = impParts.lastIndexOf(features)
85+
if (impFeaturesIdx === -1) return
86+
87+
const targetFeature = impParts[impFeaturesIdx + 1]
88+
if (!targetFeature) return
89+
90+
// skip reporting for ignored target features
91+
if (isFileIgnored(targetFeature, ignore)) return
92+
93+
if (targetFeature !== sourceFeature) {
94+
context.report({ node, messageId: 'forbidden', data: { targetFeature } })
95+
}
96+
},
97+
}
98+
},
99+
}
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
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('does not report when index.ts present for components folder', () => {
35+
mockFileSystem(true)
36+
const filename = path.join(process.cwd(), 'src', 'features', 'auth', 'components', 'Button.vue')
37+
const ctx = runRule(rule, filename, [{ components: 'components', ignore: [] }])
38+
expect(ctx.report).not.toHaveBeenCalled()
39+
})
40+
41+
it('respects ignore option', () => {
42+
mockFileSystem(false)
43+
const filename = path.join(process.cwd(), 'src', 'features', 'shared', 'components', 'Icon.vue')
44+
const ctx = runRule(rule, filename, [{ components: 'components', ignore: ['shared'] }])
45+
expect(ctx.report).not.toHaveBeenCalled()
46+
})
47+
48+
it('supports custom index filename', () => {
49+
mockFileSystem(true, 'main.ts')
50+
const filename = path.join(process.cwd(), 'src', 'features', 'auth', 'components', 'Button.vue')
51+
const ctx = runRule(rule, filename, [{ components: 'components', ignore: [], index: 'main.ts' }])
52+
expect(ctx.report).not.toHaveBeenCalled()
53+
})
54+
})
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import path from 'path'
2+
import { describe, it, expect, beforeEach } from 'vitest'
3+
import { setupTest, runRule } from '../utils.js'
4+
import rule from '../../src/rules/no-direct-feature-imports.js'
5+
6+
describe('vue-modular/no-direct-feature-imports', () => {
7+
beforeEach(setupTest)
8+
9+
it('reports when importing directly from another feature', () => {
10+
const filename = path.join(process.cwd(), 'src', 'features', 'auth', 'components', 'Button.vue')
11+
const imports = [path.join(process.cwd(), 'src', 'features', 'payments', 'utils', 'api.js')]
12+
const ctx = runRule(rule, filename, [{ features: 'features' }], imports)
13+
expect(ctx.report).toHaveBeenCalled()
14+
})
15+
16+
it('does not report when importing from shared', () => {
17+
const filename = path.join(process.cwd(), 'src', 'features', 'auth', 'components', 'Button.vue')
18+
const imports = [path.join(process.cwd(), 'src', 'shared', 'utils', 'api.js')]
19+
const ctx = runRule(rule, filename, [{ features: 'features' }], imports)
20+
expect(ctx.report).not.toHaveBeenCalled()
21+
})
22+
23+
it('does not report when importing from same feature deep when allowed', () => {
24+
const filename = path.join(process.cwd(), 'src', 'features', 'auth', 'components', 'Button.vue')
25+
const imports = [path.join(process.cwd(), 'src', 'features', 'auth', 'utils', 'helper.js')]
26+
const ctx = runRule(rule, filename, [{ features: 'features' }], imports)
27+
expect(ctx.report).not.toHaveBeenCalled()
28+
})
29+
30+
it('ignores virtual filenames and test files', () => {
31+
const ctx1 = runRule(rule, '<input>', [{ features: 'features', imports: ['src/features/other/x.js'] }])
32+
expect(ctx1.report).not.toHaveBeenCalled()
33+
34+
const filename = path.join(process.cwd(), 'tests', 'features', 'auth', 'components', 'Button.test.js')
35+
const ctx2 = runRule(rule, filename, [{ features: 'features' }], ['src/features/other/x.js'])
36+
expect(ctx2.report).not.toHaveBeenCalled()
37+
})
38+
39+
it('does nothing when no imports option is provided', () => {
40+
const filename = path.join(process.cwd(), 'src', 'features', 'auth', 'components', 'Button.vue')
41+
const ctx = runRule(rule, filename, [{ features: 'features' }], undefined)
42+
expect(ctx.report).not.toHaveBeenCalled()
43+
})
44+
45+
it('ignores non-string imports', () => {
46+
const filename = path.join(process.cwd(), 'src', 'features', 'auth', 'components', 'Button.vue')
47+
const imports = [null, 123, {}]
48+
const ctx = runRule(rule, filename, [{ features: 'features' }], imports)
49+
expect(ctx.report).not.toHaveBeenCalled()
50+
})
51+
52+
it('ignores imports that reference features segment only (no target feature)', () => {
53+
const filename = path.join(process.cwd(), 'src', 'features', 'auth', 'components', 'Button.vue')
54+
const imports = [path.join(process.cwd(), 'src', 'features')]
55+
const ctx = runRule(rule, filename, [{ features: 'features' }], imports)
56+
expect(ctx.report).not.toHaveBeenCalled()
57+
})
58+
59+
it('does nothing when filename ends with the configured features segment (no source feature)', () => {
60+
const filename = path.join(process.cwd(), 'src', 'features')
61+
const imports = [path.join(process.cwd(), 'src', 'features', 'payments', 'utils', 'api.js')]
62+
const ctx = runRule(rule, filename, [{ features: 'features' }], imports)
63+
expect(ctx.report).not.toHaveBeenCalled()
64+
})
65+
66+
it('does nothing when configured features folder is not in filename path', () => {
67+
const filename = path.join(process.cwd(), 'src', 'other', 'auth', 'components', 'Button.vue')
68+
const imports = [path.join(process.cwd(), 'src', 'features', 'payments', 'utils', 'api.js')]
69+
const ctx = runRule(rule, filename, [{ features: 'features' }], imports)
70+
expect(ctx.report).not.toHaveBeenCalled()
71+
})
72+
73+
it('respects ignore option for target features', () => {
74+
const filename = path.join(process.cwd(), 'src', 'features', 'auth', 'components', 'Button.vue')
75+
const imports = [path.join(process.cwd(), 'src', 'features', 'payments', 'utils', 'api.js')]
76+
const ctx = runRule(rule, filename, [{ features: 'features', ignore: ['payments'] }], imports)
77+
expect(ctx.report).not.toHaveBeenCalled()
78+
})
79+
80+
it('reports when a relative import resolves to another feature', () => {
81+
const filename = path.join(process.cwd(), 'src', 'features', 'auth', 'components', 'Button.vue')
82+
// from .../features/auth/components, ../../payments/... resolves to .../features/payments/...
83+
const imports = ['../../payments/utils/api.js']
84+
const ctx = runRule(rule, filename, [{ features: 'features' }], imports)
85+
expect(ctx.report).toHaveBeenCalled()
86+
})
87+
88+
it('resolves relative imports and respects ignore option', () => {
89+
const filename = path.join(process.cwd(), 'src', 'features', 'auth', 'components', 'Button.vue')
90+
const imports = ['../../payments/utils/api.js']
91+
const ctx = runRule(rule, filename, [{ features: 'features', ignore: ['payments'] }], imports)
92+
expect(ctx.report).not.toHaveBeenCalled()
93+
})
94+
})

tests/utils.js

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,14 +25,25 @@ export const setupTest = () => {
2525
}
2626

2727
// Run a rule with the given context
28-
export const runRule = (rule, filename = 'index.js', options = [{}]) => {
28+
export const runRule = (rule, filename = 'index.js', options = [{}], imports = []) => {
29+
const opts = Array.isArray(options) ? options : [options || {}]
2930
const context = {
30-
options,
31+
options: opts,
3132
getFilename: () => filename,
3233
report: vi.fn(),
3334
settings: {},
3435
}
3536
const ruleInstance = rule.create(context)
3637
if (ruleInstance.Program) ruleInstance.Program()
38+
39+
// If tests provided an `imports` array, synthesize minimal ImportDeclaration nodes
40+
const importsArr = Array.isArray(imports) ? imports : []
41+
if (Array.isArray(importsArr) && ruleInstance.ImportDeclaration) {
42+
for (const imp of importsArr) {
43+
// create a minimal node similar to ESTree ImportDeclaration
44+
const node = { source: { value: imp } }
45+
ruleInstance.ImportDeclaration(node)
46+
}
47+
}
3748
return context
3849
}

0 commit comments

Comments
 (0)