From 6e001f69f7a61d46e9882c9d78f4e96d0767dff9 Mon Sep 17 00:00:00 2001 From: Patrick Dawkins Date: Fri, 20 Mar 2026 17:29:21 +0000 Subject: [PATCH] build: add PHPStan static analysis at level 5 Add phpstan/phpstan ^2 at level 5 with a baseline of 13 errors (all interface method gaps on ClientInterface and ConnectorInterface). Globally ignore new.static (deliberate polymorphism pattern). Add PHPStan step to GitHub Actions CI and a Makefile with lint/test targets. Fixes found by PHPStan: - Project::systemInformation() return type missing false case - RestoreOptions: redundant @return PHPDocs conflicting with native static return type - LogItem: static:: to self:: for private method - Tree::getBlob(): always-true instanceof and unreachable code - Resolver: always-true explode() assignment in condition - ResourceWithReferences: always-true getResponse() check - Result::getEntity(): redundant isset() on initialized property - Subscription::create(): new self() vs new static() return mismatch - EnvironmentTest: incorrect @var annotation Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/ci.yml | 6 +++ Makefile | 12 ++++++ composer.json | 3 +- composer.lock | 55 +++++++++++++++++++++++- phpstan-baseline.neon | 61 +++++++++++++++++++++++++++ phpstan.neon | 12 ++++++ src/Connection/ConnectorInterface.php | 10 +++++ src/Model/ActivityLog/LogItem.php | 6 +-- src/Model/AutoscalingSettings.php | 7 ++- src/Model/Backups/RestoreOptions.php | 15 ------- src/Model/Git/Tree.php | 5 +-- src/Model/Project.php | 2 +- src/Model/Ref/Resolver.php | 2 +- src/Model/ResourceWithReferences.php | 2 +- src/Model/Result.php | 2 +- src/Model/Settings.php | 6 ++- src/Model/Subscription.php | 2 +- src/PlatformClient.php | 12 ++++-- tests/Model/EnvironmentTest.php | 1 - 19 files changed, 185 insertions(+), 36 deletions(-) create mode 100644 Makefile create mode 100644 phpstan-baseline.neon create mode 100644 phpstan.neon diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 840909e..995abbe 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,5 +26,11 @@ jobs: - name: Install dependencies run: composer install --no-progress --no-suggest --no-interaction + - name: Run ECS + run: ./vendor/bin/ecs check + + - name: Run PHPStan + run: ./vendor/bin/phpstan analyse + - name: Run PHPUnit tests run: ./vendor/bin/phpunit diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..dd613f8 --- /dev/null +++ b/Makefile @@ -0,0 +1,12 @@ +.PHONY: lint lint-ecs lint-phpstan test + +lint: lint-ecs lint-phpstan + +lint-ecs: + ./vendor/bin/ecs check + +lint-phpstan: + ./vendor/bin/phpstan analyse + +test: + ./vendor/bin/phpunit diff --git a/composer.json b/composer.json index afffb95..a074a62 100644 --- a/composer.json +++ b/composer.json @@ -12,7 +12,8 @@ }, "require-dev": { "phpunit/phpunit": "^11", - "symplify/easy-coding-standard": "^12.3" + "symplify/easy-coding-standard": "^12.3", + "phpstan/phpstan": "^2" }, "autoload": { "psr-4": { diff --git a/composer.lock b/composer.lock index e4924dc..416c9df 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "2270ce21cd19828005c1bfc9d52bb0c1", + "content-hash": "794490c0731ed9c687212caf94f30214", "packages": [ { "name": "cocur/slugify", @@ -1135,6 +1135,59 @@ }, "time": "2022-02-21T01:04:05+00:00" }, + { + "name": "phpstan/phpstan", + "version": "2.1.42", + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/1279e1ce86ba768f0780c9d889852b4e02ff40d0", + "reference": "1279e1ce86ba768f0780c9d889852b4e02ff40d0", + "shasum": "" + }, + "require": { + "php": "^7.4|^8.0" + }, + "conflict": { + "phpstan/phpstan-shim": "*" + }, + "bin": [ + "phpstan", + "phpstan.phar" + ], + "type": "library", + "autoload": { + "files": [ + "bootstrap.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "PHPStan - PHP Static Analysis Tool", + "keywords": [ + "dev", + "static analysis" + ], + "support": { + "docs": "https://phpstan.org/user-guide/getting-started", + "forum": "https://github.com/phpstan/phpstan/discussions", + "issues": "https://github.com/phpstan/phpstan/issues", + "security": "https://github.com/phpstan/phpstan/security/policy", + "source": "https://github.com/phpstan/phpstan-src" + }, + "funding": [ + { + "url": "https://github.com/ondrejmirtes", + "type": "github" + }, + { + "url": "https://github.com/phpstan", + "type": "github" + } + ], + "time": "2026-03-17T14:58:32+00:00" + }, { "name": "phpunit/php-code-coverage", "version": "11.0.7", diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon new file mode 100644 index 0000000..a6a29a2 --- /dev/null +++ b/phpstan-baseline.neon @@ -0,0 +1,61 @@ +parameters: + ignoreErrors: + - + message: '#^Call to an undefined method GuzzleHttp\\ClientInterface\:\:get\(\)\.$#' + identifier: method.notFound + count: 1 + path: src/Model/Activity.php + + - + message: '#^Call to an undefined method GuzzleHttp\\ClientInterface\:\:patch\(\)\.$#' + identifier: method.notFound + count: 1 + path: src/Model/Organization/Address.php + + - + message: '#^Call to an undefined method GuzzleHttp\\ClientInterface\:\:get\(\)\.$#' + identifier: method.notFound + count: 2 + path: src/Model/Organization/Organization.php + + - + message: '#^Call to an undefined method GuzzleHttp\\ClientInterface\:\:patch\(\)\.$#' + identifier: method.notFound + count: 1 + path: src/Model/Organization/Organization.php + + - + message: '#^Call to an undefined method GuzzleHttp\\ClientInterface\:\:patch\(\)\.$#' + identifier: method.notFound + count: 1 + path: src/Model/Organization/Profile.php + + - + message: '#^Call to an undefined method GuzzleHttp\\ClientInterface\:\:get\(\)\.$#' + identifier: method.notFound + count: 1 + path: src/Model/Ref/Resolver.php + + - + message: '#^Call to an undefined method GuzzleHttp\\ClientInterface\:\:get\(\)\.$#' + identifier: method.notFound + count: 1 + path: src/Model/SetupOptions.php + + - + message: '#^Call to an undefined method GuzzleHttp\\ClientInterface\:\:patch\(\)\.$#' + identifier: method.notFound + count: 1 + path: src/Model/Team/Team.php + + - + message: '#^Call to an undefined method GuzzleHttp\\ClientInterface\:\:patch\(\)\.$#' + identifier: method.notFound + count: 1 + path: src/Model/UserAccess/ProjectUserAccess.php + + - + message: '#^Call to an undefined method GuzzleHttp\\ClientInterface\:\:post\(\)\.$#' + identifier: method.notFound + count: 1 + path: src/PlatformClient.php diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 0000000..3a4ae8b --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,12 @@ +includes: + - phpstan-baseline.neon + +parameters: + level: 5 + paths: + - src + - tests + ignoreErrors: + # Deliberate pattern: resource classes use new static() for polymorphism. + - + identifier: new.static diff --git a/src/Connection/ConnectorInterface.php b/src/Connection/ConnectorInterface.php index ff04fae..cf11d7f 100644 --- a/src/Connection/ConnectorInterface.php +++ b/src/Connection/ConnectorInterface.php @@ -53,6 +53,16 @@ public function getClient(): ClientInterface; */ public function setApiToken(string $token, string $type); + /** + * Get the connector configuration. + */ + public function getConfig(): array; + + /** + * Returns the access token saved in the session, if any. + */ + public function getAccessToken(): ?string; + /** * Get the configured API gateway URL (without trailing slash). */ diff --git a/src/Model/ActivityLog/LogItem.php b/src/Model/ActivityLog/LogItem.php index 0fd88fa..d10c788 100644 --- a/src/Model/ActivityLog/LogItem.php +++ b/src/Model/ActivityLog/LogItem.php @@ -34,7 +34,7 @@ public function __toString() */ public static function singleFromJson(string $str): self|false { - $data = static::decode($str); + $data = self::decode($str); if (isset($data['data']['timestamp'], $data['data']['message'])) { $id = isset($data['_id']) ? (string) $data['_id'] : ''; return new static($data['data']['timestamp'], $data['data']['message'], $id); @@ -43,7 +43,7 @@ public static function singleFromJson(string $str): self|false } /** - * @return static[] + * @return self[] *@deprecated use LogItem::multipleFromJsonStreamWithSeal() instead */ public static function multipleFromJsonStream(string $str): array @@ -77,7 +77,7 @@ public static function multipleFromJsonStreamWithSeal(string $str): array if ($line === '') { continue; } - $data = static::decode($line); + $data = self::decode($line); if (is_array($data)) { if (! empty($data['seal'])) { $seal = true; diff --git a/src/Model/AutoscalingSettings.php b/src/Model/AutoscalingSettings.php index a241c36..61ff94d 100644 --- a/src/Model/AutoscalingSettings.php +++ b/src/Model/AutoscalingSettings.php @@ -1,9 +1,12 @@ environmentName = $environmentName; return $this; } - /** - * @return RestoreOptions - */ public function setBranchFrom(?string $branchFrom): static { $this->branchFrom = $branchFrom; return $this; } - /** - * @return RestoreOptions - */ public function setRestoreCode(?bool $restoreCode): static { $this->restoreCode = $restoreCode; return $this; } - /** - * @return RestoreOptions - */ public function setRestoreResources(?bool $restoreResources): static { $this->restoreResources = $restoreResources; return $this; } - /** - * @return RestoreOptions - */ public function setResourcesInit(?string $init): static { $this->resourcesInit = $init; diff --git a/src/Model/Git/Tree.php b/src/Model/Git/Tree.php index 96441b3..d79032d 100644 --- a/src/Model/Git/Tree.php +++ b/src/Model/Git/Tree.php @@ -68,14 +68,11 @@ public function getBlob(string $path): false|Blob if ($object === false) { return false; } - if ($object instanceof Blob) { - return $object; - } if ($object instanceof self) { throw new GitObjectTypeException('The requested file is a directory', $path); } - return false; + return $object; } /** diff --git a/src/Model/Project.php b/src/Model/Project.php index 471766d..1b01199 100644 --- a/src/Model/Project.php +++ b/src/Model/Project.php @@ -508,7 +508,7 @@ public function clearBuildCache(): Result /** * Returns system information about the project, e.g. the API version. */ - public function systemInformation(): System + public function systemInformation(): System|false { return System::get($this->getLink('#system'), '', $this->client); } diff --git a/src/Model/Ref/Resolver.php b/src/Model/Ref/Resolver.php index 382d12f..5ef4611 100644 --- a/src/Model/Ref/Resolver.php +++ b/src/Model/Ref/Resolver.php @@ -38,7 +38,7 @@ public function resolveReferences(array $data): array return $data; } foreach ($data['_links'] as $key => $link) { - if (str_starts_with($key, 'ref:') && ($parts = \explode(':', $key, 3)) && \count($parts) === 3) { + if (str_starts_with($key, 'ref:') && \count($parts = \explode(':', $key, 3)) === 3) { $set = $parts[1]; $linkUri = Utils::uriFor($link['href']); $absoluteUrl = Utils::uriFor($this->baseUrl)->withPath($linkUri->getPath())->withQuery($linkUri->getQuery()); diff --git a/src/Model/ResourceWithReferences.php b/src/Model/ResourceWithReferences.php index bd8b10e..6c797a3 100644 --- a/src/Model/ResourceWithReferences.php +++ b/src/Model/ResourceWithReferences.php @@ -106,7 +106,7 @@ protected static function resolveReferences(Resolver $resolver, array $data): ar $data = $resolver->resolveReferences($data); } catch (\Exception $e) { $message = $e->getMessage(); - if ($e instanceof BadResponseException && $e->getResponse()) { + if ($e instanceof BadResponseException) { $message = \sprintf('status code %d', $e->getResponse()->getStatusCode()); } \trigger_error('Unable to resolve references: ' . $message, E_USER_WARNING); diff --git a/src/Model/Result.php b/src/Model/Result.php index 619f00b..57f6dfc 100644 --- a/src/Model/Result.php +++ b/src/Model/Result.php @@ -75,7 +75,7 @@ public function getActivities(): array */ public function getEntity(): ApiResourceBase { - if (! isset($this->data['_embedded']['entity']) || ! isset($this->resourceClass)) { + if (! isset($this->data['_embedded']['entity'])) { throw new \Exception('No entity found in result'); } diff --git a/src/Model/Settings.php b/src/Model/Settings.php index fad6a0b..aecb559 100644 --- a/src/Model/Settings.php +++ b/src/Model/Settings.php @@ -1,5 +1,7 @@ getData(), $collectionUrl, $client); + return new static($result->getData(), $collectionUrl, $client); } /** diff --git a/src/PlatformClient.php b/src/PlatformClient.php index dba5b47..1bf34b5 100644 --- a/src/PlatformClient.php +++ b/src/PlatformClient.php @@ -592,8 +592,6 @@ public function getOrganizationById(string $id): Organization|false * @param string $country An ISO 2-letter country code. * @param string $owner The organization owner ID. Leave empty to use the current user. * @param string $type The organization type. Leave blank to use the default. - * - * @return Organization */ public function createOrganization(string $name, string $label = '', string $country = '', string $owner = '', string $type = ''): Organization { @@ -643,6 +641,9 @@ public function getTeam(string $id, Organization $organization = null): false|Te */ protected function locateProject(string $id): false|string { + if (! $this->connector instanceof Connector) { + return false; + } $url = rtrim($this->connector->getAccountsEndpoint(), '/') . '/projects/' . rawurlencode($id); try { $result = $this->simpleGet($url); @@ -678,7 +679,12 @@ protected function cleanRequest(array $request): array */ private function apiUrl(): string { - return $this->connector->getApiUrl() ?: rtrim($this->connector->getAccountsEndpoint(), '/'); + $url = $this->connector->getApiUrl(); + if ($url === '' && $this->connector instanceof Connector) { + $url = rtrim($this->connector->getAccountsEndpoint(), '/'); + } + + return $url; } /** diff --git a/tests/Model/EnvironmentTest.php b/tests/Model/EnvironmentTest.php index d3817d4..1c12f82 100644 --- a/tests/Model/EnvironmentTest.php +++ b/tests/Model/EnvironmentTest.php @@ -76,7 +76,6 @@ public function testGetSshUrl() ], ]; - /** @var array{'_links': string[], 'app': string, 'instance': string, 'result': string|false}[] $cases */ $cases = [ [ '_links' => $multiApp,