diff --git a/README.md b/README.md
index 118a8ba..8b7f5e3 100644
--- a/README.md
+++ b/README.md
@@ -451,6 +451,15 @@ Run the test suite:
./shift test
```
+Run local quality checks:
+
+```sh
+./shift lint
+./shift qa
+```
+
+`shift lint` checks PHP syntax and basic file hygiene. `shift qa` runs Composer validation, lint checks, the test suite, and route listing.
+
Run the example module command:
```sh
diff --git a/REFACTORING.md b/REFACTORING.md
index 9a11a06..330dbda 100644
--- a/REFACTORING.md
+++ b/REFACTORING.md
@@ -40,6 +40,7 @@ ShiftPHP is moving toward an API-only modular monolith. View templates, compiled
- [x] Structured exception logging with JSON file logger and service container override.
- [x] Request id lifecycle with generated `X-Request-Id` response headers and log context.
- [x] Developer documentation organized into a Laravel-like guide.
+- [x] CLI quality gate with `shift lint` and `shift qa`.
- [x] Removal of view storage and example page assets from runtime.
- [x] Removal of legacy `application/controllers` and `application/routes.php`.
- [x] Domain-oriented framework namespaces:
@@ -165,4 +166,4 @@ Internal errors return a generic `500` message unless `display_errors` is enable
## Next
-- [ ] Static analysis and coding style checks.
+- [ ] Deeper static analysis for framework contracts and type safety.
diff --git a/docs/index.html b/docs/index.html
index 179d170..77435ce 100644
--- a/docs/index.html
+++ b/docs/index.html
@@ -297,6 +297,7 @@
Operations
./shift doctor
+ ./shift doctor
+./shift qa
@@ -806,13 +808,26 @@ Cache
Rebuild the module cache after changing module boundaries, module config, or module command mappings.
+
+ Quality Checks
+ The CLI includes zero-dependency quality gates for local development and pull request preparation.
+
+ ./shift lint
+./shift qa
+
+ shift lint checks PHP syntax and basic file hygiene, including trailing whitespace and missing final newlines. shift qa runs Composer validation, lint checks, the test suite, and the route list command.
+
+ ./shift help lint
+./shift help qa
+
+
Doctor
The doctor command runs local diagnostics and returns a non-zero exit code when a required check fails.
./shift doctor
- It checks PHP version, required extensions, Composer JSON validity, PHP lint, the test suite, environment presence, database config, and module cache status.
+ It checks PHP version, required extensions, Composer JSON validity, PHP syntax, the test suite, environment presence, database config, and module cache status.
@@ -822,7 +837,7 @@ Testing
composer test
./shift test
- The GitHub workflow validates Composer config, dumps autoload files, lints PHP files, runs tests, and verifies the route list command.
+ The GitHub workflow validates Composer config, dumps autoload files, lints PHP files, runs tests, and verifies the route list command. Locally, ./shift qa runs the same kind of pre-PR quality gate.
diff --git a/src/Console/Commands/Doctor.php b/src/Console/Commands/Doctor.php
index 8a3d660..b01289a 100644
--- a/src/Console/Commands/Doctor.php
+++ b/src/Console/Commands/Doctor.php
@@ -4,6 +4,7 @@
use Shift\Console\Cli;
use Shift\Console\CommandInterface;
+use Shift\Console\Quality\QualityChecks;
use Shift\Database\DatabaseConfig;
use Shift\Modules\ModuleLoader;
use Throwable;
@@ -14,12 +15,13 @@ class Doctor implements CommandInterface
public function execute(mixed ...$args): void
{
$cli = new Cli();
+ $quality = new QualityChecks();
$checks = [
$this->phpVersion(),
$this->extensions(),
$this->composerConfig(),
- $this->phpLint(),
- $this->testSuite(),
+ $quality->phpSyntax()->toRow(),
+ $quality->testSuite()->toRow(),
$this->environment(),
$this->databaseConfig(),
$this->moduleCache(),
@@ -87,41 +89,6 @@ private function composerConfig(): array
];
}
- private function phpLint(): array
- {
- $files = array_merge(
- $this->phpFiles(APP_ROOT . '/src'),
- $this->phpFiles(APP_ROOT . '/application'),
- $this->phpFiles(APP_ROOT . '/tests'),
- [APP_ROOT . '/shift']
- );
-
- foreach ($files as $file) {
- if (!is_file($file)) {
- continue;
- }
-
- exec(escapeshellarg(PHP_BINARY) . ' -l ' . escapeshellarg($file) . ' 2>&1', $output, $exitCode);
-
- if ($exitCode !== 0) {
- return ['PHP lint', 'fail', basename($file) . ': ' . trim(implode(' ', $output))];
- }
- }
-
- return ['PHP lint', 'ok', count($files) . ' file(s) checked'];
- }
-
- private function testSuite(): array
- {
- exec('composer test 2>&1', $output, $exitCode);
-
- return [
- 'Test suite',
- $exitCode === 0 ? 'ok' : 'fail',
- $exitCode === 0 ? 'composer test passed' : trim(implode(' ', array_slice($output, -3))),
- ];
- }
-
private function environment(): array
{
return [
@@ -157,25 +124,4 @@ private function moduleCache(): array
];
}
- private function phpFiles(string $path): array
- {
- if (!is_dir($path)) {
- return [];
- }
-
- $files = [];
- $iterator = new \RecursiveIteratorIterator(
- new \RecursiveDirectoryIterator($path, \FilesystemIterator::SKIP_DOTS)
- );
-
- foreach ($iterator as $file) {
- if ($file->isFile() && $file->getExtension() === 'php') {
- $files[] = $file->getPathname();
- }
- }
-
- sort($files);
-
- return $files;
- }
}
diff --git a/src/Console/Commands/Lint.php b/src/Console/Commands/Lint.php
new file mode 100644
index 0000000..c978aa8
--- /dev/null
+++ b/src/Console/Commands/Lint.php
@@ -0,0 +1,49 @@
+render((new QualityChecks())->lint(), 'Lint checks passed.', 'Lint checks failed.');
+ }
+
+ public function getHelp(): string
+ {
+ return 'Usage: ./shift lint';
+ }
+
+ public function getDescription(): string
+ {
+ return 'Run PHP syntax and file hygiene checks.';
+ }
+
+ /**
+ * @param list $results
+ */
+ private function render(array $results, string $successMessage, string $failureMessage): void
+ {
+ $cli = new Cli();
+ $cli->table(['Check', 'Status', 'Details'], array_map(
+ static fn (CheckResult $result): array => $result->toRow(),
+ $results
+ ));
+
+ $failed = array_values(array_filter($results, static fn (CheckResult $result): bool => !$result->passed()));
+
+ if ($failed === []) {
+ $cli->success($successMessage);
+ return;
+ }
+
+ $cli->error($failureMessage);
+ exit(1);
+ }
+}
diff --git a/src/Console/Commands/Qa.php b/src/Console/Commands/Qa.php
new file mode 100644
index 0000000..a48242a
--- /dev/null
+++ b/src/Console/Commands/Qa.php
@@ -0,0 +1,49 @@
+render((new QualityChecks())->qa(), 'Quality checks passed.', 'Quality checks failed.');
+ }
+
+ public function getHelp(): string
+ {
+ return 'Usage: ./shift qa';
+ }
+
+ public function getDescription(): string
+ {
+ return 'Run Composer validation, lint, tests, and route checks.';
+ }
+
+ /**
+ * @param list $results
+ */
+ private function render(array $results, string $successMessage, string $failureMessage): void
+ {
+ $cli = new Cli();
+ $cli->table(['Check', 'Status', 'Details'], array_map(
+ static fn (CheckResult $result): array => $result->toRow(),
+ $results
+ ));
+
+ $failed = array_values(array_filter($results, static fn (CheckResult $result): bool => !$result->passed()));
+
+ if ($failed === []) {
+ $cli->success($successMessage);
+ return;
+ }
+
+ $cli->error($failureMessage);
+ exit(1);
+ }
+}
diff --git a/src/Console/Console.php b/src/Console/Console.php
index 5116bd1..7f4a742 100644
--- a/src/Console/Console.php
+++ b/src/Console/Console.php
@@ -16,7 +16,7 @@ class Console
{
/**
* Instancja klasy Cli do operacji terminalowych
- *
+ *
* @var Cli
*/
private Cli $cli;
@@ -31,7 +31,7 @@ public function __construct()
/**
* Zwraca instancjÄ™ klasy Cli
- *
+ *
* @return Cli
*/
public function cli(): Cli
diff --git a/src/Console/Quality/CheckResult.php b/src/Console/Quality/CheckResult.php
new file mode 100644
index 0000000..fd286cf
--- /dev/null
+++ b/src/Console/Quality/CheckResult.php
@@ -0,0 +1,36 @@
+name, $this->status, $this->details];
+ }
+
+ public function passed(): bool
+ {
+ return $this->status !== 'fail';
+ }
+}
diff --git a/src/Console/Quality/ProjectFileFinder.php b/src/Console/Quality/ProjectFileFinder.php
new file mode 100644
index 0000000..c0af107
--- /dev/null
+++ b/src/Console/Quality/ProjectFileFinder.php
@@ -0,0 +1,124 @@
+|null $paths
+ * @param list $hygieneExtensions
+ */
+ public function __construct(
+ private readonly ?array $paths = null,
+ private readonly array $hygieneExtensions = ['php', 'md', 'json', 'html', 'yml', 'yaml']
+ ) {
+ }
+
+ /**
+ * @return list
+ */
+ public function phpFiles(): array
+ {
+ $files = array_values(array_filter(
+ $this->allFiles(),
+ static fn (string $file): bool => pathinfo($file, PATHINFO_EXTENSION) === 'php' || basename($file) === 'shift'
+ ));
+
+ sort($files);
+
+ return $files;
+ }
+
+ /**
+ * @return list
+ */
+ public function hygieneFiles(): array
+ {
+ $files = array_values(array_filter($this->allFiles(), function (string $file): bool {
+ if (basename($file) === 'shift') {
+ return true;
+ }
+
+ return in_array(pathinfo($file, PATHINFO_EXTENSION), $this->hygieneExtensions, true);
+ }));
+
+ sort($files);
+
+ return $files;
+ }
+
+ /**
+ * @return list
+ */
+ private function allFiles(): array
+ {
+ $files = [];
+
+ foreach ($this->paths ?? $this->defaultPaths() as $path) {
+ if (is_file($path)) {
+ $files[] = $path;
+ continue;
+ }
+
+ if (!is_dir($path)) {
+ continue;
+ }
+
+ $iterator = new RecursiveIteratorIterator(
+ new RecursiveDirectoryIterator($path, FilesystemIterator::SKIP_DOTS)
+ );
+
+ foreach ($iterator as $file) {
+ if (!$file->isFile()) {
+ continue;
+ }
+
+ $pathName = $file->getPathname();
+
+ if ($this->isIgnored($pathName)) {
+ continue;
+ }
+
+ $files[] = $pathName;
+ }
+ }
+
+ return array_values(array_unique($files));
+ }
+
+ /**
+ * @return list
+ */
+ private function defaultPaths(): array
+ {
+ return [
+ APP_ROOT . '/src',
+ APP_ROOT . '/application',
+ APP_ROOT . '/database/migrations',
+ APP_ROOT . '/tests',
+ APP_ROOT . '/docs',
+ APP_ROOT . '/.github',
+ APP_ROOT . '/README.md',
+ APP_ROOT . '/REFACTORING.md',
+ APP_ROOT . '/composer.json',
+ APP_ROOT . '/bootstrap.php',
+ APP_ROOT . '/index.php',
+ APP_ROOT . '/shift',
+ ];
+ }
+
+ private function isIgnored(string $path): bool
+ {
+ foreach (['/.git/', '/vendor/', '/.idea/', '/storage/cache/', '/storage/logs/'] as $ignored) {
+ if (str_contains($path, $ignored)) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+}
diff --git a/src/Console/Quality/QualityChecks.php b/src/Console/Quality/QualityChecks.php
new file mode 100644
index 0000000..bfc11d8
--- /dev/null
+++ b/src/Console/Quality/QualityChecks.php
@@ -0,0 +1,141 @@
+
+ */
+ public function lint(): array
+ {
+ return [
+ $this->phpSyntax(),
+ $this->fileHygiene(),
+ ];
+ }
+
+ /**
+ * @return list
+ */
+ public function qa(): array
+ {
+ return [
+ $this->composerValidate(),
+ $this->phpSyntax(),
+ $this->fileHygiene(),
+ $this->testSuite(),
+ $this->routeList(),
+ ];
+ }
+
+ public function composerValidate(): CheckResult
+ {
+ return $this->runShellCheck('Composer config', 'composer validate --no-check-publish 2>&1', 'composer.json valid');
+ }
+
+ public function phpSyntax(): CheckResult
+ {
+ $files = $this->fileFinder()->phpFiles();
+
+ foreach ($files as $file) {
+ $output = [];
+ exec(escapeshellarg(PHP_BINARY) . ' -l ' . escapeshellarg($file) . ' 2>&1', $output, $exitCode);
+
+ if ($exitCode !== 0) {
+ return CheckResult::fail('PHP syntax', $this->relativePath($file) . ': ' . trim(implode(' ', $output)));
+ }
+ }
+
+ return CheckResult::ok('PHP syntax', count($files) . ' file(s) checked');
+ }
+
+ public function fileHygiene(): CheckResult
+ {
+ $files = $this->fileFinder()->hygieneFiles();
+
+ foreach ($files as $file) {
+ $contents = file_get_contents($file);
+
+ if ($contents === false || $contents === '') {
+ continue;
+ }
+
+ if (!str_ends_with($contents, "\n")) {
+ return CheckResult::fail('File hygiene', $this->relativePath($file) . ': missing final newline');
+ }
+
+ foreach (preg_split('/\r?\n/', $contents) ?: [] as $line => $text) {
+ if (preg_match('/[ \t]+$/', $text)) {
+ return CheckResult::fail('File hygiene', $this->relativePath($file) . ':' . ($line + 1) . ': trailing whitespace');
+ }
+ }
+ }
+
+ return CheckResult::ok('File hygiene', count($files) . ' file(s) checked');
+ }
+
+ public function testSuite(): CheckResult
+ {
+ return $this->runShellCheck('Test suite', 'composer test 2>&1', 'composer test passed');
+ }
+
+ public function routeList(): CheckResult
+ {
+ $command = escapeshellarg(PHP_BINARY) . ' ' . escapeshellarg($this->projectRoot() . '/shift') . ' route:list 2>&1';
+
+ return $this->runShellCheck('Route list', $command, './shift route:list passed');
+ }
+
+ private function runShellCheck(string $name, string $command, string $successDetails): CheckResult
+ {
+ $previousDirectory = getcwd();
+
+ if ($previousDirectory !== false) {
+ chdir($this->projectRoot());
+ }
+
+ try {
+ exec($command, $output, $exitCode);
+ } finally {
+ if ($previousDirectory !== false) {
+ chdir($previousDirectory);
+ }
+ }
+
+ if ($exitCode === 0) {
+ return CheckResult::ok($name, $successDetails);
+ }
+
+ $details = trim(implode(' ', array_slice($output, -3)));
+
+ return CheckResult::fail($name, $details !== '' ? $details : 'Command failed with exit code ' . $exitCode);
+ }
+
+ private function fileFinder(): ProjectFileFinder
+ {
+ return $this->files ?? new ProjectFileFinder();
+ }
+
+ private function projectRoot(): string
+ {
+ return $this->root ?? APP_ROOT;
+ }
+
+ private function relativePath(string $path): string
+ {
+ $root = rtrim($this->projectRoot(), '/') . '/';
+
+ if (str_starts_with($path, $root)) {
+ return substr($path, strlen($root));
+ }
+
+ return $path;
+ }
+}
diff --git a/src/Error/ErrorHandler.php b/src/Error/ErrorHandler.php
index 96752d4..0738685 100644
--- a/src/Error/ErrorHandler.php
+++ b/src/Error/ErrorHandler.php
@@ -39,7 +39,7 @@ public static function handleError(int $level, string $message, string $file = '
$exception = new ShiftError($message, $level);
$exception->setFile($file);
$exception->setLine($line);
-
+
self::handleException($exception);
return true;
}
@@ -69,12 +69,12 @@ public static function handleException(Throwable $exception): void
public static function handleShutdown(): void
{
$error = error_get_last();
-
+
if ($error !== null && in_array($error['type'], [E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR])) {
$exception = new ShiftError($error['message'], $error['type']);
$exception->setFile($error['file']);
$exception->setLine($error['line']);
-
+
self::handleException($exception);
}
}
@@ -122,4 +122,4 @@ private static function shouldDisplayDetails(): bool
{
return in_array(strtolower((string) ini_get('display_errors')), ['1', 'on', 'true'], true);
}
-}
+}
diff --git a/src/Service/ServiceContainer.php b/src/Service/ServiceContainer.php
index 2cbc6ac..b8465f3 100644
--- a/src/Service/ServiceContainer.php
+++ b/src/Service/ServiceContainer.php
@@ -169,4 +169,4 @@ public function getSingletonServices(): array
{
return array_keys($this->singletons);
}
-}
+}
diff --git a/src/Service/ServiceInterface.php b/src/Service/ServiceInterface.php
index ad94d5f..2593cd2 100644
--- a/src/Service/ServiceInterface.php
+++ b/src/Service/ServiceInterface.php
@@ -22,4 +22,4 @@ public function isReady(): bool;
* Get service name
*/
public function getName(): string;
-}
+}
diff --git a/tests/Feature/QualityCommandsTest.php b/tests/Feature/QualityCommandsTest.php
new file mode 100644
index 0000000..7c410c4
--- /dev/null
+++ b/tests/Feature/QualityCommandsTest.php
@@ -0,0 +1,62 @@
+ function (): void {
+ $registry = CommandRegistry::default();
+ $commands = $registry->all();
+
+ assertSameValue(Lint::class, $commands['lint'] ?? null, 'Registry should expose lint command.');
+ assertSameValue(Qa::class, $commands['qa'] ?? null, 'Registry should expose qa command.');
+ assertSameValue(Lint::class, $registry->find('l'), 'Registry should resolve lint alias.');
+ assertSameValue(Qa::class, $registry->find('quality'), 'Registry should resolve qa alias.');
+ assertSameValue(Qa::class, $registry->find('ci'), 'Registry should resolve ci alias.');
+ },
+ 'quality checks pass for valid php file' => function (): void {
+ $root = sys_get_temp_dir() . '/shift-quality-' . bin2hex(random_bytes(6));
+ mkdir($root, 0775, true);
+
+ try {
+ $file = $root . '/Example.php';
+ file_put_contents($file, "phpSyntax();
+
+ assertSameValue('ok', $result->status, 'PHP syntax check should pass for valid PHP.');
+ } finally {
+ removeDirectory($root);
+ }
+ },
+ 'quality checks detect file hygiene issues' => function (): void {
+ $root = sys_get_temp_dir() . '/shift-quality-' . bin2hex(random_bytes(6));
+ mkdir($root, 0775, true);
+
+ try {
+ $file = $root . '/README.md';
+ file_put_contents($file, "Bad whitespace \n");
+
+ $checks = new QualityChecks(new ProjectFileFinder([$file]), $root);
+ $result = $checks->fileHygiene();
+
+ assertSameValue('fail', $result->status, 'File hygiene should fail on trailing whitespace.');
+ assertStringContains('trailing whitespace', $result->details, 'File hygiene should explain the issue.');
+ } finally {
+ removeDirectory($root);
+ }
+ },
+ 'lint command reports syntax and hygiene checks' => function (): void {
+ ob_start();
+ (new Lint())->execute();
+ $output = ob_get_clean();
+
+ assertStringContains('PHP syntax', $output, 'Lint output should include PHP syntax check.');
+ assertStringContains('File hygiene', $output, 'Lint output should include file hygiene check.');
+ assertStringContains('Lint checks passed.', $output, 'Lint output should include success message.');
+ },
+];