frontend-guardian-core 2.6.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.
Files changed (152) hide show
  1. package/LICENSE +21 -0
  2. package/bin/fg-core.js +1238 -0
  3. package/bin/watch-mode.js +123 -0
  4. package/dist/engine/cache.d.ts +68 -0
  5. package/dist/engine/cache.d.ts.map +1 -0
  6. package/dist/engine/cache.js +164 -0
  7. package/dist/engine/cache.js.map +1 -0
  8. package/dist/engine/rule-engine.d.ts +135 -0
  9. package/dist/engine/rule-engine.d.ts.map +1 -0
  10. package/dist/engine/rule-engine.js +716 -0
  11. package/dist/engine/rule-engine.js.map +1 -0
  12. package/dist/formatters/github-annotation.d.ts +36 -0
  13. package/dist/formatters/github-annotation.d.ts.map +1 -0
  14. package/dist/formatters/github-annotation.js +122 -0
  15. package/dist/formatters/github-annotation.js.map +1 -0
  16. package/dist/formatters/pr-comment.d.ts +43 -0
  17. package/dist/formatters/pr-comment.d.ts.map +1 -0
  18. package/dist/formatters/pr-comment.js +171 -0
  19. package/dist/formatters/pr-comment.js.map +1 -0
  20. package/dist/formatters/sarif.d.ts +104 -0
  21. package/dist/formatters/sarif.d.ts.map +1 -0
  22. package/dist/formatters/sarif.js +130 -0
  23. package/dist/formatters/sarif.js.map +1 -0
  24. package/dist/index.d.ts +46 -0
  25. package/dist/index.d.ts.map +1 -0
  26. package/dist/index.js +108 -0
  27. package/dist/index.js.map +1 -0
  28. package/dist/integrations/base.d.ts +44 -0
  29. package/dist/integrations/base.d.ts.map +1 -0
  30. package/dist/integrations/base.js +104 -0
  31. package/dist/integrations/base.js.map +1 -0
  32. package/dist/integrations/eslint.d.ts +8 -0
  33. package/dist/integrations/eslint.d.ts.map +1 -0
  34. package/dist/integrations/eslint.js +67 -0
  35. package/dist/integrations/eslint.js.map +1 -0
  36. package/dist/integrations/formatter.d.ts +35 -0
  37. package/dist/integrations/formatter.d.ts.map +1 -0
  38. package/dist/integrations/formatter.js +182 -0
  39. package/dist/integrations/formatter.js.map +1 -0
  40. package/dist/integrations/index.d.ts +17 -0
  41. package/dist/integrations/index.d.ts.map +1 -0
  42. package/dist/integrations/index.js +25 -0
  43. package/dist/integrations/index.js.map +1 -0
  44. package/dist/integrations/stylelint.d.ts +8 -0
  45. package/dist/integrations/stylelint.d.ts.map +1 -0
  46. package/dist/integrations/stylelint.js +59 -0
  47. package/dist/integrations/stylelint.js.map +1 -0
  48. package/dist/integrations/typescript.d.ts +8 -0
  49. package/dist/integrations/typescript.d.ts.map +1 -0
  50. package/dist/integrations/typescript.js +92 -0
  51. package/dist/integrations/typescript.js.map +1 -0
  52. package/dist/rules/registry.d.ts +83 -0
  53. package/dist/rules/registry.d.ts.map +1 -0
  54. package/dist/rules/registry.js +205 -0
  55. package/dist/rules/registry.js.map +1 -0
  56. package/dist/scanners/a11y-scanner.d.ts +14 -0
  57. package/dist/scanners/a11y-scanner.d.ts.map +1 -0
  58. package/dist/scanners/a11y-scanner.js +781 -0
  59. package/dist/scanners/a11y-scanner.js.map +1 -0
  60. package/dist/scanners/component-scanner.d.ts +12 -0
  61. package/dist/scanners/component-scanner.d.ts.map +1 -0
  62. package/dist/scanners/component-scanner.js +304 -0
  63. package/dist/scanners/component-scanner.js.map +1 -0
  64. package/dist/scanners/cross-file-scanner.d.ts +18 -0
  65. package/dist/scanners/cross-file-scanner.d.ts.map +1 -0
  66. package/dist/scanners/cross-file-scanner.js +684 -0
  67. package/dist/scanners/cross-file-scanner.js.map +1 -0
  68. package/dist/scanners/hooks-scanner.d.ts +15 -0
  69. package/dist/scanners/hooks-scanner.d.ts.map +1 -0
  70. package/dist/scanners/hooks-scanner.js +670 -0
  71. package/dist/scanners/hooks-scanner.js.map +1 -0
  72. package/dist/scanners/i18n-scanner.d.ts +13 -0
  73. package/dist/scanners/i18n-scanner.d.ts.map +1 -0
  74. package/dist/scanners/i18n-scanner.js +535 -0
  75. package/dist/scanners/i18n-scanner.js.map +1 -0
  76. package/dist/scanners/naming-scanner.d.ts +19 -0
  77. package/dist/scanners/naming-scanner.d.ts.map +1 -0
  78. package/dist/scanners/naming-scanner.js +746 -0
  79. package/dist/scanners/naming-scanner.js.map +1 -0
  80. package/dist/scanners/performance-scanner.d.ts +7 -0
  81. package/dist/scanners/performance-scanner.d.ts.map +1 -0
  82. package/dist/scanners/performance-scanner.js +402 -0
  83. package/dist/scanners/performance-scanner.js.map +1 -0
  84. package/dist/scanners/platform-scanner.d.ts +15 -0
  85. package/dist/scanners/platform-scanner.d.ts.map +1 -0
  86. package/dist/scanners/platform-scanner.js +320 -0
  87. package/dist/scanners/platform-scanner.js.map +1 -0
  88. package/dist/scanners/security-scanner.d.ts +7 -0
  89. package/dist/scanners/security-scanner.d.ts.map +1 -0
  90. package/dist/scanners/security-scanner.js +349 -0
  91. package/dist/scanners/security-scanner.js.map +1 -0
  92. package/dist/scanners/svelte-scanner.d.ts +14 -0
  93. package/dist/scanners/svelte-scanner.d.ts.map +1 -0
  94. package/dist/scanners/svelte-scanner.js +228 -0
  95. package/dist/scanners/svelte-scanner.js.map +1 -0
  96. package/dist/types.d.ts +343 -0
  97. package/dist/types.d.ts.map +1 -0
  98. package/dist/types.js +6 -0
  99. package/dist/types.js.map +1 -0
  100. package/dist/utils/ast-parser.d.ts +21 -0
  101. package/dist/utils/ast-parser.d.ts.map +1 -0
  102. package/dist/utils/ast-parser.js +119 -0
  103. package/dist/utils/ast-parser.js.map +1 -0
  104. package/dist/utils/baseline.d.ts +89 -0
  105. package/dist/utils/baseline.d.ts.map +1 -0
  106. package/dist/utils/baseline.js +156 -0
  107. package/dist/utils/baseline.js.map +1 -0
  108. package/dist/utils/ci-generator.d.ts +34 -0
  109. package/dist/utils/ci-generator.d.ts.map +1 -0
  110. package/dist/utils/ci-generator.js +194 -0
  111. package/dist/utils/ci-generator.js.map +1 -0
  112. package/dist/utils/common.d.ts +8 -0
  113. package/dist/utils/common.d.ts.map +1 -0
  114. package/dist/utils/common.js +38 -0
  115. package/dist/utils/common.js.map +1 -0
  116. package/dist/utils/concurrent.d.ts +16 -0
  117. package/dist/utils/concurrent.d.ts.map +1 -0
  118. package/dist/utils/concurrent.js +49 -0
  119. package/dist/utils/concurrent.js.map +1 -0
  120. package/dist/utils/config-loader.d.ts +8 -0
  121. package/dist/utils/config-loader.d.ts.map +1 -0
  122. package/dist/utils/config-loader.js +154 -0
  123. package/dist/utils/config-loader.js.map +1 -0
  124. package/dist/utils/fix-bot.d.ts +36 -0
  125. package/dist/utils/fix-bot.d.ts.map +1 -0
  126. package/dist/utils/fix-bot.js +274 -0
  127. package/dist/utils/fix-bot.js.map +1 -0
  128. package/dist/utils/git-hooks.d.ts +55 -0
  129. package/dist/utils/git-hooks.d.ts.map +1 -0
  130. package/dist/utils/git-hooks.js +318 -0
  131. package/dist/utils/git-hooks.js.map +1 -0
  132. package/dist/utils/history-report.d.ts +72 -0
  133. package/dist/utils/history-report.d.ts.map +1 -0
  134. package/dist/utils/history-report.js +144 -0
  135. package/dist/utils/history-report.js.map +1 -0
  136. package/dist/utils/init-config.d.ts +23 -0
  137. package/dist/utils/init-config.d.ts.map +1 -0
  138. package/dist/utils/init-config.js +146 -0
  139. package/dist/utils/init-config.js.map +1 -0
  140. package/dist/utils/pr-publisher.d.ts +64 -0
  141. package/dist/utils/pr-publisher.d.ts.map +1 -0
  142. package/dist/utils/pr-publisher.js +265 -0
  143. package/dist/utils/pr-publisher.js.map +1 -0
  144. package/dist/utils/project-detector.d.ts +20 -0
  145. package/dist/utils/project-detector.d.ts.map +1 -0
  146. package/dist/utils/project-detector.js +342 -0
  147. package/dist/utils/project-detector.js.map +1 -0
  148. package/dist/utils/report-uploader.d.ts +35 -0
  149. package/dist/utils/report-uploader.d.ts.map +1 -0
  150. package/dist/utils/report-uploader.js +106 -0
  151. package/dist/utils/report-uploader.js.map +1 -0
  152. package/package.json +78 -0
