@af-mobile/eslint-plugin 2.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.
- package/index.js +95 -0
- package/package.json +31 -0
- package/rules/atomic-duplicate.js +60 -0
- package/rules/no-arbitrary-value.js +52 -0
- package/rules/no-inline-style.js +127 -0
- package/rules/no-recipe-break.js +52 -0
- package/rules/no-register-all.js +29 -0
- package/rules/no-tailwind-syntax.js +31 -0
- package/rules/no-token-modification.js +92 -0
- package/rules/no-variant-conflict.js +70 -0
- package/rules/prefer-component.js +58 -0
- package/rules/token-whitelist.js +78 -0
- package/rules/wc-aria-required.js +60 -0
- package/rules/wc-block-no-internal-ref.js +59 -0
- package/rules/wc-block-props-count.js +48 -0
- package/rules/wc-block-states.js +64 -0
- package/rules/wc-block-variant-enum.js +74 -0
- package/rules/wc-cleanup.js +75 -0
- package/rules/wc-event-naming.js +55 -0
- package/rules/wc-light-no-style.js +87 -0
- package/rules/wc-part-naming.js +33 -0
- package/rules/wc-shadow-use-token.js +72 -0
- package/utils/aria-requirements.json +87 -0
- package/utils/helpers.js +55 -0
- package/utils/whitelist-v1.json +278 -0
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// L2-6 aiflow/prefer-component(warn)
|
|
2
|
+
// 检测:(a) .toast class + setTimeout / (b) .sheet class vs <af-action-sheet> / (c) .list class + scroll 监听
|
|
3
|
+
import { extractAllClassLists } from '../utils/helpers.js';
|
|
4
|
+
|
|
5
|
+
export default {
|
|
6
|
+
meta: {
|
|
7
|
+
type: 'suggestion',
|
|
8
|
+
docs: { description: '建议使用 L3 组件替代手动实现' },
|
|
9
|
+
schema: [],
|
|
10
|
+
messages: {
|
|
11
|
+
toast: "Manual .toast + setTimeout detected, prefer <af-toast> for singleton queue / auto-dismiss / aria-live",
|
|
12
|
+
sheet: "Manual .sheet class detected, prefer <af-action-sheet> for touch-friendly bottom sheet / gesture dismiss",
|
|
13
|
+
list: "Manual .list + scroll listener detected, prefer <af-list> for virtual scroll / pull refresh / load more",
|
|
14
|
+
},
|
|
15
|
+
},
|
|
16
|
+
create(context) {
|
|
17
|
+
const filename = context.filename || context.getFilename();
|
|
18
|
+
// 组件源码自身实现不适用此规则(避免 af-list.js 报"prefer <af-list>"的自指误报)
|
|
19
|
+
if (/src[\\/](?:charts[\\/])?components[\\/].*\.js$/.test(filename)) return {};
|
|
20
|
+
const sourceCode = context.sourceCode || context.getSourceCode();
|
|
21
|
+
let hasToast = false, hasSheet = false, hasList = false;
|
|
22
|
+
let hasSetTimeout = false, hasScrollListener = false;
|
|
23
|
+
|
|
24
|
+
function scan(str) {
|
|
25
|
+
for (const { classes } of extractAllClassLists(str)) {
|
|
26
|
+
if (classes.includes('toast')) hasToast = true;
|
|
27
|
+
if (classes.includes('sheet')) hasSheet = true;
|
|
28
|
+
if (classes.includes('list')) hasList = true;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return {
|
|
33
|
+
Literal(node) { if (typeof node.value === 'string') scan(node.value); },
|
|
34
|
+
TemplateElement(node) { if (node.value?.raw) scan(node.value.raw); },
|
|
35
|
+
CallExpression(node) {
|
|
36
|
+
// setTimeout 检测
|
|
37
|
+
const callee = node.callee;
|
|
38
|
+
if (callee.type === 'Identifier' && callee.name === 'setTimeout') hasSetTimeout = true;
|
|
39
|
+
// addEventListener('scroll', ...) 检测
|
|
40
|
+
if (callee.type === 'MemberExpression' && callee.property?.name === 'addEventListener') {
|
|
41
|
+
const arg = node.arguments[0];
|
|
42
|
+
if (arg?.value === 'scroll') hasScrollListener = true;
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
'Program:exit'() {
|
|
46
|
+
if (hasToast && hasSetTimeout) {
|
|
47
|
+
context.report({ loc: { line: 1, column: 0 }, messageId: 'toast' });
|
|
48
|
+
}
|
|
49
|
+
if (hasSheet) {
|
|
50
|
+
context.report({ loc: { line: 1, column: 0 }, messageId: 'sheet' });
|
|
51
|
+
}
|
|
52
|
+
if (hasList && hasScrollListener) {
|
|
53
|
+
context.report({ loc: { line: 1, column: 0 }, messageId: 'list' });
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
},
|
|
58
|
+
};
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// L2-1 aiflow/token-whitelist(error)
|
|
2
|
+
// 检测:class="" 中的 class / 自定义元素 tagName → 与 whitelist + extraClass/extraComponents 对比
|
|
3
|
+
import { ALL_CLASSES, ALL_COMPONENTS, extractAllClassLists, extractCustomElements } from '../utils/helpers.js';
|
|
4
|
+
|
|
5
|
+
export default {
|
|
6
|
+
meta: {
|
|
7
|
+
type: 'problem',
|
|
8
|
+
docs: { description: 'class 名和自定义元素必须在白名单内' },
|
|
9
|
+
schema: [{
|
|
10
|
+
type: 'object',
|
|
11
|
+
properties: {
|
|
12
|
+
extraClass: { type: 'array', items: { type: 'string' } },
|
|
13
|
+
extraComponents: { type: 'array', items: { type: 'string' } },
|
|
14
|
+
},
|
|
15
|
+
additionalProperties: false,
|
|
16
|
+
}],
|
|
17
|
+
messages: {
|
|
18
|
+
unknownClass: "Class '{{name}}' not in whitelist. Use recipe/atomic or register in 'aiflow/token-whitelist' rule's extraClass",
|
|
19
|
+
unknownComponent: "Component '{{name}}' not in whitelist. Register in 'aiflow/token-whitelist' rule's extraComponents",
|
|
20
|
+
},
|
|
21
|
+
},
|
|
22
|
+
create(context) {
|
|
23
|
+
const options = context.options[0] || {};
|
|
24
|
+
const allowClass = new Set([...ALL_CLASSES, ...(options.extraClass || [])]);
|
|
25
|
+
const allowComp = new Set([...ALL_COMPONENTS, ...(options.extraComponents || [])]);
|
|
26
|
+
|
|
27
|
+
function checkString(str, node) {
|
|
28
|
+
// class 检查
|
|
29
|
+
for (const { classes } of extractAllClassLists(str)) {
|
|
30
|
+
for (const cls of classes) {
|
|
31
|
+
if (!allowClass.has(cls)) {
|
|
32
|
+
context.report({ node, messageId: 'unknownClass', data: { name: cls } });
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
// 自定义元素检查
|
|
37
|
+
for (const tag of extractCustomElements(str)) {
|
|
38
|
+
if (!allowComp.has(tag)) {
|
|
39
|
+
context.report({ node, messageId: 'unknownComponent', data: { name: tag } });
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return {
|
|
45
|
+
Literal(node) { if (typeof node.value === 'string') checkString(node.value, node); },
|
|
46
|
+
TemplateElement(node) { if (node.value?.raw) checkString(node.value.raw, node); },
|
|
47
|
+
// 检测 classList.add/remove/toggle 的字符串参数(绕过 class="..." 字面量检测)
|
|
48
|
+
CallExpression(node) {
|
|
49
|
+
const callee = node.callee;
|
|
50
|
+
if (callee?.type !== 'MemberExpression') return;
|
|
51
|
+
if (callee.object?.type !== 'MemberExpression') return;
|
|
52
|
+
if (callee.object.property?.name !== 'classList') return;
|
|
53
|
+
const method = callee.property?.name;
|
|
54
|
+
if (!['add', 'remove', 'toggle'].includes(method)) return;
|
|
55
|
+
for (const arg of node.arguments) {
|
|
56
|
+
if (arg.type === 'Literal' && typeof arg.value === 'string') {
|
|
57
|
+
for (const cls of arg.value.split(/\s+/).filter(Boolean)) {
|
|
58
|
+
if (!allowClass.has(cls)) {
|
|
59
|
+
context.report({ node: arg, messageId: 'unknownClass', data: { name: cls } });
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
if (arg.type === 'ArrayExpression') {
|
|
64
|
+
for (const el of arg.elements) {
|
|
65
|
+
if (el?.type === 'Literal' && typeof el.value === 'string') {
|
|
66
|
+
for (const cls of el.value.split(/\s+/).filter(Boolean)) {
|
|
67
|
+
if (!allowClass.has(cls)) {
|
|
68
|
+
context.report({ node: el, messageId: 'unknownClass', data: { name: cls } });
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
},
|
|
78
|
+
};
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// L3-5 aiflow/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[\\/])?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
|
+
};
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// L3.5 aiflow/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
|
+
const PENETRATE_RE = /af-[a-z]+(?:[>+~]|\s+[^,)\]]+)/;
|
|
22
|
+
|
|
23
|
+
return {
|
|
24
|
+
CallExpression(node) {
|
|
25
|
+
const callee = node.callee;
|
|
26
|
+
// 匹配 xxx.querySelector(...) / querySelectorAll(...)
|
|
27
|
+
if (callee.type !== 'MemberExpression') return;
|
|
28
|
+
const propName = callee.property?.name;
|
|
29
|
+
if (propName !== 'querySelector' && propName !== 'querySelectorAll') return;
|
|
30
|
+
|
|
31
|
+
const selArg = node.arguments[0];
|
|
32
|
+
if (!selArg) return;
|
|
33
|
+
let sel = '';
|
|
34
|
+
if (selArg.type === 'Literal' && typeof selArg.value === 'string') sel = selArg.value;
|
|
35
|
+
else if (selArg.type === 'TemplateLiteral' && selArg.quasis.length === 1) sel = selArg.quasis[0].value.raw;
|
|
36
|
+
|
|
37
|
+
// 穿透检测:af-xxx > div / af-xxx div / af-xxx > .foo
|
|
38
|
+
if (sel && PENETRATE_RE.test(sel)) {
|
|
39
|
+
context.report({ node: selArg, messageId: 'childSelector', data: { sel } });
|
|
40
|
+
}
|
|
41
|
+
},
|
|
42
|
+
// 检测 blockInstance.shadowRoot 访问
|
|
43
|
+
MemberExpression(node) {
|
|
44
|
+
// 形如 xxx.shadowRoot
|
|
45
|
+
if (node.property?.type === 'Identifier' && node.property.name === 'shadowRoot') {
|
|
46
|
+
// 只对 af-* 开头的变量名报错(近似 Block 实例)
|
|
47
|
+
const obj = node.object;
|
|
48
|
+
let name = '';
|
|
49
|
+
if (obj.type === 'Identifier') name = obj.name;
|
|
50
|
+
else if (obj.type === 'MemberExpression' && obj.property?.name) name = obj.property.name;
|
|
51
|
+
// 启发式:变量名含 af 或块名关键词视为 Block 实例
|
|
52
|
+
if (/af|block|form|grid|card|list/i.test(name)) {
|
|
53
|
+
context.report({ node: node.property, messageId: 'shadowRoot', data: { name } });
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
},
|
|
59
|
+
};
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// L3.5 aiflow/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
|
+
};
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// L3.5 aiflow/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
|
+
};
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// L3.5 aiflow/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
|
+
'Program:exit'() {
|
|
68
|
+
if (hasVariantProp && !variantEnumDeclared) {
|
|
69
|
+
context.report({ loc: { line: 1, column: 0 }, messageId: 'noEnum' });
|
|
70
|
+
}
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
},
|
|
74
|
+
};
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// L3-6 aiflow/wc-cleanup(warn)
|
|
2
|
+
// 检测两类资源泄漏(覆盖 src/components 与 src/blocks):
|
|
3
|
+
// 1) addEventListener 未通过 this._listen() 登记(传 signal 选项的原生自动解绑除外)
|
|
4
|
+
// —— 基类 disconnectedCallback 统一解绑 _listeners,绕过登记即泄漏
|
|
5
|
+
// 2) IntersectionObserver/ResizeObserver/MutationObserver/setTimeout/setInterval/rAF
|
|
6
|
+
// 在 unmounted() 中无对应清理调用
|
|
7
|
+
const TIMERS = [
|
|
8
|
+
{ name: 'setTimeout', cleanup: 'clearTimeout' },
|
|
9
|
+
{ name: 'setInterval', cleanup: 'clearInterval' },
|
|
10
|
+
{ name: 'requestAnimationFrame', cleanup: 'cancelAnimationFrame' },
|
|
11
|
+
];
|
|
12
|
+
const OBSERVERS = ['IntersectionObserver', 'ResizeObserver', 'MutationObserver'];
|
|
13
|
+
|
|
14
|
+
export default {
|
|
15
|
+
meta: {
|
|
16
|
+
type: 'problem',
|
|
17
|
+
docs: { description: '组件资源清理:addEventListener 走 _listen,观察器/定时器在 unmounted() 清理' },
|
|
18
|
+
schema: [],
|
|
19
|
+
messages: {
|
|
20
|
+
listen: 'addEventListener 需通过 this._listen(target, type, handler) 登记,由基类在断开时统一解绑(AbortController 场景可传 { signal })',
|
|
21
|
+
leak: '{{name}} 需在 unmounted() 中调用 {{cleanup}},否则潜在内存泄漏',
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
create(context) {
|
|
25
|
+
const filename = context.filename || context.getFilename();
|
|
26
|
+
// 覆盖 src/components、src/blocks 与 charts 子库 src/charts/components
|
|
27
|
+
if (!/src[\\/](?:charts[\\/])?(?:components|blocks)[\\/].*\.js$/.test(filename)) return {};
|
|
28
|
+
|
|
29
|
+
const sourceCode = context.sourceCode || context.getSourceCode();
|
|
30
|
+
const leaks = []; // { node, name, cleanup }
|
|
31
|
+
|
|
32
|
+
const hasSignal = (args) => args.some((a) => a.type === 'ObjectExpression'
|
|
33
|
+
&& a.properties.some((p) => p.key?.name === 'signal'));
|
|
34
|
+
|
|
35
|
+
return {
|
|
36
|
+
CallExpression(node) {
|
|
37
|
+
const callee = node.callee;
|
|
38
|
+
if (callee.type === 'MemberExpression' && callee.property?.name === 'addEventListener') {
|
|
39
|
+
if (!hasSignal(node.arguments)) context.report({ node, messageId: 'listen' });
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
if (callee.type === 'Identifier') {
|
|
43
|
+
const p = TIMERS.find((t) => t.name === callee.name);
|
|
44
|
+
if (p) leaks.push({ node, name: p.name, cleanup: p.cleanup });
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
NewExpression(node) {
|
|
48
|
+
if (OBSERVERS.includes(node.callee?.name)) {
|
|
49
|
+
leaks.push({ node, name: node.callee.name, cleanup: 'disconnect' });
|
|
50
|
+
}
|
|
51
|
+
},
|
|
52
|
+
'Program:exit'() {
|
|
53
|
+
// 提取 unmounted() 方法体文本(括号计数,支持任意嵌套)
|
|
54
|
+
const source = sourceCode.getText();
|
|
55
|
+
let unmountedBody = '';
|
|
56
|
+
const startMatch = source.match(/unmounted\s*\(\s*\)\s*\{/);
|
|
57
|
+
if (startMatch) {
|
|
58
|
+
let depth = 1;
|
|
59
|
+
let i = startMatch.index + startMatch[0].length;
|
|
60
|
+
while (i < source.length && depth > 0) {
|
|
61
|
+
if (source[i] === '{') depth++;
|
|
62
|
+
else if (source[i] === '}') depth--;
|
|
63
|
+
if (depth > 0) unmountedBody += source[i];
|
|
64
|
+
i++;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
for (const l of leaks) {
|
|
68
|
+
if (!unmountedBody.includes(l.cleanup)) {
|
|
69
|
+
context.report({ node: l.node, messageId: 'leak', data: { name: l.name, cleanup: l.cleanup } });
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
},
|
|
75
|
+
};
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// L3-4 aiflow/wc-event-naming(error,可自动修)
|
|
2
|
+
// 检测:emit('xxx') 调用名不匹配 /^af-[a-z0-9]+:[a-z]+$/
|
|
3
|
+
const EVENT_RE = /^af-[a-z0-9-]+:[a-z]+$/;
|
|
4
|
+
|
|
5
|
+
export default {
|
|
6
|
+
meta: {
|
|
7
|
+
type: 'problem',
|
|
8
|
+
docs: { description: '事件名必须匹配 af-{component}:{action} 格式' },
|
|
9
|
+
schema: [],
|
|
10
|
+
messages: {
|
|
11
|
+
naming: "Event name '{{name}}' should match 'af-{component}:{action}' (e.g. 'af-list:loadmore')",
|
|
12
|
+
},
|
|
13
|
+
fixable: 'code',
|
|
14
|
+
},
|
|
15
|
+
create(context) {
|
|
16
|
+
return {
|
|
17
|
+
CallExpression(node) {
|
|
18
|
+
// 检测 emit('xxx') 或 this.emit('xxx')
|
|
19
|
+
const callee = node.callee;
|
|
20
|
+
const isEmit = (callee.type === 'Identifier' && callee.name === 'emit') ||
|
|
21
|
+
(callee.type === 'MemberExpression' && callee.property?.name === 'emit');
|
|
22
|
+
if (!isEmit) return;
|
|
23
|
+
const arg = node.arguments[0];
|
|
24
|
+
if (arg?.type !== 'Literal' || typeof arg.value !== 'string') return;
|
|
25
|
+
const name = arg.value;
|
|
26
|
+
if (EVENT_RE.test(name)) return;
|
|
27
|
+
// 自动修:尝试转换为 af-xxx:yyy 格式
|
|
28
|
+
let fixed = name;
|
|
29
|
+
// snake_case / camelCase → kebab + colon
|
|
30
|
+
fixed = fixed.replace(/_/g, '-')
|
|
31
|
+
.replace(/([a-z])([A-Z])/g, '$1-$2')
|
|
32
|
+
.toLowerCase();
|
|
33
|
+
// 如果不含冒号,尝试在组件名后插入冒号
|
|
34
|
+
if (!fixed.includes(':')) {
|
|
35
|
+
// 检测 af- 前缀
|
|
36
|
+
if (fixed.startsWith('af-')) {
|
|
37
|
+
// 在 af-xxx 后插入 : (xxx 是第一个单词)
|
|
38
|
+
fixed = fixed.replace(/^af-([a-z0-9]+)/, 'af-$1:');
|
|
39
|
+
} else {
|
|
40
|
+
// 无 af- 前缀,添加 af- 前缀 + 冒号
|
|
41
|
+
fixed = 'af-component:' + fixed;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
context.report({
|
|
45
|
+
node: arg,
|
|
46
|
+
messageId: 'naming',
|
|
47
|
+
data: { name },
|
|
48
|
+
fix(fixer) {
|
|
49
|
+
return fixer.replaceText(arg, `'${fixed}'`);
|
|
50
|
+
},
|
|
51
|
+
});
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
},
|
|
55
|
+
};
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// L3-1 aiflow/wc-light-no-style(error)
|
|
2
|
+
// 检测:Light 组件(useShadow=false)中 .style.xxx 赋值 / innerHTML 含 <style> 标签 / innerHTML 含 style="..." 属性
|
|
3
|
+
export default {
|
|
4
|
+
meta: {
|
|
5
|
+
type: 'problem',
|
|
6
|
+
docs: { description: 'Light DOM 组件不能有内联样式,必须用 L2 class' },
|
|
7
|
+
schema: [],
|
|
8
|
+
messages: {
|
|
9
|
+
styleProp: "Light DOM component must use L2 recipe classes only. Custom styles → use Shadow component or recipes.project.css",
|
|
10
|
+
styleTag: "Light DOM component must not contain <style> tags. Move to Shadow component or recipes.project.css",
|
|
11
|
+
styleAttr: 'Light DOM component must not contain style="..." attributes in innerHTML. Use L2 recipe/atomic classes instead',
|
|
12
|
+
},
|
|
13
|
+
},
|
|
14
|
+
create(context) {
|
|
15
|
+
const filename = context.filename || context.getFilename();
|
|
16
|
+
// 仅检查 src/components/*.js 文件
|
|
17
|
+
if (!/src[\\/](?:charts[\\/])?components[\\/].*\.js$/.test(filename)) return {};
|
|
18
|
+
|
|
19
|
+
const sourceCode = context.sourceCode || context.getSourceCode();
|
|
20
|
+
const source = sourceCode.getText();
|
|
21
|
+
// 检测是否为 Light 组件(含 useShadow = false)
|
|
22
|
+
if (!/useShadow\s*=\s*false/.test(source)) return {};
|
|
23
|
+
|
|
24
|
+
// 检测 innerHTML / 模板字符串中的 style="..." 或 style='...' 属性
|
|
25
|
+
// 例外:style="--xxx:..."(CSS 自定义属性传递,非视觉属性,与 setProperty('--*') 同理)
|
|
26
|
+
const hasVisualStyleAttr = (str) => {
|
|
27
|
+
if (!str) return false;
|
|
28
|
+
// 匹配 style="..." 或 style='...',且值非 -- 开头(CSS 自定义属性放行)
|
|
29
|
+
const re = /\bstyle=(["'])([^"']*)\1/gi;
|
|
30
|
+
let m;
|
|
31
|
+
while ((m = re.exec(str))) {
|
|
32
|
+
const val = m[2];
|
|
33
|
+
// CSS 自定义属性(--xxx)放行:用于主题变量传递
|
|
34
|
+
if (val.trim().startsWith('--')) continue;
|
|
35
|
+
// 任何非空 style 属性都违规(视觉属性)
|
|
36
|
+
if (val.trim()) return true;
|
|
37
|
+
}
|
|
38
|
+
return false;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
return {
|
|
42
|
+
AssignmentExpression(node) {
|
|
43
|
+
// .style.xxx = 赋值
|
|
44
|
+
if (node.left?.type === 'MemberExpression' &&
|
|
45
|
+
node.left.object?.type === 'MemberExpression' &&
|
|
46
|
+
node.left.object.property?.name === 'style') {
|
|
47
|
+
context.report({ node, messageId: 'styleProp' });
|
|
48
|
+
}
|
|
49
|
+
},
|
|
50
|
+
CallExpression(node) {
|
|
51
|
+
// 检测 x.style.setProperty('prop', ...) 绕过:非 CSS 自定义属性(--*)的视觉属性
|
|
52
|
+
const callee = node.callee;
|
|
53
|
+
if (callee?.type !== 'MemberExpression') return;
|
|
54
|
+
if (callee.object?.type !== 'MemberExpression') return;
|
|
55
|
+
if (callee.object.property?.name !== 'style') return;
|
|
56
|
+
if (callee.property?.name !== 'setProperty') return;
|
|
57
|
+
const propArg = node.arguments?.[0];
|
|
58
|
+
if (!propArg || propArg.type !== 'Literal' || typeof propArg.value !== 'string') return;
|
|
59
|
+
// CSS 自定义属性(--*)允许:用于主题变量传递,非视觉属性
|
|
60
|
+
if (propArg.value.startsWith('--')) return;
|
|
61
|
+
context.report({ node, messageId: 'styleProp' });
|
|
62
|
+
},
|
|
63
|
+
Literal(node) {
|
|
64
|
+
if (typeof node.value !== 'string') return;
|
|
65
|
+
// <style> 标签
|
|
66
|
+
if (/<style[\s>]/i.test(node.value)) {
|
|
67
|
+
context.report({ node, messageId: 'styleTag' });
|
|
68
|
+
}
|
|
69
|
+
// style="..." 属性(视觉属性)
|
|
70
|
+
if (hasVisualStyleAttr(node.value)) {
|
|
71
|
+
context.report({ node, messageId: 'styleAttr' });
|
|
72
|
+
}
|
|
73
|
+
},
|
|
74
|
+
TemplateElement(node) {
|
|
75
|
+
if (!node.value?.raw) return;
|
|
76
|
+
// <style> 标签
|
|
77
|
+
if (/<style[\s>]/i.test(node.value.raw)) {
|
|
78
|
+
context.report({ node, messageId: 'styleTag' });
|
|
79
|
+
}
|
|
80
|
+
// style="..." 属性(视觉属性)
|
|
81
|
+
if (hasVisualStyleAttr(node.value.raw)) {
|
|
82
|
+
context.report({ node, messageId: 'styleAttr' });
|
|
83
|
+
}
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
},
|
|
87
|
+
};
|