Skip to content

chore: run PHPStan level 9 in CI and clear the 14 real errors (#24) - #31

Merged
karlwaldman merged 1 commit into
mainfrom
chore/phpstan-ci
Sep 13, 2026
Merged

karlwaldman merged 1 commit into
mainfrom
chore/phpstan-ci

Conversation

@karlwaldman

Copy link
Copy Markdown
Member

Closes #24

Re-measured on current main — the issue's count was stale

#24 quotes 17 level-9 errors. Six of those were Price.php casts that #27
already removed and a seventh was in the transport #26 rewrote. Measured on
0672df0 with phpstan/phpstan 2.2.14, level 9, paths: [src], no baseline:

Level 9 errors on src/
Issue text (stale) 17
True count on main today 14
After this PR 0

Level shipped: 9. No baseline file. No @phpstan-ignore comments. No casts added to silence anything.

What ships

Change File
phpstan/phpstan: ^2.1 in require-dev composer.json
level: 9, paths: [src], no baseline phpstan.neon (new)
composer phpstan script (+ composer check = phpstan then phpunit) composer.json
static-analysis job on push and pull_request, pinned actions, --error-format=github .github/workflows/test.yml
phpstan.neon export-ignored and excluded from the composer archive .gitattributes, composer.json

The 14 errors and how each was fixed

# Site Error Fix
1 Client::dataOrFail() 225 returns array<mixed, mixed>, declares array<string, mixed> new stringKeyed() rebuilds the decoded object with string keys
2 Client::priceOrFail() 265 Price::fromArray() given array<mixed, mixed> same, at the Price boundary
3 Client::request() 314 $this->apiKey !== null always true deleted — the guard 18 lines above already proved it
4-5 Client::request() 340 assert($response instanceof HttpResponse) always true deleted, with the $response = null seed; max(1, …) guarantees the loop body runs
6-8 Client::originOf() 414/415/421 cannot cast mixed to string lowerPart() reads parse_url() parts through is_string(); port through is_int()
9-10 Client::errorMessage() 579/589 offset message on mixed narrow $body['error'] / $body['data'] with is_array() first
11 CurlTransport::request() 34 CURLOPT_URL wants non-empty-string, CURLOPT_CUSTOMREQUEST wants non-empty-string|null reject an empty method or URL with TransportException
12-14 CurlTransport::originOf() 119/120/125 cannot cast mixed to string same lowerPart() treatment

stringKeyed() is the one worth a second look, because on its face it resembles
a cast that silences an error. It is the inverse of a real decode step:
json_decode(…, true) turns a numeric JSON key such as "2026" into an int
array key, which is exactly why the decoded envelope is array<array-key, mixed>
and not the array<string, mixed> this SDK declares on every internal signature.
Restoring the keys makes the declared type true rather than asserted.

lowerPart() is a behaviour improvement, not just a type one: (string) on a
non-string parse_url() part turned null/false into '' and 0 into '0',
so two origins we cannot name could compare equal. An unnameable part is now
always '' and never a plausible-looking value.

TDD evidence

RED 1 — PHPStan level 9 on src/, unmodified main

./vendor/bin/phpstan analyse --no-progress with only phpstan.neon added:

 ------ -----------------------------------------------------------------------
  Line   Client.php
 ------ -----------------------------------------------------------------------
  225    Method OilPriceAPI\Client::dataOrFail() should return array<string, m
         ixed> but returns array<mixed, mixed>.
         🪪  return.type
  265    Parameter #1 $data of static method OilPriceAPI\Price::fromArray()
         expects array<string, mixed>, array<mixed, mixed> given.
         🪪  argument.type
  314    Strict comparison using !== between string and null will always
         evaluate to true.
         🪪  notIdentical.alwaysTrue
  340    Call to function assert() with true will always evaluate to true.
         🪪  function.alreadyNarrowedType
  340    Instanceof between OilPriceAPI\Http\HttpResponse and
         OilPriceAPI\Http\HttpResponse will always evaluate to true.
         🪪  instanceof.alwaysTrue
  414    Cannot cast mixed to string.
         🪪  cast.string
  415    Cannot cast mixed to string.
         🪪  cast.string
  421    Cannot cast mixed to string.
         🪪  cast.string
  579    Cannot access offset 'message' on mixed.
         🪪  offsetAccess.nonOffsetAccessible
  589    Cannot access offset 'message' on mixed.
         🪪  offsetAccess.nonOffsetAccessible
 ------ -----------------------------------------------------------------------

 ------ -----------------------------------------------------------------------
  Line   Http/CurlTransport.php
 ------ -----------------------------------------------------------------------
  34     Parameter #2 $options of function curl_setopt_array expects
         array{10002: non-empty-string, 10036: non-empty-string|null, ...},
         array{10002: string, 10036: string, ...} given.
         🪪  argument.type
         💡  Offset 10002 (non-empty-string) does not accept type string.
         💡  Offset 10036 (non-empty-string|null) does not accept type string.
  119    Cannot cast mixed to string.
         🪪  cast.string
  120    Cannot cast mixed to string.
         🪪  cast.string
  125    Cannot cast mixed to string.
         🪪  cast.string
 ------ -----------------------------------------------------------------------

 [ERROR] Found 14 errors

GREEN 1 — same command, this branch

Note: Using configuration file /private/tmp/sdkfix/php/phpstan.neon.

 [OK] No errors

RED 2 — tests/StaticAnalysisCiTest.php against main's config

A config file in the repo is not a check that runs, and a baseline is the
documented way to make a failing check green without fixing anything — so the
wiring itself gets a test. Captured by restoring composer.json,
.github/workflows/test.yml and .gitattributes from origin/main and
deleting phpstan.neon:

FAILURES!
Tests: 5, Assertions: 12, Failures: 5.

with, among them:

4) OilPriceAPI\Tests\StaticAnalysisCiTest::testCiRunsStaticAnalysisOnPullRequests
The test workflow must invoke PHPStan, or the config is decoration.
Failed asserting that '…workflow…' matches PCRE pattern "~(vendor/bin/phpstan|composer (run(-script)? )?phpstan)~".

