Skip to content
Merged
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
27 changes: 27 additions & 0 deletions .claude/settings.json
Original file line number Diff line number Diff line change
@@ -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" }
]
}
]
}
}
19 changes: 19 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -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
19 changes: 19 additions & 0 deletions .github/workflows/harness.yml
Original file line number Diff line number Diff line change
@@ -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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,5 @@ composer.lock
.idea/
.vscode/
.DS_Store
# Pollora harness reports
/.harness/
12 changes: 12 additions & 0 deletions .harness.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"profile": "core",
"mode": "enforce",
"allow_laravel": [
"laravel/prompts"
],
"ratchet": {
"phpstan_level": 8,
"phpstan_baseline": 0,
"coverage": 36.86
}
}
57 changes: 57 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# Agent instructions

<!-- pollora-harness:start -->
## 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 <ID>` 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/<module>/<event_snake_case> _(pollora-qa surface, G2)_
- **CONV-05** Configuration lives under pollora.<package>; env() is only called in config/ _(Pest arch, G1)_
- **CONV-06** Commands are named pollora:<verb> or pollora:<group>:<verb>, 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)_
<!-- pollora-harness:end -->
7 changes: 5 additions & 2 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand All @@ -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": [
Expand Down Expand Up @@ -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,
Expand Down
5 changes: 4 additions & 1 deletion phpstan.neon
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
includes:
- vendor/pollora/harness/presets/phpstan/base.neon

parameters:
paths:
- src/

level: 5
level: 8
5 changes: 4 additions & 1 deletion pint.json
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
{
"preset": "laravel"
"preset": "laravel",
"rules": {
"declare_strict_types": true
}
}
2 changes: 1 addition & 1 deletion src/Application.php
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ private function registerProjectCommands(ProjectDetector $detector): void

$cmd = new Command($name);
$cmd->setDescription($description);
$this->add($cmd);
$this->addCommand($cmd);
}
}
}
18 changes: 8 additions & 10 deletions src/Commands/NewCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand All @@ -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('');
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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();
}
}

Expand Down Expand Up @@ -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;
Expand Down
51 changes: 51 additions & 0 deletions src/Exceptions/InstallationException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
<?php

declare(strict_types=1);

namespace Pollora\Cli\Exceptions;

use RuntimeException;

/**
* Raised when `pollora new` cannot create the project; the message says what to do next.
*/
final class InstallationException extends RuntimeException
{
public static function applicationExists(): self
{
return new self('Application already exists! Choose another name or pass --force to replace it.');
}

public static function forceInCurrentDirectory(): self
{
return new self('Cannot use --force option when using current directory for installation! Run the command from the parent directory.');
}

public static function installFailed(): self
{
return new self('There was a problem installing Pollora! Fix the problem above, then run the command again.');
}

public static function ddevMissing(): self
{
return new self('DDEV is not installed. Please install it from https://ddev.readthedocs.io');
}

public static function ddevInstallFailed(): self
{
return new self('There was a problem installing Pollora via DDEV! Fix the problem above, then run the command again.');
}

public static function metadataFileNotRemovable(): self
{
return new self('Could not remove .DS_Store before DDEV Composer installation. Delete it by hand, then run the command again.');
}

public static function artisanFailed(string $artisanCommand): self
{
return new self(sprintf(
'php artisan %s failed. Fix the problem above, then run it again from your project directory.',
$artisanCommand
));
}
}
15 changes: 15 additions & 0 deletions tests/Arch.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<?php

declare(strict_types=1);

use Pollora\Harness\Pest\Architecture;

/*
* Architecture rules of the Pollora harness (ARCH-*, CONV-*).
*
* The rules follow the profile in .harness.json and the layers found under
* the PSR-4 roots: nothing to maintain here. List them with
* `vendor/bin/pollora-qa rules`.
*/

Architecture::for(dirname(__DIR__))->register();
25 changes: 25 additions & 0 deletions tests/Unit/InstallationExceptionTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<?php

declare(strict_types=1);

use Pollora\Cli\Exceptions\InstallationException;

it('stays catchable as a runtime exception', function (): void {
expect(InstallationException::applicationExists())->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'],
]);
Loading