Skip to content

chore(psalm): make the static analysis pass again - #113

Merged
roxblnfk merged 3 commits into
1.xfrom
psalm-strict
Aug 11, 2026
Merged

chore(psalm): make the static analysis pass again#113
roxblnfk merged 3 commits into
1.xfrom
psalm-strict

Conversation

@roxblnfk

@roxblnfk roxblnfk commented Aug 11, 2026

Copy link
Copy Markdown
Member

🔍 What was changed

Psalm goes from 27 errors to 0. It had been red on 1.x for a while — the GitLab classes were written by copying their GitHub twins, whose identical issues were already in the baseline, so the copies went unsuppressed. Everything here is fixed at the source rather than added to the baseline.

Four of the reports turned out to be real bugs, not type noise:

  • parse_url($uri, PHP_URL_PATH) returns false on a malformed URI, and ?? $config->uri only covered nullfalse was passed on as if it were a repository path.
  • A PSR-7 stream may report eof() as false and still read nothing, so both asset download loops could spin without yielding. They now stop on an empty read, which is also what makes the non-empty-string chunk contract in AssetInterface::download() true.
  • A failed preg_replace() returns null, which turned the gz temp path into a bare directory separator.
  • --path arrived as mixed, so a blank value reached a non-empty-string parameter; blank software arguments had the same problem.

The rest is type-level: Collection::create() documents the closure it has always accepted at runtime, the GitLab response docblocks now say what the API actually returns (visibility is a string, not a bool), and TomlData declares array<string, mixed> throughout, narrowed once where the untyped parser hands the array over — a TOML document is a table, so its top-level keys are strings by construction.

Both destroy() methods check isset() instead of === null. The property is not nullable, so the old comparison never guarded the second call that the Destroyable contract asks to be safe; the isset() is suppressed with a note, because Psalm does not model unset() leaving a typed property uninitialized.

The dead Symfony pre-5.3 getDefaultName() fallback is gone — AsCommand has shipped since 5.3 and composer.json requires ^6.4 || ^7 || ^8, so the branch was unreachable and its only effect was a deprecation warning.

Why?

psalm is a required check, so every PR against 1.x currently opens red through no fault of its own — #112 was the one that made this visible.

Regenerating the baseline drops 99 lines of entries that no longer match any code, so the remaining ones are the ones that still mean something.

Tests

Three of the four fixes are now covered; each was checked by reverting the fix and watching the new test go red.

The download loop was duplicated verbatim in GitHubAsset and GitLabAsset, and sat inside final classes that cannot be built without the whole API chain — so the empty-read guard had no way to be tested. It moved into HttpClient\StreamReader, which takes a stream and yields non-empty chunks: one place instead of two, and directly testable. The case that matters most — a stream whose eof() never flips — is asserted with a bounded loop, so a regression fails rather than hanging the suite.

The GitLab URI fallback is covered across a bare project path, a full URL, a URL without a path, and two URLs parse_url() cannot parse at all. GzArchive, which had no tests whatsoever, is covered over a real gzip file: the decompressed content, the yielded key, the .gz stripping (including uppercase) and the copy-to-destination handshake.

Left uncovered on purpose: the null branch of preg_replace() in GzArchive. With the pattern /\.gz$/i it is only reachable through a PCRE engine error, which no input to that method can trigger — the fix stays as a guard, without a test pretending to exercise it. Get::getDownloadActions() is a private static behind the command wiring, so the blank---path fix has no unit-level seam either.

Checklist

  • How was this tested:
    • composer psalm — 0 errors, was 27
    • composer test — 477 passed, 3 skipped; 24 new tests, none of the existing ones changed
    • composer cs:diff clean
    • Each new regression test verified to fail against the unfixed code

fix(repository): stop an empty stream read from looping forever

fix(archive): keep the gz output name when stripping the extension fails

fix(command): ignore a blank --path option and blank software arguments

Psalm surfaced all four. `parse_url()` returns false on a malformed URI, which
was passed on as if it were a path. A PSR-7 stream may report `eof()` as false
and still read nothing, so the download loop could spin without yielding. A
failed `preg_replace()` returns null, which made the temp path a bare directory
separator. And `--path` arrived as mixed, so an empty value reached a
non-empty-string parameter.

Both `destroy()` methods now check `isset()` rather than `=== null`: the property
is not nullable, so the old comparison never guarded the second call the
`Destroyable` contract asks to be safe.

Assisted-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
refactor(command): drop the dead Symfony pre-5.3 command-name fallback

Psalm had been red on 1.x for a while: the GitLab classes were written by copying
their GitHub twins, whose identical issues were already in the baseline, so the
copies went unsuppressed. The shared causes are fixed at the source instead —
`Collection::create()` documents the closure it has always accepted, and the
GitLab response docblocks now say what the API returns (`visibility` is a string,
not a bool).

`TomlData` declares `array<string, mixed>` throughout, narrowed once where the
untyped parser hands the array over; a TOML document is a table, so its top-level
keys are strings by construction.

The command-name fallback guarded against Symfony without `AsCommand`, which has
shipped since 5.3 while composer.json requires ^6.4 — unreachable, and its only
effect was a deprecation warning.

The baseline is regenerated, which drops 99 lines of entries that no longer match
any code.

Assisted-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@github-actions github-actions Bot added the tests label Aug 11, 2026
@codecov

codecov Bot commented Aug 11, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

Files with missing lines Coverage Δ
src/Module/Archive/Internal/GzArchive.php 87.50% <100.00%> (ø)
src/Module/HttpClient/StreamReader.php 100.00% <100.00%> (ø)
src/Module/Repository/Internal/Collection.php 70.96% <ø> (ø)
src/Module/Repository/Internal/GitLab/Factory.php 100.00% <100.00%> (ø)
...Module/Velox/Internal/Config/Pipeline/TomlData.php 96.15% <100.00%> (+0.15%) ⬆️
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

test: cover the three fixes that were reachable from a test

The download loop was duplicated verbatim in GitHubAsset and GitLabAsset, and sat
inside final classes that cannot be built without the whole API chain — so the
empty-read guard had no way to be tested. It now lives in one place that takes a
stream and yields non-empty chunks, which is directly testable and removes the
duplication.

Covered: the guard itself, including a stream whose eof() never flips, asserted
with a bounded loop so a regression fails instead of hanging. The GitLab URI
fallback across a bare path, a full URL, a URL without a path and two unparsable
ones. And GzArchive, which had no tests at all, over a real gzip file.

The null branch of the preg_replace in GzArchive is left uncovered: with the
pattern `/\.gz$/i` it can only be reached through a PCRE engine error, which no
input to that method can trigger.

Assisted-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@roxblnfk
roxblnfk merged commit 117cce5 into 1.x Aug 11, 2026
23 checks passed
@roxblnfk
roxblnfk deleted the psalm-strict branch August 11, 2026 13:15
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant