+ ${
+ violation.wcag && violation.wcag.length > 0
+ ? `
+
+ `
+ : ''
+ }
+ ${
+ violation.act_rules && violation.act_rules.length > 0
+ ? `
+
+ `
+ : ''
+ }
+ ${
+ violation.supporting_links &&
+ violation.supporting_links.length > 0
+ ? `
+
+ `
+ : ''
+ }
${
violation.nodes && violation.nodes.length > 0
? this.generateCollapsibleCode(violation.nodes, 'problem')
: ''
}
+ ${
+ violation.userStory
+ ? `
+
+ `
+ : ''
+ }
${
violation.aiExplanation
? `
-
`
: ''
}
${
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
+ ? `
+
+ `
+ : ''
+ }
+ ${
+ violation.act_rules && violation.act_rules.length > 0
+ ? `
+
+ `
+ : ''
+ }
+ ${
+ violation.supporting_links &&
+ violation.supporting_links.length > 0
+ ? `
+
+
Additional Resources
+
+
+ `
+ : ''
+ }
${
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');
+ });
+ });
+});