Skip to content

Date native functions and methods by the versioned callmaps - #12009

Merged
danog merged 4 commits into
vimeo:6.xfrom
danog:callmap-symbol-availability
Sep 28, 2026
Merged

danog merged 4 commits into
vimeo:6.xfrom
danog:callmap-symbol-availability

Conversation

@danog

@danog danog commented Sep 28, 2026 •

Copy link
Copy Markdown
Collaborator

Stacked on #12010 (version guards); the first commit is #12010. Builds on #12006 (merged).

With #12010, guarded uses are not reported, e.g. PHP_VERSION_ID >= 80000 ? fdiv(...) : ..., if (PHP_VERSION_ID < 80300) { return new ReflectionMethod($m); } return ReflectionMethod::createFromMethodName($m);, function_exists()/method_exists().

Summary

The callmaps are versioned (dictionaries/CallMap_70.php … CallMap_85.php). A native function or method without a stub @since is now dated by the first callmap it appears in, and reported when used below that version. On 6.x these are silently accepted at phpVersion="7.4", because Psalm resolves them through the reflection of the PHP it runs on:

Symbol Introduced in
str_contains(), fdiv() 8.0
array_is_list() 8.1
array_find() 8.4
array_first() 8.5
ReflectionClass::isEnum() 8.1

Filtering extension noise

The callmaps are generated with PECL extensions installed, and those extensions' versions are unrelated to the PHP version. For example, hundreds of Swoole, Redis, MongoDB, uv, ds and ssh2 keys "first appear" at arbitrary PHP versions. So only extensions bundled with PHP are dated, and a bundled extension reports PHP_VERSION as its own version. These symbols are reachable only through the runtime's reflection anyway, so the runtime answers that question.

The version is the first appearance across all callmaps, not the next one after the analysed version. That way the few keys with gaps in the generated maps don't produce false positives.

Cost

A first-appearance index (about 25k keys) is built once, lazily. That only happens the first time a native symbol from a bundled extension is missing from the analysed version's callmap. Each callmap takes about 14 ms to load.

Polyfills

  • function_exists('x') in a conditional now evaluates against the analysed version when x is dated by the callmaps. Before, a runtime-native function made Psalm skip the body of if (!function_exists('str_contains')) { function str_contains(…) }, so symfony/polyfill and project polyfills were never scanned.
  • A project polyfill of such a function is no longer a DuplicateFunction ("already defined as a core function").
  • A function declared in any scanned PHP file (project or autoloaded, not a stub) counts as available. The native stub or reflection storage takes precedence over that declaration, so the scanned files are asked directly, and only on the report path.

Checked on scratch projects at 7.4:

  • with symfony/polyfill-php80: nothing is reported for str_contains/str_starts_with, while array_is_list (8.1) is
  • a project fdiv polyfill in one file, used from others: no reports, with 1 or 4 threads

Known gaps

🤖 Generated with Claude Code

@danog danog added the release:feature The PR will be included in 'Features' section of the release notes label Sep 28, 2026

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.

Fix All in Cursor

Comment @cursor review or bugbot run to trigger another review on this PR

Reviewed by Cursor Bugbot for commit fcb47be. Configure here.

// stub (the scanner only lets it through below that version)
$is_polyfill = $duplicate_storage->stubbed
&& $duplicate_storage->since_php_version_id !== null
&& $storage->user_defined;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Polyfill merge ignores analysis version

Medium Severity

In ClassLikeStorageProvider::addMore, $is_polyfill treats any user-defined class as replacing a versioned native stub whenever the stub has since_php_version_id, without comparing analysis_php_version_id to that version. The inline scanner only allows that replacement below the introduced version, so parallel scan merges can overwrite the native stub at the analysed version or newer and skip DuplicateClass.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit fcb47be. Configure here.

@danog
danog force-pushed the callmap-symbol-availability branch from fcb47be to 1e5a585 Compare September 28, 2026 12:29
@danog
danog force-pushed the callmap-symbol-availability branch 2 times, most recently from 076167f to 4322d4e Compare September 28, 2026 13:03
danog and others added 4 commits September 28, 2026 15:15
A native symbol newer than the analysed PHP version is not reported where a
guard proves the running PHP has it:

- PHP_VERSION_ID comparisons now narrow the constant through a pseudo-variable
  in the context, so if/else, early returns, ternaries and && all apply, e.g.
  after `if (PHP_VERSION_ID < 80300) { return ...; }`.
- function_exists(), class_exists(), interface_exists(), enum_exists() and
  method_exists() on such a symbol assert PHP_VERSION_ID >= the version that
  introduced it.

The class, method, function and property checks compare against the lowest
PHP version the branch runs on (Codebase::getGuardedPhpVersionId()) instead of
the analysed one. Type signatures and docblocks can't be guarded and are
unchanged.

Co-Authored-By: Claude Opus 5.5 
A native function or method without a stub `@since` is now dated by the first
per-version callmap it appears in, so e.g. str_contains() (8.0), array_is_list()
(8.1), array_find() (8.4) or ReflectionClass::isEnum() (8.1) are reported when
used below that version. Psalm otherwise resolves them through the reflection of
the PHP it runs on and silently accepts them.

- Only symbols of extensions bundled with PHP are dated: the callmaps are
  generated with PECL extensions (swoole, redis, mongodb, ...) whose versions are
  unrelated to the PHP version. A bundled extension reports PHP's own version.
- The first-appearance index is built once, lazily, on the first native symbol
  missing from the analysed version's callmap.
- Polyfills count as available. `function_exists()` on such a function now
  evaluates against the analysed version, so the body of a polyfill's
  `if (!function_exists(...))` is scanned (and not a DuplicateFunction), and a
  function declared in any scanned PHP file is not reported.

Co-Authored-By: Claude Opus 5.5 
Co-Authored-By: Claude Opus 5.5 
str_contains()/str_starts_with()/str_ends_with(), Reflection*::getAttributes()
and SimpleXMLElement iteration are PHP 8.0 APIs, now reported when these tests
ran at the default 7.4. Also match the new message tests with a word boundary.

Co-Authored-By: Claude Opus 5.5 
@danog
danog force-pushed the callmap-symbol-availability branch from 4322d4e to f276a65 Compare September 28, 2026 13:16
@danog
danog merged commit be20afa into vimeo:6.x Sep 28, 2026
61 checks passed
danog added a commit that referenced this pull request Sep 28, 2026
…on cached runs

#12009 stopped an autoloaded polyfill of a native function the analysed PHP
version predates (e.g. symfony/polyfill-php84's array_any) from replacing the
native signature, but only on a fresh scan. On a warm cache the file storages
are restored by the Scanner, and a rescan of an already-stored function goes
through a separate branch; both still registered the polyfill globally, so e.g.
the native purity of array_any was lost (ImpureFunctionCall in Psalm's own
Atomic.php on every cached run).

Co-Authored-By: Claude Opus 5.5 
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

release:feature The PR will be included in 'Features' section of the release notes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant