Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ This plugin provides rules to enforce modular architecture boundaries in Vue.js

> The list of rules is a work in progress. Implemented rules are linked below; unimplemented rules are listed as plain names.
>
> ![Progress](https://progress-bar.xyz/13/?scale=87&width=500&title=13%20of%2087%20rules%20completed)
> ![Progress](https://progress-bar.xyz/14/?scale=87&width=500&title=14%20of%2087%20rules%20completed)

### File Organization Rules

Expand All @@ -126,6 +126,7 @@ This plugin provides rules to enforce modular architecture boundaries in Vue.js
| -------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
| [feature-imports](./docs/rules/feature-imports.md) | Features should only import from the `shared/` layer or their own internal files. |
| [shared-imports](./docs/rules/shared-imports.md) | `shared/` folder cannot import from `features/` or `views/`. |
| [app-imports](./docs/rules/app-imports.md) | `app/` folder can import from `shared/` and `features/` (exception: `app/router.ts` may import feature route files). |
| app-imports | `app/` folder can import from `shared/` and `features/` (exception: `app/router.ts` may import feature route files to compose the global router). |
| imports-absolute-alias | Use absolute imports with `@/` alias for cross-layer imports and shared resources. |
| imports-no-deep-relative | Avoid relative imports with more than 2 levels (`../../../`) - use absolute instead. |
Expand Down
84 changes: 84 additions & 0 deletions docs/rules/app-imports.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
# vue-modular/app-imports

Require that files under the configured application path (default: `src/app`) only
import from the `shared/` layer or from feature public APIs. This helps keep the
app shell focused on composition and prevents accidental dependencies on feature
internals.

## Rule Details

This rule flags import declarations that originate from files inside the
configured `appPath` and resolve into disallowed locations. The rule resolves
both the current filename and the import specifier to normalized project-
relative paths and reports when an import targets a non-shared, non-public
feature path.

The rule skips checks when the current filename cannot be resolved to a
project-relative path or when the filename matches an `ignores` pattern. The
router special-case is respected: `app/router.ts` may import feature route files
so the global router can be composed from feature routes.

How it works

- Resolve the current filename to a project-relative path. If the file is not
inside `appPath` do nothing.
- For each import, resolve the import specifier to a normalized project-relative
path. If resolution fails (external package, unresolved alias) skip the
import.
- Allow imports that resolve inside `sharedPath`.
- Allow imports that resolve to a feature public API (feature root index file,
e.g. `src/features/<feature>/index.ts`).
- Allow `app/router.ts` to import feature route files (for example
`src/features/<feature>/routes.ts`).

## Options

The rule accepts an optional options object with the following properties:

- `ignores` (string[], default: `['**/*.spec.*', '**/*.test.*', '**/*.stories.*']`) —
minimatch patterns that skip files from rule checks when they match the
resolved project-relative filename.

Note: project-level settings control `rootPath`, `rootAlias`, `appPath`,
`sharedPath`, and `featuresPath`. Configure these via `settings['vue-modular']`.

### Example configuration

```js
{
"vue-modular/app-imports": ["error"]
}

// skip app files in tests
{
"vue-modular/app-imports": ["error", { "ignores": ["**/tests/**"] }]
}
```

## Examples

Incorrect (importing implementation from another layer):

```ts
// File: src/app/main.ts
import helper from '@/lib/some' // forbidden
```

Correct (import from shared or feature public API):

```ts
// File: src/app/main.ts
import helper from '@/shared/utils' // allowed
import routes from '@/features/auth/routes' // allowed (router exception)
```

## When Not To Use

- Disable this rule during large-scale refactors or when your architecture
intentionally allows cross-layer imports.

## Implementation notes

- The rule reports with `messageId: 'forbiddenImport'` and includes `file`
(project-relative filename) and `target` (import specifier) in the message
data.
2 changes: 2 additions & 0 deletions src/rules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { fileTsNaming } from './rules/file-ts-naming'
import { folderKebabCase } from './rules/folder-kebab-case'
import { sfcRequired } from './rules/sfc-required'
import { sharedImports } from './rules/shared-imports'
import { appImports } from './rules/app-imports'
import { serviceFilenameNoSuffix } from './rules/service-filename-no-suffix'
import { storeFilenameNoSuffix } from './rules/store-filename-no-suffix'
import { sharedUiIndexRequired } from './rules/shared-ui-index-required'
Expand All @@ -21,6 +22,7 @@ export const rules: Record<string, VueModularRuleModule> = {
'shared-ui-index-required': sharedUiIndexRequired,
'sfc-required': sfcRequired,
'shared-imports': sharedImports,
'app-imports': appImports,
'service-filename-no-suffix': serviceFilenameNoSuffix,
'store-filename-no-suffix': storeFilenameNoSuffix,
'feature-imports': featureImports,
Expand Down
77 changes: 77 additions & 0 deletions src/rules/app-imports.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { createRule, parseRuleOptions, parseProjectOptions, resolvePath, isIgnored } from '../utils'
import type { VueModularRuleModule, VueModularRuleContext } from '../types'
import { resolveImportPath } from '../utils/resolveImportPath'

const defaultOptions = {
ignores: [] as string[],
}

export const appImports = createRule<VueModularRuleModule>({
create(context: VueModularRuleContext) {
const options = parseRuleOptions(context, defaultOptions)
const projectOptions = parseProjectOptions(context)
const filename = resolvePath(context.filename, projectOptions.rootPath, projectOptions.rootAlias)
if (!filename) return {}
if (isIgnored(filename, options.ignores)) return {}

// only run for files inside appPath
const isInApp = filename.startsWith(projectOptions.appPath)
if (!isInApp) return {}

return {
ImportDeclaration(node) {
const importPath = node.source.value as string
const resolvedPath = resolveImportPath(filename, importPath, projectOptions.rootPath, projectOptions.rootAlias)
if (!resolvedPath) return

// Special case: allow app/router.ts to import feature route files
const isRouterFile = filename === `${projectOptions.appPath}/router.ts`
if (isRouterFile) {
// allow imports from feature route files (features/*/routes.ts)
const isRouteFile = resolvedPath.endsWith('/routes.ts') || resolvedPath.endsWith('/routes')
if (resolvedPath.startsWith(projectOptions.featuresPath) && isRouteFile) return
}

// Allow imports from shared
if (resolvedPath.startsWith(projectOptions.sharedPath)) return

// Allow imports from features from Public API from the root of the feature (features/*/index.ts)
if (resolvedPath.startsWith(projectOptions.featuresPath)) {
const featureSegment = resolvedPath.slice(projectOptions.featuresPath.length).split('/')[1]
const isFeatureRootImport =
resolvedPath === `${projectOptions.featuresPath}/${featureSegment}/index.ts` ||
resolvedPath === `${projectOptions.featuresPath}/${featureSegment}` ||
resolvedPath === `${projectOptions.featuresPath}/${featureSegment}/index`
if (isFeatureRootImport) return
}

// If we reach here, it's a forbidden import
context.report({ node, messageId: 'forbiddenImport', data: { file: filename, target: importPath } })
},
}
},
name: 'app-imports',
recommended: false,
level: 'error',
meta: {
type: 'problem',
docs: {
description: 'app/ folder can import from shared/ and features/ (exception: app/router.ts may import feature route files)',
category: 'Dependency',
recommended: false,
},
defaultOptions: [defaultOptions],
schema: [
{
type: 'object',
properties: {
ignores: { type: 'array', items: { type: 'string' } },
},
additionalProperties: false,
},
],
messages: {
forbiddenImport: "App file '{{file}}' must not import from '{{target}}'.",
},
},
})
48 changes: 48 additions & 0 deletions tests/rules/app-imports.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { describe, it } from 'vitest'
import { appImports } from '../../src/rules/app-imports'
import { getRuleTester } from '../test-utils'

const ruleTester = getRuleTester()

describe('app-imports', () => {
it('enforces allowed imports for app folder', () => {
ruleTester.run('app-imports', appImports, {
valid: [
// imports from shared folder
{ code: "import x from '@/shared/utils'", filename: 'src/app/main.ts' },
// imports from features folder
{ code: "import r from '@/features/auth'", filename: 'src/app/main.ts' },
// router may import feature route files
{ code: "import r from '@/features/auth/routes'", filename: 'src/app/router.ts' },
// unresolvable filename should be skipped
{ code: "import r from '@/features/auth/routes'", filename: 'app/main.ts' },
// file outside app should be skipped
{ code: "import r from '@/shared/utils'", filename: 'src/features/auth/main.ts' },
// default ignored patterns should skip rule checks
{ code: "import r from 'external/lib'", filename: 'src/app/main.test.ts' },
// imports from feature public api (root)
{ code: "import fm from '@/features/auth'", filename: 'src/app/main.ts' },
{ code: "import fm from '@/features/auth/index'", filename: 'src/app/main.ts' },
{ code: "import fm from '@/features/auth/index.ts'", filename: 'src/app/main.ts' },
{ code: "import x from '@/utils/local'", filename: 'src/app/main.ts', options: [{ ignores: ['**/app/main.ts'] }] },
],
invalid: [
{
code: "import x from '@/utils/local'",
filename: 'src/app/main.ts',
errors: [{ messageId: 'forbiddenImport', data: { file: 'src/app/main.ts', target: '@/utils/local' } }],
},
{
code: "import x from '../outside'",
filename: 'src/app/main.ts',
errors: [{ messageId: 'forbiddenImport', data: { file: 'src/app/main.ts', target: '../outside' } }],
},
{
code: "import lib from '@/lib/some'",
filename: 'src/app/router.ts',
errors: [{ messageId: 'forbiddenImport', data: { file: 'src/app/router.ts', target: '@/lib/some' } }],
},
],
})
})
})