Git Komut Rehberi & Terminal Asistanı
Sıfırdan Yeni Bir Depo Başlatın:
Mevcut Bir Git Deposu veya Klasörü Gönderin:
Hataları Geri Alma, İptal & Temizlik:
Son commit'i geri al (yapılan değişiklikleri korur):
git reset --soft HEAD~1
Yapılan yerel değişiklikleri iptal et (çalışma ağacını temizler):
git restore .
Takip edilmeyen gereksiz dosyaları sil:
git clean -fd
Değişiklikleri geçici sakla ve geri getir (stash):
git stash git stash pop
Dal (Branch) ve Sürüm Etiketleri:
Yeni dal aç ve geçiş yap:
git checkout -b yeni-ozellik git push -u origin yeni-ozellik
Sürüm etiketi (tag) oluştur ve gönder:
git tag -a v1.0.0 -m "Sürüm 1.0.0" git push origin --tags
feat: Release v3.0.0 - PHPStan-style analysis, configuration, baseline, and advanced WCAG rules
This commit is contained in:
@@ -9,9 +9,10 @@ use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
use YakNet\AccessibilityConsole\AI\GeminiFixer;
|
||||
use YakNet\AccessibilityConsole\Core\Analyser;
|
||||
use YakNet\AccessibilityConsole\Core\Config;
|
||||
use YakNet\AccessibilityConsole\Core\Scanner;
|
||||
use YakNet\AccessibilityConsole\Rules\StandardRuleSet;
|
||||
use YakNet\AccessibilityConsole\Rules\RuleLevels;
|
||||
use YakNet\AccessibilityConsole\Source\SourceLocator;
|
||||
|
||||
class FixCommand extends Command
|
||||
@@ -21,46 +22,183 @@ class FixCommand extends Command
|
||||
$this
|
||||
->setName('fix')
|
||||
->setDescription('Automatically fix accessibility violations using AI')
|
||||
->addArgument('target', InputArgument::REQUIRED, 'The URL or file path to scan')
|
||||
->addOption('project-path', null, InputOption::VALUE_REQUIRED, 'Base path for source mapping')
|
||||
->addOption('dry-run', null, InputOption::VALUE_NONE, 'Show what would be fixed without modifying files');
|
||||
->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)');
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
$target = $input->getArgument('target');
|
||||
if (!is_string($target)) {
|
||||
$io->error("Target must be a string URL or file path.");
|
||||
|
||||
// 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.");
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$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)));
|
||||
|
||||
$fixer = new GeminiFixer($apiKey);
|
||||
$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;
|
||||
}
|
||||
|
||||
foreach ($fileViolations as $violation) {
|
||||
$line = $violation->location['line'] ?? 0;
|
||||
$io->write("Analyzing <fg=yellow>{$violation->ruleId}</> on line <fg=cyan>$line</>... ");
|
||||
|
||||
// Get AI Suggestion
|
||||
$rawSuggestion = $fixer->suggestFix($violation);
|
||||
$suggestion = $rawSuggestion;
|
||||
if ($rawSuggestion && str_contains($rawSuggestion, 'FIX:')) {
|
||||
preg_match('/FIX:(.*)/s', $rawSuggestion, $matches);
|
||||
$suggestion = trim($matches[1] ?? $rawSuggestion);
|
||||
$suggestion = preg_replace('/^```html\s*|\s*```$/i', '', $suggestion);
|
||||
}
|
||||
|
||||
if (!$suggestion || ($suggestion === $rawSuggestion && str_contains($suggestion, 'EXPLANATION:'))) {
|
||||
$io->writeln("<fg=red>Skipped (AI could not generate a clean fix)</>");
|
||||
continue;
|
||||
}
|
||||
|
||||
$io->writeln("<fg=green>Fix Found!</>");
|
||||
$io->writeln(" <fg=gray>Original:</> <fg=red>" . htmlspecialchars(trim($violation->htmlSnippet)) . "</>");
|
||||
$io->writeln(" <fg=gray>AI Fix: </> <fg=green>" . htmlspecialchars(trim($suggestion)) . "</>");
|
||||
|
||||
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
|
||||
{
|
||||
$projectPathOption = $input->getOption('project-path');
|
||||
$projectPath = is_string($projectPathOption) ? $projectPathOption : getcwd();
|
||||
if ($projectPath === false) {
|
||||
$projectPath = '.';
|
||||
}
|
||||
$dryRun = (bool)$input->getOption('dry-run');
|
||||
$useJs = (bool)$input->getOption('js');
|
||||
|
||||
$config = new Config();
|
||||
$apiKey = $config->getGeminiApiKey();
|
||||
$io->title("AI Self-Healing Mode - Fixing URL: $target");
|
||||
|
||||
if (!$apiKey) {
|
||||
$io->error("GEMINI_API_KEY not found in .env or environment variables. AI fixing requires an API key.");
|
||||
return Command::FAILURE;
|
||||
// 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);
|
||||
}
|
||||
|
||||
$io->title("AI Self-Healing Mode - Fixing: $target");
|
||||
|
||||
// 1. Fetch HTML
|
||||
$html = @file_get_contents($target);
|
||||
if ($html === false || $html === '') {
|
||||
$io->error("Could not read target: $target");
|
||||
$io->error("Could not read target URL: $target");
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
// 2. Scan
|
||||
// Scan
|
||||
$scanner = new Scanner();
|
||||
foreach (StandardRuleSet::all() as $rule) {
|
||||
$rules = RuleLevels::getRulesForLevel($level);
|
||||
foreach ($rules as $rule) {
|
||||
$scanner->addRule($rule);
|
||||
}
|
||||
$violations = $scanner->scan($html);
|
||||
@@ -92,7 +230,6 @@ class FixCommand extends Command
|
||||
if ($rawSuggestion && str_contains($rawSuggestion, 'FIX:')) {
|
||||
preg_match('/FIX:(.*)/s', $rawSuggestion, $matches);
|
||||
$suggestion = trim($matches[1] ?? $rawSuggestion);
|
||||
// Clean up potential markdown
|
||||
$suggestion = preg_replace('/^```html\s*|\s*```$/i', '', $suggestion);
|
||||
}
|
||||
|
||||
@@ -123,7 +260,7 @@ class FixCommand extends Command
|
||||
if ($fixedCount > 0) {
|
||||
$io->success("Self-healing complete! $fixedCount violations were automatically fixed.");
|
||||
} else {
|
||||
$io->warning("No files were modified. Check error logs for details.");
|
||||
$io->warning("No files were modified.");
|
||||
}
|
||||
|
||||
return Command::SUCCESS;
|
||||
@@ -138,8 +275,7 @@ class FixCommand extends Command
|
||||
if (str_contains($content, $originalSnippet)) {
|
||||
$newContent = str_replace($originalSnippet, $fixedSnippet, $content);
|
||||
} else {
|
||||
// Fallback: Fuzzy replacement (simplified for this demo)
|
||||
// In a real professional lib, we'd use more advanced regex or DOM patching
|
||||
// Fallback: Fuzzy replacement
|
||||
$newContent = str_replace(trim($originalSnippet), trim($fixedSnippet), $content);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
<?php
|
||||
|
||||
namespace YakNet\AccessibilityConsole\Console\Command;
|
||||
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
class InitCommand extends Command
|
||||
{
|
||||
protected function configure(): void
|
||||
{
|
||||
$this
|
||||
->setName('init')
|
||||
->setDescription('Initialize a default a11y.yaml configuration file');
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
$configPath = getcwd() . '/a11y.yaml';
|
||||
|
||||
if (file_exists($configPath)) {
|
||||
$io->warning("Configuration file 'a11y.yaml' already exists in the current directory.");
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
$defaultConfig = <<<YAML
|
||||
# YakNet Accessibility Console Configuration
|
||||
# For detailed configuration, see: https://forum.yak.net.tr
|
||||
|
||||
# Paths to scan (can be directories or specific files)
|
||||
paths:
|
||||
- .
|
||||
|
||||
# Exclude directories or files
|
||||
exclude_paths:
|
||||
- vendor
|
||||
- node_modules
|
||||
- tests
|
||||
- storage
|
||||
- .git
|
||||
|
||||
# Accessibility scanning level (1-9, default 4)
|
||||
level: 4
|
||||
|
||||
# Output format (console, json, html, github)
|
||||
format: console
|
||||
|
||||
# Path to the baseline file for ignoring existing violations
|
||||
# baseline: a11y-baseline.json
|
||||
|
||||
# Rule adjustments
|
||||
rules:
|
||||
# Rules to exclude from analysis
|
||||
exclude:
|
||||
# - WCAG_1_3_1_FIELDSET
|
||||
# Additional custom rules to run
|
||||
include:
|
||||
# - App\Rules\MyCustomRule
|
||||
YAML;
|
||||
|
||||
if (file_put_contents($configPath, $defaultConfig) !== false) {
|
||||
$io->success("Created configuration file: a11y.yaml");
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
$io->error("Failed to create configuration file 'a11y.yaml'.");
|
||||
return Command::FAILURE;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
namespace YakNet\AccessibilityConsole\Console\Command;
|
||||
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Helper\Table;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
use YakNet\AccessibilityConsole\Rules\RuleLevels;
|
||||
|
||||
class RulesCommand extends Command
|
||||
{
|
||||
protected function configure(): void
|
||||
{
|
||||
$this
|
||||
->setName('rules')
|
||||
->setDescription('List all available accessibility scanning rules');
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
$io->title("Accessibility Console - Available Rules");
|
||||
|
||||
$table = new Table($output);
|
||||
$table->setHeaders(['Rule ID', 'Standard', 'Severity', 'Level', 'Description']);
|
||||
|
||||
$rules = RuleLevels::getRulesForLevel(9);
|
||||
foreach ($rules as $rule) {
|
||||
$ruleId = ($rule instanceof \YakNet\AccessibilityConsole\Rules\RuleInterface)
|
||||
? $rule->getId()
|
||||
: (new \ReflectionClass($rule))->getShortName();
|
||||
|
||||
$description = method_exists($rule, 'getDescription') ? $rule->getDescription() : '';
|
||||
$standard = method_exists($rule, 'getStandard') ? $rule->getStandard()->value : '';
|
||||
$severity = method_exists($rule, 'getSeverity') ? $rule->getSeverity()->value : '';
|
||||
$level = method_exists($rule, 'getLevel') ? $rule->getLevel() : 4;
|
||||
|
||||
$table->addRow([
|
||||
"<fg=cyan;options=bold>$ruleId</>",
|
||||
$standard,
|
||||
$severity,
|
||||
$level,
|
||||
$description
|
||||
]);
|
||||
}
|
||||
|
||||
$table->render();
|
||||
$io->newLine();
|
||||
$io->note("Total rules available: " . count($rules));
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -3,45 +3,236 @@
|
||||
namespace YakNet\AccessibilityConsole\Console\Command;
|
||||
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Helper\ProgressBar;
|
||||
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;
|
||||
use YakNet\AccessibilityConsole\AI\GeminiFixer;
|
||||
use YakNet\AccessibilityConsole\Core\Analyser;
|
||||
use YakNet\AccessibilityConsole\Core\BaselineManager;
|
||||
use YakNet\AccessibilityConsole\Core\Config;
|
||||
use YakNet\AccessibilityConsole\Core\Scanner;
|
||||
use YakNet\AccessibilityConsole\Rules\StandardRuleSet;
|
||||
use YakNet\AccessibilityConsole\Rules\RuleLevels;
|
||||
use YakNet\AccessibilityConsole\Reporting\ConsoleFormatter;
|
||||
use YakNet\AccessibilityConsole\Reporting\GithubFormatter;
|
||||
use YakNet\AccessibilityConsole\Reporting\JsonFormatter;
|
||||
use YakNet\AccessibilityConsole\Source\SourceLocator;
|
||||
|
||||
class ScanCommand extends Command
|
||||
{
|
||||
protected static ?string $defaultName = 'scan';
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this
|
||||
->setName('scan')
|
||||
->setDescription('Scan a URL or file for accessibility violations')
|
||||
->addArgument('target', InputArgument::REQUIRED, 'The URL or file path to scan')
|
||||
->setAliases(['analyse'])
|
||||
->setDescription('Scan local files, directories, or a URL for accessibility violations')
|
||||
->addArgument('target', InputArgument::OPTIONAL, 'The URL, file, or directory path to scan')
|
||||
->addOption('config', 'c', InputOption::VALUE_REQUIRED, 'Path to the configuration file (default: a11y.yaml)')
|
||||
->addOption('level', 'l', InputOption::VALUE_REQUIRED, 'Accessibility scan level (1-9)')
|
||||
->addOption('format', 'f', InputOption::VALUE_REQUIRED, 'Output format (console, json, github)')
|
||||
->addOption('generate-baseline', null, InputOption::VALUE_NONE, 'Generate a baseline file containing all current violations')
|
||||
->addOption('baseline', null, InputOption::VALUE_REQUIRED, 'Path to the baseline file')
|
||||
->addOption('no-progress', null, InputOption::VALUE_NONE, 'Disable progress bar')
|
||||
->addOption('ai', null, InputOption::VALUE_NONE, 'Enable AI-powered fix suggestions')
|
||||
->addOption('report', null, InputOption::VALUE_REQUIRED, 'Save report to file (html or json)')
|
||||
->addOption('project-path', null, InputOption::VALUE_REQUIRED, 'Base path for source mapping')
|
||||
->addOption('crawl', null, InputOption::VALUE_NONE, 'Crawl internal links recursively')
|
||||
->addOption('depth', null, InputOption::VALUE_REQUIRED, 'Maximum crawling depth', '3')
|
||||
->addOption('max-pages', null, InputOption::VALUE_REQUIRED, 'Maximum number of pages to crawl', '20')
|
||||
->addOption('level', 'l', InputOption::VALUE_REQUIRED, 'Accessibility scan level (1-9)', '4');
|
||||
->addOption('project-path', null, InputOption::VALUE_REQUIRED, 'Base path for source mapping (URL scans only)')
|
||||
->addOption('crawl', null, InputOption::VALUE_NONE, 'Crawl internal links recursively (URL scans only)')
|
||||
->addOption('depth', null, InputOption::VALUE_REQUIRED, 'Maximum crawling depth (URL scans only)', '3')
|
||||
->addOption('max-pages', null, InputOption::VALUE_REQUIRED, 'Maximum pages to crawl (URL scans only)', '20')
|
||||
->addOption('js', null, InputOption::VALUE_NONE, 'Enable JavaScript rendering using Headless Chrome (URL scans only)');
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
$target = $input->getArgument('target');
|
||||
if (!is_string($target)) {
|
||||
$io->error("Target must be a string URL or file path.");
|
||||
return Command::FAILURE;
|
||||
|
||||
// 1. Resolve configuration
|
||||
$configPathOption = $input->getOption('config');
|
||||
$configPath = is_string($configPathOption) ? $configPathOption : getcwd() . '/a11y.yaml';
|
||||
$config = Config::fromYaml($configPath);
|
||||
|
||||
// 2. Resolve parameters (CLI options override config file)
|
||||
$levelOption = $input->getOption('level');
|
||||
$level = is_numeric($levelOption) ? (int)$levelOption : $config->getLevel();
|
||||
|
||||
$formatOption = $input->getOption('format');
|
||||
$format = is_string($formatOption) ? $formatOption : $config->getFormat();
|
||||
|
||||
$baselinePathOption = $input->getOption('baseline');
|
||||
$baselinePath = is_string($baselinePathOption) ? $baselinePathOption : $config->getBaselinePath();
|
||||
if (!$baselinePath && $input->getOption('generate-baseline')) {
|
||||
$baselinePath = 'a11y-baseline.json'; // default baseline filename
|
||||
}
|
||||
|
||||
$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 scanning / crawling logic
|
||||
if ($isUrl) {
|
||||
return $this->executeUrlScan($target, $level, $input, $output, $io);
|
||||
}
|
||||
|
||||
// --- LOCAL DIRECTORY / FILE STATIC ANALYSIS ENGINE ---
|
||||
$io->title("Accessibility Console - Static Analysis");
|
||||
$io->writeln("<fg=gray>Configuration loaded from:</> <fg=cyan>$configPath</>");
|
||||
$io->writeln("<fg=gray>Rule Level:</> <fg=yellow>$level</>");
|
||||
|
||||
// Resolve paths to scan
|
||||
$paths = $config->getPaths();
|
||||
if (is_string($target) && $target !== '') {
|
||||
$paths = [$target];
|
||||
}
|
||||
|
||||
// Setup Analyser
|
||||
$scanner = new Scanner();
|
||||
$rules = RuleLevels::getRulesForLevel($level);
|
||||
|
||||
// Filter rules based on config
|
||||
$rulesConfig = $config->getRulesConfig();
|
||||
$activeRules = [];
|
||||
foreach ($rules as $rule) {
|
||||
$ruleId = ($rule instanceof \YakNet\AccessibilityConsole\Rules\RuleInterface)
|
||||
? $rule->getId()
|
||||
: (new \ReflectionClass($rule))->getShortName();
|
||||
|
||||
if (in_array($ruleId, $rulesConfig['exclude'], true)) {
|
||||
continue;
|
||||
}
|
||||
$activeRules[$ruleId] = $rule;
|
||||
}
|
||||
|
||||
foreach ($rulesConfig['include'] as $customRuleClass) {
|
||||
if (class_exists($customRuleClass)) {
|
||||
try {
|
||||
$reflection = new \ReflectionClass($customRuleClass);
|
||||
if ($reflection->isInstantiable()) {
|
||||
$rule = $reflection->newInstance();
|
||||
$ruleId = ($rule instanceof \YakNet\AccessibilityConsole\Rules\RuleInterface)
|
||||
? $rule->getId()
|
||||
: $reflection->getShortName();
|
||||
$activeRules[$ruleId] = $rule;
|
||||
}
|
||||
} catch (\Throwable $e) {}
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($activeRules as $rule) {
|
||||
$scanner->addRule($rule);
|
||||
}
|
||||
|
||||
// Setup Baseline Manager
|
||||
$baselineManager = new BaselineManager();
|
||||
if ($baselinePath && !$input->getOption('generate-baseline')) {
|
||||
$baselineManager->load(getcwd() . '/' . $baselinePath);
|
||||
}
|
||||
|
||||
$analyser = new Analyser($config, $scanner, $baselineManager);
|
||||
|
||||
// Progress bar setup
|
||||
$progressBar = null;
|
||||
$showProgress = !$input->getOption('no-progress') && $format === 'console';
|
||||
$onProgress = null;
|
||||
|
||||
if ($showProgress) {
|
||||
$onProgress = function (string $file, int $index, int $total) use ($io, &$progressBar) {
|
||||
if ($progressBar === null) {
|
||||
$progressBar = new ProgressBar($io, $total);
|
||||
$progressBar->start();
|
||||
}
|
||||
$progressBar->setProgress($index);
|
||||
$progressBar->setMessage(basename($file), 'filename');
|
||||
};
|
||||
}
|
||||
|
||||
// Run analysis
|
||||
$result = $analyser->analyse($paths, $onProgress);
|
||||
|
||||
if ($progressBar) {
|
||||
$progressBar->finish();
|
||||
$io->newLine(2);
|
||||
}
|
||||
|
||||
$violations = $result['violations'];
|
||||
$baselinedCount = $result['baselinedCount'];
|
||||
|
||||
// AI Fix generation (if requested)
|
||||
if ($input->getOption('ai') && !empty($violations)) {
|
||||
$apiKey = $config->getGeminiApiKey();
|
||||
if ($apiKey) {
|
||||
$fixer = new GeminiFixer($apiKey);
|
||||
$io->comment("Requesting AI fix suggestions for violations...");
|
||||
foreach ($violations as $v) {
|
||||
$suggestion = $fixer->suggestFix($v);
|
||||
if ($suggestion) {
|
||||
$v->fixSuggestion = $suggestion;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
$io->warning("GEMINI_API_KEY not found. AI suggestions skipped.");
|
||||
}
|
||||
}
|
||||
|
||||
// Generate baseline if option is present
|
||||
if ($input->getOption('generate-baseline')) {
|
||||
if (!$baselinePath) {
|
||||
$io->error("No baseline path specified.");
|
||||
return Command::FAILURE;
|
||||
}
|
||||
$baselineFile = getcwd() . '/' . $baselinePath;
|
||||
|
||||
// Re-run analysis without baseline filtering to catch all violations
|
||||
$unfilteredAnalyser = new Analyser($config, $scanner, new BaselineManager());
|
||||
$allResult = $unfilteredAnalyser->analyse($paths);
|
||||
$allViolations = $allResult['violations'];
|
||||
|
||||
if ($baselineManager->generate($allViolations, $baselineFile)) {
|
||||
$io->success(sprintf("Baseline generated successfully with %d violations at: %s", count($allViolations), $baselinePath));
|
||||
return Command::SUCCESS;
|
||||
} else {
|
||||
$io->error("Failed to generate baseline file.");
|
||||
return Command::FAILURE;
|
||||
}
|
||||
}
|
||||
|
||||
// Select formatter
|
||||
$formatter = new ConsoleFormatter();
|
||||
if ($format === 'json') {
|
||||
$formatter = new JsonFormatter();
|
||||
} elseif ($format === 'github') {
|
||||
$formatter = new GithubFormatter();
|
||||
}
|
||||
|
||||
$exitCode = $formatter->format($violations, $baselinedCount, $io);
|
||||
|
||||
// Export report if --report is specified
|
||||
$reportPath = $input->getOption('report');
|
||||
if (is_string($reportPath) && $reportPath !== '') {
|
||||
$extension = pathinfo($reportPath, PATHINFO_EXTENSION);
|
||||
if ($extension === 'json') {
|
||||
$serialized = [];
|
||||
foreach ($violations as $v) {
|
||||
$serialized[] = $v->toArray();
|
||||
}
|
||||
file_put_contents($reportPath, json_encode($serialized, JSON_PRETTY_PRINT));
|
||||
} else {
|
||||
$reporter = new \YakNet\AccessibilityConsole\Reporting\HtmlDashboardReporter();
|
||||
// Map violations into page format
|
||||
$mappedViolations = ['Static Analysis' => $violations];
|
||||
file_put_contents($reportPath, $reporter->render($mappedViolations, 'Static Analysis'));
|
||||
}
|
||||
$io->success("Report saved to: $reportPath");
|
||||
}
|
||||
|
||||
return $exitCode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy URL scanning and crawling logic
|
||||
*/
|
||||
private function executeUrlScan(string $target, int $level, InputInterface $input, OutputInterface $output, SymfonyStyle $io): int
|
||||
{
|
||||
$enableAi = (bool)$input->getOption('ai');
|
||||
$projectPathOption = $input->getOption('project-path');
|
||||
$projectPath = is_string($projectPathOption) ? $projectPathOption : getcwd();
|
||||
@@ -49,14 +240,7 @@ class ScanCommand extends Command
|
||||
$projectPath = '.';
|
||||
}
|
||||
|
||||
$levelOption = $input->getOption('level');
|
||||
$level = is_numeric($levelOption) ? (int)$levelOption : 4;
|
||||
if ($level < 1 || $level > 9) {
|
||||
$io->error("Scan level must be between 1 and 9.");
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$io->title("Accessibility Console - Scanning: $target (Level $level)");
|
||||
$io->title("Accessibility Console - Scanning URL: $target (Level $level)");
|
||||
|
||||
// 1. Setup Scanner
|
||||
$scanner = new Scanner();
|
||||
@@ -66,10 +250,9 @@ class ScanCommand extends Command
|
||||
}
|
||||
|
||||
// 2. Fetch and Scan
|
||||
$isUrl = str_starts_with($target, 'http://') || str_starts_with($target, 'https://');
|
||||
$crawl = (bool)$input->getOption('crawl') && $isUrl;
|
||||
|
||||
/** @var array<string, \YakNet\AccessibilityConsole\Core\Violation[]> $results */
|
||||
$crawl = (bool)$input->getOption('crawl');
|
||||
$useJs = (bool)$input->getOption('js');
|
||||
$chromeRenderer = $useJs ? new \YakNet\AccessibilityConsole\Core\ChromeRenderer() : null;
|
||||
$results = [];
|
||||
|
||||
if ($crawl) {
|
||||
@@ -83,9 +266,7 @@ class ScanCommand extends Command
|
||||
$parsedBase = parse_url($target);
|
||||
$baseHost = $parsedBase['host'] ?? '';
|
||||
|
||||
$queue = [
|
||||
['url' => $target, 'depth' => 1]
|
||||
];
|
||||
$queue = [['url' => $target, 'depth' => 1]];
|
||||
$visited = [];
|
||||
|
||||
$client = new \GuzzleHttp\Client([
|
||||
@@ -105,7 +286,6 @@ class ScanCommand extends Command
|
||||
$url = $current['url'];
|
||||
$depth = (int)$current['depth'];
|
||||
|
||||
// Remove fragment
|
||||
$cleanUrl = strtok($url, '#');
|
||||
if ($cleanUrl === false || isset($visited[$cleanUrl])) {
|
||||
continue;
|
||||
@@ -115,15 +295,17 @@ class ScanCommand extends Command
|
||||
$io->write(" <fg=gray>Crawling [Depth $depth]:</> <fg=cyan>$cleanUrl</>... ");
|
||||
|
||||
try {
|
||||
$response = $client->request('GET', $cleanUrl);
|
||||
$html = (string)$response->getBody();
|
||||
if ($chromeRenderer) {
|
||||
$html = $chromeRenderer->render($cleanUrl);
|
||||
} else {
|
||||
$response = $client->request('GET', $cleanUrl);
|
||||
$html = (string)$response->getBody();
|
||||
}
|
||||
$io->writeln("<fg=green>OK</>");
|
||||
|
||||
// Scan the page HTML
|
||||
$pageViolations = $scanner->scan($html);
|
||||
$results[$cleanUrl] = $pageViolations;
|
||||
|
||||
// Extract links if depth is less than limit
|
||||
if ($depth < $depthLimit) {
|
||||
$html5 = new \Masterminds\HTML5(['disable_html_ns' => true]);
|
||||
$doc = $html5->loadHTML($html);
|
||||
@@ -135,7 +317,6 @@ class ScanCommand extends Command
|
||||
continue;
|
||||
}
|
||||
|
||||
// Resolve relative URI
|
||||
try {
|
||||
$baseUri = new \GuzzleHttp\Psr7\Uri($cleanUrl);
|
||||
$relativeUri = new \GuzzleHttp\Psr7\Uri($href);
|
||||
@@ -145,12 +326,10 @@ class ScanCommand extends Command
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check host compatibility
|
||||
$parsedAbsolute = parse_url($absoluteUrl);
|
||||
$absoluteHost = $parsedAbsolute['host'] ?? '';
|
||||
|
||||
|
||||
if ($absoluteHost === $baseHost) {
|
||||
// Add to queue if not already visited or in queue
|
||||
$cleanAbsolute = strtok($absoluteUrl, '#');
|
||||
if ($cleanAbsolute !== false && !isset($visited[$cleanAbsolute])) {
|
||||
$inQueue = false;
|
||||
@@ -172,10 +351,19 @@ class ScanCommand extends Command
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Standard single page/file scan
|
||||
$html = @file_get_contents($target);
|
||||
if ($chromeRenderer) {
|
||||
try {
|
||||
$html = $chromeRenderer->render($target);
|
||||
} catch (\Throwable $e) {
|
||||
$io->error("Failed JavaScript rendering: " . $e->getMessage());
|
||||
return Command::FAILURE;
|
||||
}
|
||||
} else {
|
||||
$html = @file_get_contents($target);
|
||||
}
|
||||
|
||||
if ($html === false || $html === '') {
|
||||
$io->error("Could not read target: $target");
|
||||
$io->error("Could not read target URL: $target");
|
||||
return Command::FAILURE;
|
||||
}
|
||||
$violations = $scanner->scan($html);
|
||||
@@ -194,7 +382,6 @@ class ScanCommand extends Command
|
||||
|
||||
$io->section("Found a total of $totalViolations violations across " . count($results) . " pages");
|
||||
|
||||
// 3. Setup Helpers
|
||||
$locator = new SourceLocator($projectPath);
|
||||
$config = new Config();
|
||||
$fixer = $enableAi ? new GeminiFixer($config->getGeminiApiKey()) : null;
|
||||
@@ -207,17 +394,14 @@ class ScanCommand extends Command
|
||||
|
||||
foreach ($violations as $violation) {
|
||||
$io->writeln("<fg=white;bg=red;options=bold> {$violation->ruleId} </>\t<fg=red>{$violation->message}</>");
|
||||
|
||||
// Show Snippet
|
||||
$io->writeln(" <fg=gray>Snippet:</> <fg=yellow>" . htmlspecialchars($violation->htmlSnippet) . "</>");
|
||||
|
||||
// Source Mapping
|
||||
$location = $locator->locate($violation->htmlSnippet);
|
||||
if ($location) {
|
||||
$io->writeln(" <fg=gray>Source:</> <fg=cyan>{$location['file']}:{$location['line']}</>");
|
||||
$violation->location = $location;
|
||||
}
|
||||
|
||||
// AI Fixer
|
||||
if ($enableAi && $fixer) {
|
||||
$io->write(" <fg=gray>AI Fix Suggestion:</> ");
|
||||
$suggestion = $fixer->suggestFix($violation);
|
||||
@@ -228,14 +412,10 @@ class ScanCommand extends Command
|
||||
$io->writeln("<fg=red>Could not generate suggestion (check API key)</>");
|
||||
}
|
||||
}
|
||||
|
||||
$io->writeln("");
|
||||
}
|
||||
}
|
||||
|
||||
$io->warning("Scanned " . $totalViolations . " errors. Use --ai to get fix suggestions.");
|
||||
|
||||
// 4. Export Report
|
||||
$reportPath = $input->getOption('report');
|
||||
if (is_string($reportPath) && $reportPath !== '') {
|
||||
$extension = pathinfo($reportPath, PATHINFO_EXTENSION);
|
||||
@@ -252,6 +432,6 @@ class ScanCommand extends Command
|
||||
$io->success("Report saved to: $reportPath");
|
||||
}
|
||||
|
||||
return Command::SUCCESS;
|
||||
return Command::FAILURE;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user