Skip to content

Commit eb29a67

Browse files
committed
feat: add app-imports rule to enforce import restrictions in app folder
1 parent b60fc3c commit eb29a67

File tree

5 files changed

+213
-1
lines changed

5 files changed

+213
-1
lines changed

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,7 @@ 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/13/?scale=87&width=500&title=13%20of%2087%20rules%20completed)
110+
> ![Progress](https://progress-bar.xyz/14/?scale=87&width=500&title=14%20of%2087%20rules%20completed)
111111
112112
### File Organization Rules
113113

@@ -126,6 +126,7 @@ This plugin provides rules to enforce modular architecture boundaries in Vue.js
126126
| -------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
127127
| [feature-imports](./docs/rules/feature-imports.md) | Features should only import from the `shared/` layer or their own internal files. |
128128
| [shared-imports](./docs/rules/shared-imports.md) | `shared/` folder cannot import from `features/` or `views/`. |
129+
| [app-imports](./docs/rules/app-imports.md) | `app/` folder can import from `shared/` and `features/` (exception: `app/router.ts` may import feature route files). |
129130
| app-imports | `app/` folder can import from `shared/` and `features/` (exception: `app/router.ts` may import feature route files to compose the global router). |
130131
| imports-absolute-alias | Use absolute imports with `@/` alias for cross-layer imports and shared resources. |
131132
| imports-no-deep-relative | Avoid relative imports with more than 2 levels (`../../../`) - use absolute instead. |

docs/rules/app-imports.md

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
# vue-modular/app-imports
2+
3+
Require that files under the configured application path (default: `src/app`) only
4+
import from the `shared/` layer or from feature public APIs. This helps keep the
5+
app shell focused on composition and prevents accidental dependencies on feature
6+
internals.
7+
8+
## Rule Details
9+
10+
This rule flags import declarations that originate from files inside the
11+
configured `appPath` and resolve into disallowed locations. The rule resolves
12+
both the current filename and the import specifier to normalized project-
13+
relative paths and reports when an import targets a non-shared, non-public
14+
feature path.
15+
16+
The rule skips checks when the current filename cannot be resolved to a
17+
project-relative path or when the filename matches an `ignores` pattern. The
18+
router special-case is respected: `app/router.ts` may import feature route files
19+
so the global router can be composed from feature routes.
20+
21+
How it works
22+
23+
- Resolve the current filename to a project-relative path. If the file is not
24+
inside `appPath` do nothing.
25+
- For each import, resolve the import specifier to a normalized project-relative
26+
path. If resolution fails (external package, unresolved alias) skip the
27+
import.
28+
- Allow imports that resolve inside `sharedPath`.
29+
- Allow imports that resolve to a feature public API (feature root index file,
30+
e.g. `src/features/<feature>/index.ts`).
31+
- Allow `app/router.ts` to import feature route files (for example
32+
`src/features/<feature>/routes.ts`).
33+
34+
## Options
35+
36+
The rule accepts an optional options object with the following properties:
37+
38+
- `ignores` (string[], default: `['**/*.spec.*', '**/*.test.*', '**/*.stories.*']`) —
39+
minimatch patterns that skip files from rule checks when they match the
40+
resolved project-relative filename.
41+
42+
Note: project-level settings control `rootPath`, `rootAlias`, `appPath`,
43+
`sharedPath`, and `featuresPath`. Configure these via `settings['vue-modular']`.
44+
45+
### Example configuration
46+
47+
```js
48+
{
49+
"vue-modular/app-imports": ["error"]
50+
}
51+
52+
// skip app files in tests
53+
{
54+
"vue-modular/app-imports": ["error", { "ignores": ["**/tests/**"] }]
55+
}
56+
```
57+
58+
## Examples
59+
60+
Incorrect (importing implementation from another layer):
61+
62+
```ts
63+
// File: src/app/main.ts
64+
import helper from '@/lib/some' // forbidden
65+
```
66+
67+
Correct (import from shared or feature public API):
68+
69+
```ts
70+
// File: src/app/main.ts
71+
import helper from '@/shared/utils' // allowed
72+
import routes from '@/features/auth/routes' // allowed (router exception)
73+
```
74+
75+
## When Not To Use
76+
77+
- Disable this rule during large-scale refactors or when your architecture
78+
intentionally allows cross-layer imports.
79+
80+
## Implementation notes
81+
82+
- The rule reports with `messageId: 'forbiddenImport'` and includes `file`
83+
(project-relative filename) and `target` (import specifier) in the message
84+
data.

src/rules.ts

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'
66
import { folderKebabCase } from './rules/folder-kebab-case'
77
import { sfcRequired } from './rules/sfc-required'
88
import { sharedImports } from './rules/shared-imports'
9+
import { appImports } from './rules/app-imports'
910
import { serviceFilenameNoSuffix } from './rules/service-filename-no-suffix'
1011
import { storeFilenameNoSuffix } from './rules/store-filename-no-suffix'
1112
import { sharedUiIndexRequired } from './rules/shared-ui-index-required'
@@ -21,6 +22,7 @@ export const rules: Record<string, VueModularRuleModule> = {
2122
'shared-ui-index-required': sharedUiIndexRequired,
2223
'sfc-required': sfcRequired,
2324
'shared-imports': sharedImports,
25+
'app-imports': appImports,
2426
'service-filename-no-suffix': serviceFilenameNoSuffix,
2527
'store-filename-no-suffix': storeFilenameNoSuffix,
2628
'feature-imports': featureImports,

src/rules/app-imports.ts

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import { createRule, parseRuleOptions, parseProjectOptions, resolvePath, isIgnored } from '../utils'
2+
import type { VueModularRuleModule, VueModularRuleContext } from '../types'
3+
import { resolveImportPath } from '../utils/resolveImportPath'
4+
5+
const defaultOptions = {
6+
ignores: [] as string[],
7+
}
8+
9+
export const appImports = createRule<VueModularRuleModule>({
10+
create(context: VueModularRuleContext) {
11+
const options = parseRuleOptions(context, defaultOptions)
12+
const projectOptions = parseProjectOptions(context)
13+
const filename = resolvePath(context.filename, projectOptions.rootPath, projectOptions.rootAlias)
14+
if (!filename) return {}
15+
if (isIgnored(filename, options.ignores)) return {}
16+
17+
// only run for files inside appPath
18+
const isInApp = filename.startsWith(projectOptions.appPath)
19+
if (!isInApp) return {}
20+
21+
return {
22+
ImportDeclaration(node) {
23+
const importPath = node.source.value as string
24+
const resolvedPath = resolveImportPath(filename, importPath, projectOptions.rootPath, projectOptions.rootAlias)
25+
if (!resolvedPath) return
26+
27+
// Special case: allow app/router.ts to import feature route files
28+
const isRouterFile = filename === `${projectOptions.appPath}/router.ts`
29+
if (isRouterFile) {
30+
// allow imports from feature route files (features/*/routes.ts)
31+
const isRouteFile = resolvedPath.endsWith('/routes.ts') || resolvedPath.endsWith('/routes')
32+
if (resolvedPath.startsWith(projectOptions.featuresPath) && isRouteFile) return
33+
}
34+
35+
// Allow imports from shared
36+
if (resolvedPath.startsWith(projectOptions.sharedPath)) return
37+
38+
// Allow imports from features from Public API from the root of the feature (features/*/index.ts)
39+
if (resolvedPath.startsWith(projectOptions.featuresPath)) {
40+
const featureSegment = resolvedPath.slice(projectOptions.featuresPath.length).split('/')[1]
41+
const isFeatureRootImport =
42+
resolvedPath === `${projectOptions.featuresPath}/${featureSegment}/index.ts` ||
43+
resolvedPath === `${projectOptions.featuresPath}/${featureSegment}` ||
44+
resolvedPath === `${projectOptions.featuresPath}/${featureSegment}/index`
45+
if (isFeatureRootImport) return
46+
}
47+
48+
// If we reach here, it's a forbidden import
49+
context.report({ node, messageId: 'forbiddenImport', data: { file: filename, target: importPath } })
50+
},
51+
}
52+
},
53+
name: 'app-imports',
54+
recommended: false,
55+
level: 'error',
56+
meta: {
57+
type: 'problem',
58+
docs: {
59+
description: 'app/ folder can import from shared/ and features/ (exception: app/router.ts may import feature route files)',
60+
category: 'Dependency',
61+
recommended: false,
62+
},
63+
defaultOptions: [defaultOptions],
64+
schema: [
65+
{
66+
type: 'object',
67+
properties: {
68+
ignores: { type: 'array', items: { type: 'string' } },
69+
},
70+
additionalProperties: false,
71+
},
72+
],
73+
messages: {
74+
forbiddenImport: "App file '{{file}}' must not import from '{{target}}'.",
75+
},
76+
},
77+
})

tests/rules/app-imports.test.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { describe, it } from 'vitest'
2+
import { appImports } from '../../src/rules/app-imports'
3+
import { getRuleTester } from '../test-utils'
4+
5+
const ruleTester = getRuleTester()
6+
7+
describe('app-imports', () => {
8+
it('enforces allowed imports for app folder', () => {
9+
ruleTester.run('app-imports', appImports, {
10+
valid: [
11+
// imports from shared folder
12+
{ code: "import x from '@/shared/utils'", filename: 'src/app/main.ts' },
13+
// imports from features folder
14+
{ code: "import r from '@/features/auth'", filename: 'src/app/main.ts' },
15+
// router may import feature route files
16+
{ code: "import r from '@/features/auth/routes'", filename: 'src/app/router.ts' },
17+
// unresolvable filename should be skipped
18+
{ code: "import r from '@/features/auth/routes'", filename: 'app/main.ts' },
19+
// file outside app should be skipped
20+
{ code: "import r from '@/shared/utils'", filename: 'src/features/auth/main.ts' },
21+
// default ignored patterns should skip rule checks
22+
{ code: "import r from 'external/lib'", filename: 'src/app/main.test.ts' },
23+
// imports from feature public api (root)
24+
{ code: "import fm from '@/features/auth'", filename: 'src/app/main.ts' },
25+
{ code: "import fm from '@/features/auth/index'", filename: 'src/app/main.ts' },
26+
{ code: "import fm from '@/features/auth/index.ts'", filename: 'src/app/main.ts' },
27+
{ code: "import x from '@/utils/local'", filename: 'src/app/main.ts', options: [{ ignores: ['**/app/main.ts'] }] },
28+
],
29+
invalid: [
30+
{
31+
code: "import x from '@/utils/local'",
32+
filename: 'src/app/main.ts',
33+
errors: [{ messageId: 'forbiddenImport', data: { file: 'src/app/main.ts', target: '@/utils/local' } }],
34+
},
35+
{
36+
code: "import x from '../outside'",
37+
filename: 'src/app/main.ts',
38+
errors: [{ messageId: 'forbiddenImport', data: { file: 'src/app/main.ts', target: '../outside' } }],
39+
},
40+
{
41+
code: "import lib from '@/lib/some'",
42+
filename: 'src/app/router.ts',
43+
errors: [{ messageId: 'forbiddenImport', data: { file: 'src/app/router.ts', target: '@/lib/some' } }],
44+
},
45+
],
46+
})
47+
})
48+
})

0 commit comments

Comments
 (0)