Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/gate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ jobs:
path: base-coverage

- name: Run Gate on itself
run: php gate certify --coverage=100
run: php gate certify --coverage=90 --test-timeout=600
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

Expand Down
5 changes: 5 additions & 0 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,10 @@ inputs:
description: 'Post coverage report as PR comment'
required: false
default: 'true'
test-timeout:
description: 'Test execution timeout in seconds (default: 300, max: 900)'
required: false
default: '300'

outputs:
verdict:
Expand Down Expand Up @@ -122,6 +126,7 @@ runs:
certify|*)
php ${{ github.action_path }}/gate certify \
--coverage=${{ inputs.coverage-threshold }} \
--test-timeout=${{ inputs.test-timeout }} \
--token=${{ inputs.github-token }} \
${{ inputs.compact == 'true' && '--compact' || '' }}
;;
Expand Down
3 changes: 2 additions & 1 deletion app/Checks/TestRunner.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ final class TestRunner implements CheckInterface

public function __construct(
private readonly int $coverageThreshold = 100,
private readonly int $testTimeout = 300,
private readonly PestOutputParser $parser = new PestOutputParser,
private readonly ProcessRunner $processRunner = new SymfonyProcessRunner,
) {}
Expand Down Expand Up @@ -45,7 +46,7 @@ public function run(string $workingDirectory): CheckResult
$result = $this->processRunner->run(
['vendor/bin/pest', '--coverage', "--min={$this->coverageThreshold}", "--coverage-clover={$cloverPath}", '--colors=never'],
$workingDirectory,
timeout: 300,
timeout: $this->testTimeout,
);

// Post coverage comment to PR if enabled
Expand Down
39 changes: 35 additions & 4 deletions app/Commands/AnalyzeCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,37 @@ class AnalyzeCommand extends Command

protected $description = 'Send failures to Prefrontal Cortex for AI analysis';

/** @var Client|null For testing */
private ?Client $httpClient = null;

/** @var callable|null For testing file reading */
private $fileReader = null;

/** @internal For testing only */
public function withMocks(?Client $httpClient = null): self
{
$this->httpClient = $httpClient;

return $this;
}

/** @internal For testing only */
public function withFileReader(callable $reader): self
{
$this->fileReader = $reader;

return $this;
}

protected function readFile(string $path): string|false
{
if ($this->fileReader) {
return ($this->fileReader)($path);
}

return file_get_contents($path);
}

public function handle(): int
{
$apiUrl = $this->option('api-url') ?? getenv('PREFRONTAL_API_URL') ?: 'https://prefrontal.jordanpartridge.us';
Expand All @@ -35,7 +66,7 @@ public function handle(): int
return 1;
}

