diff --git a/src/cli/services/utils/html-generator.ts b/src/cli/services/utils/html-generator.ts index 0a7e837..2621cfa 100644 --- a/src/cli/services/utils/html-generator.ts +++ b/src/cli/services/utils/html-generator.ts @@ -165,8 +165,10 @@ export class HtmlGeneratorService { (violation) => `
-

${violation.id}

+

${violation.title || violation.id}

+ ${violation.summary ? `

${this.escapeHtml(violation.summary)}

` : ''}

${violation.description}

+ ${violation.explanation ? `

${this.escapeHtml(violation.explanation)}

` : ''}
${violation.impact || 'unknown'} impact @@ -175,6 +177,67 @@ export class HtmlGeneratorService {
+ ${ + violation.wcag && violation.wcag.length > 0 + ? ` +
+
WCAG Standards
+ +
+ ` + : '' + } + ${ + violation.act_rules && violation.act_rules.length > 0 + ? ` +
+
ACT Rules
+ +
+ ` + : '' + } + ${ + violation.supporting_links && violation.supporting_links.length > 0 + ? ` + + ` + : '' + } ${ violation.nodes && violation.nodes.length > 0 ? this.generateCollapsibleCode(violation.nodes, 'problem') @@ -233,16 +296,76 @@ export class HtmlGeneratorService { (violation) => `
-

${violation.id}

+

${violation.title || violation.id}

+ ${violation.summary ? `

${this.escapeHtml(violation.summary)}

` : ''}

${violation.description}

+ ${violation.explanation ? `

${this.escapeHtml(violation.explanation)}

` : ''}
${violation.impact || 'unknown'} impact -
- Help: ${violation.help} -
+ ${ + violation.wcag && violation.wcag.length > 0 + ? ` +
+
WCAG Standards
+ +
+ ` + : '' + } + ${ + violation.act_rules && violation.act_rules.length > 0 + ? ` +
+
ACT Rules
+ +
+ ` + : '' + } + ${ + violation.supporting_links && violation.supporting_links.length > 0 + ? ` + + ` + : '' + } ${ violation.nodes && violation.nodes.length > 0 ? this.generateCollapsibleCode(violation.nodes, 'problem') @@ -385,36 +508,107 @@ export class HtmlGeneratorService { .map( (violation: any) => `
-
${this.escapeHtml(violation.id)}
+
${this.escapeHtml(violation.title || violation.id)}
+ ${violation.summary ? `

${this.escapeHtml(violation.summary)}

` : ''}

${this.escapeHtml(violation.description)}

+ ${violation.explanation ? `

${this.escapeHtml(violation.explanation)}

` : ''}
${violation.impact || 'unknown'} impact - - Help: ${this.escapeHtml(violation.help)} -
+ ${ + violation.wcag && violation.wcag.length > 0 + ? ` +
+
WCAG Standards
+ +
+ ` + : '' + } + ${ + violation.act_rules && violation.act_rules.length > 0 + ? ` +
+
ACT Rules
+ +
+ ` + : '' + } + ${ + violation.supporting_links && + violation.supporting_links.length > 0 + ? ` + + ` + : '' + } ${ violation.nodes && violation.nodes.length > 0 ? this.generateCollapsibleCode(violation.nodes, 'problem') : '' } + ${ + violation.userStory + ? ` +
+
User Story
+
${this.escapeHtml(violation.userStory)}
+
+ ` + : '' + } ${ violation.aiExplanation ? ` -
-
AI Explanation
-
${this.markdownToHtml(violation.aiExplanation)}
-
+
+
AI Explanation
+
${this.markdownToHtml(violation.aiExplanation)}
+
` : '' } ${ violation.aiRemediation ? ` -
-
Solution
-
${this.markdownToHtml(violation.aiRemediation)}
-
+
+
Solution
+
${this.markdownToHtml(violation.aiRemediation)}
+
` : '' } @@ -567,11 +761,75 @@ export class HtmlGeneratorService { .map( (violation: any) => `
-
${violation.id}
-

${violation.description}

+
${this.escapeHtml(violation.title || violation.id)}
+ ${violation.summary ? `

${this.escapeHtml(violation.summary)}

` : ''} +

${this.escapeHtml(violation.description)}

+ ${violation.explanation ? `

${this.escapeHtml(violation.explanation)}

` : ''}
${violation.impact || 'unknown'} impact
+ ${ + violation.wcag && violation.wcag.length > 0 + ? ` +
+
WCAG Standards
+ +
+ ` + : '' + } + ${ + violation.act_rules && violation.act_rules.length > 0 + ? ` +
+
ACT Rules
+ +
+ ` + : '' + } + ${ + violation.supporting_links && + violation.supporting_links.length > 0 + ? ` + + ` + : '' + } ${ violation.nodes && violation.nodes.length > 0 ? this.generateCollapsibleCode(violation.nodes, 'problem') diff --git a/src/services/user-stories.ts b/src/services/user-stories.ts index 7d28ca0..61e0656 100644 --- a/src/services/user-stories.ts +++ b/src/services/user-stories.ts @@ -38,10 +38,34 @@ export class UserStoryService { if (this.loaded) return; try { - const dataPath = - process.env['NODE_ENV'] === 'production' - ? path.join(__dirname, '..', '..', 'src', 'data', 'rulesData.json') - : path.join(__dirname, '..', 'data', 'rulesData.json'); + let dataPath: string; + const distDataPath = path.join(__dirname, '..', 'data', 'rulesData.json'); + const srcDataPath = path.join( + __dirname, + '..', + '..', + 'src', + 'data', + 'rulesData.json' + ); + + try { + await fs.access(distDataPath); + dataPath = distDataPath; + } catch { + try { + await fs.access(srcDataPath); + dataPath = srcDataPath; + } catch { + const rootDataPath = path.join( + process.cwd(), + 'src', + 'data', + 'rulesData.json' + ); + dataPath = rootDataPath; + } + } const data = await fs.readFile(dataPath, 'utf8'); this.rulesData = JSON.parse(data); @@ -89,7 +113,8 @@ export class UserStoryService { return stories.length > 0; } - getRuleData(ruleId: string) { + async getRuleData(ruleId: string) { + await this.loadRulesData(); return this.rulesData[ruleId]; } } diff --git a/src/types/ai.ts b/src/types/ai.ts index bd3504d..ab88309 100644 --- a/src/types/ai.ts +++ b/src/types/ai.ts @@ -3,7 +3,7 @@ export interface AccessibilityIssue { impact: string; description: string; help: string; - helpUrl: string; + helpUrl?: string; nodes: Array<{ target: string[]; html: string; @@ -12,6 +12,12 @@ export interface AccessibilityIssue { } export interface AIProcessedIssue extends AccessibilityIssue { + title?: string; + summary?: string; + explanation?: string; + wcag?: Array<{ level: string; name: string; link: string }>; + act_rules?: Array<{ name: string; link: string }>; + supporting_links?: Array<{ name: string; link: string }>; aiExplanation?: string; aiRemediation?: string; userStory?: string; diff --git a/src/utils/ai-processor.ts b/src/utils/ai-processor.ts index c31d49f..14f3f0f 100644 --- a/src/utils/ai-processor.ts +++ b/src/utils/ai-processor.ts @@ -3,6 +3,7 @@ import { OpenAIMessage } from '../services/openai'; import { AIPromptEngine, AIResponse } from './ai-prompts'; import { CacheService } from '../services/cache'; import { UserStoryService } from '../services/user-stories'; +import { ViolationTransformer } from './violation-transformer'; import { CacheKey } from '../types/cache'; import { AccessibilityIssue, @@ -36,11 +37,13 @@ export class AIProcessor { let cacheHits = 0; let cacheMisses = 0; + const transformedIssues = await ViolationTransformer.transformMany(issues); + if (!isAIEnabled(apiKey)) { logger.info('AI processing disabled - no API key provided'); return { enabled: false, - issues, + issues: transformedIssues, metadata: { cacheHits: 0, cacheMisses: 0, @@ -56,7 +59,7 @@ export class AIProcessor { logger.error('Failed to initialize OpenAI service'); return { enabled: false, - issues, + issues: transformedIssues, error: 'Failed to initialize OpenAI service', metadata: { cacheHits: 0, @@ -68,17 +71,23 @@ export class AIProcessor { const processedIssues: AIProcessedIssue[] = []; - for (const issue of issues) { - const processedIssue: AIProcessedIssue = { ...issue }; + for (const transformedIssue of transformedIssues) { + const processedIssue: AIProcessedIssue = { + ...transformedIssue, + }; + const originalIssue = + issues.find((i) => i.id === transformedIssue.id) || transformedIssue; try { - const userStory = await this.userStoryService.getUserStory(issue.id); + const userStory = await this.userStoryService.getUserStory( + transformedIssue.id + ); if (userStory) { processedIssue.userStory = userStory; } } catch (error) { logger.warn('Failed to get user story', { - issueId: issue.id, + issueId: transformedIssue.id, error: error instanceof Error ? error.message : 'Unknown error', }); } @@ -86,7 +95,7 @@ export class AIProcessor { try { const { aiResponse, cacheHit } = await this.processIssueWithCache( openaiService, - issue, + originalIssue, projectContext ); @@ -105,12 +114,12 @@ export class AIProcessor { } } catch (error) { logger.warn('AI processing failed for issue', { - issueId: issue.id, + issueId: transformedIssue.id, error: error instanceof Error ? error.message : 'Unknown error', }); - // Use fallback response - const fallbackResponse = AIPromptEngine.createFallbackResponse(issue); + const fallbackResponse = + AIPromptEngine.createFallbackResponse(originalIssue); if (includeExplanations) { processedIssue.aiExplanation = fallbackResponse.plain_explanation; @@ -140,7 +149,7 @@ export class AIProcessor { return { enabled: true, - issues, + issues: transformedIssues, error: error instanceof Error ? error.message : 'Unknown error occurred', metadata: { @@ -156,19 +165,22 @@ export class AIProcessor { issues: AccessibilityIssue[] ): Promise { const startTime = Date.now(); + const transformedIssues = await ViolationTransformer.transformMany(issues); const processedIssues: AIProcessedIssue[] = []; - for (const issue of issues) { - const processedIssue: AIProcessedIssue = { ...issue }; + for (const transformedIssue of transformedIssues) { + const processedIssue: AIProcessedIssue = { ...transformedIssue }; try { - const userStory = await this.userStoryService.getUserStory(issue.id); + const userStory = await this.userStoryService.getUserStory( + transformedIssue.id + ); if (userStory) { processedIssue.userStory = userStory; } } catch (error) { logger.warn('Failed to get user story', { - issueId: issue.id, + issueId: transformedIssue.id, error: error instanceof Error ? error.message : 'Unknown error', }); } @@ -208,11 +220,10 @@ export class AIProcessor { projectContext: projectContext || {}, }; - // Try to get from cache first const cachedEntry = await this.cacheService.get(cacheKey); - if (cachedEntry) { + if (cachedEntry && this.isAIResponse(cachedEntry.value)) { logger.info('Cache hit for AI response', { ruleId: issue.id }); - return { aiResponse: cachedEntry.value as AIResponse, cacheHit: true }; + return { aiResponse: cachedEntry.value, cacheHit: true }; } logger.info('Cache miss for AI response', { ruleId: issue.id }); @@ -239,4 +250,14 @@ export class AIProcessor { return { aiResponse, cacheHit: false }; } + + private isAIResponse(value: unknown): value is AIResponse { + if (!value || typeof value !== 'object') return false; + const obj = value as Record; + return ( + typeof obj['rule_id'] === 'string' && + typeof obj['plain_explanation'] === 'string' && + typeof obj['remediation'] === 'string' + ); + } } diff --git a/src/utils/ai-prompts.ts b/src/utils/ai-prompts.ts index e0d9010..d95bb24 100644 --- a/src/utils/ai-prompts.ts +++ b/src/utils/ai-prompts.ts @@ -61,7 +61,7 @@ Rule ID: ${issue.id} Description: ${issue.description} Impact: ${issue.impact} Help: ${issue.help} -Help URL: ${issue.helpUrl} +${issue.helpUrl ? `Help URL: ${issue.helpUrl}` : ''} ${htmlContexts ? `CURRENT PROBLEMATIC CODE:\n${htmlContexts}\n` : ''} diff --git a/src/utils/violation-transformer.ts b/src/utils/violation-transformer.ts new file mode 100644 index 0000000..e66c484 --- /dev/null +++ b/src/utils/violation-transformer.ts @@ -0,0 +1,64 @@ +import { AccessibilityIssue, AIProcessedIssue } from '../types/ai'; +import { UserStoryService } from '../services/user-stories'; + +interface RuleData { + title: string; + summary: string; + description: string; + severity: string; + type: string; + wcag: Array<{ level: string; name: string; link: string }>; + act_rules?: Array<{ name: string; link: string }>; + supporting_links?: Array<{ name: string; link: string }>; +} + +function isRuleData(data: unknown): data is RuleData { + if (!data || typeof data !== 'object') return false; + const obj = data as Record; + return ( + typeof obj['title'] === 'string' && + typeof obj['summary'] === 'string' && + typeof obj['description'] === 'string' && + typeof obj['severity'] === 'string' && + typeof obj['type'] === 'string' && + Array.isArray(obj['wcag']) + ); +} + +export class ViolationTransformer { + private static userStoryService = UserStoryService.getInstance(); + + static async transform(issue: AccessibilityIssue): Promise { + const rawRuleData = await this.userStoryService.getRuleData(issue.id); + const ruleData = isRuleData(rawRuleData) ? rawRuleData : undefined; + + const transformed: AIProcessedIssue = { + id: issue.id, + impact: issue.impact, + description: issue.description, + help: issue.help, + nodes: issue.nodes, + }; + + if (ruleData) { + transformed.title = ruleData.title; + transformed.summary = ruleData.summary; + transformed.explanation = ruleData.description; + transformed.wcag = ruleData.wcag; + if (ruleData.act_rules && ruleData.act_rules.length > 0) { + transformed.act_rules = ruleData.act_rules; + } + if (ruleData.supporting_links && ruleData.supporting_links.length > 0) { + transformed.supporting_links = ruleData.supporting_links; + } + } + + return transformed; + } + + static async transformMany( + issues: AccessibilityIssue[] + ): Promise { + return Promise.all(issues.map((issue) => this.transform(issue))); + } +} diff --git a/tests/unit/violation-transformer.test.ts b/tests/unit/violation-transformer.test.ts new file mode 100644 index 0000000..af06306 --- /dev/null +++ b/tests/unit/violation-transformer.test.ts @@ -0,0 +1,221 @@ +import { ViolationTransformer } from '../../src/utils/violation-transformer'; +import { AccessibilityIssue, AIProcessedIssue } from '../../src/types/ai'; +import { UserStoryService } from '../../src/services/user-stories'; + +jest.mock('../../src/services/user-stories', () => { + const mockGetRuleData = jest.fn(); + return { + UserStoryService: { + getInstance: jest.fn(() => ({ + getRuleData: mockGetRuleData, + loadRulesData: jest.fn(), + })), + }, + }; +}); + +describe('ViolationTransformer', () => { + let mockGetRuleData: jest.Mock; + + beforeEach(() => { + const mockService = UserStoryService.getInstance(); + mockGetRuleData = mockService.getRuleData as jest.Mock; + mockGetRuleData.mockClear(); + }); + + const mockIssue: AccessibilityIssue = { + id: 'color-contrast', + impact: 'serious', + description: 'Elements must have sufficient color contrast', + help: 'Ensure all text elements have sufficient color contrast', + helpUrl: 'https://dequeuniversity.com/rules/axe/4.8/color-contrast', + nodes: [ + { + target: ['h1'], + html: '

Low contrast text

', + failureSummary: + 'Fix any of the following: Element has insufficient color contrast', + }, + ], + }; + + describe('transform', () => { + it('should remove helpUrl from transformed violation', async () => { + mockGetRuleData.mockResolvedValue(undefined); + + const result = await ViolationTransformer.transform(mockIssue); + + expect(result).not.toHaveProperty('helpUrl'); + expect(result.id).toBe(mockIssue.id); + expect(result.impact).toBe(mockIssue.impact); + expect(result.description).toBe(mockIssue.description); + expect(result.help).toBe(mockIssue.help); + expect(result.nodes).toEqual(mockIssue.nodes); + }); + + it('should add data from rulesData when available', async () => { + const mockRuleData = { + title: 'Color Contrast Issue', + summary: 'Text does not meet contrast requirements', + description: 'Detailed description of color contrast issue', + severity: 'serious', + type: 'failure', + wcag: [ + { + level: 'AA', + name: '1.4.3 Contrast (Minimum)', + link: 'https://www.w3.org/TR/WCAG22/#contrast-minimum', + }, + ], + act_rules: [ + { + name: 'ACT Rule', + link: 'https://act-rules.github.io/rules/abc123', + }, + ], + supporting_links: [ + { + name: 'MDN Documentation', + link: 'https://developer.mozilla.org/en-US/docs/Web/Accessibility', + }, + ], + }; + + mockGetRuleData.mockResolvedValue(mockRuleData); + + const result = await ViolationTransformer.transform(mockIssue); + + expect(result.title).toBe(mockRuleData.title); + expect(result.summary).toBe(mockRuleData.summary); + expect(result.explanation).toBe(mockRuleData.description); + expect(result.wcag).toEqual(mockRuleData.wcag); + expect(result.act_rules).toEqual(mockRuleData.act_rules); + expect(result.supporting_links).toEqual(mockRuleData.supporting_links); + }); + + it('should handle missing optional fields in ruleData', async () => { + const mockRuleData = { + title: 'Color Contrast Issue', + summary: 'Text does not meet contrast requirements', + description: 'Detailed description', + severity: 'serious', + type: 'failure', + wcag: [], + }; + + mockGetRuleData.mockResolvedValue(mockRuleData); + + const result = await ViolationTransformer.transform(mockIssue); + + expect(result.title).toBe(mockRuleData.title); + expect(result.summary).toBe(mockRuleData.summary); + expect(result.explanation).toBe(mockRuleData.description); + expect(result.wcag).toEqual(mockRuleData.wcag); + expect(result.act_rules).toBeUndefined(); + expect(result.supporting_links).toBeUndefined(); + }); + + it('should preserve all original issue properties except helpUrl', async () => { + mockGetRuleData.mockResolvedValue(undefined); + + const result = await ViolationTransformer.transform(mockIssue); + + expect(result.id).toBe(mockIssue.id); + expect(result.impact).toBe(mockIssue.impact); + expect(result.description).toBe(mockIssue.description); + expect(result.help).toBe(mockIssue.help); + expect(result.nodes).toEqual(mockIssue.nodes); + expect(result).not.toHaveProperty('helpUrl'); + }); + + it('should handle issue without helpUrl', async () => { + const issueWithoutHelpUrl: AccessibilityIssue = { + ...mockIssue, + helpUrl: undefined, + }; + + mockGetRuleData.mockResolvedValue(undefined); + + const result = await ViolationTransformer.transform(issueWithoutHelpUrl); + + expect(result).not.toHaveProperty('helpUrl'); + expect(result.id).toBe(issueWithoutHelpUrl.id); + }); + }); + + describe('transformMany', () => { + it('should transform multiple issues', async () => { + const issues: AccessibilityIssue[] = [ + mockIssue, + { + ...mockIssue, + id: 'image-alt', + helpUrl: 'https://dequeuniversity.com/rules/axe/4.8/image-alt', + }, + ]; + + mockGetRuleData.mockResolvedValue(undefined); + + const results = await ViolationTransformer.transformMany(issues); + + expect(results).toHaveLength(2); + expect(results[0]).not.toHaveProperty('helpUrl'); + expect(results[1]).not.toHaveProperty('helpUrl'); + expect(results[0]?.id).toBe('color-contrast'); + expect(results[1]?.id).toBe('image-alt'); + }); + + it('should handle empty array', async () => { + const results = await ViolationTransformer.transformMany([]); + + expect(results).toEqual([]); + expect(results).toHaveLength(0); + }); + + it('should apply ruleData to each issue', async () => { + const mockRuleData = { + title: 'Test Rule', + summary: 'Test Summary', + description: 'Test Description', + severity: 'serious', + type: 'failure', + wcag: [], + }; + + mockGetRuleData.mockResolvedValue(mockRuleData); + + const issues: AccessibilityIssue[] = [ + mockIssue, + { ...mockIssue, id: 'other-rule' }, + ]; + const results = await ViolationTransformer.transformMany(issues); + + expect(results).toHaveLength(2); + expect(results[0]?.title).toBe(mockRuleData.title); + expect(results[1]?.title).toBe(mockRuleData.title); + expect(mockGetRuleData).toHaveBeenCalledTimes(2); + }); + }); + + describe('type safety', () => { + it('should return AIProcessedIssue type', async () => { + mockGetRuleData.mockResolvedValue(undefined); + + const result = await ViolationTransformer.transform(mockIssue); + + const processedIssue: AIProcessedIssue = result; + expect(processedIssue).toBeDefined(); + expect(processedIssue.id).toBe(mockIssue.id); + }); + + it('should handle invalid ruleData gracefully', async () => { + mockGetRuleData.mockResolvedValue(null as unknown); + + const result = await ViolationTransformer.transform(mockIssue); + + expect(result).not.toHaveProperty('title'); + expect(result).not.toHaveProperty('summary'); + expect(result).not.toHaveProperty('helpUrl'); + }); + }); +});