Skip to content

Commit cdad05a

Browse files
authored
feat: add no-conflicting-module-names rule (#1563)
1 parent 28c1234 commit cdad05a

31 files changed

Lines changed: 326 additions & 0 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'eslint-plugin-svelte': minor
3+
---
4+
5+
feat: add no-conflicting-module-names rule

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -297,6 +297,7 @@ These rules relate to possible syntax or logic errors in Svelte code:
297297
|:--------|:------------|:---|
298298
| [svelte/infinite-reactive-loop](https://sveltejs.github.io/eslint-plugin-svelte/rules/infinite-reactive-loop/) | Svelte runtime prevents calling the same reactive statement twice in a microtask. But between different microtask, it doesn't prevent. | :star: |
299299
| [svelte/no-bind-value-on-checkable-inputs](https://sveltejs.github.io/eslint-plugin-svelte/rules/no-bind-value-on-checkable-inputs/) | disallow useless `bind:value` on `<input type="checkbox">` and `<input type="radio">` | :bulb: |
300+
| [svelte/no-conflicting-module-names](https://sveltejs.github.io/eslint-plugin-svelte/rules/no-conflicting-module-names/) | disallow a `.svelte` component and a same-named runes module (e.g. `Foo.svelte` and `Foo.svelte.ts`) from coexisting | |
300301
| [svelte/no-dom-manipulating](https://sveltejs.github.io/eslint-plugin-svelte/rules/no-dom-manipulating/) | disallow DOM manipulating | :star: |
301302
| [svelte/no-dupe-else-if-blocks](https://sveltejs.github.io/eslint-plugin-svelte/rules/no-dupe-else-if-blocks/) | disallow duplicate conditions in `{#if}` / `{:else if}` chains | :star: |
302303
| [svelte/no-dupe-on-directives](https://sveltejs.github.io/eslint-plugin-svelte/rules/no-dupe-on-directives/) | disallow duplicate `on:` directives | :star: |

docs/rules.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ These rules relate to possible syntax or logic errors in Svelte code:
1818
| :----------------------------------------------------------------------------------------------- | :------------------------------------------------------------------------------------------------------------------------------------- | :------------- |
1919
| [svelte/infinite-reactive-loop](./rules/infinite-reactive-loop.md) | Svelte runtime prevents calling the same reactive statement twice in a microtask. But between different microtask, it doesn't prevent. | :star: |
2020
| [svelte/no-bind-value-on-checkable-inputs](./rules/no-bind-value-on-checkable-inputs.md) | disallow useless `bind:value` on `<input type="checkbox">` and `<input type="radio">` | :bulb: |
21+
| [svelte/no-conflicting-module-names](./rules/no-conflicting-module-names.md) | disallow a `.svelte` component and a same-named runes module (e.g. `Foo.svelte` and `Foo.svelte.ts`) from coexisting | |
2122
| [svelte/no-dom-manipulating](./rules/no-dom-manipulating.md) | disallow DOM manipulating | :star: |
2223
| [svelte/no-dupe-else-if-blocks](./rules/no-dupe-else-if-blocks.md) | disallow duplicate conditions in `{#if}` / `{:else if}` chains | :star: |
2324
| [svelte/no-dupe-on-directives](./rules/no-dupe-on-directives.md) | disallow duplicate `on:` directives | :star: |
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
---
2+
pageClass: 'rule-details'
3+
sidebarDepth: 0
4+
title: 'svelte/no-conflicting-module-names'
5+
description: 'disallow a `.svelte` component and a same-named runes module (e.g. `Foo.svelte` and `Foo.svelte.ts`) from coexisting'
6+
---
7+
8+
# svelte/no-conflicting-module-names
9+
10+
> disallow a `.svelte` component and a same-named runes module (e.g. `Foo.svelte` and `Foo.svelte.ts`) from coexisting
11+
12+
- :exclamation: <badge text="This rule has not been released yet." vertical="middle" type="error"> **_This rule has not been released yet._** </badge>
13+
14+
## :book: Rule Details
15+
16+
Since Svelte 5 you can write runes modules named `.svelte.ts` and `.svelte.js`. When a component `Foo.svelte` and a module `Foo.svelte.ts` sit in the same folder, the import specifier `./Foo.svelte` no longer has one meaning. Different tools resolve it differently:
17+
18+
| Tool | `./Foo.svelte` resolves to |
19+
| ------------------------------------------------------- | -------------------------- |
20+
| Bundlers (Vite) and `svelte-check` | `Foo.svelte` (component) |
21+
| Plain TypeScript (`tsc`, typescript-eslint type checks) | `Foo.svelte.ts` (module) |
22+
23+
Plain TypeScript does not know the `.svelte` extension, so it appends `.ts` and lands on the module. Your app builds and runs with the component, while type checking and type-aware lint quietly use the module. The mismatch is silent and hard to spot.
24+
25+
This rule reports **both files** of the collision: the component `Foo.svelte` and the module `Foo.svelte.ts`. This plugin lints `.svelte.js` and `.svelte.ts` files too, and a lint run may include only one of the two files, so reporting on both makes sure you see the problem either way.
26+
27+
To fix it, rename the module (for example to `foo-state.svelte.ts`). The component keeps its name.
28+
29+
The following module extensions trigger a report: `.ts`, `.tsx`, `.js`, `.jsx`, `.mts`, `.cts`, `.mjs`, `.cjs`.
30+
31+
Declaration files are not reported. `Foo.svelte.d.ts` and `Foo.d.svelte.ts` are legitimate and do not shadow the component.
32+
33+
<!-- prettier-ignore-start -->
34+
```
35+
36+
src/
37+
Foo.svelte // ✗ BAD: reported, a module with the same name exists
38+
Foo.svelte.ts // ✗ BAD: reported, it collides with Foo.svelte
39+
40+
src/
41+
Foo.svelte // ✓ GOOD
42+
foo-state.svelte.ts // ✓ GOOD: a different name, no collision
43+
```
44+
45+
<!-- prettier-ignore-end -->
46+
47+
## :wrench: Options
48+
49+
Nothing.
50+
51+
## :couple: Related Rules
52+
53+
- None
54+
55+
## :books: Further Reading
56+
57+
- [Repro repository](https://github.com/baseballyama/svelte-module-collision-repro)
58+
59+
## :mag: Preset
60+
61+
This rule is not included in any preset. Enable it explicitly if you want it.
62+
63+
## :mag: Implementation
64+
65+
- [Rule source](https://github.com/sveltejs/eslint-plugin-svelte/blob/main/packages/eslint-plugin-svelte/src/rules/no-conflicting-module-names.ts)
66+
- [Test source](https://github.com/sveltejs/eslint-plugin-svelte/blob/main/packages/eslint-plugin-svelte/tests/src/rules/no-conflicting-module-names.ts)

packages/eslint-plugin-svelte/src/rule-types.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,11 @@ export interface RuleOptions {
130130
* @see https://sveltejs.github.io/eslint-plugin-svelte/rules/no-bind-value-on-checkable-inputs/
131131
*/
132132
'svelte/no-bind-value-on-checkable-inputs'?: Linter.RuleEntry<[]>
133+
/**
134+
* disallow a `.svelte` component and a same-named runes module (e.g. `Foo.svelte` and `Foo.svelte.ts`) from coexisting
135+
* @see https://sveltejs.github.io/eslint-plugin-svelte/rules/no-conflicting-module-names/
136+
*/
137+
'svelte/no-conflicting-module-names'?: Linter.RuleEntry<[]>
133138
/**
134139
* disallow DOM manipulating
135140
* @see https://sveltejs.github.io/eslint-plugin-svelte/rules/no-dom-manipulating/
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
import fs from 'node:fs';
2+
import path from 'node:path';
3+
import { createRule } from '../utils/index.js';
4+
5+
// Extensions that plain TypeScript resolution appends to an unknown `.svelte`
6+
// specifier. `.d.ts` is intentionally excluded: `Foo.svelte.d.ts` is a
7+
// hand-written component declaration and does not shadow the component.
8+
const MODULE_EXTENSIONS = ['.ts', '.tsx', '.js', '.jsx', '.mts', '.cts', '.mjs', '.cjs'];
9+
10+
function isFile(filePath: string): boolean {
11+
try {
12+
return fs.statSync(filePath).isFile();
13+
} catch {
14+
return false;
15+
}
16+
}
17+
18+
// Resolve the real on-disk casing of a path's basename. On case-insensitive
19+
// file systems (macOS, Windows) `fs.statSync` matches a differently-cased
20+
// sibling, so the constructed name may not exist with that exact casing. Read
21+
// the directory and use the actual entry name when one matches.
22+
function realBasename(filePath: string): string {
23+
const wanted = path.basename(filePath);
24+
try {
25+
const entries = fs.readdirSync(path.dirname(filePath));
26+
return (
27+
entries.find((entry) => entry === wanted) ??
28+
entries.find((entry) => entry.toLowerCase() === wanted.toLowerCase()) ??
29+
wanted
30+
);
31+
} catch {
32+
return wanted;
33+
}
34+
}
35+
36+
export default createRule('no-conflicting-module-names', {
37+
meta: {
38+
docs: {
39+
description:
40+
'disallow a `.svelte` component and a same-named runes module (e.g. `Foo.svelte` and `Foo.svelte.ts`) from coexisting',
41+
category: 'Possible Errors',
42+
recommended: false
43+
},
44+
schema: [],
45+
messages: {
46+
conflictOnComponent:
47+
'The module `{{moduleName}}` has the same name as this component. TypeScript resolves the import `{{specifier}}` to that module, not to this component. Rename `{{moduleName}}`.',
48+
conflictOnModule:
49+
'This module has the same name as the component `{{svelteName}}`. TypeScript resolves the import `{{specifier}}` to this module, not to the component. Rename this file.'
50+
},
51+
type: 'problem'
52+
},
53+
create(context) {
54+
const filename = context.physicalFilename;
55+
56+
// Skip virtual/untitled files that do not exist on disk (editors, tests
57+
// with fake paths). Only real files on disk are checked.
58+
if (!isFile(filename)) {
59+
return {};
60+
}
61+
62+
// Both sides of the collision are linted by this plugin, and a lint run
63+
// may contain only one of them, so each side reports on itself.
64+
if (filename.endsWith('.svelte')) {
65+
return {
66+
Program(node) {
67+
const svelteName = path.basename(filename);
68+
for (const ext of MODULE_EXTENSIONS) {
69+
const modulePath = `${filename}${ext}`;
70+
if (isFile(modulePath)) {
71+
context.report({
72+
node,
73+
loc: { line: 1, column: 0 },
74+
messageId: 'conflictOnComponent',
75+
data: {
76+
moduleName: realBasename(modulePath),
77+
specifier: `./${svelteName}`
78+
}
79+
});
80+
return;
81+
}
82+
}
83+
}
84+
};
85+
}
86+
87+
// A module named `<name>.svelte.<ext>`. Dropping the extension gives the
88+
// component path it collides with. Declaration files such as
89+
// `Foo.svelte.d.ts` do not match, because dropping `.ts` leaves
90+
// `Foo.svelte.d`, which is not a component name.
91+
const moduleExt = MODULE_EXTENSIONS.find((ext) => filename.endsWith(ext));
92+
if (moduleExt == null) {
93+
return {};
94+
}
95+
const sveltePath = filename.slice(0, -moduleExt.length);
96+
if (!sveltePath.endsWith('.svelte') || !isFile(sveltePath)) {
97+
return {};
98+
}
99+
100+
return {
101+
Program(node) {
102+
const svelteName = realBasename(sveltePath);
103+
context.report({
104+
node,
105+
loc: { line: 1, column: 0 },
106+
messageId: 'conflictOnModule',
107+
data: {
108+
svelteName,
109+
specifier: `./${svelteName}`
110+
}
111+
});
112+
}
113+
};
114+
}
115+
});

packages/eslint-plugin-svelte/src/utils/rules.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import noAtConstTags from '../rules/no-at-const-tags.js';
2525
import noAtDebugTags from '../rules/no-at-debug-tags.js';
2626
import noAtHtmlTags from '../rules/no-at-html-tags.js';
2727
import noBindValueOnCheckableInputs from '../rules/no-bind-value-on-checkable-inputs.js';
28+
import noConflictingModuleNames from '../rules/no-conflicting-module-names.js';
2829
import noDomManipulating from '../rules/no-dom-manipulating.js';
2930
import noDupeElseIfBlocks from '../rules/no-dupe-else-if-blocks.js';
3031
import noDupeOnDirectives from '../rules/no-dupe-on-directives.js';
@@ -111,6 +112,7 @@ export const rules = [
111112
noAtDebugTags,
112113
noAtHtmlTags,
113114
noBindValueOnCheckableInputs,
115+
noConflictingModuleNames,
114116
noDomManipulating,
115117
noDupeElseIfBlocks,
116118
noDupeOnDirectives,
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
<p>cjs</p>
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export const value = 1;
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
<p>cts</p>

0 commit comments

Comments
 (0)