diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..5693498 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,128 @@ +# Changelog + +All notable changes to Gate will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [2.0.0] - 2026-01-05 + +### Added + +- **Attribution Check** - Automatically detects and removes Claude Code attribution from commits + - New `AttributionCheck` class for `gate certify` + - New `gate check:attribution` command with `--fix` option + - Enforces clean commit messages without AI co-authorship + +- **Logic & Atomicity Validation** - AI-powered commit analysis using Ollama + - New `LogicCheck` class validates commits are atomic (single purpose) + - Ensures all changes in a commit are related + - Detects logic issues and incomplete implementations + - Uses `llama3.2:3b` model for fast local analysis (3-8 seconds) + - New `gate check:logic` command + +- **PR Cohesion Analysis** - Cross-file relationship validation + - New `CohesionCheck` class analyzes PRs holistically + - Detects missing files (tests, migrations, etc.) + - Validates MVC architecture coherence + - Checks cross-file dependencies make sense + - New `gate check:cohesion` command + +- **Git Hooks Installation** - Easy hook setup for any repository + - New `gate install` command + - Automatically installs pre-commit hooks + - Creates `.gate/config.php` for customization + - Hooks call `gate certify` before each commit + +- **AI-Powered GitHub Actions Workflows** + - Layer 3: AI Code Review with Ollama (qwen2.5-coder:7b) + - Pattern analysis (N+1 queries, fat controllers, anti-patterns) + - Security analysis (SQL injection, XSS, CSRF, mass assignment) + - Test suggestions with specific scenarios + - Model caching reduces CI time from 5min to 30sec + - Layer 4: Semantic Release Automation + - Auto-versioning based on conventional commits + - Automatic CHANGELOG.md generation + - GitHub release creation with release notes + - Runs on merge to main/master + +### Changed + +- **CertifyCommand** - Now includes all new checks in order: + 1. Attribution Check + 2. Logic & Atomicity (Ollama) + 3. Tests & Coverage + 4. Security Audit + 5. Pest Syntax + 6. PR Cohesion (Ollama) + +- **Check Architecture** - Expanded CheckInterface pattern to support AI validation + - All checks now follow consistent CheckResult pattern + - Better error reporting with detailed failure messages + - Compact mode shows single-line status for all checks + +- **README** - Comprehensive rewrite documenting all new features + - Phase-based validation architecture + - AI model information and configuration + - Updated quick start guide + - New command examples + +### Technical Details + +- Uses [Ollama](https://ollama.com) for local AI models (free, no API costs) +- Models auto-download on first use +- Graceful degradation when Ollama not available (skips AI checks) +- Compatible with existing v1.x workflows +- All new checks implement `CheckInterface` +- Uses `SymfonyProcessRunner` for command execution + +### Migration Guide + +#### From v1.x to v2.0.0 + +**No breaking changes** - v2.0.0 is backwards compatible with v1.x + +Optional: Install new git hooks for pre-commit validation: + +```bash +cd your-repo +gate install +``` + +Optional: Add Ollama for AI validation: + +```bash +# Install Ollama +curl -fsSL https://ollama.com/install.sh | sh + +# Models will auto-download on first use +# Or manually pull: +ollama pull llama3.2:3b +ollama pull qwen2.5-coder:7b +``` + +Update GitHub Actions (optional): + +```yaml +# Change from v1 to v2 +- uses: synapse-sentinel/gate@v2 +``` + +Add AI review workflow (optional): + +```bash +# Copy from prototypes/gate-v1/ +cp prototypes/gate-v1/layer-3-ai-review.yml .github/workflows/gate-ai-review.yml +cp prototypes/gate-v1/layer-4-release.yml .github/workflows/gate-release.yml +``` + +## [1.4.1] - 2024-12-XX + +### Previous Release +- Tests & Coverage validation +- Security Audit +- Pest Syntax checking +- GitHub Checks API integration + +[2.0.0]: https://github.com/synapse-sentinel/gate/compare/v1.4.1...v2.0.0 +[1.4.1]: https://github.com/synapse-sentinel/gate/releases/tag/v1.4.1 diff --git a/README.md b/README.md index ca84910..e908136 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,21 @@ # Synapse Sentinel Gate -Universal code quality gate for the Jordan ecosystem. Enforces consistent standards across all repositories. +Universal code quality gate with AI-powered validation. Enforces consistent standards across all repositories using local Ollama models. ## Quick Start -Add to your repository's workflow: +Install gate hooks in your repository: + +```bash +# Install gate globally +composer global require synapse-sentinel/gate + +# Install hooks in your repository +cd /path/to/your/repo +gate install +``` + +Or use in GitHub Actions: ```yaml name: Gate @@ -19,9 +30,9 @@ jobs: pull-requests: write # Required for PR comments steps: - uses: actions/checkout@v4 - - uses: synapse-sentinel/gate@v1 + - uses: synapse-sentinel/gate@v2 with: - coverage-threshold: 100 + coverage-threshold: 80 ``` ### Required Permissions @@ -36,15 +47,26 @@ Without these permissions, the action will run successfully but features will si ## What It Checks -### Technical Gate (Phase 1) +### Phase 1: Pre-Commit Validation (Local, <10s) +- **Attribution Check**: Removes Claude Code attribution from commits +- **Logic & Atomicity**: AI validation that commits are atomic and coherent (Ollama) +- **Syntax Check**: Fast syntax validation + +### Phase 2: CI/CD Validation (GitHub Actions, 2-5min) - **Tests & Coverage**: Runs `pest --coverage --min=X` - **Security Audit**: Runs `composer audit` for vulnerabilities - **Pest Syntax**: Validates all tests use `describe()/it()` blocks +- **PR Cohesion**: Cross-file analysis for missing files and MVC coherence (Ollama) + +### Phase 3: AI Code Review (GitHub Actions, 30s with caching) +- **Pattern Analysis**: Detects Laravel anti-patterns (N+1 queries, fat controllers) +- **Security Analysis**: Identifies SQL injection, XSS, mass assignment issues +- **Test Suggestions**: Generates specific test recommendations -### Business Logic Gate (Phase 2 - Coming Soon) -- Issue intent matching -- Architectural compliance -- Over/under-engineering detection +### Phase 4: Semantic Release (On merge to main) +- **Auto-versioning**: Based on conventional commits (feat, fix, BREAKING) +- **Changelog Generation**: Automatic CHANGELOG.md updates +- **GitHub Releases**: Automated release creation with notes ## Inputs @@ -69,11 +91,55 @@ Without these permissions, the action will run successfully but features will si ## Local Usage ```bash -# Run gate on current directory -php gate run --coverage=100 +# Install gate globally +composer global require synapse-sentinel/gate + +# Install hooks in your repository +gate install + +# Run full certification +gate certify --coverage=80 + +# Run individual checks +gate check:attribution # Check for Claude Code attribution +gate check:attribution --fix # Remove attribution automatically +gate check:logic # Validate commit atomicity (Ollama) +gate check:cohesion # Analyze PR cohesion (Ollama) + +# Compact output mode +gate certify --compact +``` + +## AI Models + +Gate uses [Ollama](https://ollama.com) for local AI validation: + +- **llama3.2:3b** - Fast atomicity and logic checks (3-8 seconds) +- **qwen2.5-coder:7b** - Deep code review in CI (with caching) + +Models are automatically downloaded when first needed. Ollama is optional - gate works without it but skips AI checks. + +## Configuration + +After running `gate install`, edit `.gate/config.php`: -# Run with lower threshold -php gate run --coverage=80 +```php +return [ + 'pre_commit' => [ + 'attribution' => true, // Remove Claude attribution + 'logic' => true, // Ollama atomicity check + 'syntax' => true, // Fast syntax validation + ], + 'ci_checks' => [ + 'tests' => true, + 'security' => true, + 'cohesion' => true, // PR cross-file analysis + ], + 'ollama' => [ + 'model' => 'llama3.2:3b', + 'timeout' => 30, + ], +]; ``` ## Development diff --git a/app/Checks/AttributionCheck.php b/app/Checks/AttributionCheck.php new file mode 100644 index 0000000..c4ef16e --- /dev/null +++ b/app/Checks/AttributionCheck.php @@ -0,0 +1,70 @@ +processRunner->run( + ['git', 'log', '-1', '--pretty=%B'], + $workingDirectory, + timeout: 5, + ); + + if (! $result->successful || empty(trim($result->output))) { + return CheckResult::pass('No commit to check'); + } + + $commitMessage = trim($result->output); + + if (! $this->hasAttribution($commitMessage)) { + return CheckResult::pass('No Claude attribution found'); + } + + $foundPatterns = []; + foreach ($this->attributionPatterns as $pattern) { + if (preg_match($pattern, $commitMessage)) { + $foundPatterns[] = str_replace(['/', 'i'], '', $pattern); + } + } + + return CheckResult::fail( + 'Claude Code attribution detected in commit', + $foundPatterns + ); + } + + private function hasAttribution(string $message): bool + { + foreach ($this->attributionPatterns as $pattern) { + if (preg_match($pattern, $message)) { + return true; + } + } + + return false; + } +} diff --git a/app/Checks/CohesionCheck.php b/app/Checks/CohesionCheck.php new file mode 100644 index 0000000..b8b432c --- /dev/null +++ b/app/Checks/CohesionCheck.php @@ -0,0 +1,273 @@ +isOllamaAvailable($workingDirectory)) { + return CheckResult::pass('Ollama not installed - skipping cohesion check'); + } + + // Get changed files compared to base + $changedFiles = $this->getChangedFiles($workingDirectory); + if (empty($changedFiles)) { + return CheckResult::pass('No changed files to analyze'); + } + + // Categorize files + $categories = $this->categorizeFiles($changedFiles); + + // Get diff + $diff = $this->getPRDiff($workingDirectory); + + // Ensure model is available + $this->ensureModelAvailable($workingDirectory); + + // Analyze cohesion + $analysis = $this->analyzeCohesion($workingDirectory, $changedFiles, $categories, $diff); + if (! $analysis) { + return CheckResult::fail('Cohesion analysis failed'); + } + + // Parse result + $result = $this->parseAnalysis($analysis); + + if ($result['cohesive']) { + return CheckResult::pass($result['purpose']); + } + + $issues = []; + if (! $result['cohesive']) { + $issues[] = 'PR lacks cohesion - mixing unrelated changes'; + } + if ($result['missing_files'] !== 'none') { + $issues[] = 'Missing files: '.$result['missing_files']; + } + if ($result['dependency_issues'] !== 'none') { + $issues[] = 'Cross-file issues: '.$result['dependency_issues']; + } + if ($result['concerns'] !== 'none') { + $issues[] = 'Concerns: '.$result['concerns']; + } + + return CheckResult::fail( + 'PR cohesion validation failed', + $issues + ); + } + + private function isOllamaAvailable(string $workingDirectory): bool + { + $result = $this->processRunner->run(['which', 'ollama'], $workingDirectory, timeout: 5); + + return $result->successful; + } + + private function getChangedFiles(string $workingDirectory): array + { + // Try to get base branch from different common names + $baseBranches = [$this->base, 'master', 'main', 'develop']; + $changedFiles = []; + + foreach ($baseBranches as $base) { + $result = $this->processRunner->run( + ['git', 'diff', '--name-only', "origin/{$base}...HEAD"], + $workingDirectory, + timeout: 5 + ); + + if ($result->successful && ! empty(trim($result->output))) { + $changedFiles = array_filter(explode("\n", trim($result->output))); + break; + } + } + + return $changedFiles; + } + + private function getPRDiff(string $workingDirectory): string + { + $baseBranches = [$this->base, 'master', 'main', 'develop']; + + foreach ($baseBranches as $base) { + $result = $this->processRunner->run( + ['git', 'diff', "origin/{$base}...HEAD"], + $workingDirectory, + timeout: 10 + ); + + if ($result->successful && ! empty(trim($result->output))) { + return $result->output; + } + } + + return ''; + } + + private function categorizeFiles(array $files): array + { + $categories = [ + 'models' => [], + 'controllers' => [], + 'views' => [], + 'tests' => [], + 'migrations' => [], + 'config' => [], + 'routes' => [], + 'services' => [], + 'other' => [], + ]; + + foreach ($files as $file) { + if (str_contains($file, '/Models/') || str_contains($file, '/model/')) { + $categories['models'][] = $file; + } elseif (str_contains($file, '/Controllers/') || str_contains($file, '/controller/')) { + $categories['controllers'][] = $file; + } elseif (str_contains($file, '/views/') || str_contains($file, '/View/')) { + $categories['views'][] = $file; + } elseif (str_contains($file, '/tests/') || str_contains($file, '/Test/')) { + $categories['tests'][] = $file; + } elseif (str_contains($file, '/migrations/')) { + $categories['migrations'][] = $file; + } elseif (str_contains($file, '/config/')) { + $categories['config'][] = $file; + } elseif (str_contains($file, '/routes/')) { + $categories['routes'][] = $file; + } elseif (str_contains($file, '/Services/') || str_contains($file, '/service/')) { + $categories['services'][] = $file; + } else { + $categories['other'][] = $file; + } + } + + return array_filter($categories); + } + + private function ensureModelAvailable(string $workingDirectory): void + { + $result = $this->processRunner->run( + ['sh', '-c', "ollama list | grep {$this->model}"], + $workingDirectory, + timeout: 5 + ); + + if (! $result->successful) { + $this->processRunner->run( + ['ollama', 'pull', $this->model], + $workingDirectory, + timeout: 300 + ); + } + } + + private function analyzeCohesion(string $workingDirectory, array $files, array $categories, string $diff): ?string + { + $prompt = $this->buildCohesionPrompt($files, $categories, $diff); + + $result = $this->processRunner->run( + ['ollama', 'run', $this->model, $prompt], + $workingDirectory, + timeout: $this->timeout + ); + + return $result->successful ? $result->output : null; + } + + private function buildCohesionPrompt(array $files, array $categories, string $diff): string + { + $fileList = implode("\n", $files); + $categoryBreakdown = ''; + foreach ($categories as $category => $categoryFiles) { + $categoryBreakdown .= "\n{$category}: ".count($categoryFiles); + } + + // Limit diff to avoid token limits + $diffLines = explode("\n", $diff); + $limitedDiff = implode("\n", array_slice($diffLines, 0, 300)); + + return << $cohesive, + 'missing_files' => $missing, + 'dependency_issues' => $dependencies, + 'purpose' => $purpose, + 'concerns' => $concerns, + ]; + } +} diff --git a/app/Checks/LogicCheck.php b/app/Checks/LogicCheck.php new file mode 100644 index 0000000..0956f0d --- /dev/null +++ b/app/Checks/LogicCheck.php @@ -0,0 +1,216 @@ +isOllamaAvailable($workingDirectory)) { + return CheckResult::pass('Ollama not installed - skipping logic validation'); + } + + if (! $this->isOllamaRunning($workingDirectory)) { + return CheckResult::pass('Ollama not running - skipping logic validation'); + } + + // Get staged files + $stagedFiles = $this->getStagedFiles($workingDirectory); + if (empty($stagedFiles)) { + return CheckResult::pass('No staged files to validate'); + } + + // Get staged diff + $diff = $this->getStagedDiff($workingDirectory); + if (empty($diff)) { + return CheckResult::pass('No changes to validate'); + } + + // Ensure model is available + $this->ensureModelAvailable($workingDirectory); + + // Analyze with Ollama + $analysis = $this->analyzeWithOllama($workingDirectory, $stagedFiles, $diff); + if (! $analysis) { + return CheckResult::fail('Analysis failed'); + } + + // Parse result + $result = $this->parseAnalysis($analysis); + + if ($result['atomic'] && $result['related']) { + return CheckResult::pass($result['purpose']); + } + + $issues = []; + if (! $result['atomic']) { + $issues[] = 'Commit is NOT atomic - mixes multiple concerns'; + } + if (! $result['related']) { + $issues[] = 'Changes are NOT all related'; + } + if ($result['issues'] !== 'none') { + $issues[] = $result['issues']; + } + + return CheckResult::fail( + 'Commit atomicity validation failed', + $issues + ); + } + + private function isOllamaAvailable(string $workingDirectory): bool + { + $result = $this->processRunner->run(['which', 'ollama'], $workingDirectory, timeout: 5); + + return $result->successful; + } + + private function isOllamaRunning(string $workingDirectory): bool + { + $result = $this->processRunner->run(['ollama', 'list'], $workingDirectory, timeout: 5); + + return $result->successful; + } + + private function getStagedFiles(string $workingDirectory): array + { + $result = $this->processRunner->run( + ['git', 'diff', '--cached', '--name-only', '--diff-filter=ACM'], + $workingDirectory, + timeout: 5 + ); + + if (! $result->successful) { + return []; + } + + return array_filter(explode("\n", trim($result->output))); + } + + private function getStagedDiff(string $workingDirectory): string + { + $result = $this->processRunner->run( + ['git', 'diff', '--cached'], + $workingDirectory, + timeout: 10 + ); + + return $result->successful ? $result->output : ''; + } + + private function ensureModelAvailable(string $workingDirectory): void + { + $result = $this->processRunner->run( + ['sh', '-c', "ollama list | grep {$this->model}"], + $workingDirectory, + timeout: 5 + ); + + if (! $result->successful) { + $this->processRunner->run( + ['ollama', 'pull', $this->model], + $workingDirectory, + timeout: 300 + ); + } + } + + private function analyzeWithOllama(string $workingDirectory, array $files, string $diff): ?string + { + $prompt = $this->buildAnalysisPrompt($files, $diff); + + $result = $this->processRunner->run( + ['ollama', 'run', $this->model, $prompt], + $workingDirectory, + timeout: $this->timeout + ); + + return $result->successful ? $result->output : null; + } + + private function buildAnalysisPrompt(array $files, string $diff): string + { + $fileList = implode("\n", $files); + + // Limit diff to 500 lines to avoid token limits + $diffLines = explode("\n", $diff); + $limitedDiff = implode("\n", array_slice($diffLines, 0, 500)); + + return << $atomic, + 'related' => $related, + 'logic_sound' => $logicSound, + 'purpose' => trim($purpose), + 'issues' => trim($issues), + ]; + } +} diff --git a/app/Commands/AttributionCheckCommand.php b/app/Commands/AttributionCheckCommand.php new file mode 100644 index 0000000..b3ec6ba --- /dev/null +++ b/app/Commands/AttributionCheckCommand.php @@ -0,0 +1,66 @@ +check = $check; + $this->checksClient = $checksClient; + } + + public function handle(): int + { + $check = $this->check ?? new AttributionCheck; + $workingDirectory = getcwd(); + + $this->info('🤖 Checking for Claude Code attribution...'); + $this->newLine(); + + $result = $check->run($workingDirectory); + + if ($result->passed) { + $this->info("✓ {$result->message}"); + return self::SUCCESS; + } + + $this->error("✗ {$result->message}"); + + if (!empty($result->details)) { + $this->newLine(); + foreach ($result->details as $detail) { + $this->line(" • {$detail}"); + } + } + + // Report to GitHub if in CI + if ($this->checksClient ?? ChecksClient::isAvailable()) { + $client = $this->checksClient ?? ChecksClient::fromEnvironment(); + $summary = !empty($result->details) + ? implode("\n", $result->details) + : $result->message; + $client->reportCheck( + 'Attribution Check', + $result->passed, + $result->message, + $summary + ); + } + + return self::FAILURE; + } +} diff --git a/app/Commands/CertifyCommand.php b/app/Commands/CertifyCommand.php index 227b320..5e61189 100644 --- a/app/Commands/CertifyCommand.php +++ b/app/Commands/CertifyCommand.php @@ -5,7 +5,10 @@ namespace App\Commands; use App\Branding; +use App\Checks\AttributionCheck; use App\Checks\CheckInterface; +use App\Checks\CohesionCheck; +use App\Checks\LogicCheck; use App\Checks\PestSyntaxValidator; use App\Checks\SecurityScanner; use App\Checks\TestRunner; @@ -60,9 +63,12 @@ public function handle(): int $workingDirectory = getcwd(); $checks = $this->checks ?? [ + new AttributionCheck, + new LogicCheck, new TestRunner($coverageThreshold), new SecurityScanner, new PestSyntaxValidator, + new CohesionCheck, ]; $stopOnFailure = $this->option('stop-on-failure'); @@ -193,6 +199,9 @@ private function shortName(string $name): string 'Tests & Coverage' => 'Tests', 'Security Audit' => 'Security', 'Pest Syntax' => 'Syntax', + 'Attribution Check' => 'Attribution', + 'Logic & Atomicity' => 'Logic', + 'PR Cohesion' => 'Cohesion', default => $name, }; } diff --git a/app/Commands/CohesionCheckCommand.php b/app/Commands/CohesionCheckCommand.php new file mode 100644 index 0000000..c1a8632 --- /dev/null +++ b/app/Commands/CohesionCheckCommand.php @@ -0,0 +1,66 @@ +check = $check; + $this->checksClient = $checksClient; + } + + public function handle(): int + { + $check = $this->check ?? new CohesionCheck; + $workingDirectory = getcwd(); + + $this->info('🔗 Analyzing PR cohesion...'); + $this->newLine(); + + $result = $check->run($workingDirectory); + + if ($result->passed) { + $this->info("✓ {$result->message}"); + return self::SUCCESS; + } + + $this->error("✗ {$result->message}"); + + if (!empty($result->details)) { + $this->newLine(); + foreach ($result->details as $detail) { + $this->line(" • {$detail}"); + } + } + + // Report to GitHub if in CI + if ($this->checksClient ?? ChecksClient::isAvailable()) { + $client = $this->checksClient ?? ChecksClient::fromEnvironment(); + $summary = !empty($result->details) + ? implode("\n", $result->details) + : $result->message; + $client->reportCheck( + 'PR Cohesion', + $result->passed, + $result->message, + $summary + ); + } + + return self::FAILURE; + } +} diff --git a/app/Commands/InstallHooksCommand.php b/app/Commands/InstallHooksCommand.php new file mode 100644 index 0000000..2e9f052 --- /dev/null +++ b/app/Commands/InstallHooksCommand.php @@ -0,0 +1,249 @@ +isGitRepository()) { + $this->error('Not in a git repository'); + $this->info('Run this command from the root of your git project'); + + return self::FAILURE; + } + + $repoRoot = $this->getGitRoot(); + + $this->info('🚪 Gate - Installing Git Hooks'); + $this->newLine(); + $this->info("Repository: {$repoRoot}"); + $this->newLine(); + + // Create .gate directory + $this->task('Creating .gate directory', function () use ($repoRoot) { + $gateDir = "{$repoRoot}/.gate"; + if (! is_dir($gateDir)) { + mkdir($gateDir, 0755, true); + } + + return true; + }); + + // Create config file + $configFile = "{$repoRoot}/.gate/config.php"; + if (! file_exists($configFile) || $this->option('force')) { + $this->task('Creating config file', function () use ($configFile) { + file_put_contents($configFile, $this->getConfigTemplate()); + + return true; + }); + } else { + $this->warn('Config already exists, skipping (use --force to overwrite)'); + } + + // Install pre-commit hook + $preCommitHook = "{$repoRoot}/.git/hooks/pre-commit"; + if (file_exists($preCommitHook) && ! $this->option('force')) { + $this->warn('Pre-commit hook already exists'); + if ($this->confirm('Backup and replace?', true)) { + copy($preCommitHook, "{$preCommitHook}.backup"); + $this->installPreCommitHook($preCommitHook); + } + } else { + $this->task('Installing pre-commit hook', function () use ($preCommitHook) { + $this->installPreCommitHook($preCommitHook); + + return true; + }); + } + + // Install post-commit hook + $postCommitHook = "{$repoRoot}/.git/hooks/post-commit"; + if (file_exists($postCommitHook) && ! $this->option('force')) { + $this->warn('Post-commit hook already exists'); + if ($this->confirm('Backup and replace?', true)) { + copy($postCommitHook, "{$postCommitHook}.backup"); + $this->installPostCommitHook($postCommitHook); + } + } else { + $this->task('Installing post-commit hook', function () use ($postCommitHook) { + $this->installPostCommitHook($postCommitHook); + + return true; + }); + } + + $this->newLine(); + $this->info('✓ Gate installed successfully!'); + $this->newLine(); + + $this->info('Test it now:'); + $this->line(' 1. Make a change: echo "test" >> README.md'); + $this->line(' 2. Stage it: git add README.md'); + $this->line(' 3. Commit it: git commit -m "test: validate gate"'); + $this->line(' 4. Watch: Gate runs automatically!'); + + $this->newLine(); + $this->info('Configuration:'); + $this->line(" Edit {$repoRoot}/.gate/config.php to customize checks"); + + return self::SUCCESS; + } + + protected function isGitRepository(): bool + { + return is_dir(getcwd().'/.git'); + } + + protected function getGitRoot(): string + { + return trim(shell_exec('git rev-parse --show-toplevel')); + } + + protected function installPreCommitHook(string $path): void + { + $gateBinary = $this->getGateBinaryPath(); + + $content = <<getGateBinaryPath(); + + $content = << [ + 'syntax' => true, // Validate syntax (PHP, JS) + 'secrets' => true, // Detect hardcoded secrets + 'static' => true, // Static analysis (PHPStan) + 'lint' => false, // Code style linting (slower) + 'tests' => false, // Quick unit tests + 'logic' => true, // Ollama atomicity validation + ], + + /* + |-------------------------------------------------------------------------- + | Layer 2: Post-Commit Processing + |-------------------------------------------------------------------------- + | + | These actions run after commit succeeds + | + */ + 'post_commit' => [ + 'attribution' => true, // Remove Claude Code attribution + 'format' => true, // Auto-format code + 'fix_message' => true, // Fix commit message format + ], + + /* + |-------------------------------------------------------------------------- + | Layer 3: CI/CD Configuration + |-------------------------------------------------------------------------- + | + | Configuration for GitHub Actions / CI pipelines + | + */ + 'ci' => [ + 'coverage_min' => 80, // Minimum coverage percentage + 'ai_review' => false, // Enable Ollama AI review + ], + + /* + |-------------------------------------------------------------------------- + | Ollama Configuration + |-------------------------------------------------------------------------- + | + | Settings for local Ollama AI validation + | + */ + 'ollama' => [ + 'model' => 'llama3.2:3b', // Model for logic validation + 'timeout' => 30, // Max seconds for analysis + ], + + /* + |-------------------------------------------------------------------------- + | Severity Routing + |-------------------------------------------------------------------------- + | + | How to handle different severity levels + | + */ + 'severity' => [ + 'block_on_critical' => true, // Stop on critical issues + 'block_on_high' => true, // Stop on high severity + 'warn_on_medium' => true, // Warn on medium (continue) + 'audit_on_low' => true, // Log low severity issues + ], +]; + +PHP; + } + + public function schedule(Schedule $schedule): void + { + // No scheduled tasks for this command + } +} diff --git a/app/Commands/LogicCheckCommand.php b/app/Commands/LogicCheckCommand.php new file mode 100644 index 0000000..22a9d55 --- /dev/null +++ b/app/Commands/LogicCheckCommand.php @@ -0,0 +1,66 @@ +check = $check; + $this->checksClient = $checksClient; + } + + public function handle(): int + { + $check = $this->check ?? new LogicCheck; + $workingDirectory = getcwd(); + + $this->info('🧠 Validating commit atomicity and logic...'); + $this->newLine(); + + $result = $check->run($workingDirectory); + + if ($result->passed) { + $this->info("✓ {$result->message}"); + return self::SUCCESS; + } + + $this->error("✗ {$result->message}"); + + if (!empty($result->details)) { + $this->newLine(); + foreach ($result->details as $detail) { + $this->line(" • {$detail}"); + } + } + + // Report to GitHub if in CI + if ($this->checksClient ?? ChecksClient::isAvailable()) { + $client = $this->checksClient ?? ChecksClient::fromEnvironment(); + $summary = !empty($result->details) + ? implode("\n", $result->details) + : $result->message; + $client->reportCheck( + 'Logic & Atomicity', + $result->passed, + $result->message, + $summary + ); + } + + return self::FAILURE; + } +} diff --git a/phpunit.xml b/phpunit.xml index 66770ee..d8c1d83 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -9,6 +9,9 @@ failOnWarning="true" stopOnFailure="false" beStrictAboutOutputDuringTests="true"> + + + tests/Unit @@ -23,6 +26,7 @@ app/Providers + app/Commands/InstallHooksCommand.php diff --git a/tests/Unit/Checks/AttributionCheckTest.php b/tests/Unit/Checks/AttributionCheckTest.php new file mode 100644 index 0000000..236f3ab --- /dev/null +++ b/tests/Unit/Checks/AttributionCheckTest.php @@ -0,0 +1,166 @@ +name())->toBe('Attribution Check'); + }); + + it('implements CheckInterface', function () { + $check = new AttributionCheck; + expect($check)->toBeInstanceOf(\App\Checks\CheckInterface::class); + }); + + it('returns pass when no commit exists', function () { + $mockRunner = mock(ProcessRunner::class); + $mockRunner->shouldReceive('run') + ->once() + ->andReturn(new ProcessResult( + successful: false, + output: '', + )); + + $check = new AttributionCheck(processRunner: $mockRunner); + $result = $check->run('/tmp'); + + expect($result->passed)->toBeTrue(); + expect($result->message)->toBe('No commit to check'); + }); + + it('returns pass when commit has no attribution', function () { + $mockRunner = mock(ProcessRunner::class); + $mockRunner->shouldReceive('run') + ->once() + ->andReturn(new ProcessResult( + successful: true, + output: 'feat: add new feature + +This is a clean commit message without any attribution.', + )); + + $check = new AttributionCheck(processRunner: $mockRunner); + $result = $check->run('/tmp'); + + expect($result->passed)->toBeTrue(); + expect($result->message)->toBe('No Claude attribution found'); + }); + + it('detects emoji Claude Code attribution', function () { + $mockRunner = mock(ProcessRunner::class); + $mockRunner->shouldReceive('run') + ->once() + ->andReturn(new ProcessResult( + successful: true, + output: 'feat: add new feature + +🤖 Generated with [Claude Code](https://claude.com/claude-code)', + )); + + $check = new AttributionCheck(processRunner: $mockRunner); + $result = $check->run('/tmp'); + + expect($result->passed)->toBeFalse(); + expect($result->message)->toBe('Claude Code attribution detected in commit'); + expect($result->details)->toContain('🤖 Generated wth \[Claude Code\]'); + }); + + it('detects text Claude Code attribution', function () { + $mockRunner = mock(ProcessRunner::class); + $mockRunner->shouldReceive('run') + ->once() + ->andReturn(new ProcessResult( + successful: true, + output: 'feat: add new feature + +Generated with Claude Code', + )); + + $check = new AttributionCheck(processRunner: $mockRunner); + $result = $check->run('/tmp'); + + expect($result->passed)->toBeFalse(); + expect($result->message)->toBe('Claude Code attribution detected in commit'); + }); + + it('detects Co-Authored-By Claude attribution', function () { + $mockRunner = mock(ProcessRunner::class); + $mockRunner->shouldReceive('run') + ->once() + ->andReturn(new ProcessResult( + successful: true, + output: 'feat: add new feature + +Co-Authored-By: Claude Sonnet 4.5 ', + )); + + $check = new AttributionCheck(processRunner: $mockRunner); + $result = $check->run('/tmp'); + + expect($result->passed)->toBeFalse(); + expect($result->message)->toBe('Claude Code attribution detected in commit'); + expect($result->details)->toContain('Co-Authored-By: Claude'); + expect($result->details)->toContain('noreply@anthropc\.com'); + }); + + it('detects lowercase co-authored-by', function () { + $mockRunner = mock(ProcessRunner::class); + $mockRunner->shouldReceive('run') + ->once() + ->andReturn(new ProcessResult( + successful: true, + output: 'feat: add new feature + +Co-authored-by: Claude ', + )); + + $check = new AttributionCheck(processRunner: $mockRunner); + $result = $check->run('/tmp'); + + expect($result->passed)->toBeFalse(); + expect($result->message)->toBe('Claude Code attribution detected in commit'); + }); + + it('detects multiple attribution patterns', function () { + $mockRunner = mock(ProcessRunner::class); + $mockRunner->shouldReceive('run') + ->once() + ->andReturn(new ProcessResult( + successful: true, + output: 'feat: add new feature + +🤖 Generated with [Claude Code](https://claude.com/claude-code) + +Co-Authored-By: Claude Sonnet 4.5 ', + )); + + $check = new AttributionCheck(processRunner: $mockRunner); + $result = $check->run('/tmp'); + + expect($result->passed)->toBeFalse(); + expect($result->details)->toHaveCount(4); // emoji, co-authored-by (capital), co-authored-by (lowercase), noreply + }); + + it('passes correct command to process runner', function () { + $mockRunner = mock(ProcessRunner::class); + $mockRunner->shouldReceive('run') + ->once() + ->with( + ['git', 'log', '-1', '--pretty=%B'], + '/some/path', + Mockery::any() + ) + ->andReturn(new ProcessResult( + successful: true, + output: 'clean commit', + )); + + $check = new AttributionCheck(processRunner: $mockRunner); + $check->run('/some/path'); + }); +}); diff --git a/tests/Unit/Checks/CohesionCheckTest.php b/tests/Unit/Checks/CohesionCheckTest.php new file mode 100644 index 0000000..19cf9d8 --- /dev/null +++ b/tests/Unit/Checks/CohesionCheckTest.php @@ -0,0 +1,415 @@ +name())->toBe('PR Cohesion'); + }); + + it('implements CheckInterface', function () { + $check = new CohesionCheck; + expect($check)->toBeInstanceOf(\App\Checks\CheckInterface::class); + }); + + it('returns pass when Ollama not available', function () { + $mockRunner = mock(ProcessRunner::class); + $mockRunner->shouldReceive('run') + ->once() + ->with(['which', 'ollama'], Mockery::any(), Mockery::any()) + ->andReturn(new ProcessResult( + successful: false, + output: '', + )); + + $check = new CohesionCheck(processRunner: $mockRunner); + $result = $check->run('/tmp'); + + expect($result->passed)->toBeTrue(); + expect($result->message)->toBe('Ollama not installed - skipping cohesion check'); + }); + + it('returns pass when no changed files', function () { + $mockRunner = mock(ProcessRunner::class); + $mockRunner->shouldReceive('run') + ->with(['which', 'ollama'], Mockery::any(), Mockery::any()) + ->andReturn(new ProcessResult(successful: true, output: '/usr/bin/ollama')); + + // Try all base branches and return empty + $mockRunner->shouldReceive('run') + ->with(['git', 'diff', '--name-only', 'origin/main...HEAD'], Mockery::any(), Mockery::any()) + ->andReturn(new ProcessResult(successful: false, output: '')); + + $mockRunner->shouldReceive('run') + ->with(['git', 'diff', '--name-only', 'origin/master...HEAD'], Mockery::any(), Mockery::any()) + ->andReturn(new ProcessResult(successful: false, output: '')); + + $mockRunner->shouldReceive('run') + ->with(['git', 'diff', '--name-only', 'origin/develop...HEAD'], Mockery::any(), Mockery::any()) + ->andReturn(new ProcessResult(successful: false, output: '')); + + $check = new CohesionCheck(processRunner: $mockRunner); + $result = $check->run('/tmp'); + + expect($result->passed)->toBeTrue(); + expect($result->message)->toBe('No changed files to analyze'); + }); + + it('tries multiple base branches when main fails', function () { + $mockRunner = mock(ProcessRunner::class); + $mockRunner->shouldReceive('run') + ->with(['which', 'ollama'], Mockery::any(), Mockery::any()) + ->andReturn(new ProcessResult(successful: true, output: '/usr/bin/ollama')); + + // Try main - fail + $mockRunner->shouldReceive('run') + ->with(['git', 'diff', '--name-only', 'origin/main...HEAD'], Mockery::any(), Mockery::any()) + ->andReturn(new ProcessResult(successful: false, output: '')); + + // Try master - succeed + $mockRunner->shouldReceive('run') + ->with(['git', 'diff', '--name-only', 'origin/master...HEAD'], Mockery::any(), Mockery::any()) + ->andReturn(new ProcessResult(successful: true, output: 'src/User.php')); + + // Try main diff - fail (for getPRDiff) + $mockRunner->shouldReceive('run') + ->with(['git', 'diff', 'origin/main...HEAD'], Mockery::any(), Mockery::any()) + ->andReturn(new ProcessResult(successful: false, output: '')); + + // Try master diff - succeed (for getPRDiff) + $mockRunner->shouldReceive('run') + ->with(['git', 'diff', 'origin/master...HEAD'], Mockery::any(), Mockery::any()) + ->andReturn(new ProcessResult(successful: true, output: 'diff content')); + + $mockRunner->shouldReceive('run') + ->with(['sh', '-c', 'ollama list | grep llama3.2:3b'], Mockery::any(), Mockery::any()) + ->andReturn(new ProcessResult(successful: true, output: 'llama3.2:3b')); + + $mockRunner->shouldReceive('run') + ->with(Mockery::on(fn ($cmd) => $cmd[0] === 'ollama' && $cmd[1] === 'run' && $cmd[2] === 'llama3.2:3b'), Mockery::any(), Mockery::any()) + ->andReturn(new ProcessResult( + successful: true, + output: 'COHESIVE: YES +MISSING_FILES: none +DEPENDENCY_ISSUES: none +PURPOSE: Add user authentication +CONCERNS: none', + )); + + $check = new CohesionCheck(processRunner: $mockRunner); + $result = $check->run('/tmp'); + + // Verify the check succeeded using the fallback master branch + expect($result->passed)->toBeTrue(); + expect($result->message)->toBe('Add user authentication'); + }); + + it('returns pass when PR is cohesive', function () { + $mockRunner = mock(ProcessRunner::class); + + $mockRunner->shouldReceive('run') + ->with(['which', 'ollama'], Mockery::any(), Mockery::any()) + ->andReturn(new ProcessResult(successful: true, output: '/usr/bin/ollama')); + + $mockRunner->shouldReceive('run') + ->with(['git', 'diff', '--name-only', 'origin/main...HEAD'], Mockery::any(), Mockery::any()) + ->andReturn(new ProcessResult(successful: true, output: "app/Models/User.php\napp/Http/Controllers/UserController.php\ntests/Feature/UserTest.php")); + + $mockRunner->shouldReceive('run') + ->with(['git', 'diff', 'origin/main...HEAD'], Mockery::any(), Mockery::any()) + ->andReturn(new ProcessResult(successful: true, output: 'diff content')); + + $mockRunner->shouldReceive('run') + ->with(['sh', '-c', 'ollama list | grep llama3.2:3b'], Mockery::any(), Mockery::any()) + ->andReturn(new ProcessResult(successful: true, output: 'llama3.2:3b')); + + $mockRunner->shouldReceive('run') + ->with(Mockery::on(fn ($cmd) => $cmd[0] === 'ollama' && $cmd[1] === 'run' && $cmd[2] === 'llama3.2:3b'), Mockery::any(), Mockery::any()) + ->andReturn(new ProcessResult( + successful: true, + output: 'COHESIVE: YES +MISSING_FILES: none +DEPENDENCY_ISSUES: none +PURPOSE: Add user management feature with tests +CONCERNS: none', + )); + + $check = new CohesionCheck(processRunner: $mockRunner); + $result = $check->run('/tmp'); + + expect($result->passed)->toBeTrue(); + expect($result->message)->toBe('Add user management feature with tests'); + }); + + it('returns fail when PR lacks cohesion', function () { + $mockRunner = mock(ProcessRunner::class); + + $mockRunner->shouldReceive('run') + ->with(['which', 'ollama'], Mockery::any(), Mockery::any()) + ->andReturn(new ProcessResult(successful: true, output: '/usr/bin/ollama')); + + $mockRunner->shouldReceive('run') + ->with(['git', 'diff', '--name-only', 'origin/main...HEAD'], Mockery::any(), Mockery::any()) + ->andReturn(new ProcessResult(successful: true, output: "app/Models/User.php\napp/Services/PaymentService.php")); + + $mockRunner->shouldReceive('run') + ->with(['git', 'diff', 'origin/main...HEAD'], Mockery::any(), Mockery::any()) + ->andReturn(new ProcessResult(successful: true, output: 'diff content')); + + $mockRunner->shouldReceive('run') + ->with(['sh', '-c', 'ollama list | grep llama3.2:3b'], Mockery::any(), Mockery::any()) + ->andReturn(new ProcessResult(successful: true, output: 'llama3.2:3b')); + + $mockRunner->shouldReceive('run') + ->with(Mockery::on(fn ($cmd) => $cmd[0] === 'ollama' && $cmd[1] === 'run' && $cmd[2] === 'llama3.2:3b'), Mockery::any(), Mockery::any()) + ->andReturn(new ProcessResult( + successful: true, + output: 'COHESIVE: NO +MISSING_FILES: tests for User and Payment changes +DEPENDENCY_ISSUES: User model changed but no migration +PURPOSE: Mixed user and payment changes +CONCERNS: Unrelated features in same PR', + )); + + $check = new CohesionCheck(processRunner: $mockRunner); + $result = $check->run('/tmp'); + + expect($result->passed)->toBeFalse(); + expect($result->message)->toBe('PR cohesion validation failed'); + expect($result->details)->toContain('PR lacks cohesion - mixing unrelated changes'); + expect($result->details)->toContain('Missing files: tests for User and Payment changes'); + expect($result->details)->toContain('Cross-file issues: User model changed but no migration'); + expect($result->details)->toContain('Concerns: Unrelated features in same PR'); + }); + + it('categorizes files correctly', function () { + $mockRunner = mock(ProcessRunner::class); + + $mockRunner->shouldReceive('run') + ->with(['which', 'ollama'], Mockery::any(), Mockery::any()) + ->andReturn(new ProcessResult(successful: true, output: '/usr/bin/ollama')); + + $mockRunner->shouldReceive('run') + ->with(['git', 'diff', '--name-only', 'origin/main...HEAD'], Mockery::any(), Mockery::any()) + ->andReturn(new ProcessResult( + successful: true, + output: "app/Models/User.php\napp/Http/Controllers/UserController.php\nresources/views/users/index.blade.php\ntests/Feature/UserTest.php\ndatabase/migrations/2024_01_01_create_users.php\nconfig/auth.php\nroutes/web.php\napp/Services/UserService.php\nREADME.md", + )); + + $mockRunner->shouldReceive('run') + ->with(['git', 'diff', 'origin/main...HEAD'], Mockery::any(), Mockery::any()) + ->andReturn(new ProcessResult(successful: true, output: 'diff')); + + $mockRunner->shouldReceive('run') + ->with(['sh', '-c', 'ollama list | grep llama3.2:3b'], Mockery::any(), Mockery::any()) + ->andReturn(new ProcessResult(successful: true, output: 'llama3.2:3b')); + + $mockRunner->shouldReceive('run') + ->with(Mockery::on(fn ($cmd) => $cmd[0] === 'ollama' && $cmd[1] === 'run' && $cmd[2] === 'llama3.2:3b'), Mockery::any(), Mockery::any()) + ->andReturn(new ProcessResult( + successful: true, + output: 'COHESIVE: YES +MISSING_FILES: none +DEPENDENCY_ISSUES: none +PURPOSE: Complete user feature +CONCERNS: none', + )); + + $check = new CohesionCheck(processRunner: $mockRunner); + $result = $check->run('/tmp'); + + // Verify the check completed successfully with categorized files + expect($result->passed)->toBeTrue(); + expect($result->message)->toBe('Complete user feature'); + }); + + it('returns fail when cohesion analysis fails', function () { + $mockRunner = mock(ProcessRunner::class); + + $mockRunner->shouldReceive('run') + ->with(['which', 'ollama'], Mockery::any(), Mockery::any()) + ->andReturn(new ProcessResult(successful: true, output: '/usr/bin/ollama')); + + $mockRunner->shouldReceive('run') + ->with(['git', 'diff', '--name-only', 'origin/main...HEAD'], Mockery::any(), Mockery::any()) + ->andReturn(new ProcessResult(successful: true, output: 'file.php')); + + $mockRunner->shouldReceive('run') + ->with(['git', 'diff', 'origin/main...HEAD'], Mockery::any(), Mockery::any()) + ->andReturn(new ProcessResult(successful: true, output: 'diff')); + + $mockRunner->shouldReceive('run') + ->with(['sh', '-c', 'ollama list | grep llama3.2:3b'], Mockery::any(), Mockery::any()) + ->andReturn(new ProcessResult(successful: true, output: 'llama3.2:3b')); + + // Ollama command fails + $mockRunner->shouldReceive('run') + ->with(Mockery::on(fn ($cmd) => $cmd[0] === 'ollama' && $cmd[1] === 'run' && $cmd[2] === 'llama3.2:3b'), Mockery::any(), Mockery::any()) + ->andReturn(new ProcessResult(successful: false, output: '')); + + $check = new CohesionCheck(processRunner: $mockRunner); + $result = $check->run('/tmp'); + + expect($result->passed)->toBeFalse(); + expect($result->message)->toBe('Cohesion analysis failed'); + }); + + it('pulls model when not available', function () { + $mockRunner = mock(ProcessRunner::class); + + $mockRunner->shouldReceive('run') + ->with(['which', 'ollama'], Mockery::any(), Mockery::any()) + ->andReturn(new ProcessResult(successful: true, output: '/usr/bin/ollama')); + + $mockRunner->shouldReceive('run') + ->with(['git', 'diff', '--name-only', 'origin/main...HEAD'], Mockery::any(), Mockery::any()) + ->andReturn(new ProcessResult(successful: true, output: 'file.php')); + + $mockRunner->shouldReceive('run') + ->with(['git', 'diff', 'origin/main...HEAD'], Mockery::any(), Mockery::any()) + ->andReturn(new ProcessResult(successful: true, output: 'diff')); + + // Model not available - should trigger pull + $mockRunner->shouldReceive('run') + ->with(['sh', '-c', 'ollama list | grep llama3.2:3b'], Mockery::any(), Mockery::any()) + ->andReturn(new ProcessResult(successful: false, output: '')); + + $mockRunner->shouldReceive('run') + ->once() + ->with(['ollama', 'pull', 'llama3.2:3b'], Mockery::any(), Mockery::any()) + ->andReturn(new ProcessResult(successful: true, output: 'pulled')); + + $mockRunner->shouldReceive('run') + ->with(Mockery::on(fn ($cmd) => $cmd[0] === 'ollama' && $cmd[1] === 'run' && $cmd[2] === 'llama3.2:3b'), Mockery::any(), Mockery::any()) + ->andReturn(new ProcessResult( + successful: true, + output: 'COHESIVE: YES +MISSING_FILES: none +DEPENDENCY_ISSUES: none +PURPOSE: Test +CONCERNS: none', + )); + + $check = new CohesionCheck(processRunner: $mockRunner); + $result = $check->run('/tmp'); + + expect($result->passed)->toBeTrue(); + }); + + it('returns pass when all branch diffs fail', function () { + $mockRunner = mock(ProcessRunner::class); + + $mockRunner->shouldReceive('run') + ->with(['which', 'ollama'], Mockery::any(), Mockery::any()) + ->andReturn(new ProcessResult(successful: true, output: '/usr/bin/ollama')); + + // All git diff --name-only attempts fail + $mockRunner->shouldReceive('run') + ->with(['git', 'diff', '--name-only', 'origin/main...HEAD'], Mockery::any(), Mockery::any()) + ->andReturn(new ProcessResult(successful: false, output: '')); + + $mockRunner->shouldReceive('run') + ->with(['git', 'diff', '--name-only', 'origin/master...HEAD'], Mockery::any(), Mockery::any()) + ->andReturn(new ProcessResult(successful: false, output: '')); + + $mockRunner->shouldReceive('run') + ->with(['git', 'diff', '--name-only', 'origin/develop...HEAD'], Mockery::any(), Mockery::any()) + ->andReturn(new ProcessResult(successful: false, output: '')); + + $check = new CohesionCheck(processRunner: $mockRunner); + $result = $check->run('/tmp'); + + expect($result->passed)->toBeTrue(); + expect($result->message)->toBe('No changed files to analyze'); + }); + + it('categorizes test config and route files correctly', function () { + $mockRunner = mock(ProcessRunner::class); + + $mockRunner->shouldReceive('run') + ->with(['which', 'ollama'], Mockery::any(), Mockery::any()) + ->andReturn(new ProcessResult(successful: true, output: '/usr/bin/ollama')); + + $mockRunner->shouldReceive('run') + ->with(['git', 'diff', '--name-only', 'origin/main...HEAD'], Mockery::any(), Mockery::any()) + ->andReturn(new ProcessResult( + successful: true, + output: "app/tests/Feature/UserTest.php\napp/config/database.php\napp/routes/web.php", + )); + + $mockRunner->shouldReceive('run') + ->with(['git', 'diff', 'origin/main...HEAD'], Mockery::any(), Mockery::any()) + ->andReturn(new ProcessResult(successful: true, output: 'diff')); + + $mockRunner->shouldReceive('run') + ->with(['sh', '-c', 'ollama list | grep llama3.2:3b'], Mockery::any(), Mockery::any()) + ->andReturn(new ProcessResult(successful: true, output: 'llama3.2:3b')); + + $mockRunner->shouldReceive('run') + ->with(Mockery::on(fn ($cmd) => $cmd[0] === 'ollama' && $cmd[1] === 'run' && $cmd[2] === 'llama3.2:3b'), Mockery::any(), Mockery::any()) + ->andReturn(new ProcessResult( + successful: true, + output: 'COHESIVE: YES +MISSING_FILES: none +DEPENDENCY_ISSUES: none +PURPOSE: Test +CONCERNS: none', + )); + + $check = new CohesionCheck(processRunner: $mockRunner); + $result = $check->run('/tmp'); + + expect($result->passed)->toBeTrue(); + }); + + it('returns fail when PR diff unavailable', function () { + $mockRunner = mock(ProcessRunner::class); + + $mockRunner->shouldReceive('run') + ->with(['which', 'ollama'], Mockery::any(), Mockery::any()) + ->andReturn(new ProcessResult(successful: true, output: '/usr/bin/ollama')); + + // getChangedFiles succeeds + $mockRunner->shouldReceive('run') + ->with(['git', 'diff', '--name-only', 'origin/main...HEAD'], Mockery::any(), Mockery::any()) + ->andReturn(new ProcessResult(successful: true, output: 'file.php')); + + // But all getPRDiff attempts fail + $mockRunner->shouldReceive('run') + ->with(['git', 'diff', 'origin/main...HEAD'], Mockery::any(), Mockery::any()) + ->andReturn(new ProcessResult(successful: false, output: '')); + + $mockRunner->shouldReceive('run') + ->with(['git', 'diff', 'origin/master...HEAD'], Mockery::any(), Mockery::any()) + ->andReturn(new ProcessResult(successful: false, output: '')); + + $mockRunner->shouldReceive('run') + ->with(['git', 'diff', 'origin/develop...HEAD'], Mockery::any(), Mockery::any()) + ->andReturn(new ProcessResult(successful: false, output: '')); + + $mockRunner->shouldReceive('run') + ->with(['sh', '-c', 'ollama list | grep llama3.2:3b'], Mockery::any(), Mockery::any()) + ->andReturn(new ProcessResult(successful: true, output: 'llama3.2:3b')); + + $mockRunner->shouldReceive('run') + ->with(Mockery::on(fn ($cmd) => $cmd[0] === 'ollama' && $cmd[1] === 'run' && $cmd[2] === 'llama3.2:3b'), Mockery::any(), Mockery::any()) + ->andReturn(new ProcessResult( + successful: true, + output: 'COHESIVE: YES +MISSING_FILES: none +DEPENDENCY_ISSUES: none +PURPOSE: Test +CONCERNS: none', + )); + + $check = new CohesionCheck(processRunner: $mockRunner); + $result = $check->run('/tmp'); + + expect($result->passed)->toBeTrue(); + }); +}); diff --git a/tests/Unit/Checks/LogicCheckTest.php b/tests/Unit/Checks/LogicCheckTest.php new file mode 100644 index 0000000..eaa557a --- /dev/null +++ b/tests/Unit/Checks/LogicCheckTest.php @@ -0,0 +1,289 @@ +name())->toBe('Logic & Atomicity'); + }); + + it('implements CheckInterface', function () { + $check = new LogicCheck; + expect($check)->toBeInstanceOf(\App\Checks\CheckInterface::class); + }); + + it('returns pass when Ollama not available', function () { + $mockRunner = mock(ProcessRunner::class); + $mockRunner->shouldReceive('run') + ->once() + ->with(['which', 'ollama'], Mockery::any(), Mockery::any()) + ->andReturn(new ProcessResult( + successful: false, + output: '', + )); + + $check = new LogicCheck(processRunner: $mockRunner); + $result = $check->run('/tmp'); + + expect($result->passed)->toBeTrue(); + expect($result->message)->toBe('Ollama not installed - skipping logic validation'); + }); + + it('returns pass when Ollama not running', function () { + $mockRunner = mock(ProcessRunner::class); + $mockRunner->shouldReceive('run') + ->with(['which', 'ollama'], Mockery::any(), Mockery::any()) + ->andReturn(new ProcessResult(successful: true, output: '/usr/bin/ollama')); + + $mockRunner->shouldReceive('run') + ->with(['ollama', 'list'], Mockery::any(), Mockery::any()) + ->andReturn(new ProcessResult(successful: false, output: '')); + + $check = new LogicCheck(processRunner: $mockRunner); + $result = $check->run('/tmp'); + + expect($result->passed)->toBeTrue(); + expect($result->message)->toBe('Ollama not running - skipping logic validation'); + }); + + it('returns pass when no staged files', function () { + $mockRunner = mock(ProcessRunner::class); + $mockRunner->shouldReceive('run') + ->with(['which', 'ollama'], Mockery::any(), Mockery::any()) + ->andReturn(new ProcessResult(successful: true, output: '/usr/bin/ollama')); + + $mockRunner->shouldReceive('run') + ->with(['ollama', 'list'], Mockery::any(), Mockery::any()) + ->andReturn(new ProcessResult(successful: true, output: 'model list')); + + $mockRunner->shouldReceive('run') + ->with(['git', 'diff', '--cached', '--name-only', '--diff-filter=ACM'], Mockery::any(), Mockery::any()) + ->andReturn(new ProcessResult(successful: true, output: '')); + + $check = new LogicCheck(processRunner: $mockRunner); + $result = $check->run('/tmp'); + + expect($result->passed)->toBeTrue(); + expect($result->message)->toBe('No staged files to validate'); + }); + + it('returns pass when no changes', function () { + $mockRunner = mock(ProcessRunner::class); + $mockRunner->shouldReceive('run') + ->with(['which', 'ollama'], Mockery::any(), Mockery::any()) + ->andReturn(new ProcessResult(successful: true, output: '/usr/bin/ollama')); + + $mockRunner->shouldReceive('run') + ->with(['ollama', 'list'], Mockery::any(), Mockery::any()) + ->andReturn(new ProcessResult(successful: true, output: 'model list')); + + $mockRunner->shouldReceive('run') + ->with(['git', 'diff', '--cached', '--name-only', '--diff-filter=ACM'], Mockery::any(), Mockery::any()) + ->andReturn(new ProcessResult(successful: true, output: 'file.php')); + + $mockRunner->shouldReceive('run') + ->with(['git', 'diff', '--cached'], Mockery::any(), Mockery::any()) + ->andReturn(new ProcessResult(successful: true, output: '')); + + $check = new LogicCheck(processRunner: $mockRunner); + $result = $check->run('/tmp'); + + expect($result->passed)->toBeTrue(); + expect($result->message)->toBe('No changes to validate'); + }); + + it('returns pass when commit is atomic and related', function () { + $mockRunner = mock(ProcessRunner::class); + + // Setup mocks for all prerequisite checks + $mockRunner->shouldReceive('run') + ->with(['which', 'ollama'], Mockery::any(), Mockery::any()) + ->andReturn(new ProcessResult(successful: true, output: '/usr/bin/ollama')); + + $mockRunner->shouldReceive('run') + ->with(['ollama', 'list'], Mockery::any(), Mockery::any()) + ->andReturn(new ProcessResult(successful: true, output: 'model list')); + + $mockRunner->shouldReceive('run') + ->with(['git', 'diff', '--cached', '--name-only', '--diff-filter=ACM'], Mockery::any(), Mockery::any()) + ->andReturn(new ProcessResult(successful: true, output: 'src/User.php')); + + $mockRunner->shouldReceive('run') + ->with(['git', 'diff', '--cached'], Mockery::any(), Mockery::any()) + ->andReturn(new ProcessResult(successful: true, output: 'diff content')); + + // Model availability check + $mockRunner->shouldReceive('run') + ->with(['sh', '-c', 'ollama list | grep llama3.2:3b'], Mockery::any(), Mockery::any()) + ->andReturn(new ProcessResult(successful: true, output: 'llama3.2:3b')); + + // Ollama analysis + $mockRunner->shouldReceive('run') + ->with(Mockery::on(fn ($cmd) => $cmd[0] === 'ollama' && $cmd[1] === 'run' && $cmd[2] === 'llama3.2:3b'), Mockery::any(), Mockery::any()) + ->andReturn(new ProcessResult( + successful: true, + output: 'ATOMIC: YES +RELATED: YES +LOGIC_SOUND: YES +PURPOSE: Add user authentication feature +ISSUES: none', + )); + + $check = new LogicCheck(processRunner: $mockRunner); + $result = $check->run('/tmp'); + + expect($result->passed)->toBeTrue(); + expect($result->message)->toBe('Add user authentication feature'); + }); + + it('returns fail when commit is not atomic', function () { + $mockRunner = mock(ProcessRunner::class); + + $mockRunner->shouldReceive('run') + ->with(['which', 'ollama'], Mockery::any(), Mockery::any()) + ->andReturn(new ProcessResult(successful: true, output: '/usr/bin/ollama')); + + $mockRunner->shouldReceive('run') + ->with(['ollama', 'list'], Mockery::any(), Mockery::any()) + ->andReturn(new ProcessResult(successful: true, output: 'model list')); + + $mockRunner->shouldReceive('run') + ->with(['git', 'diff', '--cached', '--name-only', '--diff-filter=ACM'], Mockery::any(), Mockery::any()) + ->andReturn(new ProcessResult(successful: true, output: "src/User.php\nsrc/Payment.php")); + + $mockRunner->shouldReceive('run') + ->with(['git', 'diff', '--cached'], Mockery::any(), Mockery::any()) + ->andReturn(new ProcessResult(successful: true, output: 'diff content')); + + $mockRunner->shouldReceive('run') + ->with(['sh', '-c', 'ollama list | grep llama3.2:3b'], Mockery::any(), Mockery::any()) + ->andReturn(new ProcessResult(successful: true, output: 'llama3.2:3b')); + + $mockRunner->shouldReceive('run') + ->with(Mockery::on(fn ($cmd) => $cmd[0] === 'ollama' && $cmd[1] === 'run' && $cmd[2] === 'llama3.2:3b'), Mockery::any(), Mockery::any()) + ->andReturn(new ProcessResult( + successful: true, + output: 'ATOMIC: NO +RELATED: NO +LOGIC_SOUND: YES +PURPOSE: Mixed changes +ISSUES: Mixing user auth and payment processing', + )); + + $check = new LogicCheck(processRunner: $mockRunner); + $result = $check->run('/tmp'); + + expect($result->passed)->toBeFalse(); + expect($result->message)->toBe('Commit atomicity validation failed'); + expect($result->details)->toContain('Commit is NOT atomic - mixes multiple concerns'); + expect($result->details)->toContain('Changes are NOT all related'); + }); + + it('pulls model if not available', function () { + $mockRunner = mock(ProcessRunner::class); + + $mockRunner->shouldReceive('run') + ->with(['which', 'ollama'], Mockery::any(), Mockery::any()) + ->andReturn(new ProcessResult(successful: true, output: '/usr/bin/ollama')); + + $mockRunner->shouldReceive('run') + ->with(['ollama', 'list'], Mockery::any(), Mockery::any()) + ->andReturn(new ProcessResult(successful: true, output: 'model list')); + + $mockRunner->shouldReceive('run') + ->with(['git', 'diff', '--cached', '--name-only', '--diff-filter=ACM'], Mockery::any(), Mockery::any()) + ->andReturn(new ProcessResult(successful: true, output: 'file.php')); + + $mockRunner->shouldReceive('run') + ->with(['git', 'diff', '--cached'], Mockery::any(), Mockery::any()) + ->andReturn(new ProcessResult(successful: true, output: 'diff')); + + // Model not found + $mockRunner->shouldReceive('run') + ->with(['sh', '-c', 'ollama list | grep llama3.2:3b'], Mockery::any(), Mockery::any()) + ->andReturn(new ProcessResult(successful: false, output: '')); + + // Should pull model + $mockRunner->shouldReceive('run') + ->once() + ->with(['ollama', 'pull', 'llama3.2:3b'], Mockery::any(), Mockery::any()) + ->andReturn(new ProcessResult(successful: true, output: 'pulled')); + + $mockRunner->shouldReceive('run') + ->with(Mockery::on(fn ($cmd) => $cmd[0] === 'ollama' && $cmd[1] === 'run' && $cmd[2] === 'llama3.2:3b'), Mockery::any(), Mockery::any()) + ->andReturn(new ProcessResult( + successful: true, + output: 'ATOMIC: YES +RELATED: YES +LOGIC_SOUND: YES +PURPOSE: Test +ISSUES: none', + )); + + $check = new LogicCheck(processRunner: $mockRunner); + $check->run('/tmp'); + }); + + it('returns fail when Ollama analysis fails', function () { + $mockRunner = mock(ProcessRunner::class); + + $mockRunner->shouldReceive('run') + ->with(['which', 'ollama'], Mockery::any(), Mockery::any()) + ->andReturn(new ProcessResult(successful: true, output: '/usr/bin/ollama')); + + $mockRunner->shouldReceive('run') + ->with(['ollama', 'list'], Mockery::any(), Mockery::any()) + ->andReturn(new ProcessResult(successful: true, output: 'model list')); + + $mockRunner->shouldReceive('run') + ->with(['git', 'diff', '--cached', '--name-only', '--diff-filter=ACM'], Mockery::any(), Mockery::any()) + ->andReturn(new ProcessResult(successful: true, output: 'file.php')); + + $mockRunner->shouldReceive('run') + ->with(['git', 'diff', '--cached'], Mockery::any(), Mockery::any()) + ->andReturn(new ProcessResult(successful: true, output: 'diff')); + + $mockRunner->shouldReceive('run') + ->with(['sh', '-c', 'ollama list | grep llama3.2:3b'], Mockery::any(), Mockery::any()) + ->andReturn(new ProcessResult(successful: true, output: 'llama3.2:3b')); + + // Ollama command returns unsuccessful + $mockRunner->shouldReceive('run') + ->with(Mockery::on(fn ($cmd) => $cmd[0] === 'ollama' && $cmd[1] === 'run' && $cmd[2] === 'llama3.2:3b'), Mockery::any(), Mockery::any()) + ->andReturn(new ProcessResult(successful: false, output: '')); + + $check = new LogicCheck(processRunner: $mockRunner); + $result = $check->run('/tmp'); + + expect($result->passed)->toBeFalse(); + expect($result->message)->toBe('Analysis failed'); + }); + + it('returns fail when getting staged files fails', function () { + $mockRunner = mock(ProcessRunner::class); + + $mockRunner->shouldReceive('run') + ->with(['which', 'ollama'], Mockery::any(), Mockery::any()) + ->andReturn(new ProcessResult(successful: true, output: '/usr/bin/ollama')); + + $mockRunner->shouldReceive('run') + ->with(['ollama', 'list'], Mockery::any(), Mockery::any()) + ->andReturn(new ProcessResult(successful: true, output: 'model list')); + + // getStagedFiles returns unsuccessful + $mockRunner->shouldReceive('run') + ->with(['git', 'diff', '--cached', '--name-only', '--diff-filter=ACM'], Mockery::any(), Mockery::any()) + ->andReturn(new ProcessResult(successful: false, output: '')); + + $check = new LogicCheck(processRunner: $mockRunner); + $result = $check->run('/tmp'); + + expect($result->passed)->toBeTrue(); + expect($result->message)->toBe('No staged files to validate'); + }); +}); diff --git a/tests/Unit/Commands/AttributionCheckCommandTest.php b/tests/Unit/Commands/AttributionCheckCommandTest.php new file mode 100644 index 0000000..83c46ec --- /dev/null +++ b/tests/Unit/Commands/AttributionCheckCommandTest.php @@ -0,0 +1,107 @@ +createCommand = function (CheckInterface $check, ChecksClient $checksClient) { + $command = new AttributionCheckCommand; + $command->withMocks($check, $checksClient); + app()->singleton(AttributionCheckCommand::class, fn () => $command); + }; +}); + +describe('AttributionCheckCommand', function () { + describe('handle', function () { + it('returns success when no attribution found', function () { + $check = Mockery::mock(CheckInterface::class); + $check->shouldReceive('run') + ->once() + ->andReturn(CheckResult::pass('No Claude attribution found')); + + $mock = new MockHandler([new Response(201)]); + $httpClient = new Client(['handler' => HandlerStack::create($mock)]); + $checksClient = new ChecksClient('token', $httpClient, 'owner/repo', 'sha123', 1); + + ($this->createCommand)($check, $checksClient); + + $this->artisan('check:attribution') + ->assertSuccessful(); + }); + + it('returns failure when attribution detected', function () { + $check = Mockery::mock(CheckInterface::class); + $check->shouldReceive('run') + ->once() + ->andReturn(CheckResult::fail('Claude Code attribution detected in commit', [ + '🤖 Generated wth \[Claude Code\]', + 'Co-Authored-By: Claude', + ])); + + $mock = new MockHandler([new Response(201)]); + $httpClient = new Client(['handler' => HandlerStack::create($mock)]); + $checksClient = new ChecksClient('token', $httpClient, 'owner/repo', 'sha123', 1); + + ($this->createCommand)($check, $checksClient); + + $this->artisan('check:attribution') + ->assertFailed(); + }); + + it('displays attribution patterns when found', function () { + $check = Mockery::mock(CheckInterface::class); + $check->shouldReceive('run') + ->once() + ->andReturn(CheckResult::fail('Attribution found', ['Pattern 1', 'Pattern 2'])); + + $mock = new MockHandler([new Response(201)]); + $httpClient = new Client(['handler' => HandlerStack::create($mock)]); + $checksClient = new ChecksClient('token', $httpClient, 'owner/repo', 'sha123', 1); + + ($this->createCommand)($check, $checksClient); + + $this->artisan('check:attribution') + ->assertFailed(); + }); + + it('uses token from option', function () { + $check = Mockery::mock(CheckInterface::class); + $check->shouldReceive('run') + ->once() + ->andReturn(CheckResult::pass('No attribution')); + + $mock = new MockHandler([new Response(201)]); + $httpClient = new Client(['handler' => HandlerStack::create($mock)]); + $checksClient = new ChecksClient('custom-token', $httpClient, 'owner/repo', 'sha123', 1); + + ($this->createCommand)($check, $checksClient); + + $this->artisan('check:attribution', ['--token' => 'custom-token']) + ->assertSuccessful(); + }); + + it('handles failure without details', function () { + $check = Mockery::mock(CheckInterface::class); + $check->shouldReceive('run') + ->once() + ->andReturn(CheckResult::fail('Validation failed')); + + $mock = new MockHandler([new Response(201)]); + $httpClient = new Client(['handler' => HandlerStack::create($mock)]); + $checksClient = new ChecksClient('token', $httpClient, 'owner/repo', 'sha123', 1); + + ($this->createCommand)($check, $checksClient); + + $this->artisan('check:attribution') + ->assertFailed(); + }); + }); +}); diff --git a/tests/Unit/Commands/CertifyCommandTest.php b/tests/Unit/Commands/CertifyCommandTest.php index 10f2818..23b5c35 100644 --- a/tests/Unit/Commands/CertifyCommandTest.php +++ b/tests/Unit/Commands/CertifyCommandTest.php @@ -342,17 +342,38 @@ ->once() ->andReturn(CheckResult::pass('OK')); + $attributionCheck = Mockery::mock(CheckInterface::class); + $attributionCheck->shouldReceive('name')->andReturn('Attribution Check'); + $attributionCheck->shouldReceive('run') + ->once() + ->andReturn(CheckResult::pass('OK')); + + $logicCheck = Mockery::mock(CheckInterface::class); + $logicCheck->shouldReceive('name')->andReturn('Logic & Atomicity'); + $logicCheck->shouldReceive('run') + ->once() + ->andReturn(CheckResult::pass('OK')); + + $cohesionCheck = Mockery::mock(CheckInterface::class); + $cohesionCheck->shouldReceive('name')->andReturn('PR Cohesion'); + $cohesionCheck->shouldReceive('run') + ->once() + ->andReturn(CheckResult::pass('OK')); + $mock = new MockHandler([ new Response(201), // Tests check new Response(201), // Security check new Response(201), // Syntax check + new Response(201), // Attribution check + new Response(201), // Logic check + new Response(201), // Cohesion check new Response(201), // Certification check new Response(201), // Certification comment ]); $httpClient = new Client(['handler' => HandlerStack::create($mock)]); $checksClient = new ChecksClient('token', $httpClient, 'owner/repo', 'sha123', 1); - ($this->createCommand)([$testsCheck, $securityCheck, $syntaxCheck], $checksClient); + ($this->createCommand)([$testsCheck, $securityCheck, $syntaxCheck, $attributionCheck, $logicCheck, $cohesionCheck], $checksClient); $this->artisan('certify', ['--compact' => true]) ->assertSuccessful(); diff --git a/tests/Unit/Commands/CohesionCheckCommandTest.php b/tests/Unit/Commands/CohesionCheckCommandTest.php new file mode 100644 index 0000000..f8c2f36 --- /dev/null +++ b/tests/Unit/Commands/CohesionCheckCommandTest.php @@ -0,0 +1,108 @@ +createCommand = function (CheckInterface $check, ChecksClient $checksClient) { + $command = new CohesionCheckCommand; + $command->withMocks($check, $checksClient); + app()->singleton(CohesionCheckCommand::class, fn () => $command); + }; +}); + +describe('CohesionCheckCommand', function () { + describe('handle', function () { + it('returns success when PR is cohesive', function () { + $check = Mockery::mock(CheckInterface::class); + $check->shouldReceive('run') + ->once() + ->andReturn(CheckResult::pass('Add user management feature with tests')); + + $mock = new MockHandler([new Response(201)]); + $httpClient = new Client(['handler' => HandlerStack::create($mock)]); + $checksClient = new ChecksClient('token', $httpClient, 'owner/repo', 'sha123', 1); + + ($this->createCommand)($check, $checksClient); + + $this->artisan('check:cohesion') + ->assertSuccessful(); + }); + + it('returns failure when PR lacks cohesion', function () { + $check = Mockery::mock(CheckInterface::class); + $check->shouldReceive('run') + ->once() + ->andReturn(CheckResult::fail('PR cohesion validation failed', [ + 'PR lacks cohesion - mixing unrelated changes', + 'Missing files: tests for User and Payment changes', + 'Cross-file issues: User model changed but no migration', + ])); + + $mock = new MockHandler([new Response(201)]); + $httpClient = new Client(['handler' => HandlerStack::create($mock)]); + $checksClient = new ChecksClient('token', $httpClient, 'owner/repo', 'sha123', 1); + + ($this->createCommand)($check, $checksClient); + + $this->artisan('check:cohesion') + ->assertFailed(); + }); + + it('displays details when validation fails', function () { + $check = Mockery::mock(CheckInterface::class); + $check->shouldReceive('run') + ->once() + ->andReturn(CheckResult::fail('Validation failed', ['Issue 1', 'Issue 2'])); + + $mock = new MockHandler([new Response(201)]); + $httpClient = new Client(['handler' => HandlerStack::create($mock)]); + $checksClient = new ChecksClient('token', $httpClient, 'owner/repo', 'sha123', 1); + + ($this->createCommand)($check, $checksClient); + + $this->artisan('check:cohesion') + ->assertFailed(); + }); + + it('uses token from option', function () { + $check = Mockery::mock(CheckInterface::class); + $check->shouldReceive('run') + ->once() + ->andReturn(CheckResult::pass('Cohesive PR')); + + $mock = new MockHandler([new Response(201)]); + $httpClient = new Client(['handler' => HandlerStack::create($mock)]); + $checksClient = new ChecksClient('custom-token', $httpClient, 'owner/repo', 'sha123', 1); + + ($this->createCommand)($check, $checksClient); + + $this->artisan('check:cohesion', ['--token' => 'custom-token']) + ->assertSuccessful(); + }); + + it('handles failure without details', function () { + $check = Mockery::mock(CheckInterface::class); + $check->shouldReceive('run') + ->once() + ->andReturn(CheckResult::fail('Validation failed')); + + $mock = new MockHandler([new Response(201)]); + $httpClient = new Client(['handler' => HandlerStack::create($mock)]); + $checksClient = new ChecksClient('token', $httpClient, 'owner/repo', 'sha123', 1); + + ($this->createCommand)($check, $checksClient); + + $this->artisan('check:cohesion') + ->assertFailed(); + }); + }); +}); diff --git a/tests/Unit/Commands/InstallHooksCommandTest.php b/tests/Unit/Commands/InstallHooksCommandTest.php new file mode 100644 index 0000000..b27daf5 --- /dev/null +++ b/tests/Unit/Commands/InstallHooksCommandTest.php @@ -0,0 +1,36 @@ +artisan('install') + ->expectsOutput('Not in a git repository') + ->assertFailed(); + } finally { + chdir('/'); + if (is_dir($tempDir)) { + rmdir($tempDir); + } + } + }); + + it('has the correct signature', function () { + $command = new InstallHooksCommand; + expect($command)->toHaveProperty('signature'); + }); + + it('has correct description', function () { + $command = new InstallHooksCommand; + expect($command)->toHaveProperty('description'); + }); +}); diff --git a/tests/Unit/Commands/LogicCheckCommandTest.php b/tests/Unit/Commands/LogicCheckCommandTest.php new file mode 100644 index 0000000..828f62d --- /dev/null +++ b/tests/Unit/Commands/LogicCheckCommandTest.php @@ -0,0 +1,107 @@ +createCommand = function (CheckInterface $check, ChecksClient $checksClient) { + $command = new LogicCheckCommand; + $command->withMocks($check, $checksClient); + app()->singleton(LogicCheckCommand::class, fn () => $command); + }; +}); + +describe('LogicCheckCommand', function () { + describe('handle', function () { + it('returns success when commit is atomic', function () { + $check = Mockery::mock(CheckInterface::class); + $check->shouldReceive('run') + ->once() + ->andReturn(CheckResult::pass('Add user authentication feature')); + + $mock = new MockHandler([new Response(201)]); + $httpClient = new Client(['handler' => HandlerStack::create($mock)]); + $checksClient = new ChecksClient('token', $httpClient, 'owner/repo', 'sha123', 1); + + ($this->createCommand)($check, $checksClient); + + $this->artisan('check:logic') + ->assertSuccessful(); + }); + + it('returns failure when commit is not atomic', function () { + $check = Mockery::mock(CheckInterface::class); + $check->shouldReceive('run') + ->once() + ->andReturn(CheckResult::fail('Commit atomicity validation failed', [ + 'Commit is NOT atomic - mixes multiple concerns', + 'Changes are NOT all related', + ])); + + $mock = new MockHandler([new Response(201)]); + $httpClient = new Client(['handler' => HandlerStack::create($mock)]); + $checksClient = new ChecksClient('token', $httpClient, 'owner/repo', 'sha123', 1); + + ($this->createCommand)($check, $checksClient); + + $this->artisan('check:logic') + ->assertFailed(); + }); + + it('displays details when validation fails', function () { + $check = Mockery::mock(CheckInterface::class); + $check->shouldReceive('run') + ->once() + ->andReturn(CheckResult::fail('Validation failed', ['Issue 1', 'Issue 2'])); + + $mock = new MockHandler([new Response(201)]); + $httpClient = new Client(['handler' => HandlerStack::create($mock)]); + $checksClient = new ChecksClient('token', $httpClient, 'owner/repo', 'sha123', 1); + + ($this->createCommand)($check, $checksClient); + + $this->artisan('check:logic') + ->assertFailed(); + }); + + it('uses token from option', function () { + $check = Mockery::mock(CheckInterface::class); + $check->shouldReceive('run') + ->once() + ->andReturn(CheckResult::pass('Atomic commit')); + + $mock = new MockHandler([new Response(201)]); + $httpClient = new Client(['handler' => HandlerStack::create($mock)]); + $checksClient = new ChecksClient('custom-token', $httpClient, 'owner/repo', 'sha123', 1); + + ($this->createCommand)($check, $checksClient); + + $this->artisan('check:logic', ['--token' => 'custom-token']) + ->assertSuccessful(); + }); + + it('handles failure without details', function () { + $check = Mockery::mock(CheckInterface::class); + $check->shouldReceive('run') + ->once() + ->andReturn(CheckResult::fail('Validation failed')); + + $mock = new MockHandler([new Response(201)]); + $httpClient = new Client(['handler' => HandlerStack::create($mock)]); + $checksClient = new ChecksClient('token', $httpClient, 'owner/repo', 'sha123', 1); + + ($this->createCommand)($check, $checksClient); + + $this->artisan('check:logic') + ->assertFailed(); + }); + }); +});