@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
package/index.js
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// AIFlow UI —— eslint-plugin-aiflow 入口(ESLint 9 flat config 兼容)
|
|
2
|
+
// 20 条规则:L1(2) + L2(7) + L3(6) + L3.5(5)
|
|
3
|
+
// (definePage 消费端 7 条规则已随 definePage 全局单例移除)
|
|
4
|
+
import noTokenModification from './rules/no-token-modification.js';
|
|
5
|
+
import noInlineStyle from './rules/no-inline-style.js';
|
|
6
|
+
import tokenWhitelist from './rules/token-whitelist.js';
|
|
7
|
+
import noRecipeBreak from './rules/no-recipe-break.js';
|
|
8
|
+
import noVariantConflict from './rules/no-variant-conflict.js';
|
|
9
|
+
import noArbitraryValue from './rules/no-arbitrary-value.js';
|
|
10
|
+
import noTailwindSyntax from './rules/no-tailwind-syntax.js';
|
|
11
|
+
import preferComponent from './rules/prefer-component.js';
|
|
12
|
+
import atomicDuplicate from './rules/atomic-duplicate.js';
|
|
13
|
+
import noRegisterAll from './rules/no-register-all.js';
|
|
14
|
+
import wcLightNoStyle from './rules/wc-light-no-style.js';
|
|
15
|
+
import wcShadowUseToken from './rules/wc-shadow-use-token.js';
|
|
16
|
+
import wcPartNaming from './rules/wc-part-naming.js';
|
|
17
|
+
import wcEventNaming from './rules/wc-event-naming.js';
|
|
18
|
+
import wcAriaRequired from './rules/wc-aria-required.js';
|
|
19
|
+
import wcCleanup from './rules/wc-cleanup.js';
|
|
20
|
+
import wcBlockNoInternalRef from './rules/wc-block-no-internal-ref.js';
|
|
21
|
+
import wcBlockPropsCount from './rules/wc-block-props-count.js';
|
|
22
|
+
import wcBlockStates from './rules/wc-block-states.js';
|
|
23
|
+
import wcBlockVariantEnum from './rules/wc-block-variant-enum.js';
|
|
24
|
+
|
|
25
|
+
const plugin = {
|
|
26
|
+
meta: { name: 'eslint-plugin-aiflow', version: '2.0.0' },
|
|
27
|
+
rules: {
|
|
28
|
+
// L1(2 条,全部 error;tokens-css-locked 由 CODEOWNERS + 分支保护,非 ESLint 规则)
|
|
29
|
+
'no-token-modification': noTokenModification,
|
|
30
|
+
'no-inline-style': noInlineStyle,
|
|
31
|
+
// L1-3 tokens-css-locked 是 CODEOWNERS + 分支保护,非 ESLint 规则
|
|
32
|
+
// L2(7 条:4 error + 3 warn)
|
|
33
|
+
'token-whitelist': tokenWhitelist,
|
|
34
|
+
'no-recipe-break': noRecipeBreak,
|
|
35
|
+
'no-variant-conflict': noVariantConflict,
|
|
36
|
+
'no-arbitrary-value': noArbitraryValue,
|
|
37
|
+
'no-tailwind-syntax': noTailwindSyntax,
|
|
38
|
+
'prefer-component': preferComponent,
|
|
39
|
+
'atomic-duplicate': atomicDuplicate,
|
|
40
|
+
// L3(6 条:4 error + 2 warn)
|
|
41
|
+
'wc-light-no-style': wcLightNoStyle,
|
|
42
|
+
'wc-shadow-use-token': wcShadowUseToken,
|
|
43
|
+
'wc-part-naming': wcPartNaming,
|
|
44
|
+
'wc-event-naming': wcEventNaming,
|
|
45
|
+
'wc-aria-required': wcAriaRequired,
|
|
46
|
+
'wc-cleanup': wcCleanup,
|
|
47
|
+
// L3.5(5 条:Block 层约束 + register 引导)
|
|
48
|
+
'no-register-all': noRegisterAll,
|
|
49
|
+
'wc-block-no-internal-ref': wcBlockNoInternalRef,
|
|
50
|
+
'wc-block-props-count': wcBlockPropsCount,
|
|
51
|
+
'wc-block-states': wcBlockStates,
|
|
52
|
+
'wc-block-variant-enum': wcBlockVariantEnum,
|
|
53
|
+
},
|
|
54
|
+
configs: {},
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
// recommended 配置:按设计文档 L4 §3.2-3.4 的 severity 启用
|
|
58
|
+
Object.assign(plugin.configs, {
|
|
59
|
+
recommended: {
|
|
60
|
+
plugins: { aiflow: plugin },
|
|
61
|
+
rules: {
|
|
62
|
+
// L1 error
|
|
63
|
+
'aiflow/no-token-modification': 'error',
|
|
64
|
+
'aiflow/no-inline-style': 'error',
|
|
65
|
+
// L2 error
|
|
66
|
+
// extraComponents: af-data 属 L3.5 Block 层数据源元素(非 L3 注册组件,不在 whitelist-v1.json),
|
|
67
|
+
// 但 AI 生成的页面代码(createPage/:bind 体系)需要能使用 <af-data>,故在规则级放行
|
|
68
|
+
'aiflow/token-whitelist': ['error', { extraComponents: ['af-data'] }],
|
|
69
|
+
'aiflow/no-recipe-break': 'error',
|
|
70
|
+
'aiflow/no-arbitrary-value': 'error',
|
|
71
|
+
'aiflow/no-tailwind-syntax': 'error',
|
|
72
|
+
// L2 warn
|
|
73
|
+
'aiflow/no-variant-conflict': 'warn',
|
|
74
|
+
'aiflow/prefer-component': 'warn',
|
|
75
|
+
'aiflow/atomic-duplicate': 'warn',
|
|
76
|
+
// L3 error
|
|
77
|
+
'aiflow/wc-light-no-style': 'error',
|
|
78
|
+
'aiflow/wc-shadow-use-token': 'error',
|
|
79
|
+
'aiflow/wc-event-naming': 'error',
|
|
80
|
+
'aiflow/wc-aria-required': 'error',
|
|
81
|
+
// L3 warn
|
|
82
|
+
'aiflow/wc-part-naming': 'warn',
|
|
83
|
+
'aiflow/wc-cleanup': 'warn',
|
|
84
|
+
// L3.5 error(Block 层约束)
|
|
85
|
+
'aiflow/wc-block-no-internal-ref': 'error',
|
|
86
|
+
'aiflow/wc-block-props-count': 'error',
|
|
87
|
+
'aiflow/wc-block-states': 'error',
|
|
88
|
+
// L3.5 warn(引导性/启发式)
|
|
89
|
+
'aiflow/no-register-all': 'warn',
|
|
90
|
+
'aiflow/wc-block-variant-enum': 'warn',
|
|
91
|
+
},
|
|
92
|
+
},
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
export default plugin;
|
package/package.json
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@af-mobile/eslint-plugin",
|
|
3
|
+
"version": "2.0.0",
|
|
4
|
+
"description": "AIFlow UI ESLint plugin — 27 rules (L1/L2/L3/L3.5) enforcing design-system + Web Component constraints",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./index.js",
|
|
7
|
+
"module": "./index.js",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": "./index.js"
|
|
10
|
+
},
|
|
11
|
+
"files": [
|
|
12
|
+
"index.js",
|
|
13
|
+
"rules/",
|
|
14
|
+
"utils/"
|
|
15
|
+
],
|
|
16
|
+
"dependencies": {
|
|
17
|
+
"postcss": "^8.4.0"
|
|
18
|
+
},
|
|
19
|
+
"peerDependencies": {
|
|
20
|
+
"eslint": ">=9.0.0"
|
|
21
|
+
},
|
|
22
|
+
"keywords": [
|
|
23
|
+
"eslint",
|
|
24
|
+
"eslintplugin",
|
|
25
|
+
"eslint-plugin",
|
|
26
|
+
"web-components",
|
|
27
|
+
"design-system",
|
|
28
|
+
"aiflow"
|
|
29
|
+
],
|
|
30
|
+
"license": "MIT"
|
|
31
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// L2-7 aiflow/atomic-duplicate(warn,可自动修)
|
|
2
|
+
// 检测:同一 class 属性内出现两个同属性原子(如 p-4 p-2),保留最后一个
|
|
3
|
+
import { extractAllClassLists } from '../utils/helpers.js';
|
|
4
|
+
|
|
5
|
+
// 属性前缀映射:p-* → padding, m-* → margin, t-* → font-size, r-* → border-radius
|
|
6
|
+
function getPropPrefix(cls) {
|
|
7
|
+
const m = cls.match(/^([pmtr])-(.+)$/);
|
|
8
|
+
return m ? m[1] : null;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const PROP_LABELS = { p: 'padding', m: 'margin', t: 'font-size', r: 'border-radius' };
|
|
12
|
+
|
|
13
|
+
export default {
|
|
14
|
+
meta: {
|
|
15
|
+
type: 'suggestion',
|
|
16
|
+
docs: { description: '检测同属性原子类重复,保留最后一个' },
|
|
17
|
+
schema: [],
|
|
18
|
+
messages: {
|
|
19
|
+
duplicate: "Duplicate {{prop}}: '{{a}}' is overwritten by '{{b}}'. Keep only '{{b}}'",
|
|
20
|
+
},
|
|
21
|
+
fixable: 'code',
|
|
22
|
+
},
|
|
23
|
+
create(context) {
|
|
24
|
+
function checkClasses(classes, node, raw) {
|
|
25
|
+
const seen = new Map(); // prefix → { cls, index }
|
|
26
|
+
for (let i = 0; i < classes.length; i++) {
|
|
27
|
+
const cls = classes[i];
|
|
28
|
+
const prefix = getPropPrefix(cls);
|
|
29
|
+
if (!prefix) continue;
|
|
30
|
+
if (seen.has(prefix)) {
|
|
31
|
+
const prev = seen.get(prefix);
|
|
32
|
+
context.report({
|
|
33
|
+
node,
|
|
34
|
+
messageId: 'duplicate',
|
|
35
|
+
data: { prop: PROP_LABELS[prefix] || prefix, a: prev.cls, b: cls },
|
|
36
|
+
fix(fixer) {
|
|
37
|
+
const sourceCode = context.sourceCode || context.getSourceCode();
|
|
38
|
+
const text = sourceCode.getText(node);
|
|
39
|
+
const idx = text.indexOf(raw);
|
|
40
|
+
if (idx < 0) return null;
|
|
41
|
+
const classIdx = idx + raw.indexOf(prev.cls);
|
|
42
|
+
const before = text.slice(0, classIdx);
|
|
43
|
+
const after = text.slice(classIdx + prev.cls.length);
|
|
44
|
+
const trimmedBefore = before.replace(/\s+$/, '');
|
|
45
|
+
return fixer.replaceText(node, trimmedBefore + after);
|
|
46
|
+
},
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
seen.set(prefix, { cls, index: i });
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
function checkString(str, node) {
|
|
53
|
+
for (const { classes, raw } of extractAllClassLists(str)) checkClasses(classes, node, raw);
|
|
54
|
+
}
|
|
55
|
+
return {
|
|
56
|
+
Literal(node) { if (typeof node.value === 'string') checkString(node.value, node); },
|
|
57
|
+
TemplateElement(node) { if (node.value?.raw) checkString(node.value.raw, node); },
|
|
58
|
+
};
|
|
59
|
+
},
|
|
60
|
+
};
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// L2-4 aiflow/no-arbitrary-value(error,部分可自动修)
|
|
2
|
+
// 检测:class 含 [xxx] 任意值语法 / 档位越界(如 p-7)
|
|
3
|
+
import { extractAllClassLists, ALL_CLASSES } from '../utils/helpers.js';
|
|
4
|
+
|
|
5
|
+
// 合法档位
|
|
6
|
+
const RANGES = {
|
|
7
|
+
'p': ['0', '1', '2', '3', '4', '5', '6', '8', '10'],
|
|
8
|
+
'm': ['0', '1', '2', '3', '4', '5', '6'],
|
|
9
|
+
't': ['xs', 'sm', 'md', 'lg', 'xl'],
|
|
10
|
+
'r': ['s', 'm', 'l', 'f'],
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export default {
|
|
14
|
+
meta: {
|
|
15
|
+
type: 'problem',
|
|
16
|
+
docs: { description: '禁止任意值语法和档位越界' },
|
|
17
|
+
schema: [],
|
|
18
|
+
messages: {
|
|
19
|
+
arbitrary: "'{{name}}' arbitrary value syntax forbidden; use closest atomic or extend recipes.project.css",
|
|
20
|
+
outOfRange: "'{{name}}' out of range ({{valid}}); use closest atomic",
|
|
21
|
+
},
|
|
22
|
+
},
|
|
23
|
+
create(context) {
|
|
24
|
+
function checkClass(cls, node) {
|
|
25
|
+
// 白名单内的 class 直接放行(如 t-center/t-b 是合法原子类,非档位)
|
|
26
|
+
if (ALL_CLASSES.has(cls)) return;
|
|
27
|
+
// [xxx] 任意值
|
|
28
|
+
if (/[\[\]]/.test(cls)) {
|
|
29
|
+
context.report({ node, messageId: 'arbitrary', data: { name: cls } });
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
// 档位越界:前缀-档位
|
|
33
|
+
const m = cls.match(/^([pmtr])-(.+)$/);
|
|
34
|
+
if (m) {
|
|
35
|
+
const [, prefix, val] = m;
|
|
36
|
+
const valid = RANGES[prefix.toUpperCase()] || RANGES[prefix];
|
|
37
|
+
if (valid && !valid.includes(val)) {
|
|
38
|
+
context.report({ node, messageId: 'outOfRange', data: { name: cls, valid: valid.join('/') } });
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
function checkString(str, node) {
|
|
43
|
+
for (const { classes } of extractAllClassLists(str)) {
|
|
44
|
+
for (const cls of classes) checkClass(cls, node);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return {
|
|
48
|
+
Literal(node) { if (typeof node.value === 'string') checkString(node.value, node); },
|
|
49
|
+
TemplateElement(node) { if (node.value?.raw) checkString(node.value.raw, node); },
|
|
50
|
+
};
|
|
51
|
+
},
|
|
52
|
+
};
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
// L1-2 aiflow/no-inline-style(error,部分可自动修)
|
|
2
|
+
// 检测:style="" 中设置 forbiddenInlineStyle 列表内的属性
|
|
3
|
+
// 例外:display/transform/z-index/width/height 等布局属性放行;skeleton class 元素内 width/height 放行
|
|
4
|
+
// 适用:HTML/JS 文件中所有字符串字面量(含 template literal),覆盖组件 innerHTML 赋值场景
|
|
5
|
+
import { readFileSync } from 'node:fs';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
import { resolve, dirname } from 'node:path';
|
|
8
|
+
|
|
9
|
+
// 加载 whitelist(与规则文件同级目录 ../utils/whitelist-v1.json)
|
|
10
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
11
|
+
const whitelistPath = resolve(__dirname, '../utils/whitelist-v1.json');
|
|
12
|
+
const whitelist = JSON.parse(readFileSync(whitelistPath, 'utf8'));
|
|
13
|
+
const FORBIDDEN = new Set(whitelist.forbiddenInlineStyle);
|
|
14
|
+
|
|
15
|
+
// 布局类属性放行(设计文档 L1-2 例外)
|
|
16
|
+
const ALLOWED_LAYOUT = new Set([
|
|
17
|
+
'display', 'transform', 'z-index', 'width', 'height',
|
|
18
|
+
'position', 'top', 'right', 'bottom', 'left',
|
|
19
|
+
'flex', 'flex-direction', 'flex-wrap', 'justify-content', 'align-items',
|
|
20
|
+
'grid-template-columns', 'grid-template-rows', 'gap',
|
|
21
|
+
'overflow', 'overflow-x', 'overflow-y',
|
|
22
|
+
]);
|
|
23
|
+
|
|
24
|
+
// 提取 style="..." 内容中的属性名
|
|
25
|
+
function extractStyleProps(styleContent) {
|
|
26
|
+
const props = [];
|
|
27
|
+
// 匹配 prop-name: value; 形式
|
|
28
|
+
const re = /([a-z-]+)\s*:/g;
|
|
29
|
+
let m;
|
|
30
|
+
while ((m = re.exec(styleContent))) props.push(m[1].toLowerCase());
|
|
31
|
+
return props;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// 检测 style="" 字符串中的 forbidden 属性
|
|
35
|
+
function checkStyleString(str) {
|
|
36
|
+
const violations = [];
|
|
37
|
+
const styleRe = /style\s*=\s*"([^"]*)"/g;
|
|
38
|
+
let m;
|
|
39
|
+
while ((m = styleRe.exec(str))) {
|
|
40
|
+
const styleContent = m[1];
|
|
41
|
+
const props = extractStyleProps(styleContent);
|
|
42
|
+
for (const prop of props) {
|
|
43
|
+
if (FORBIDDEN.has(prop)) {
|
|
44
|
+
violations.push({ prop, value: styleContent });
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return violations;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export default {
|
|
52
|
+
meta: {
|
|
53
|
+
type: 'problem',
|
|
54
|
+
docs: {
|
|
55
|
+
description: '禁止 inline style 中使用 forbidden 属性(应使用 L2 class)',
|
|
56
|
+
},
|
|
57
|
+
schema: [{
|
|
58
|
+
type: 'object',
|
|
59
|
+
properties: {
|
|
60
|
+
allowProperties: {
|
|
61
|
+
type: 'array',
|
|
62
|
+
items: { type: 'string' },
|
|
63
|
+
description: '项目级额外放行的属性名',
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
additionalProperties: false,
|
|
67
|
+
}],
|
|
68
|
+
messages: {
|
|
69
|
+
forbidden: "Inline style '{{value}}' is forbidden. Use class instead",
|
|
70
|
+
},
|
|
71
|
+
},
|
|
72
|
+
|
|
73
|
+
create(context) {
|
|
74
|
+
const options = context.options[0] || {};
|
|
75
|
+
const extraAllow = new Set(options.allowProperties || []);
|
|
76
|
+
|
|
77
|
+
return {
|
|
78
|
+
// 检测字符串字面量(含 template literal)
|
|
79
|
+
Literal(node) {
|
|
80
|
+
if (typeof node.value !== 'string') return;
|
|
81
|
+
const violations = checkStyleString(node.value);
|
|
82
|
+
for (const v of violations) {
|
|
83
|
+
if (extraAllow.has(v.prop)) continue;
|
|
84
|
+
context.report({ node, messageId: 'forbidden', data: { value: v.value } });
|
|
85
|
+
}
|
|
86
|
+
},
|
|
87
|
+
TemplateElement(node) {
|
|
88
|
+
if (!node.value || !node.value.raw) return;
|
|
89
|
+
const violations = checkStyleString(node.value.raw);
|
|
90
|
+
for (const v of violations) {
|
|
91
|
+
if (extraAllow.has(v.prop)) continue;
|
|
92
|
+
context.report({ node, messageId: 'forbidden', data: { value: v.value } });
|
|
93
|
+
}
|
|
94
|
+
},
|
|
95
|
+
// 检测 el.style.setProperty('color', ...) / el.style.xxx = '...' 绕过
|
|
96
|
+
// 仅当属性名为 forbidden 视觉属性且非常量 --* CSS 变量时报告
|
|
97
|
+
CallExpression(node) {
|
|
98
|
+
const callee = node.callee;
|
|
99
|
+
if (callee?.type !== 'MemberExpression') return;
|
|
100
|
+
if (callee.object?.type !== 'MemberExpression') return;
|
|
101
|
+
if (callee.object.property?.name !== 'style') return;
|
|
102
|
+
if (callee.property?.name !== 'setProperty') return;
|
|
103
|
+
const propArg = node.arguments?.[0];
|
|
104
|
+
if (!propArg || propArg.type !== 'Literal' || typeof propArg.value !== 'string') return;
|
|
105
|
+
// CSS 自定义属性(--*)豁免:用于主题变量传递,由 no-token-modification 规则单独管
|
|
106
|
+
if (propArg.value.startsWith('--')) return;
|
|
107
|
+
const prop = propArg.value.toLowerCase();
|
|
108
|
+
if (FORBIDDEN.has(prop) && !ALLOWED_LAYOUT.has(prop) && !extraAllow.has(prop)) {
|
|
109
|
+
context.report({ node, messageId: 'forbidden', data: { value: `${prop}` } });
|
|
110
|
+
}
|
|
111
|
+
},
|
|
112
|
+
AssignmentExpression(node) {
|
|
113
|
+
// el.style.color = 'red' 形式
|
|
114
|
+
if (node.left?.type !== 'MemberExpression') return;
|
|
115
|
+
if (node.left.object?.type !== 'MemberExpression') return;
|
|
116
|
+
if (node.left.object.property?.name !== 'style') return;
|
|
117
|
+
const propName = node.left.property?.name;
|
|
118
|
+
if (!propName) return;
|
|
119
|
+
// kebab-case 转换:backgroundColor → background-color
|
|
120
|
+
const kebab = propName.replace(/[A-Z]/g, m => '-' + m.toLowerCase());
|
|
121
|
+
if (FORBIDDEN.has(kebab) && !ALLOWED_LAYOUT.has(kebab) && !extraAllow.has(kebab)) {
|
|
122
|
+
context.report({ node, messageId: 'forbidden', data: { value: kebab } });
|
|
123
|
+
}
|
|
124
|
+
},
|
|
125
|
+
};
|
|
126
|
+
},
|
|
127
|
+
};
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// L2-2 aiflow/no-recipe-break(error,3 子规则)
|
|
2
|
+
// a: .cell + f/fc 原子 → 布局冲突
|
|
3
|
+
// b: .btn(非 .btn-ghost)+ text-brand/text-danger/text-success → 对比度问题
|
|
4
|
+
// c: .input + t-sm/t-xs → iOS 焦点缩放
|
|
5
|
+
import { extractAllClassLists } from '../utils/helpers.js';
|
|
6
|
+
|
|
7
|
+
const FLEX_ATOMS = new Set(['f', 'fc']);
|
|
8
|
+
const COLORED_TEXT = new Set(['text-brand', 'text-danger', 'text-success']);
|
|
9
|
+
const SMALL_FONT = new Set(['t-sm', 't-xs']);
|
|
10
|
+
|
|
11
|
+
export default {
|
|
12
|
+
meta: {
|
|
13
|
+
type: 'problem',
|
|
14
|
+
docs: { description: '禁止原子类破坏 recipe 内置样式' },
|
|
15
|
+
schema: [],
|
|
16
|
+
messages: {
|
|
17
|
+
cellFlex: ".cell has built-in display:flex, adding 'f'/'fc' may break layout",
|
|
18
|
+
btnColor: ".btn background uses --c-brand, text color must keep --c-onbrand contrast. Use .btn-ghost instead for colored text",
|
|
19
|
+
inputFont: ".input font-size must be 16px (--t-md) to prevent iOS focus zoom. Put help text in <label> or .form-err instead",
|
|
20
|
+
},
|
|
21
|
+
},
|
|
22
|
+
create(context) {
|
|
23
|
+
function checkClasses(classes, node) {
|
|
24
|
+
const set = new Set(classes);
|
|
25
|
+
// a: .cell + f/fc
|
|
26
|
+
if (set.has('cell')) {
|
|
27
|
+
for (const a of FLEX_ATOMS) {
|
|
28
|
+
if (set.has(a)) { context.report({ node, messageId: 'cellFlex' }); break; }
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
// b: .btn(非 .btn-ghost)+ colored text
|
|
32
|
+
if (set.has('btn') && !set.has('btn-ghost')) {
|
|
33
|
+
for (const a of COLORED_TEXT) {
|
|
34
|
+
if (set.has(a)) { context.report({ node, messageId: 'btnColor' }); break; }
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
// c: .input + t-sm/t-xs
|
|
38
|
+
if (set.has('input')) {
|
|
39
|
+
for (const a of SMALL_FONT) {
|
|
40
|
+
if (set.has(a)) { context.report({ node, messageId: 'inputFont' }); break; }
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
function checkString(str, node) {
|
|
45
|
+
for (const { classes } of extractAllClassLists(str)) checkClasses(classes, node);
|
|
46
|
+
}
|
|
47
|
+
return {
|
|
48
|
+
Literal(node) { if (typeof node.value === 'string') checkString(node.value, node); },
|
|
49
|
+
TemplateElement(node) { if (node.value?.raw) checkString(node.value.raw, node); },
|
|
50
|
+
};
|
|
51
|
+
},
|
|
52
|
+
};
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// L3 aiflow/no-register-all(error)
|
|
2
|
+
// 检测:registerAll() 调用(已废弃,诱导全量加载,失去 Tree Shaking)
|
|
3
|
+
// 正确:register('af-list', 'af-dialog') 显式列名,配合 Tree Shaking 按需打包
|
|
4
|
+
export default {
|
|
5
|
+
meta: {
|
|
6
|
+
type: 'problem',
|
|
7
|
+
docs: { description: '禁止 registerAll(),改用 register(...names) 显式列名', fixable: 'ai-rewrite' },
|
|
8
|
+
schema: [],
|
|
9
|
+
messages: {
|
|
10
|
+
registerAll: "registerAll() 已废弃(诱导全量加载,失去 Tree Shaking),改用 register(...names) 显式列名",
|
|
11
|
+
},
|
|
12
|
+
},
|
|
13
|
+
create(context) {
|
|
14
|
+
const filename = context.filename || context.getFilename();
|
|
15
|
+
// 非消费端放行:库源码(index.js 导出 registerAll)/单元测试/构建脚本(build.mjs 全量入口)
|
|
16
|
+
if (/src[\\/]|test[\\/]|scripts[\\/]/.test(filename)) return {};
|
|
17
|
+
return {
|
|
18
|
+
CallExpression(node) {
|
|
19
|
+
const callee = node.callee;
|
|
20
|
+
const name = callee.type === 'Identifier' ? callee.name
|
|
21
|
+
: callee.type === 'MemberExpression' && callee.property.type === 'Identifier' ? callee.property.name
|
|
22
|
+
: null;
|
|
23
|
+
if (name === 'registerAll') {
|
|
24
|
+
context.report({ node, messageId: 'registerAll' });
|
|
25
|
+
}
|
|
26
|
+
},
|
|
27
|
+
};
|
|
28
|
+
},
|
|
29
|
+
};
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// L2-5 aiflow/no-tailwind-syntax(error)
|
|
2
|
+
// 检测:class 含 sm:/md:/lg:/xl:/hover:/focus:/active:/dark: 前缀
|
|
3
|
+
import { extractAllClassLists } from '../utils/helpers.js';
|
|
4
|
+
|
|
5
|
+
const TAILWIND_PREFIX = /^(sm|md|lg|xl|hover|focus|active|dark):/;
|
|
6
|
+
|
|
7
|
+
export default {
|
|
8
|
+
meta: {
|
|
9
|
+
type: 'problem',
|
|
10
|
+
docs: { description: '禁止 Tailwind 响应式/状态前缀语法' },
|
|
11
|
+
schema: [],
|
|
12
|
+
messages: {
|
|
13
|
+
tailwind: "'{{name}}' responsive/state prefix syntax forbidden. Use @container in recipes.project.css for container-queries responsive behavior",
|
|
14
|
+
},
|
|
15
|
+
},
|
|
16
|
+
create(context) {
|
|
17
|
+
function checkString(str, node) {
|
|
18
|
+
for (const { classes } of extractAllClassLists(str)) {
|
|
19
|
+
for (const cls of classes) {
|
|
20
|
+
if (TAILWIND_PREFIX.test(cls)) {
|
|
21
|
+
context.report({ node, messageId: 'tailwind', data: { name: cls } });
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return {
|
|
27
|
+
Literal(node) { if (typeof node.value === 'string') checkString(node.value, node); },
|
|
28
|
+
TemplateElement(node) { if (node.value?.raw) checkString(node.value.raw, node); },
|
|
29
|
+
};
|
|
30
|
+
},
|
|
31
|
+
};
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// L1-1 aiflow/no-token-modification(error)
|
|
2
|
+
// 检测:非 tokens.css / tokens.project.css 文件内,重定义 --(c|s|r|t|lh|fw|shadow|z|ease|dur)-* 变量
|
|
3
|
+
// 例外:路径匹配 **/tokens.css 或 **/tokens.project.css
|
|
4
|
+
import postcss from 'postcss';
|
|
5
|
+
|
|
6
|
+
const TOKEN_PREFIX_RE = /^--(c|s|r|t|lh|fw|shadow|z|ease|dur)-/;
|
|
7
|
+
|
|
8
|
+
export default {
|
|
9
|
+
meta: {
|
|
10
|
+
type: 'problem',
|
|
11
|
+
docs: {
|
|
12
|
+
description: '禁止在 tokens.css 之外修改/重定义设计 token 变量',
|
|
13
|
+
},
|
|
14
|
+
schema: [],
|
|
15
|
+
messages: {
|
|
16
|
+
locked: "Token variable '{{name}}' is locked. Modify tokens.css or register in tokens.project.css instead",
|
|
17
|
+
},
|
|
18
|
+
},
|
|
19
|
+
|
|
20
|
+
create(context) {
|
|
21
|
+
const filename = context.filename || context.getFilename();
|
|
22
|
+
// 例外文件:tokens.css 和 tokens.project.css
|
|
23
|
+
if (/tokens(\.project)?\.css$/.test(filename)) return {};
|
|
24
|
+
|
|
25
|
+
return {
|
|
26
|
+
// ESLint 9 对 .css 文件原生支持不足,用源码文本直接喂 postcss
|
|
27
|
+
Program(node) {
|
|
28
|
+
const source = context.sourceCode || context.getSourceCode();
|
|
29
|
+
const css = source.getText();
|
|
30
|
+
if (!css.includes('--')) return;
|
|
31
|
+
try {
|
|
32
|
+
const root = postcss.parse(css);
|
|
33
|
+
root.walkDecls(decl => {
|
|
34
|
+
if (TOKEN_PREFIX_RE.test(decl.prop)) {
|
|
35
|
+
context.report({
|
|
36
|
+
node,
|
|
37
|
+
loc: {
|
|
38
|
+
// postcss 节点有 source.start/end(行从 1 起,列从 0 起 → ESLint 期望 0 起)
|
|
39
|
+
start: { line: decl.source.start.line, column: decl.source.start.column - 1 },
|
|
40
|
+
end: { line: decl.source.end.line, column: decl.source.end.column },
|
|
41
|
+
},
|
|
42
|
+
messageId: 'locked',
|
|
43
|
+
data: { name: decl.prop },
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
} catch {
|
|
48
|
+
// 非 CSS 内容(如 .js 文件恰好进入此规则):静默跳过
|
|
49
|
+
}
|
|
50
|
+
},
|
|
51
|
+
// 检测 JS 内 el.style.setProperty('--c-brand', ...) / removeProperty('--c-brand') 间接覆盖 token
|
|
52
|
+
// 也覆盖 el.style['--c-brand'] = ... 形式
|
|
53
|
+
CallExpression(node) {
|
|
54
|
+
const callee = node.callee;
|
|
55
|
+
if (callee?.type !== 'MemberExpression') return;
|
|
56
|
+
if (callee.object?.type !== 'MemberExpression') return;
|
|
57
|
+
if (callee.object.property?.name !== 'style') return;
|
|
58
|
+
const method = callee.property?.name;
|
|
59
|
+
if (method !== 'setProperty' && method !== 'removeProperty') return;
|
|
60
|
+
const propArg = node.arguments?.[0];
|
|
61
|
+
if (!propArg || propArg.type !== 'Literal' || typeof propArg.value !== 'string') return;
|
|
62
|
+
if (TOKEN_PREFIX_RE.test(propArg.value)) {
|
|
63
|
+
context.report({ node, messageId: 'locked', data: { name: propArg.value } });
|
|
64
|
+
}
|
|
65
|
+
},
|
|
66
|
+
AssignmentExpression(node) {
|
|
67
|
+
// el.style['--c-brand'] = ... 或 el.style.cssText = '--c-brand: ...'
|
|
68
|
+
if (node.left?.type !== 'MemberExpression') return;
|
|
69
|
+
if (node.left.object?.type !== 'MemberExpression') return;
|
|
70
|
+
if (node.left.object.property?.name !== 'style') return;
|
|
71
|
+
// 计算属性访问 el.style['--c-brand']
|
|
72
|
+
const propNode = node.left.property;
|
|
73
|
+
if (propNode?.type === 'Literal' && typeof propNode.value === 'string'
|
|
74
|
+
&& TOKEN_PREFIX_RE.test(propNode.value)) {
|
|
75
|
+
context.report({ node, messageId: 'locked', data: { name: propNode.value } });
|
|
76
|
+
}
|
|
77
|
+
// el.style.cssText = '--c-brand: red; ...' 间接覆盖
|
|
78
|
+
if (propNode?.type === 'Identifier' && propNode.name === 'cssText') {
|
|
79
|
+
const valNode = node.right;
|
|
80
|
+
if (valNode?.type === 'Literal' && typeof valNode.value === 'string') {
|
|
81
|
+
const matches = valNode.value.match(/--[a-z][\w-]*/g) || [];
|
|
82
|
+
for (const m of matches) {
|
|
83
|
+
if (TOKEN_PREFIX_RE.test(m)) {
|
|
84
|
+
context.report({ node, messageId: 'locked', data: { name: m } });
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
},
|
|
92
|
+
};
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// L2-3 aiflow/no-variant-conflict(warn,可自动修)
|
|
2
|
+
// 检测:同属性互斥变体对同时出现,自动修:保留最后一个
|
|
3
|
+
import { extractAllClassLists } from '../utils/helpers.js';
|
|
4
|
+
|
|
5
|
+
// 互斥变体组:同组内只能出现一个
|
|
6
|
+
const CONFLICT_GROUPS = [
|
|
7
|
+
['btn-sm', 'btn-lg'],
|
|
8
|
+
['tag-ok', 'tag-warn', 'tag-danger'],
|
|
9
|
+
// 圆角类
|
|
10
|
+
['r-s', 'r-m', 'r-l', 'r-f'],
|
|
11
|
+
// padding 类
|
|
12
|
+
['p-0', 'p-1', 'p-2', 'p-3', 'p-4', 'p-5', 'p-6', 'p-8', 'p-10'],
|
|
13
|
+
];
|
|
14
|
+
|
|
15
|
+
export default {
|
|
16
|
+
meta: {
|
|
17
|
+
type: 'suggestion',
|
|
18
|
+
docs: { description: '检测互斥变体冲突,保留最后一个' },
|
|
19
|
+
schema: [],
|
|
20
|
+
messages: {
|
|
21
|
+
conflict: "Variant conflict: '{{a}}' and '{{b}}' — only the later one takes effect, remove the earlier one",
|
|
22
|
+
},
|
|
23
|
+
fixable: 'code',
|
|
24
|
+
},
|
|
25
|
+
create(context) {
|
|
26
|
+
function checkClasses(classes, node, raw, offset) {
|
|
27
|
+
for (const group of CONFLICT_GROUPS) {
|
|
28
|
+
const found = [];
|
|
29
|
+
for (const cls of classes) {
|
|
30
|
+
if (group.includes(cls)) found.push(cls);
|
|
31
|
+
}
|
|
32
|
+
if (found.length < 2) continue;
|
|
33
|
+
// 保留最后一个,删除前面的
|
|
34
|
+
const keep = found[found.length - 1];
|
|
35
|
+
const remove = found.slice(0, -1);
|
|
36
|
+
for (const r of remove) {
|
|
37
|
+
context.report({
|
|
38
|
+
node,
|
|
39
|
+
messageId: 'conflict',
|
|
40
|
+
data: { a: r, b: keep },
|
|
41
|
+
fix(fixer) {
|
|
42
|
+
// 删除 class 名(含前导空格)
|
|
43
|
+
const sourceCode = context.sourceCode || context.getSourceCode();
|
|
44
|
+
const text = sourceCode.getText(node);
|
|
45
|
+
const idx = text.indexOf(raw);
|
|
46
|
+
if (idx < 0) return null;
|
|
47
|
+
const classIdx = idx + raw.indexOf(r);
|
|
48
|
+
// 删除 " r" 或 "r " 或 "r"
|
|
49
|
+
const before = text.slice(0, classIdx);
|
|
50
|
+
const after = text.slice(classIdx + r.length);
|
|
51
|
+
// 如果前面有空格,连空格一起删
|
|
52
|
+
const trimmedBefore = before.replace(/\s+$/, '');
|
|
53
|
+
const newText = trimmedBefore + after;
|
|
54
|
+
return fixer.replaceText(node, newText);
|
|
55
|
+
},
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
function checkString(str, node) {
|
|
61
|
+
for (const { classes, raw, offset } of extractAllClassLists(str)) {
|
|
62
|
+
checkClasses(classes, node, raw, offset);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return {
|
|
66
|
+
Literal(node) { if (typeof node.value === 'string') checkString(node.value, node); },
|
|
67
|
+
TemplateElement(node) { if (node.value?.raw) checkString(node.value.raw, node); },
|
|
68
|
+
};
|
|
69
|
+
},
|
|
70
|
+
};
|