@@ -0,0 +1,781 @@
1
+ "use strict";
2
+ /**
3
+ * 可访问性规则 Scanner
4
+ * 参考 WCAG 2.1 标准和 Vercel Web Design Guidelines
5
+ *
6
+ * 规则列表:
7
+ * 1. a11y-img-alt — 图片必须有 alt 属性
8
+ * 2. a11y-form-label — 表单元素必须有 label
9
+ * 3. a11y-button-role — 可点击元素语义化
10
+ * 4. a11y-contrast — 颜色对比度 WCAG AA
11
+ * 5. a11y-aria-valid — ARIA 属性合法性
12
+ */
13
+ var __importDefault = (this && this.__importDefault) || function (mod) {
14
+ return (mod && mod.__esModule) ? mod : { "default": mod };
15
+ };
16
+ Object.defineProperty(exports, "__esModule", { value: true });
17
+ exports.a11yRules = void 0;
18
+ const traverse_1 = __importDefault(require("@babel/traverse"));
19
+ const common_js_1 = require("../utils/common.js");
20
+ /** 有效的 ARIA 属性列表 (WAI-ARIA 1.2) */
21
+ const VALID_ARIA_ATTRIBUTES = new Set([
22
+ "aria-atomic",
23
+ "aria-autocomplete",
24
+ "aria-busy",
25
+ "aria-checked",
26
+ "aria-colcount",
27
+ "aria-colindex",
28
+ "aria-colspan",
29
+ "aria-controls",
30
+ "aria-current",
31
+ "aria-describedby",
32
+ "aria-details",
33
+ "aria-disabled",
34
+ "aria-dropeffect",
35
+ "aria-errormessage",
36
+ "aria-expanded",
37
+ "aria-flowto",
38
+ "aria-grabbed",
39
+ "aria-haspopup",
40
+ "aria-hidden",
41
+ "aria-invalid",
42
+ "aria-keyshortcuts",
43
+ "aria-label",
44
+ "aria-labelledby",
45
+ "aria-level",
46
+ "aria-live",
47
+ "aria-modal",
48
+ "aria-multiline",
49
+ "aria-multiselectable",
50
+ "aria-orientation",
51
+ "aria-owns",
52
+ "aria-placeholder",
53
+ "aria-posinset",
54
+ "aria-pressed",
55
+ "aria-readonly",
56
+ "aria-relevant",
57
+ "aria-required",
58
+ "aria-roledescription",
59
+ "aria-rowcount",
60
+ "aria-rowindex",
61
+ "aria-rowspan",
62
+ "aria-selected",
63
+ "aria-setsize",
64
+ "aria-sort",
65
+ "aria-valuemax",
66
+ "aria-valuemin",
67
+ "aria-valuenow",
68
+ "aria-valuetext",
69
+ ]);
70
+ /** 有效的 ARIA role 列表 */
71
+ const VALID_ROLES = new Set([
72
+ "alert",
73
+ "alertdialog",
74
+ "application",
75
+ "article",
76
+ "banner",
77
+ "button",
78
+ "cell",
79
+ "checkbox",
80
+ "columnheader",
81
+ "combobox",
82
+ "complementary",
83
+ "contentinfo",
84
+ "definition",
85
+ "dialog",
86
+ "directory",
87
+ "document",
88
+ "feed",
89
+ "figure",
90
+ "form",
91
+ "grid",
92
+ "gridcell",
93
+ "group",
94
+ "heading",
95
+ "img",
96
+ "link",
97
+ "list",
98
+ "listbox",
99
+ "listitem",
100
+ "log",
101
+ "main",
102
+ "marquee",
103
+ "math",
104
+ "menu",
105
+ "menubar",
106
+ "menuitem",
107
+ "menuitemcheckbox",
108
+ "menuitemradio",
109
+ "navigation",
110
+ "none",
111
+ "note",
112
+ "option",
113
+ "presentation",
114
+ "progressbar",
115
+ "radio",
116
+ "radiogroup",
117
+ "region",
118
+ "row",
119
+ "rowgroup",
120
+ "rowheader",
121
+ "scrollbar",
122
+ "search",
123
+ "searchbox",
124
+ "separator",
125
+ "slider",
126
+ "spinbutton",
127
+ "status",
128
+ "switch",
129
+ "tab",
130
+ "table",
131
+ "tablist",
132
+ "tabpanel",
133
+ "term",
134
+ "textbox",
135
+ "timer",
136
+ "toolbar",
137
+ "tooltip",
138
+ "tree",
139
+ "treegrid",
140
+ "treeitem",
141
+ ]);
142
+ /** 需要 label 的表单元素 */
143
+ const FORM_ELEMENTS = new Set([
144
+ "input",
145
+ "select",
146
+ "textarea",
147
+ // 组件库常见命名
148
+ "Input",
149
+ "Select",
150
+ "Textarea",
151
+ "TextField",
152
+ "AutoComplete",
153
+ "DatePicker",
154
+ "TimePicker",
155
+ "Search",
156
+ "Password",
157
+ "Checkbox",
158
+ "Radio",
159
+ "Switch",
160
+ "Slider",
161
+ "Rate",
162
+ "Upload",
163
+ "Cascader",
164
+ "TreeSelect",
165
+ "Mention",
166
+ ]);
167
+ /** 非交互元素(可点击时需要语义化) */
168
+ const NON_INTERACTIVE_TAGS = new Set([
169
+ "div",
170
+ "span",
171
+ "i",
172
+ "em",
173
+ "strong",
174
+ "b",
175
+ "p",
176
+ "h1",
177
+ "h2",
178
+ "h3",
179
+ "h4",
180
+ "h5",
181
+ "h6",
182
+ "section",
183
+ "article",
184
+ "aside",
185
+ "header",
186
+ "footer",
187
+ "nav",
188
+ "main",
189
+ "figure",
190
+ "figcaption",
191
+ ]);
192
+ /** 交互事件处理器 */
193
+ const INTERACTIVE_EVENTS = new Set([
194
+ "onClick",
195
+ "onDoubleClick",
196
+ "onMouseDown",
197
+ "onMouseUp",
198
+ "onMouseEnter",
199
+ "onMouseLeave",
200
+ "onMouseOver",
201
+ "onMouseOut",
202
+ "onTouchStart",
203
+ "onTouchEnd",
204
+ "onTouchMove",
205
+ "onTouchCancel",
206
+ "onPointerDown",
207
+ "onPointerUp",
208
+ ]);
209
+ /** 标准颜色名 → hex */
210
+ const NAMED_COLORS = {
211
+ black: "#000000",
212
+ white: "#ffffff",
213
+ red: "#ff0000",
214
+ green: "#008000",
215
+ blue: "#0000ff",
216
+ yellow: "#ffff00",
217
+ cyan: "#00ffff",
218
+ magenta: "#ff00ff",
219
+ silver: "#c0c0c0",
220
+ gray: "#808080",
221
+ grey: "#808080",
222
+ maroon: "#800000",
223
+ olive: "#808000",
224
+ lime: "#00ff00",
225
+ aqua: "#00ffff",
226
+ teal: "#008080",
227
+ navy: "#000080",
228
+ fuchsia: "#ff00ff",
229
+ purple: "#800080",
230
+ orange: "#ffa500",
231
+ };
232
+ // ============================================================================
233
+ // 规则定义
234
+ // ============================================================================
235
+ exports.a11yRules = [
236
+ {
237
+ id: "a11y-img-alt",
238
+ name: "图片必须有 alt 属性",
239
+ description: "<img> 标签必须包含 alt 属性,即使为空字符串(装饰性图片)",
240
+ severity: "critical",
241
+ category: "accessibility",
242
+ defaultEnabled: true,
243
+ docsUrl: "https://github.com/wzm111/frontend-guardian/blob/main/docs/rules/a11y-img-alt.md",
244
+ frameworks: ["react", "vue", "nextjs", "nuxt", "uniapp", "taro"],
245
+ execute(context) {
246
+ const issues = [];
247
+ const ast = context.utils.parseAST(context.source, {
248
+ ext: (0, common_js_1.getFileExt)(context.filePath),
249
+ });
250
+ if (!ast)
251
+ return issues;
252
+ (0, traverse_1.default)(ast, {
253
+ JSXOpeningElement(path) {
254
+ const tagName = (0, common_js_1.getJSXTagName)(path.node.name);
255
+ if (!tagName)
256
+ return;
257
+ // 检测 <img> 和组件库 Image 组件
258
+ const isImg = tagName === "img" || tagName === "Image";
259
+ if (!isImg)
260
+ return;
261
+ const { altFound, line, column } = checkImgAlt(path.node);
262
+ if (!altFound) {
263
+ issues.push({
264
+ ruleId: "a11y-img-alt",
265
+ title: `<${tagName}> 缺少 alt 属性`,
266
+ description: `图片元素缺少 alt 属性,屏幕阅读器用户无法获取图片信息。装饰性图片可设置 alt=""`,
267
+ severity: "critical",
268
+ file: context.filePath,
269
+ line,
270
+ column,
271
+ source: `<${tagName} ... />`,
272
+ fix: {
273
+ text: `alt=""`,
274
+ description: "为图片添加 alt 属性,装饰性图片可设为空字符串",
275
+ confidence: "high",
276
+ start: { line, column },
277
+ end: { line, column },
278
+ },
279
+ });
280
+ }
281
+ },
282
+ });
283
+ return issues;
284
+ },
285
+ },
286
+ {
287
+ id: "a11y-form-label",
288
+ name: "表单元素必须有 label",
289
+ description: "input、select、textarea 必须关联 label 或通过 aria-label 说明",
290
+ severity: "warning",
291
+ category: "accessibility",
292
+ defaultEnabled: true,
293
+ docsUrl: "https://github.com/wzm111/frontend-guardian/blob/main/docs/rules/a11y-form-label.md",
294
+ frameworks: ["react", "vue", "nextjs", "nuxt", "uniapp", "taro"],
295
+ execute(context) {
296
+ const issues = [];
297
+ const ast = context.utils.parseAST(context.source, {
298
+ ext: (0, common_js_1.getFileExt)(context.filePath),
299
+ });
300
+ if (!ast)
301
+ return issues;
302
+ (0, traverse_1.default)(ast, {
303
+ JSXOpeningElement(path) {
304
+ const tagName = (0, common_js_1.getJSXTagName)(path.node.name);
305
+ if (!tagName || !FORM_ELEMENTS.has(tagName))
306
+ return;
307
+ // 检查是否有 label 关联属性
308
+ const { hasLabel, line, column } = checkFormLabel(path.node);
309
+ if (!hasLabel) {
310
+ // 检查是否有 placeholder(作为降级提示)
311
+ const hasPlaceholder = path.node.attributes.some((attr) => attr.type === "JSXAttribute" &&
312
+ attr.name.type === "JSXIdentifier" &&
313
+ attr.name.name === "placeholder");
314
+ const suggestion = hasPlaceholder
315
+ ? `placeholder 不能替代 label,请添加 aria-label 或关联 <label htmlFor="...">`
316
+ : `请添加 aria-label、aria-labelledby 或关联 <label htmlFor="...">`;
317
+ issues.push({
318
+ ruleId: "a11y-form-label",
319
+ title: `<${tagName}> 缺少 label 关联`,
320
+ description: suggestion,
321
+ severity: "warning",
322
+ file: context.filePath,
323
+ line,
324
+ column,
325
+ source: `<${tagName} ... />`,
326
+ });
327
+ }
328
+ },
329
+ });
330
+ return issues;
331
+ },
332
+ },
333
+ {
334
+ id: "a11y-button-role",
335
+ name: "可点击元素语义化",
336
+ description: "使用 <button> 而非 div/span + onClick 模拟按钮",
337
+ severity: "warning",
338
+ category: "accessibility",
339
+ defaultEnabled: true,
340
+ docsUrl: "https://github.com/wzm111/frontend-guardian/blob/main/docs/rules/a11y-button-role.md",
341
+ frameworks: ["react", "vue", "nextjs", "nuxt", "uniapp", "taro"],
342
+ execute(context) {
343
+ const issues = [];
344
+ const ast = context.utils.parseAST(context.source, {
345
+ ext: (0, common_js_1.getFileExt)(context.filePath),
346
+ });
347
+ if (!ast)
348
+ return issues;
349
+ (0, traverse_1.default)(ast, {
350
+ JSXOpeningElement(path) {
351
+ const tagName = (0, common_js_1.getJSXTagName)(path.node.name);
352
+ if (!tagName || !NON_INTERACTIVE_TAGS.has(tagName))
353
+ return;
354
+ // 检查是否有交互事件
355
+ const hasInteractiveEvent = path.node.attributes.some((attr) => {
356
+ if (attr.type !== "JSXAttribute")
357
+ return false;
358
+ if (attr.name.type !== "JSXIdentifier")
359
+ return false;
360
+ return INTERACTIVE_EVENTS.has(attr.name.name);
361
+ });
362
+ if (!hasInteractiveEvent)
363
+ return;
364
+ // 检查是否已有合适的 role
365
+ const { hasRole, roleValue, hasTabIndex } = checkRole(path.node);
366
+ if (!hasRole) {
367
+ const { line: l, column: c } = path.node.loc?.start || { line: 0, column: 0 };
368
+ issues.push({
369
+ ruleId: "a11y-button-role",
370
+ title: `非交互元素 ${tagName} 绑定了点击事件`,
371
+ description: `${tagName} 不是语义化交互元素,应改为 <button> 或添加 role="button" tabIndex={0},并处理键盘事件(onKeyDown Enter/Space)`,
372
+ severity: "warning",
373
+ file: context.filePath,
374
+ line: l,
375
+ column: c,
376
+ source: `<${tagName} onClick={...} />`,
377
+ fix: {
378
+ text: `<button>`,
379
+ description: "改为 button 标签可能需调整样式",
380
+ confidence: "medium",
381
+ start: { line: l, column: c },
382
+ end: { line: l, column: c + tagName.length + 1 },
383
+ },
384
+ });
385
+ }
386
+ else if (hasRole && roleValue === "button" && !hasTabIndex) {
387
+ const { line: l, column: c } = path.node.loc?.start || { line: 0, column: 0 };
388
+ issues.push({
389
+ ruleId: "a11y-button-role",
390
+ title: `role="button" 缺少 tabIndex`,
391
+ description: `自定义按钮必须设置 tabIndex={0} 以支持键盘导航`,
392
+ severity: "warning",
393
+ file: context.filePath,
394
+ line: l,
395
+ column: c,
396
+ source: `<${tagName} role="button" ... />`,
397
+ });
398
+ }
399
+ },
400
+ });
401
+ return issues;
402
+ },
403
+ },
404
+ {
405
+ id: "a11y-contrast",
406
+ name: "颜色对比度",
407
+ description: "文本与背景色的对比度应满足 WCAG AA 标准 (4.5:1)",
408
+ severity: "suggestion",
409
+ category: "accessibility",
410
+ defaultEnabled: true,
411
+ docsUrl: "https://github.com/wzm111/frontend-guardian/blob/main/docs/rules/a11y-contrast.md",
412
+ frameworks: ["react", "vue", "nextjs", "nuxt"],
413
+ execute(context) {
414
+ const issues = [];
415
+ const ast = context.utils.parseAST(context.source, {
416
+ ext: (0, common_js_1.getFileExt)(context.filePath),
417
+ });
418
+ if (!ast)
419
+ return issues;
420
+ (0, traverse_1.default)(ast, {
421
+ JSXOpeningElement(path) {
422
+ const tagName = (0, common_js_1.getJSXTagName)(path.node.name);
423
+ if (!tagName)
424
+ return;
425
+ // 从 style 属性提取颜色
426
+ const colors = extractStyleColors(path.node);
427
+ if (!colors.fg && !colors.bg)
428
+ return;
429
+ // 尝试推断缺失的颜色(使用上下文或默认值)
430
+ const fg = colors.fg || "#000000";
431
+ const bg = colors.bg || "#ffffff";
432
+ const ratio = calculateContrastRatio(fg, bg);
433
+ if (ratio < 4.5) {
434
+ const { line, column } = path.node.loc?.start || { line: 0, column: 0 };
435
+ issues.push({
436
+ ruleId: "a11y-contrast",
437
+ title: `颜色对比度不足 (${ratio.toFixed(2)}:1)`,
438
+ description: `前景色 ${fg} 与背景色 ${bg} 对比度为 ${ratio.toFixed(2)}:1,不满足 WCAG AA 标准 (4.5:1)。建议使用更深的文字颜色或更浅的背景色`,
439
+ severity: "suggestion",
440
+ file: context.filePath,
441
+ line,
442
+ column,
443
+ source: `color: ${fg}, backgroundColor: ${bg}`,
444
+ });
445
+ }
446
+ },
447
+ });
448
+ return issues;
449
+ },
450
+ },
451
+ {
452
+ id: "a11y-aria-valid",
453
+ name: "ARIA 属性合法性",
454
+ description: "使用正确的 ARIA 角色和属性",
455
+ severity: "warning",
456
+ category: "accessibility",
457
+ defaultEnabled: true,
458
+ docsUrl: "https://github.com/wzm111/frontend-guardian/blob/main/docs/rules/a11y-aria-valid.md",
459
+ frameworks: ["react", "vue", "nextjs", "nuxt", "uniapp", "taro"],
460
+ execute(context) {
461
+ const issues = [];
462
+ const ast = context.utils.parseAST(context.source, {
463
+ ext: (0, common_js_1.getFileExt)(context.filePath),
464
+ });
465
+ if (!ast)
466
+ return issues;
467
+ (0, traverse_1.default)(ast, {
468
+ JSXAttribute(path) {
469
+ const attrName = path.node.name;
470
+ if (attrName.type !== "JSXIdentifier")
471
+ return;
472
+ const name = attrName.name;
473
+ // 1. 检测无效 aria-* 属性
474
+ if (name.startsWith("aria-")) {
475
+ if (!VALID_ARIA_ATTRIBUTES.has(name)) {
476
+ const { line, column } = path.node.loc?.start || { line: 0, column: 0 };
477
+ issues.push({
478
+ ruleId: "a11y-aria-valid",
479
+ title: `无效的 ARIA 属性: ${name}`,
480
+ description: `"${name}" 不是有效的 WAI-ARIA 属性。请查阅 ARIA 1.2 规范`,
481
+ severity: "warning",
482
+ file: context.filePath,
483
+ line,
484
+ column,
485
+ source: `${name}=...`,
486
+ });
487
+ }
488
+ // 2. 检测 aria-hidden="true" 但包含可聚焦元素(简化检测:在当前文件检查)
489
+ if (name === "aria-hidden" &&
490
+ path.node.value?.type === "StringLiteral" &&
491
+ path.node.value.value === "true") {
492
+ const parentElement = path.parentPath;
493
+ if (parentElement?.isJSXOpeningElement()) {
494
+ const hasFocusable = checkHasFocusableChild(parentElement.node);
495
+ if (hasFocusable) {
496
+ const { line, column } = path.node.loc?.start || { line: 0, column: 0 };
497
+ issues.push({
498
+ ruleId: "a11y-aria-valid",
499
+ title: `aria-hidden="true" 包含可聚焦元素`,
500
+ description: 'aria-hidden="true" 的元素内部不应包含可聚焦元素(如 input、button、a[href]),否则会导致键盘用户无法访问',
501
+ severity: "critical",
502
+ file: context.filePath,
503
+ line,
504
+ column,
505
+ source: `aria-hidden="true"`,
506
+ });
507
+ }
508
+ }
509
+ }
510
+ }
511
+ // 3. 检测无效 role 值
512
+ if (name === "role" && path.node.value?.type === "StringLiteral") {
513
+ const roleValue = path.node.value.value;
514
+ if (!VALID_ROLES.has(roleValue)) {
515
+ const { line, column } = path.node.loc?.start || { line: 0, column: 0 };
516
+ issues.push({
517
+ ruleId: "a11y-aria-valid",
518
+ title: `无效的 ARIA role: "${roleValue}"`,
519
+ description: `"${roleValue}" 不是有效的 ARIA role。请查阅 ARIA 1.2 角色列表`,
520
+ severity: "warning",
521
+ file: context.filePath,
522
+ line,
523
+ column,
524
+ source: `role="${roleValue}"`,
525
+ });
526
+ }
527
+ }
528
+ },
529
+ });
530
+ return issues;
531
+ },
532
+ },
533
+ ];
534
+ // ============================================================================
535
+ // 辅助函数
536
+ // ============================================================================
537
+ /** 检查 img 是否有 alt 属性 */
538
+ function checkImgAlt(node) {
539
+ const { line, column } = node.loc?.start || { line: 0, column: 0 };
540
+ let altFound = false;
541
+ for (const attr of node.attributes) {
542
+ if (attr.type !== "JSXAttribute")
543
+ continue;
544
+ if (attr.name.type !== "JSXIdentifier")
545
+ continue;
546
+ if (attr.name.name === "alt") {
547
+ altFound = true;
548
+ break;
549
+ }
550
+ }
551
+ return { altFound, line, column };
552
+ }
553
+ /** 检查表单元素是否有 label 关联 */
554
+ function checkFormLabel(node) {
555
+ const { line, column } = node.loc?.start || { line: 0, column: 0 };
556
+ const labelAttrs = [];
557
+ let hasLabel = false;
558
+ for (const attr of node.attributes) {
559
+ if (attr.type !== "JSXAttribute")
560
+ continue;
561
+ if (attr.name.type !== "JSXIdentifier")
562
+ continue;
563
+ const name = attr.name.name;
564
+ if (name === "aria-label" || name === "aria-labelledby" || name === "id") {
565
+ labelAttrs.push(name);
566
+ hasLabel = true;
567
+ }
568
+ if (name === "aria-label" || name === "aria-labelledby") {
569
+ hasLabel = true;
570
+ }
571
+ }
572
+ return { hasLabel, line, column, labelAttrs };
573
+ }
574
+ /** 检查元素是否有 role 和 tabIndex */
575
+ function checkRole(node) {
576
+ const { line, column } = node.loc?.start || { line: 0, column: 0 };
577
+ let hasRole = false;
578
+ let roleValue = null;
579
+ let hasTabIndex = false;
580
+ for (const attr of node.attributes) {
581
+ if (attr.type !== "JSXAttribute")
582
+ continue;
583
+ if (attr.name.type !== "JSXIdentifier")
584
+ continue;
585
+ const name = attr.name.name;
586
+ if (name === "role" && attr.value?.type === "StringLiteral") {
587
+ hasRole = true;
588
+ roleValue = attr.value.value;
589
+ }
590
+ if (name === "tabIndex" && attr.value) {
591
+ hasTabIndex = true;
592
+ }
593
+ }
594
+ return { hasRole, roleValue, hasTabIndex, line, column };
595
+ }
596
+ /** 检查元素是否包含可聚焦子元素(简化:检查 JSXElement 的子元素) */
597
+ function checkHasFocusableChild(node) {
598
+ // 简化实现:在当前节点属性中检查是否有可聚焦元素的标记
599
+ // 更完整的实现需要遍历 JSX 子树
600
+ const focusableRoles = new Set([
601
+ "button",
602
+ "link",
603
+ "textbox",
604
+ "checkbox",
605
+ "radio",
606
+ "combobox",
607
+ "slider",
608
+ "spinbutton",
609
+ ]);
610
+ for (const attr of node.attributes) {
611
+ if (attr.type !== "JSXAttribute")
612
+ continue;
613
+ if (attr.name.type !== "JSXIdentifier")
614
+ continue;
615
+ const name = attr.name.name;
616
+ if (name === "href")
617
+ return true; // <a href> 可聚焦
618
+ if (name === "tabIndex")
619
+ return true; // 显式 tabIndex 可聚焦
620
+ if (name === "role" && attr.value?.type === "StringLiteral" && focusableRoles.has(attr.value.value)) {
621
+ return true;
622
+ }
623
+ }
624
+ return false;
625
+ }
626
+ // ============================================================================
627
+ // 颜色对比度计算 (WCAG 2.1)
628
+ // ============================================================================
629
+ /** 从 JSX style 属性提取颜色 */
630
+ function extractStyleColors(node) {
631
+ let fg = null;
632
+ let bg = null;
633
+ for (const attr of node.attributes) {
634
+ if (attr.type !== "JSXAttribute")
635
+ continue;
636
+ if (attr.name.type !== "JSXIdentifier")
637
+ continue;
638
+ if (attr.name.name === "style" && attr.value?.type === "JSXExpressionContainer") {
639
+ const expr = attr.value.expression;
640
+ if (expr.type === "ObjectExpression") {
641
+ for (const prop of expr.properties) {
642
+ if (prop.type !== "ObjectProperty")
643
+ continue;
644
+ const key = prop.key.type === "Identifier"
645
+ ? prop.key.name
646
+ : prop.key.type === "StringLiteral"
647
+ ? prop.key.value
648
+ : null;
649
+ if (!key)
650
+ continue;
651
+ if (key === "color" && prop.value.type === "StringLiteral") {
652
+ fg = prop.value.value;
653
+ }
654
+ if ((key === "backgroundColor" || key === "background-color") &&
655
+ prop.value.type === "StringLiteral") {
656
+ bg = prop.value.value;
657
+ }
658
+ }
659
+ }
660
+ }
661
+ // Tailwind 类名中的颜色(简化检测)
662
+ if (attr.name.name === "className" || attr.name.name === "class") {
663
+ const classValue = getStringValue(attr.value);
664
+ if (classValue) {
665
+ const tailwindColor = extractTailwindColor(classValue);
666
+ if (tailwindColor.fg)
667
+ fg = tailwindColor.fg;
668
+ if (tailwindColor.bg)
669
+ bg = tailwindColor.bg;
670
+ }
671
+ }
672
+ }
673
+ return { fg, bg };
674
+ }
675
+ /** 获取 JSX 属性的字符串值 */
676
+ function getStringValue(value) {
677
+ if (!value)
678
+ return null;
679
+ if (value.type === "StringLiteral")
680
+ return value.value;
681
+ if (value.type === "JSXExpressionContainer" && value.expression?.type === "StringLiteral") {
682
+ return value.expression.value;
683
+ }
684
+ return null;
685
+ }
686
+ /** 简化提取 Tailwind 颜色 */
687
+ function extractTailwindColor(classStr) {
688
+ const fg = null;
689
+ const bg = null;
690
+ // Tailwind 文本颜色: text-red-500, text-gray-900, text-white
691
+ const textMatch = classStr.match(/\btext-(black|white|red|green|blue|yellow|gray|slate|zinc|neutral|stone|orange|amber|emerald|teal|cyan|sky|indigo|violet|purple|fuchsia|pink|rose)-(\d{2,3})\b/);
692
+ if (textMatch) {
693
+ // 简化为近似 hex(不做完整 Tailwind 色板映射,只处理基本色)
694
+ const color = textMatch[1];
695
+ const shade = parseInt(textMatch[2]);
696
+ return {
697
+ fg: approximateTailwindColor(color, shade),
698
+ bg,
699
+ };
700
+ }
701
+ // Tailwind 背景色: bg-red-500
702
+ const bgMatch = classStr.match(/\bbg-(black|white|red|green|blue|yellow|gray|slate|zinc|neutral|stone|orange|amber|emerald|teal|cyan|sky|indigo|violet|purple|fuchsia|pink|rose)-(\d{2,3})\b/);
703
+ if (bgMatch) {
704
+ const color = bgMatch[1];
705
+ const shade = parseInt(bgMatch[2]);
706
+ return {
707
+ fg,
708
+ bg: approximateTailwindColor(color, shade),
709
+ };
710
+ }
711
+ return { fg, bg };
712
+ }
713
+ /** Tailwind 颜色近似值(简化映射) */
714
+ function approximateTailwindColor(color, shade) {
715
+ // 简化:深色(shade >= 700)近似为深色,浅色近似为浅色
716
+ const dark = shade >= 600;
717
+ const mid = shade >= 400 && shade < 600;
718
+ const colorMap = {
719
+ black: { dark: "#000000", mid: "#000000", light: "#000000" },
720
+ white: { dark: "#ffffff", mid: "#ffffff", light: "#ffffff" },
721
+ gray: { dark: "#374151", mid: "#9ca3af", light: "#d1d5db" },
722
+ slate: { dark: "#334155", mid: "#94a3b8", light: "#cbd5e1" },
723
+ red: { dark: "#dc2626", mid: "#f87171", light: "#fca5a5" },
724
+ green: { dark: "#16a34a", mid: "#4ade80", light: "#86efac" },
725
+ blue: { dark: "#2563eb", mid: "#60a5fa", light: "#93c5fd" },
726
+ yellow: { dark: "#ca8a04", mid: "#facc15", light: "#fde047" },
727
+ orange: { dark: "#ea580c", mid: "#fb923c", light: "#fdba74" },
728
+ };
729
+ const mapping = colorMap[color];
730
+ if (!mapping)
731
+ return dark ? "#333333" : "#ffffff";
732
+ return dark ? mapping.dark : mid ? mapping.mid : mapping.light;
733
+ }
734
+ /** 计算对比度 */
735
+ function calculateContrastRatio(fg, bg) {
736
+ const l1 = getLuminance(fg);
737
+ const l2 = getLuminance(bg);
738
+ const lighter = Math.max(l1, l2);
739
+ const darker = Math.min(l1, l2);
740
+ return (lighter + 0.05) / (darker + 0.05);
741
+ }
742
+ /** 获取颜色的亮度 */
743
+ function getLuminance(colorStr) {
744
+ const rgb = parseColor(colorStr);
745
+ if (!rgb)
746
+ return 1;
747
+ const [r, g, b] = rgb.map((c) => {
748
+ const s = c / 255;
749
+ return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
750
+ });
751
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b;
752
+ }
753
+ /** 解析颜色为 RGB */
754
+ function parseColor(colorStr) {
755
+ colorStr = colorStr.trim().toLowerCase();
756
+ // 1. Named colors
757
+ if (NAMED_COLORS[colorStr]) {
758
+ colorStr = NAMED_COLORS[colorStr];
759
+ }
760
+ // 2. Hex: #fff, #ffffff
761
+ if (colorStr.startsWith("#")) {
762
+ let hex = colorStr.slice(1);
763
+ if (hex.length === 3) {
764
+ hex = hex
765
+ .split("")
766
+ .map((c) => c + c)
767
+ .join("");
768
+ }
769
+ if (hex.length === 6) {
770
+ return [parseInt(hex.slice(0, 2), 16), parseInt(hex.slice(2, 4), 16), parseInt(hex.slice(4, 6), 16)];
771
+ }
772
+ return null;
773
+ }
774
+ // 3. rgb(r, g, b)
775
+ const rgbMatch = colorStr.match(/rgba?\s*\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/);
776
+ if (rgbMatch) {
777
+ return [parseInt(rgbMatch[1], 10), parseInt(rgbMatch[2], 10), parseInt(rgbMatch[3], 10)];
778
+ }
779
+ return null;
780
+ }
781
+ //# sourceMappingURL=a11y-scanner.js.map