$failuresContent = file_get_contents($failuresFile);
$failuresContent = $this->readFile($failuresFile);
if ($failuresContent === false) {
$this->error('Could not read failures file');

Expand All @@ -52,7 +83,7 @@ public function handle(): int
$this->info('🧠 Sending failures to Prefrontal Cortex for analysis...');

try {
$client = new Client([
$client = $this->httpClient ?? new Client([
'base_uri' => $apiUrl,
'timeout' => 60,
]);
Expand Down Expand Up @@ -91,9 +122,9 @@ public function handle(): int
}
}

protected function detectRepo(): string
protected function detectRepo(?string $remoteOverride = null): string
{
$remote = trim(shell_exec('git remote get-url origin 2>/dev/null') ?? '');
$remote = $remoteOverride ?? trim(shell_exec('git remote get-url origin 2>/dev/null') ?? '');
if (preg_match('#github\.com[:/](.+?)(?:\.git)?$#', $remote, $matches)) {
return $matches[1];
}
Expand Down
7 changes: 4 additions & 3 deletions app/Commands/CertifyCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@

use App\Branding;
use App\Checks\CheckInterface;
use App\Checks\CheckResult;
use App\Checks\PestSyntaxValidator;
use App\Checks\SecurityScanner;
use App\Checks\TestRunner;
Expand All @@ -24,6 +23,7 @@ final class CertifyCommand extends Command
{
protected $signature = 'certify
{--coverage=80 : Minimum coverage threshold percentage}
{--test-timeout=300 : Test execution timeout in seconds (default: 300)}
{--token= : GitHub token for Checks API}
{--stop-on-failure : Stop at first failing check}
{--compact : Show single-line output instead of verbose}';
Expand Down Expand Up @@ -53,6 +53,7 @@ public function withMocks(?array $checks = null, ?ChecksClient $checksClient = n
public function handle(): int
{
$coverageThreshold = (int) $this->option('coverage');
$testTimeout = (int) $this->option('test-timeout');
$optionToken = $this->option('token');
$envToken = getenv('GITHUB_TOKEN');
$token = $optionToken ?: $envToken ?: null;
Expand All @@ -62,7 +63,7 @@ public function handle(): int
$workingDirectory = getcwd();

$checks = $this->checks ?? [
new TestRunner($coverageThreshold),
new TestRunner($coverageThreshold, $testTimeout),
new SecurityScanner,
new PestSyntaxValidator,
];
Expand Down Expand Up @@ -123,7 +124,7 @@ public function handle(): int
$checksClient->postCertificationComment($checkResults);
} else {
// Post actionable prompt with fix directions on failure
$assembler = new PromptAssembler();
$assembler = new PromptAssembler;
$assembled = $assembler->assemble($rawOutputs);

if ($assembled['prompt'] !== '') {
Expand Down
44 changes: 41 additions & 3 deletions app/Commands/CheckCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

namespace App\Commands;

use Closure;
use LaravelZero\Framework\Commands\Command;
use Symfony\Component\Process\Process;

Expand All @@ -25,11 +26,48 @@ class CheckCommand extends Command
'verdict' => 'pending',
];

/** @var array<string, mixed>|null */
private ?array $mockResults = null;

private ?Closure $processFactory = null;

/** @internal For testing only */
public function withMockResults(array $results): self
{
$this->mockResults = $results;

return $this;
}

/** @internal For testing only - factory receives command array, returns Process */
public function withProcessFactory(Closure $factory): self
{
$this->processFactory = $factory;

return $this;
}

protected function createProcess(array $command): Process
{
if ($this->processFactory) {
return ($this->processFactory)($command);
}

return new Process($command);
}

public function handle(): int
{
$this->info('🔒 Synapse Sentinel Gate');
$this->newLine();

// Use mock results for testing if provided
if ($this->mockResults !== null) {
$this->results = array_merge($this->results, $this->mockResults);

return $this->outputResults();
}

if (! $this->option('no-tests')) {
$this->runTests();
}
Expand All @@ -48,7 +86,7 @@ public function handle(): int
protected function runTests(): void
{
$this->task('Running tests with coverage', function () {
$process = new Process([
$process = $this->createProcess([
'vendor/bin/pest',
'--coverage',
'--coverage-clover=coverage.xml',
Expand Down Expand Up @@ -78,7 +116,7 @@ protected function runTests(): void
protected function runPhpstan(): void
{
$this->task('Running PHPStan analysis', function () {
$process = new Process([
$process = $this->createProcess([
'vendor/bin/phpstan',
'analyse',
'--error-format=json',
Expand All @@ -102,7 +140,7 @@ protected function runPhpstan(): void
protected function runStyle(): void
{
$this->task('Checking code style', function () {
$process = new Process([
$process = $this->createProcess([
'vendor/bin/pint',
'--test',
]);
Expand Down
8 changes: 1 addition & 7 deletions app/Transformers/PhpStanPromptTransformer.php
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ private function buildPrompt(array $data): array
$relativePath = $this->relativePath($filePath);
$errorCount = $fileData['errors'] ?? 0;

$prompt .= "### {$relativePath} ({$errorCount} error" . ($errorCount === 1 ? '' : 's') . ")\n\n";
$prompt .= "### {$relativePath} ({$errorCount} error".($errorCount === 1 ? '' : 's').")\n\n";

foreach ($fileData['messages'] ?? [] as $index => $message) {
$prompt .= $this->formatError($index + 1, $message);
Expand Down Expand Up @@ -166,12 +166,6 @@ private function getFixDirection(string $identifier, string $message): string
return self::FIX_DIRECTIONS[$identifier];
}

// Prefix match (e.g., "argument.type.strict" → "argument.type")
$prefix = explode('.', $identifier)[0] ?? '';
if (isset(self::FIX_DIRECTIONS[$prefix])) {
return self::FIX_DIRECTIONS[$prefix];
}

// Infer from message
return $this->inferFromMessage($message);
}
Expand Down
Loading