Skip to content

Detect polyfills by their conditional declaration instead of a polyfill substring in the file path - #6231

Closed
phpstan-bot wants to merge 1 commit into
phpstan:2.2.xfrom
phpstan-bot:create-pull-request/patch-0sadoqd
Closed

Detect polyfills by their conditional declaration instead of a polyfill substring in the file path#6231
phpstan-bot wants to merge 1 commit into
phpstan:2.2.xfrom
phpstan-bot:create-pull-request/patch-0sadoqd

Conversation

@phpstan-bot

Copy link
Copy Markdown
Collaborator

Summary

str_contains(), str_starts_with() and str_ends_with() started being reported as
expecting non-empty-string in the nextras/orm CI job:

Parameter #1 $haystack of function str_contains expects non-empty-string, string given.

The culprit is mockery/mockery's library/helpers.php, which is a Composer
autoload.files entry containing

if (! \function_exists('str_contains')) {
    /**
     * @param non-empty-string $haystack
     * @param non-empty-string $needle
     */
    function str_contains(string $haystack, string $needle): bool { ... }
}

That declaration is dead code on PHP 8 - PHP provides str_contains() natively - but
PHPStan reflected it anyway and let its (wrong) PHPDoc replace the native signature.
PHPStan only recognised polyfills by looking for the substring polyfill in the
declaring file's path, which library/helpers.php does not contain.

The fix recognises polyfills by what actually makes them polyfills: the declaration is
guarded by a conditional, so it can never run when PHP provides the symbol natively.

Changes

  • src/Reflection/ConditionallyDeclaredSymbolDetector.php (new): parses a file with
    @php8Parser and reports which functions, classes/interfaces/traits/enums and
    define()d constants are declared inside a conditional block. Results are cached per
    file (FILE_CACHE_LIMIT); an unreadable or unparseable file simply yields nothing.
  • src/Reflection/SignatureMap/NativeFunctionReflectionProvider.php: replaced
    str_contains(strtolower($fileName), 'polyfill') with isPolyfill(), which requires
    both that the declaration is conditional and that
    PhpStormStubsSourceStubber::isPresentFunction() confirms PHP provides the function at
    the configured PHP version.
  • src/Reflection/BetterReflection/SourceLocator/SkipPolyfillSourceLocator.php: the
    same rule now also hides conditionally declared classes/interfaces/traits/enums
    (gated by isPresentClass()) and define()d constants (gated by
    generateConstantStub()), so the internal stub is reflected instead of the polyfill's
    approximation. The existing symfony/polyfill-php8x path list is kept - it also covers
    symbols those packages declare unconditionally.
  • src/Reflection/BetterReflection/BetterReflectionSourceLocatorFactory.php and
    src/Testing/TestCaseSourceLocatorFactory.php: pass the new detector and the stubber
    into SkipPolyfillSourceLocator.

