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
36 changes: 18 additions & 18 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,24 +105,24 @@ The plugin will now enforce modular architecture patterns in your Vue.js project

This plugin provides rules to enforce modular architecture boundaries in Vue.js applications. Here is a summary of the available rules:

| Rule | Description |
| ------------------------------------------------------------------------ | ------------------------------------------------------------------------------ |
| [app-imports](./docs/rules/app-imports.md) | App folder can import from shared and features with specific exceptions |
| [components-index-required](./docs/rules/components-index-required.md) | All components folders must contain an index.ts file for component exports |
| [cross-imports-alias](./docs/rules/cross-imports-alias.md) | Cross-layer imports must use the project root alias instead of absolute paths |
| [feature-imports](./docs/rules/feature-imports.md) | Features should only import from the shared layer or their own internal files |
| [feature-index-required](./docs/rules/feature-index-required.md) | Each feature folder must contain an index.ts file as its public API |
| [file-component-naming](./docs/rules/file-component-naming.md) | All Vue components must use PascalCase naming |
| [file-ts-naming](./docs/rules/file-ts-naming.md) | All TypeScript files must use camelCase naming |
| [folder-kebab-case](./docs/rules/folder-kebab-case.md) | All folders must use kebab-case naming |
| [internal-imports-relative](./docs/rules/internal-imports-relative.md) | Internal feature/shared/app imports should use relative paths instead of alias |
| [service-filename-no-suffix](./docs/rules/service-filename-no-suffix.md) | Service files must not have Service suffix |
| [sfc-order](./docs/rules/sfc-order.md) | Enforce SFC block order: script, template, style |
| [sfc-required](./docs/rules/sfc-required.md) | All Vue components should be written as Single File Components |
| [shared-imports](./docs/rules/shared-imports.md) | Shared folder cannot import from features or views |
| [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 |
| [store-filename-no-suffix](./docs/rules/store-filename-no-suffix.md) | Store files must not have Store suffix |
| [views-suffix](./docs/rules/views-suffix.md) | View files must end with View.vue suffix |
| Rule | Description |
| ------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------- |
| [app-imports](./docs/rules/app-imports.md) | App folder can import from shared and features with specific exceptions |
| [components-index-required](./docs/rules/components-index-required.md) | All components folders must contain an index.ts file for component exports |
| [cross-imports-alias](./docs/rules/cross-imports-alias.md) | Cross-layer imports must use the project root alias instead of absolute paths |
| [feature-imports](./docs/rules/feature-imports.md) | Features should only import from the shared layer or their own internal files |
| [feature-index-required](./docs/rules/feature-index-required.md) | Each feature folder must contain an index.ts file as its public API |
| [file-component-naming](./docs/rules/file-component-naming.md) | All Vue components must use PascalCase naming |
| [file-ts-naming](./docs/rules/file-ts-naming.md) | All TypeScript files must use camelCase naming |
| [folder-kebab-case](./docs/rules/folder-kebab-case.md) | All folders must use kebab-case naming |
| [internal-imports-relative](./docs/rules/internal-imports-relative.md) | Prefer relative imports for local feature/shared/app files but suggest alias for deep relative traversals. |
| [service-filename-no-suffix](./docs/rules/service-filename-no-suffix.md) | Service files must not have Service suffix |
| [sfc-order](./docs/rules/sfc-order.md) | Enforce SFC block order: script, template, style |
| [sfc-required](./docs/rules/sfc-required.md) | All Vue components should be written as Single File Components |
| [shared-imports](./docs/rules/shared-imports.md) | Shared folder cannot import from features or views |
| [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 |
| [store-filename-no-suffix](./docs/rules/store-filename-no-suffix.md) | Store files must not have Store suffix |
| [views-suffix](./docs/rules/views-suffix.md) | View files must end with View.vue suffix |

## Contributing

Expand Down
36 changes: 36 additions & 0 deletions docs/rules/internal-imports-relative.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,30 @@ How it works
- Imports within shared or app when both sides are inside the same `shared` or `app` folders.
- If both source and target are within the project areas of interest but the import uses the project root alias (e.g. `@/features/...` or `@/shared/...`), the rule reports and recommends using a relative import instead.

Behavior details (new/important):

- depth control: the rule accepts a `depth` option (default: `2`) which defines how many `/` segments are allowed for relative imports before the rule suggests switching to the alias. In other words:
- Short relative imports (up to the configured `depth`) are preferred for clearly-local dependencies.
- Long/deep relative imports that exceed `depth` are considered "distant" and the rule will recommend using the project root alias (message id `useAliasImport`).

- Two message types are emitted by the rule:
- `useRelativeImport` — the import uses the project root alias but the target is local (same feature/shared/app); recommend changing to a relative import.
- `useAliasImport` — the import is a relative path that exceeds the configured `depth`; recommend changing to the project root alias for clarity.

- Alias exceptions: certain alias imports remain allowed (no report)
- Alias imports between different features (for example `@/features/featureA/...` importing `@/features/featureB/...`) are permitted.
- Alias imports from `app` files to targets outside `app` (and similarly from `shared` to outside `shared`) are permitted to avoid spurious reports when crossing architectural areas.

- Path normalization for monorepo / nested-app layouts: filenames like `apps/web/src/...` are normalized to the configured `rootPath` (for example `src/...`) before rule logic is applied. That means files under `apps/web/src` are treated the same as `src` files for the purposes of this rule.

## Options

The rule accepts an options object with the following properties:

- `ignores` (string[], default: `[]`) — array of minimatch glob patterns tested against the resolved project-relative filename. If any pattern matches, the rule will skip that file. Use this to permit exceptions during migration or for generated files.

- `depth` (number, default: `2`) — maximum number of `/` segments allowed for a relative import before the rule recommends switching to the project root alias. Increase this value if your feature layout commonly requires deeper relative traversals and you want to prefer relative imports more often.

Note: project-level settings control `rootPath`, `rootAlias`, `appPath`, `featuresPath`, and `sharedPath` used by the rule; those defaults are provided by the plugin and can be overridden via settings.

### Example configuration
Expand Down Expand Up @@ -62,6 +80,24 @@ import { theme } from '@/shared/utils/theme'
```

Correct:
Incorrect (relative import is too deep; prefer alias):

```ts
// File: src/features/auth/components/very/deep/path/File.vue
import helper from '../../../utils/helper' // many ../ segments
```

Correct (use alias when relative traversal is deep):

```ts
// File: src/features/auth/components/very/deep/path/File.vue
import helper from '@/features/auth/utils/helper'
```

Notes:

- The rule will still allow alias imports when crossing feature boundaries or when the import target is outside the same architectural area (see "Alias exceptions" above).
- Files that cannot be resolved to the project root (for example external scripts) are ignored; use the `ignores` option for explicit exceptions.

```ts
// File: src/shared/components/Button.vue
Expand Down
39 changes: 35 additions & 4 deletions src/rules/internal-imports-relative.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { createRule, parseRuleOptions, parseProjectOptions, resolvePath, isIgnored } from '../utils'
import { createRule, parseRuleOptions, parseProjectOptions, resolvePath, isIgnored, normalizePath } from '../utils'
import type { VueModularRuleModule, VueModularRuleContext } from '../types'
import { resolveImportPath } from '../utils'

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

Expand All @@ -26,25 +27,53 @@ export const internalImportsRelative = createRule<VueModularRuleModule>({
const alias = projectOptions.rootAlias
const isAliasImport = importPath === alias || importPath.startsWith(`${alias}/`)

let depthSegments = 0
if (!isAliasImport && importPath.startsWith('.')) {
let path = normalizePath(importPath)
depthSegments = path.split('/').length - 1
}

// Only care about imports within the same feature or same shared folder
const fromFeature = filename.startsWith(projectOptions.featuresPath)
const toFeature = resolvedPath.startsWith(projectOptions.featuresPath)
if (fromFeature && toFeature) {
const fromFeatureSegment = filename.slice(projectOptions.featuresPath.length).split('/')[1]
const toFeatureSegment = resolvedPath.slice(projectOptions.featuresPath.length).split('/')[1]
if (fromFeatureSegment && toFeatureSegment && fromFeatureSegment === toFeatureSegment && !isAliasImport) return

if (fromFeatureSegment && toFeatureSegment && fromFeatureSegment === toFeatureSegment && !isAliasImport) {
if (depthSegments <= options.depth) return
context.report({
node,
messageId: 'useAliasImport',
data: { file: filename, target: importPath, distance: String(depthSegments) },
})
}
if (fromFeatureSegment && toFeatureSegment && fromFeatureSegment !== toFeatureSegment && isAliasImport) return
}

// Care about app and shared folders as well
const fromApp = filename.startsWith(projectOptions.appPath)
const toApp = resolvedPath.startsWith(projectOptions.appPath)
if (fromApp && toApp && !isAliasImport) return
if (fromApp && toApp && !isAliasImport) {
if (depthSegments <= options.depth) return
context.report({
node,
messageId: 'useAliasImport',
data: { file: filename, target: importPath, distance: String(depthSegments) },
})
}
if (fromApp && !toApp && isAliasImport) return

const fromShared = filename.startsWith(projectOptions.sharedPath)
const toShared = resolvedPath.startsWith(projectOptions.sharedPath)
if (fromShared && toShared && !isAliasImport) return
if (fromShared && toShared && !isAliasImport) {
if (depthSegments <= options.depth) return
context.report({
node,
messageId: 'useAliasImport',
data: { file: filename, target: importPath, distance: String(depthSegments) },
})
}
if (fromShared && !toShared && isAliasImport) return

// If import is outside of app, features, or shared, ignore
Expand All @@ -71,13 +100,15 @@ export const internalImportsRelative = createRule<VueModularRuleModule>({
{
type: 'object',
properties: {
depth: { type: 'number' },
ignores: { type: 'array', items: { type: 'string' } },
},
additionalProperties: false,
},
],
messages: {
useRelativeImport: 'Use relative import for same-feature or nearby file imports ({{ file }} -> {{ target }}).',
useAliasImport: 'Use alias import for distant ({{ distance }} levels) file imports ({{ file }} -> {{ target }}).',
},
},
})
25 changes: 24 additions & 1 deletion tests/rules/internal-imports-relative.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,12 +62,17 @@ describe('internal-imports-relative', () => {
filename: 'apps/web/src/app/App.vue',
code: "<script setup>import { iButton } from '@/shared/ui'</script>",
},
// Shared file importing a feature via alias should be allowed (covers fromShared && !toShared && isAliasImport)
{
filename: '/project/src/shared/components/Button.vue',
code: "<script>import x from '@/features/featureA/utils'</script>",
},
],
invalid: [
// Alias import within the same feature
{
filename: '/project/src/features/featureA/fileA.ts',
code: "<script>import x from '@/features/featureA/fileB'",
code: "<script>import x from '@/features/featureA/fileB'</script>",
errors: [{ messageId: 'useRelativeImport' }],
},
// Alias import within the same feature (subfolder)
Expand All @@ -82,6 +87,24 @@ describe('internal-imports-relative', () => {
code: "<script setup>import { cn } from '@/shared/utils/cn'</script>",
errors: [{ messageId: 'useRelativeImport' }],
},
// Relative import within the same feature but too deep -> should report useAliasImport
{
filename: '/project/src/features/featureA/sub/sub2/fileA.ts',
code: "<script>import x from '../aaa/bbb/fileB'</script>",
errors: [{ messageId: 'useAliasImport' }],
},
// Relative import within the app but too deep -> should report useAliasImport
{
filename: 'src/app/sub1/sub2/fileA.ts',
code: "<script>import x from '../aaa/bbb/fileB'</script>",
errors: [{ messageId: 'useAliasImport' }],
},
// Relative import within shared but too deep -> should report useAliasImport
{
filename: '/project/src/shared/ui/button/iButton.vue',
code: "<script setup>import { cn } from '../../utils/cn'</script>",
errors: [{ messageId: 'useAliasImport' }],
},
],
})
})
Expand Down