UNPKG

sicua

Version:

A tool for analyzing project structure and dependencies

707 lines (706 loc) 31.2 kB
"use strict"; /** * Individual validator functions for accessibility rules * Updated to use enhanced text extraction and context analysis */ Object.defineProperty(exports, "__esModule", { value: true }); exports.RuleValidators = void 0; const constants_1 = require("../constants"); const jsxAnalysisUtils_1 = require("../utils/jsxAnalysisUtils"); const textContentExtractor_1 = require("../utils/textContentExtractor"); const contextAnalyzer_1 = require("../utils/contextAnalyzer"); class RuleValidators { /** * Images must have alt text - WCAG 1.1.1 (Level A) */ static validateImageAlt(element) { if (element.tagName !== "img") return null; const altProp = jsxAnalysisUtils_1.JSXAnalysisUtils.getPropValue(element, "alt"); if (!altProp) { return { ruleId: "img-alt", severity: "error", message: "img element must have an alt attribute", element: element.tagName, elementLocation: element.location, context: element.context, }; } // Check for empty alt text (decorative images should have alt="") if (altProp.type === "string" && altProp.value === "") { return null; // Valid for decorative images } // Enhanced meaningless alt text detection if (altProp.type === "string") { const altText = altProp.value.toLowerCase().trim(); if (constants_1.MEANINGLESS_ALT_PATTERNS.some((pattern) => altText === pattern)) { return { ruleId: "img-alt-meaningful", severity: "warning", message: `Alt text "${altText}" is not descriptive. Use text that describes the image content or purpose.`, element: element.tagName, elementLocation: element.location, context: element.context, }; } // Check for file extension patterns if (/\.(jpg|jpeg|png|gif|svg|webp|bmp)$/i.test(altText)) { return { ruleId: "img-alt-meaningful", severity: "warning", message: "Alt text should not contain file extensions. Describe the image content instead.", element: element.tagName, elementLocation: element.location, context: element.context, }; } } return null; } /** * Form inputs must have labels - WCAG 1.3.1 (Level A) */ static validateInputLabel(element, allElements) { if (element.tagName !== "input") return null; // Use context analyzer for comprehensive labeling check const hasLabeling = contextAnalyzer_1.ContextAnalyzer.hasFormInputLabeling(element, allElements || []); if (!hasLabeling) { const suggestions = contextAnalyzer_1.ContextAnalyzer.getSuggestions(element); const severity = contextAnalyzer_1.ContextAnalyzer.determineSeverity(element, true, false); return { ruleId: "input-label", severity: severity || "error", message: `Form input must have accessible labeling. ${suggestions.join(". ")}`, element: element.tagName, elementLocation: element.location, context: element.context, }; } // Check for placeholder-only labeling (should warn) const hasPlaceholder = jsxAnalysisUtils_1.JSXAnalysisUtils.getPropStringValue(element, "placeholder"); const hasDirectLabeling = constants_1.ARIA_LABELING_ATTRIBUTES.some((attr) => jsxAnalysisUtils_1.JSXAnalysisUtils.hasProp(element, attr)) || jsxAnalysisUtils_1.JSXAnalysisUtils.hasProp(element, "title"); if (hasPlaceholder && !hasDirectLabeling) { return { ruleId: "input-label", severity: "warning", message: "Relying only on placeholder text for labeling is not sufficient. Consider adding aria-label or a label element.", element: element.tagName, elementLocation: element.location, context: element.context, }; } return null; } /** * Buttons must have accessible text - WCAG 4.1.2 (Level A) */ static validateButtonText(element) { if (element.tagName !== "button") return null; // Use enhanced text extraction const hasText = textContentExtractor_1.TextContentExtractor.hasAccessibleText(element); const likelyHasText = textContentExtractor_1.TextContentExtractor.likelyHasText(element); if (hasText || likelyHasText) { return null; // Has or likely has accessible text } // Check for ARIA labeling const hasDirectLabeling = constants_1.ARIA_LABELING_ATTRIBUTES.some((attr) => jsxAnalysisUtils_1.JSXAnalysisUtils.hasProp(element, attr)) || jsxAnalysisUtils_1.JSXAnalysisUtils.hasProp(element, "title"); if (hasDirectLabeling) { return null; } // Special case: Icon-only buttons should have explicit labeling if (this.isIconOnlyButton(element)) { return { ruleId: "button-text", severity: "error", message: "Icon-only button must have aria-label or aria-labelledby for accessibility", element: element.tagName, elementLocation: element.location, context: element.context, }; } // Determine context and severity const hasSpreadProps = contextAnalyzer_1.ContextAnalyzer.hasSpreadProps(element); let severity = "error"; let message = "Button must have accessible text content or aria-label"; if (hasSpreadProps) { severity = "warning"; message += ". Note: Button uses spread props which may contain accessibility attributes."; } return { ruleId: "button-text", severity, message, element: element.tagName, elementLocation: element.location, context: element.context, }; } /** * Check if button is icon-only (has only decorative children) */ static isIconOnlyButton(element) { if (!element.children || element.children.length === 0) { return false; } // Check if all children are decorative const allChildrenDecorative = element.children.every((child) => { return textContentExtractor_1.TextContentExtractor["isDecorativeElement"](child); }); // Also check context for icon patterns if (element.context) { const hasOnlyIcons = /^<Button[^>]*>\s*<[A-Z][a-zA-Z]*\s+[^>]*\/>\s*<\/Button>$/m.test(element.context); if (hasOnlyIcons) { return true; } } return allChildrenDecorative; } /** * Links must have accessible text - WCAG 4.1.2 (Level A) */ static validateLinkText(element) { if (element.tagName !== "a") return null; // Enhanced text extraction const accessibleText = textContentExtractor_1.TextContentExtractor.extractAccessibleText(element); const hasText = textContentExtractor_1.TextContentExtractor.hasAccessibleText(element); const likelyHasText = textContentExtractor_1.TextContentExtractor.likelyHasText(element); if (!hasText && !likelyHasText) { // Check for ARIA labeling const hasDirectLabeling = constants_1.ARIA_LABELING_ATTRIBUTES.some((attr) => jsxAnalysisUtils_1.JSXAnalysisUtils.hasProp(element, attr)) || jsxAnalysisUtils_1.JSXAnalysisUtils.hasProp(element, "title"); if (hasDirectLabeling) { return null; } const hasSpreadProps = contextAnalyzer_1.ContextAnalyzer.hasSpreadProps(element); const severity = hasSpreadProps ? "warning" : "error"; let message = "Link must have accessible text content or aria-label"; if (hasSpreadProps) { message += ". Note: Link uses spread props which may contain accessibility attributes."; } return { ruleId: "link-text", severity, message, element: element.tagName, elementLocation: element.location, context: element.context, }; } // Check for descriptive text if we have actual text content if (hasText && accessibleText) { const isDescriptive = contextAnalyzer_1.ContextAnalyzer.hasDescriptiveLinkText(element); if (!isDescriptive) { return { ruleId: "link-text-descriptive", severity: "warning", message: `Link text "${accessibleText}" is not descriptive. Use text that describes the destination or purpose.`, element: element.tagName, elementLocation: element.location, context: element.context, }; } } return null; } /** * Validate ARIA roles are valid - WCAG 4.1.2 (Level A) */ static validateAriaRole(element) { const roleProp = jsxAnalysisUtils_1.JSXAnalysisUtils.getPropValue(element, "role"); if (!roleProp) return null; let roleValue; switch (roleProp.type) { case "string": roleValue = roleProp.value; break; case "expression": if (roleProp.rawValue) { // Handle conditional expressions like: role={condition ? "button" : undefined} const conditionalMatch = roleProp.rawValue.match(/.*\?\s*["']([^"']+)["']\s*:\s*(?:undefined|null|["']([^"']*)["'])/); if (conditionalMatch) { // Validate the non-undefined role value const primaryRole = conditionalMatch[1]; const fallbackRole = conditionalMatch[2]; if (primaryRole && !constants_1.VALID_ARIA_ROLES.includes(primaryRole)) { return this.createInvalidRoleError(element, primaryRole); } if (fallbackRole && fallbackRole !== "" && !constants_1.VALID_ARIA_ROLES.includes(fallbackRole)) { return this.createInvalidRoleError(element, fallbackRole); } return null; // Valid conditional role } // Handle simple string literals in expressions like: role={"button"} const stringLiteralMatch = roleProp.rawValue.match(/^["']([^"']+)["']$/); if (stringLiteralMatch) { roleValue = stringLiteralMatch[1]; } else { // For complex expressions we can't statically validate, skip validation // unless it's obviously invalid if (this.isObviouslyInvalidRole(roleProp.rawValue)) { return this.createInvalidRoleError(element, roleProp.rawValue); } return null; } } break; default: return null; // Skip validation for other types } if (!roleValue) return null; // Handle multiple roles (space-separated) const roles = roleValue.split(/\s+/).filter((role) => role.trim()); for (const role of roles) { const trimmedRole = role.trim(); if (trimmedRole && !constants_1.VALID_ARIA_ROLES.includes(trimmedRole)) { return this.createInvalidRoleError(element, trimmedRole); } } return null; } /** * Helper method to create invalid role error */ static createInvalidRoleError(element, invalidRole) { return { ruleId: "aria-role-valid", severity: "error", message: `Invalid ARIA role "${invalidRole}". Must be a valid ARIA role: ${constants_1.VALID_ARIA_ROLES.slice(0, 10).join(", ")}...`, element: element.tagName, elementLocation: element.location, context: element.context, }; } /** * Check if a role expression is obviously invalid */ static isObviouslyInvalidRole(expression) { const cleanExpr = expression.trim(); // Don't flag variables, function calls, or property access as invalid if (/^[a-zA-Z_$][a-zA-Z0-9_$.]*(\(\s*.*\s*\))?$/.test(cleanExpr)) { return false; } // Don't flag ternary expressions if (/^.+\?\s*.+\s*:\s*.+$/.test(cleanExpr)) { return false; } // Don't flag logical expressions if (/^.+\s*(&&|\|\|)\s*.+$/.test(cleanExpr)) { return false; } // Only flag if it's a string literal with an obviously invalid role const stringMatch = cleanExpr.match(/^["']([^"']+)["']$/); if (stringMatch) { const role = stringMatch[1]; return !constants_1.VALID_ARIA_ROLES.includes(role); } return false; } /** * Interactive elements should not have onClick without proper role */ static validateInteractiveRole(element) { // Check if element has interactive event handlers const hasOnClick = [ "onClick", "onPress", "onTap", "onKeyDown", "onKeyPress", "onKeyUp", ].some((handler) => jsxAnalysisUtils_1.JSXAnalysisUtils.hasProp(element, handler)); if (!hasOnClick) return null; // Allow inherently interactive HTML elements const interactiveElements = [ "button", "a", "input", "select", "textarea", "details", "summary", ]; if (interactiveElements.includes(element.tagName.toLowerCase())) { return null; } // Check for proper ARIA role const roleProp = jsxAnalysisUtils_1.JSXAnalysisUtils.getPropStringValue(element, "role"); if (roleProp && constants_1.INTERACTIVE_ARIA_ROLES.includes(roleProp)) { return null; } // Check for keyboard accessibility const hasTabIndex = jsxAnalysisUtils_1.JSXAnalysisUtils.hasProp(element, "tabIndex"); const hasKeyboardHandlers = ["onKeyDown", "onKeyPress", "onKeyUp"].some((handler) => jsxAnalysisUtils_1.JSXAnalysisUtils.hasProp(element, handler)); let severity = "warning"; let message = `Interactive ${element.tagName} should have appropriate ARIA role (button, link, etc.)`; if (!hasTabIndex && !hasKeyboardHandlers) { message += " and keyboard accessibility (tabIndex, onKeyDown)"; } // Check if this might be handled by a component library if (contextAnalyzer_1.ContextAnalyzer.hasSpreadProps(element)) { severity = "warning"; message += ". Note: Element uses spread props which may handle accessibility."; } return { ruleId: "interactive-role", severity, message, element: element.tagName, elementLocation: element.location, context: element.context, }; } /** * Form elements should have proper labels or fieldsets */ static validateFormStructure(element) { if (element.tagName !== "fieldset") return null; // Check for legend element in children const hasLegend = element.children.some((child) => child.tagName.toLowerCase() === "legend"); if (!hasLegend) { // Check if fieldset has other labeling const hasDirectLabeling = constants_1.ARIA_LABELING_ATTRIBUTES.some((attr) => jsxAnalysisUtils_1.JSXAnalysisUtils.hasProp(element, attr)) || jsxAnalysisUtils_1.JSXAnalysisUtils.hasProp(element, "title"); if (hasDirectLabeling) { return { ruleId: "fieldset-legend", severity: "info", message: "Fieldset has ARIA labeling but would benefit from a legend element for better semantic structure.", element: element.tagName, elementLocation: element.location, context: element.context, }; } return { ruleId: "fieldset-legend", severity: "warning", message: "Fieldset should contain a legend element for better accessibility and form structure.", element: element.tagName, elementLocation: element.location, context: element.context, }; } return null; } /** * Heading hierarchy should be logical */ static validateHeadingHierarchy(elements) { const violations = []; const headings = elements.filter((el) => /^h[1-6]$/.test(el.tagName.toLowerCase())); if (headings.length === 0) return violations; let previousLevel = 0; headings.forEach((heading, index) => { const currentLevel = parseInt(heading.tagName.charAt(1)); const headingText = textContentExtractor_1.TextContentExtractor.extractAccessibleText(heading) || "[No text content]"; // First heading should be h1 if (index === 0 && currentLevel !== 1) { violations.push({ ruleId: "heading-hierarchy-start", severity: "warning", message: `Page should start with an h1 heading, but found ${heading.tagName}. Current text: "${headingText}"`, element: heading.tagName, elementLocation: heading.location, context: heading.context, }); } // Don't skip heading levels if (previousLevel > 0 && currentLevel > previousLevel + 1) { violations.push({ ruleId: "heading-hierarchy-skip", severity: "warning", message: `Heading level skipped: ${heading.tagName} follows h${previousLevel}. Consider using h${previousLevel + 1} instead. Current text: "${headingText}"`, element: heading.tagName, elementLocation: heading.location, context: heading.context, }); } previousLevel = currentLevel; }); return violations; } /** * Check for missing lang attribute on html element */ static validateLangAttribute(element) { if (element.tagName !== "html") return null; const langProp = jsxAnalysisUtils_1.JSXAnalysisUtils.getPropValue(element, "lang"); if (!langProp) { return { ruleId: "html-lang", severity: "error", message: 'html element must have a lang attribute to identify the page language (e.g., lang="en")', element: element.tagName, elementLocation: element.location, context: element.context, }; } let langValue; switch (langProp.type) { case "string": langValue = langProp.value; break; case "expression": if (langProp.rawValue) { // Handle string literals in expressions const stringLiteralMatch = langProp.rawValue.match(/^["']([^"']+)["']$/); if (stringLiteralMatch) { langValue = stringLiteralMatch[1]; } else { // For variables and complex expressions, check if they likely contain locale values if (this.isLikelyLocaleExpression(langProp.rawValue)) { return null; // Skip validation for likely valid locale expressions } // For other expressions we can't validate, skip unless obviously wrong return null; } } break; default: return null; } if (!langValue) return null; // Validate lang value format (basic check) if (!this.isValidLanguageCode(langValue)) { return { ruleId: "html-lang", severity: "warning", message: `lang attribute value "${langValue}" should follow ISO language codes (e.g., "en", "en-US", "fr", "es")`, element: element.tagName, elementLocation: element.location, context: element.context, }; } return null; } /** * Check if an expression likely contains a valid locale value */ static isLikelyLocaleExpression(expression) { const cleanExpr = expression.trim(); // Common locale variable names const localeVariablePatterns = [ /^locale$/i, /^language$/i, /^lang$/i, /^currentLocale$/i, /^activeLocale$/i, /^selectedLanguage$/i, /^i18n\.locale$/i, /^router\.locale$/i, /^params\.locale$/i, /^props\.locale$/i, ]; if (localeVariablePatterns.some((pattern) => pattern.test(cleanExpr))) { return true; } // Property access that likely contains locale if (/^[a-zA-Z_$][a-zA-Z0-9_$.]*\.(locale|language|lang)\b/i.test(cleanExpr)) { return true; } // Function calls that likely return locale const localeFunctionPatterns = [ /^getLocale\s*\(/i, /^getCurrentLocale\s*\(/i, /^getLanguage\s*\(/i, /^useLocale\s*\(/i, /^i18n\.getLocale\s*\(/i, ]; if (localeFunctionPatterns.some((pattern) => pattern.test(cleanExpr))) { return true; } // Conditional expressions that likely contain locale if (/.*\?\s*["'][a-z]{2,3}(-[A-Z]{2})?["']\s*:\s*["'][a-z]{2,3}(-[A-Z]{2})?["']/.test(cleanExpr)) { return true; } return false; } /** * Validate language code format */ static isValidLanguageCode(langValue) { // Basic ISO language code patterns const validPatterns = [ /^[a-z]{2}$/, // "en", "fr", "es" /^[a-z]{2}-[A-Z]{2}$/, // "en-US", "fr-FR", "es-ES" /^[a-z]{3}$/, // "eng", "fra" (ISO 639-2) /^[a-z]{2}-[A-Z]{2}-[a-zA-Z]+$/, // "en-US-posix" ]; return validPatterns.some((pattern) => pattern.test(langValue)); } /** * Check for duplicate IDs within component */ static validateUniqueIds(elements) { const violations = []; const idCounts = new Map(); // Collect all elements with IDs elements.forEach((element) => { const idProp = jsxAnalysisUtils_1.JSXAnalysisUtils.getPropStringValue(element, "id"); if (idProp && idProp.trim()) { if (!idCounts.has(idProp)) { idCounts.set(idProp, []); } idCounts.get(idProp).push(element); } }); // Find duplicates for (const [id, elementsWithId] of idCounts) { if (elementsWithId.length > 1) { elementsWithId.forEach((element, index) => { violations.push({ ruleId: "duplicate-id", severity: "error", message: `Duplicate ID "${id}" found on ${element.tagName} element (occurrence ${index + 1} of ${elementsWithId.length}). IDs must be unique within the document.`, element: element.tagName, elementLocation: element.location, context: element.context, }); }); } } return violations; } /** * Check ARIA attributes have valid values */ static validateAriaAttributes(element) { const ariaProps = Object.keys(element.props).filter((prop) => prop.startsWith("aria-")); for (const ariaProp of ariaProps) { const propValue = jsxAnalysisUtils_1.JSXAnalysisUtils.getPropValue(element, ariaProp); // Check for specific ARIA attribute validation if (ariaProp in constants_1.ARIA_ATTRIBUTE_VALUES) { const validValues = constants_1.ARIA_ATTRIBUTE_VALUES[ariaProp]; // Handle different prop value types if (propValue) { let valueToCheck; switch (propValue.type) { case "string": valueToCheck = propValue.value; break; case "boolean": valueToCheck = String(propValue.value); break; case "expression": // For expressions, we need to be more lenient if (propValue.rawValue) { // Check if it's a simple boolean variable or expression if (this.isLikelyValidBooleanExpression(propValue.rawValue, ariaProp)) { continue; // Skip validation for likely valid expressions } // Check if it's a string literal within the expression const stringMatch = propValue.rawValue.match(/^["'](.+)["']$/); if (stringMatch) { valueToCheck = stringMatch[1]; } else { // For complex expressions, we can't statically validate // Only flag obvious invalid patterns if (this.isObviouslyInvalidAriaValue(propValue.rawValue, ariaProp)) { valueToCheck = propValue.rawValue; } else { continue; // Skip validation for complex expressions } } } break; default: continue; // Skip validation for undefined or other types } if (valueToCheck && !validValues.includes(valueToCheck)) { return { ruleId: `${ariaProp.replace("-", "-")}-value`, severity: "error", message: `${ariaProp} must be one of: ${validValues.join(", ")}. Current value: "${valueToCheck}"`, element: element.tagName, elementLocation: element.location, context: element.context, }; } } } // Check for empty ARIA labeling attributes if (constants_1.ARIA_LABELING_ATTRIBUTES.includes(ariaProp)) { const stringValue = jsxAnalysisUtils_1.JSXAnalysisUtils.getPropStringValue(element, ariaProp); // Only flag as empty if it's explicitly an empty string, not an expression if (propValue && propValue.type === "string" && (!stringValue || stringValue.trim() === "")) { return { ruleId: "aria-empty-value", severity: "warning", message: `${ariaProp} should not be empty. Provide meaningful text for screen readers.`, element: element.tagName, elementLocation: element.location, context: element.context, }; } } } return null; } /** * Check if an expression is likely a valid boolean expression for ARIA attributes */ static isLikelyValidBooleanExpression(expression, ariaProp) { const cleanExpr = expression.trim(); // Boolean variables (simple identifiers) if (/^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(cleanExpr)) { return true; } // Boolean expressions with logical operators if (/^[a-zA-Z_$][a-zA-Z0-9_$]*\s*[!&|]\s*/.test(cleanExpr)) { return true; } // Negation expressions if (/^!\s*[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(cleanExpr)) { return true; } // Comparison expressions that result in boolean if (/^.+\s*(===|!==|==|!=|<|>|<=|>=)\s*.+$/.test(cleanExpr)) { return true; } // Ternary expressions if (/^.+\?\s*.+\s*:\s*.+$/.test(cleanExpr)) { return true; } // Function calls that likely return boolean (for aria-expanded, aria-pressed, etc.) if (/^[a-zA-Z_$][a-zA-Z0-9_$.]*\(\s*.*\s*\)$/.test(cleanExpr)) { return true; } // Property access that might be boolean if (/^[a-zA-Z_$][a-zA-Z0-9_$.]+$/.test(cleanExpr)) { return true; } return false; } /** * Check if an expression is obviously invalid for ARIA attributes */ static isObviouslyInvalidAriaValue(expression, ariaProp) { const cleanExpr = expression.trim(); // Obviously invalid string literals const invalidStringPatterns = [ /^["'](?!(true|false|undefined|yes|no|mixed|off|polite|assertive|page|step|location|date|time|menu|listbox|tree|grid|dialog|ascending|descending|none|other|inline|list|both|horizontal|vertical|grammar|spelling)).*["']$/, ]; return invalidStringPatterns.some((pattern) => pattern.test(cleanExpr)); } } exports.RuleValidators = RuleValidators;