Analogous cases

  • str_contains / str_starts_with / str_ends_with, positional and named arguments -
    all covered by the same fix and exercised by the e2e fixture.
  • Classes / interfaces / traits / enums polyfilled behind a class_exists() /
    interface_exists() / PHP_VERSION_ID guard - probed, found broken (a polyfilled
    ValueError/Stringable shadowed the native one), fixed in SkipPolyfillSourceLocator.
  • Constants polyfilled with a guarded define() - probed, found broken
    (JSON_THROW_ON_ERROR resolved to the polyfill's value), fixed in the same place.
  • const inside a conditional - not possible in PHP (parse error), so no handling is
    needed for that form.
  • Unconditional userland declarations of a name that only sounds native
    (swf_actiongotoframe, False positive parameter count error for static method named "add" phpstan#13556) - probed, still correctly resolved to the
    user's own signature, since isPolyfill() requires the conditional guard.
  • Functions at the source-locator level - deliberately not hidden there. Hiding them
    would also hide their existence when the analysed PHP version does not have them
    natively (and would make getallheaders() unknown); preferring the native signature in
    NativeFunctionReflectionProvider achieves the fix without that side effect.

Root cause

NativeFunctionReflectionProvider::findFunctionReflection() returned null - handing the
function over to userland reflection - as soon as the function had a declaring file that
was not internal, unless the path contained the literal substring polyfill. That path
heuristic was introduced to stop the signature map from being applied to a user's own
function that happens to share a name with an ancient PHP function
(phpstan/phpstan#13556), but it cannot tell those two situations apart:

  • a user's own function swf_actiongotoframe($string) {} - declared unconditionally,
    because nothing else declares it, and
  • a polyfill's if (!function_exists('str_contains')) { function str_contains(...) {} } -
    necessarily declared conditionally, because PHP would fatal on the redeclaration.

The conditional guard is the discriminator, and it applies verbatim to the other symbol
kinds: class_exists() guards for classes and defined() guards for constants.
SkipPolyfillSourceLocator had exactly the same path-based blind spot for those.

Test

  • e2e/shadowed-native-function/ - a mockery-shaped helpers.php with all three
    guarded str_* polyfills carrying @param non-empty-string, registered in
    .github/workflows/e2e-tests.yml. Without the fix it reports the three
    argument.type errors from the issue (including the named-argument call); with the fix
    it reports none.
  • tests/PHPStan/Analyser/nsrt/shadowed-native-function.php - getallheaders() from
    ralouphie/getallheaders (already in PHPStan's own vendor, guarded by
    function_exists() and carrying an invalid @return string[string]). Inferred as
    *ERROR* before the fix, array after.
  • tests/PHPStan/Reflection/BetterReflection/SourceLocator/SkipPolyfillSourceLocatorTest.php -
    polyfilled ValueError, Stringable and JSON_THROW_ON_ERROR are skipped, while a
    conditionally declared non-native class, an unconditional class and a non-native
    constant are kept.
  • tests/PHPStan/Reflection/ConditionallyDeclaredSymbolDetectorTest.php - unit coverage
    for the detector: function_exists() and PHP_VERSION_ID guards, elseif/else
    branches, namespaced and global declarations, case-insensitivity for functions and
    classes vs. case-sensitivity for constants, unconditional declarations and unreadable
    files.

make tests, make phpstan and make cs are green. make name-collision fails on this
machine for a pre-existing, unrelated reason: tests/PHPStan/Reflection/data/attribute-const-reflection.php
and tests/PHPStan/Analyser/nsrt/pipe-operator.php need PHP 8.5 to parse and the runner
has PHP 8.4.

Fixes phpstan/phpstan#15086

…ill` substring in the file path

- `NativeFunctionReflectionProvider::findFunctionReflection()` no longer drops
  the native signature just because a userland file declares the function; it
  keeps it when the declaration is conditional (the polyfill idiom) and
  phpstorm-stubs says PHP provides the function at the configured PHP version.
- New `ConditionallyDeclaredSymbolDetector` parses the declaring file and
  reports which functions, classes and `define()`d constants are declared
  inside a conditional block (`function_exists()`, `class_exists()`,
  `defined()`, `PHP_VERSION_ID` comparisons, ...).
- `SkipPolyfillSourceLocator` applies the same rule to the analogous cases:
  conditionally declared classes/interfaces/traits/enums and `define()`d
  constants that PHP provides natively are hidden so the internal stub wins,
  next to the existing symfony/polyfill path list.
- Functions are deliberately not hidden at the source-locator level - that
  would also hide their existence on PHP versions without them natively.
- An unconditional userland declaration of a native-sounding name (the
  `swf_actiongotoframe` case from #13556) keeps winning over the signature map.

@staabm staabm left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

which recent change introduced the regression?

@staabm staabm closed this Aug 18, 2026
@staabm
staabm deleted the create-pull-request/patch-0sadoqd branch August 18, 2026 09:20
@staabm

staabm commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

the declarations are wrong, see phpstan/phpstan#15086 (comment)

@phpstan-bot

Copy link
Copy Markdown
Collaborator Author

No code change was needed for this review — it's a question. I investigated and here's the answer, with evidence.

Answer: no phpstan-src commit did — mockery 1.6.13 did

The trigger is external. mockery/mockery 1.6.13, released 2026-08-15 03:07 UTC, added str_contains(), str_starts_with(), str_ends_with() (plus get_debug_type(), array_is_list()) to library/helpers.php, each behind a function_exists() guard and each carrying @param non-empty-string $haystack. 1.6.12 already autoloaded library/helpers.php via autoload.files, but that file had no str_* polyfills.

nextras/orm requires mockery/mockery: >=1.5.1 (no upper bound), so the integration job picks up the new release immediately. From the CI logs:

nextras/orm job mockery installed result
run 31838613597, job 94891572636 — 2026-08-14 20:40 UTC 1.6.12 success
run 32115262993, job 95646190165 — 2026-08-18 08:25 UTC (PR #6228) 1.6.13 failure

The last green run and the first red one bracket the mockery release, and no commit landed on 2.2.x in that window (last push before was 1081ec4ec, 2026-08-13 17:15; next was 6c642f1bf, 2026-08-15 11:06). The job also failed on several unrelated branches (resolve-type-rewrite-2, fix-turbo-dist-atomicity, cachedparser-memoize-file-contents, …) from that point on, which is what an environmental trigger looks like.

The latent phpstan-src change it exposed

The behaviour it woke up came from 326c6ec "Improve function signatures with functionMap only for built-in symbols" (2025-09-22, first released in 2.1.29, fixing phpstan/phpstan#13556), which added to NativeFunctionReflectionProvider::findFunctionReflection():

if (!$reflectionFunctionAdapter->isInternal() && !str_contains(strtolower($fileName), 'polyfill')) {
    return null;
}

Returning null hands the function over to userland reflection, so the polyfill's PHPDoc — not the signature map — describes str_contains(). Before that commit the signature map always won, and the @param tags in the declaring file were never read here (only @throws is, and parameter PHPDocs come from stubPhpDocProvider).

I verified this empirically rather than by reading alone, using the PR's e2e fixture at the pre-fix commit 6c642f1bf:

  • as-is → 4 argument.type errors (str_contains/str_starts_with/str_ends_with, positional and named);
  • with just that return null block deleted → 0 errors, only the dumpType probe output.

So: mockery/mockery 1.6.13 is the change that made CI go red; 326c6ec (2.1.29) is the phpstan-src change that made a guarded userland declaration able to shadow a native signature at all, and that's what this PR narrows — the polyfill-in-path heuristic never covered library/helpers.php.

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.

CI: str_* functions regression

2 participants