From a719b5b8ba61e9413d090a53f3e523a50314b148 Mon Sep 17 00:00:00 2001 From: Olivier Gorzalka Date: Wed, 4 Nov 2026 09:15:35 +0100 Subject: [PATCH 1/2] chore: adopt the Pollora harness (enforce mode, PHPStan level 8, coverage ratchet) --- .claude/settings.json | 27 +++++++++++++++++ .gitattributes | 19 ++++++++++++ .github/workflows/harness.yml | 19 ++++++++++++ .gitignore | 2 ++ .harness.json | 12 ++++++++ AGENTS.md | 57 +++++++++++++++++++++++++++++++++++ composer.json | 5 ++- phpstan.neon | 5 ++- pint.json | 5 ++- src/Application.php | 1 + tests/Arch.php | 15 +++++++++ 11 files changed, 164 insertions(+), 3 deletions(-) create mode 100644 .claude/settings.json create mode 100644 .gitattributes create mode 100644 .github/workflows/harness.yml create mode 100644 .harness.json create mode 100644 AGENTS.md create mode 100644 tests/Arch.php diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..c8079f4 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,27 @@ +{ + "hooks": { + "PreToolUse": [ + { + "matcher": "Edit|Write", + "hooks": [ + { "type": "command", "command": "\"$CLAUDE_PROJECT_DIR\"/vendor/bin/pollora-qa hook pre-edit" } + ] + } + ], + "PostToolUse": [ + { + "matcher": "Edit|Write", + "hooks": [ + { "type": "command", "command": "\"$CLAUDE_PROJECT_DIR\"/vendor/bin/pollora-qa hook post-edit" } + ] + } + ], + "Stop": [ + { + "hooks": [ + { "type": "command", "command": "\"$CLAUDE_PROJECT_DIR\"/vendor/bin/pollora-qa hook stop" } + ] + } + ] + } +} diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..da5539b --- /dev/null +++ b/.gitattributes @@ -0,0 +1,19 @@ +# Pollora harness (QA-06): development files stay out of the archive Composer downloads +/.gitattributes export-ignore +/.claude export-ignore +/.harness.json export-ignore +/AGENTS.md export-ignore +/api-surface.json export-ignore +/composer-dependency-analyser.php export-ignore +/rector.php export-ignore +/.claude export-ignore +/.gitattributes export-ignore +/.github export-ignore +/.gitignore export-ignore +/.harness.json export-ignore +/AGENTS.md export-ignore +/phpstan.neon export-ignore +/phpunit.xml export-ignore +/pint.json export-ignore +/rector.php export-ignore +/tests export-ignore diff --git a/.github/workflows/harness.yml b/.github/workflows/harness.yml new file mode 100644 index 0000000..980030d --- /dev/null +++ b/.github/workflows/harness.yml @@ -0,0 +1,19 @@ +name: Harness + +on: + push: + branches: [main] + pull_request: + # A label (api-change, breaking) changes what the public surface gate allows. + types: [opened, synchronize, reopened, labeled, unlabeled] + schedule: + - cron: '17 3 * * *' + workflow_dispatch: + +permissions: {} + +jobs: + harness: + uses: Pollora/harness/.github/workflows/php-package.yml@v0.1.17 + permissions: + contents: read diff --git a/.gitignore b/.gitignore index 3e58946..096f351 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,5 @@ composer.lock .idea/ .vscode/ .DS_Store +# Pollora harness reports +/.harness/ diff --git a/.harness.json b/.harness.json new file mode 100644 index 0000000..d9014b5 --- /dev/null +++ b/.harness.json @@ -0,0 +1,12 @@ +{ + "profile": "core", + "mode": "enforce", + "allow_laravel": [ + "laravel/prompts" + ], + "ratchet": { + "phpstan_level": 8, + "phpstan_baseline": 0, + "coverage": 35.98 + } +} diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..fc697b2 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,57 @@ +# Agent instructions + + +## Pollora harness + +This repository follows the Pollora harness (profile `core`, observe mode). The same gates run on your machine, in agent hooks and in CI; a pull request merges only when they pass. + +### Before you hand back + +- `composer qa:fast` runs the fast gates on what you changed (style, static analysis, architecture, dependencies). It must pass. +- `composer qa` runs every gate, as CI does. +- `vendor/bin/pollora-qa rules ` explains a rule quoted in a failure. + +### Never + +- Never edit `.harness.json`, `phpstan-baseline.neon`, `api-surface.json`: they hold the thresholds. If one is wrong, stop and explain why to the user. +- Never add `@phpstan-ignore`, a baseline entry or a `nosemgrep` comment without a written reason on the same line. +- Never weaken a test to make it pass, and never mark a test skipped to get a green run. +- Never break the public API (attributes, hooks fired, configuration keys, commands) in a minor version: deprecate first. + +### Always + +- A bug fix comes with a test that fails without the fix. +- Domain code depends on ports (interfaces), never on WordPress functions, Laravel or the Infrastructure layer. +- The common case of a public API needs no configuration; options are chained methods or named arguments. + +### Rules checked + +- **ARCH-01** Domain code imports neither Illuminate nor the Infrastructure or UI layers _(Pest arch, G1)_ +- **ARCH-02** Domain code calls no global WordPress function _(PHPStan rule pollora.noWordPressCallInDomain, G1)_ +- **ARCH-03** Application code depends on the Domain layer only _(Pest arch, G1)_ +- **ARCH-04** A core package requires neither illuminate/* nor laravel/*, unless listed in .harness.json _(pollora-qa deps, G1)_ +- **ARCH-05** The framework core references no third-party plugin (WooCommerce, ACF, Meta Box, WP Rocket) _(Semgrep, G1)_ +- **ARCH-06** Only integration packages, templates and projects depend on pollora/framework _(pollora-qa deps, G1)_ +- **ARCH-07** Every dependency used is declared, none declared is unused _(composer-dependency-analyser, G1)_ +- **ARCH-08** Attribute classes are final, readonly and declare their targets _(Pest arch, G1)_ +- **ARCH-09** declare(strict_types=1) in every file _(Pest arch + Pint, G1)_ +- **CONV-03** Laravel naming: ServiceProvider, Command, Exception, Repository, Factory suffixes _(Pest arch, G1)_ +- **CONV-04** Hooks fired by Pollora are named pollora// _(pollora-qa surface, G2)_ +- **CONV-05** Configuration lives under pollora.; env() is only called in config/ _(Pest arch, G1)_ +- **CONV-06** Commands are named pollora: or pollora::, such as pollora:make:theme; the older dashed names stay as aliases _(pollora-qa surface, G2)_ +- **CONV-07** Domain and Application code never calls app() or resolve(); it receives contracts _(Pest arch, G1)_ +- **CONV-10** One named exception per business case; never throw a bare \Exception _(Semgrep, G1)_ +- **CONV-11** Native types everywhere; PHPDoc only for generics, array shapes, @internal, @deprecated _(PHPStan + Pest type coverage, G1)_ +- **SEC-01** A REST route declared by attribute states its permission; a public route says so explicitly _(Semgrep, G1)_ +- **SEC-03** No $wpdb query built by interpolation without prepare() _(Semgrep, G1)_ +- **SEC-04** No unescaped Blade output {!! !!} outside a commented allowlist _(Semgrep, G1)_ +- **SEC-05** No unserialize, eval or extract _(Semgrep, G1)_ +- **SEC-08** No secret in the repository _(gitleaks, G1)_ +- **SEC-09** No dependency with a known vulnerability _(composer audit, G1)_ +- **QA-01** Ratchet: no metric gets worse (PHPStan level and baseline, coverage, mutation score) _(pollora-qa ratchet, G2)_ +- **QA-02** The harness files are protected: baseline, .harness.json and api-surface.json change only through a human _(Agent hooks + CODEOWNERS, G0)_ +- **QA-04** The public surface (attributes, hooks fired, configuration keys, commands) changes only with a label: api-change to add, breaking to remove or change _(pollora-qa surface, G2)_ +- **QA-05** No PHP signature breaks backward compatibility outside a major version _(roave/backward-compatibility-check, G2)_ +- **QA-06** The archive of a tagged release carries only what runs: tests, CI workflows, tool configuration and agent files are export-ignored _(pollora-qa archive, G2)_ +- **QA-03** A change to a core package keeps the framework green _(Downstream job, G2)_ + diff --git a/composer.json b/composer.json index 0ef8828..0ec0a1d 100644 --- a/composer.json +++ b/composer.json @@ -23,6 +23,7 @@ "pestphp/pest": "^3.8", "pestphp/pest-plugin-type-coverage": "^3.5", "phpstan/phpstan": "^2.1", + "pollora/harness": "^0.1.17", "rector/rector": "^2.0" }, "bin": [ @@ -52,7 +53,9 @@ "@test:types", "@test:unit", "@test:type-coverage" - ] + ], + "qa": "@php vendor/bin/pollora-qa check", + "qa:fast": "@php vendor/bin/pollora-qa check --fast" }, "config": { "sort-packages": true, diff --git a/phpstan.neon b/phpstan.neon index c531fc8..a832bfe 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -1,5 +1,8 @@ +includes: + - vendor/pollora/harness/presets/phpstan/base.neon + parameters: paths: - src/ - level: 5 + level: 8 diff --git a/pint.json b/pint.json index 93061b6..4b4f940 100644 --- a/pint.json +++ b/pint.json @@ -1,3 +1,6 @@ { - "preset": "laravel" + "preset": "laravel", + "rules": { + "declare_strict_types": true + } } diff --git a/src/Application.php b/src/Application.php index 72986d0..6dcdd31 100644 --- a/src/Application.php +++ b/src/Application.php @@ -66,6 +66,7 @@ private function registerProjectCommands(ProjectDetector $detector): void $cmd = new Command($name); $cmd->setDescription($description); + // @phpstan-ignore method.deprecated (addCommand() only exists since Symfony 7.4; composer.json allows ^7.0) $this->add($cmd); } } diff --git a/tests/Arch.php b/tests/Arch.php new file mode 100644 index 0000000..3aa969e --- /dev/null +++ b/tests/Arch.php @@ -0,0 +1,15 @@ +register(); From 116a6f828b9d107836797549776c27cfe7e60f6b Mon Sep 17 00:00:00 2001 From: Olivier Gorzalka Date: Wed, 4 Nov 2026 09:21:13 +0100 Subject: [PATCH 2/2] chore: throw a named exception from pollora new and require Symfony Console 7.4 for addCommand() --- .harness.json | 2 +- composer.json | 2 +- src/Application.php | 3 +- src/Commands/NewCommand.php | 18 ++++----- src/Exceptions/InstallationException.php | 51 ++++++++++++++++++++++++ tests/Unit/InstallationExceptionTest.php | 25 ++++++++++++ 6 files changed, 87 insertions(+), 14 deletions(-) create mode 100644 src/Exceptions/InstallationException.php create mode 100644 tests/Unit/InstallationExceptionTest.php diff --git a/.harness.json b/.harness.json index d9014b5..594de40 100644 --- a/.harness.json +++ b/.harness.json @@ -7,6 +7,6 @@ "ratchet": { "phpstan_level": 8, "phpstan_baseline": 0, - "coverage": 35.98 + "coverage": 36.86 } } diff --git a/composer.json b/composer.json index 0ec0a1d..80b11c0 100644 --- a/composer.json +++ b/composer.json @@ -14,7 +14,7 @@ "php": "^8.2", "guzzlehttp/guzzle": "^7.0", "laravel/prompts": "^0.3", - "symfony/console": "^7.0", + "symfony/console": "^7.4", "symfony/process": "^7.0" }, "require-dev": { diff --git a/src/Application.php b/src/Application.php index 6dcdd31..a42d946 100644 --- a/src/Application.php +++ b/src/Application.php @@ -66,8 +66,7 @@ private function registerProjectCommands(ProjectDetector $detector): void $cmd = new Command($name); $cmd->setDescription($description); - // @phpstan-ignore method.deprecated (addCommand() only exists since Symfony 7.4; composer.json allows ^7.0) - $this->add($cmd); + $this->addCommand($cmd); } } } diff --git a/src/Commands/NewCommand.php b/src/Commands/NewCommand.php index 504b242..67649d0 100644 --- a/src/Commands/NewCommand.php +++ b/src/Commands/NewCommand.php @@ -6,6 +6,7 @@ use Pollora\Cli\Concerns\ConfiguresPrompts; use Pollora\Cli\Concerns\RunsCommands; +use Pollora\Cli\Exceptions\InstallationException; use RuntimeException; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputArgument; @@ -122,11 +123,11 @@ private function processArguments(): self private function validateArguments(): self { if (! $this->force && $this->applicationExists()) { - throw new RuntimeException('Application already exists!'); + throw InstallationException::applicationExists(); } if ($this->force && $this->pathIsCwd()) { - throw new RuntimeException('Cannot use --force option when using current directory for installation!'); + throw InstallationException::forceInCurrentDirectory(); } return $this; @@ -178,7 +179,7 @@ private function installWithComposer(): self $this->runCommands($commands); if (! $this->wasInstallSuccessful()) { - throw new RuntimeException('There was a problem installing Pollora!'); + throw InstallationException::installFailed(); } // The skeleton's post-install scripts were skipped, so the .env file @@ -198,7 +199,7 @@ private function installWithComposer(): self private function installWithDdev(): self { if (! $this->isDdevInstalled()) { - throw new RuntimeException('DDEV is not installed. Please install it from https://ddev.readthedocs.io'); + throw InstallationException::ddevMissing(); } $this->output->writeln(''); @@ -234,7 +235,7 @@ private function installWithDdev(): self ], workingPath: $this->absolutePath); if (! $this->wasInstallSuccessful()) { - throw new RuntimeException('There was a problem installing Pollora via DDEV!'); + throw InstallationException::ddevInstallFailed(); } // Configure .env with DDEV database credentials before running install @@ -266,7 +267,7 @@ private function removeDdevBlockingMetadata(): void $metadataFile = $this->absolutePath.'/.DS_Store'; if (is_file($metadataFile) && ! unlink($metadataFile)) { - throw new RuntimeException('Could not remove .DS_Store before DDEV Composer installation.'); + throw InstallationException::metadataFileNotRemovable(); } } @@ -406,10 +407,7 @@ private function runArtisan(string $phpPrefix, string $artisanCommand, string $m }); if (! $process->isSuccessful()) { - throw new RuntimeException(sprintf( - 'php artisan %s failed. Fix the problem above, then run it again from your project directory.', - $artisanCommand - )); + throw InstallationException::artisanFailed($artisanCommand); } return $this; diff --git a/src/Exceptions/InstallationException.php b/src/Exceptions/InstallationException.php new file mode 100644 index 0000000..fba7aeb --- /dev/null +++ b/src/Exceptions/InstallationException.php @@ -0,0 +1,51 @@ +toBeInstanceOf(RuntimeException::class); +}); + +it('says how to recover from each installation failure', function (string $named, string $fragment): void { + $exception = $named === 'artisanFailed' + ? InstallationException::artisanFailed('pollora:install') + : InstallationException::$named(); + + expect($exception->getMessage())->toContain($fragment); +})->with([ + 'existing application' => ['applicationExists', '--force'], + 'force in the current directory' => ['forceInCurrentDirectory', 'parent directory'], + 'failed install' => ['installFailed', 'run the command again'], + 'missing DDEV' => ['ddevMissing', 'ddev.readthedocs.io'], + 'failed DDEV install' => ['ddevInstallFailed', 'run the command again'], + 'DS_Store not removable' => ['metadataFileNotRemovable', 'Delete it by hand'], + 'failed artisan command' => ['artisanFailed', 'php artisan pollora:install failed'], +]);