@polyv/eslint-config 0.8.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,85 @@
1
+ # no-vue-component-variable-name-conflict
2
+
3
+ `polyv/no-vue-component-variable-name-conflict` 用于禁止 Vue `<script setup>` 中的组件导入名和 lower camel 本地声明产生命名冲突。
4
+
5
+ ## 适用场景
6
+
7
+ 在 `<script setup>` 中,Vue 组件导入名通常会在模板里自动暴露。例如 `MemberType` 会对应模板里的 `<member-type>`。
8
+
9
+ 如果同一个 `<script setup>` 又声明了 `memberType` 变量、函数或类,模板和 ESLint 的变量使用标记容易把 `<member-type>` 关联到 `memberType`,从而导致真正的组件导入 `MemberType` 被判断为未使用。
10
+
11
+ ## 使用方式
12
+
13
+ 如果已经组合 `polyv.configs.common`,`polyv` plugin 会自动注册:
14
+
15
+ ```javascript
16
+ // eslint.config.mjs
17
+ import polyv from '@polyv/eslint-config';
18
+
19
+ export default [
20
+ ...polyv.configs.common,
21
+ {
22
+ rules: {
23
+ 'polyv/no-vue-component-variable-name-conflict': 'error'
24
+ }
25
+ }
26
+ ];
27
+ ```
28
+
29
+ Vue2 项目通常直接组合 `polyv.configs.vue2`,该配置会默认启用本规则:
30
+
31
+ ```javascript
32
+ // eslint.config.mjs
33
+ import polyv from '@polyv/eslint-config';
34
+
35
+ export default [
36
+ ...polyv.configs.common,
37
+ ...polyv.configs.ts,
38
+ ...polyv.configs.vue2
39
+ ];
40
+ ```
41
+
42
+ ## 错误示例
43
+
44
+ ```vue
45
+ <!-- bad.vue -->
46
+ <template>
47
+ <member-type v-model="memberType" />
48
+ </template>
49
+
50
+ <script setup lang="ts">
51
+ import { ref } from 'vue';
52
+ import MemberType from './components/member-type.vue';
53
+
54
+ const memberType = ref('online');
55
+ </script>
56
+ ```
57
+
58
+ ## 正确示例
59
+
60
+ ```vue
61
+ <!-- good.vue -->
62
+ <template>
63
+ <member-type v-model="currentMemberType" />
64
+ </template>
65
+
66
+ <script setup lang="ts">
67
+ import { ref } from 'vue';
68
+ import MemberType from './components/member-type.vue';
69
+
70
+ const currentMemberType = ref('online');
71
+ </script>
72
+ ```
73
+
74
+ ## 检查范围
75
+
76
+ 该规则只检查 `.vue` 文件中的 `<script setup>`:
77
+
78
+ - 收集模板里的 kebab-case 标签,例如 `<member-type>`。
79
+ - 收集 `<script setup>` 中 PascalCase 组件导入,例如 `MemberType`。
80
+ - 把组件名转换为 kebab-case 标签名和 lower camel 变量名,例如 `MemberType` 对应 `<member-type>` 和 `memberType`。
81
+ - 检查 `<script setup>` 中同名 lower camel 的变量、函数或类声明。
82
+
83
+ ## 自动修复
84
+
85
+ 该规则不提供自动修复。变量改名会影响业务语义和引用范围,应由开发者按提示手动调整为更明确的名称,例如 `currentMemberType`。
@@ -0,0 +1,262 @@
1
+ function getFilename(context) {
2
+ return context.filename || context.getFilename?.();
3
+ }
4
+
5
+ function isVueFile(context) {
6
+ return getFilename(context)?.endsWith('.vue') === true;
7
+ }
8
+
9
+ function getSourceCode(context) {
10
+ return context.sourceCode || context.getSourceCode();
11
+ }
12
+
13
+ function getParserServices(context) {
14
+ return getSourceCode(context).parserServices || context.parserServices;
15
+ }
16
+
17
+ function getAttributeName(attribute) {
18
+ return attribute?.key?.name ?? null;
19
+ }
20
+
21
+ function isScriptSetupElement(node) {
22
+ return (
23
+ node.type === 'VElement' &&
24
+ node.name === 'script' &&
25
+ node.startTag?.attributes?.some((attribute) => getAttributeName(attribute) === 'setup')
26
+ );
27
+ }
28
+
29
+ function getScriptSetupRanges(context) {
30
+ const services = getParserServices(context);
31
+ const fragment = services?.getDocumentFragment?.();
32
+
33
+ if (!fragment?.children) {
34
+ return [];
35
+ }
36
+
37
+ return fragment.children
38
+ .filter(isScriptSetupElement)
39
+ .map((node) => [
40
+ node.startTag.range[1],
41
+ node.endTag?.range?.[0] ?? node.range[1]
42
+ ]);
43
+ }
44
+
45
+ function isInRanges(node, ranges) {
46
+ if (!node?.range) {
47
+ return false;
48
+ }
49
+
50
+ return ranges.some(([start, end]) => node.range[0] >= start && node.range[1] <= end);
51
+ }
52
+
53
+ function isKebabCaseTagName(tagName) {
54
+ return /^[a-z][a-z0-9]*(?:-[a-z0-9]+)+$/.test(tagName);
55
+ }
56
+
57
+ function isComponentLocalName(name) {
58
+ return /^[A-Z]/.test(name);
59
+ }
60
+
61
+ function toKebabCase(name) {
62
+ return name
63
+ .replace(/([A-Z]+)([A-Z][a-z])/g, '$1-$2')
64
+ .replace(/([a-z0-9])([A-Z])/g, '$1-$2')
65
+ .replace(/_/g, '-')
66
+ .toLowerCase();
67
+ }
68
+
69
+ function toLowerCamelCase(name) {
70
+ return name.charAt(0).toLowerCase() + name.slice(1);
71
+ }
72
+
73
+ function toSuggestName(componentName) {
74
+ return `current${componentName}`;
75
+ }
76
+
77
+ function getImportLocalNames(node) {
78
+ return node.specifiers
79
+ .map((specifier) => specifier.local?.name)
80
+ .filter((name) => typeof name === 'string' && isComponentLocalName(name));
81
+ }
82
+
83
+ function addLocalDeclaration(localDeclarations, name, node) {
84
+ if (!name || localDeclarations.has(name)) {
85
+ return;
86
+ }
87
+
88
+ localDeclarations.set(name, node);
89
+ }
90
+
91
+ function collectPatternIdentifiers(pattern, identifiers) {
92
+ if (!pattern) {
93
+ return;
94
+ }
95
+
96
+ if (pattern.type === 'Identifier') {
97
+ identifiers.push(pattern);
98
+ return;
99
+ }
100
+
101
+ if (pattern.type === 'RestElement') {
102
+ collectPatternIdentifiers(pattern.argument, identifiers);
103
+ return;
104
+ }
105
+
106
+ if (pattern.type === 'AssignmentPattern') {
107
+ collectPatternIdentifiers(pattern.left, identifiers);
108
+ return;
109
+ }
110
+
111
+ if (pattern.type === 'ArrayPattern') {
112
+ pattern.elements.forEach((element) => collectPatternIdentifiers(element, identifiers));
113
+ return;
114
+ }
115
+
116
+ if (pattern.type === 'ObjectPattern') {
117
+ pattern.properties.forEach((property) => {
118
+ if (property.type === 'Property') {
119
+ collectPatternIdentifiers(property.value, identifiers);
120
+ return;
121
+ }
122
+
123
+ collectPatternIdentifiers(property.argument, identifiers);
124
+ });
125
+ }
126
+ }
127
+
128
+ function getDeclarationIdentifierNodes(node) {
129
+ if (
130
+ (node.type === 'FunctionDeclaration' || node.type === 'ClassDeclaration') &&
131
+ node.id?.type === 'Identifier'
132
+ ) {
133
+ return [node.id];
134
+ }
135
+
136
+ if (node.type !== 'VariableDeclarator') {
137
+ return [];
138
+ }
139
+
140
+ const identifiers = [];
141
+ collectPatternIdentifiers(node.id, identifiers);
142
+ return identifiers;
143
+ }
144
+
145
+ function isRootTemplateElement(node) {
146
+ return (node.rawName || node.name) === 'template' && node.parent?.type === 'VDocumentFragment';
147
+ }
148
+
149
+ export default {
150
+ meta: {
151
+ type: 'problem',
152
+ docs: {
153
+ description: '禁止 Vue script setup 中的组件导入名和 lower camel 本地声明产生命名冲突'
154
+ },
155
+ schema: [],
156
+ messages: {
157
+ componentVariableNameConflict:
158
+ '变量 "{{variableName}}" 与组件 "{{componentName}}" 的模板标签 "<{{tagName}}>" 命名冲突,可能导致模板把该标签识别为变量而不是组件。请将变量改成更明确的名称,例如 "{{suggestName}}"。'
159
+ }
160
+ },
161
+ create(context) {
162
+ if (!isVueFile(context)) {
163
+ return {};
164
+ }
165
+
166
+ const services = getParserServices(context);
167
+ if (!services?.defineTemplateBodyVisitor) {
168
+ return {};
169
+ }
170
+
171
+ const scriptSetupRanges = getScriptSetupRanges(context);
172
+ if (scriptSetupRanges.length === 0) {
173
+ return {};
174
+ }
175
+
176
+ const templateTags = new Set();
177
+ const componentImports = new Map();
178
+ const localDeclarations = new Map();
179
+ let hasReported = false;
180
+
181
+ function reportConflicts() {
182
+ if (hasReported) {
183
+ return;
184
+ }
185
+
186
+ hasReported = true;
187
+
188
+ componentImports.forEach((component) => {
189
+ const localDeclaration = localDeclarations.get(component.variableName);
190
+ if (!templateTags.has(component.tagName) || !localDeclaration) {
191
+ return;
192
+ }
193
+
194
+ context.report({
195
+ node: localDeclaration,
196
+ messageId: 'componentVariableNameConflict',
197
+ data: component
198
+ });
199
+ });
200
+ }
201
+
202
+ return services.defineTemplateBodyVisitor(
203
+ {
204
+ VElement(node) {
205
+ const tagName = node.rawName || node.name;
206
+ if (typeof tagName === 'string' && isKebabCaseTagName(tagName)) {
207
+ templateTags.add(tagName);
208
+ }
209
+ },
210
+ 'VElement:exit'(node) {
211
+ if (isRootTemplateElement(node)) {
212
+ reportConflicts();
213
+ }
214
+ }
215
+ },
216
+ {
217
+ ImportDeclaration(node) {
218
+ if (!isInRanges(node, scriptSetupRanges)) {
219
+ return;
220
+ }
221
+
222
+ getImportLocalNames(node).forEach((componentName) => {
223
+ const tagName = toKebabCase(componentName);
224
+ componentImports.set(componentName, {
225
+ componentName,
226
+ tagName,
227
+ variableName: toLowerCamelCase(componentName),
228
+ suggestName: toSuggestName(componentName)
229
+ });
230
+ });
231
+ },
232
+ VariableDeclarator(node) {
233
+ if (!isInRanges(node, scriptSetupRanges)) {
234
+ return;
235
+ }
236
+
237
+ getDeclarationIdentifierNodes(node).forEach((identifier) => {
238
+ addLocalDeclaration(localDeclarations, identifier.name, identifier);
239
+ });
240
+ },
241
+ FunctionDeclaration(node) {
242
+ if (!isInRanges(node, scriptSetupRanges)) {
243
+ return;
244
+ }
245
+
246
+ getDeclarationIdentifierNodes(node).forEach((identifier) => {
247
+ addLocalDeclaration(localDeclarations, identifier.name, identifier);
248
+ });
249
+ },
250
+ ClassDeclaration(node) {
251
+ if (!isInRanges(node, scriptSetupRanges)) {
252
+ return;
253
+ }
254
+
255
+ getDeclarationIdentifierNodes(node).forEach((identifier) => {
256
+ addLocalDeclaration(localDeclarations, identifier.name, identifier);
257
+ });
258
+ }
259
+ }
260
+ );
261
+ }
262
+ };
package/utils.mjs ADDED
@@ -0,0 +1,3 @@
1
+ export const devWarnProdError = process.env.NODE_ENV === 'production'
2
+ ? 'error'
3
+ : 'warn';
package/.editorconfig DELETED
@@ -1,5 +0,0 @@
1
- [*.{js,jsx,ts,tsx,mjs,vue,css,scss,html,json,md,txt}]
2
- indent_style = space
3
- indent_size = 2
4
- trim_trailing_whitespace = true
5
- insert_final_newline = true
package/.eslintrc.js DELETED
@@ -1,3 +0,0 @@
1
- const config = require('./lib/for-js');
2
- config.env.node = true;
3
- module.exports = config;
package/.gitattributes DELETED
@@ -1,12 +0,0 @@
1
- *.js eol=lf
2
- *.jsx eol=lf
3
- *.ts eol=lf
4
- *.tsx eol=lf
5
- *.mjs eol=lf
6
- *.vue eol=lf
7
- *.css eol=lf
8
- *.scss eol=lf
9
- *.html eol=lf
10
- *.json eol=lf
11
- *.txt eol=lf
12
- *.md eol=lf
package/.nvmrc DELETED
@@ -1 +0,0 @@
1
- 20
package/lib/for-js.js DELETED
@@ -1,84 +0,0 @@
1
- /**
2
- * JavaScript 验证规则(基础规则)。
3
- */
4
-
5
- const {
6
- devWarnProdError,
7
- strictErrorOtherwiseWarn
8
- } = require('./util');
9
-
10
- module.exports = {
11
- parser: '@babel/eslint-parser',
12
- parserOptions: {
13
- ecmaVersion: 2020,
14
- sourceType: 'module',
15
- requireConfigFile: false
16
- },
17
- env: {
18
- browser: true,
19
- es6: true
20
- },
21
- plugins: [
22
- 'promise'
23
- ],
24
- extends: [
25
- 'eslint:recommended',
26
- 'standard',
27
- 'plugin:import/recommended',
28
- 'plugin:sonarjs/recommended'
29
- ],
30
- rules: {
31
- semi: ['error', 'always'],
32
- 'space-before-function-paren': ['error', {
33
- anonymous: 'never',
34
- named: 'never',
35
- asyncArrow: 'always'
36
- }],
37
- 'operator-linebreak': ['error', 'after', {
38
- overrides: {
39
- '?': 'before',
40
- ':': 'before'
41
- }
42
- }],
43
- 'comma-dangle': ['error', 'only-multiline'],
44
- 'no-trailing-spaces': ['error', { ignoreComments: true }],
45
- 'no-multiple-empty-lines': ['error', { max: 2 }],
46
- 'wrap-iife': ['error', 'inside'],
47
- 'no-confusing-arrow': 'error',
48
- 'padded-blocks': 'off',
49
-
50
- camelcase: 'error',
51
- 'no-debugger': devWarnProdError,
52
- 'no-unused-vars': [devWarnProdError, {
53
- vars: 'all',
54
- args: 'after-used',
55
- ignoreRestSiblings: true,
56
- caughtErrors: 'none'
57
- }],
58
- 'no-use-before-define': ['error', {
59
- functions: false,
60
- classes: false,
61
- variables: true
62
- }],
63
- 'no-loop-func': 'error',
64
- 'no-script-url': 'error',
65
- 'no-new': 'off',
66
- 'no-constant-condition': devWarnProdError,
67
- 'no-empty': [devWarnProdError, { allowEmptyCatch: true }],
68
- 'no-lonely-if': 'off',
69
- 'no-var': 'error',
70
- 'no-template-curly-in-string': 'off',
71
- 'prefer-promise-reject-errors': 'error',
72
-
73
- 'import/no-unresolved': 'off',
74
- 'import/no-duplicates': 'error',
75
-
76
- 'promise/prefer-await-to-then': 'warn',
77
-
78
- 'sonarjs/cognitive-complexity': ['error', 20],
79
- 'sonarjs/no-duplicate-string': [devWarnProdError, { threshold: 5 }],
80
- 'sonarjs/prefer-single-boolean-return': 'off',
81
- 'sonarjs/no-collection-size-mischeck': 'off',
82
- 'sonarjs/no-nested-template-literals': strictErrorOtherwiseWarn
83
- }
84
- };
package/lib/for-ts.js DELETED
@@ -1,88 +0,0 @@
1
- /**
2
- * TypeScript 工程的附加验证规则。
3
- */
4
-
5
- const {
6
- devWarnProdError,
7
- strictErrorOtherwiseWarn
8
- } = require('./util');
9
-
10
- module.exports = {
11
- overrides: [{
12
- files: ['*.ts', '*.tsx'],
13
- extends: [
14
- 'plugin:@typescript-eslint/recommended',
15
- 'plugin:import/typescript'
16
- ],
17
- rules: {
18
- semi: 'off',
19
- '@typescript-eslint/semi': ['error', 'always'],
20
-
21
- indent: 'off',
22
- '@typescript-eslint/indent': ['error', 2, {
23
- SwitchCase: 1
24
- }],
25
-
26
- 'comma-spacing': 'off',
27
- '@typescript-eslint/comma-spacing': ['error'],
28
-
29
- 'comma-dangle': 'off',
30
- '@typescript-eslint/comma-dangle': ['error', 'only-multiline'],
31
-
32
- 'no-use-before-define': 'off',
33
- '@typescript-eslint/no-use-before-define': ['error', {
34
- functions: false,
35
- classes: false,
36
- variables: true
37
- }],
38
-
39
- 'no-unused-vars': 'off',
40
- '@typescript-eslint/no-unused-vars': [devWarnProdError, {
41
- vars: 'all',
42
- args: 'after-used',
43
- ignoreRestSiblings: true,
44
- caughtErrors: 'none'
45
- }],
46
-
47
- 'no-loop-func': 'off',
48
- '@typescript-eslint/no-loop-func': 'error',
49
-
50
- 'space-before-function-paren': 'off',
51
- '@typescript-eslint/space-before-function-paren': ['error', {
52
- anonymous: 'never',
53
- named: 'never',
54
- asyncArrow: 'always'
55
- }],
56
-
57
- '@typescript-eslint/no-explicit-any': 'warn',
58
- '@typescript-eslint/no-empty-function': 'off',
59
- '@typescript-eslint/no-empty-interface': 'off',
60
- '@typescript-eslint/type-annotation-spacing': ['error', {
61
- after: true,
62
- before: false,
63
- overrides: {
64
- arrow: {
65
- before: true,
66
- after: true
67
- }
68
- }
69
- }],
70
- '@typescript-eslint/explicit-module-boundary-types': devWarnProdError,
71
- '@typescript-eslint/naming-convention': [strictErrorOtherwiseWarn, {
72
- selector: 'enumMember',
73
- format: ['PascalCase']
74
- }, {
75
- selector: 'enum',
76
- format: ['PascalCase']
77
- }],
78
- '@typescript-eslint/no-var-requires': 'off',
79
- '@typescript-eslint/no-for-in-array': 'error'
80
- },
81
- settings: {
82
- 'import/resolver': {
83
- typescript: true,
84
- node: true
85
- }
86
- }
87
- }]
88
- };
@@ -1,45 +0,0 @@
1
- /**
2
- * Vue.js 工程的验证规则。
3
- */
4
-
5
- const { strictErrorOtherwiseWarn } = require('./util');
6
- const jsConfig = require('./for-js');
7
-
8
- module.exports = Object.assign({}, jsConfig, {
9
- parser: 'vue-eslint-parser',
10
- parserOptions: {
11
- parser: '@babel/eslint-parser',
12
- ...jsConfig.parserOptions
13
- },
14
- extends: [
15
- ...jsConfig.extends,
16
- '@vue/standard',
17
- 'plugin:vue/essential'
18
- ],
19
- rules: {
20
- ...jsConfig.rules,
21
- 'vue/html-indent': ['error', 2],
22
- 'vue/html-self-closing': ['error', {
23
- html: {
24
- void: 'always',
25
- normal: 'never',
26
- component: 'always'
27
- },
28
- svg: 'always',
29
- math: 'always'
30
- }],
31
- 'vue/no-v-html': 'off',
32
- 'vue/max-attributes-per-line': ['error', {
33
- singleline: 3,
34
- multiline: 1
35
- }],
36
- 'vue/singleline-html-element-content-newline': 'off',
37
- 'vue/custom-event-name-casing': [strictErrorOtherwiseWarn, 'kebab-case'],
38
- 'vue/no-mutating-props': strictErrorOtherwiseWarn,
39
- 'vue/multi-word-component-names': [strictErrorOtherwiseWarn, {
40
- ignores: ['index', 'Index', 'default', 'Default']
41
- }],
42
- 'vue/attribute-hyphenation': [strictErrorOtherwiseWarn, 'always'],
43
- 'vue/v-on-event-hyphenation': [strictErrorOtherwiseWarn, 'always']
44
- }
45
- });
package/lib/util.js DELETED
@@ -1,7 +0,0 @@
1
- exports.devWarnProdError = process.env.NODE_ENV === 'production'
2
- ? 'error'
3
- : 'warn';
4
-
5
- exports.strictErrorOtherwiseWarn = process.env.STRICT_LINT !== 'false'
6
- ? 'error'
7
- : 'warn';