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
5 changes: 5 additions & 0 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,10 @@ inputs:
description: 'Directory to scan for coverage (auto-detects app/ or src/ if not specified)'
required: false
default: ''
phpstan-level:
description: 'PHPStan analysis level (0-9). Set to -1 to skip.'
required: false
default: '5'
compact:
description: 'Use compact single-line output instead of verbose'
required: false
Expand Down Expand Up @@ -122,6 +126,7 @@ runs:
certify|*)
php ${{ github.action_path }}/gate certify \
--coverage=${{ inputs.coverage-threshold }} \
--phpstan-level=${{ inputs.phpstan-level }} \
--token=${{ inputs.github-token }} \
${{ inputs.compact == 'true' && '--compact' || '' }}
;;
Expand Down
72 changes: 72 additions & 0 deletions app/Checks/PhpStanAnalyzer.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
<?php

declare(strict_types=1);

namespace App\Checks;

use App\Contracts\ProcessRunner;
use App\Services\SymfonyProcessRunner;

final class PhpStanAnalyzer implements CheckInterface
{
public function __construct(
private readonly int $level = 5,
private readonly ProcessRunner $processRunner = new SymfonyProcessRunner,
) {}

public function name(): string
{
return 'PHPStan';
}

public function run(string $workingDirectory): CheckResult
{
$binary = $workingDirectory.'/vendor/bin/phpstan';

if (! file_exists($binary)) {
return CheckResult::pass('PHPStan not installed — skipped');
}

$result = $this->processRunner->run(
[$binary, 'analyse', '--no-progress', '--error-format=json', "--level={$this->level}", '--memory-limit=512M'],
$workingDirectory,
timeout: 300,
);
Comment on lines +22 to +34
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

phpstan-level=-1 skip behavior is not implemented.

The analyzer always runs and forwards the level, so a configured skip value still invokes PHPStan. Add an explicit skip branch and validate allowed range before execution.

Suggested fix
     public function run(string $workingDirectory): CheckResult
     {
+        if ($this->level === -1) {
+            return CheckResult::pass('PHPStan skipped (level -1)');
+        }
+
+        if ($this->level < 0 || $this->level > 9) {
+            return CheckResult::fail(
+                "Invalid PHPStan level: {$this->level}. Expected -1 or 0-9."
+            );
+        }
+
         $binary = $workingDirectory.'/vendor/bin/phpstan';
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
public function run(string $workingDirectory): CheckResult
{
$binary = $workingDirectory.'/vendor/bin/phpstan';
if (! file_exists($binary)) {
return CheckResult::pass('PHPStan not installed — skipped');
}
$result = $this->processRunner->run(
[$binary, 'analyse', '--no-progress', '--error-format=json', "--level={$this->level}", '--memory-limit=512M'],
$workingDirectory,
timeout: 300,
);
public function run(string $workingDirectory): CheckResult
{
if ($this->level === -1) {
return CheckResult::pass('PHPStan skipped (level -1)');
}
if ($this->level < 0 || $this->level > 9) {
return CheckResult::fail(
"Invalid PHPStan level: {$this->level}. Expected -1 or 0-9."
);
}
$binary = $workingDirectory.'/vendor/bin/phpstan';
if (! file_exists($binary)) {
return CheckResult::pass('PHPStan not installed — skipped');
}
$result = $this->processRunner->run(
[$binary, 'analyse', '--no-progress', '--error-format=json', "--level={$this->level}", '--memory-limit=512M'],
$workingDirectory,
timeout: 300,
);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/Checks/PhpStanAnalyzer.php` around lines 22 - 34, The run method in
PhpStanAnalyzer currently always executes PHPStan even when configured with
phpstan-level=-1 and doesn't validate the level; update PhpStanAnalyzer::run to
first check if $this->level === -1 and immediately return
CheckResult::pass('PHPStan skipped via configuration') to implement the skip
behavior, then validate that $this->level is an integer within the allowed range
(e.g., minimum and maximum allowed PHPStan levels configured for your project)
and return a CheckResult::fail with a clear message if it's out of range before
constructing and running the process; only then build the $binary command and
call $this->processRunner->run as before.


$data = json_decode($result->output, true);

if ($data === null) {
// Fall back to exit code if JSON parsing fails
if ($result->exitCode === 0) {
return CheckResult::pass('PHPStan passed (level '.$this->level.')');
}

return CheckResult::fail(
'PHPStan failed',
[substr($result->output, 0, 500)],
$result->output,
);
}

$errorCount = $data['totals']['errors'] ?? 0;
$fileErrors = $data['totals']['file_errors'] ?? 0;
$totalErrors = $errorCount + $fileErrors;

if ($totalErrors === 0) {
return CheckResult::pass('PHPStan passed (level '.$this->level.', 0 errors)');
}

$details = [];
foreach ($data['files'] ?? [] as $file => $fileData) {
foreach ($fileData['messages'] ?? [] as $msg) {
$details[] = basename($file).':'.$msg['line'].' — '.$msg['message'];
}
Comment on lines +60 to +63
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Guard message fields before building detail lines.

Line 62 assumes line and message always exist. Use fallbacks to avoid notices on unexpected payloads.

Suggested fix
         foreach ($data['files'] ?? [] as $file => $fileData) {
             foreach ($fileData['messages'] ?? [] as $msg) {
-                $details[] = basename($file).':'.$msg['line'].' — '.$msg['message'];
+                $line = $msg['line'] ?? '?';
+                $message = $msg['message'] ?? 'Unknown PHPStan error';
+                $details[] = basename($file).':'.$line.' — '.$message;
             }
         }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
foreach ($data['files'] ?? [] as $file => $fileData) {
foreach ($fileData['messages'] ?? [] as $msg) {
$details[] = basename($file).':'.$msg['line'].''.$msg['message'];
}
foreach ($data['files'] ?? [] as $file => $fileData) {
foreach ($fileData['messages'] ?? [] as $msg) {
$line = $msg['line'] ?? '?';
$message = $msg['message'] ?? 'Unknown PHPStan error';
$details[] = basename($file).':'.$line.''.$message;
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/Checks/PhpStanAnalyzer.php` around lines 60 - 63, In PhpStanAnalyzer
(inside the loop that builds $details from $data['files']), guard the message
fields before concatenation: check $msg['line'] and $msg['message'] and use safe
fallbacks (e.g., $msg['line'] ?? 0 and $msg['message'] ?? '') or skip entries
that lack both, then build the detail string (currently done with
basename($file).':'.$msg['line'].' — '.$msg['message']) using those guarded
values so notices are avoided; update the foreach that populates $details
accordingly.

}

return CheckResult::fail(
"PHPStan found {$totalErrors} error(s) at level {$this->level}",
array_slice($details, 0, 20),
$result->output,
);
}
}
70 changes: 70 additions & 0 deletions app/Checks/PintFormatter.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
<?php

declare(strict_types=1);

namespace App\Checks;

use App\Contracts\ProcessRunner;
use App\Services\SymfonyProcessRunner;

final class PintFormatter implements CheckInterface
{
public function __construct(
private readonly ProcessRunner $processRunner = new SymfonyProcessRunner,
) {}

public function name(): string
{
return 'Pint Style';
}

public function run(string $workingDirectory): CheckResult
{
$binary = $workingDirectory.'/vendor/bin/pint';

if (! file_exists($binary)) {
return CheckResult::pass('Pint not installed — skipped');
}

$result = $this->processRunner->run(
[$binary, '--test', '--format=json'],
$workingDirectory,
timeout: 120,
);

$data = json_decode($result->output, true);

if ($data === null) {
if ($result->exitCode === 0) {
return CheckResult::pass('Code style is clean');
}

return CheckResult::fail(
'Pint check failed',
[substr($result->output, 0, 500)],
$result->output,
);
}

if (($data['result'] ?? '') === 'pass') {
return CheckResult::pass('Code style is clean');
}

$files = $data['files'] ?? [];
$details = [];

foreach (array_slice($files, 0, 20) as $f) {
if (is_array($f) && isset($f['path'])) {
$details[] = $f['path'].' ('.implode(', ', $f['fixers'] ?? []).')';
} elseif (is_string($f)) {
$details[] = $f;
}
}

return CheckResult::fail(
count($files).' file(s) need formatting',
$details,
$result->output,
);
}
}
74 changes: 74 additions & 0 deletions app/Checks/PublishGuard.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
<?php

declare(strict_types=1);

namespace App\Checks;

use App\Contracts\ProcessRunner;
use App\Services\SymfonyProcessRunner;

final class PublishGuard implements CheckInterface
{
public function __construct(
private readonly ProcessRunner $processRunner = new SymfonyProcessRunner,
) {}

public function name(): string
{
return 'Publish Guard';
}

public function run(string $workingDirectory): CheckResult
{
$violations = [];

// Check for .env files (excluding .env.testing and .env.example)
$envResult = $this->processRunner->run(
['git', 'ls-files', '*.env', '.env*'],
$workingDirectory,
timeout: 10,
);

foreach (array_filter(explode("\n", trim($envResult->output))) as $file) {
if (! str_ends_with($file, '.env.testing') && ! str_ends_with($file, '.env.example')) {
$violations[] = "Tracked .env file: {$file}";
}
}

// Check for source map files
$mapResult = $this->processRunner->run(
['git', 'ls-files', '*.map'],
$workingDirectory,
timeout: 10,
);

foreach (array_filter(explode("\n", trim($mapResult->output))) as $file) {
$violations[] = "Tracked .map file: {$file}";
}

// Check for large files (> 1MB)
$lsResult = $this->processRunner->run(
['git', 'ls-files'],
$workingDirectory,
timeout: 10,
);
Comment on lines +26 to +54
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Fail closed when git ls-files fails.

Lines 26–54 parse command output without checking command success. A failed git call can bypass the guard and incorrectly report pass.

Suggested fix
         $envResult = $this->processRunner->run(
             ['git', 'ls-files', '*.env', '.env*'],
             $workingDirectory,
             timeout: 10,
         );
+        if (! $envResult->successful) {
+            return CheckResult::fail(
+                'Publish Guard failed to inspect tracked .env files',
+                [trim($envResult->output)],
+                $envResult->output,
+            );
+        }
@@
         $mapResult = $this->processRunner->run(
             ['git', 'ls-files', '*.map'],
             $workingDirectory,
             timeout: 10,
         );
+        if (! $mapResult->successful) {
+            return CheckResult::fail(
+                'Publish Guard failed to inspect tracked .map files',
+                [trim($mapResult->output)],
+                $mapResult->output,
+            );
+        }
@@
         $lsResult = $this->processRunner->run(
             ['git', 'ls-files'],
             $workingDirectory,
             timeout: 10,
         );
+        if (! $lsResult->successful) {
+            return CheckResult::fail(
+                'Publish Guard failed to inspect tracked files',
+                [trim($lsResult->output)],
+                $lsResult->output,
+            );
+        }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/Checks/PublishGuard.php` around lines 26 - 54, The checks call
$this->processRunner->run (producing $envResult, $mapResult, $lsResult) and then
parse ->output without verifying the command succeeded; if git fails the guard
can incorrectly pass. After each run() call (for $envResult, $mapResult and
$lsResult) check the returned result for failure (e.g. non-zero exit code or an
isSuccessful()/successful flag) and if it failed, treat it as a violation (or
throw) to "fail closed" — add a violation entry like "git ls-files failed:
{stderr or exit code}" or throw a PublishGuardException so the publish is
blocked; ensure you reference $envResult->errorOutput / $mapResult->errorOutput
/ $lsResult->errorOutput (or ->exitCode) when constructing the message.


foreach (array_filter(explode("\n", trim($lsResult->output))) as $file) {
$path = $workingDirectory.'/'.$file;
if (file_exists($path) && filesize($path) > 1_048_576) {
$size = round(filesize($path) / 1_048_576, 1);
$violations[] = "Large file ({$size}MB): {$file}";
}
}

Comment on lines +21 to +63
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Secrets check is missing from Publish Guard.

This implementation enforces .env, .map, and size checks, but there is no explicit secret-artifact detection (e.g., key/cert files), which is part of the stated quality gate scope.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/Checks/PublishGuard.php` around lines 21 - 63, The PublishGuard::run
currently checks for .env, .map and large files but lacks secret/artifact
detection; update run to also scan tracked files for common secret
filenames/extensions (e.g., files ending with .key, .pem, .crt, .p12, .jks, .pfx
or names like id_rsa, private_key, secret, token) and to search file contents
for high-confidence secret patterns (e.g., base64 API keys, AWS keys, private
key headers like "-----BEGIN PRIVATE KEY-----", or strings like "API_KEY",
"SECRET=", "PRIVATE_KEY"). Use the existing git ls-files result
($lsResult->output) to iterate tracked files, skip binaries by size/type if
needed, read files safely (check file_exists and filesize) and add violations to
$violations with clear messages (e.g., "Tracked secret file: {file}" or "Secret
pattern in: {file}"). Ensure this logic is placed alongside the large-file loop
in PublishGuard::run so secrets are reported together.

if ($violations === []) {
return CheckResult::pass('No disallowed artifacts found');
}

return CheckResult::fail(
count($violations).' publish violation(s) found',
$violations,
implode("\n", $violations),
);
}
}
63 changes: 63 additions & 0 deletions app/Checks/RectorAnalyzer.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
<?php

declare(strict_types=1);

namespace App\Checks;

use App\Contracts\ProcessRunner;
use App\Services\SymfonyProcessRunner;

final class RectorAnalyzer implements CheckInterface
{
public function __construct(
private readonly ProcessRunner $processRunner = new SymfonyProcessRunner,
) {}

public function name(): string
{
return 'Rector';
}

public function run(string $workingDirectory): CheckResult
{
$binary = $workingDirectory.'/vendor/bin/rector';

if (! file_exists($binary)) {
return CheckResult::pass('Rector not installed — skipped');
}

if (! file_exists($workingDirectory.'/rector.php')) {
return CheckResult::pass('No rector.php config — skipped');
}

$result = $this->processRunner->run(
[$binary, 'process', '--dry-run', '--no-progress-bar'],
$workingDirectory,
timeout: 300,
);

if ($result->exitCode === 0) {
return CheckResult::pass('Rector found no issues');
}

// Count suggested changes from output
$lines = explode("\n", trim($result->output));
$changeCount = 0;
$details = [];

foreach ($lines as $line) {
if (str_contains($line, 'diff')) {
$changeCount++;
}
if (str_starts_with(trim($line), '---') || str_starts_with(trim($line), '+++')) {
$details[] = trim($line);
}
}

return CheckResult::fail(
'Rector found '.max($changeCount, 1).' file(s) needing refactoring',
array_slice($details, 0, 20),
Comment on lines +57 to +59
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Failure count/message can be incorrect when no diffs are detected.

Lines 57–59 force at least 1 changed file even when parsing finds zero diff markers, which can misreport tooling/config failures as refactoring suggestions.

Suggested fix
-        return CheckResult::fail(
-            'Rector found '.max($changeCount, 1).' file(s) needing refactoring',
-            array_slice($details, 0, 20),
-            $result->output,
-        );
+        $message = $changeCount > 0
+            ? "Rector found {$changeCount} file(s) needing refactoring"
+            : 'Rector failed to run cleanly; see raw output';
+
+        return CheckResult::fail(
+            $message,
+            array_slice($details, 0, 20),
+            $result->output,
+        );
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
return CheckResult::fail(
'Rector found '.max($changeCount, 1).' file(s) needing refactoring',
array_slice($details, 0, 20),
$message = $changeCount > 0
? "Rector found {$changeCount} file(s) needing refactoring"
: 'Rector failed to run cleanly; see raw output';
return CheckResult::fail(
$message,
array_slice($details, 0, 20),
$result->output,
);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/Checks/RectorAnalyzer.php` around lines 57 - 59, The failure message in
RectorAnalyzer uses max($changeCount, 1) which forces a minimum of 1 even when
no diffs were found; change the logic in the return call that invokes
CheckResult::fail to use the actual $changeCount (not max(...,1)) and
interpolate that value into the message and pluralization, and ensure the
array_slice($details, 0, 20) remains unchanged; this will ensure the reported
file count reflects the real number of diffs found.

$result->output,
);
}
}
18 changes: 10 additions & 8 deletions app/Checks/TestRunner.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,18 @@
namespace App\Checks;

use App\Contracts\ProcessRunner;
use App\GitHub\CommentsClient;
use App\Services\CoverageReporter;
use App\Services\PestOutputParser;
use App\Services\SymfonyProcessRunner;

final class TestRunner implements CheckInterface
{
/** @var \App\GitHub\CommentsClient|null For testing */
private ?\App\GitHub\CommentsClient $commentsClient = null;
/** @var CommentsClient|null For testing */
private ?CommentsClient $commentsClient = null;

/** @var \App\Services\CoverageReporter|null For testing */
private ?\App\Services\CoverageReporter $coverageReporter = null;
/** @var CoverageReporter|null For testing */
private ?CoverageReporter $coverageReporter = null;

public function __construct(
private readonly int $coverageThreshold = 100,
Expand All @@ -24,8 +26,8 @@ public function __construct(

/** @internal For testing only */
public function withCommentDependencies(
\App\GitHub\CommentsClient $commentsClient,
\App\Services\CoverageReporter $coverageReporter,
CommentsClient $commentsClient,
CoverageReporter $coverageReporter,
): self {
$this->commentsClient = $commentsClient;
$this->coverageReporter = $coverageReporter;
Expand Down Expand Up @@ -75,8 +77,8 @@ private function postCoverageComment(string $cloverPath): void
return;
}

$commentsClient = $this->commentsClient ?? new \App\GitHub\CommentsClient($token);
$reporter = $this->coverageReporter ?? new \App\Services\CoverageReporter($this->coverageThreshold);
$commentsClient = $this->commentsClient ?? new CommentsClient($token);
$reporter = $this->coverageReporter ?? new CoverageReporter($this->coverageThreshold);

if ($commentsClient->isAvailable()) {
try {
Expand Down
Loading
Loading