5) OilPriceAPI\Tests\StaticAnalysisCiTest::testPhpstanConfigIsNotShippedInTheComposerArchive
Dev tooling config must not ship to Packagist.
Failed asserting that an array contains '/phpstan.neon'.

GREEN 2

OK (5 tests, 17 assertions)

Full suite

Result
Baseline, origin/main OK (229 tests, 747 assertions)
This branch OK (234 tests, 764 assertions)

Left alone, deliberately

The >=8.1 PHP constraint stays. The argument for ^8.1 is real — >=8.1
lets Composer install this on a PHP 9 nobody has tested it against. Two reasons
it does not belong in this PR:

  1. It is a pinned public contract, not an oversight:
    PublicClaimsTest::testCanonicalDeveloperContractIsDiscoverable asserts
    assertSame('>=8.1', $composer['require']['php']). Changing it means
    changing that assertion, which is a decision the repo deliberately recorded.
  2. It is a packaging change with user-visible consequences — it would make
    the package uninstallable on PHP 9 the day it lands, which wants its own
    release note and its own PR, not a rider on a CI change.

Recommend a follow-up issue: narrow to ^8.1, update the pinning assertion, add
the next PHP major to the CI matrix when it enters RC, and note it in the
CHANGELOG under the release that ships it.

No CHANGELOG entry and no version bump. VersioningTest pins the top
CHANGELOG heading to Client::VERSION, so an ## Unreleased section fails the
suite; this belongs in the next release commit.

PHPStan runs over src/ only, per the issue. Extending it to tests/ and
examples/ is worth doing but is a separate, noisier change.

🤖 Generated with Claude Code

https://claude.ai/code/session_015ao5paex73xXvuM424Libo

Adds phpstan/phpstan as a dev dependency, a phpstan.neon at level 9 over
src/ with no baseline, a "phpstan" composer script, and a static-analysis
job in the test workflow.

The 17 errors quoted in #24 are stale: PR #27 removed six Price.php casts
and #26 rewrote the transport. Re-measured on 0672df0, level 9 on src/
reports 14. All 14 are fixed here, so the level ships clean:

- dataOrFail()/priceOrFail() rebuild decoded JSON objects with string keys,
  making the array<string, mixed> the SDK declares everywhere actually true
  (json_decode turns a numeric JSON key into an int array key).
- originOf() in Client and CurlTransport reads parse_url() parts through
  is_string()/is_int() instead of casting mixed, so an unnameable part
  cannot compare equal to a real origin.
- errorMessage() narrows $body['error'] and $body['data'] to arrays before
  reaching into them.
- CurlTransport rejects an empty method or URL, which is also what
  curl_setopt_array's non-empty-string contract requires.
- Removed two provably dead checks: the api-key null test the guard above it
  already proved, and the assert()/null seed on a loop that always runs.

No baseline file and no @PHPStan-Ignore comments. phpstan.neon is
export-ignored and excluded from the composer archive - it is dev tooling,
not package content.

The PHP constraint stays >=8.1: it is pinned by an existing assertion in
PublicClaimsTest and narrowing it to ^8.1 is a packaging decision with its
own release note, not a CI change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015ao5paex73xXvuM424Libo
@coderabbitai

coderabbitai Bot commented Sep 13, 2026

Copy link
Copy Markdown

Important

  • 🔍 Trigger review

This repository does not receive automatic reviews because it has fewer than 10 stars.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Advanced

Run ID: daaf6da7-4b3e-4daa-9b8e-5104d444d07f


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@karlwaldman
karlwaldman merged commit ea86475 into main Sep 13, 2026
9 checks passed
@karlwaldman
karlwaldman deleted the chore/phpstan-ci branch September 13, 2026 19:05
@karlwaldman karlwaldman mentioned this pull request Sep 13, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[P3] No static analysis in CI (PHPStan L9 finds 17 errors incl. the #21 coercion bug) and an open-ended >=8.1 PHP constraint

1 participant