2026-05-13 09:10:43 +03:00
|
|
|
<?php
|
|
|
|
|
|
|
|
|
|
namespace YakNet\AccessibilityConsole\Console\Command;
|
|
|
|
|
|
|
|
|
|
use Symfony\Component\Console\Command\Command;
|
|
|
|
|
use Symfony\Component\Console\Input\InputArgument;
|
|
|
|
|
use Symfony\Component\Console\Input\InputInterface;
|
|
|
|
|
use Symfony\Component\Console\Input\InputOption;
|
|
|
|
|
use Symfony\Component\Console\Output\OutputInterface;
|
|
|
|
|
use Symfony\Component\Console\Style\SymfonyStyle;
|
2026-07-13 10:54:25 +03:00
|
|
|
use YakNet\AccessibilityConsole\AI\AiFixerManager;
|
2026-06-23 01:02:15 +03:00
|
|
|
use YakNet\AccessibilityConsole\Core\Analyser;
|
2026-05-13 09:10:43 +03:00
|
|
|
use YakNet\AccessibilityConsole\Core\Config;
|
|
|
|
|
use YakNet\AccessibilityConsole\Core\Scanner;
|
2026-06-23 01:02:15 +03:00
|
|
|
use YakNet\AccessibilityConsole\Rules\RuleLevels;
|
2026-05-13 09:10:43 +03:00
|
|
|
use YakNet\AccessibilityConsole\Source\SourceLocator;
|
|
|
|
|
|
|
|
|
|
class FixCommand extends Command
|
|
|
|
|
{
|
|
|
|
|
protected function configure(): void
|
|
|
|
|
{
|
|
|
|
|
$this
|
|
|
|
|
->setName('fix')
|
|
|
|
|
->setDescription('Automatically fix accessibility violations using AI')
|
2026-06-23 01:02:15 +03:00
|
|
|
->addArgument('target', InputArgument::OPTIONAL, 'The URL, file, or directory path to scan and fix')
|
|
|
|
|
->addOption('config', 'c', InputOption::VALUE_REQUIRED, 'Path to the configuration file (default: a11y.yaml)')
|
|
|
|
|
->addOption('project-path', null, InputOption::VALUE_REQUIRED, 'Base path for source mapping (URL scans only)')
|
|
|
|
|
->addOption('dry-run', null, InputOption::VALUE_NONE, 'Show what would be fixed without modifying files')
|
|
|
|
|
->addOption('level', 'l', InputOption::VALUE_REQUIRED, 'Accessibility scan level (1-9)')
|
|
|
|
|
->addOption('js', null, InputOption::VALUE_NONE, 'Enable JavaScript rendering using Headless Chrome (URL scans only)');
|
2026-05-13 09:10:43 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
protected function execute(InputInterface $input, OutputInterface $output): int
|
|
|
|
|
{
|
|
|
|
|
$io = new SymfonyStyle($input, $output);
|
2026-06-23 01:02:15 +03:00
|
|
|
|
|
|
|
|
// 1. Resolve configuration
|
|
|
|
|
$configPathOption = $input->getOption('config');
|
|
|
|
|
$configPath = is_string($configPathOption) ? $configPathOption : getcwd() . '/a11y.yaml';
|
|
|
|
|
$config = Config::fromYaml($configPath);
|
|
|
|
|
|
|
|
|
|
$apiKey = $config->getGeminiApiKey();
|
|
|
|
|
if (!$apiKey) {
|
|
|
|
|
$io->error("GEMINI_API_KEY not found in .env or environment variables. AI fixing requires an API key.");
|
2026-06-06 09:36:46 +03:00
|
|
|
return Command::FAILURE;
|
|
|
|
|
}
|
2026-06-23 01:02:15 +03:00
|
|
|
|
|
|
|
|
$levelOption = $input->getOption('level');
|
|
|
|
|
$level = is_numeric($levelOption) ? (int)$levelOption : $config->getLevel();
|
|
|
|
|
|
|
|
|
|
$target = $input->getArgument('target');
|
|
|
|
|
$isUrl = is_string($target) && (str_starts_with($target, 'http://') || str_starts_with($target, 'https://'));
|
|
|
|
|
|
|
|
|
|
// If target is a URL, run the legacy URL fixing logic
|
|
|
|
|
if ($isUrl) {
|
|
|
|
|
return $this->executeUrlFix($target, $level, $apiKey, $input, $output, $io);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// --- LOCAL FILE / DIRECTORY SELF-HEALING ENGINE ---
|
|
|
|
|
$io->title("AI Self-Healing Mode - Directory Static Analysis");
|
|
|
|
|
$io->writeln("<fg=gray>Configuration loaded from:</> <fg=cyan>$configPath</>");
|
|
|
|
|
$dryRun = (bool)$input->getOption('dry-run');
|
|
|
|
|
|
|
|
|
|
// Resolve paths to scan
|
|
|
|
|
$paths = $config->getPaths();
|
|
|
|
|
if (is_string($target) && $target !== '') {
|
|
|
|
|
$paths = [$target];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Setup Analyser
|
|
|
|
|
$scanner = new Scanner();
|
|
|
|
|
$rules = RuleLevels::getRulesForLevel($level);
|
|
|
|
|
foreach ($rules as $rule) {
|
|
|
|
|
$scanner->addRule($rule);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// We run without baseline filtering so we can fix ALL errors in the project,
|
|
|
|
|
// or we can optionally load baseline. Fixing everything is better for a general "fix" run.
|
|
|
|
|
$analyser = new Analyser($config, $scanner, new \YakNet\AccessibilityConsole\Core\BaselineManager());
|
|
|
|
|
|
|
|
|
|
$io->note("Scanning targets for violations...");
|
|
|
|
|
$result = $analyser->analyse($paths);
|
|
|
|
|
$violations = $result['violations'];
|
|
|
|
|
|
|
|
|
|
if (empty($violations)) {
|
|
|
|
|
$io->success("Everything looks good! No violations to fix.");
|
|
|
|
|
return Command::SUCCESS;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$io->note(sprintf("Found %d violations. Starting AI analysis and healing...", count($violations)));
|
|
|
|
|
|
2026-07-13 10:54:25 +03:00
|
|
|
$fixer = new AiFixerManager($apiKey);
|
|
|
|
|
$diffGenerator = new \YakNet\AccessibilityConsole\Core\Diff\VisualDiffGenerator();
|
2026-06-23 01:02:15 +03:00
|
|
|
$fixedCount = 0;
|
|
|
|
|
|
|
|
|
|
// Group violations by file path
|
|
|
|
|
$groupedViolations = [];
|
|
|
|
|
foreach ($violations as $v) {
|
|
|
|
|
$file = $v->location['file'] ?? '';
|
|
|
|
|
if ($file !== '') {
|
|
|
|
|
$groupedViolations[$file][] = $v;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
foreach ($groupedViolations as $file => $fileViolations) {
|
|
|
|
|
$io->section("Fixing file: $file");
|
|
|
|
|
|
|
|
|
|
// Sort violations by line number DESCENDING (bottom to top)
|
|
|
|
|
// This prevents edits at the bottom of the file from invalidating line numbers above them!
|
|
|
|
|
usort($fileViolations, function ($a, $b) {
|
|
|
|
|
return ($b->location['line'] ?? 0) <=> ($a->location['line'] ?? 0);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
$absoluteFile = getcwd() . '/' . $file;
|
|
|
|
|
if (!file_exists($absoluteFile) || !is_writable($absoluteFile)) {
|
|
|
|
|
$io->warning("File not found or not writable: $file. Skipping.");
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-13 10:54:25 +03:00
|
|
|
$localHealer = new \YakNet\AccessibilityConsole\Core\SelfHealing\LocalHealer();
|
|
|
|
|
|
2026-06-23 01:02:15 +03:00
|
|
|
foreach ($fileViolations as $violation) {
|
|
|
|
|
$line = $violation->location['line'] ?? 0;
|
|
|
|
|
$io->write("Analyzing <fg=yellow>{$violation->ruleId}</> on line <fg=cyan>$line</>... ");
|
|
|
|
|
|
2026-07-13 10:54:25 +03:00
|
|
|
// Try Local Healer first
|
|
|
|
|
$rawSuggestion = $localHealer->heal($violation);
|
|
|
|
|
$isLocalFix = false;
|
|
|
|
|
|
|
|
|
|
if ($rawSuggestion !== null) {
|
|
|
|
|
$isLocalFix = true;
|
|
|
|
|
} else {
|
|
|
|
|
// Fallback to AI Suggestion
|
|
|
|
|
$rawSuggestion = $fixer->suggestFix($violation);
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-23 01:02:15 +03:00
|
|
|
$suggestion = $rawSuggestion;
|
2026-07-13 10:54:25 +03:00
|
|
|
if (is_string($rawSuggestion) && str_contains($rawSuggestion, 'FIX:')) {
|
2026-06-23 01:02:15 +03:00
|
|
|
preg_match('/FIX:(.*)/s', $rawSuggestion, $matches);
|
|
|
|
|
$suggestion = trim($matches[1] ?? $rawSuggestion);
|
|
|
|
|
$suggestion = preg_replace('/^```html\s*|\s*```$/i', '', $suggestion);
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-13 10:54:25 +03:00
|
|
|
if (!is_string($suggestion)) {
|
2026-06-23 01:02:15 +03:00
|
|
|
$io->writeln("<fg=red>Skipped (AI could not generate a clean fix)</>");
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-13 10:54:25 +03:00
|
|
|
if (is_string($rawSuggestion) && $suggestion === $rawSuggestion && str_contains($suggestion, 'EXPLANATION:')) {
|
|
|
|
|
$io->writeln("<fg=red>Skipped (AI could not generate a clean fix)</>");
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if ($isLocalFix) {
|
|
|
|
|
$io->writeln("<fg=green>Local Fix Found!</>");
|
|
|
|
|
} else {
|
|
|
|
|
$io->writeln("<fg=green>AI Fix Found!</>");
|
|
|
|
|
}
|
|
|
|
|
$io->writeln(" <fg=gray>Diff:</>");
|
|
|
|
|
$diffOutput = $diffGenerator->generate($violation->htmlSnippet, $suggestion);
|
|
|
|
|
$indentedDiff = implode("\n", array_map(fn($line) => ' ' . $line, explode("\n", trim($diffOutput))));
|
|
|
|
|
$io->writeln($indentedDiff);
|
2026-06-23 01:02:15 +03:00
|
|
|
|
|
|
|
|
if ($dryRun) {
|
|
|
|
|
$io->writeln(" <fg=blue>[DRY RUN] File modification skipped.</>");
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Apply Fix
|
|
|
|
|
if ($this->applyFix($absoluteFile, $violation->htmlSnippet, $suggestion)) {
|
|
|
|
|
$io->writeln(" <fg=bright-green>✔ Applied to line $line</>");
|
|
|
|
|
$fixedCount++;
|
|
|
|
|
} else {
|
|
|
|
|
$io->writeln(" <fg=red>✘ Failed to apply fix to file.</>");
|
|
|
|
|
}
|
|
|
|
|
$io->writeln("");
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if ($fixedCount > 0) {
|
|
|
|
|
$io->success("Self-healing complete! $fixedCount violations were automatically fixed.");
|
|
|
|
|
} else {
|
|
|
|
|
$io->warning("No files were modified.");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return Command::SUCCESS;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Legacy URL scan and fix logic
|
|
|
|
|
*/
|
|
|
|
|
private function executeUrlFix(string $target, int $level, string $apiKey, InputInterface $input, OutputInterface $output, SymfonyStyle $io): int
|
|
|
|
|
{
|
2026-06-06 09:36:46 +03:00
|
|
|
$projectPathOption = $input->getOption('project-path');
|
|
|
|
|
$projectPath = is_string($projectPathOption) ? $projectPathOption : getcwd();
|
|
|
|
|
if ($projectPath === false) {
|
|
|
|
|
$projectPath = '.';
|
|
|
|
|
}
|
|
|
|
|
$dryRun = (bool)$input->getOption('dry-run');
|
2026-06-23 01:02:15 +03:00
|
|
|
$useJs = (bool)$input->getOption('js');
|
2026-05-13 09:10:43 +03:00
|
|
|
|
2026-06-23 01:02:15 +03:00
|
|
|
$io->title("AI Self-Healing Mode - Fixing URL: $target");
|
2026-05-13 09:10:43 +03:00
|
|
|
|
2026-06-23 01:02:15 +03:00
|
|
|
// Fetch HTML
|
|
|
|
|
if ($useJs) {
|
|
|
|
|
try {
|
|
|
|
|
$chromeRenderer = new \YakNet\AccessibilityConsole\Core\ChromeRenderer();
|
|
|
|
|
$html = $chromeRenderer->render($target);
|
|
|
|
|
} catch (\Throwable $e) {
|
|
|
|
|
$io->error("Failed JavaScript rendering: " . $e->getMessage());
|
|
|
|
|
return Command::FAILURE;
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
$html = @file_get_contents($target);
|
2026-05-13 09:10:43 +03:00
|
|
|
}
|
2026-06-06 09:36:46 +03:00
|
|
|
if ($html === false || $html === '') {
|
2026-06-23 01:02:15 +03:00
|
|
|
$io->error("Could not read target URL: $target");
|
2026-05-13 09:10:43 +03:00
|
|
|
return Command::FAILURE;
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-23 01:02:15 +03:00
|
|
|
// Scan
|
2026-05-13 09:10:43 +03:00
|
|
|
$scanner = new Scanner();
|
2026-06-23 01:02:15 +03:00
|
|
|
$rules = RuleLevels::getRulesForLevel($level);
|
|
|
|
|
foreach ($rules as $rule) {
|
2026-05-13 09:10:43 +03:00
|
|
|
$scanner->addRule($rule);
|
|
|
|
|
}
|
|
|
|
|
$violations = $scanner->scan($html);
|
|
|
|
|
|
|
|
|
|
if (empty($violations)) {
|
|
|
|
|
$io->success("Everything looks good! No violations to fix.");
|
|
|
|
|
return Command::SUCCESS;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$io->note("Found " . count($violations) . " violations. Starting AI analysis...");
|
|
|
|
|
|
2026-07-13 10:54:25 +03:00
|
|
|
$fixer = new AiFixerManager($apiKey);
|
|
|
|
|
$diffGenerator = new \YakNet\AccessibilityConsole\Core\Diff\VisualDiffGenerator();
|
2026-05-13 09:10:43 +03:00
|
|
|
$locator = new SourceLocator($projectPath);
|
|
|
|
|
$fixedCount = 0;
|
|
|
|
|
|
|
|
|
|
foreach ($violations as $violation) {
|
|
|
|
|
$io->write("Analyzing <fg=yellow>{$violation->ruleId}</>... ");
|
|
|
|
|
|
|
|
|
|
// Locate source
|
|
|
|
|
$location = $locator->locate($violation->htmlSnippet);
|
|
|
|
|
if (!$location) {
|
|
|
|
|
$io->writeln("<fg=red>Skipped (Source not found)</>");
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-13 10:54:25 +03:00
|
|
|
$localHealer = new \YakNet\AccessibilityConsole\Core\SelfHealing\LocalHealer();
|
|
|
|
|
$rawSuggestion = $localHealer->heal($violation);
|
|
|
|
|
$isLocalFix = false;
|
|
|
|
|
|
|
|
|
|
if ($rawSuggestion !== null) {
|
|
|
|
|
$isLocalFix = true;
|
|
|
|
|
} else {
|
|
|
|
|
$rawSuggestion = $fixer->suggestFix($violation);
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-13 13:09:01 +03:00
|
|
|
$suggestion = $rawSuggestion;
|
2026-07-13 10:54:25 +03:00
|
|
|
if (is_string($rawSuggestion) && str_contains($rawSuggestion, 'FIX:')) {
|
2026-05-13 13:09:01 +03:00
|
|
|
preg_match('/FIX:(.*)/s', $rawSuggestion, $matches);
|
|
|
|
|
$suggestion = trim($matches[1] ?? $rawSuggestion);
|
|
|
|
|
$suggestion = preg_replace('/^```html\s*|\s*```$/i', '', $suggestion);
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-13 10:54:25 +03:00
|
|
|
if (!is_string($suggestion)) {
|
2026-05-13 13:09:01 +03:00
|
|
|
$io->writeln("<fg=red>Skipped (AI could not generate a clean fix)</>");
|
2026-05-13 09:10:43 +03:00
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-13 10:54:25 +03:00
|
|
|
if (is_string($rawSuggestion) && $suggestion === $rawSuggestion && str_contains($suggestion, 'EXPLANATION:')) {
|
|
|
|
|
$io->writeln("<fg=red>Skipped (AI could not generate a clean fix)</>");
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if ($isLocalFix) {
|
|
|
|
|
$io->writeln("<fg=green>Local Fix Found!</>");
|
|
|
|
|
} else {
|
|
|
|
|
$io->writeln("<fg=green>AI Fix Found!</>");
|
|
|
|
|
}
|
|
|
|
|
$io->writeln(" <fg=gray>Diff:</>");
|
|
|
|
|
$diffOutput = $diffGenerator->generate($violation->htmlSnippet, $suggestion);
|
|
|
|
|
$indentedDiff = implode("\n", array_map(fn($line) => ' ' . $line, explode("\n", trim($diffOutput))));
|
|
|
|
|
$io->writeln($indentedDiff);
|
2026-05-13 09:10:43 +03:00
|
|
|
|
|
|
|
|
if ($dryRun) {
|
|
|
|
|
$io->writeln(" <fg=blue>[DRY RUN] File modification skipped.</>");
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Apply Fix
|
|
|
|
|
if ($this->applyFix($location['file'], $violation->htmlSnippet, $suggestion)) {
|
|
|
|
|
$io->writeln(" <fg=bright-green>✔ Applied to {$location['file']}:{$location['line']}</>");
|
|
|
|
|
$fixedCount++;
|
|
|
|
|
} else {
|
|
|
|
|
$io->writeln(" <fg=red>✘ Failed to apply fix to file.</>");
|
|
|
|
|
}
|
|
|
|
|
$io->writeln("");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if ($fixedCount > 0) {
|
|
|
|
|
$io->success("Self-healing complete! $fixedCount violations were automatically fixed.");
|
|
|
|
|
} else {
|
2026-06-23 01:02:15 +03:00
|
|
|
$io->warning("No files were modified.");
|
2026-05-13 09:10:43 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return Command::SUCCESS;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private function applyFix(string $filePath, string $originalSnippet, string $fixedSnippet): bool
|
|
|
|
|
{
|
|
|
|
|
$content = file_get_contents($filePath);
|
|
|
|
|
if ($content === false) return false;
|
|
|
|
|
|
|
|
|
|
// Try exact replacement first
|
|
|
|
|
if (str_contains($content, $originalSnippet)) {
|
|
|
|
|
$newContent = str_replace($originalSnippet, $fixedSnippet, $content);
|
|
|
|
|
} else {
|
2026-06-23 01:02:15 +03:00
|
|
|
// Fallback: Fuzzy replacement
|
2026-05-13 09:10:43 +03:00
|
|
|
$newContent = str_replace(trim($originalSnippet), trim($fixedSnippet), $content);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if ($newContent === $content) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return file_put_contents($filePath, $newContent) !== false;
|
|
|
|
|
}
|
|
|
|
|
}
|