@af-mobile/eslint-plugin 2.1.0 → 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.
@@ -1,75 +1,75 @@
1
- // L3-6 af-mobile/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
- };
1
+ // L3-6 af-mobile/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
+ };
@@ -1,60 +1,60 @@
1
- // L3-4 af-mobile/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
- if (fixed.startsWith('af-')) {
36
- // af-xxx-yyy → af-xxx:yyy(首个 - 起视为动作;动作段的 - 去除,保证 [a-z]+ 格式)
37
- const rest = fixed.slice(3);
38
- const dash = rest.indexOf('-');
39
- if (dash > 0) {
40
- fixed = `af-${rest.slice(0, dash)}:${rest.slice(dash + 1).replace(/-/g, '')}`;
41
- } else {
42
- fixed = 'af-component:' + rest;
43
- }
44
- } else {
45
- // 无 af- 前缀,添加 af- 前缀 + 冒号
46
- fixed = 'af-component:' + fixed.replace(/-/g, '');
47
- }
48
- }
49
- context.report({
50
- node: arg,
51
- messageId: 'naming',
52
- data: { name },
53
- fix(fixer) {
54
- return fixer.replaceText(arg, `'${fixed}'`);
55
- },
56
- });
57
- },
58
- };
59
- },
60
- };
1
+ // L3-4 af-mobile/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
+ if (fixed.startsWith('af-')) {
36
+ // af-xxx-yyy → af-xxx:yyy(首个 - 起视为动作;动作段的 - 去除,保证 [a-z]+ 格式)
37
+ const rest = fixed.slice(3);
38
+ const dash = rest.indexOf('-');
39
+ if (dash > 0) {
40
+ fixed = `af-${rest.slice(0, dash)}:${rest.slice(dash + 1).replace(/-/g, '')}`;
41
+ } else {
42
+ fixed = 'af-component:' + rest;
43
+ }
44
+ } else {
45
+ // 无 af- 前缀,添加 af- 前缀 + 冒号
46
+ fixed = 'af-component:' + fixed.replace(/-/g, '');
47
+ }
48
+ }
49
+ context.report({
50
+ node: arg,
51
+ messageId: 'naming',
52
+ data: { name },
53
+ fix(fixer) {
54
+ return fixer.replaceText(arg, `'${fixed}'`);
55
+ },
56
+ });
57
+ },
58
+ };
59
+ },
60
+ };
@@ -1,87 +1,87 @@
1
- // L3-1 af-mobile/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
- };
1
+ // L3-1 af-mobile/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
+ };
@@ -1,33 +1,33 @@
1
- // L3-3 af-mobile/wc-part-naming(warn)
2
- // 检测:part="xxx" 属性名非 kebab-case
3
- const KEBAB_RE = /^[a-z]+(-[a-z0-9]+)*$/;
4
-
5
- export default {
6
- meta: {
7
- type: 'suggestion',
8
- docs: { description: 'part 属性名必须 kebab-case' },
9
- schema: [],
10
- messages: {
11
- naming: "Part name '{{name}}' should be kebab-case (e.g. 'dialog-content')",
12
- },
13
- },
14
- create(context) {
15
- const filename = context.filename || context.getFilename();
16
- if (!/src[\\/](?:charts[\\/])?components[\\/].*\.js$/.test(filename)) return {};
17
-
18
- function checkString(str, node) {
19
- const re = /part\s*=\s*"([^"]*)"/g;
20
- let m;
21
- while ((m = re.exec(str))) {
22
- const partName = m[1].trim();
23
- if (partName && !KEBAB_RE.test(partName)) {
24
- context.report({ node, messageId: 'naming', data: { name: partName } });
25
- }
26
- }
27
- }
28
- return {
29
- Literal(node) { if (typeof node.value === 'string') checkString(node.value, node); },
30
- TemplateElement(node) { if (node.value?.raw) checkString(node.value.raw, node); },
31
- };
32
- },
33
- };
1
+ // L3-3 af-mobile/wc-part-naming(warn)
2
+ // 检测:part="xxx" 属性名非 kebab-case
3
+ const KEBAB_RE = /^[a-z]+(-[a-z0-9]+)*$/;
4
+
5
+ export default {
6
+ meta: {
7
+ type: 'suggestion',
8
+ docs: { description: 'part 属性名必须 kebab-case' },
9
+ schema: [],
10
+ messages: {
11
+ naming: "Part name '{{name}}' should be kebab-case (e.g. 'dialog-content')",
12
+ },
13
+ },
14
+ create(context) {
15
+ const filename = context.filename || context.getFilename();
16
+ if (!/src[\\/](?:charts[\\/])?components[\\/].*\.js$/.test(filename)) return {};
17
+
18
+ function checkString(str, node) {
19
+ const re = /part\s*=\s*"([^"]*)"/g;
20
+ let m;
21
+ while ((m = re.exec(str))) {
22
+ const partName = m[1].trim();
23
+ if (partName && !KEBAB_RE.test(partName)) {
24
+ context.report({ node, messageId: 'naming', data: { name: partName } });
25
+ }
26
+ }
27
+ }
28
+ return {
29
+ Literal(node) { if (typeof node.value === 'string') checkString(node.value, node); },
30
+ TemplateElement(node) { if (node.value?.raw) checkString(node.value.raw, node); },
31
+ };
32
+ },
33
+ };