@lifeonlars/prime-yggdrasil 0.2.6 → 0.4.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.
@@ -0,0 +1,433 @@
1
+ import { readFileSync, readdirSync, statSync } from 'fs';
2
+ import { join, extname } from 'path';
3
+
4
+ // Import Phase 6 rules
5
+ import interactionPatternsRules from '../rules/interaction-patterns/index.js';
6
+ import accessibilityRules from '../rules/accessibility/index.js';
7
+
8
+ /**
9
+ * Validate command - Report-only mode
10
+ *
11
+ * Scans the project for design system violations and reports them.
12
+ * Does NOT block builds - use for analysis and adoption phase.
13
+ *
14
+ * For autofix suggestions, use the audit command instead.
15
+ */
16
+
17
+ // Import validation logic from ESLint rules
18
+ // Since we can't directly import ESLint rules into CLI without ESLint dependency,
19
+ // we'll implement simplified validation logic here that shares the same concepts
20
+
21
+ const PRIMEREACT_COMPONENTS = [
22
+ 'AutoComplete', 'Calendar', 'CascadeSelect', 'Checkbox', 'Chips', 'ColorPicker',
23
+ 'Dropdown', 'Editor', 'FloatLabel', 'IconField', 'InputGroup', 'InputMask',
24
+ 'InputNumber', 'InputOtp', 'InputSwitch', 'InputText', 'InputTextarea',
25
+ 'Knob', 'Listbox', 'MultiSelect', 'Password', 'RadioButton', 'Rating',
26
+ 'SelectButton', 'Slider', 'TreeSelect', 'TriStateCheckbox', 'ToggleButton',
27
+ 'Button', 'SpeedDial', 'SplitButton',
28
+ 'DataTable', 'DataView', 'OrderList', 'OrganizationChart', 'Paginator',
29
+ 'PickList', 'Timeline', 'Tree', 'TreeTable', 'VirtualScroller',
30
+ 'Accordion', 'AccordionTab', 'Card', 'DeferredContent', 'Divider',
31
+ 'Fieldset', 'Panel', 'ScrollPanel', 'Splitter', 'SplitterPanel',
32
+ 'Stepper', 'StepperPanel', 'TabView', 'TabPanel', 'Toolbar',
33
+ 'ConfirmDialog', 'ConfirmPopup', 'Dialog', 'DynamicDialog', 'OverlayPanel',
34
+ 'Sidebar', 'Tooltip', 'FileUpload',
35
+ 'Breadcrumb', 'ContextMenu', 'Dock', 'Menu', 'Menubar', 'MegaMenu',
36
+ 'PanelMenu', 'Steps', 'TabMenu', 'TieredMenu', 'Chart',
37
+ 'Message', 'Messages', 'Toast',
38
+ 'Carousel', 'Galleria', 'Image',
39
+ 'Avatar', 'AvatarGroup', 'Badge', 'BlockUI', 'Chip', 'Inplace',
40
+ 'MeterGroup', 'ProgressBar', 'ProgressSpinner', 'ScrollTop', 'Skeleton',
41
+ 'Tag', 'Terminal'
42
+ ];
43
+
44
+ const UTILITY_PATTERNS = [
45
+ /^bg-[a-z]+-\d+$/, /^text-[a-z]+-\d+$/, /^border-[a-z]+-\d+$/,
46
+ /^rounded(-[a-z]+)?(-\d+)?$/, /^shadow(-\d+)?$/,
47
+ /^p-\d+$/, /^m-\d+$/, /^gap-\d+$/
48
+ ];
49
+
50
+ const TAILWIND_PATTERNS = [
51
+ /\w+-\[.+\]/, // Arbitrary values
52
+ /^(hover|focus|dark|sm|md|lg|xl):/, // Modifiers
53
+ /^space-(x|y)-\d+$/, /^ring(-\d+)?$/, /^backdrop-/
54
+ ];
55
+
56
+ const HEX_COLOR = /^#[0-9a-fA-F]{3,8}$/;
57
+ const RGB_COLOR = /rgba?\(/;
58
+ const HSL_COLOR = /hsla?\(/;
59
+
60
+ /**
61
+ * Validation rules
62
+ */
63
+ const RULES = {
64
+ 'no-utility-on-components': {
65
+ name: 'No PrimeFlex on PrimeReact Components',
66
+ severity: 'error',
67
+ check: (content, filePath) => {
68
+ const violations = [];
69
+ const lines = content.split('\n');
70
+
71
+ lines.forEach((line, index) => {
72
+ // Find JSX tags with className
73
+ PRIMEREACT_COMPONENTS.forEach(component => {
74
+ const regex = new RegExp(`<${component}[^>]*className=["'\`]([^"'\`]+)["'\`]`, 'g');
75
+ let match;
76
+
77
+ while ((match = regex.exec(line)) !== null) {
78
+ const classes = match[1].split(/\s+/);
79
+ const utilityClasses = classes.filter(c =>
80
+ UTILITY_PATTERNS.some(p => p.test(c))
81
+ );
82
+
83
+ if (utilityClasses.length > 0) {
84
+ violations.push({
85
+ line: index + 1,
86
+ column: match.index,
87
+ message: `PrimeFlex utility classes "${utilityClasses.join(', ')}" on <${component}>`,
88
+ suggestion: `Remove utility classes. The theme handles component styling.`
89
+ });
90
+ }
91
+ }
92
+ });
93
+ });
94
+
95
+ return violations;
96
+ }
97
+ },
98
+
99
+ 'no-tailwind': {
100
+ name: 'No Tailwind CSS Classes',
101
+ severity: 'error',
102
+ check: (content, filePath) => {
103
+ const violations = [];
104
+ const lines = content.split('\n');
105
+
106
+ lines.forEach((line, index) => {
107
+ // Find className attributes
108
+ const regex = /className=["'`]([^"'`]+)["'`]/g;
109
+ let match;
110
+
111
+ while ((match = regex.exec(line)) !== null) {
112
+ const classes = match[1].split(/\s+/);
113
+ const tailwindClasses = classes.filter(c =>
114
+ TAILWIND_PATTERNS.some(p => p.test(c))
115
+ );
116
+
117
+ if (tailwindClasses.length > 0) {
118
+ violations.push({
119
+ line: index + 1,
120
+ column: match.index,
121
+ message: `Tailwind classes "${tailwindClasses.join(', ')}" detected`,
122
+ suggestion: `Use PrimeFlex for layout or semantic tokens for design.`
123
+ });
124
+ }
125
+ }
126
+ });
127
+
128
+ return violations;
129
+ }
130
+ },
131
+
132
+ 'no-hardcoded-colors': {
133
+ name: 'No Hardcoded Colors',
134
+ severity: 'error',
135
+ check: (content, filePath) => {
136
+ const violations = [];
137
+ const lines = content.split('\n');
138
+
139
+ lines.forEach((line, index) => {
140
+ // Check for hex colors
141
+ const hexMatches = line.match(HEX_COLOR);
142
+ if (hexMatches) {
143
+ violations.push({
144
+ line: index + 1,
145
+ column: line.indexOf(hexMatches[0]),
146
+ message: `Hardcoded hex color "${hexMatches[0]}"`,
147
+ suggestion: `Use semantic token: var(--surface-neutral-primary) or var(--text-neutral-default)`
148
+ });
149
+ }
150
+
151
+ // Check for rgb/rgba
152
+ if (RGB_COLOR.test(line)) {
153
+ violations.push({
154
+ line: index + 1,
155
+ column: line.search(RGB_COLOR),
156
+ message: `Hardcoded RGB color detected`,
157
+ suggestion: `Use semantic token: var(--surface-neutral-primary)`
158
+ });
159
+ }
160
+
161
+ // Check for hsl/hsla
162
+ if (HSL_COLOR.test(line)) {
163
+ violations.push({
164
+ line: index + 1,
165
+ column: line.search(HSL_COLOR),
166
+ message: `Hardcoded HSL color detected`,
167
+ suggestion: `Use semantic token: var(--surface-neutral-primary)`
168
+ });
169
+ }
170
+ });
171
+
172
+ return violations;
173
+ }
174
+ },
175
+
176
+ 'semantic-tokens-only': {
177
+ name: 'Semantic Tokens Only',
178
+ severity: 'warning',
179
+ check: (content, filePath) => {
180
+ const violations = [];
181
+ const lines = content.split('\n');
182
+
183
+ lines.forEach((line, index) => {
184
+ // Check for foundation tokens
185
+ const foundationTokens = line.match(/var\(--(blue|green|red|yellow|gray|purple|pink|indigo|teal|orange|cyan|bluegray)-\d+\)/g);
186
+ if (foundationTokens) {
187
+ foundationTokens.forEach(token => {
188
+ violations.push({
189
+ line: index + 1,
190
+ column: line.indexOf(token),
191
+ message: `Foundation token "${token}" in app code`,
192
+ suggestion: `Use semantic token: var(--surface-brand-primary) or var(--text-neutral-default)`
193
+ });
194
+ });
195
+ }
196
+ });
197
+
198
+ return violations;
199
+ }
200
+ },
201
+
202
+ 'valid-spacing': {
203
+ name: 'Valid 4px Grid Spacing',
204
+ severity: 'warning',
205
+ check: (content, filePath) => {
206
+ const violations = [];
207
+ const lines = content.split('\n');
208
+
209
+ const validPx = [0, 4, 8, 12, 16, 20, 24, 28, 32];
210
+ const spacingProps = ['padding', 'margin', 'gap'];
211
+
212
+ lines.forEach((line, index) => {
213
+ spacingProps.forEach(prop => {
214
+ // Check for px values
215
+ const regex = new RegExp(`${prop}[^:]*:\\s*['"]?(\\d+)px`, 'g');
216
+ let match;
217
+
218
+ while ((match = regex.exec(line)) !== null) {
219
+ const value = parseInt(match[1], 10);
220
+ if (!validPx.includes(value) && value !== 1) {
221
+ const nearest = validPx.reduce((prev, curr) =>
222
+ Math.abs(curr - value) < Math.abs(prev - value) ? curr : prev
223
+ );
224
+ violations.push({
225
+ line: index + 1,
226
+ column: match.index,
227
+ message: `Off-grid spacing: ${value}px`,
228
+ suggestion: `Use nearest 4px grid value: ${nearest}px (${nearest / 16}rem)`
229
+ });
230
+ }
231
+ }
232
+ });
233
+
234
+ // Check for invalid PrimeFlex classes (p-9, p-10, etc.)
235
+ const invalidClasses = line.match(/[pm][trblxy]?-([9-9]\d+)/g);
236
+ if (invalidClasses) {
237
+ violations.push({
238
+ line: index + 1,
239
+ column: line.indexOf(invalidClasses[0]),
240
+ message: `Invalid PrimeFlex spacing: ${invalidClasses.join(', ')}`,
241
+ suggestion: `Use p-0 through p-8 (0-32px in 4px increments)`
242
+ });
243
+ }
244
+ });
245
+
246
+ return violations;
247
+ }
248
+ },
249
+
250
+ // Phase 6 Rules: Interaction Patterns
251
+ ...Object.fromEntries(
252
+ Object.entries(interactionPatternsRules).map(([key, rule]) => [
253
+ key,
254
+ {
255
+ name: rule.name,
256
+ severity: rule.severity,
257
+ check: (content, filePath) => rule.validate(content, filePath)
258
+ }
259
+ ])
260
+ ),
261
+
262
+ // Phase 6 Rules: Accessibility
263
+ ...Object.fromEntries(
264
+ Object.entries(accessibilityRules).map(([key, rule]) => [
265
+ key,
266
+ {
267
+ name: rule.name,
268
+ severity: rule.severity,
269
+ check: (content, filePath) => rule.validate(content, filePath)
270
+ }
271
+ ])
272
+ )
273
+ };
274
+
275
+ /**
276
+ * Find all relevant files in directory
277
+ */
278
+ function findFiles(dir, extensions = ['.tsx', '.jsx', '.ts', '.js']) {
279
+ const files = [];
280
+
281
+ function walk(currentDir) {
282
+ try {
283
+ const entries = readdirSync(currentDir);
284
+
285
+ entries.forEach(entry => {
286
+ const fullPath = join(currentDir, entry);
287
+
288
+ // Skip node_modules, .git, dist, build
289
+ if (['node_modules', '.git', 'dist', 'build', '.next'].includes(entry)) {
290
+ return;
291
+ }
292
+
293
+ try {
294
+ const stat = statSync(fullPath);
295
+
296
+ if (stat.isDirectory()) {
297
+ walk(fullPath);
298
+ } else if (extensions.includes(extname(fullPath))) {
299
+ files.push(fullPath);
300
+ }
301
+ } catch (err) {
302
+ // Skip files we can't access
303
+ }
304
+ });
305
+ } catch (err) {
306
+ // Skip directories we can't access
307
+ }
308
+ }
309
+
310
+ walk(dir);
311
+ return files;
312
+ }
313
+
314
+ /**
315
+ * Run validation on a file
316
+ */
317
+ function validateFile(filePath, rules) {
318
+ try {
319
+ const content = readFileSync(filePath, 'utf8');
320
+ const results = {};
321
+
322
+ Object.entries(rules).forEach(([ruleId, rule]) => {
323
+ const violations = rule.check(content, filePath);
324
+ if (violations.length > 0) {
325
+ results[ruleId] = {
326
+ rule: rule.name,
327
+ severity: rule.severity,
328
+ violations
329
+ };
330
+ }
331
+ });
332
+
333
+ return results;
334
+ } catch (err) {
335
+ return { error: err.message };
336
+ }
337
+ }
338
+
339
+ /**
340
+ * Format validation results
341
+ */
342
+ function formatResults(results, format = 'cli') {
343
+ if (format === 'json') {
344
+ return JSON.stringify(results, null, 2);
345
+ }
346
+
347
+ // CLI format
348
+ let output = '\n';
349
+ let totalViolations = 0;
350
+ let errorCount = 0;
351
+ let warningCount = 0;
352
+
353
+ Object.entries(results).forEach(([filePath, fileResults]) => {
354
+ if (fileResults.error) {
355
+ output += `❌ Error reading ${filePath}: ${fileResults.error}\n`;
356
+ return;
357
+ }
358
+
359
+ const violations = Object.values(fileResults);
360
+ if (violations.length === 0) return;
361
+
362
+ output += `\n📄 ${filePath}\n`;
363
+
364
+ Object.entries(fileResults).forEach(([ruleId, result]) => {
365
+ result.violations.forEach(violation => {
366
+ totalViolations++;
367
+ const icon = result.severity === 'error' ? '❌' : '⚠️';
368
+ if (result.severity === 'error') errorCount++;
369
+ else warningCount++;
370
+
371
+ output += ` ${icon} ${result.rule}\n`;
372
+ output += ` Line ${violation.line}, Col ${violation.column}\n`;
373
+ output += ` ${violation.message}\n`;
374
+ output += ` 💡 ${violation.suggestion}\n\n`;
375
+ });
376
+ });
377
+ });
378
+
379
+ if (totalViolations === 0) {
380
+ output += '✅ No violations found!\n\n';
381
+ } else {
382
+ output += `\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n`;
383
+ output += `📊 Summary: ${totalViolations} violations found\n`;
384
+ output += ` ${errorCount} errors, ${warningCount} warnings\n\n`;
385
+ }
386
+
387
+ return output;
388
+ }
389
+
390
+ /**
391
+ * Main validate command
392
+ */
393
+ export async function validateCommand(options = {}) {
394
+ const cwd = options.cwd || process.cwd();
395
+ const format = options.format || 'cli';
396
+ const rulesFilter = options.rules ? options.rules.split(',') : Object.keys(RULES);
397
+
398
+ console.log(`
399
+ 🌳 Yggdrasil Design System Validation
400
+ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
401
+
402
+ 📂 Scanning directory: ${cwd}
403
+ 🔍 Active rules: ${rulesFilter.length}
404
+ `);
405
+
406
+ // Filter rules
407
+ const activeRules = {};
408
+ rulesFilter.forEach(ruleId => {
409
+ if (RULES[ruleId]) {
410
+ activeRules[ruleId] = RULES[ruleId];
411
+ }
412
+ });
413
+
414
+ // Find files
415
+ const files = findFiles(cwd);
416
+ console.log(`📝 Found ${files.length} files to check\n`);
417
+
418
+ // Validate files
419
+ const results = {};
420
+ files.forEach(file => {
421
+ const fileResults = validateFile(file, activeRules);
422
+ if (Object.keys(fileResults).length > 0) {
423
+ results[file] = fileResults;
424
+ }
425
+ });
426
+
427
+ // Output results
428
+ const output = formatResults(results, format);
429
+ console.log(output);
430
+
431
+ // Return results for programmatic use
432
+ return results;
433
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Accessibility Rules
3
+ *
4
+ * Phase 6 - WCAG 2.1 AA compliance enforcement
5
+ *
6
+ * All rules enforce patterns from .ai/agents/accessibility.md
7
+ */
8
+
9
+ import missingAltText from './missing-alt-text.js';
10
+ import missingFormLabels from './missing-form-labels.js';
11
+
12
+ export default {
13
+ 'accessibility/missing-alt-text': missingAltText,
14
+ 'accessibility/missing-form-labels': missingFormLabels,
15
+ };
@@ -0,0 +1,201 @@
1
+ /**
2
+ * Accessibility Rule: Missing Alt Text
3
+ *
4
+ * Enforces alt text on images and aria-label on icon-only buttons.
5
+ * WCAG 2.1 Level AA - Guideline 1.1 Text Alternatives
6
+ *
7
+ * Phase 6 - Accessibility Agent
8
+ */
9
+
10
+ export default {
11
+ name: 'accessibility/missing-alt-text',
12
+ description: 'Enforce alt text on images and aria-label on icon-only buttons',
13
+ category: 'accessibility',
14
+ severity: 'error',
15
+ documentation: 'https://github.com/lifeonlars/prime-yggdrasil/blob/master/.ai/agents/accessibility.md#11-text-alternatives',
16
+
17
+ validate(fileContent, filePath) {
18
+ const violations = [];
19
+
20
+ // Skip non-React/TSX files
21
+ if (!filePath.match(/\.(jsx|tsx|js|ts)$/)) {
22
+ return violations;
23
+ }
24
+
25
+ // Pattern 1: Check <img> tags for alt attribute
26
+ const imgRegex = /<img([^>]*)>/g;
27
+ let match;
28
+
29
+ while ((match = imgRegex.exec(fileContent)) !== null) {
30
+ const imgTag = match[1];
31
+ const line = this.getLineNumber(fileContent, match.index);
32
+
33
+ if (!imgTag.includes('alt=')) {
34
+ violations.push({
35
+ line,
36
+ column: 1,
37
+ message: 'Image missing alt attribute. Add alt text or alt="" for decorative images.',
38
+ severity: 'error',
39
+ rule: this.name,
40
+ suggestion: 'alt="Description of image content" or alt="" for decorative',
41
+ });
42
+ }
43
+
44
+ // Check for empty alt with src that suggests non-decorative
45
+ if (imgTag.includes('alt=""') || imgTag.includes("alt=''")) {
46
+ const srcMatch = imgTag.match(/src=["']([^"']+)["']/);
47
+ if (srcMatch) {
48
+ const src = srcMatch[1];
49
+ const nonDecorativeKeywords = ['chart', 'graph', 'diagram', 'photo', 'avatar', 'profile'];
50
+
51
+ if (nonDecorativeKeywords.some(keyword => src.toLowerCase().includes(keyword))) {
52
+ violations.push({
53
+ line,
54
+ column: 1,
55
+ message: 'Image appears non-decorative but has empty alt. Add descriptive alt text.',
56
+ severity: 'warning',
57
+ rule: this.name,
58
+ suggestion: `alt="Description of ${src}"`,
59
+ });
60
+ }
61
+ }
62
+ }
63
+ }
64
+
65
+ // Pattern 2: Check PrimeReact Image component
66
+ const primeImageRegex = /<Image([^>]*)>/g;
67
+
68
+ while ((match = primeImageRegex.exec(fileContent)) !== null) {
69
+ const imageTag = match[1];
70
+ const line = this.getLineNumber(fileContent, match.index);
71
+
72
+ if (!imageTag.includes('alt=')) {
73
+ violations.push({
74
+ line,
75
+ column: 1,
76
+ message: 'PrimeReact Image missing alt attribute.',
77
+ severity: 'error',
78
+ rule: this.name,
79
+ suggestion: 'Add alt="Description" prop',
80
+ });
81
+ }
82
+ }
83
+
84
+ // Pattern 3: Check icon-only buttons for aria-label
85
+ const buttonRegex = /<Button([^>]*)>/g;
86
+
87
+ while ((match = buttonRegex.exec(fileContent)) !== null) {
88
+ const buttonTag = match[1];
89
+ const line = this.getLineNumber(fileContent, match.index);
90
+
91
+ const hasIcon = buttonTag.includes('icon=');
92
+ const hasLabel = buttonTag.includes('label=');
93
+ const hasAriaLabel = buttonTag.includes('ariaLabel=') || buttonTag.includes('aria-label=');
94
+
95
+ // Icon-only button (has icon but no label)
96
+ if (hasIcon && !hasLabel && !hasAriaLabel) {
97
+ violations.push({
98
+ line,
99
+ column: 1,
100
+ message: 'Icon-only button missing ariaLabel. Screen readers need descriptive label.',
101
+ severity: 'error',
102
+ rule: this.name,
103
+ suggestion: 'Add ariaLabel="Description of action"',
104
+ });
105
+ }
106
+ }
107
+
108
+ // Pattern 4: Check custom icon buttons (i tag with onClick)
109
+ const iconClickRegex = /<i[^>]*className=["'][^"']*pi[^"']*["'][^>]*onClick/g;
110
+
111
+ while ((match = iconClickRegex.exec(fileContent)) !== null) {
112
+ const line = this.getLineNumber(fileContent, match.index);
113
+
114
+ violations.push({
115
+ line,
116
+ column: 1,
117
+ message: 'Icon with onClick should be wrapped in Button with ariaLabel, not clickable <i> tag.',
118
+ severity: 'error',
119
+ rule: this.name,
120
+ suggestion: '<Button icon="pi pi-..." ariaLabel="Action" onClick={handler} />',
121
+ });
122
+ }
123
+
124
+ // Pattern 5: Check Avatar component without alt or image
125
+ const avatarRegex = /<Avatar([^>]*)>/g;
126
+
127
+ while ((match = avatarRegex.exec(fileContent)) !== null) {
128
+ const avatarTag = match[1];
129
+ const line = this.getLineNumber(fileContent, match.index);
130
+
131
+ const hasImage = avatarTag.includes('image=');
132
+ const hasLabel = avatarTag.includes('label=');
133
+ const hasAlt = avatarTag.includes('alt=');
134
+ const hasAriaLabel = avatarTag.includes('ariaLabel=') || avatarTag.includes('aria-label=');
135
+
136
+ if (hasImage && !hasAlt && !hasAriaLabel) {
137
+ violations.push({
138
+ line,
139
+ column: 1,
140
+ message: 'Avatar with image should have alt or ariaLabel for accessibility.',
141
+ severity: 'warning',
142
+ rule: this.name,
143
+ suggestion: 'Add alt="User name" or ariaLabel="User name"',
144
+ });
145
+ }
146
+ }
147
+
148
+ return violations;
149
+ },
150
+
151
+ getLineNumber(content, index) {
152
+ return content.substring(0, index).split('\n').length;
153
+ },
154
+
155
+ autofix(fileContent, violation) {
156
+ if (violation.message.includes('Image missing alt attribute')) {
157
+ // Add alt="" as safe default (user must fill in description)
158
+ const imgRegex = /(<img[^>]*)(>)/;
159
+ const match = imgRegex.exec(fileContent);
160
+
161
+ if (match && !match[1].includes('alt=')) {
162
+ const fixedContent = fileContent.replace(
163
+ match[0],
164
+ `${match[1]} alt=""${match[2]}`
165
+ );
166
+
167
+ return {
168
+ fixed: true,
169
+ content: fixedContent,
170
+ message: 'Added empty alt attribute (add description if image is not decorative)',
171
+ };
172
+ }
173
+ }
174
+
175
+ if (violation.message.includes('Icon-only button missing ariaLabel')) {
176
+ const buttonRegex = /(<Button[^>]*icon=["'][^"']+["'][^>]*)(>)/;
177
+ const match = buttonRegex.exec(fileContent);
178
+
179
+ if (match && !match[1].includes('ariaLabel')) {
180
+ const iconMatch = match[1].match(/icon=["']pi pi-([^"']+)["']/);
181
+ const iconName = iconMatch ? iconMatch[1] : 'action';
182
+
183
+ const fixedContent = fileContent.replace(
184
+ match[0],
185
+ `${match[1]} ariaLabel="${iconName}"${match[2]}`
186
+ );
187
+
188
+ return {
189
+ fixed: true,
190
+ content: fixedContent,
191
+ message: `Added ariaLabel="${iconName}" (customize for specific action)`,
192
+ };
193
+ }
194
+ }
195
+
196
+ return {
197
+ fixed: false,
198
+ message: 'Manual fix required. Add descriptive alt text or aria-label.',
199
+ };
200
+ },
201
+ };