@af-mobile/eslint-plugin 2.0.4 → 2.2.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.
- package/index.js +111 -95
- package/package.json +35 -31
- package/rules/atomic-duplicate.js +68 -60
- package/rules/k-for-require-key.js +33 -0
- package/rules/k-no-bare-and.js +31 -0
- package/rules/k-no-object-interpolation.js +31 -0
- package/rules/no-arbitrary-value.js +52 -52
- package/rules/no-emoji-icon.js +69 -0
- package/rules/no-inline-style.js +129 -127
- package/rules/no-recipe-break.js +59 -59
- package/rules/no-register-all.js +31 -31
- package/rules/no-tailwind-syntax.js +31 -31
- package/rules/no-token-modification.js +92 -92
- package/rules/no-variant-conflict.js +83 -72
- package/rules/prefer-component.js +58 -58
- package/rules/token-whitelist.js +84 -84
- package/rules/wc-aria-required.js +60 -60
- package/rules/wc-block-no-internal-ref.js +60 -60
- package/rules/wc-block-props-count.js +48 -48
- package/rules/wc-block-states.js +64 -64
- package/rules/wc-block-variant-enum.js +83 -83
- package/rules/wc-cleanup.js +75 -75
- package/rules/wc-event-naming.js +60 -60
- package/rules/wc-light-no-style.js +87 -87
- package/rules/wc-part-naming.js +33 -33
- package/rules/wc-shadow-use-token.js +72 -72
- package/utils/aria-requirements.json +101 -101
- package/utils/helpers.js +106 -84
- package/utils/whitelist-v1.json +41 -1
|
@@ -1,60 +1,60 @@
|
|
|
1
|
-
// L3-5 af-mobile/wc-aria-required(error)
|
|
2
|
-
// 检测:组件 render 输出的 DOM 缺少必需的 ARIA 角色/属性
|
|
3
|
-
import { readFileSync } from 'node:fs';
|
|
4
|
-
import { fileURLToPath } from 'node:url';
|
|
5
|
-
import { resolve, dirname } from 'node:path';
|
|
6
|
-
|
|
7
|
-
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
8
|
-
const ariaPath = resolve(__dirname, '../utils/aria-requirements.json');
|
|
9
|
-
const ARIA_REQ = JSON.parse(readFileSync(ariaPath, 'utf8'));
|
|
10
|
-
|
|
11
|
-
export default {
|
|
12
|
-
meta: {
|
|
13
|
-
type: 'problem',
|
|
14
|
-
docs: { description: '组件必须包含声明的必需 ARIA 角色/属性' },
|
|
15
|
-
schema: [],
|
|
16
|
-
messages: {
|
|
17
|
-
missingRole: "af-{{comp}} missing role=\"{{role}}\"; required by WAI-ARIA, see aria-requirements.json",
|
|
18
|
-
missingAriaLabel: "af-{{comp}} missing aria-label; required for screen reader accessibility",
|
|
19
|
-
missingAriaLive: "af-{{comp}} missing aria-live; required for dynamic content announcements",
|
|
20
|
-
missingAriaChecked: "af-{{comp}} missing aria-checked; required for switch/checkbox state",
|
|
21
|
-
},
|
|
22
|
-
},
|
|
23
|
-
create(context) {
|
|
24
|
-
const filename = context.filename || context.getFilename();
|
|
25
|
-
if (!/src[\\/](?:(?:charts|chat)[\\/])?components[\\/].*\.js$/.test(filename)) return {};
|
|
26
|
-
|
|
27
|
-
// 从文件名提取组件名(af-xxx.js → af-xxx)
|
|
28
|
-
const m = filename.match(/(af-[a-z-]+)\.js$/);
|
|
29
|
-
if (!m) return {};
|
|
30
|
-
const compName = m[1];
|
|
31
|
-
const req = ARIA_REQ[compName];
|
|
32
|
-
if (!req) return {};
|
|
33
|
-
|
|
34
|
-
const sourceCode = context.sourceCode || context.getSourceCode();
|
|
35
|
-
|
|
36
|
-
return {
|
|
37
|
-
'Program:exit'() {
|
|
38
|
-
const source = sourceCode.getText();
|
|
39
|
-
// 识别两种形式:(a) 字面量 role="xxx" (b) setAttribute('role', 'xxx')
|
|
40
|
-
const hasRole = (r) =>
|
|
41
|
-
source.includes(`role="${r}"`) ||
|
|
42
|
-
source.includes(`role='${r}'`) ||
|
|
43
|
-
source.includes(`setAttribute('role', '${r}')`) ||
|
|
44
|
-
source.includes(`setAttribute("role", "${r}")`);
|
|
45
|
-
if (req.role && !hasRole(req.role)) {
|
|
46
|
-
context.report({ loc: { line: 1, column: 0 }, messageId: 'missingRole', data: { comp: compName, role: req.role } });
|
|
47
|
-
}
|
|
48
|
-
if (req.ariaLabel && !source.includes('aria-label') && !source.includes("ariaLabel")) {
|
|
49
|
-
context.report({ loc: { line: 1, column: 0 }, messageId: 'missingAriaLabel', data: { comp: compName } });
|
|
50
|
-
}
|
|
51
|
-
if (req.ariaLive && !source.includes('aria-live') && !source.includes("ariaLive")) {
|
|
52
|
-
context.report({ loc: { line: 1, column: 0 }, messageId: 'missingAriaLive', data: { comp: compName } });
|
|
53
|
-
}
|
|
54
|
-
if (req.ariaChecked && !source.includes('aria-checked') && !source.includes("ariaChecked")) {
|
|
55
|
-
context.report({ loc: { line: 1, column: 0 }, messageId: 'missingAriaChecked', data: { comp: compName } });
|
|
56
|
-
}
|
|
57
|
-
},
|
|
58
|
-
};
|
|
59
|
-
},
|
|
60
|
-
};
|
|
1
|
+
// L3-5 af-mobile/wc-aria-required(error)
|
|
2
|
+
// 检测:组件 render 输出的 DOM 缺少必需的 ARIA 角色/属性
|
|
3
|
+
import { readFileSync } from 'node:fs';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { resolve, dirname } from 'node:path';
|
|
6
|
+
|
|
7
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
8
|
+
const ariaPath = resolve(__dirname, '../utils/aria-requirements.json');
|
|
9
|
+
const ARIA_REQ = JSON.parse(readFileSync(ariaPath, 'utf8'));
|
|
10
|
+
|
|
11
|
+
export default {
|
|
12
|
+
meta: {
|
|
13
|
+
type: 'problem',
|
|
14
|
+
docs: { description: '组件必须包含声明的必需 ARIA 角色/属性' },
|
|
15
|
+
schema: [],
|
|
16
|
+
messages: {
|
|
17
|
+
missingRole: "af-{{comp}} missing role=\"{{role}}\"; required by WAI-ARIA, see aria-requirements.json",
|
|
18
|
+
missingAriaLabel: "af-{{comp}} missing aria-label; required for screen reader accessibility",
|
|
19
|
+
missingAriaLive: "af-{{comp}} missing aria-live; required for dynamic content announcements",
|
|
20
|
+
missingAriaChecked: "af-{{comp}} missing aria-checked; required for switch/checkbox state",
|
|
21
|
+
},
|
|
22
|
+
},
|
|
23
|
+
create(context) {
|
|
24
|
+
const filename = context.filename || context.getFilename();
|
|
25
|
+
if (!/src[\\/](?:(?:charts|chat)[\\/])?components[\\/].*\.js$/.test(filename)) return {};
|
|
26
|
+
|
|
27
|
+
// 从文件名提取组件名(af-xxx.js → af-xxx)
|
|
28
|
+
const m = filename.match(/(af-[a-z-]+)\.js$/);
|
|
29
|
+
if (!m) return {};
|
|
30
|
+
const compName = m[1];
|
|
31
|
+
const req = ARIA_REQ[compName];
|
|
32
|
+
if (!req) return {};
|
|
33
|
+
|
|
34
|
+
const sourceCode = context.sourceCode || context.getSourceCode();
|
|
35
|
+
|
|
36
|
+
return {
|
|
37
|
+
'Program:exit'() {
|
|
38
|
+
const source = sourceCode.getText();
|
|
39
|
+
// 识别两种形式:(a) 字面量 role="xxx" (b) setAttribute('role', 'xxx')
|
|
40
|
+
const hasRole = (r) =>
|
|
41
|
+
source.includes(`role="${r}"`) ||
|
|
42
|
+
source.includes(`role='${r}'`) ||
|
|
43
|
+
source.includes(`setAttribute('role', '${r}')`) ||
|
|
44
|
+
source.includes(`setAttribute("role", "${r}")`);
|
|
45
|
+
if (req.role && !hasRole(req.role)) {
|
|
46
|
+
context.report({ loc: { line: 1, column: 0 }, messageId: 'missingRole', data: { comp: compName, role: req.role } });
|
|
47
|
+
}
|
|
48
|
+
if (req.ariaLabel && !source.includes('aria-label') && !source.includes("ariaLabel")) {
|
|
49
|
+
context.report({ loc: { line: 1, column: 0 }, messageId: 'missingAriaLabel', data: { comp: compName } });
|
|
50
|
+
}
|
|
51
|
+
if (req.ariaLive && !source.includes('aria-live') && !source.includes("ariaLive")) {
|
|
52
|
+
context.report({ loc: { line: 1, column: 0 }, messageId: 'missingAriaLive', data: { comp: compName } });
|
|
53
|
+
}
|
|
54
|
+
if (req.ariaChecked && !source.includes('aria-checked') && !source.includes("ariaChecked")) {
|
|
55
|
+
context.report({ loc: { line: 1, column: 0 }, messageId: 'missingAriaChecked', data: { comp: compName } });
|
|
56
|
+
}
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
},
|
|
60
|
+
};
|
|
@@ -1,60 +1,60 @@
|
|
|
1
|
-
// L3.5 af-mobile/wc-block-no-internal-ref(error)
|
|
2
|
-
// 检测:消费端代码(非 src/)禁止穿透 Block 边界访问内部
|
|
3
|
-
// 反例:querySelector('af-auth-form > div') / blockInstance.shadowRoot.querySelector(...)
|
|
4
|
-
// 正例:document.querySelector('af-auth-form').setAttribute('loading', 'true')
|
|
5
|
-
export default {
|
|
6
|
-
meta: {
|
|
7
|
-
type: 'problem',
|
|
8
|
-
docs: { description: '消费端禁止穿透 Block 边界访问内部', fixable: 'manual' },
|
|
9
|
-
schema: [],
|
|
10
|
-
messages: {
|
|
11
|
-
childSelector: "querySelector('{{sel}}') penetrates Block boundary; only root tag selector allowed (e.g. 'af-auth-form')",
|
|
12
|
-
shadowRoot: "accessing .shadowRoot of Block '{{name}}' is forbidden; use props/events only",
|
|
13
|
-
},
|
|
14
|
-
},
|
|
15
|
-
create(context) {
|
|
16
|
-
const filename = context.filename || context.getFilename();
|
|
17
|
-
// 非消费端放行:库源码/单元测试/构建脚本
|
|
18
|
-
if (/src[\\/]|test[\\/]|scripts[\\/]/.test(filename)) return {};
|
|
19
|
-
|
|
20
|
-
// af-* 标签选择器(含子代/后代穿透):af-xxx > 或 af-xxx 空格 后跟其他选择器
|
|
21
|
-
// 标签名含连字符(af-auth-form),字符类须含 - 否则匹配不到组合器
|
|
22
|
-
const PENETRATE_RE = /af-[a-z][a-z0-9-]*(?:[>+~]|\s+[^,)\]]+)/;
|
|
23
|
-
|
|
24
|
-
return {
|
|
25
|
-
CallExpression(node) {
|
|
26
|
-
const callee = node.callee;
|
|
27
|
-
// 匹配 xxx.querySelector(...) / querySelectorAll(...)
|
|
28
|
-
if (callee.type !== 'MemberExpression') return;
|
|
29
|
-
const propName = callee.property?.name;
|
|
30
|
-
if (propName !== 'querySelector' && propName !== 'querySelectorAll') return;
|
|
31
|
-
|
|
32
|
-
const selArg = node.arguments[0];
|
|
33
|
-
if (!selArg) return;
|
|
34
|
-
let sel = '';
|
|
35
|
-
if (selArg.type === 'Literal' && typeof selArg.value === 'string') sel = selArg.value;
|
|
36
|
-
else if (selArg.type === 'TemplateLiteral' && selArg.quasis.length === 1) sel = selArg.quasis[0].value.raw;
|
|
37
|
-
|
|
38
|
-
// 穿透检测:af-xxx > div / af-xxx div / af-xxx > .foo
|
|
39
|
-
if (sel && PENETRATE_RE.test(sel)) {
|
|
40
|
-
context.report({ node: selArg, messageId: 'childSelector', data: { sel } });
|
|
41
|
-
}
|
|
42
|
-
},
|
|
43
|
-
// 检测 blockInstance.shadowRoot 访问
|
|
44
|
-
MemberExpression(node) {
|
|
45
|
-
// 形如 xxx.shadowRoot
|
|
46
|
-
if (node.property?.type === 'Identifier' && node.property.name === 'shadowRoot') {
|
|
47
|
-
// 只对 af-* 开头的变量名报错(近似 Block 实例)
|
|
48
|
-
const obj = node.object;
|
|
49
|
-
let name = '';
|
|
50
|
-
if (obj.type === 'Identifier') name = obj.name;
|
|
51
|
-
else if (obj.type === 'MemberExpression' && obj.property?.name) name = obj.property.name;
|
|
52
|
-
// 启发式:变量名含 af 或块名关键词视为 Block 实例
|
|
53
|
-
if (/af|block|form|grid|card|list/i.test(name)) {
|
|
54
|
-
context.report({ node: node.property, messageId: 'shadowRoot', data: { name } });
|
|
55
|
-
}
|
|
56
|
-
}
|
|
57
|
-
},
|
|
58
|
-
};
|
|
59
|
-
},
|
|
60
|
-
};
|
|
1
|
+
// L3.5 af-mobile/wc-block-no-internal-ref(error)
|
|
2
|
+
// 检测:消费端代码(非 src/)禁止穿透 Block 边界访问内部
|
|
3
|
+
// 反例:querySelector('af-auth-form > div') / blockInstance.shadowRoot.querySelector(...)
|
|
4
|
+
// 正例:document.querySelector('af-auth-form').setAttribute('loading', 'true')
|
|
5
|
+
export default {
|
|
6
|
+
meta: {
|
|
7
|
+
type: 'problem',
|
|
8
|
+
docs: { description: '消费端禁止穿透 Block 边界访问内部', fixable: 'manual' },
|
|
9
|
+
schema: [],
|
|
10
|
+
messages: {
|
|
11
|
+
childSelector: "querySelector('{{sel}}') penetrates Block boundary; only root tag selector allowed (e.g. 'af-auth-form')",
|
|
12
|
+
shadowRoot: "accessing .shadowRoot of Block '{{name}}' is forbidden; use props/events only",
|
|
13
|
+
},
|
|
14
|
+
},
|
|
15
|
+
create(context) {
|
|
16
|
+
const filename = context.filename || context.getFilename();
|
|
17
|
+
// 非消费端放行:库源码/单元测试/构建脚本
|
|
18
|
+
if (/src[\\/]|test[\\/]|scripts[\\/]/.test(filename)) return {};
|
|
19
|
+
|
|
20
|
+
// af-* 标签选择器(含子代/后代穿透):af-xxx > 或 af-xxx 空格 后跟其他选择器
|
|
21
|
+
// 标签名含连字符(af-auth-form),字符类须含 - 否则匹配不到组合器
|
|
22
|
+
const PENETRATE_RE = /af-[a-z][a-z0-9-]*(?:[>+~]|\s+[^,)\]]+)/;
|
|
23
|
+
|
|
24
|
+
return {
|
|
25
|
+
CallExpression(node) {
|
|
26
|
+
const callee = node.callee;
|
|
27
|
+
// 匹配 xxx.querySelector(...) / querySelectorAll(...)
|
|
28
|
+
if (callee.type !== 'MemberExpression') return;
|
|
29
|
+
const propName = callee.property?.name;
|
|
30
|
+
if (propName !== 'querySelector' && propName !== 'querySelectorAll') return;
|
|
31
|
+
|
|
32
|
+
const selArg = node.arguments[0];
|
|
33
|
+
if (!selArg) return;
|
|
34
|
+
let sel = '';
|
|
35
|
+
if (selArg.type === 'Literal' && typeof selArg.value === 'string') sel = selArg.value;
|
|
36
|
+
else if (selArg.type === 'TemplateLiteral' && selArg.quasis.length === 1) sel = selArg.quasis[0].value.raw;
|
|
37
|
+
|
|
38
|
+
// 穿透检测:af-xxx > div / af-xxx div / af-xxx > .foo
|
|
39
|
+
if (sel && PENETRATE_RE.test(sel)) {
|
|
40
|
+
context.report({ node: selArg, messageId: 'childSelector', data: { sel } });
|
|
41
|
+
}
|
|
42
|
+
},
|
|
43
|
+
// 检测 blockInstance.shadowRoot 访问
|
|
44
|
+
MemberExpression(node) {
|
|
45
|
+
// 形如 xxx.shadowRoot
|
|
46
|
+
if (node.property?.type === 'Identifier' && node.property.name === 'shadowRoot') {
|
|
47
|
+
// 只对 af-* 开头的变量名报错(近似 Block 实例)
|
|
48
|
+
const obj = node.object;
|
|
49
|
+
let name = '';
|
|
50
|
+
if (obj.type === 'Identifier') name = obj.name;
|
|
51
|
+
else if (obj.type === 'MemberExpression' && obj.property?.name) name = obj.property.name;
|
|
52
|
+
// 启发式:变量名含 af 或块名关键词视为 Block 实例
|
|
53
|
+
if (/af|block|form|grid|card|list/i.test(name)) {
|
|
54
|
+
context.report({ node: node.property, messageId: 'shadowRoot', data: { name } });
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
},
|
|
60
|
+
};
|
|
@@ -1,48 +1,48 @@
|
|
|
1
|
-
// L3.5 af-mobile/wc-block-props-count(error)
|
|
2
|
-
// 检测:Block 类的 AfElement.defineProp 调用数必须在 2-5 之间
|
|
3
|
-
// 适用:src/blocks/**/*.js
|
|
4
|
-
export default {
|
|
5
|
-
meta: {
|
|
6
|
-
type: 'problem',
|
|
7
|
-
docs: { description: 'Block props 数必须在 2-5' },
|
|
8
|
-
schema: [],
|
|
9
|
-
messages: {
|
|
10
|
-
tooFew: "Block '{{name}}' has {{n}} prop(s); minimum is 2 (合并到现有 Block 或补充必要 props)",
|
|
11
|
-
tooMany: "Block '{{name}}' has {{n}} props; maximum is 5 (拆分为多个 Block)",
|
|
12
|
-
},
|
|
13
|
-
},
|
|
14
|
-
create(context) {
|
|
15
|
-
const filename = context.filename || context.getFilename();
|
|
16
|
-
if (!/src[\\/]blocks[\\/].*\.js$/.test(filename)) return {};
|
|
17
|
-
|
|
18
|
-
const MIN = 2, MAX = 5;
|
|
19
|
-
let definePropCalls = 0;
|
|
20
|
-
let className = 'unknown';
|
|
21
|
-
|
|
22
|
-
return {
|
|
23
|
-
ClassDeclaration(node) {
|
|
24
|
-
if (node.id?.name) className = node.id.name;
|
|
25
|
-
},
|
|
26
|
-
CallExpression(node) {
|
|
27
|
-
// 匹配 AfElement.defineProp(...) 或 this.constructor.defineProp(...)
|
|
28
|
-
const callee = node.callee;
|
|
29
|
-
const isDefineProp =
|
|
30
|
-
(callee.type === 'MemberExpression' &&
|
|
31
|
-
callee.property?.type === 'Identifier' &&
|
|
32
|
-
callee.property.name === 'defineProp') ||
|
|
33
|
-
(callee.type === 'MemberExpression' &&
|
|
34
|
-
callee.object?.type === 'MemberExpression' &&
|
|
35
|
-
callee.object.property?.name === 'constructor' &&
|
|
36
|
-
callee.property?.name === 'defineProp');
|
|
37
|
-
if (isDefineProp) definePropCalls++;
|
|
38
|
-
},
|
|
39
|
-
'Program:exit'() {
|
|
40
|
-
if (definePropCalls > 0 && definePropCalls < MIN) {
|
|
41
|
-
context.report({ loc: { line: 1, column: 0 }, messageId: 'tooFew', data: { name: className, n: definePropCalls } });
|
|
42
|
-
} else if (definePropCalls > MAX) {
|
|
43
|
-
context.report({ loc: { line: 1, column: 0 }, messageId: 'tooMany', data: { name: className, n: definePropCalls } });
|
|
44
|
-
}
|
|
45
|
-
},
|
|
46
|
-
};
|
|
47
|
-
},
|
|
48
|
-
};
|
|
1
|
+
// L3.5 af-mobile/wc-block-props-count(error)
|
|
2
|
+
// 检测:Block 类的 AfElement.defineProp 调用数必须在 2-5 之间
|
|
3
|
+
// 适用:src/blocks/**/*.js
|
|
4
|
+
export default {
|
|
5
|
+
meta: {
|
|
6
|
+
type: 'problem',
|
|
7
|
+
docs: { description: 'Block props 数必须在 2-5' },
|
|
8
|
+
schema: [],
|
|
9
|
+
messages: {
|
|
10
|
+
tooFew: "Block '{{name}}' has {{n}} prop(s); minimum is 2 (合并到现有 Block 或补充必要 props)",
|
|
11
|
+
tooMany: "Block '{{name}}' has {{n}} props; maximum is 5 (拆分为多个 Block)",
|
|
12
|
+
},
|
|
13
|
+
},
|
|
14
|
+
create(context) {
|
|
15
|
+
const filename = context.filename || context.getFilename();
|
|
16
|
+
if (!/src[\\/]blocks[\\/].*\.js$/.test(filename)) return {};
|
|
17
|
+
|
|
18
|
+
const MIN = 2, MAX = 5;
|
|
19
|
+
let definePropCalls = 0;
|
|
20
|
+
let className = 'unknown';
|
|
21
|
+
|
|
22
|
+
return {
|
|
23
|
+
ClassDeclaration(node) {
|
|
24
|
+
if (node.id?.name) className = node.id.name;
|
|
25
|
+
},
|
|
26
|
+
CallExpression(node) {
|
|
27
|
+
// 匹配 AfElement.defineProp(...) 或 this.constructor.defineProp(...)
|
|
28
|
+
const callee = node.callee;
|
|
29
|
+
const isDefineProp =
|
|
30
|
+
(callee.type === 'MemberExpression' &&
|
|
31
|
+
callee.property?.type === 'Identifier' &&
|
|
32
|
+
callee.property.name === 'defineProp') ||
|
|
33
|
+
(callee.type === 'MemberExpression' &&
|
|
34
|
+
callee.object?.type === 'MemberExpression' &&
|
|
35
|
+
callee.object.property?.name === 'constructor' &&
|
|
36
|
+
callee.property?.name === 'defineProp');
|
|
37
|
+
if (isDefineProp) definePropCalls++;
|
|
38
|
+
},
|
|
39
|
+
'Program:exit'() {
|
|
40
|
+
if (definePropCalls > 0 && definePropCalls < MIN) {
|
|
41
|
+
context.report({ loc: { line: 1, column: 0 }, messageId: 'tooFew', data: { name: className, n: definePropCalls } });
|
|
42
|
+
} else if (definePropCalls > MAX) {
|
|
43
|
+
context.report({ loc: { line: 1, column: 0 }, messageId: 'tooMany', data: { name: className, n: definePropCalls } });
|
|
44
|
+
}
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
},
|
|
48
|
+
};
|
package/rules/wc-block-states.js
CHANGED
|
@@ -1,64 +1,64 @@
|
|
|
1
|
-
// L3.5 af-mobile/wc-block-states(error)
|
|
2
|
-
// 检测:Block 类的 mounted/render/_render 方法必须包含 loading/error/empty 分支
|
|
3
|
-
// 五态:idle(默认)/ loading / error / empty / success
|
|
4
|
-
// 检测策略:源码文本扫描,找 loading / error / empty 三个关键词至少各出现一次(在条件分支中)
|
|
5
|
-
// 适用:src/blocks/**/*.js
|
|
6
|
-
export default {
|
|
7
|
-
meta: {
|
|
8
|
-
type: 'problem',
|
|
9
|
-
docs: { description: 'Block 必须实现 loading/error/empty 三态分支' },
|
|
10
|
-
schema: [],
|
|
11
|
-
messages: {
|
|
12
|
-
missing: "Block '{{name}}' missing state '{{state}}'; must implement loading/error/empty branches in render",
|
|
13
|
-
},
|
|
14
|
-
},
|
|
15
|
-
create(context) {
|
|
16
|
-
const filename = context.filename || context.getFilename();
|
|
17
|
-
if (!/src[\\/]blocks[\\/].*\.js$/.test(filename)) return {};
|
|
18
|
-
|
|
19
|
-
const REQUIRED = ['loading', 'error', 'empty'];
|
|
20
|
-
const found = new Set();
|
|
21
|
-
let className = 'unknown';
|
|
22
|
-
|
|
23
|
-
return {
|
|
24
|
-
ClassDeclaration(node) {
|
|
25
|
-
if (node.id?.name) className = node.id.name;
|
|
26
|
-
},
|
|
27
|
-
// 检测 this.loading / this._error / this._data?.length(empty 分支)
|
|
28
|
-
// 检测 if (this.loading) / if (this._error) / if (!this._data?.length)
|
|
29
|
-
MemberExpression(node) {
|
|
30
|
-
const propName = node.property?.name;
|
|
31
|
-
if (propName === 'loading') found.add('loading');
|
|
32
|
-
if (propName === '_error' || propName === 'error') found.add('error');
|
|
33
|
-
},
|
|
34
|
-
// 检测字符串中的 loading/error/empty(模板、注释、class 名)
|
|
35
|
-
Literal(node) {
|
|
36
|
-
if (typeof node.value === 'string' && /(loading|error|empty)/.test(node.value)) {
|
|
37
|
-
for (const s of REQUIRED) {
|
|
38
|
-
if (node.value.includes(s)) found.add(s);
|
|
39
|
-
}
|
|
40
|
-
}
|
|
41
|
-
},
|
|
42
|
-
TemplateElement(node) {
|
|
43
|
-
const raw = node.value?.raw || '';
|
|
44
|
-
for (const s of REQUIRED) {
|
|
45
|
-
if (raw.includes(s)) found.add(s);
|
|
46
|
-
}
|
|
47
|
-
},
|
|
48
|
-
Identifier(node) {
|
|
49
|
-
// _renderLoading / _renderError / _renderEmpty 方法名
|
|
50
|
-
const name = node.name || '';
|
|
51
|
-
if (/loading/i.test(name)) found.add('loading');
|
|
52
|
-
if (/error/i.test(name)) found.add('error');
|
|
53
|
-
if (/empty/i.test(name)) found.add('empty');
|
|
54
|
-
},
|
|
55
|
-
'Program:exit'() {
|
|
56
|
-
for (const s of REQUIRED) {
|
|
57
|
-
if (!found.has(s)) {
|
|
58
|
-
context.report({ loc: { line: 1, column: 0 }, messageId: 'missing', data: { name: className, state: s } });
|
|
59
|
-
}
|
|
60
|
-
}
|
|
61
|
-
},
|
|
62
|
-
};
|
|
63
|
-
},
|
|
64
|
-
};
|
|
1
|
+
// L3.5 af-mobile/wc-block-states(error)
|
|
2
|
+
// 检测:Block 类的 mounted/render/_render 方法必须包含 loading/error/empty 分支
|
|
3
|
+
// 五态:idle(默认)/ loading / error / empty / success
|
|
4
|
+
// 检测策略:源码文本扫描,找 loading / error / empty 三个关键词至少各出现一次(在条件分支中)
|
|
5
|
+
// 适用:src/blocks/**/*.js
|
|
6
|
+
export default {
|
|
7
|
+
meta: {
|
|
8
|
+
type: 'problem',
|
|
9
|
+
docs: { description: 'Block 必须实现 loading/error/empty 三态分支' },
|
|
10
|
+
schema: [],
|
|
11
|
+
messages: {
|
|
12
|
+
missing: "Block '{{name}}' missing state '{{state}}'; must implement loading/error/empty branches in render",
|
|
13
|
+
},
|
|
14
|
+
},
|
|
15
|
+
create(context) {
|
|
16
|
+
const filename = context.filename || context.getFilename();
|
|
17
|
+
if (!/src[\\/]blocks[\\/].*\.js$/.test(filename)) return {};
|
|
18
|
+
|
|
19
|
+
const REQUIRED = ['loading', 'error', 'empty'];
|
|
20
|
+
const found = new Set();
|
|
21
|
+
let className = 'unknown';
|
|
22
|
+
|
|
23
|
+
return {
|
|
24
|
+
ClassDeclaration(node) {
|
|
25
|
+
if (node.id?.name) className = node.id.name;
|
|
26
|
+
},
|
|
27
|
+
// 检测 this.loading / this._error / this._data?.length(empty 分支)
|
|
28
|
+
// 检测 if (this.loading) / if (this._error) / if (!this._data?.length)
|
|
29
|
+
MemberExpression(node) {
|
|
30
|
+
const propName = node.property?.name;
|
|
31
|
+
if (propName === 'loading') found.add('loading');
|
|
32
|
+
if (propName === '_error' || propName === 'error') found.add('error');
|
|
33
|
+
},
|
|
34
|
+
// 检测字符串中的 loading/error/empty(模板、注释、class 名)
|
|
35
|
+
Literal(node) {
|
|
36
|
+
if (typeof node.value === 'string' && /(loading|error|empty)/.test(node.value)) {
|
|
37
|
+
for (const s of REQUIRED) {
|
|
38
|
+
if (node.value.includes(s)) found.add(s);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
},
|
|
42
|
+
TemplateElement(node) {
|
|
43
|
+
const raw = node.value?.raw || '';
|
|
44
|
+
for (const s of REQUIRED) {
|
|
45
|
+
if (raw.includes(s)) found.add(s);
|
|
46
|
+
}
|
|
47
|
+
},
|
|
48
|
+
Identifier(node) {
|
|
49
|
+
// _renderLoading / _renderError / _renderEmpty 方法名
|
|
50
|
+
const name = node.name || '';
|
|
51
|
+
if (/loading/i.test(name)) found.add('loading');
|
|
52
|
+
if (/error/i.test(name)) found.add('error');
|
|
53
|
+
if (/empty/i.test(name)) found.add('empty');
|
|
54
|
+
},
|
|
55
|
+
'Program:exit'() {
|
|
56
|
+
for (const s of REQUIRED) {
|
|
57
|
+
if (!found.has(s)) {
|
|
58
|
+
context.report({ loc: { line: 1, column: 0 }, messageId: 'missing', data: { name: className, state: s } });
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
},
|
|
64
|
+
};
|
|
@@ -1,83 +1,83 @@
|
|
|
1
|
-
// L3.5 af-mobile/wc-block-variant-enum(warn)
|
|
2
|
-
// 检测:Block 类的 variant 属性必须在 defineProp 时声明枚举值,或在 onAttributeChange 中校验
|
|
3
|
-
// 策略:检测 defineProp(proto, 'variant', { ... }) 调用,检查是否有 enum / values / 一行注释枚举
|
|
4
|
-
// 或:onAttributeChange 内有 variant 的 if/switch 校验
|
|
5
|
-
// 适用:src/blocks/**/*.js
|
|
6
|
-
export default {
|
|
7
|
-
meta: {
|
|
8
|
-
type: 'suggestion',
|
|
9
|
-
docs: { description: 'variant 属性必须限制枚举值' },
|
|
10
|
-
schema: [],
|
|
11
|
-
messages: {
|
|
12
|
-
noEnum: "Block variant property has no enum constraint; declare allowed values via comment or validate in onAttributeChange",
|
|
13
|
-
},
|
|
14
|
-
},
|
|
15
|
-
create(context) {
|
|
16
|
-
const filename = context.filename || context.getFilename();
|
|
17
|
-
if (!/src[\\/]blocks[\\/].*\.js$/.test(filename)) return {};
|
|
18
|
-
|
|
19
|
-
const sourceCode = context.sourceCode || context.getSourceCode();
|
|
20
|
-
const source = sourceCode.getText();
|
|
21
|
-
let hasVariantProp = false;
|
|
22
|
-
let variantEnumDeclared = false;
|
|
23
|
-
|
|
24
|
-
return {
|
|
25
|
-
CallExpression(node) {
|
|
26
|
-
const callee = node.callee;
|
|
27
|
-
if (callee.type !== 'MemberExpression' || callee.property?.name !== 'defineProp') return;
|
|
28
|
-
const arg = node.arguments[1];
|
|
29
|
-
if (arg?.type !== 'Literal' || arg.value !== 'variant') return;
|
|
30
|
-
hasVariantProp = true;
|
|
31
|
-
|
|
32
|
-
// 检查 defineProp 第三参数是否有 enum/values 字段
|
|
33
|
-
const optsArg = node.arguments[2];
|
|
34
|
-
if (optsArg?.type === 'ObjectExpression') {
|
|
35
|
-
for (const prop of optsArg.properties) {
|
|
36
|
-
if (prop.type === 'Property' && (prop.key?.name === 'enum' || prop.key?.name === 'values')) {
|
|
37
|
-
variantEnumDeclared = true;
|
|
38
|
-
return;
|
|
39
|
-
}
|
|
40
|
-
}
|
|
41
|
-
}
|
|
42
|
-
// 检查上方注释是否含枚举值(// variant: phone-code / password / sms)
|
|
43
|
-
const line = node.loc.start.line;
|
|
44
|
-
const commentLines = [];
|
|
45
|
-
for (let i = line - 1; i >= 1; i--) {
|
|
46
|
-
const l = sourceCode.lines[i - 1];
|
|
47
|
-
if (l.trim() === '') break;
|
|
48
|
-
if (/\bvariant\b.*\//.test(l) || /variant\s*:/i.test(l)) {
|
|
49
|
-
commentLines.push(l);
|
|
50
|
-
break;
|
|
51
|
-
}
|
|
52
|
-
if (l.trim().startsWith('//')) { commentLines.push(l); break; }
|
|
53
|
-
break;
|
|
54
|
-
}
|
|
55
|
-
if (commentLines.some(l => /variant\s*:?\s*[a-z-]+(\s*\/\s*[a-z-]+)+/i.test(l))) {
|
|
56
|
-
variantEnumDeclared = true;
|
|
57
|
-
}
|
|
58
|
-
},
|
|
59
|
-
// 检测 onAttributeChange 内是否校验 variant
|
|
60
|
-
MethodDefinition(node) {
|
|
61
|
-
if (node.key?.name !== 'onAttributeChange') return;
|
|
62
|
-
const bodyText = sourceCode.getText(node.value);
|
|
63
|
-
if (/variant/.test(bodyText) && /(if|switch)/.test(bodyText)) {
|
|
64
|
-
variantEnumDeclared = true;
|
|
65
|
-
}
|
|
66
|
-
},
|
|
67
|
-
// 赋值形式:AfFoo.prototype.onAttributeChange = function (name) { ... }
|
|
68
|
-
AssignmentExpression(node) {
|
|
69
|
-
const left = node.left;
|
|
70
|
-
if (left?.type !== 'MemberExpression' || left.property?.name !== 'onAttributeChange') return;
|
|
71
|
-
const bodyText = sourceCode.getText(node.right);
|
|
72
|
-
if (/variant/.test(bodyText) && /(if|switch)/.test(bodyText)) {
|
|
73
|
-
variantEnumDeclared = true;
|
|
74
|
-
}
|
|
75
|
-
},
|
|
76
|
-
'Program:exit'() {
|
|
77
|
-
if (hasVariantProp && !variantEnumDeclared) {
|
|
78
|
-
context.report({ loc: { line: 1, column: 0 }, messageId: 'noEnum' });
|
|
79
|
-
}
|
|
80
|
-
},
|
|
81
|
-
};
|
|
82
|
-
},
|
|
83
|
-
};
|
|
1
|
+
// L3.5 af-mobile/wc-block-variant-enum(warn)
|
|
2
|
+
// 检测:Block 类的 variant 属性必须在 defineProp 时声明枚举值,或在 onAttributeChange 中校验
|
|
3
|
+
// 策略:检测 defineProp(proto, 'variant', { ... }) 调用,检查是否有 enum / values / 一行注释枚举
|
|
4
|
+
// 或:onAttributeChange 内有 variant 的 if/switch 校验
|
|
5
|
+
// 适用:src/blocks/**/*.js
|
|
6
|
+
export default {
|
|
7
|
+
meta: {
|
|
8
|
+
type: 'suggestion',
|
|
9
|
+
docs: { description: 'variant 属性必须限制枚举值' },
|
|
10
|
+
schema: [],
|
|
11
|
+
messages: {
|
|
12
|
+
noEnum: "Block variant property has no enum constraint; declare allowed values via comment or validate in onAttributeChange",
|
|
13
|
+
},
|
|
14
|
+
},
|
|
15
|
+
create(context) {
|
|
16
|
+
const filename = context.filename || context.getFilename();
|
|
17
|
+
if (!/src[\\/]blocks[\\/].*\.js$/.test(filename)) return {};
|
|
18
|
+
|
|
19
|
+
const sourceCode = context.sourceCode || context.getSourceCode();
|
|
20
|
+
const source = sourceCode.getText();
|
|
21
|
+
let hasVariantProp = false;
|
|
22
|
+
let variantEnumDeclared = false;
|
|
23
|
+
|
|
24
|
+
return {
|
|
25
|
+
CallExpression(node) {
|
|
26
|
+
const callee = node.callee;
|
|
27
|
+
if (callee.type !== 'MemberExpression' || callee.property?.name !== 'defineProp') return;
|
|
28
|
+
const arg = node.arguments[1];
|
|
29
|
+
if (arg?.type !== 'Literal' || arg.value !== 'variant') return;
|
|
30
|
+
hasVariantProp = true;
|
|
31
|
+
|
|
32
|
+
// 检查 defineProp 第三参数是否有 enum/values 字段
|
|
33
|
+
const optsArg = node.arguments[2];
|
|
34
|
+
if (optsArg?.type === 'ObjectExpression') {
|
|
35
|
+
for (const prop of optsArg.properties) {
|
|
36
|
+
if (prop.type === 'Property' && (prop.key?.name === 'enum' || prop.key?.name === 'values')) {
|
|
37
|
+
variantEnumDeclared = true;
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
// 检查上方注释是否含枚举值(// variant: phone-code / password / sms)
|
|
43
|
+
const line = node.loc.start.line;
|
|
44
|
+
const commentLines = [];
|
|
45
|
+
for (let i = line - 1; i >= 1; i--) {
|
|
46
|
+
const l = sourceCode.lines[i - 1];
|
|
47
|
+
if (l.trim() === '') break;
|
|
48
|
+
if (/\bvariant\b.*\//.test(l) || /variant\s*:/i.test(l)) {
|
|
49
|
+
commentLines.push(l);
|
|
50
|
+
break;
|
|
51
|
+
}
|
|
52
|
+
if (l.trim().startsWith('//')) { commentLines.push(l); break; }
|
|
53
|
+
break;
|
|
54
|
+
}
|
|
55
|
+
if (commentLines.some(l => /variant\s*:?\s*[a-z-]+(\s*\/\s*[a-z-]+)+/i.test(l))) {
|
|
56
|
+
variantEnumDeclared = true;
|
|
57
|
+
}
|
|
58
|
+
},
|
|
59
|
+
// 检测 onAttributeChange 内是否校验 variant
|
|
60
|
+
MethodDefinition(node) {
|
|
61
|
+
if (node.key?.name !== 'onAttributeChange') return;
|
|
62
|
+
const bodyText = sourceCode.getText(node.value);
|
|
63
|
+
if (/variant/.test(bodyText) && /(if|switch)/.test(bodyText)) {
|
|
64
|
+
variantEnumDeclared = true;
|
|
65
|
+
}
|
|
66
|
+
},
|
|
67
|
+
// 赋值形式:AfFoo.prototype.onAttributeChange = function (name) { ... }
|
|
68
|
+
AssignmentExpression(node) {
|
|
69
|
+
const left = node.left;
|
|
70
|
+
if (left?.type !== 'MemberExpression' || left.property?.name !== 'onAttributeChange') return;
|
|
71
|
+
const bodyText = sourceCode.getText(node.right);
|
|
72
|
+
if (/variant/.test(bodyText) && /(if|switch)/.test(bodyText)) {
|
|
73
|
+
variantEnumDeclared = true;
|
|
74
|
+
}
|
|
75
|
+
},
|
|
76
|
+
'Program:exit'() {
|
|
77
|
+
if (hasVariantProp && !variantEnumDeclared) {
|
|
78
|
+
context.report({ loc: { line: 1, column: 0 }, messageId: 'noEnum' });
|
|
79
|
+
}
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
},
|
|
83
|
+
};
|