clean install

This commit is contained in:
2024-12-10 15:08:16 +01:00
commit e14eb2d8fd
31193 changed files with 3555714 additions and 0 deletions

View File

@@ -0,0 +1,148 @@
version: 2.1
defaults: &defaults
docker:
- image: circleci/php:7.4-cli
working_directory: ~/repo
aliases:
- &composer-cache
v4-composer-cache
commands:
start-project:
steps:
- run: sudo apt-get update && sudo apt-get install -y libpng-dev libjpeg62-turbo-dev
- run:
name: Install PHP Extensions
command: sudo docker-php-ext-install gd
- run:
name: Disable Xdebug PHP extension
command: sudo rm /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini
- checkout
- restore_cache:
keys:
- *composer-cache
install-dependencies:
steps:
- run: composer install -n --prefer-dist
- save_cache:
key: *composer-cache
paths:
- ~/.composer/cache
create-drupal-project:
parameters:
project:
type: string
default: 'drupal/recommended-project:^9.0@alpha'
steps:
- run: composer create-project << parameters.project >> /tmp/drupal --no-interaction --prefer-dist --ignore-platform-reqs
- run: composer require zaporylie/composer-drupal-optimizations:^1.1 --dev --working-dir=/tmp/drupal
local-require:
steps:
- run:
name: Add as local
command: |
cd /tmp/drupal
composer require --dev drupal/core-dev:^9.0
composer config repositories.1 '{"type": "path", "url": "'${CIRCLE_WORKING_DIRECTORY}'", "options": { "symlink": false }}'
composer require --dev mglaman/phpstan-drupal "*" phpstan/extension-installer
cat composer.json
cp ~/repo/tests/fixtures/config/drupal-phpstan.neon /tmp/drupal/phpstan.neon
./vendor/bin/phpstan --version
jobs:
build:
<<: *defaults
steps:
- start-project
- install-dependencies
- run:
name: phpspec/prophecy-phpunit fix
command: composer require --dev phpspec/prophecy-phpunit:^2
- run:
name: CodeSniffer
command: ./vendor/bin/phpcs src
- run:
name: PHPStan Analyze
command: php -dmemory_limit=-1 vendor/bin/phpstan.phar
- run:
name: PHPUnit
command: ./vendor/bin/phpunit
test_drupal:
<<: *defaults
steps:
- start-project
- create-drupal-project:
project: 'drupal/legacy-project:^9.0'
- local-require
- run:
name: Run against a file
command: |
cd /tmp/drupal
./vendor/bin/phpstan analyze core/install.php --debug
- run:
name: Run against a module
command: |
cd /tmp/drupal
./vendor/bin/phpstan analyze --memory-limit=256M core/modules/dynamic_page_cache --debug
test_drupal_project:
<<: *defaults
steps:
- start-project
- create-drupal-project
- local-require
- run:
name: Run against a file
command: |
cd /tmp/drupal
./vendor/bin/phpstan analyze web/core/install.php --debug
- run:
name: Run against a module
command: |
cd /tmp/drupal
./vendor/bin/phpstan analyze --memory-limit=256M web/core/modules/dynamic_page_cache --debug
test_upgrade_status:
<<: *defaults
steps:
- start-project
- create-drupal-project:
project: 'drupal/legacy-project:^9@alpha'
- local-require
# Composer constraints prevent requiring via compser, but this helps test drupalci's phpstan build step as well.
- run:
name: Add upgrade_status
command: |
cd /tmp/drupal
composer require phpstan/phpstan-deprecation-rules drupal/git_deploy
curl -L https://ftp.drupal.org/files/projects/upgrade_status-8.x-1.x-dev.tar.gz | tar -zx -C /tmp/drupal/modules
- run:
name: Start builtin
command: php -S 127.0.0.1:8080 -t /tmp/drupal
background: true
- run:
name: Wait for web server
command: dockerize -wait http://127.0.0.1:8080 -timeout 120s
- run:
name: Upgrade Status PHPUnit
command: |
cp ~/repo/tests/fixtures/config/circleci-phpunit.xml /tmp/drupal/core/phpunit.xml
cd /tmp/drupal
./vendor/bin/phpunit -c core modules/upgrade_status --debug --stop-on-failure
workflows:
version: 2
tests:
jobs:
- build
- test_drupal
- test_drupal_project
# - test_upgrade_status
weekly:
triggers:
- schedule:
# Every thursday to run soon after patch and security updates on wednesdays.
cron: "0 0 * * 4"
filters:
branches:
only:
- master
jobs:
- build
- test_drupal
- test_drupal_project

View File

@@ -0,0 +1,3 @@
#!/usr/bin/env bash
# Note: must be run from the root of the project.
circleci config process .circleci/config.yml > .circleci/config_local.yml

View File

@@ -0,0 +1,4 @@
#!/usr/bin/env bash
# Note: must be run from the root of the project.
source .circleci/generate-local-config.sh
circleci local execute --job ${1:-build} --config .circleci/config_local.yml

View File

@@ -0,0 +1,18 @@
# This file is for unifying the coding style for different editors and IDEs
# editorconfig.org
# PHP PSR-2 Coding Standards
# http://www.php-fig.org/psr/psr-2/
root = true
[*.php]
charset = utf-8
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
indent_style = space
indent_size = 4
[*.neon]
indent_style = tab

View File

@@ -0,0 +1,12 @@
# These are supported funding model platforms
github: mglaman
patreon: # Replace with a single Patreon username
open_collective: phpstan-drupal
tidelift: "packagist/mglaman/phpstan-drupal"
ko_fi: #
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
liberapay: #
issuehunt: # Replace with a single IssueHunt username
otechie: # Replace with a single Otechie username
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']

View File

@@ -0,0 +1,19 @@
---
name: Bug report
about: Something isn't working right
title: ''
labels: bug
assignees: ''
---
# Bug report
<!-- Before reporting an issue please check that you are using the latest PHPStan and phpstan-drupal versions! -->
<!-- composer update phpstan/phpstan mglaman/phpstan-drupal -->
<!-- Please describe your problem here. -->
### Code snippet that reproduces the problem
<!-- Code example or link to public code scanned -->

View File

@@ -0,0 +1,4 @@
contact_links:
- name: Support question ❓
url: https://github.com/mglaman/phpstan-drupal/discussions/new/choose
about: Please open a new discussion instead. Thank you.

View File

@@ -0,0 +1,12 @@
---
name: Feature request
about: It'd be great if...
title: ''
labels: enhancement
assignees: ''
---
# Feature request
<!-- Please provide a clear description of what problem you are trying to solve and how would you want it to be solved. -->

View File

@@ -0,0 +1,5 @@
## Security contact information
To report a security vulnerability, please use the
[Tidelift security contact](https://tidelift.com/security).
Tidelift will coordinate the fix and disclosure.

View File

@@ -0,0 +1,6 @@
version: 2
updates:
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"

View File

@@ -0,0 +1,260 @@
name: Tests
on:
push:
branches: [main]
pull_request:
branches: [main]
schedule:
- cron: 0 0 * * *
jobs:
lint:
runs-on: "ubuntu-latest"
name: "Linting | PHP ${{ matrix.php-version }}"
strategy:
matrix:
php-version:
- "8.1"
- "8.2"
- "8.3"
steps:
- name: "Checkout"
uses: "actions/checkout@v4"
- name: "Install PHP"
uses: "shivammathur/setup-php@v2"
with:
coverage: "none"
php-version: "${{ matrix.php-version }}"
tools: composer:v2
extensions: dom, curl, libxml, mbstring, zip, pdo, mysql, pdo_mysql, gd
- name: "Install dependencies"
uses: "ramsey/composer-install@v3"
- name: "PHPCS"
run: "php vendor/bin/phpcs"
- name: "PHPStan"
run: "php vendor/bin/phpstan analyze"
tests:
continue-on-error: ${{ matrix.experimental }}
runs-on: "ubuntu-latest"
name: "Tests | PHP ${{ matrix.php-version }} | Drupal ${{ matrix.drupal }}"
strategy:
matrix:
experimental: [false]
php-version:
- "8.1"
- "8.2"
- "8.3"
drupal:
- "^10"
include:
- php-version: "8.1"
drupal: "^9.0"
experimental: false
- php-version: "8.3"
drupal: "11.x-dev"
experimental: true
steps:
- name: "Checkout"
uses: "actions/checkout@v4"
- name: "Install PHP"
uses: "shivammathur/setup-php@v2"
with:
coverage: "xdebug"
php-version: "${{ matrix.php-version }}"
tools: composer:v2
extensions: dom, curl, libxml, mbstring, zip, pdo, mysql, pdo_mysql, gd
- name: "Set drupal/core to ${{ matrix.drupal }}"
run: "composer require drupal/core-recommended:${{ matrix.drupal }} --with-all-dependencies --dev --no-update"
- name: "Add phpspec/prophecy-phpunit"
run: "composer require phpspec/prophecy-phpunit:^2 --dev --no-update"
if: ${{ matrix.drupal == '^9.0' }}
- name: "Install dependencies"
uses: "ramsey/composer-install@v3"
- name: "PHPUnit"
run: "php vendor/bin/phpunit"
build_integration:
needs:
- lint
- tests
continue-on-error: ${{ matrix.experimental }}
runs-on: "ubuntu-latest"
name: "Build Integration | PHP ${{ matrix.php-version }} | Drupal ${{ matrix.drupal }}"
strategy:
matrix:
experimental: [false]
php-version:
- "8.1"
- "8.2"
drupal:
- "^10"
include:
- php-version: "8.1"
drupal: "^9.0"
experimental: false
- php-version: "8.3"
drupal: "11.x-dev"
experimental: true
steps:
- name: "Checkout"
uses: "actions/checkout@v4"
- name: "Install PHP"
uses: "shivammathur/setup-php@v2"
with:
coverage: "none"
php-version: "${{ matrix.php-version }}"
tools: composer:v2
extensions: dom, curl, libxml, mbstring, zip, pdo, mysql, pdo_mysql, bcmath, gd, exif, iconv
- name: Setup Drupal
uses: bluehorndigital/setup-drupal@v1.1.0
with:
version: ${{ matrix.drupal }}
path: ~/drupal
- name: "set the version alias for self"
run: |
if [ "${{ github.event_name }}" == 'pull_request' ]; then
echo "VERSION_ALIAS=dev-"${{ github.sha }}"" >> $GITHUB_OUTPUT
else
echo "VERSION_ALIAS=dev-main" >> $GITHUB_OUTPUT
fi
id: branch_alias
- name: "require phpstan-drupal"
run: |
cd ~/drupal
COMPOSER_MEMORY_LIMIT=-1 composer require mglaman/phpstan-drupal "${{ steps.branch_alias.outputs.VERSION_ALIAS }} as 1.2.99" phpstan/extension-installer --with-all-dependencies
cp $GITHUB_WORKSPACE/tests/fixtures/config/drupal-phpstan.neon phpstan.neon
- name: "Test core/install.php"
run: |
cd ~/drupal
./vendor/bin/phpstan analyze web/core/install.php --debug
- name: "Test BrowserTestBase is autoloaded"
run: |
cd ~/drupal
./vendor/bin/phpstan analyze web/core/modules/dynamic_page_cache | grep -q "Class Drupal\Tests\BrowserTestBase not found and could not be autoloaded." && false || true
- name: "Verify test fixtures are ignored."
run: |
cd ~/drupal
./vendor/bin/phpstan analyze web/core/modules/migrate_drupal --no-progress | grep -q "tests/fixtures" && false || true
- name: 'Check "Cannot redeclare token_theme() due to blazy_test.module"'
if: ${{ matrix.drupal != '11.x-dev' }}
run: |
cd ~/drupal
COMPOSER_MEMORY_LIMIT=-1 composer require drupal/token drupal/blazy
./vendor/bin/phpstan analyze web/modules/contrib/blazy --no-progress || if (($? == 255)); then false; else true; fi
COMPOSER_MEMORY_LIMIT=-1 composer remove drupal/token drupal/blazy
- name: 'Check "Cannot redeclare video_embed_media_media_bundle_insert()"'
if: ${{ matrix.drupal != '11.x-dev' }}
run: |
cd ~/drupal
COMPOSER_MEMORY_LIMIT=-1 composer require drupal/video_embed_field drupal/slick
./vendor/bin/phpstan analyze web/modules/contrib --no-progress || if (($? == 255)); then false; else true; fi
COMPOSER_MEMORY_LIMIT=-1 composer remove drupal/video_embed_field drupal/slick
build_integration_no_phpunit:
needs:
- lint
- tests
continue-on-error: ${{ matrix.experimental }}
runs-on: "ubuntu-latest"
name: "Build Integration (No PHPUnit) | PHP ${{ matrix.php-version }} | Drupal ${{ matrix.drupal }}"
strategy:
matrix:
experimental: [false]
php-version:
- "8.1"
- "8.2"
drupal:
- "^10"
include:
- php-version: "8.1"
drupal: "^9.0"
experimental: false
- php-version: "8.3"
drupal: "11.x-dev"
experimental: true
steps:
- name: "Checkout"
uses: "actions/checkout@v4"
- name: "Install PHP"
uses: "shivammathur/setup-php@v2"
with:
coverage: "none"
php-version: "${{ matrix.php-version }}"
tools: composer:v2
extensions: dom, curl, libxml, mbstring, zip, pdo, mysql, pdo_mysql, bcmath, gd, exif, iconv
- name: Setup Drupal
uses: bluehorndigital/setup-drupal@v1.1.0
with:
version: ${{ matrix.drupal }}
path: ~/drupal
- name: "Remove PHPUnit"
run: |
cd ~/drupal
composer --dev remove phpspec/prophecy-phpunit drupal/core-dev
- name: "require phpstan-drupal"
run: |
cd ~/drupal
COMPOSER_MEMORY_LIMIT=-1 composer require mglaman/phpstan-drupal *@dev
cp $GITHUB_WORKSPACE/tests/fixtures/config/drupal-no-dev-phpstan.neon phpstan.neon
- name: "Test core/install.php"
run: |
cd ~/drupal
./vendor/bin/phpstan analyze web/core/install.php --debug
- name: "Test no crash"
run: |
cd ~/drupal
./vendor/bin/phpstan analyze web/core/modules/dynamic_page_cache --debug
core_baseline:
needs:
- lint
- tests
continue-on-error: true
runs-on: "ubuntu-latest"
name: "Drupal core HEAD baseline check"
steps:
- name: "Checkout"
uses: "actions/checkout@v4"
- name: "set the version alias for self"
run: |
if [ "${{ github.event_name }}" == 'pull_request' ]; then
echo "VERSION_ALIAS=dev-"${{ github.sha }}"" >> $GITHUB_OUTPUT
else
echo "VERSION_ALIAS=dev-main" >> $GITHUB_OUTPUT
fi
id: branch_alias
- name: determine phpstan cache directory
run: echo PHPSTAN_TMP_DIR=$(php -r "print sys_get_temp_dir() . '/phpstan';") >> $GITHUB_OUTPUT
id: phpstan_tmp_dir
- name: cache phpstan
uses: actions/cache@v4
with:
path: ${{ steps.phpstan_tmp_dir.outputs.PHPSTAN_TMP_DIR }}
key: ${{ runner.os }}-phpstan-core-baseline
restore-keys: ${{ runner.os }}-phpstan-core-baseline
- name: "Install PHP"
uses: "shivammathur/setup-php@v2"
with:
coverage: "none"
php-version: "8.3"
tools: composer:v2
extensions: dom, curl, libxml, mbstring, zip, pdo, mysql, pdo_mysql, gd, apcu
- name: "Checkout Drupal core"
run: |
cd ${{ runner.temp }}
git clone https://git.drupalcode.org/project/drupal.git
cd drupal
composer config repositories.0 composer https://packages.drupal.org/8
composer config repositories.1 path $GITHUB_WORKSPACE
- name: "Install Drupal core dependencies"
uses: "ramsey/composer-install@v3"
with:
working-directory: "${{ runner.temp }}/drupal"
- name: "require phpstan-drupal"
run: |
cd ${{ runner.temp }}/drupal
composer require --dev mglaman/phpstan-drupal "${{ steps.branch_alias.outputs.VERSION_ALIAS }} as 1.1.99" --with-all-dependencies
- name: "Check baseline"
run: |
cd ${{ runner.temp }}/drupal
./vendor/bin/phpstan analyze --configuration=core/phpstan.neon.dist

View File

@@ -0,0 +1,36 @@
name: PHPStan other versions
on:
push:
branches: [main]
pull_request:
branches: [main]
schedule:
- cron: 0 0 * * *
jobs:
dev:
runs-on: "ubuntu-latest"
name: "PHPStan ${{ matrix.phpstan }}"
strategy:
matrix:
phpstan:
- '1.11.x-dev'
steps:
- name: "Checkout"
uses: "actions/checkout@v4"
- name: "Install PHP"
uses: "shivammathur/setup-php@v2"
with:
coverage: "none"
php-version: 8.1
tools: composer:v2
extensions: dom, curl, libxml, mbstring, zip, pdo, mysql, pdo_mysql, gd
- name: "Bump PHPStan"
run: "composer require --no-update phpstan/phpstan:${{ matrix.phpstan }}"
- name: "Add phpspec/prophecy-phpunit"
run: "composer require phpspec/prophecy-phpunit:^2 --dev --no-update"
- name: "Install dependencies"
run: "composer update --no-progress --prefer-dist"
- name: "PHPStan"
run: "php vendor/bin/phpstan analyze"
- name: "PHPUnit"
run: "php vendor/bin/phpunit"

View File

@@ -0,0 +1,24 @@
name: Post release
# More triggers
# https://docs.github.com/en/actions/learn-github-actions/events-that-trigger-workflows#release
on:
release:
types: [published]
jobs:
tweet:
runs-on: ubuntu-latest
steps:
- uses: Eomm/why-don-t-you-tweet@v2
if: ${{ !github.event.repository.private }}
with:
# GitHub event payload
# https://docs.github.com/en/developers/webhooks-and-events/webhooks/webhook-events-and-payloads#release
tweet-message: "New release: phpstan-drupal ${{ github.event.release.tag_name }} ${{ github.event.release.html_url }} #drupal"
env:
# Get your tokens from https://developer.twitter.com/apps
TWITTER_CONSUMER_API_KEY: ${{ secrets.TWITTER_CONSUMER_API_KEY }}
TWITTER_CONSUMER_API_SECRET: ${{ secrets.TWITTER_CONSUMER_API_SECRET }}
TWITTER_ACCESS_TOKEN: ${{ secrets.TWITTER_ACCESS_TOKEN }}
TWITTER_ACCESS_TOKEN_SECRET: ${{ secrets.TWITTER_ACCESS_TOKEN_SECRET }}

View File

@@ -0,0 +1,21 @@
name: Toot release
# More triggers
# https://docs.github.com/en/actions/learn-github-actions/events-that-trigger-workflows#release
on:
release:
types: [published]
jobs:
toot:
runs-on: ubuntu-latest
steps:
- uses: cbrgm/mastodon-github-action@v2
if: ${{ !github.event.repository.private }}
with:
# GitHub event payload
# https://docs.github.com/en/developers/webhooks-and-events/webhooks/webhook-events-and-payloads#release
message: "New release: phpstan-drupal ${{ github.event.release.tag_name }} ${{ github.event.release.html_url }} #drupal"
env:
MASTODON_URL: https://phpc.social
MASTODON_ACCESS_TOKEN: ${{ secrets.MASTODON_ACCESS_TOKEN }}

View File

@@ -0,0 +1,128 @@
# Contributor Covenant Code of Conduct
## Our Pledge
We as members, contributors, and leaders pledge to make participation in our
community a harassment-free experience for everyone, regardless of age, body
size, visible or invisible disability, ethnicity, sex characteristics, gender
identity and expression, level of experience, education, socio-economic status,
nationality, personal appearance, race, religion, or sexual identity
and orientation.
We pledge to act and interact in ways that contribute to an open, welcoming,
diverse, inclusive, and healthy community.
## Our Standards
Examples of behavior that contributes to a positive environment for our
community include:
* Demonstrating empathy and kindness toward other people
* Being respectful of differing opinions, viewpoints, and experiences
* Giving and gracefully accepting constructive feedback
* Accepting responsibility and apologizing to those affected by our mistakes,
and learning from the experience
* Focusing on what is best not just for us as individuals, but for the
overall community
Examples of unacceptable behavior include:
* The use of sexualized language or imagery, and sexual attention or
advances of any kind
* Trolling, insulting or derogatory comments, and personal or political attacks
* Public or private harassment
* Publishing others' private information, such as a physical or email
address, without their explicit permission
* Other conduct which could reasonably be considered inappropriate in a
professional setting
## Enforcement Responsibilities
Community leaders are responsible for clarifying and enforcing our standards of
acceptable behavior and will take appropriate and fair corrective action in
response to any behavior that they deem inappropriate, threatening, offensive,
or harmful.
Community leaders have the right and responsibility to remove, edit, or reject
comments, commits, code, wiki edits, issues, and other contributions that are
not aligned to this Code of Conduct, and will communicate reasons for moderation
decisions when appropriate.
## Scope
This Code of Conduct applies within all community spaces, and also applies when
an individual is officially representing the community in public spaces.
Examples of representing our community include using an official e-mail address,
posting via an official social media account, or acting as an appointed
representative at an online or offline event.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported to the community leaders responsible for enforcement at
matt@mglaman.dev.
All complaints will be reviewed and investigated promptly and fairly.
All community leaders are obligated to respect the privacy and security of the
reporter of any incident.
## Enforcement Guidelines
Community leaders will follow these Community Impact Guidelines in determining
the consequences for any action they deem in violation of this Code of Conduct:
### 1. Correction
**Community Impact**: Use of inappropriate language or other behavior deemed
unprofessional or unwelcome in the community.
**Consequence**: A private, written warning from community leaders, providing
clarity around the nature of the violation and an explanation of why the
behavior was inappropriate. A public apology may be requested.
### 2. Warning
**Community Impact**: A violation through a single incident or series
of actions.
**Consequence**: A warning with consequences for continued behavior. No
interaction with the people involved, including unsolicited interaction with
those enforcing the Code of Conduct, for a specified period of time. This
includes avoiding interactions in community spaces as well as external channels
like social media. Violating these terms may lead to a temporary or
permanent ban.
### 3. Temporary Ban
**Community Impact**: A serious violation of community standards, including
sustained inappropriate behavior.
**Consequence**: A temporary ban from any sort of interaction or public
communication with the community for a specified period of time. No public or
private interaction with the people involved, including unsolicited interaction
with those enforcing the Code of Conduct, is allowed during this period.
Violating these terms may lead to a permanent ban.
### 4. Permanent Ban
**Community Impact**: Demonstrating a pattern of violation of community
standards, including sustained inappropriate behavior, harassment of an
individual, or aggression toward or disparagement of classes of individuals.
**Consequence**: A permanent ban from any sort of public interaction within
the community.
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
version 2.0, available at
https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
Community Impact Guidelines were inspired by [Mozilla's code of conduct
enforcement ladder](https://github.com/mozilla/diversity).
[homepage]: https://www.contributor-covenant.org
For answers to common questions about this code of conduct, see the FAQ at
https://www.contributor-covenant.org/faq. Translations are available at
https://www.contributor-covenant.org/translations.

21
vendor/mglaman/phpstan-drupal/LICENSE vendored Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2018 Matt Glaman
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

217
vendor/mglaman/phpstan-drupal/README.md vendored Normal file
View File

@@ -0,0 +1,217 @@
# phpstan-drupal
[![Tests](https://github.com/mglaman/phpstan-drupal/actions/workflows/php.yml/badge.svg)](https://github.com/mglaman/phpstan-drupal/actions/workflows/php.yml) [![CircleCI](https://circleci.com/gh/mglaman/phpstan-drupal.svg?style=svg)](https://circleci.com/gh/mglaman/phpstan-drupal)
Extension for [PHPStan](https://phpstan.org/) to allow analysis of Drupal code.
PHPStan is able to [discover symbols](https://phpstan.org/user-guide/discovering-symbols) by using autoloading provided
by Composer. However, Drupal does not provide autoloading information for modules and themes. This project registers
those namespaces so that PHPStan can properly discover symbols in your Drupal code base automatically.
## Sponsors
<a href="https://www.undpaul.de/"><img src="https://www.undpaul.de/themes/custom/undpaul3/logo.svg" alt="undpaul" width="250" /></a> <a href="https://www.optasy.com/"><img src="https://optasy.com/themes/custom/optasy/img/logo_optasy.png" alt="Optasy" width="250"></a> <a href="https://www.fame.fi/"><img src="https://www.fame.fi/assets/images/fame-logo.png" alt="Fame Helsinki" width="250" ></a>
[Would you like to sponsor?](https://github.com/sponsors/mglaman)
## Usage
When you are using [`phpstan/extension-installer`](https://github.com/phpstan/extension-installer), `phpstan.neon` will be automatically included.
<details>
<summary>Manual installation</summary>
If you don't want to use `phpstan/extension-installer`, include `extension.neon` in your project's PHPStan config:
```
includes:
- vendor/mglaman/phpstan-drupal/extension.neon
```
To include Drupal specific analysis rules, include this file:
```
includes:
- vendor/mglaman/phpstan-drupal/rules.neon
```
</details>
## Getting help
Ask for assistance in the [discussions](https://github.com/mglaman/phpstan-drupal/discussions) or [#phpstan](https://drupal.slack.com/archives/C033S2JUMLJ) channel on Drupal Slack.
## Excluding tests from analysis
To exclude tests from analysis, add the following parameter
```
parameters:
excludePaths:
- *Test.php
- *TestBase.php
```
## Deprecation testing
This project depends on `phpstan/phpstan-deprecation-rules` which adds deprecation rules. We provide Drupal-specific
deprecated scope resolvers.
To only handle deprecation testing, use a `phpstan.neon` like this:
```
parameters:
customRulesetUsed: true
reportUnmatchedIgnoredErrors: false
# Ignore phpstan-drupal extension's rules.
ignoreErrors:
- '#\Drupal calls should be avoided in classes, use dependency injection instead#'
- '#Plugin definitions cannot be altered.#'
- '#Missing cache backend declaration for performance.#'
- '#Plugin manager has cache backend specified but does not declare cache tags.#'
includes:
- vendor/mglaman/phpstan-drupal/extension.neon
- vendor/phpstan/phpstan-deprecation-rules/rules.neon
```
To disable deprecation rules while using `phpstan/extension-installer`, you can do the following:
```json
{
"extra": {
"phpstan/extension-installer": {
"ignore": [
"phpstan/phpstan-deprecation-rules"
]
}
}
}
```
See the `extension-installer` documentation for more information: https://github.com/phpstan/extension-installer#ignoring-a-particular-extension
## Adapting to your project
### Customizing rules
#### Disabling checks for extending `@internal` classes
You can disable the `ClassExtendsInternalClassRule` rule by adding the following to your `phpstan.neon`:
```neon
parameters:
drupal:
rules:
classExtendsInternalClassRule: false
```
### Entity storage mappings.
The `EntityTypeManagerGetStorageDynamicReturnTypeExtension` service helps map dynamic return types. This inspects the
passed entity type ID and tries to return a known storage class, besides the default `EntityStorageInterface`. The
default mapping can be found in `extension.neon`. For example:
```
parameters:
drupal:
entityMapping:
block:
class: Drupal\block\Entity\Block
storage: Drupal\Core\Config\Entity\ConfigEntityStorage
node:
class: Drupal\node\Entity\Node
storage: Drupal\node\NodeStorage
taxonomy_term:
class: Drupal\taxonomy\Entity\Term
storage: Drupal\taxonomy\TermStorage
user:
class: Drupal\user\Entity\User
storage: Drupal\user\UserStorage
```
To add support for custom entities, you may add the same definition in your project's `phpstan.neon`. See the following
example for adding a mapping for Search API:
```
parameters:
drupal:
entityMapping:
search_api_index:
class: Drupal\search_api\Entity\Index
storage: Drupal\search_api\Entity\SearchApiConfigEntityStorage
search_api_server:
class: Drupal\search_api\Entity\Server
storage: Drupal\search_api\Entity\SearchApiConfigEntityStorage
```
Similarly, the `EntityStorageDynamicReturnTypeExtension` service helps to determine the type of the entity which is
loaded, created etc.. when using an entity storage.
For instance when using
```php
$node = \Drupal::entityTypeManager()->getStorage('node')->create(['type' => 'page', 'title' => 'foo']);
```
It helps with knowing the type of the `$node` variable is `Drupal\node\Entity\Node`.
The default mapping can be found in `extension.neon`:
```neon
parameters:
drupal:
entityMapping:
block:
class: Drupal\block\Entity\Block
storage: Drupal\Core\Config\Entity\ConfigEntityStorage
node:
class: Drupal\node\Entity\Node
storage: Drupal\node\NodeStorage
taxonomy_term:
class: Drupal\taxonomy\Entity\Term
storage: Drupal\taxonomy\TermStorage
user:
class: Drupal\user\Entity\User
storage: Drupal\user\UserStorage
```
To add support for custom entities, you may add the same definition in your project's `phpstan.neon` likewise.
### Providing entity type mappings for a contrib module
Contributed modules can provide their own mapping that can be automatically registered with a user's code base when
they use the `phpstan/extension-installer`. The extension installer scans installed package's `composer.json` for a
value in `extra.phpstan`. This will automatically bundle the defined include that contains an entity mapping
configuration.
For example, the Paragraphs module could have the following `entity_mapping.neon` file:
```neon
parameters:
drupal:
entityMapping:
paragraph:
class: Drupal\paragraphs\Entity\Paragraph
paragraphs_type:
class: Drupal\paragraphs\Entity\ParagraphsType
```
Then in the `composer.json` for Paragraphs, the `entity_mapping.neon` would be provided as a PHPStan include
```json
{
"name": "drupal/paragraphs",
"description": "Enables the creation of Paragraphs entities.",
"type": "drupal-module",
"license": "GPL-2.0-or-later",
"require": {
"drupal/entity_reference_revisions": "~1.3"
},
"extra": {
"phpstan": {
"includes": [
"entity_mapping.neon"
]
}
}
}
```

View File

@@ -0,0 +1,10 @@
parameters:
drupal:
bleedingEdge:
checkDeprecatedHooksInApiFiles: false
checkCoreDeprecatedHooksInApiFiles: true
checkContribDeprecatedHooksInApiFiles: true
rules:
testClassSuffixNameRule: true
dependencySerializationTraitPropertyRule: true
accessResultConditionRule: true

View File

@@ -0,0 +1,78 @@
{
"name": "mglaman/phpstan-drupal",
"description": "Drupal extension and rules for PHPStan",
"license": "MIT",
"type": "phpstan-extension",
"authors": [
{
"name": "Matt Glaman",
"email": "nmd.matt@gmail.com"
}
],
"require": {
"php": "^8.1",
"phpstan/phpstan": "^1.10.56",
"phpstan/phpstan-deprecation-rules": "^1.1.4",
"symfony/finder": "^4.2 || ^5.0 || ^6.0 || ^7.0",
"symfony/yaml": "^4.2|| ^5.0 || ^6.0 || ^7.0",
"webflo/drupal-finder": "^1.3.1"
},
"require-dev": {
"behat/mink": "^1.8",
"composer/installers": "^1.9",
"drupal/core-recommended": "^10",
"drush/drush": "^10.0 || ^11 || ^12 || ^13@beta",
"phpstan/extension-installer": "^1.1",
"phpstan/phpstan-strict-rules": "^1.0",
"phpunit/phpunit": "^8.5 || ^9 || ^10 || ^11",
"slevomat/coding-standard": "^7.1",
"squizlabs/php_codesniffer": "^3.3",
"symfony/phpunit-bridge": "^4.4 || ^5.4 || ^6.0 || ^7.0"
},
"minimum-stability": "dev",
"prefer-stable": true,
"suggest": {
"phpstan/phpstan-deprecation-rules": "For catching deprecations, especially in Drupal core.",
"jangregor/phpstan-prophecy": "Provides a prophecy/prophecy extension for phpstan/phpstan.",
"phpstan/phpstan-phpunit": "PHPUnit extensions and rules for PHPStan."
},
"autoload": {
"psr-4": {
"mglaman\\PHPStanDrupal\\": "src/"
}
},
"autoload-dev": {
"psr-4": {
"mglaman\\PHPStanDrupal\\Tests\\": "tests/src/"
},
"classmap": [
"tests/src/Type/data",
"tests/src/Rules/data"
]
},
"extra": {
"branch-alias": {
"dev-main": "1.0-dev"
},
"installer-paths": {
"tests/fixtures/drupal/core": ["type:drupal-core"],
"tests/fixtures/drupal/libraries/{$name}": ["type:drupal-library"],
"tests/fixtures/drupal/modules/contrib/{$name}": ["type:drupal-module"],
"tests/fixtures/drupal/profiles/contrib/{$name}": ["type:drupal-profile"],
"tests/fixtures/drupal/themes/contrib/{$name}": ["type:drupal-theme"]
},
"phpstan": {
"includes": [
"extension.neon",
"rules.neon"
]
}
},
"config": {
"allow-plugins": {
"composer/installers": true,
"phpstan/extension-installer": true,
"dealerdirect/phpcodesniffer-composer-installer": true
}
}
}

View File

@@ -0,0 +1,18 @@
<?php declare(strict_types=1);
use mglaman\PHPStanDrupal\Drupal\DrupalAutoloader;
use PHPStan\DependencyInjection\Container;
if (!isset($container)) {
throw new \PHPStan\ShouldNotHappenException('The autoloader did not receive the container.');
}
if (!$container instanceof Container) {
throw new \PHPStan\ShouldNotHappenException('The autoloader did not receive the container.');
}
if (!defined('DRUPAL_TEST_IN_CHILD_SITE')) {
define('DRUPAL_TEST_IN_CHILD_SITE', false);
}
$drupalAutoloader = new DrupalAutoloader();
$drupalAutoloader->register($container);

View File

@@ -0,0 +1,335 @@
parameters:
bootstrapFiles:
- drupal-autoloader.php
excludePaths:
- '*.api.php'
- '*/tests/fixtures/*.php'
fileExtensions:
- module
- theme
- inc
- install
- profile
- engine
dynamicConstantNames:
- Drupal::VERSION
scanFiles:
- stubs/Twig/functions.stub
drupal:
drupal_root:
bleedingEdge:
checkDeprecatedHooksInApiFiles: false
checkCoreDeprecatedHooksInApiFiles: false
checkContribDeprecatedHooksInApiFiles: false
rules:
testClassSuffixNameRule: false
dependencySerializationTraitPropertyRule: false
accessResultConditionRule: false
classExtendsInternalClassRule: true
entityMapping:
aggregator_feed:
class: Drupal\aggregator\Entity\Feed
storage: Drupal\aggregator\FeedStorage
aggregator_item:
class: Drupal\aggregator\Entity\Item
storage: Drupal\aggregator\ItemStorage
block:
class: Drupal\block\Entity\Block
storage: Drupal\Core\Config\Entity\ConfigEntityStorage
block_content:
class: Drupal\block_content\Entity\BlockContent
storage: Drupal\Core\Entity\Sql\SqlContentEntityStorage
block_content_type:
class: Drupal\block_content\Entity\BlockContentType
storage: Drupal\Core\Config\Entity\ConfigEntityStorage
comment_type:
class: Drupal\comment\Entity\CommentType
storage: Drupal\Core\Config\Entity\ConfigEntityStorage
comment:
class: Drupal\comment\Entity\Comment
storage: Drupal\comment\CommentStorage
contact_form:
class: Drupal\contact\Entity\ContactForm
storage: Drupal\Core\Config\Entity\ConfigEntityStorage
contact_message:
class: Drupal\contact\Entity\Message
storage: Drupal\Core\Entity\ContentEntityNullStorage
content_moderation_state:
class: Drupal\content_moderation\Entity\ContentModerationState
storage: Drupal\Core\Entity\Sql\SqlContentEntityStorage
editor:
class: Drupal\editor\Entity\Editor
storage: Drupal\Core\Config\Entity\ConfigEntityStorage
field_config:
class: Drupal\field\Entity\FieldConfig
storage: Drupal\field\FieldConfigStorage
field_storage_config:
class: Drupal\field\Entity\FieldStorageConfig
storage: Drupal\field\FieldStorageConfigStorage
file:
class: Drupal\file\Entity\File
storage: Drupal\file\FileStorage
filter_format:
class: Drupal\filter\Entity\FilterFormat
storage: Drupal\Core\Config\Entity\ConfigEntityStorage
image_style:
class: Drupal\image\Entity\ImageStyle
storage: Drupal\image\ImageStyleStorage
imce_profile:
class: Drupal\imce\Entity\ImceProfile
storage: Drupal\Core\Config\Entity\ConfigEntityStorage
configurable_language:
class: Drupal\language\Entity\ConfigurableLanguage
storage: Drupal\Core\Config\Entity\ConfigEntityStorage
language_content_settings:
class: Drupal\language\Entity\ContentLanguageSettings
storage: Drupal\Core\Config\Entity\ConfigEntityStorage
media_type:
class: Drupal\media\Entity\MediaType
storage: Drupal\Core\Config\Entity\ConfigEntityStorage
media:
class: Drupal\media\Entity\Media
storage: Drupal\media\MediaStorage
menu_link_content:
class: Drupal\menu_link_content\Entity\MenuLinkContent
storage: \Drupal\menu_link_content\MenuLinkContentStorage
metatag_defaults:
class: Drupal\metatag\Entity\MetatagDefaults
storage: Drupal\Core\Config\Entity\ConfigEntityStorage
node_type:
class: Drupal\node\Entity\NodeType
storage: Drupal\Core\Config\Entity\ConfigEntityStorage
node:
class: Drupal\node\Entity\Node
storage: Drupal\node\NodeStorage
path_alias:
class: Drupal\path_alias\Entity\PathAlias
storage: Drupal\path_alias\PathAliasStorage
rdf_mapping:
class: Drupal\rdf\Entity\RdfMapping
storage: Drupal\Core\Config\Entity\ConfigEntityStorage
responsive_image_style:
class: Drupal\responsive_image\Entity\ResponsiveImageStyle
storage: Drupal\Core\Config\Entity\ConfigEntityStorage
search_page:
class: Drupal\search\Entity\SearchPage
storage: Drupal\Core\Config\Entity\ConfigEntityStorage
search_api_server:
class: Drupal\search_api\Entity\Server
storage: Drupal\search_api\Entity\SearchApiConfigEntityStorage
search_api_index:
class: Drupal\search_api\Entity\Index
storage: Drupal\search_api\Entity\SearchApiConfigEntityStorage
search_api_task:
class: Drupal\search_api\Entity\Task
storage: Drupal\Core\Entity\Sql\SqlContentEntityStorage
shortcut_set:
class: Drupal\shortcut\Entity\ShortcutSet
storage: Drupal\shortcut\ShortcutSetStorage
shortcut:
class: Drupal\shortcut\Entity\Shortcut
storage: Drupal\Core\Entity\Sql\SqlContentEntityStorage
action:
class: Drupal\system\Entity\Action
storage: Drupal\Core\Config\Entity\ConfigEntityStorage
menu:
class: Drupal\system\Entity\Menu
storage: Drupal\system\MenuStorage
taxonomy_term:
class: Drupal\taxonomy\Entity\Term
storage: Drupal\taxonomy\TermStorage
taxonomy_vocabulary:
class: Drupal\taxonomy\Entity\Vocabulary
storage: Drupal\taxonomy\VocabularyStorage
tour:
class: Drupal\tour\Entity\Tour
storage: Drupal\Core\Config\Entity\ConfigEntityStorage
user:
class: Drupal\user\Entity\User
storage: Drupal\user\UserStorage
user_role:
class: Drupal\user\Entity\Role
storage: Drupal\user\RoleStorage
webform:
class: Drupal\webform\Entity\Webform
storage: \Drupal\webform\WebformEntityStorage
webform_submission:
class: Drupal\webform\Entity\WebformSubmission
storage: Drupal\webform\WebformSubmissionStorage
webform_options:
class: Drupal\webform\Entity\WebformOptions
storage: \Drupal\webform\WebformOptionsStorage
workflow:
class: Drupal\workflows\Entity\Workflow
storage: Drupal\Core\Config\Entity\ConfigEntityStorage
pathauto_pattern:
class: Drupal\pathauto\Entity\PathautoPattern
storage: Drupal\Core\Config\Entity\ConfigEntityStorage
view:
class: Drupal\views\Entity\View
storage: Drupal\Core\Config\Entity\ConfigEntityStorage
date_format:
class: Drupal\Core\Datetime\Entity\DateFormat
storage: Drupal\Core\Config\Entity\ConfigEntityStorage
entity_form_mode:
class: Drupal\Core\Entity\Entity\EntityFormMode
storage: Drupal\Core\Config\Entity\ConfigEntityStorage
entity_view_display:
class: Drupal\layout_builder\Entity\LayoutBuilderEntityViewDisplay
storage: Drupal\layout_builder\Entity\LayoutBuilderEntityViewDisplayStorage
entity_form_display:
class: Drupal\Core\Entity\Entity\EntityFormDisplay
storage: Drupal\Core\Config\Entity\ConfigEntityStorage
entity_view_mode:
class: Drupal\Core\Entity\Entity\EntityViewMode
storage: Drupal\Core\Config\Entity\ConfigEntityStorage
base_field_override:
class: Drupal\Core\Field\Entity\BaseFieldOverride
storage: Drupal\Core\Field\BaseFieldOverrideStorage
parametersSchema:
drupal: structure([
drupal_root: schema(string(), nullable())
bleedingEdge: structure([
checkDeprecatedHooksInApiFiles: boolean()
checkCoreDeprecatedHooksInApiFiles: boolean()
checkContribDeprecatedHooksInApiFiles: boolean()
])
rules: structure([
testClassSuffixNameRule: boolean()
dependencySerializationTraitPropertyRule: boolean()
accessResultConditionRule: boolean()
classExtendsInternalClassRule: boolean()
])
entityMapping: arrayOf(anyOf(
structure([
class: string()
]),
structure([
class: string()
storage: string()
])
))
])
services:
-
class: mglaman\PHPStanDrupal\Drupal\ServiceMap
-
class: mglaman\PHPStanDrupal\Drupal\ExtensionMap
-
class: mglaman\PHPStanDrupal\Drupal\EntityDataRepository
arguments:
entityMapping: %drupal.entityMapping%
-
class: mglaman\PHPStanDrupal\Type\EntityTypeManagerGetStorageDynamicReturnTypeExtension
tags: [phpstan.broker.dynamicMethodReturnTypeExtension]
-
class: mglaman\PHPStanDrupal\Type\EntityStorage\EntityStorageDynamicReturnTypeExtension
tags: [phpstan.broker.dynamicMethodReturnTypeExtension]
-
class: mglaman\PHPStanDrupal\Type\EntityRepositoryReturnTypeExtension
tags: [phpstan.broker.dynamicMethodReturnTypeExtension]
-
class: mglaman\PHPStanDrupal\Type\EntityStorage\GetQueryReturnTypeExtension
tags: [phpstan.broker.dynamicMethodReturnTypeExtension]
-
class: mglaman\PHPStanDrupal\Type\ContainerDynamicReturnTypeExtension
tags: [phpstan.broker.dynamicMethodReturnTypeExtension]
-
class: mglaman\PHPStanDrupal\Type\DrupalClassResolverDynamicReturnTypeExtension
tags: [phpstan.broker.dynamicMethodReturnTypeExtension]
-
class: mglaman\PHPStanDrupal\Type\EntityQuery\EntityQueryDynamicReturnTypeExtension
tags: [phpstan.broker.dynamicMethodReturnTypeExtension]
-
class: mglaman\PHPStanDrupal\Type\EntityQuery\EntityQueryAccessCheckDynamicReturnTypeExtension
tags: [phpstan.broker.dynamicMethodReturnTypeExtension]
-
class: mglaman\PHPStanDrupal\Type\EntityAccessControlHandlerReturnTypeExtension
tags: [phpstan.broker.dynamicMethodReturnTypeExtension]
-
class: mglaman\PHPStanDrupal\Type\DrupalClassResolverDynamicStaticReturnTypeExtension
tags: [phpstan.broker.dynamicStaticMethodReturnTypeExtension]
-
class: mglaman\PHPStanDrupal\Type\DrupalServiceDynamicReturnTypeExtension
tags: [phpstan.broker.dynamicStaticMethodReturnTypeExtension]
-
class: mglaman\PHPStanDrupal\Type\DrupalStaticEntityQueryDynamicReturnTypeExtension
tags: [phpstan.broker.dynamicStaticMethodReturnTypeExtension]
-
class: mglaman\PHPStanDrupal\Reflection\EntityFieldsViaMagicReflectionExtension
tags: [phpstan.broker.propertiesClassReflectionExtension]
-
class: mglaman\PHPStanDrupal\Reflection\EntityFieldMethodsViaMagicReflectionExtension
tags: [phpstan.broker.methodsClassReflectionExtension]
-
class: mglaman\PHPStanDrupal\Drupal\DrupalStubFilesExtension
tags: [phpstan.stubFilesExtension]
-
class: mglaman\PHPStanDrupal\Type\EntityQuery\AccessCheckTypeSpecifyingExtension
tags:
- phpstan.typeSpecifier.methodTypeSpecifyingExtension
-
class: mglaman\PHPStanDrupal\DeprecatedScope\GroupLegacyScope
tags:
- phpstan.deprecations.deprecatedScopeResolver
-
class: mglaman\PHPStanDrupal\DeprecatedScope\IgnoreDeprecationsScope
tags:
- phpstan.deprecations.deprecatedScopeResolver
-
class: mglaman\PHPStanDrupal\DeprecatedScope\DeprecationHelperScope
tags:
- phpstan.deprecations.deprecatedScopeResolver

28
vendor/mglaman/phpstan-drupal/lock.yml vendored Normal file
View File

@@ -0,0 +1,28 @@
name: 'Lock Threads'
on:
schedule:
- cron: '0 0 * * *'
workflow_dispatch:
permissions:
issues: write
pull-requests: write
concurrency:
group: lock
jobs:
action:
runs-on: ubuntu-latest
steps:
- uses: dessant/lock-threads@v3
with:
issue-inactive-days: '31'
issue-comment: >
This thread has been automatically locked since there has not been
any recent activity after it was closed. Please open a new issue for
related bugs.
issue-lock-reason: 'resolved'
process-only: 'issues'
log-output: true

View File

@@ -0,0 +1,90 @@
parameters:
ignoreErrors:
-
message: """
#^Call to deprecated method locateRoot\\(\\) of class DrupalFinder\\\\DrupalFinder\\:
Will be removed in v2\\. Future usage should instantiate
a new DrupalFinder object by passing the starting path to its
constructor\\.$#
"""
count: 1
path: src/Drupal/DrupalAutoloader.php
-
message: """
#^Instantiation of deprecated class DrupalFinder\\\\DrupalFinder\\:
in drupal\\-finder\\:1\\.3\\.0 and is removed from drupal\\-finder\\:2\\.0\\.0\\.
Use \\\\DrupalFinder\\\\DrupalFinderComposerRuntime instead\\.$#
"""
count: 1
path: src/Drupal/DrupalAutoloader.php
-
message: "#^Parameter \\#1 \\$root of class mglaman\\\\PHPStanDrupal\\\\Drupal\\\\ExtensionDiscovery constructor expects string, bool\\|string given\\.$#"
count: 1
path: src/Drupal/DrupalAutoloader.php
-
message: "#^Parameter \\#1 \\$start_path of method DrupalFinder\\\\DrupalFinder\\:\\:locateRoot\\(\\) expects string, string\\|false given\\.$#"
count: 1
path: src/Drupal/DrupalAutoloader.php
-
message: "#^Property mglaman\\\\PHPStanDrupal\\\\Drupal\\\\DrupalAutoloader\\:\\:\\$drupalRoot \\(string\\) does not accept bool\\|string\\.$#"
count: 1
path: src/Drupal/DrupalAutoloader.php
-
message: "#^Doing instanceof PHPStan\\\\Type\\\\Constant\\\\ConstantArrayType is error\\-prone and deprecated\\. Use Type\\:\\:getConstantArrays\\(\\) instead\\.$#"
count: 1
path: src/Rules/Deprecations/ConditionManagerCreateInstanceContextConfigurationRule.php
-
message: "#^Doing instanceof PHPStan\\\\Type\\\\Constant\\\\ConstantStringType is error\\-prone and deprecated\\. Use Type\\:\\:getConstantStrings\\(\\) instead\\.$#"
count: 1
path: src/Rules/Deprecations/ConditionManagerCreateInstanceContextConfigurationRule.php
-
message: "#^Doing instanceof PHPStan\\\\Type\\\\Constant\\\\ConstantStringType is error\\-prone and deprecated\\. Use Type\\:\\:getConstantStrings\\(\\) instead\\.$#"
count: 2
path: src/Rules/Drupal/RenderCallbackRule.php
-
message: "#^Doing instanceof PHPStan\\\\Type\\\\Generic\\\\GenericClassStringType is error\\-prone and deprecated\\. Use Type\\:\\:isClassStringType\\(\\) and Type\\:\\:getClassStringObjectType\\(\\) instead\\.$#"
count: 1
path: src/Rules/Drupal/RenderCallbackRule.php
-
message: "#^Doing instanceof PHPStan\\\\Type\\\\IntersectionType is error\\-prone and deprecated\\.$#"
count: 1
path: src/Rules/Drupal/RenderCallbackRule.php
-
message: "#^Doing instanceof PHPStan\\\\Type\\\\ObjectType is error\\-prone and deprecated\\. Use Type\\:\\:isObject\\(\\) or Type\\:\\:getObjectClassNames\\(\\) instead\\.$#"
count: 1
path: src/Rules/Drupal/Tests/BrowserTestBaseDefaultThemeRule.php
-
message: "#^Doing instanceof PHPStan\\\\Type\\\\ObjectType is error\\-prone and deprecated\\. Use Type\\:\\:isObject\\(\\) or Type\\:\\:getObjectClassNames\\(\\) instead\\.$#"
count: 1
path: src/Type/DrupalStaticEntityQueryDynamicReturnTypeExtension.php
-
message: "#^Doing instanceof PHPStan\\\\Type\\\\ObjectType is error\\-prone and deprecated\\. Use Type\\:\\:isObject\\(\\) or Type\\:\\:getObjectClassNames\\(\\) instead\\.$#"
count: 1
path: src/Type/EntityQuery/EntityQueryDynamicReturnTypeExtension.php
-
message: "#^Doing instanceof PHPStan\\\\Type\\\\ObjectType is error\\-prone and deprecated\\. Use Type\\:\\:isObject\\(\\) or Type\\:\\:getObjectClassNames\\(\\) instead\\.$#"
count: 1
path: src/Type/EntityStorage/EntityStorageDynamicReturnTypeExtension.php
-
message: "#^Doing instanceof PHPStan\\\\Type\\\\ObjectType is error\\-prone and deprecated\\. Use Type\\:\\:isObject\\(\\) or Type\\:\\:getObjectClassNames\\(\\) instead\\.$#"
count: 1
path: src/Type/EntityStorage/GetQueryReturnTypeExtension.php
-
message: "#^Doing instanceof PHPStan\\\\Type\\\\ObjectType is error\\-prone and deprecated\\. Use Type\\:\\:isObject\\(\\) or Type\\:\\:getObjectClassNames\\(\\) instead\\.$#"
count: 1
path: src/Type/EntityTypeManagerGetStorageDynamicReturnTypeExtension.php

View File

@@ -0,0 +1,31 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/9.3/phpunit.xsd" bootstrap="vendor/autoload.php"
forceCoversAnnotation="false" beStrictAboutCoversAnnotation="true" beStrictAboutOutputDuringTests="true"
beStrictAboutTodoAnnotatedTests="true" beStrictAboutChangesToGlobalState="true" verbose="false">
<coverage processUncoveredFiles="true">
<include>
<directory suffix=".php">src</directory>
</include>
<exclude>
<directory>./vendor</directory>
<directory>./tests</directory>
</exclude>
<report>
<clover outputFile="clover.xml"/>
<text outputFile="php://stdout" showUncoveredFiles="true"/>
</report>
</coverage>
<php>
<ini name="error_reporting" value="32767"/>
<ini name="memory_limit" value="-1"/>
<!-- Drupal requires Symfony PHPUnit Bridge, silence its warnings. -->
<env name="SYMFONY_DEPRECATIONS_HELPER" value="disabled"/>
</php>
<testsuites>
<testsuite name="default">
<directory suffix="Test.php">tests/src</directory>
</testsuite>
</testsuites>
<logging/>
</phpunit>

View File

@@ -0,0 +1,42 @@
rules:
- mglaman\PHPStanDrupal\Rules\Drupal\Coder\DiscouragedFunctionsRule
- mglaman\PHPStanDrupal\Rules\Drupal\GlobalDrupalDependencyInjectionRule
- mglaman\PHPStanDrupal\Rules\Drupal\PluginManager\PluginManagerSetsCacheBackendRule
- mglaman\PHPStanDrupal\Rules\Deprecations\AccessDeprecatedConstant
- mglaman\PHPStanDrupal\Rules\Classes\PluginManagerInspectionRule
- mglaman\PHPStanDrupal\Rules\Deprecations\ConditionManagerCreateInstanceContextConfigurationRule
- mglaman\PHPStanDrupal\Rules\Drupal\RenderCallbackRule
- mglaman\PHPStanDrupal\Rules\Deprecations\StaticServiceDeprecatedServiceRule
- mglaman\PHPStanDrupal\Rules\Deprecations\GetDeprecatedServiceRule
- mglaman\PHPStanDrupal\Rules\Drupal\Tests\BrowserTestBaseDefaultThemeRule
- mglaman\PHPStanDrupal\Rules\Deprecations\ConfigEntityConfigExportRule
- mglaman\PHPStanDrupal\Rules\Deprecations\PluginAnnotationContextDefinitionsRule
- mglaman\PHPStanDrupal\Rules\Drupal\ModuleLoadInclude
- mglaman\PHPStanDrupal\Rules\Drupal\LoadIncludes
- mglaman\PHPStanDrupal\Rules\Drupal\EntityQuery\EntityQueryHasAccessCheckRule
- mglaman\PHPStanDrupal\Rules\Deprecations\SymfonyCmfRouteObjectInterfaceConstantsRule
- mglaman\PHPStanDrupal\Rules\Deprecations\SymfonyCmfRoutingInClassMethodSignatureRule
- mglaman\PHPStanDrupal\Rules\Drupal\RequestStackGetMainRequestRule
- mglaman\PHPStanDrupal\Rules\Drupal\TestClassesProtectedPropertyModulesRule
conditionalTags:
mglaman\PHPStanDrupal\Rules\Drupal\Tests\TestClassSuffixNameRule:
phpstan.rules.rule: %drupal.rules.testClassSuffixNameRule%
mglaman\PHPStanDrupal\Rules\Drupal\DependencySerializationTraitPropertyRule:
phpstan.rules.rule: %drupal.rules.dependencySerializationTraitPropertyRule%
mglaman\PHPStanDrupal\Rules\Drupal\AccessResultConditionRule:
phpstan.rules.rule: %drupal.rules.accessResultConditionRule%
mglaman\PHPStanDrupal\Rules\Classes\ClassExtendsInternalClassRule:
phpstan.rules.rule: %drupal.rules.classExtendsInternalClassRule%
services:
-
class: mglaman\PHPStanDrupal\Rules\Drupal\Tests\TestClassSuffixNameRule
-
class: mglaman\PHPStanDrupal\Rules\Drupal\DependencySerializationTraitPropertyRule
-
class: mglaman\PHPStanDrupal\Rules\Drupal\AccessResultConditionRule
arguments:
treatPhpDocTypesAsCertain: %treatPhpDocTypesAsCertain%
-
class: mglaman\PHPStanDrupal\Rules\Classes\ClassExtendsInternalClassRule

View File

View File

@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace mglaman\PHPStanDrupal\DeprecatedScope;
use Drupal\Component\Utility\DeprecationHelper;
use PHPStan\Analyser\Scope;
use PHPStan\Reflection\MethodReflection;
use PHPStan\Rules\Deprecations\DeprecatedScopeResolver;
use function class_exists;
use function count;
final class DeprecationHelperScope implements DeprecatedScopeResolver
{
public function isScopeDeprecated(Scope $scope): bool
{
if (!class_exists(DeprecationHelper::class)) {
return false;
}
$callStack = $scope->getFunctionCallStackWithParameters();
if (count($callStack) === 0) {
return false;
}
[$function, $parameter] = $callStack[0];
if (!$function instanceof MethodReflection) {
return false;
}
if ($function->getName() !== 'backwardsCompatibleCall'
|| $function->getDeclaringClass()->getName() !== DeprecationHelper::class
) {
return false;
}
return $parameter !== null && $parameter->getName() === 'deprecatedCallable';
}
}

View File

@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace mglaman\PHPStanDrupal\DeprecatedScope;
use PHPStan\Analyser\Scope;
use PHPStan\Rules\Deprecations\DeprecatedScopeResolver;
use function strpos;
final class GroupLegacyScope implements DeprecatedScopeResolver
{
public function isScopeDeprecated(Scope $scope): bool
{
if ($scope->isInClass()) {
$class = $scope->getClassReflection();
$phpDoc = $class->getResolvedPhpDoc();
if ($phpDoc !== null && strpos($phpDoc->getPhpDocString(), '@group legacy') !== false) {
return true;
}
}
$function = $scope->getFunction();
return $function !== null
&& $function->getDocComment() !== null
&& strpos($function->getDocComment(), '@group legacy') !== false;
}
}

View File

@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace mglaman\PHPStanDrupal\DeprecatedScope;
use PHPStan\Analyser\Scope;
use PHPStan\Rules\Deprecations\DeprecatedScopeResolver;
use PHPUnit\Framework\Attributes\IgnoreDeprecations;
final class IgnoreDeprecationsScope implements DeprecatedScopeResolver
{
public function isScopeDeprecated(Scope $scope): bool
{
if (!class_exists(IgnoreDeprecations::class)) {
return false;
}
if ($scope->isInClass()) {
$class = $scope->getClassReflection()->getNativeReflection();
if ($class->getAttributes(IgnoreDeprecations::class) !== []) {
return true;
}
$function = $scope->getFunction();
if ($function === null) {
return false;
}
$method = $class->getMethod($function->getName());
if ($method->getAttributes(IgnoreDeprecations::class) !== []) {
return true;
}
}
return false;
}
}

View File

@@ -0,0 +1,378 @@
<?php declare(strict_types=1);
namespace mglaman\PHPStanDrupal\Drupal;
use Composer\Autoload\ClassLoader;
use Drupal\Core\DependencyInjection\ContainerNotInitializedException;
use Drupal\Core\DrupalKernelInterface;
use Drupal\TestTools\PhpUnitCompatibility\PhpUnit8\ClassWriter;
use DrupalFinder\DrupalFinderComposerRuntime;
use Drush\Drush;
use PHPStan\DependencyInjection\Container;
use PHPUnit\Framework\Test;
use ReflectionClass;
use RuntimeException;
use Symfony\Component\Finder\Finder;
use Symfony\Component\Yaml\Yaml;
use Throwable;
use function array_map;
use function array_merge;
use function array_walk;
use function class_exists;
use function dirname;
use function file_exists;
use function in_array;
use function interface_exists;
use function is_array;
use function is_dir;
use function is_string;
use function str_replace;
use function strpos;
use function strtr;
use function trigger_error;
use function ucwords;
use function usort;
class DrupalAutoloader
{
/**
* @var \Composer\Autoload\ClassLoader
*/
private $autoloader;
/**
* @var string
*/
private $drupalRoot;
/**
* List of available modules.
*
* @var Extension[]
*/
protected $moduleData = [];
/**
* List of available themes.
*
* @var Extension[]
*/
protected $themeData = [];
/**
* @var array<array<string, string>>
*/
private $serviceMap = [];
/**
* @var array<string, string>
*/
private $serviceYamls = [];
/**
* @var array<string, string>
*/
private $serviceClassProviders = [];
/**
* @var array
*/
private $namespaces = [];
public function register(Container $container): void
{
/**
* @var array{drupal_root: string|null, bleedingEdge: array{checkDeprecatedHooksInApiFiles: bool, checkCoreDeprecatedHooksInApiFiles: bool, checkContribDeprecatedHooksInApiFiles: bool}} $drupalParams
*/
$drupalParams = $container->getParameter('drupal');
// Trigger deprecation error if drupal_root is used.
if (is_string($drupalParams['drupal_root'])) {
trigger_error('The drupal_root parameter is deprecated. Remove it from your configuration. Drupal Root is discoverd automatically.', E_USER_DEPRECATED);
}
$finder = new DrupalFinderComposerRuntime();
$drupalRoot = $finder->getDrupalRoot();
$drupalVendorRoot = $finder->getVendorDir();
if (!(is_string($drupalRoot) && is_string($drupalVendorRoot))) {
throw new RuntimeException("Unable to detect Drupal with webflo/drupal-finder.");
}
$this->drupalRoot = $drupalRoot;
$this->autoloader = include $drupalVendorRoot . '/autoload.php';
$this->serviceYamls['core'] = $drupalRoot . '/core/core.services.yml';
$this->serviceClassProviders['core'] = '\Drupal\Core\CoreServiceProvider';
$this->serviceMap['service_provider.core.service_provider'] = ['class' => $this->serviceClassProviders['core']];
// Attach synthetic services
// @see \Drupal\Core\DrupalKernel::attachSynthetic
$this->serviceMap['kernel'] = ['class' => DrupalKernelInterface::class];
$this->serviceMap['class_loader'] = ['class' => ClassLoader::class];
$extensionDiscovery = new ExtensionDiscovery($this->drupalRoot);
$extensionDiscovery->setProfileDirectories([]);
$profiles = $extensionDiscovery->scan('profile');
$profile_directories = array_map(static function (Extension $profile) : string {
return $profile->getPath();
}, $profiles);
$extensionDiscovery->setProfileDirectories($profile_directories);
$this->moduleData = array_merge($extensionDiscovery->scan('module'), $profiles);
usort($this->moduleData, static function (Extension $a, Extension $b) {
return strpos($a->getName(), '_test') !== false ? 10 : 0;
});
$this->themeData = $extensionDiscovery->scan('theme');
$this->addCoreTestNamespaces();
$this->addModuleNamespaces();
$this->addThemeNamespaces();
$this->registerPs4Namespaces($this->namespaces);
$this->loadLegacyIncludes();
// Trigger deprecation error if checkDeprecatedHooksInApiFiles is enabled.
if ($drupalParams['bleedingEdge']['checkDeprecatedHooksInApiFiles']) {
trigger_error('The bleedingEdge.checkDeprecatedHooksInApiFiles parameter is deprecated and will be removed in a future release.', E_USER_DEPRECATED);
}
$checkDeprecatedHooksInApiFiles = $drupalParams['bleedingEdge']['checkDeprecatedHooksInApiFiles'];
$checkCoreDeprecatedHooksInApiFiles = $drupalParams['bleedingEdge']['checkCoreDeprecatedHooksInApiFiles'] || $checkDeprecatedHooksInApiFiles;
$checkContribDeprecatedHooksInApiFiles = $drupalParams['bleedingEdge']['checkContribDeprecatedHooksInApiFiles'] || $checkDeprecatedHooksInApiFiles;
foreach ($this->moduleData as $extension) {
$this->loadExtension($extension);
$module_name = $extension->getName();
$module_dir = $this->drupalRoot . '/' . $extension->getPath();
// Add .install
if (file_exists($module_dir . '/' . $module_name . '.install')) {
$ignored_install_files = ['entity_test', 'entity_test_update', 'update_test_schema'];
if (!in_array($module_name, $ignored_install_files, true)) {
$this->loadAndCatchErrors($module_dir . '/' . $module_name . '.install');
}
}
// Add .post_update.php
if (file_exists($module_dir . '/' . $module_name . '.post_update.php')) {
$this->loadAndCatchErrors($module_dir . '/' . $module_name . '.post_update.php');
}
// Add .api.php for core modules
if ($checkCoreDeprecatedHooksInApiFiles && $extension->origin === 'core' && file_exists($module_dir . '/' . $module_name . '.api.php')) {
$this->loadAndCatchErrors($module_dir . '/' . $module_name . '.api.php');
}
// Add .api.php for contrib modules
if ($checkContribDeprecatedHooksInApiFiles && $extension->origin !== 'core' && file_exists($module_dir . '/' . $module_name . '.api.php')) {
$this->loadAndCatchErrors($module_dir . '/' . $module_name . '.api.php');
}
// Add misc .inc that are magically allowed via hook_hook_info.
$magic_hook_info_includes = [
'views',
'views_execution',
'tokens',
'search_api',
'pathauto',
];
foreach ($magic_hook_info_includes as $hook_info_include) {
if (file_exists($module_dir . "/$module_name.$hook_info_include.inc")) {
$this->loadAndCatchErrors($module_dir . "/$module_name.$hook_info_include.inc");
}
}
}
foreach ($this->themeData as $extension) {
$this->loadExtension($extension);
$theme_dir = $this->drupalRoot . '/' . $extension->getPath();
$theme_settings_file = $theme_dir . '/theme-settings.php';
if (file_exists($theme_settings_file)) {
$this->loadAndCatchErrors($theme_settings_file);
}
}
if (class_exists(Drush::class)) {
$reflect = new ReflectionClass(Drush::class);
if ($reflect->getFileName() !== false) {
$levels = 2;
if (Drush::getMajorVersion() < 9) {
$levels = 3;
}
$drushDir = dirname($reflect->getFileName(), $levels);
/** @var \SplFileInfo $file */
foreach (Finder::create()->files()->name('*.inc')->in($drushDir . '/includes') as $file) {
require_once $file->getPathname();
}
}
}
foreach ($this->serviceYamls as $extension => $serviceYaml) {
$yaml = Yaml::parseFile($serviceYaml, Yaml::PARSE_CUSTOM_TAGS);
// Weed out service files which only provide parameters.
if (!isset($yaml['services']) || !is_array($yaml['services'])) {
continue;
}
foreach ($yaml['services'] as $serviceId => $serviceDefinition) {
// Check if this is an alias shortcut.
// @link https://symfony.com/doc/4.4/service_container/alias_private.html#aliasing
if (is_string($serviceDefinition)) {
$serviceDefinition = [
'alias' => str_replace('@', '', $serviceDefinition),
];
}
// Prevent \Nette\DI\ContainerBuilder::completeStatement from array_walk_recursive into the arguments
// and thinking these are real services for PHPStan's container.
if (isset($serviceDefinition['arguments']) && is_array($serviceDefinition['arguments'])) {
array_walk($serviceDefinition['arguments'], function (&$argument) : void {
if (is_array($argument) || !is_string($argument)) {
// @todo fix for @http_kernel.controller.argument_metadata_factory
$argument = '';
} else {
$argument = str_replace('@', '', $argument);
}
});
}
// @todo sanitize "calls" and "configurator" and "factory"
/**
jsonapi.params.enhancer:
class: Drupal\jsonapi\Routing\JsonApiParamEnhancer
calls:
- [setContainer, ['@service_container']]
tags:
- { name: route_enhancer }
*/
unset($serviceDefinition['tags'], $serviceDefinition['calls'], $serviceDefinition['configurator'], $serviceDefinition['factory']);
$this->serviceMap[$serviceId] = $serviceDefinition;
}
}
$service_map = $container->getByType(ServiceMap::class);
$service_map->setDrupalServices($this->serviceMap);
if (interface_exists(Test::class)
&& class_exists('Drupal\TestTools\PhpUnitCompatibility\PhpUnit8\ClassWriter')) {
ClassWriter::mutateTestBase($this->autoloader);
}
$extension_map = $container->getByType(ExtensionMap::class);
$extension_map->setExtensions($this->moduleData, $this->themeData, $profiles);
}
protected function loadLegacyIncludes(): void
{
/** @var \SplFileInfo $file */
foreach (Finder::create()->files()->name('*.inc')->in($this->drupalRoot . '/core/includes') as $file) {
require_once $file->getPathname();
}
}
protected function addCoreTestNamespaces(): void
{
// Add core test namespaces.
$core_tests_dir = $this->drupalRoot . '/core/tests/Drupal';
$this->namespaces['Drupal\\BuildTests'] = $core_tests_dir . '/BuildTests';
$this->namespaces['Drupal\\FunctionalJavascriptTests'] = $core_tests_dir . '/FunctionalJavascriptTests';
$this->namespaces['Drupal\\FunctionalTests'] = $core_tests_dir . '/FunctionalTests';
$this->namespaces['Drupal\\KernelTests'] = $core_tests_dir . '/KernelTests';
$this->namespaces['Drupal\\Tests'] = $core_tests_dir . '/Tests';
$this->namespaces['Drupal\\TestSite'] = $core_tests_dir . '/TestSite';
$this->namespaces['Drupal\\TestTools'] = $core_tests_dir . '/TestTools';
$this->namespaces['Drupal\\Tests\\TestSuites'] = $this->drupalRoot . '/core/tests/TestSuites';
}
protected function addModuleNamespaces(): void
{
foreach ($this->moduleData as $module) {
$module_name = $module->getName();
$module_dir = $this->drupalRoot . '/' . $module->getPath();
$this->namespaces["Drupal\\$module_name"] = $module_dir . '/src';
// Extensions can have a \Drupal\Tests\extension namespace for test cases, traits, and other classes such
// as those that extend \Drupal\TestSite\TestSetupInterface.
// @see drupal_phpunit_get_extension_namespaces()
$module_test_dir = $module_dir . '/tests/src';
if (is_dir($module_test_dir)) {
$this->namespaces["Drupal\\Tests\\$module_name"] = $module_test_dir;
}
$servicesFileName = $module_dir . '/' . $module_name . '.services.yml';
if (file_exists($servicesFileName)) {
$this->serviceYamls[$module_name] = $servicesFileName;
}
$camelized = $this->camelize($module_name);
$name = "{$camelized}ServiceProvider";
$class = "Drupal\\{$module_name}\\{$name}";
$this->serviceClassProviders[$module_name] = $class;
$serviceId = "service_provider.$module_name.service_provider";
$this->serviceMap[$serviceId] = ['class' => $class];
$this->registerExtensionTestNamespace($module);
}
}
protected function addThemeNamespaces(): void
{
foreach ($this->themeData as $theme_name => $theme) {
$theme_dir = $this->drupalRoot . '/' . $theme->getPath();
$this->namespaces["Drupal\\$theme_name"] = $theme_dir . '/src';
$this->registerExtensionTestNamespace($theme);
}
}
protected function registerExtensionTestNamespace(Extension $extension): void
{
$suite_names = ['Unit', 'Kernel', 'Functional', 'Build', 'FunctionalJavascript'];
$dir = $this->drupalRoot . '/' . $extension->getPath();
$test_dir = $dir . '/tests/src';
if (is_dir($test_dir)) {
foreach ($suite_names as $suite_name) {
$suite_dir = $test_dir . '/' . $suite_name;
if (is_dir($suite_dir)) {
// Register the PSR-4 directory for PHPUnit-based suites.
$this->namespaces['Drupal\\Tests\\' . $extension->getName() . '\\' . $suite_name . '\\'][] = $suite_dir;
}
}
// Extensions can have a \Drupal\Tests\extension\Traits namespace for
// cross-suite trait code.
$trait_dir = $test_dir . '/Traits';
if (is_dir($trait_dir)) {
$this->namespaces['Drupal\\Tests\\' . $extension->getName() . '\\Traits\\'][] = $trait_dir;
}
}
}
protected function registerPs4Namespaces(array $namespaces): void
{
foreach ($namespaces as $prefix => $paths) {
if (is_array($paths)) {
foreach ($paths as $key => $value) {
$paths[$key] = $value;
}
}
$this->autoloader->addPsr4($prefix . '\\', $paths);
}
}
protected function loadExtension(Extension $extension): void
{
try {
$extension->load();
} catch (Throwable $e) {
// Something prevented the extension file from loading.
// This can happen when drupal_get_path or drupal_get_filename are used outside of the scope of a function.
}
}
protected function loadAndCatchErrors(string $path): void
{
try {
require_once $path;
} catch (ContainerNotInitializedException $e) {
$path = str_replace(dirname($this->drupalRoot) . '/', '', $path);
// This can happen when drupal_get_path or drupal_get_filename are used outside the scope of a function.
@trigger_error("$path invoked the Drupal container outside of the scope of a function or class method. It was not loaded.", E_USER_WARNING);
} catch (Throwable $e) {
$path = str_replace(dirname($this->drupalRoot) . '/', '', $path);
// Something prevented the extension file from loading.
@trigger_error("$path failed loading due to {$e->getMessage()}", E_USER_WARNING);
}
}
protected function camelize(string $id): string
{
return strtr(ucwords(strtr($id, ['_' => ' ', '.' => '_ ', '\\' => '_ '])), [' ' => '']);
}
}

View File

@@ -0,0 +1,144 @@
<?php declare(strict_types=1);
namespace mglaman\PHPStanDrupal\Drupal;
use PHPStan\Type\ObjectType;
use PHPStan\Type\StringType;
use PHPStan\Type\Type;
use PHPStan\Type\TypeCombinator;
use function count;
use function str_replace;
class DrupalServiceDefinition
{
/**
* @var string
*/
private $id;
/**
* @var string|null
*/
private $class;
/**
* @var bool
*/
private $public;
/**
* @var bool
*/
private $deprecated = false;
/**
* @var string|null
*/
private $deprecationTemplate;
/**
* @var string
*/
private static $defaultDeprecationTemplate = 'The "%service_id%" service is deprecated. You should stop using it, as it will soon be removed.';
/**
* @var string|null
*/
private $alias;
/**
* @var array<string, \mglaman\PHPStanDrupal\Drupal\DrupalServiceDefinition>
*/
private $decorators = [];
public function __construct(string $id, ?string $class, bool $public = true, ?string $alias = null)
{
$this->id = $id;
$this->class = $class;
$this->public = $public;
$this->alias = $alias;
}
public function setDeprecated(bool $status = true, ?string $template = null): void
{
$this->deprecated = $status;
$this->deprecationTemplate = $template;
}
/**
* @return string
*/
public function getId(): string
{
return $this->id;
}
/**
* @return string|null
*/
public function getClass(): ?string
{
return $this->class;
}
/**
* @return bool
*/
public function isPublic(): bool
{
return $this->public;
}
/**
* @return string|null
*/
public function getAlias(): ?string
{
return $this->alias;
}
public function isDeprecated(): bool
{
return $this->deprecated;
}
public function getDeprecatedDescription(): string
{
return str_replace('%service_id%', $this->id, $this->deprecationTemplate ?? self::$defaultDeprecationTemplate);
}
public function getType(): Type
{
// Work around Drupal misusing the SplString class for string
// pseudo-services such as 'app.root'.
// @see https://www.drupal.org/project/drupal/issues/3074585
if ($this->getClass() === 'SplString') {
return new StringType();
}
$decorating_services = $this->getDecorators();
if (count($decorating_services) !== 0) {
$combined_services = [];
$combined_services[] = new ObjectType($this->getClass() ?? $this->id);
foreach ($decorating_services as $service_id => $service_definition) {
$combined_services[] = $service_definition->getType();
}
return TypeCombinator::union(...$combined_services);
}
return new ObjectType($this->getClass() ?? $this->id);
}
public function addDecorator(DrupalServiceDefinition $definition): void
{
$this->decorators[$definition->getId()] = $definition;
}
/**
* @return array<string, \mglaman\PHPStanDrupal\Drupal\DrupalServiceDefinition>
*/
public function getDecorators(): array
{
return $this->decorators;
}
}

View File

@@ -0,0 +1,19 @@
<?php declare(strict_types=1);
namespace mglaman\PHPStanDrupal\Drupal;
use PHPStan\PhpDoc\StubFilesExtension;
use Symfony\Component\Finder\Finder;
final class DrupalStubFilesExtension implements StubFilesExtension
{
public function getFiles(): array
{
$files = [];
$finder = Finder::create()->files()->name('*.stub')->in(__DIR__ . '/../../stubs');
foreach ($finder as $file) {
$files[] = $file->getPathname();
}
return $files;
}
}

View File

@@ -0,0 +1,73 @@
<?php declare(strict_types=1);
namespace mglaman\PHPStanDrupal\Drupal;
use Drupal\Core\Config\Entity\ConfigEntityInterface;
use Drupal\Core\Config\Entity\ConfigEntityStorageInterface;
use Drupal\Core\Entity\ContentEntityInterface;
use Drupal\Core\Entity\ContentEntityStorageInterface;
use Drupal\Core\Entity\EntityStorageInterface;
use mglaman\PHPStanDrupal\Type\EntityStorage\ConfigEntityStorageType;
use mglaman\PHPStanDrupal\Type\EntityStorage\ContentEntityStorageType;
use mglaman\PHPStanDrupal\Type\EntityStorage\EntityStorageType;
use PHPStan\Type\ObjectType;
final class EntityData
{
/**
* @var string
*/
private $entityTypeId;
/**
* @var string|null
*/
private $className;
/**
* @var string|null
*/
private $storageClassName;
public function __construct(string $entityTypeId, array $definition)
{
$this->entityTypeId = $entityTypeId;
$this->className = $definition['class'] ?? null;
$this->storageClassName = $definition['storage'] ?? null;
}
public function getClassType(): ?ObjectType
{
return $this->className === null ? null : new ObjectType($this->className);
}
public function getStorageType(): ?EntityStorageType
{
if ($this->storageClassName === null) {
$classType = $this->getClassType();
if ($classType === null) {
return null;
}
if ((new ObjectType(ConfigEntityInterface::class))->isSuperTypeOf($classType)->yes()) {
$this->storageClassName = 'Drupal\Core\Config\Entity\ConfigEntityStorage';
} elseif ((new ObjectType(ContentEntityInterface::class))->isSuperTypeOf($classType)->yes()) {
$this->storageClassName = 'Drupal\Core\Entity\Sql\SqlContentEntityStorage';
} else {
return null;
}
}
$storageType = new ObjectType($this->storageClassName);
if ((new ObjectType(EntityStorageInterface::class))->isSuperTypeOf($storageType)->no()) {
return null;
}
if ((new ObjectType(ConfigEntityStorageInterface::class))->isSuperTypeOf($storageType)->yes()) {
return new ConfigEntityStorageType($this->entityTypeId, $this->storageClassName);
}
if ((new ObjectType(ContentEntityStorageInterface::class))->isSuperTypeOf($storageType)->yes()) {
return new ContentEntityStorageType($this->entityTypeId, $this->storageClassName);
}
return new EntityStorageType($this->entityTypeId, $this->storageClassName);
}
}

View File

@@ -0,0 +1,57 @@
<?php declare(strict_types=1);
namespace mglaman\PHPStanDrupal\Drupal;
use Drupal\Core\Config\Entity\ConfigEntityStorageInterface;
use Drupal\Core\Entity\ContentEntityStorageInterface;
use Drupal\Core\Entity\EntityStorageInterface;
use PHPStan\Type\ObjectType;
final class EntityDataRepository
{
/**
* @var array<string, EntityData>
*/
private $entityData;
public function __construct(array $entityMapping)
{
foreach ($entityMapping as $entityTypeId => $entityData) {
$this->entityData[$entityTypeId] = new EntityData(
$entityTypeId,
$entityData
);
}
}
public function get(string $entityTypeId): EntityData
{
if (!isset($this->entityData[$entityTypeId])) {
$this->entityData[$entityTypeId] = new EntityData(
$entityTypeId,
[]
);
}
return $this->entityData[$entityTypeId];
}
public function resolveFromStorage(ObjectType $callerType): ?EntityData
{
if ($callerType->equals(new ObjectType(EntityStorageInterface::class))) {
return null;
}
if ($callerType->equals(new ObjectType(ConfigEntityStorageInterface::class))) {
return null;
}
if ($callerType->equals(new ObjectType(ContentEntityStorageInterface::class))) {
return null;
}
foreach ($this->entityData as $entityData) {
$storageType = $entityData->getStorageType();
if ($storageType !== null && $callerType->isSuperTypeOf($storageType)->yes()) {
return $entityData;
}
}
return null;
}
}

View File

@@ -0,0 +1,239 @@
<?php declare(strict_types=1);
namespace mglaman\PHPStanDrupal\Drupal;
use RuntimeException;
use Symfony\Component\Yaml\Yaml;
use function explode;
use function file_get_contents;
use function is_array;
use function sprintf;
use function strpos;
use function trim;
/**
* Defines an extension (file) object.
*
* Bundled version of \Drupal\Core\Extension\Extension.
*/
class Extension
{
/**
* The type of the extension (e.g., 'module').
*
* @var string
*/
protected $type;
/**
* The relative pathname of the extension (e.g.,
* 'core/modules/node/node.info.yml').
*
* @var string
*/
protected $pathname;
/**
* The filename of the main extension file (e.g., 'node.module').
*
* @var string|null
*/
protected $filename;
/**
* An SplFileInfo instance for the extension's info file.
*
* Note that SplFileInfo is a PHP resource and resources cannot be serialized.
*
* @var ?\SplFileInfo
*/
protected $splFileInfo;
/**
* The app root.
*
* @var string
*/
protected $root;
/**
* @var string
*/
public $subpath = '';
/**
* @var string
*/
public $origin = '';
/**
* @var array|null
*/
private $info;
/**
* @var string[]|null
*/
private $dependencies;
/**
* Constructs a new Extension object.
*
* @param string $root
* The app root.
* @param string $type
* The type of the extension; e.g., 'module'.
* @param string $pathname
* The relative path and filename of the extension's info file; e.g.,
* 'core/modules/node/node.info.yml'.
* @param string $filename
* (optional) The filename of the main extension file; e.g., 'node.module'.
*/
public function __construct($root, $type, $pathname, $filename = null)
{
$this->root = $root;
$this->type = $type;
$this->pathname = $pathname;
$this->filename = $filename;
}
/**
* Returns the type of the extension.
*
* @return string
*/
public function getType(): string
{
return $this->type;
}
/**
* Returns the internal name of the extension.
*
* @return string
*/
public function getName(): string
{
return basename($this->pathname, '.info.yml');
}
/**
* Returns the relative path of the extension.
*
* @return string
*/
public function getPath(): string
{
return dirname($this->pathname);
}
public function getAbsolutePath(): string
{
return $this->root . DIRECTORY_SEPARATOR . $this->getPath();
}
/**
* Returns the relative path and filename of the extension's info file.
*
* @return string
*/
public function getPathname(): string
{
return $this->pathname;
}
/**
* Returns the filename of the extension's info file.
*
* @return string
*/
public function getFilename(): string
{
return basename($this->pathname);
}
/**
* Returns the relative path of the main extension file, if any.
*
* @return string|null
*/
public function getExtensionPathname(): ?string
{
if ($this->filename !== null) {
return $this->getPath() . '/' . $this->filename;
}
return null;
}
/**
* Returns the name of the main extension file, if any.
*
* @return string|null
*/
public function getExtensionFilename(): ?string
{
return $this->filename;
}
/**
* Loads the main extension file, if any.
*
* @return bool
* TRUE if this extension has a main extension file, FALSE otherwise.
*/
public function load(): bool
{
if ($this->filename !== null) {
include_once $this->root . '/' . $this->getPath() . '/' . $this->filename;
return true;
}
return false;
}
/**
* @return string[]
*/
public function getDependencies(): array
{
if (is_array($this->dependencies)) {
return $this->dependencies;
}
$info = $this->parseInfo();
$dependencies = $info['dependencies'] ?? [];
if ($dependencies === []) {
return $this->dependencies = $dependencies;
}
$this->dependencies = [];
// @see \Drupal\Core\Extension\Dependency::createFromString().
foreach ($dependencies as $dependency) {
if (strpos($dependency, ':') !== false) {
[, $dependency] = explode(':', $dependency);
}
$parts = explode('(', $dependency, 2);
$this->dependencies[] = trim($parts[0]);
}
return $this->dependencies;
}
private function parseInfo(): array
{
if (is_array($this->info)) {
return $this->info;
}
$infoContent = file_get_contents(sprintf('%s/%s', $this->root, $this->getPathname()));
if (false === $infoContent) {
throw new RuntimeException(sprintf('Cannot read "%s', $this->getPathname()));
}
return $this->info = Yaml::parse($infoContent);
}
}

View File

@@ -0,0 +1,427 @@
<?php declare(strict_types=1);
namespace mglaman\PHPStanDrupal\Drupal;
use FilesystemIterator;
use mglaman\PHPStanDrupal\Drupal\Extension;
use RecursiveDirectoryIterator;
use RecursiveIteratorIterator;
use function array_filter;
use function array_flip;
use function array_multisort;
use function dirname;
use function file_exists;
use function is_dir;
use function preg_match;
use function strpos;
class ExtensionDiscovery
{
/**
* Origin directory weight: Core.
*/
private const ORIGIN_CORE = 0;
/**
* Origin directory weight: Installation profile.
*/
private const ORIGIN_PROFILE = 1;
/**
* Origin directory weight: sites/all.
*/
private const ORIGIN_SITES_ALL = 2;
/**
* Origin directory weight: Site-wide directory.
*/
private const ORIGIN_ROOT = 3;
/**
* Origin directory weight: Site-specific directory.
*/
private const ORIGIN_SITE = 5;
/**
* Regular expression to match PHP function names.
*
* @see http://php.net/manual/functions.user-defined.php
*/
private const PHP_FUNCTION_PATTERN = '/^[a-zA-Z_\x7f-\xff][a-zA-Z0-9_\x7f-\xff]*$/';
/**
* Previously discovered files keyed by origin directory and extension type.
*
* @var array
*/
protected static $files = [];
/**
* List of installation profile directories to additionally scan.
*
* @var array
*/
protected $profileDirectories;
/**
* The app root for the current operation.
*
* @var string
*/
protected $root;
/**
* The site path.
*
* @var string
*/
protected $sitePath;
/**
* Constructs a new ExtensionDiscovery object.
*
* @param string $root
* The app root.
*/
public function __construct($root)
{
$this->root = $root;
$this->profileDirectories = [
$root . '/core/profiles/standard'
];
$this->sitePath = 'sites/default';
}
/**
* Discovers available extensions of a given type.
*
* Finds all extensions (modules, themes, etc) that exist on the site. It
* searches in several locations. For instance, to discover all available
* modules:
* @code
* $listing = new ExtensionDiscovery(\Drupal::root());
* $modules = $listing->scan('module');
* @endcode
*
* The following directories will be searched (in the order stated):
* - the core directory; i.e., /core
* - the installation profile directory; e.g., /core/profiles/standard
* - the legacy site-wide directory; i.e., /sites/all
* - the site-wide directory; i.e., /
* - the site-specific directory; e.g., /sites/example.com
*
* To also find test modules, add
* @code
* $settings['extension_discovery_scan_tests'] = TRUE;
* @endcode
* to your settings.php.
*
* The information is returned in an associative array, keyed by the extension
* name (without .info.yml extension). Extensions found later in the search
* will take precedence over extensions found earlier - unless they are not
* compatible with the current version of Drupal core.
*
* @param string $type
* The extension type to search for. One of 'profile', 'module', 'theme', or
* 'theme_engine'.
*
* @return \mglaman\PHPStanDrupal\Drupal\Extension[]
* An associative array of Extension objects, keyed by extension name.
*/
public function scan($type)
{
static $scanresult;
if (!$scanresult) {
$scanresult = [];
}
if (isset($scanresult[$type])) {
return $scanresult[$type];
}
$searchdirs = [];
// Search the core directory.
$searchdirs[self::ORIGIN_CORE] = 'core';
// Search the legacy sites/all directory.
$searchdirs[self::ORIGIN_SITES_ALL] = 'sites/all';
// Search for contributed and custom extensions in top-level directories.
// The scan uses a whitelist to limit recursion to the expected extension
// type specific directory names only.
$searchdirs[self::ORIGIN_ROOT] = '';
$searchdirs[self::ORIGIN_SITE] = $this->sitePath;
$files = [];
foreach ($searchdirs as $dir) {
// Discover all extensions in the directory, unless we did already.
if (!isset(static::$files[$this->root][$dir])) {
static::$files[$this->root][$dir] = $this->scanDirectory($dir);
}
// Only return extensions of the requested type.
if (isset(static::$files[$this->root][$dir][$type])) {
$files += static::$files[$this->root][$dir][$type];
}
}
// If applicable, filter out extensions that do not belong to the current
// installation profiles.
$files = $this->filterByProfileDirectories($files);
// Sort the discovered extensions by their originating directories.
$origin_weights = array_flip($searchdirs);
$files = $this->sort($files, $origin_weights);
// Process and return the list of extensions keyed by extension name.
$scanresult[$type] = $this->process($files);
return $scanresult[$type];
}
/**
* Gets the installation profile directories to be scanned.
*
* @return array
* A list of installation profile directory paths relative to the system
* root directory.
*/
public function getProfileDirectories()
{
return $this->profileDirectories;
}
/**
* Sets explicit profile directories to scan.
*
* @param array $paths
* A list of installation profile directory paths relative to the system
* root directory (without trailing slash) to search for extensions.
*
* @return $this
*/
public function setProfileDirectories(array $paths = [])
{
$this->profileDirectories = $paths;
return $this;
}
/**
* Filters out extensions not belonging to the scanned installation profiles.
*
* @param \mglaman\PHPStanDrupal\Drupal\Extension[] $all_files
* The list of all extensions.
*
* @return \mglaman\PHPStanDrupal\Drupal\Extension[]
* The filtered list of extensions.
*/
protected function filterByProfileDirectories(array $all_files)
{
if ($this->profileDirectories === []) {
return $all_files;
}
return array_filter($all_files, function (Extension $file) : bool {
if (strpos($file->subpath, 'profiles') !== 0) {
// This extension doesn't belong to a profile, ignore it.
return true;
}
foreach ($this->profileDirectories as $weight => $profile_path) {
if (strpos($file->getPath(), $profile_path) === 0) {
// Parent profile found.
return true;
}
}
return false;
});
}
/**
* Sorts the discovered extensions.
*
* @param \mglaman\PHPStanDrupal\Drupal\Extension[] $all_files
* The list of all extensions.
* @param array $weights
* An array of weights, keyed by originating directory.
*
* @return \mglaman\PHPStanDrupal\Drupal\Extension[]
* The sorted list of extensions.
*/
protected function sort(array $all_files, array $weights)
{
$origins = [];
$profiles = [];
foreach ($all_files as $key => $file) {
// If the extension does not belong to a profile, just apply the weight
// of the originating directory.
if (strpos($file->subpath, 'profiles') !== 0) {
$origins[$key] = $weights[$file->origin];
$profiles[$key] = null;
} elseif ($this->profileDirectories === []) {
// If the extension belongs to a profile but no profile directories are
// defined, then we are scanning for installation profiles themselves.
// In this case, profiles are sorted by origin only.
$origins[$key] = self::ORIGIN_PROFILE;
$profiles[$key] = null;
} else {
// Apply the weight of the originating profile directory.
foreach ($this->profileDirectories as $weight => $profile_path) {
if (strpos($file->getPath(), $profile_path) === 0) {
$origins[$key] = self::ORIGIN_PROFILE;
$profiles[$key] = $weight;
continue 2;
}
}
}
}
// Now sort the extensions by origin and installation profile(s).
// The result of this multisort can be depicted like the following matrix,
// whereas the first integer is the weight of the originating directory and
// the second is the weight of the originating installation profile:
// 0 core/modules/node/node.module
// 1 0 profiles/parent_profile/modules/parent_module/parent_module.module
// 1 1 core/profiles/testing/modules/compatible_test/compatible_test.module
// 2 sites/all/modules/common/common.module
// 3 modules/devel/devel.module
// 4 sites/default/modules/custom/custom.module
array_multisort($origins, SORT_ASC, $profiles, SORT_ASC, $all_files);
return $all_files;
}
/**
* Processes the filtered and sorted list of extensions.
*
* Extensions discovered in later search paths override earlier, unless they
* are not compatible with the current version of Drupal core.
*
* @param \mglaman\PHPStanDrupal\Drupal\Extension[] $all_files
* The sorted list of all extensions that were found.
*
* @return \mglaman\PHPStanDrupal\Drupal\Extension[]
* The filtered list of extensions, keyed by extension name.
*/
protected function process(array $all_files)
{
$files = [];
// Duplicate files found in later search directories take precedence over
// earlier ones; they replace the extension in the existing $files array.
foreach ($all_files as $file) {
$files[$file->getName()] = $file;
}
return $files;
}
/**
* Recursively scans a base directory for the extensions it contains.
*
* @param string $dir
* A relative base directory path to scan, without trailing slash.
*
* @return array
* An associative array whose keys are extension type names and whose values
* are associative arrays of \Drupal\Core\Extension\Extension objects, keyed
* by absolute path name.
*
* @see \mglaman\PHPStanDrupal\Drupal\RecursiveExtensionFilterIterator
*/
protected function scanDirectory($dir): array
{
$files = [];
// In order to scan top-level directories, absolute directory paths have to
// be used (which also improves performance, since any configured PHP
// include_paths will not be consulted). Retain the relative originating
// directory being scanned, so relative paths can be reconstructed below
// (all paths are expected to be relative to $this->root).
$dir_prefix = ($dir === '' ? '' : "$dir/");
$absolute_dir = ($dir === '' ? $this->root : $this->root . "/$dir");
if (!is_dir($absolute_dir)) {
return $files;
}
// Use Unix paths regardless of platform, skip dot directories, follow
// symlinks (to allow extensions to be linked from elsewhere), and return
// the RecursiveDirectoryIterator instance to have access to getSubPath(),
// since SplFileInfo does not support relative paths.
$flags = FilesystemIterator::UNIX_PATHS;
$flags |= FilesystemIterator::SKIP_DOTS;
$flags |= FilesystemIterator::FOLLOW_SYMLINKS;
$flags |= FilesystemIterator::CURRENT_AS_SELF;
$directory_iterator = new RecursiveDirectoryIterator($absolute_dir, $flags);
// Allow directories specified in settings.php to be ignored. You can use
// this to not check for files in common special-purpose directories. For
// example, node_modules and bower_components. Ignoring irrelevant
// directories is a performance boost.
$ignore_directories = ['node_modules', 'bower_components'];
// Filter the recursive scan to discover extensions only.
// Important: Without a RecursiveFilterIterator, RecursiveDirectoryIterator
// would recurse into the entire filesystem directory tree without any kind
// of limitations.
$filter = new RecursiveExtensionFilterIterator($directory_iterator, $ignore_directories);
// The actual recursive filesystem scan is only invoked by instantiating the
// RecursiveIteratorIterator.
$iterator = new RecursiveIteratorIterator(
$filter,
RecursiveIteratorIterator::LEAVES_ONLY,
// Suppress filesystem errors in case a directory cannot be accessed.
RecursiveIteratorIterator::CATCH_GET_CHILD
);
foreach ($iterator as $key => $fileinfo) {
// All extension names in Drupal have to be valid PHP function names due
// to the module hook architecture.
if (preg_match(self::PHP_FUNCTION_PATTERN, $fileinfo->getBasename('.info.yml')) !== 1) {
continue;
}
// This test module has a function declaration that conflicts with another module. Explicitly skip it.
// @see https://www.drupal.org/project/drupal/issues/3020142
// @todo remove when Drupal core fixed.
if ($fileinfo->getBasename('.info.yml') === 'no_transitions_css') {
continue;
}
// Determine extension type from info file.
$type = false;
$file = $fileinfo->openFile('r');
while ($type === false && !$file->eof()) {
if ($line = $file->fgets()) {
preg_match('@^type:\s*(\'|")?(\w+)\1?\s*$@', $line, $matches);
if (isset($matches[2])) {
$type = $matches[2];
}
}
}
if ($type === false) {
continue;
}
$name = $fileinfo->getBasename('.info.yml');
$pathname = $dir_prefix . $fileinfo->getSubPathname();
// Determine whether the extension has a main extension file.
// For theme engines, the file extension is .engine.
if ($type === 'theme_engine') {
$filename = $name . '.engine';
} else {
$filename = $name . '.' . $type;
}
if (!file_exists($this->root . '/' . dirname($pathname) . '/' . $filename)) {
$filename = null;
}
$extension = new Extension($this->root, $type, $pathname, $filename);
// Track the originating directory for sorting purposes.
$extension->subpath = $fileinfo->getSubPath();
$extension->origin = $dir;
$files[$type][$key] = $extension;
}
return $files;
}
}

View File

@@ -0,0 +1,87 @@
<?php declare(strict_types=1);
namespace mglaman\PHPStanDrupal\Drupal;
use function array_combine;
use function array_map;
use function is_array;
final class ExtensionMap
{
/** @var array<string, Extension> */
private static $modules = [];
/** @var array<string, Extension> */
private static $themes = [];
/** @var array<string, Extension> */
private static $profiles = [];
/**
* @return Extension[]
*/
public function getModules(): array
{
return self::$modules;
}
public function getModule(string $name): ?Extension
{
return self::$modules[$name] ?? null;
}
/**
* @return Extension[]
*/
public function getThemes(): array
{
return self::$themes;
}
public function getTheme(string $name): ?Extension
{
return self::$themes[$name] ?? null;
}
/**
* @return Extension[]
*/
public function getProfiles(): array
{
return self::$profiles;
}
public function getProfile(string $name): ?Extension
{
return self::$profiles[$name] ?? null;
}
/**
* @param array<int, Extension> $modules
* @param array<int, Extension> $themes
* @param array<int, Extension> $profiles
*/
public function setExtensions(array $modules, array $themes, array $profiles): void
{
self::$modules = self::keyByExtensionName($modules);
self::$themes = self::keyByExtensionName($themes);
self::$profiles = self::keyByExtensionName($profiles);
}
/**
* @param array<int, Extension> $extensions
* @return array<string, Extension>
*/
private static function keyByExtensionName(array $extensions): array
{
// PHP 7.4 returns array|false, PHP 8.0 only returns an array.
// Make PHPStan happy. When PHP 7.4 is dropped, reduce to a single
// return.
$combined = array_combine(array_map(static function (Extension $extension) {
return $extension->getName();
}, $extensions), $extensions);
// @phpstan-ignore-next-line
assert(is_array($combined));
return $combined;
}
}

View File

@@ -0,0 +1,131 @@
<?php
namespace mglaman\PHPStanDrupal\Drupal;
use RecursiveFilterIterator;
use RecursiveIterator;
use function array_merge;
use function in_array;
use function substr;
/**
* Filters a RecursiveDirectoryIterator to discover extensions.
*
* Locally bundled version of \Drupal\Core\Extension\Discovery\RecursiveExtensionFilterIterator.
*
* @method bool isDir()
*/
class RecursiveExtensionFilterIterator extends RecursiveFilterIterator
{
/**
* List of base extension type directory names to scan.
*
* Only these directory names are considered when starting a filesystem
* recursion in a search path.
*
* @var array
*/
protected $whitelist = [
'profiles',
'modules',
'themes',
];
/**
* List of directory names to skip when recursing.
*
* These directories are globally ignored in the recursive filesystem scan;
* i.e., extensions (of all types) are not able to use any of these names,
* because their directory names will be skipped.
*
* @var array
*/
protected $blacklist = [
// Object-oriented code subdirectories.
'src',
'lib',
'vendor',
// Front-end.
'assets',
'css',
'files',
'images',
'js',
'misc',
'templates',
// Legacy subdirectories.
'includes',
// Test subdirectories.
'fixtures',
// @todo ./tests/Drupal should be ./tests/src/Drupal
'Drupal',
];
/**
* Construct a RecursiveExtensionFilterIterator.
*
* @param \RecursiveIterator $iterator
* The iterator to filter.
* @param array $blacklist
* (optional) Add to the blacklist of directories that should be filtered
* out during the iteration.
*/
public function __construct(RecursiveIterator $iterator, array $blacklist = [])
{
parent::__construct($iterator);
$this->blacklist = array_merge($this->blacklist, $blacklist);
}
/**
* {@inheritdoc}
*/
public function getChildren(): RecursiveFilterIterator
{
$filter = parent::getChildren();
if ($filter instanceof self) {
// Pass on the blacklist.
$filter->blacklist = $this->blacklist;
}
return $filter;
}
/**
* {@inheritdoc}
*/
public function accept(): bool
{
$name = $this->current()->getFilename();
// FilesystemIterator::SKIP_DOTS only skips '.' and '..', but not hidden
// directories (like '.git').
if ($name[0] === '.') {
return false;
}
if ($this->isDir()) {
// If this is a subdirectory of a base search path, only recurse into the
// fixed list of expected extension type directory names. Required for
// scanning the top-level/root directory; without this condition, we would
// recurse into the whole filesystem tree that possibly contains other
// files aside from Drupal.
if ($this->current()->getSubPath() === '') {
return in_array($name, $this->whitelist, true);
}
// 'config' directories are special-cased here, because every extension
// contains one. However, those default configuration directories cannot
// contain extensions. The directory name cannot be globally skipped,
// because core happens to have a directory of an actual module that is
// named 'config'. By explicitly testing for that case, we can skip all
// other config directories, and at the same time, still allow the core
// config module to be overridden/replaced in a profile/site directory
// (whereas it must be located directly in a modules directory).
if ($name === 'config') {
return substr($this->current()->getPathname(), -14) === 'modules/config';
}
// Accept the directory unless the name is blacklisted.
return !in_array($name, $this->blacklist, true);
}
// Only accept extension info files.
return substr($name, -9) === '.info.yml';
}
}

View File

@@ -0,0 +1,104 @@
<?php declare(strict_types=1);
namespace mglaman\PHPStanDrupal\Drupal;
use function class_exists;
class ServiceMap
{
/** @var DrupalServiceDefinition[] */
private static $services = [];
public function getService(string $id): ?DrupalServiceDefinition
{
return self::$services[$id] ?? null;
}
/**
* @return DrupalServiceDefinition[]
*/
public function getServices(): array
{
return self::$services;
}
public function setDrupalServices(array $drupalServices): void
{
self::$services = [];
$decorators = [];
foreach ($drupalServices as $serviceId => $serviceDefinition) {
if (isset($serviceDefinition['alias'], $drupalServices[$serviceDefinition['alias']])) {
$serviceDefinition = $drupalServices[$serviceDefinition['alias']];
}
if (isset($serviceDefinition['parent'], $drupalServices[$serviceDefinition['parent']])) {
$serviceDefinition = $this->resolveParentDefinition($serviceDefinition['parent'], $serviceDefinition, $drupalServices);
}
if (isset($serviceDefinition['decorates'])) {
$decorators[$serviceDefinition['decorates']][] = $serviceId;
}
// @todo support factories
if (!isset($serviceDefinition['class'])) {
if (class_exists($serviceId)) {
$serviceDefinition['class'] = $serviceId;
} else {
continue;
}
}
self::$services[$serviceId] = new DrupalServiceDefinition(
(string) $serviceId,
$serviceDefinition['class'],
$serviceDefinition['public'] ?? true,
$serviceDefinition['alias'] ?? null
);
$deprecated = $serviceDefinition['deprecated'] ?? null;
if ($deprecated) {
if (is_array($deprecated) && isset($deprecated['message'])) {
$deprecated = $deprecated['message'];
}
$deprecated = str_replace('%service_id%', $serviceId, $deprecated);
if (isset($serviceDefinition['alias'])) {
$deprecated = str_replace('%alias_id%', $serviceDefinition['alias'], $deprecated);
}
self::$services[$serviceId]->setDeprecated(true, $deprecated);
}
}
foreach ($decorators as $decorated_service_id => $services) {
foreach ($services as $dcorating_service_id) {
if (!isset(self::$services[$decorated_service_id])) {
continue;
}
self::$services[$decorated_service_id]->addDecorator(self::$services[$dcorating_service_id]);
}
}
}
private function resolveParentDefinition(string $parentId, array $serviceDefinition, array $drupalServices): array
{
$parentDefinition = $drupalServices[$parentId] ?? [];
if ([] === $parentDefinition) {
return $serviceDefinition;
}
if (isset($parentDefinition['parent'])) {
if (!isset($drupalServices[$parentDefinition['parent']])) {
return $serviceDefinition;
}
$parentDefinition = $this->resolveParentDefinition($parentDefinition['parent'], $drupalServices[$parentDefinition['parent']], $drupalServices);
}
if (isset($parentDefinition['class']) && !isset($serviceDefinition['class'])) {
$serviceDefinition['class'] = $parentDefinition['class'];
}
if (isset($parentDefinition['public']) && !isset($serviceDefinition['public'])) {
$serviceDefinition['public'] = $parentDefinition['public'];
}
return $serviceDefinition;
}
}

View File

@@ -0,0 +1,22 @@
<?php declare(strict_types=1);
namespace mglaman\PHPStanDrupal\Internal;
use PHPStan\Analyser\Scope;
final class DeprecatedScopeCheck
{
public static function inDeprecatedScope(Scope $scope): bool
{
$class = $scope->getClassReflection();
if ($class !== null && $class->isDeprecated()) {
return true;
}
$trait = $scope->getTraitReflection();
if ($trait !== null && $trait->isDeprecated()) {
return true;
}
$function = $scope->getFunction();
return $function !== null && $function->isDeprecated()->yes();
}
}

View File

@@ -0,0 +1,37 @@
<?php declare(strict_types=1);
namespace mglaman\PHPStanDrupal\Internal;
use PhpParser\Node\Stmt\Class_;
/**
* @internal
*/
final class NamespaceCheck
{
public static function isDrupalNamespace(Class_ $class): bool
{
if (!isset($class->namespacedName)) {
return false;
}
return 'Drupal' === (string) $class->namespacedName->slice(0, 1);
}
public static function isSharedNamespace(Class_ $class): bool
{
if (!isset($class->extends)) {
return false;
}
if (!isset($class->namespacedName)) {
return false;
}
if (!self::isDrupalNamespace($class)) {
return false;
}
return (string) $class->namespacedName->slice(0, 2) === (string) $class->extends->slice(0, 2);
}
}

View File

@@ -0,0 +1,49 @@
<?php
namespace mglaman\PHPStanDrupal\Reflection;
use PHPStan\Reflection\ClassReflection;
use PHPStan\Reflection\MethodReflection;
use PHPStan\Reflection\MethodsClassReflectionExtension;
use PHPStan\Type\ObjectType;
use function array_key_exists;
/**
* Allows some common methods on fields.
*/
class EntityFieldMethodsViaMagicReflectionExtension implements MethodsClassReflectionExtension
{
public function hasMethod(ClassReflection $classReflection, string $methodName): bool
{
if ($classReflection->hasNativeMethod($methodName) || array_key_exists($methodName, $classReflection->getMethodTags())) {
// Let other parts of PHPStan handle this.
return false;
}
$interfaceObject = new ObjectType('Drupal\Core\Field\FieldItemListInterface');
$objectType = new ObjectType($classReflection->getName());
if (!$interfaceObject->isSuperTypeOf($objectType)->yes()) {
return false;
}
if ($methodName === 'referencedEntities') {
return true;
}
return false;
}
public function getMethod(ClassReflection $classReflection, string $methodName): MethodReflection
{
if ($methodName === 'referencedEntities') {
$entityReferenceFieldItemListInterfaceType = new ObjectType('Drupal\Core\Field\EntityReferenceFieldItemListInterface');
$classReflection = $entityReferenceFieldItemListInterfaceType->getClassReflection();
assert($classReflection !== null);
}
return new FieldItemListMethodReflection(
$classReflection,
$methodName
);
}
}

View File

@@ -0,0 +1,129 @@
<?php
namespace mglaman\PHPStanDrupal\Reflection;
use PHPStan\Reflection\ClassReflection;
use PHPStan\Reflection\PropertyReflection;
use PHPStan\TrinaryLogic;
use PHPStan\Type\MixedType;
use PHPStan\Type\ObjectType;
use PHPStan\Type\Type;
/**
* Allows field access via magic methods
*
* See \Drupal\Core\Entity\ContentEntityBase::__get and ::__set.
*/
class EntityFieldReflection implements PropertyReflection
{
/** @var ClassReflection */
private $declaringClass;
/** @var string */
private $propertyName;
public function __construct(ClassReflection $declaringClass, string $propertyName)
{
$this->declaringClass = $declaringClass;
$this->propertyName = $propertyName;
}
public function getReadableType(): Type
{
if ($this->propertyName === 'original') {
if ($this->declaringClass->isSubclassOf('Drupal\Core\Entity\ContentEntityInterface')) {
$objectType = 'Drupal\Core\Entity\ContentEntityInterface';
} elseif ($this->declaringClass->isSubclassOf('Drupal\Core\Config\Entity\ConfigEntityInterface')) {
$objectType = 'Drupal\Core\Config\Entity\ConfigEntityInterface';
} else {
$objectType = 'Drupal\Core\Entity\EntityInterface';
}
return new ObjectType($objectType);
}
if ($this->declaringClass->isSubclassOf('Drupal\Core\Entity\ContentEntityInterface')) {
// Assume the property is a field.
return new ObjectType('Drupal\Core\Field\FieldItemListInterface');
}
return new MixedType();
}
public function getWritableType(): Type
{
if ($this->propertyName === 'original') {
if ($this->declaringClass->isSubclassOf('Drupal\Core\Entity\ContentEntityInterface')) {
$objectType = 'Drupal\Core\Entity\ContentEntityInterface';
} elseif ($this->declaringClass->isSubclassOf('Drupal\Core\Config\Entity\ConfigEntityInterface')) {
$objectType = 'Drupal\Core\Config\Entity\ConfigEntityInterface';
} else {
$objectType = 'Drupal\Core\Entity\EntityInterface';
}
return new ObjectType($objectType);
}
// @todo Drupal allows $entity->field_myfield = 'string'; does this break that?
if ($this->declaringClass->isSubclassOf('Drupal\Core\Entity\ContentEntityInterface')) {
// Assume the property is a field.
return new ObjectType('Drupal\Core\Field\FieldItemListInterface');
}
return new MixedType();
}
public function canChangeTypeAfterAssignment(): bool
{
return true;
}
public function getDeclaringClass(): ClassReflection
{
return $this->declaringClass;
}
public function isStatic(): bool
{
return false;
}
public function isPrivate(): bool
{
return false;
}
public function isPublic(): bool
{
return true;
}
public function isReadable(): bool
{
return true;
}
public function isWritable(): bool
{
return true;
}
public function getDeprecatedDescription(): ?string
{
return null;
}
public function getDocComment(): ?string
{
return null;
}
public function isDeprecated(): TrinaryLogic
{
return TrinaryLogic::createNo();
}
public function isInternal(): TrinaryLogic
{
return TrinaryLogic::createNo();
}
}

View File

@@ -0,0 +1,75 @@
<?php
namespace mglaman\PHPStanDrupal\Reflection;
use LogicException;
use PHPStan\Reflection\ClassReflection;
use PHPStan\Reflection\PropertiesClassReflectionExtension;
use PHPStan\Reflection\PropertyReflection;
use PHPStan\TrinaryLogic;
use PHPStan\Type\ObjectType;
use function array_key_exists;
/**
* Allows field access via magic methods
*
* See \Drupal\Core\Entity\ContentEntityBase::__get and ::__set.
*
* @todo split into Entity and FieldItem specifics.
*/
class EntityFieldsViaMagicReflectionExtension implements PropertiesClassReflectionExtension
{
public function hasProperty(ClassReflection $classReflection, string $propertyName): bool
{
// @todo Have this run after PHPStan\Reflection\Annotations\AnnotationsPropertiesClassReflectionExtension
// We should not have to check for the property tags if we could get this to run after PHPStan's
// existing annotation property reflection.
if ($classReflection->hasNativeProperty($propertyName) || array_key_exists($propertyName, $classReflection->getPropertyTags())) {
// Let other parts of PHPStan handle this.
return false;
}
foreach ($classReflection->getAncestors() as $ancestor) {
if (array_key_exists($propertyName, $ancestor->getPropertyTags())) {
return false;
}
}
// We need to find a way to parse the entity annotation so that at the minimum the `entity_keys` are
// supported. The real fix is Drupal developers _really_ need to start writing @property definitions in the
// class doc if they don't get `get` methods.
if ($classReflection->implementsInterface('Drupal\Core\Entity\ContentEntityInterface')) {
// @todo revisit if it's a good idea to be true.
// Content entities have magical __get... so it is kind of true.
return true;
}
if (self::classObjectIsSuperOfInterface($classReflection->getName(), self::getFieldItemListInterfaceObject())->yes()) {
return FieldItemListPropertyReflection::canHandleProperty($classReflection, $propertyName);
}
return false;
}
public function getProperty(ClassReflection $classReflection, string $propertyName): PropertyReflection
{
if ($classReflection->implementsInterface('Drupal\Core\Entity\EntityInterface')) {
return new EntityFieldReflection($classReflection, $propertyName);
}
if (self::classObjectIsSuperOfInterface($classReflection->getName(), self::getFieldItemListInterfaceObject())->yes()) {
return new FieldItemListPropertyReflection($classReflection, $propertyName);
}
throw new LogicException($classReflection->getName() . "::$propertyName should be handled earlier.");
}
public static function classObjectIsSuperOfInterface(string $name, ObjectType $interfaceObject) : TrinaryLogic
{
return $interfaceObject->isSuperTypeOf(new ObjectType($name));
}
protected static function getFieldItemListInterfaceObject() : ObjectType
{
return new ObjectType('Drupal\Core\Field\FieldItemListInterface');
}
}

View File

@@ -0,0 +1,102 @@
<?php
namespace mglaman\PHPStanDrupal\Reflection;
use PHPStan\Reflection\ClassMemberReflection;
use PHPStan\Reflection\ClassReflection;
use PHPStan\Reflection\MethodReflection;
use PHPStan\Reflection\TrivialParametersAcceptor;
use PHPStan\TrinaryLogic;
use PHPStan\Type\Type;
/**
* Allows field access to common methods.
*/
class FieldItemListMethodReflection implements MethodReflection
{
private ClassReflection $declaringClass;
private string $methodName;
public function __construct(ClassReflection $declaringClass, string $methodName)
{
$this->declaringClass = $declaringClass;
$this->methodName = $methodName;
}
public function getDeclaringClass(): ClassReflection
{
return $this->declaringClass;
}
public function isStatic(): bool
{
return false;
}
public function isPrivate(): bool
{
return false;
}
public function isPublic(): bool
{
return true;
}
public function getDocComment(): ?string
{
return null;
}
public function getName(): string
{
return $this->methodName;
}
public function getPrototype(): ClassMemberReflection
{
return $this;
}
/**
* @return \PHPStan\Reflection\ParametersAcceptor[]
*/
public function getVariants(): array
{
return [
new TrivialParametersAcceptor(),
];
}
public function isDeprecated(): TrinaryLogic
{
return TrinaryLogic::createNo();
}
public function getDeprecatedDescription(): ?string
{
return '';
}
public function isFinal(): TrinaryLogic
{
return TrinaryLogic::createYes();
}
public function isInternal(): TrinaryLogic
{
return TrinaryLogic::createNo();
}
public function getThrowType(): ?Type
{
return null;
}
public function hasSideEffects(): TrinaryLogic
{
return TrinaryLogic::createNo();
}
}

View File

@@ -0,0 +1,130 @@
<?php
namespace mglaman\PHPStanDrupal\Reflection;
use PHPStan\Reflection\ClassReflection;
use PHPStan\Reflection\PropertyReflection;
use PHPStan\TrinaryLogic;
use PHPStan\Type\NullType;
use PHPStan\Type\ObjectType;
use PHPStan\Type\StringType;
use PHPStan\Type\Type;
use function in_array;
/**
* Allows field access via magic methods
*
* See \Drupal\Core\Field\FieldItemListInterface::__get and ::__set.
*/
class FieldItemListPropertyReflection implements PropertyReflection
{
/** @var ClassReflection */
private $declaringClass;
/** @var string */
private $propertyName;
public function __construct(ClassReflection $declaringClass, string $propertyName)
{
$this->declaringClass = $declaringClass;
$this->propertyName = $propertyName;
}
public static function canHandleProperty(ClassReflection $classReflection, string $propertyName): bool
{
// @todo use the class reflection and be more specific about handled properties.
// Currently \PHPStan\Reflection\EntityFieldReflection::getType always passes FieldItemListInterface.
$names = ['entity', 'value', 'target_id'];
return in_array($propertyName, $names, true);
}
public function getReadableType(): Type
{
if ($this->propertyName === 'entity') {
return new ObjectType('Drupal\Core\Entity\EntityInterface');
}
if ($this->propertyName === 'target_id') {
// @todo needs to be union type.
return new StringType();
}
// @todo this is wrong, integer/bool/decimal/etc all use single value property.
if ($this->propertyName === 'value') {
return new StringType();
}
// Fallback.
return new NullType();
}
public function getWritableType(): Type
{
if ($this->propertyName === 'entity') {
return new ObjectType('Drupal\Core\Entity\EntityInterface');
}
if ($this->propertyName === 'target_id') {
return new StringType();
}
if ($this->propertyName === 'value') {
return new StringType();
}
// Fallback.
return new NullType();
}
public function canChangeTypeAfterAssignment(): bool
{
return true;
}
public function getDeclaringClass(): ClassReflection
{
return $this->declaringClass;
}
public function isStatic(): bool
{
return false;
}
public function isPrivate(): bool
{
return false;
}
public function isPublic(): bool
{
return true;
}
public function isReadable(): bool
{
return true;
}
public function isWritable(): bool
{
return true;
}
public function getDocComment(): ?string
{
return null;
}
public function isDeprecated(): TrinaryLogic
{
return TrinaryLogic::createNo();
}
public function getDeprecatedDescription(): ?string
{
return null;
}
public function isInternal(): TrinaryLogic
{
return TrinaryLogic::createNo();
}
}

View File

@@ -0,0 +1,81 @@
<?php declare(strict_types=1);
namespace mglaman\PHPStanDrupal\Rules\Classes;
use mglaman\PHPStanDrupal\Internal\NamespaceCheck;
use PhpParser\Node;
use PhpParser\Node\Stmt\Class_;
use PHPStan\Analyser\Scope;
use PHPStan\Reflection\ReflectionProvider;
use PHPStan\Rules\Rule;
use PHPStan\Rules\RuleErrorBuilder;
use function sprintf;
/**
* @implements Rule<Class_>
*/
class ClassExtendsInternalClassRule implements Rule
{
/**
* @var ReflectionProvider
*/
private $reflectionProvider;
public function __construct(ReflectionProvider $reflectionProvider)
{
$this->reflectionProvider = $reflectionProvider;
}
public function getNodeType(): string
{
return Class_::class;
}
public function processNode(Node $node, Scope $scope): array
{
if (!isset($node->extends)) {
return [];
}
$extendedClassName = $node->extends->toString();
if (!$this->reflectionProvider->hasClass($extendedClassName)) {
return [];
}
$extendedClassReflection = $this->reflectionProvider->getClass($extendedClassName);
if (!$extendedClassReflection->isInternal()) {
return [];
}
if (!isset($node->namespacedName)) {
return [$this->buildError(null, $extendedClassName)->build()];
}
$currentClassName = $node->namespacedName->toString();
if (!NamespaceCheck::isDrupalNamespace($node)) {
return [$this->buildError($currentClassName, $extendedClassName)->build()];
}
if (NamespaceCheck::isSharedNamespace($node)) {
return [];
}
$errorBuilder = $this->buildError($currentClassName, $extendedClassName);
if ($extendedClassName === 'Drupal\Core\Entity\ContentEntityDeleteForm') {
$errorBuilder->tip('Extend \Drupal\Core\Entity\ContentEntityConfirmFormBase. See https://www.drupal.org/node/2491057');
} elseif ((string) $node->extends->slice(0, 2) === 'Drupal\Core') {
$errorBuilder->tip('Read the Drupal core backwards compatibility and internal API policy: https://www.drupal.org/about/core/policies/core-change-policies/drupal-8-and-9-backwards-compatibility-and-internal-api#internal');
}
return [$errorBuilder->build()];
}
private function buildError(?string $currentClassName, string $extendedClassName): RuleErrorBuilder
{
return RuleErrorBuilder::message(sprintf(
'%s extends @internal class %s.',
$currentClassName !== null ? sprintf('Class %s', $currentClassName) : 'Anonymous class',
$extendedClassName
));
}
}

View File

@@ -0,0 +1,141 @@
<?php declare(strict_types=1);
namespace mglaman\PHPStanDrupal\Rules\Classes;
use PhpParser\Node;
use PHPStan\Analyser\Scope;
use PHPStan\Reflection\ReflectionProvider;
use PHPStan\Rules\Rule;
use PHPStan\Type\ObjectType;
use function sprintf;
/**
* @implements \PHPStan\Rules\Rule<\PhpParser\Node\Stmt\Class_>
*/
class PluginManagerInspectionRule implements Rule
{
/** @var ReflectionProvider */
private $reflectionProvider;
public function __construct(ReflectionProvider $reflectionProvider)
{
$this->reflectionProvider = $reflectionProvider;
}
public function getNodeType(): string
{
return Node\Stmt\Class_::class;
}
public function processNode(Node $node, Scope $scope): array
{
if ($node->namespacedName === null) {
// anonymous class
return [];
}
if ($node->extends === null) {
return [];
}
$className = (string) $node->namespacedName;
$pluginManagerType = new ObjectType($className);
$pluginManagerInterfaceType = new ObjectType('\Drupal\Component\Plugin\PluginManagerInterface');
if (!$pluginManagerInterfaceType->isSuperTypeOf($pluginManagerType)->yes()) {
return [];
}
$errors = [];
if ($this->isYamlDiscovery($node)) {
$errors = $this->inspectYamlPluginManager($node);
} else {
// @todo inspect annotated plugin managers.
}
$hasAlterInfoSet = false;
foreach ($node->stmts as $stmt) {
if ($stmt instanceof Node\Stmt\ClassMethod && $stmt->name->toString() === '__construct') {
foreach ($stmt->stmts ?? [] as $statement) {
if ($statement instanceof Node\Stmt\Expression) {
$statement = $statement->expr;
}
if ($statement instanceof Node\Expr\MethodCall
&& $statement->name instanceof Node\Identifier
&& $statement->name->name === 'alterInfo') {
$hasAlterInfoSet = true;
}
}
}
}
if (!$hasAlterInfoSet) {
$errors[] = 'Plugin definitions cannot be altered.';
}
return $errors;
}
private function isYamlDiscovery(Node\Stmt\Class_ $class): bool
{
foreach ($class->stmts as $stmt) {
// YAML discovery plugin managers must override getDiscovery.
if ($stmt instanceof Node\Stmt\ClassMethod && $stmt->name->toString() === 'getDiscovery') {
foreach ($stmt->stmts ?? [] as $methodStmt) {
if ($methodStmt instanceof Node\Stmt\If_) {
foreach ($methodStmt->stmts as $ifStmt) {
if ($ifStmt instanceof Node\Stmt\Expression) {
$ifStmtExpr = $ifStmt->expr;
if ($ifStmtExpr instanceof Node\Expr\Assign) {
$ifStmtExprVar = $ifStmtExpr->var;
if ($ifStmtExprVar instanceof Node\Expr\PropertyFetch
&& $ifStmtExprVar->var instanceof Node\Expr\Variable
&& $ifStmtExprVar->name instanceof Node\Identifier
&& $ifStmtExprVar->name->name === 'discovery'
) {
$ifStmtExprExpr = $ifStmtExpr->expr;
if ($ifStmtExprExpr instanceof Node\Expr\New_
&& ($ifStmtExprExpr->class instanceof Node\Name)
&& $ifStmtExprExpr->class->toString() === 'Drupal\Core\Plugin\Discovery\YamlDiscovery') {
return true;
}
}
}
}
}
}
}
}
}
return false;
}
private function inspectYamlPluginManager(Node\Stmt\Class_ $class): array
{
$errors = [];
$fqn = (string) $class->namespacedName;
$reflection = $this->reflectionProvider->getClass($fqn);
$constructor = $reflection->getConstructor();
if ($constructor->getDeclaringClass()->getName() !== $fqn) {
$errors[] = sprintf('%s must override __construct if using YAML plugins.', $fqn);
} else {
foreach ($class->stmts as $stmt) {
if ($stmt instanceof Node\Stmt\ClassMethod && $stmt->name->toString() === '__construct') {
foreach ($stmt->stmts ?? [] as $constructorStmt) {
if ($constructorStmt instanceof Node\Stmt\Expression) {
$constructorStmt = $constructorStmt->expr;
}
if ($constructorStmt instanceof Node\Expr\StaticCall
&& $constructorStmt->class instanceof Node\Name
&& ((string)$constructorStmt->class === 'parent')
&& $constructorStmt->name instanceof Node\Identifier
&& $constructorStmt->name->name === '__construct') {
$errors[] = sprintf('YAML plugin managers should not invoke its parent constructor.');
}
}
}
}
}
return $errors;
}
}

View File

@@ -0,0 +1,132 @@
<?php declare(strict_types = 1);
namespace mglaman\PHPStanDrupal\Rules\Deprecations;
use Drupal;
use mglaman\PHPStanDrupal\Internal\DeprecatedScopeCheck;
use PhpParser\Node;
use PHPStan\Analyser\Scope;
use PHPStan\Reflection\ReflectionProvider;
use PHPStan\Rules\Rule;
use function array_merge;
use function explode;
use function sprintf;
/**
* @implements Rule<Node\Expr\ConstFetch>
*/
class AccessDeprecatedConstant implements Rule
{
/** @var ReflectionProvider */
private $reflectionProvider;
public function __construct(ReflectionProvider $reflectionProvider)
{
$this->reflectionProvider = $reflectionProvider;
}
public function getNodeType(): string
{
return Node\Expr\ConstFetch::class;
}
public function processNode(Node $node, Scope $scope): array
{
if (DeprecatedScopeCheck::inDeprecatedScope($scope)) {
return [];
}
// nikic/php-parser does not let us access phpdoc comments from deprecated constants, so
// here goes a list of hardcoded core constants. List is available at
// https://api.drupal.org/api/drupal/deprecated/8.9.x?order=object_type&sort=asc&page=5
$deprecatedConstants = [
'DATETIME_STORAGE_TIMEZONE' => 'Deprecated in drupal:8.5.0 and is removed from drupal:9.0.0. Use \Drupal\datetime\Plugin\Field\FieldType\DateTimeItemInterface::STORAGE_TIMEZONE instead.',
'DATETIME_DATETIME_STORAGE_FORMAT' => 'Deprecated in drupal:8.5.0 and is removed from drupal:9.0.0. Use \Drupal\datetime\Plugin\Field\FieldType\DateTimeItemInterface::DATETIME_STORAGE_FORMAT instead.',
'DATETIME_DATE_STORAGE_FORMAT' => 'Deprecated in drupal:8.5.0 and is removed from drupal:9.0.0. Use \Drupal\datetime\Plugin\Field\FieldType\DateTimeItemInterface::DATE_STORAGE_FORMAT instead.',
'DRUPAL_ANONYMOUS_RID' => 'Deprecated in drupal:8.0.0 and is removed from drupal:9.0.0. Use Drupal\Core\Session\AccountInterface::ANONYMOUS_ROLE or \Drupal\user\RoleInterface::ANONYMOUS_ID instead.',
'DRUPAL_AUTHENTICATED_RID' => 'Deprecated in drupal:8.0.0 and is removed from drupal:9.0.0. Use Drupal\Core\Session\AccountInterface::AUTHENTICATED_ROLE or \Drupal\user\RoleInterface::AUTHENTICATED_ID instead.',
'REQUEST_TIME' => 'Deprecated in drupal:8.3.0 and is removed from drupal:11.0.0. Use \Drupal::time()->getRequestTime(); ',
'DRUPAL_PHP_FUNCTION_PATTERN' => 'Deprecated in drupal:8.8.0 and is removed from drupal:9.0.0. Use \Drupal\Core\Extension\ExtensionDiscovery::PHP_FUNCTION_PATTERN instead.',
'CONFIG_ACTIVE_DIRECTORY' => 'Deprecated in drupal:8.0.0 and is removed from drupal:9.0.0. Drupal core no longer creates an active directory.',
'CONFIG_SYNC_DIRECTORY' => 'Deprecated in drupal:8.8.0 and is removed from drupal:9.0.0. Use \Drupal\Core\Site\Settings::get(\'config_sync_directory\') instead.',
'CONFIG_STAGING_DIRECTORY' => 'Deprecated in drupal:8.0.0 and is removed from drupal:9.0.0. The staging directory was renamed to sync.',
'LOCALE_PLURAL_DELIMITER' => 'Deprecated in drupal:8.0.0 and is removed from drupal:9.0.0. Use Drupal\Component\Gettext\PoItem::DELIMITER instead.',
'FILE_CHMOD_DIRECTORY' => 'Deprecated in drupal:8.0.0 and is removed from drupal:9.0.0. Use \Drupal\Core\File\FileSystem::CHMOD_DIRECTORY.',
'FILE_CHMOD_FILE' => 'Deprecated in drupal:8.0.0 and is removed from drupal:9.0.0. Use \Drupal\Core\File\FileSystem::CHMOD_FILE.',
'FILE_CREATE_DIRECTORY' => 'Deprecated in drupal:8.7.0 and is removed from drupal:9.0.0. Use \Drupal\Core\File\FileSystemInterface::CREATE_DIRECTORY.',
'FILE_MODIFY_PERMISSIONS' => 'Deprecated in drupal:8.7.0 and is removed from drupal:9.0.0. Use \Drupal\Core\File\FileSystemInterface::MODIFY_PERMISSIONS.',
'FILE_EXISTS_RENAME' => 'Deprecated in drupal:8.7.0 and is removed from drupal:9.0.0. Use \Drupal\Core\File\FileSystemInterface::EXISTS_RENAME.',
'FILE_EXISTS_REPLACE' => 'Deprecated in drupal:8.7.0 and is removed from drupal:9.0.0. Use \Drupal\Core\File\FileSystemInterface::EXISTS_REPLACE.',
'FILE_EXISTS_ERROR' => 'Deprecated in drupal:8.7.0 and is removed from drupal:9.0.0. Use \Drupal\Core\File\FileSystemInterface::EXISTS_ERROR.',
'AGGREGATOR_CLEAR_NEVER' => 'Deprecated in drupal:8.3.0 and is removed from drupal:9.0.0. Use \Drupal\aggregator\FeedStorageInterface::CLEAR_NEVER instead.',
'COMMENT_ANONYMOUS_MAYNOT_CONTACT' => 'Deprecated in drupal:8.3.0 and is removed from drupal:9.0.0. Use \Drupal\comment\CommentInterface::ANONYMOUS_MAYNOT_CONTACT instead.',
'COMMENT_ANONYMOUS_MAY_CONTACT' => 'Deprecated in drupal:8.3.0 and is removed from drupal:9.0.0. Use \Drupal\comment\CommentInterface::ANONYMOUS_MAY_CONTACT instead.',
'COMMENT_ANONYMOUS_MUST_CONTACT' => 'Deprecated in drupal:8.3.0 and is removed from drupal:9.0.0. Use \Drupal\comment\CommentInterface::ANONYMOUS_MUST_CONTACT instead.',
'IMAGE_STORAGE_NORMAL' => 'Deprecated in drupal:8.1.0 and is removed from drupal:9.0.0.',
'IMAGE_STORAGE_OVERRIDE' => 'Deprecated in drupal:8.1.0 and is removed from drupal:9.0.0.',
'IMAGE_STORAGE_DEFAULT' => 'Deprecated in drupal:8.1.0 and is removed from drupal:9.0.0.',
'IMAGE_STORAGE_EDITABLE' => 'Deprecated in drupal:8.1.0 and is removed from drupal:9.0.0.',
'IMAGE_STORAGE_MODULE' => 'Deprecated in drupal:8.1.0 and is removed from drupal:9.0.0.',
'MENU_MAX_MENU_NAME_LENGTH_UI' => 'Deprecated in drupal:8.3.0 and is removed from drupal:9.0.0. Use \Drupal\system\MenuStorage::MAX_ID_LENGTH instead.',
'NODE_NOT_PUBLISHED' => 'Deprecated in drupal:8.?.? and is removed from drupal:9.0.0. Use \Drupal\node\NodeInterface::NOT_PUBLISHED instead.',
'NODE_PUBLISHED' => 'Deprecated in drupal:8.?.? and is removed from drupal:9.0.0. Use \Drupal\node\NodeInterface::PUBLISHED instead.',
'NODE_NOT_PROMOTED' => 'Deprecated in drupal:8.?.? and is removed from drupal:9.0.0. Use \Drupal\node\NodeInterface::NOT_PROMOTED instead.',
'NODE_PROMOTED' => 'Deprecated in drupal:8.?.? and is removed from drupal:9.0.0. Use \Drupal\node\NodeInterface::PROMOTED instead.',
'NODE_NOT_STICKY' => 'Deprecated in drupal:8.?.? and is removed from drupal:9.0.0. Use \Drupal\node\NodeInterface::NOT_STICKY instead.',
'NODE_STICKY' => 'Deprecated in drupal:8.?.? and is removed from drupal:9.0.0. Use \Drupal\node\NodeInterface::STICKY instead.',
'RESPONSIVE_IMAGE_EMPTY_IMAGE' => 'Deprecated in drupal:8.3.0 and is removed from drupal:9.0.0. Use Drupal\responsive_image\ResponsiveImageStyleInterface::EMPTY_IMAGE instead.',
'RESPONSIVE_IMAGE_ORIGINAL_IMAGE' => 'Deprecated in drupal:8.3.0 and is removed from drupal:9.0.0. Use \Drupal\responsive_image\ResponsiveImageStyleInterface::ORIGINAL_IMAGE instead.',
'DRUPAL_USER_TIMEZONE_DEFAULT' => 'Deprecated in drupal:8.3.0 and is removed from drupal:9.0.0. Use \Drupal\user\UserInterface::TIMEZONE_DEFAULT instead.',
'DRUPAL_USER_TIMEZONE_EMPTY' => 'Deprecated in drupal:8.3.0 and is removed from drupal:9.0.0. Use \Drupal\user\UserInterface::TIMEZONE_EMPTY instead.',
'DRUPAL_USER_TIMEZONE_SELECT' => 'Deprecated in drupal:8.3.0 and is removed from drupal:9.0.0. Use \Drupal\user\UserInterface::TIMEZONE_SELECT instead.',
'TAXONOMY_HIERARCHY_DISABLED' => 'Deprecated in drupal:8.2.0 and is removed from drupal:9.0.0. Use \Drupal\taxonomy\VocabularyInterface::HIERARCHY_DISABLED instead.',
'TAXONOMY_HIERARCHY_SINGLE' => 'Deprecated in drupal:8.2.0 and is removed from drupal:9.0.0. Use \Drupal\taxonomy\VocabularyInterface::HIERARCHY_SINGLE instead.',
'TAXONOMY_HIERARCHY_MULTIPLE' => 'Deprecated in drupal:8.2.0 and is removed from drupal:9.0.0. Use \Drupal\taxonomy\VocabularyInterface::HIERARCHY_MULTIPLE instead.',
'UPDATE_NOT_SECURE' => 'Deprecated in drupal:8.3.0 and is removed from drupal:9.0.0. Use \Drupal\update\UpdateManagerInterface::NOT_SECURE instead.',
'UPDATE_REVOKED' => 'Deprecated in drupal:8.3.0 and is removed from drupal:9.0.0. Use \Drupal\update\UpdateManagerInterface::REVOKED instead.',
'UPDATE_NOT_SUPPORTED' => 'Deprecated in drupal:8.3.0 and is removed from drupal:9.0.0. Use \Drupal\update\UpdateManagerInterface::NOT_SUPPORTED instead.',
'UPDATE_NOT_CURRENT' => 'Deprecated in drupal:8.3.0 and is removed from drupal:9.0.0. Use \Drupal\update\UpdateManagerInterface::NOT_CURRENT instead.',
'UPDATE_CURRENT' => 'Deprecated in drupal:8.3.0 and is removed from drupal:9.0.0. Use \Drupal\update\UpdateManagerInterface::CURRENT instead.',
'UPDATE_NOT_CHECKED' => 'Deprecated in drupal:8.3.0 and is removed from drupal:9.0.0. Use \Drupal\update\UpdateFetcherInterface::NOT_CHECKED instead.',
'UPDATE_UNKNOWN' => 'Deprecated in drupal:8.3.0 and is removed from drupal:9.0.0. Use \Drupal\update\UpdateFetcherInterface::UNKNOWN instead.',
'UPDATE_NOT_FETCHED' => 'Deprecated in drupal:8.3.0 and is removed from drupal:9.0.0. Use \Drupal\update\UpdateFetcherInterface::NOT_FETCHED instead.',
'UPDATE_FETCH_PENDING' => 'Deprecated in drupal:8.3.0 and is removed from drupal:9.0.0. Use \Drupal\update\UpdateFetcherInterface::FETCH_PENDING instead.',
'USERNAME_MAX_LENGTH' => 'Deprecated in drupal:8.3.0 and is removed from drupal:9.0.0. Use \Drupal\user\UserInterface::USERNAME_MAX_LENGTH instead.',
'USER_REGISTER_ADMINISTRATORS_ONLY' => 'Deprecated in drupal:8.3.0 and is removed from drupal:9.0.0. Use \Drupal\user\UserInterface::REGISTER_ADMINISTRATORS_ONLY instead.',
'USER_REGISTER_VISITORS' => 'Deprecated in drupal:8.3.0 and is removed from drupal:9.0.0. Use \Drupal\user\UserInterface::REGISTER_VISITORS instead.',
'USER_REGISTER_VISITORS_ADMINISTRATIVE_APPROVAL' => 'Deprecated in drupal:8.3.0 and is removed from drupal:9.0.0. Use \Drupal\user\UserInterface::REGISTER_VISITORS_ADMINISTRATIVE_APPROVAL instead.',
];
[$major, $minor] = explode('.', Drupal::VERSION, 3);
if ($major === '9') {
if ((int) $minor >= 1) {
$deprecatedConstants = array_merge($deprecatedConstants, [
'DRUPAL_MINIMUM_PHP' => 'Deprecated in drupal:9.1.0 and is removed from drupal:10.0.0. Use \Drupal::MINIMUM_PHP instead.',
'DRUPAL_MINIMUM_PHP_MEMORY_LIMIT' => 'Deprecated in drupal:9.1.0 and is removed from drupal:10.0.0. Use \Drupal::MINIMUM_PHP_MEMORY_LIMIT instead.',
'DRUPAL_MINIMUM_SUPPORTED_PHP' => 'Deprecated in drupal:9.1.0 and is removed from drupal:10.0.0. Use \Drupal::MINIMUM_SUPPORTED_PHP instead.',
'DRUPAL_RECOMMENDED_PHP' => 'Deprecated in drupal:9.1.0 and is removed from drupal:10.0.0. Use \Drupal::RECOMMENDED_PHP instead.',
'PREG_CLASS_CJK' => 'Deprecated in drupal:9.1.0 and is removed from drupal:10.0.0. Use \Drupal\search\SearchTextProcessorInterface::PREG_CLASS_CJK instead.',
'PREG_CLASS_NUMBERS' => 'Deprecated in drupal:9.1.0 and is removed from drupal:10.0.0. Use \Drupal\search\SearchTextProcessorInterface::PREG_CLASS_NUMBERS',
'PREG_CLASS_PUNCTUATION' => 'Deprecated in drupal:9.1.0 and is removed from drupal:10.0.0. Use \Drupal\search\SearchTextProcessorInterface::PREG_CLASS_PUNCTUATION',
]);
}
if ((int) $minor >= 2) {
$deprecatedConstants = array_merge($deprecatedConstants, [
'FILE_INSECURE_EXTENSION_REGEX' => 'Deprecated in drupal:9.2.0 and is removed from drupal:10.0.0. Use \Drupal\Core\File\FileSystemInterface::INSECURE_EXTENSION_REGEX.',
]);
}
if ((int) $minor >= 3) {
$deprecatedConstants = array_merge($deprecatedConstants, [
'FILE_STATUS_PERMANENT' => 'Deprecated in drupal:9.3.0 and is removed from drupal:10.0.0. Use \Drupal\file\FileInterface::STATUS_PERMANENT or \Drupal\file\FileInterface::setPermanent().',
'SCHEMA_UNINSTALLED' => 'Deprecated in drupal:9.3.0 and is removed from drupal:10.0.0. Use \Drupal\Core\Update\UpdateHookRegistry::SCHEMA_UNINSTALLED',
]);
}
}
$constantName = $this->reflectionProvider->resolveConstantName($node->name, $scope);
if (isset($deprecatedConstants[$constantName])) {
return [
sprintf('Call to deprecated constant %s: %s', $constantName, $deprecatedConstants[$constantName])
];
}
return [];
}
}

View File

@@ -0,0 +1,61 @@
<?php declare(strict_types=1);
namespace mglaman\PHPStanDrupal\Rules\Deprecations;
use Drupal\Core\Condition\ConditionManager;
use PhpParser\Node;
use PHPStan\Analyser\Scope;
use PHPStan\Rules\Rule;
use PHPStan\Rules\RuleErrorBuilder;
use PHPStan\Type\Constant\ConstantArrayType;
use PHPStan\Type\Constant\ConstantStringType;
use PHPStan\Type\ObjectType;
use function count;
/**
* @implements Rule<Node\Expr\MethodCall>
*/
final class ConditionManagerCreateInstanceContextConfigurationRule implements Rule
{
public function getNodeType(): string
{
return Node\Expr\MethodCall::class;
}
public function processNode(Node $node, Scope $scope): array
{
if (!$node->name instanceof Node\Identifier) {
return [];
}
if ($node->name->toString() !== 'createInstance') {
return [];
}
$args = $node->getArgs();
if (count($args) !== 2) {
return [];
}
$conditionManagerType = new ObjectType(ConditionManager::class);
$type = $scope->getType($node->var);
if (!$conditionManagerType->isSuperTypeOf($type)->yes()) {
return [];
}
$configuration = $args[1];
$configurationType = $scope->getType($configuration->value);
// Must be an array, return [] and allow parameter inspection rule to report error.
if (!$configurationType instanceof ConstantArrayType) {
return [];
}
foreach ($configurationType->getKeyTypes() as $keyType) {
if ($keyType instanceof ConstantStringType && $keyType->getValue() === 'context') {
return [
RuleErrorBuilder::message('Passing context values to plugins via configuration is deprecated in drupal:9.1.0 and will be removed before drupal:10.0.0. Instead, call ::setContextValue() on the plugin itself. See https://www.drupal.org/node/3120980')
->line($node->getStartLine())
->build()
];
}
}
return [];
}
}

View File

@@ -0,0 +1,52 @@
<?php declare(strict_types=1);
namespace mglaman\PHPStanDrupal\Rules\Deprecations;
use PhpParser\Node;
use PHPStan\Analyser\Scope;
use PHPStan\PhpDoc\ResolvedPhpDocBlock;
use PHPStan\PhpDocParser\Ast\PhpDoc\PhpDocTagNode;
use PHPStan\Reflection\ClassReflection;
use PHPStan\ShouldNotHappenException;
use function preg_match;
final class ConfigEntityConfigExportRule extends DeprecatedAnnotationsRuleBase
{
protected function getExpectedInterface(): string
{
return 'Drupal\Core\Config\Entity\ConfigEntityInterface';
}
protected function doProcessNode(ClassReflection $reflection, Node\Stmt\Class_ $node, Scope $scope): array
{
$phpDoc = $reflection->getResolvedPhpDoc();
// Plugins should always be annotated, but maybe this class is missing its
// annotation since it swaps an existing one.
if ($phpDoc === null || !$this->isAnnotated($phpDoc)) {
return [];
}
$hasMatch = preg_match('/config_export\s?=\s?{/', $phpDoc->getPhpDocString());
if ($hasMatch === false) {
throw new ShouldNotHappenException('Unexpected error when trying to run match on phpDoc string.');
}
if ($hasMatch === 0) {
return [
'Configuration entity must define a `config_export` key. See https://www.drupal.org/node/2481909',
];
}
return [];
}
private function isAnnotated(ResolvedPhpDocBlock $phpDoc): bool
{
foreach ($phpDoc->getPhpDocNodes() as $docNode) {
foreach ($docNode->children as $childNode) {
if (($childNode instanceof PhpDocTagNode) && $childNode->name === '@ConfigEntityType') {
return true;
}
}
}
return false;
}
}

View File

@@ -0,0 +1,67 @@
<?php declare(strict_types=1);
namespace mglaman\PHPStanDrupal\Rules\Deprecations;
use PhpParser\Node;
use PHPStan\Analyser\Scope;
use PHPStan\Reflection\ClassReflection;
use PHPStan\Reflection\ReflectionProvider;
use PHPStan\Rules\Rule;
/**
* @implements Rule<Node\Stmt\Class_>
*/
abstract class DeprecatedAnnotationsRuleBase implements Rule
{
/**
* @var \PHPStan\Reflection\ReflectionProvider
*/
protected $reflectionProvider;
public function __construct(ReflectionProvider $reflectionProvider)
{
$this->reflectionProvider = $reflectionProvider;
}
public function getNodeType(): string
{
return Node\Stmt\Class_::class;
}
abstract protected function getExpectedInterface(): string;
abstract protected function doProcessNode(
ClassReflection $reflection,
Node\Stmt\Class_ $node,
Scope $scope
): array;
public function processNode(Node $node, Scope $scope): array
{
if ($node->extends === null) {
return [];
}
if ($node->name === null) {
return [];
}
if ($node->isAbstract()) {
return [];
}
// PHPStan gives anonymous classes a name, so we cannot determine if
// a class is truly anonymous using the normal methods from php-parser.
// @see \PHPStan\Reflection\BetterReflection\BetterReflectionProvider::getAnonymousClassReflection
if ($node->hasAttribute('anonymousClass') && $node->getAttribute('anonymousClass') === true) {
return [];
}
$className = $node->name->name;
$namespace = $scope->getNamespace();
$reflection = $this->reflectionProvider->getClass($namespace . '\\' . $className);
$implementsExpectedInterface = $reflection->implementsInterface($this->getExpectedInterface());
if (!$implementsExpectedInterface) {
return [];
}
return $this->doProcessNode($reflection, $node, $scope);
}
}

View File

@@ -0,0 +1,104 @@
<?php
namespace mglaman\PHPStanDrupal\Rules\Deprecations;
use PhpParser\Node;
use PhpParser\Node\Name;
use PhpParser\Node\Stmt\Function_;
use PHPStan\Analyser\Scope;
use PHPStan\Reflection\ReflectionProvider;
use PHPStan\Rules\Rule;
use PHPStan\Rules\RuleErrorBuilder;
use function basename;
use function explode;
use function strlen;
use function substr_replace;
/**
* @implements Rule<Function_>
*/
class DeprecatedHookImplementation implements Rule
{
protected ReflectionProvider $reflectionProvider;
public function __construct(ReflectionProvider $reflectionProvider)
{
$this->reflectionProvider = $reflectionProvider;
}
public function getNodeType(): string
{
return Function_::class;
}
public function processNode(Node $node, Scope $scope) : array
{
if (!str_ends_with($scope->getFile(), ".module") && !str_ends_with($scope->getFile(), ".inc")) {
return [];
}
// We want both name.module and name.views.inc, to resolve to name.
$module_name = explode(".", basename($scope->getFile()))[0];
// Hooks start with their own module's name.
if (!str_starts_with($node->name->toString(), "{$module_name}_")) {
return [];
}
$function_name = $node->name->toString();
$hook_name = substr_replace($function_name, "hook", 0, strlen($module_name));
$hook_name_node = new Name($hook_name);
if (!$this->reflectionProvider->hasFunction($hook_name_node, $scope)) {
// @todo replace this hardcoded logic with something more intelligent and extensible.
if ($hook_name === 'hook_field_widget_form_alter') {
return $this->buildError(
$function_name,
$hook_name,
'in drupal:9.2.0 and is removed from drupal:10.0.0. Use hook_field_widget_single_element_form_alter instead.'
);
}
if (str_starts_with($hook_name, 'hook_field_widget_') && str_ends_with($hook_name, '_form_alter')) {
return $this->buildError(
$function_name,
'hook_field_widget_WIDGET_TYPE_form_alter',
'in drupal:9.2.0 and is removed from drupal:10.0.0. Use hook_field_widget_single_element_WIDGET_TYPE_form_alter instead.'
);
}
if ($hook_name === 'hook_field_widget_multivalue_form_alter') {
return $this->buildError(
$function_name,
$hook_name,
'in drupal:9.2.0 and is removed from drupal:10.0.0. Use hook_field_widget_complete_form_alter instead.'
);
}
if (str_starts_with($hook_name, 'hook_field_widget_multivalue_') && str_ends_with($hook_name, '_form_alter')) {
return $this->buildError(
$function_name,
'hook_field_widget_multivalue_WIDGET_TYPE_form_alter',
'in drupal:9.2.0 and is removed from drupal:10.0.0. Use hook_field_widget_complete_WIDGET_TYPE_form_alter instead.'
);
}
return [];
}
$reflection = $this->reflectionProvider->getFunction($hook_name_node, $scope);
if (!$reflection->isDeprecated()->yes()) {
return [];
}
return $this->buildError($function_name, $hook_name, $reflection->getDeprecatedDescription());
}
private function buildError(string $function_name, string $hook_name, ?string $deprecated_description): array
{
$deprecated_description = $deprecated_description !== null ? " $deprecated_description" : ".";
return [
RuleErrorBuilder::message(
"Function $function_name implements $hook_name which is deprecated$deprecated_description",
)->build()
];
}
}

View File

@@ -0,0 +1,65 @@
<?php declare(strict_types = 1);
namespace mglaman\PHPStanDrupal\Rules\Deprecations;
use mglaman\PHPStanDrupal\Drupal\DrupalServiceDefinition;
use mglaman\PHPStanDrupal\Drupal\ServiceMap;
use PhpParser\Node;
use PHPStan\Analyser\Scope;
use PHPStan\Rules\Rule;
/**
* @implements Rule<Node\Expr\MethodCall>
*/
final class GetDeprecatedServiceRule implements Rule
{
/**
* @var ServiceMap
*/
private $serviceMap;
public function __construct(ServiceMap $serviceMap)
{
$this->serviceMap = $serviceMap;
}
public function getNodeType(): string
{
return Node\Expr\MethodCall::class;
}
public function processNode(Node $node, Scope $scope): array
{
if (!$node->name instanceof Node\Identifier) {
return [];
}
$method_name = $node->name->toString();
if ($method_name !== 'get') {
return [];
}
$methodReflection = $scope->getMethodReflection($scope->getType($node->var), $node->name->toString());
if ($methodReflection === null) {
return [];
}
$declaringClass = $methodReflection->getDeclaringClass();
if ($declaringClass->getName() !== 'Symfony\Component\DependencyInjection\ContainerInterface') {
return [];
}
$serviceNameArg = $node->args[0];
assert($serviceNameArg instanceof Node\Arg);
$serviceName = $serviceNameArg->value;
// @todo check if var, otherwise throw.
// ACTUALLY what if it was a constant? can we use a resolver.
if (!$serviceName instanceof Node\Scalar\String_) {
return [];
}
$service = $this->serviceMap->getService($serviceName->value);
if (($service instanceof DrupalServiceDefinition) && $service->isDeprecated()) {
return [$service->getDeprecatedDescription()];
}
return [];
}
}

View File

@@ -0,0 +1,38 @@
<?php declare(strict_types=1);
namespace mglaman\PHPStanDrupal\Rules\Deprecations;
use PhpParser\Node;
use PHPStan\Analyser\Scope;
use PHPStan\Reflection\ClassReflection;
use PHPStan\ShouldNotHappenException;
use function preg_match;
final class PluginAnnotationContextDefinitionsRule extends DeprecatedAnnotationsRuleBase
{
protected function getExpectedInterface(): string
{
return 'Drupal\Component\Plugin\ContextAwarePluginInterface';
}
protected function doProcessNode(ClassReflection $reflection, Node\Stmt\Class_ $node, Scope $scope): array
{
$annotation = $reflection->getResolvedPhpDoc();
// Plugins should always be annotated, but maybe this class is missing its
// annotation since it swaps an existing one.
if ($annotation === null) {
return [];
}
$hasMatch = preg_match('/context\s?=\s?{/', $annotation->getPhpDocString());
if ($hasMatch === false) {
throw new ShouldNotHappenException('Unexpected error when trying to run match on phpDoc string.');
}
if ($hasMatch === 1) {
return [
'Providing context definitions via the "context" key is deprecated in Drupal 8.7.x and will be removed before Drupal 9.0.0. Use the "context_definitions" key instead.',
];
}
return [];
}
}

View File

@@ -0,0 +1,74 @@
<?php declare(strict_types = 1);
namespace mglaman\PHPStanDrupal\Rules\Deprecations;
use mglaman\PHPStanDrupal\Drupal\DrupalServiceDefinition;
use mglaman\PHPStanDrupal\Drupal\ServiceMap;
use PhpParser\Node;
use PHPStan\Analyser\Scope;
use PHPStan\Rules\Rule;
/**
* @implements Rule<Node\Expr\StaticCall>
*/
final class StaticServiceDeprecatedServiceRule implements Rule
{
/**
* @var ServiceMap
*/
private $serviceMap;
public function __construct(ServiceMap $serviceMap)
{
$this->serviceMap = $serviceMap;
}
public function getNodeType(): string
{
return Node\Expr\StaticCall::class;
}
public function processNode(Node $node, Scope $scope): array
{
if (!$node->name instanceof Node\Identifier) {
return [];
}
$method_name = $node->name->toString();
if ($method_name !== 'service') {
return [];
}
$class = $node->class;
if ($class instanceof Node\Name) {
$calledOnType = $scope->resolveTypeByName($class);
} else {
$calledOnType = $scope->getType($class);
}
$methodReflection = $scope->getMethodReflection($calledOnType, $node->name->toString());
if ($methodReflection === null) {
return [];
}
$declaringClass = $methodReflection->getDeclaringClass();
if ($declaringClass->getName() !== 'Drupal') {
return [];
}
$serviceNameArg = $node->args[0];
assert($serviceNameArg instanceof Node\Arg);
$serviceName = $serviceNameArg->value;
// @todo check if var, otherwise throw.
// ACTUALLY what if it was a constant? can we use a resolver.
if (!$serviceName instanceof Node\Scalar\String_) {
return [];
}
$service = $this->serviceMap->getService($serviceName->value);
if (($service instanceof DrupalServiceDefinition) && $service->isDeprecated()) {
return [$service->getDeprecatedDescription()];
}
return [];
}
}

View File

@@ -0,0 +1,73 @@
<?php declare(strict_types=1);
namespace mglaman\PHPStanDrupal\Rules\Deprecations;
use Drupal;
use Drupal\Core\Routing\RouteObjectInterface;
use mglaman\PHPStanDrupal\Internal\DeprecatedScopeCheck;
use PhpParser\Node;
use PHPStan\Analyser\Scope;
use PHPStan\Rules\Rule;
use PHPStan\Rules\RuleErrorBuilder;
use PHPStan\Type\ObjectType;
use Symfony\Cmf\Component\Routing\RouteObjectInterface as SymfonyRouteObjectInterface;
use function sprintf;
/**
* @implements Rule<Node\Expr\ClassConstFetch>
*/
final class SymfonyCmfRouteObjectInterfaceConstantsRule implements Rule
{
public function getNodeType(): string
{
return Node\Expr\ClassConstFetch::class;
}
public function processNode(Node $node, Scope $scope): array
{
if (!$node->name instanceof Node\Identifier) {
return [];
}
if (!$node->class instanceof Node\Name) {
return [];
}
$constantName = $node->name->name;
$className = $node->class;
$classType = $scope->resolveTypeByName($className);
if (!$classType->hasConstant($constantName)->yes()) {
return [];
}
if (DeprecatedScopeCheck::inDeprecatedScope($scope)) {
return [];
}
[$major, $minor] = explode('.', Drupal::VERSION, 3);
if ($major !== '9') {
return [];
}
if ((int) $minor < 1) {
return [];
}
// @phpstan-ignore-next-line
$cmfRouteObjectInterfaceType = new ObjectType(SymfonyRouteObjectInterface::class);
if (!$classType->isSuperTypeOf($cmfRouteObjectInterfaceType)->yes()) {
return [];
}
$coreRouteObjectInterfaceType = new ObjectType(RouteObjectInterface::class);
if (!$coreRouteObjectInterfaceType->hasConstant($constantName)->yes()) {
return [
RuleErrorBuilder::message(
sprintf('The core dependency symfony-cmf/routing is deprecated and %s::%s is not supported.', $className, $constantName)
)->tip('Change record: https://www.drupal.org/node/3151009')->build(),
];
}
return [
RuleErrorBuilder::message(
sprintf('%s::%s is deprecated and removed in Drupal 10. Use \Drupal\Core\Routing\RouteObjectInterface::%2$s instead.', $className, $constantName)
)->tip('Change record: https://www.drupal.org/node/3151009')->build(),
];
}
}

View File

@@ -0,0 +1,132 @@
<?php declare(strict_types=1);
namespace mglaman\PHPStanDrupal\Rules\Deprecations;
use Drupal;
use mglaman\PHPStanDrupal\Internal\DeprecatedScopeCheck;
use PhpParser\Node;
use PHPStan\Analyser\Scope;
use PHPStan\Node\InClassMethodNode;
use PHPStan\Reflection\ParametersAcceptorSelector;
use PHPStan\Rules\Rule;
use PHPStan\Rules\RuleErrorBuilder;
use PHPStan\Type\ObjectType;
use Symfony\Cmf\Component\Routing\LazyRouteCollection;
use Symfony\Cmf\Component\Routing\RouteObjectInterface;
use Symfony\Cmf\Component\Routing\RouteProviderInterface;
use function explode;
use function sprintf;
/**
* @implements Rule<InClassMethodNode>
*/
final class SymfonyCmfRoutingInClassMethodSignatureRule implements Rule
{
public function getNodeType(): string
{
return InClassMethodNode::class;
}
public function processNode(Node $node, Scope $scope): array
{
if (DeprecatedScopeCheck::inDeprecatedScope($scope)) {
return [];
}
[$major, $minor] = explode('.', Drupal::VERSION, 3);
if ($major !== '9' || (int) $minor < 1) {
return [];
}
$method = $node->getMethodReflection();
// @phpstan-ignore-next-line
$cmfRouteObjectInterfaceType = new ObjectType(RouteObjectInterface::class);
// @phpstan-ignore-next-line
$cmfRouteProviderInterfaceType = new ObjectType(RouteProviderInterface::class);
// @phpstan-ignore-next-line
$cmfLazyRouteCollectionType = new ObjectType(LazyRouteCollection::class);
$methodSignature = ParametersAcceptorSelector::selectFromArgs(
$scope,
[],
$method->getVariants()
);
$errors = [];
$errorMessage = 'Parameter $%s of method %s() uses deprecated %s and removed in Drupal 10. Use %s instead.';
foreach ($methodSignature->getParameters() as $parameter) {
foreach ($parameter->getType()->getReferencedClasses() as $referencedClass) {
$referencedClassType = new ObjectType($referencedClass);
if ($cmfRouteObjectInterfaceType->equals($referencedClassType)) {
$errors[] = RuleErrorBuilder::message(
sprintf(
$errorMessage,
$parameter->getName(),
$method->getName(),
$referencedClass,
'\Drupal\Core\Routing\RouteObjectInterface'
)
)->tip('Change record: https://www.drupal.org/node/3151009')->build();
} elseif ($cmfRouteProviderInterfaceType->equals($referencedClassType)) {
$errors[] = RuleErrorBuilder::message(
sprintf(
$errorMessage,
$parameter->getName(),
$method->getName(),
$referencedClass,
'\Drupal\Core\Routing\RouteProviderInterface'
)
)->tip('Change record: https://www.drupal.org/node/3151009')->build();
} elseif ($cmfLazyRouteCollectionType->equals($referencedClassType)) {
$errors[] = RuleErrorBuilder::message(
sprintf(
$errorMessage,
$parameter->getName(),
$method->getName(),
$referencedClass,
'\Drupal\Core\Routing\LazyRouteCollection'
)
)->tip('Change record: https://www.drupal.org/node/3151009')->build();
}
}
}
$errorMessage = 'Return type of method %s::%s() has typehint with deprecated %s and is removed in Drupal 10. Use %s instead.';
$returnClasses = $methodSignature->getReturnType()->getReferencedClasses();
foreach ($returnClasses as $returnClass) {
$returnType = new ObjectType($returnClass);
if ($cmfRouteObjectInterfaceType->equals($returnType)) {
$errors[] = RuleErrorBuilder::message(
sprintf(
$errorMessage,
$method->getDeclaringClass()->getName(),
$method->getName(),
$returnClass,
'\Drupal\Core\Routing\RouteObjectInterface'
)
)->tip('Change record: https://www.drupal.org/node/3151009')->build();
} elseif ($cmfRouteProviderInterfaceType->equals($returnType)) {
$errors[] = RuleErrorBuilder::message(
sprintf(
$errorMessage,
$method->getDeclaringClass()->getName(),
$method->getName(),
$returnClass,
'\Drupal\Core\Routing\RouteProviderInterface'
)
)->tip('Change record: https://www.drupal.org/node/3151009')->build();
} elseif ($cmfLazyRouteCollectionType->equals($returnType)) {
$errors[] = RuleErrorBuilder::message(
sprintf(
$errorMessage,
$method->getDeclaringClass()->getName(),
$method->getName(),
$returnClass,
'\Drupal\Core\Routing\LazyRouteCollection'
)
)->tip('Change record: https://www.drupal.org/node/3151009')->build();
}
}
return $errors;
}
}

View File

@@ -0,0 +1,79 @@
<?php
declare(strict_types=1);
namespace mglaman\PHPStanDrupal\Rules\Drupal;
use Drupal\Core\Access\AccessResult;
use PhpParser\Node;
use PHPStan\Analyser\Scope;
use PHPStan\Rules\Rule;
use PHPStan\Rules\RuleErrorBuilder;
use PHPStan\Type\VerbosityLevel;
/**
* @implements Rule<Node\Expr\StaticCall>
*/
final class AccessResultConditionRule implements Rule
{
/** @var bool */
private $treatPhpDocTypesAsCertain;
/**
* @param bool $treatPhpDocTypesAsCertain
*/
public function __construct($treatPhpDocTypesAsCertain)
{
$this->treatPhpDocTypesAsCertain = $treatPhpDocTypesAsCertain;
}
public function getNodeType(): string
{
return Node\Expr\StaticCall::class;
}
public function processNode(Node $node, Scope $scope): array
{
if (!$node->name instanceof Node\Identifier) {
return [];
}
$methodName = $node->name->toString();
if (!in_array($methodName, ['allowedIf', 'forbiddenIf'], true)) {
return [];
}
if (!$node->class instanceof Node\Name) {
return [];
}
$className = $scope->resolveName($node->class);
if ($className !== AccessResult::class) {
return [];
}
$args = $node->getArgs();
if (count($args) === 0) {
return [];
}
$condition = $args[0]->value;
if (!$condition instanceof Node\Expr\BinaryOp\Identical && !$condition instanceof Node\Expr\BinaryOp\NotIdentical) {
return [];
}
$conditionType = $this->treatPhpDocTypesAsCertain ? $scope->getType($condition) : $scope->getNativeType($condition);
$bool = $conditionType->toBoolean();
if ($bool->isTrue()->or($bool->isFalse())->yes()) {
$leftType = $this->treatPhpDocTypesAsCertain ? $scope->getType($condition->left) : $scope->getNativeType($condition->left);
$rightType = $this->treatPhpDocTypesAsCertain ? $scope->getType($condition->right) : $scope->getNativeType($condition->right);
return [
RuleErrorBuilder::message(sprintf(
'Strict comparison using %s between %s and %s will always evaluate to %s.',
$condition->getOperatorSigil(),
$leftType->describe(VerbosityLevel::value()),
$rightType->describe(VerbosityLevel::value()),
$bool->describe(VerbosityLevel::value()),
))->identifier(sprintf('%s.alwaysFalse', $condition instanceof Node\Expr\BinaryOp\Identical ? 'identical' : 'notIdentical'))->build(),
];
}
return [];
}
}

View File

@@ -0,0 +1,63 @@
<?php declare(strict_types=1);
namespace mglaman\PHPStanDrupal\Rules\Drupal\Coder;
use PhpParser\Node;
use PhpParser\Node\Expr\FuncCall;
use PHPStan\Analyser\Scope;
use PHPStan\Rules\Rule;
use function in_array;
use function sprintf;
use function strtolower;
/**
* Based on Drupal_Sniffs_Functions_DiscouragedFunctionsSniff.
*
* @implements Rule<FuncCall>
*/
class DiscouragedFunctionsRule implements Rule
{
public function getNodeType(): string
{
return FuncCall::class;
}
public function processNode(Node $node, Scope $scope): array
{
if (!($node->name instanceof Node\Name)) {
return [];
}
$name = strtolower((string)$node->name);
$discouragedFunctions = [
// Devel module debugging functions.
'dargs',
'dcp',
'dd',
'dfb',
'dfbt',
'dpm',
'dpq',
'dpr',
'dprint_r',
'drupal_debug',
'dsm',
'dvm',
'dvr',
'kdevel_print_object',
'kpr',
'kprint_r',
'sdpm',
// Functions which are not available on all
// PHP builds.
'fnmatch',
// Functions which are a security risk.
'eval',
];
if (in_array($name, $discouragedFunctions, true)) {
return [sprintf('Calls to function %s should not exist.', $name)];
}
return [];
}
}

View File

@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace mglaman\PHPStanDrupal\Rules\Drupal;
use Drupal\Core\DependencyInjection\DependencySerializationTrait;
use PhpParser\Node;
use PHPStan\Analyser\Scope;
use PHPStan\Node\ClassPropertyNode;
use PHPStan\Rules\Rule;
use PHPStan\Rules\RuleErrorBuilder;
/**
* @implements Rule<ClassPropertyNode>
*/
final class DependencySerializationTraitPropertyRule implements Rule
{
public function getNodeType(): string
{
return ClassPropertyNode::class;
}
public function processNode(Node $node, Scope $scope): array
{
if (!$node->getClassReflection()->hasTraitUse(DependencySerializationTrait::class)) {
return [];
}
$errors = [];
if ($node->isPrivate()) {
$errors[] = RuleErrorBuilder::message(
sprintf(
'%s does not support private properties.',
DependencySerializationTrait::class
)
)->tip('See https://www.drupal.org/node/3110266')->build();
}
if ($node->isReadOnly()) {
$errors[] = RuleErrorBuilder::message(
sprintf(
'Read-only properties are incompatible with %s.',
DependencySerializationTrait::class
)
)->tip('See https://www.drupal.org/node/3110266')->build();
}
return $errors;
}
}

View File

@@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
namespace mglaman\PHPStanDrupal\Rules\Drupal\EntityQuery;
use mglaman\PHPStanDrupal\Type\EntityQuery\ConfigEntityQueryType;
use mglaman\PHPStanDrupal\Type\EntityQuery\EntityQueryExecuteWithoutAccessCheckCountType;
use mglaman\PHPStanDrupal\Type\EntityQuery\EntityQueryExecuteWithoutAccessCheckType;
use PhpParser\Node;
use PHPStan\Analyser\Scope;
use PHPStan\Rules\Rule;
use PHPStan\Rules\RuleErrorBuilder;
/**
* @implements Rule<Node\Expr\MethodCall>
*/
final class EntityQueryHasAccessCheckRule implements Rule
{
public function getNodeType(): string
{
return Node\Expr\MethodCall::class;
}
public function processNode(Node $node, Scope $scope): array
{
$name = $node->name;
if (!$name instanceof Node\Identifier) {
return [];
}
if ($name->toString() !== 'execute') {
return [];
}
$type = $scope->getType($node);
if (!$type instanceof EntityQueryExecuteWithoutAccessCheckCountType && !$type instanceof EntityQueryExecuteWithoutAccessCheckType) {
return [];
}
$parent = $scope->getType($node->var);
if ($parent instanceof ConfigEntityQueryType) {
return [];
}
return [
RuleErrorBuilder::message(
'Relying on entity queries to check access by default is deprecated in drupal:9.2.0 and an error will be thrown from drupal:10.0.0. Call \Drupal\Core\Entity\Query\QueryInterface::accessCheck() with TRUE or FALSE to specify whether access should be checked.'
)->tip('See https://www.drupal.org/node/3201242')->build(),
];
}
}

View File

@@ -0,0 +1,78 @@
<?php declare(strict_types=1);
namespace mglaman\PHPStanDrupal\Rules\Drupal;
use PhpParser\Node;
use PHPStan\Analyser\Scope;
use PHPStan\Reflection\ExtendedMethodReflection;
use PHPStan\Rules\Rule;
/**
* @implements Rule<Node\Expr\StaticCall>
*/
class GlobalDrupalDependencyInjectionRule implements Rule
{
public function getNodeType(): string
{
return Node\Expr\StaticCall::class;
}
public function processNode(Node $node, Scope $scope): array
{
// Only check static calls to \Drupal
if (!($node->class instanceof Node\Name\FullyQualified) || (string) $node->class !== 'Drupal') {
return [];
}
// Do not raise if called inside a trait.
if (!$scope->isInClass() || $scope->isInTrait()) {
return [];
}
$scopeClassReflection = $scope->getClassReflection();
// Enums cannot have dependency injection.
if ($scopeClassReflection->isEnum()) {
return [];
}
$allowed_list = [
// Ignore tests.
'PHPUnit\Framework\Test',
// Typed data objects cannot use dependency injection.
'Drupal\Core\TypedData\TypedDataInterface',
// Render elements cannot use dependency injection.
'Drupal\Core\Render\Element\ElementInterface',
'Drupal\Core\Render\Element\FormElementInterface',
'Drupal\config_translation\FormElement\ElementInterface',
// Entities don't use services for now
// @see https://www.drupal.org/project/drupal/issues/2913224
'Drupal\Core\Entity\EntityInterface',
// Stream wrappers are only registered as a service for their tags
// and cannot use dependency injection. Function calls like
// file_exists, stat, etc. will construct the class directly.
'Drupal\Core\StreamWrapper\StreamWrapperInterface',
// Ignore Nightwatch test setup classes.
'Drupal\TestSite\TestSetupInterface',
];
foreach ($allowed_list as $item) {
if ($scopeClassReflection->implementsInterface($item)) {
return [];
}
}
$scopeFunction = $scope->getFunction();
if ($scopeFunction === null) {
return [];
}
if (!$scopeFunction instanceof ExtendedMethodReflection) {
return [];
}
if ($scopeFunction->isStatic()) {
return [];
}
return [
'\Drupal calls should be avoided in classes, use dependency injection instead'
];
}
}

View File

@@ -0,0 +1,61 @@
<?php declare(strict_types=1);
namespace mglaman\PHPStanDrupal\Rules\Drupal;
use mglaman\PHPStanDrupal\Drupal\ExtensionMap;
use PhpParser\Node;
use PHPStan\Analyser\Scope;
use PHPStan\Rules\Rule;
use function count;
/**
* @template TNodeType of Node
* @implements Rule<TNodeType>
*/
abstract class LoadIncludeBase implements Rule
{
/**
* @var \mglaman\PHPStanDrupal\Drupal\ExtensionMap
*/
protected $extensionMap;
public function __construct(ExtensionMap $extensionMap)
{
$this->extensionMap = $extensionMap;
}
private function getStringArgValue(Node\Expr $expr, Scope $scope): ?string
{
$type = $scope->getType($expr);
$stringTypes = $type->getConstantStrings();
if (count($stringTypes) > 0) {
return $stringTypes[0]->getValue();
}
return null;
}
protected function parseLoadIncludeArgs(Node\Arg $module, Node\Arg $type, ?Node\Arg $name, Scope $scope): array
{
$moduleName = $this->getStringArgValue($module->value, $scope);
if ($moduleName === null) {
return [false, false];
}
$fileType = $this->getStringArgValue($type->value, $scope);
if ($fileType === null) {
return [false, false];
}
$baseName = null;
if ($name !== null) {
$baseName = $this->getStringArgValue($name->value, $scope);
if ($baseName === null) {
return [false, false];
}
}
if ($baseName === null) {
$baseName = $moduleName;
}
return [$moduleName, "$baseName.$fileType"];
}
}

View File

@@ -0,0 +1,92 @@
<?php declare(strict_types=1);
namespace mglaman\PHPStanDrupal\Rules\Drupal;
use Drupal\Core\Extension\ModuleHandlerInterface;
use PhpParser\Node;
use PHPStan\Analyser\Scope;
use PHPStan\Rules\RuleErrorBuilder;
use PHPStan\Type\ObjectType;
use Throwable;
use function count;
use function is_file;
use function sprintf;
/**
* @extends LoadIncludeBase<Node\Expr\MethodCall>
*/
class LoadIncludes extends LoadIncludeBase
{
public function getNodeType(): string
{
return Node\Expr\MethodCall::class;
}
public function processNode(Node $node, Scope $scope): array
{
if (!$node->name instanceof Node\Identifier) {
return [];
}
$method_name = $node->name->toString();
if ($method_name !== 'loadInclude') {
return [];
}
$args = $node->getArgs();
if (count($args) < 2) {
return [];
}
$type = $scope->getType($node->var);
$moduleHandlerInterfaceType = new ObjectType(ModuleHandlerInterface::class);
if (!$type->isSuperTypeOf($moduleHandlerInterfaceType)->yes()) {
return [];
}
try {
// Try to invoke it similarly as the module handler itself.
[$moduleName, $filename] = $this->parseLoadIncludeArgs($args[0], $args[1], $args[2] ?? null, $scope);
if (!$moduleName && !$filename) {
// Couldn't determine module- nor file-name, most probably
// because it's a variable. Nothing to load, bail now.
return [];
}
$module = $this->extensionMap->getModule($moduleName);
if ($module === null) {
return [
RuleErrorBuilder::message(sprintf(
'File %s could not be loaded from %s::loadInclude because %s module is not found.',
$filename,
ModuleHandlerInterface::class,
$moduleName
))
->line($node->getStartLine())
->build()
];
}
$file = $module->getAbsolutePath() . DIRECTORY_SEPARATOR . $filename;
if (is_file($file)) {
require_once $file;
return [];
}
return [
RuleErrorBuilder::message(sprintf(
'File %s could not be loaded from %s::loadInclude',
$module->getPath() . '/' . $filename,
ModuleHandlerInterface::class
))
->line($node->getStartLine())
->build()
];
} catch (Throwable $e) {
return [
RuleErrorBuilder::message(sprintf(
'A file could not be loaded from %s::loadInclude',
ModuleHandlerInterface::class
))
->line($node->getStartLine())
->build()
];
}
}
}

View File

@@ -0,0 +1,80 @@
<?php declare(strict_types=1);
namespace mglaman\PHPStanDrupal\Rules\Drupal;
use PhpParser\Node;
use PhpParser\Node\Name;
use PHPStan\Analyser\Scope;
use PHPStan\Rules\RuleErrorBuilder;
use Throwable;
use function count;
use function is_file;
use function sprintf;
/**
* Handles module_load_include dynamic file loading.
*
* @note may become deprecated and removed in D10
* @see https://www.drupal.org/project/drupal/issues/697946
*
* @extends LoadIncludeBase<Node\Expr\FuncCall>
*/
class ModuleLoadInclude extends LoadIncludeBase
{
public function getNodeType(): string
{
return Node\Expr\FuncCall::class;
}
public function processNode(Node $node, Scope $scope): array
{
if (!$node->name instanceof Name) {
return [];
}
$name = (string) $node->name;
if ($name !== 'module_load_include') {
return [];
}
$args = $node->getArgs();
if (count($args) < 2) {
return [];
}
try {
// Try to invoke it similarly as the module handler itself.
[$moduleName, $filename] = $this->parseLoadIncludeArgs($args[1], $args[0], $args[2] ?? null, $scope);
$module = $this->extensionMap->getModule($moduleName);
if ($module === null) {
return [
RuleErrorBuilder::message(sprintf(
'File %s could not be loaded from module_load_include because %s module is not found.',
$filename,
$moduleName
))
->line($node->getStartLine())
->build()
];
}
$file = $module->getAbsolutePath() . DIRECTORY_SEPARATOR . $filename;
if (is_file($file)) {
require_once $file;
return [];
}
return [
RuleErrorBuilder::message(sprintf(
'File %s could not be loaded from module_load_include.',
$module->getPath() . '/' . $filename
))
->line($node->getStartLine())
->build()
];
} catch (Throwable $e) {
return [
RuleErrorBuilder::message('A file could not be loaded from module_load_include')
->line($node->getStartLine())
->build()
];
}
}
}

View File

@@ -0,0 +1,22 @@
<?php declare(strict_types=1);
namespace mglaman\PHPStanDrupal\Rules\Drupal\PluginManager;
use PHPStan\Reflection\ClassReflection;
use PHPStan\Rules\Rule;
/**
* @template TNodeType of \PhpParser\Node
* @implements Rule<TNodeType>
*/
abstract class AbstractPluginManagerRule implements Rule
{
protected function isPluginManager(ClassReflection $classReflection): bool
{
return
!$classReflection->isInterface() &&
!$classReflection->isAnonymous() &&
$classReflection->implementsInterface('Drupal\Component\Plugin\PluginManagerInterface');
}
}

View File

@@ -0,0 +1,99 @@
<?php declare(strict_types=1);
namespace mglaman\PHPStanDrupal\Rules\Drupal\PluginManager;
use PhpParser\Node;
use PhpParser\Node\Stmt\ClassMethod;
use PHPStan\Analyser\Scope;
use PHPStan\ShouldNotHappenException;
use PHPStan\Type\Type;
use function array_map;
use function count;
use function sprintf;
use function strpos;
/**
* @extends AbstractPluginManagerRule<ClassMethod>
*/
class PluginManagerSetsCacheBackendRule extends AbstractPluginManagerRule
{
public function getNodeType(): string
{
return ClassMethod::class;
}
public function processNode(Node $node, Scope $scope): array
{
if (!$scope->isInClass()) {
throw new ShouldNotHappenException();
}
if ($scope->isInTrait()) {
return [];
}
if ($node->name->name !== '__construct') {
return [];
}
$scopeClassReflection = $scope->getClassReflection();
if (!$this->isPluginManager($scopeClassReflection)) {
return [];
}
$hasCacheBackendSet = false;
$misnamedCacheTagWarnings = [];
foreach ($node->stmts ?? [] as $statement) {
if ($statement instanceof Node\Stmt\Expression) {
$statement = $statement->expr;
}
if (($statement instanceof Node\Expr\MethodCall) &&
($statement->name instanceof Node\Identifier) &&
$statement->name->name === 'setCacheBackend') {
// setCacheBackend accepts a cache backend, the cache key, and optional (but suggested) cache tags.
$setCacheBackendArgs = $statement->getArgs();
if (count($setCacheBackendArgs) < 2) {
continue;
}
$hasCacheBackendSet = true;
$cacheKey = array_map(
static fn (Type $type) => $type->getValue(),
$scope->getType($setCacheBackendArgs[1]->value)->getConstantStrings()
);
if (count($cacheKey) === 0) {
continue;
}
if (isset($setCacheBackendArgs[2])) {
$cacheTagsType = $scope->getType($setCacheBackendArgs[2]->value);
foreach ($cacheTagsType->getConstantArrays() as $constantArray) {
foreach ($constantArray->getValueTypes() as $valueType) {
foreach ($valueType->getConstantStrings() as $cacheTagConstantString) {
foreach ($cacheKey as $cacheKeyValue) {
if (strpos($cacheTagConstantString->getValue(), $cacheKeyValue) === false) {
$misnamedCacheTagWarnings[] = $cacheTagConstantString->getValue();
}
}
}
}
}
}
break;
}
}
$errors = [];
if (!$hasCacheBackendSet) {
$errors[] = 'Missing cache backend declaration for performance.';
}
foreach ($misnamedCacheTagWarnings as $cacheTagWarning) {
$errors[] = sprintf('%s cache tag might be unclear and does not contain the cache key in it.', $cacheTagWarning);
}
return $errors;
}
}

View File

@@ -0,0 +1,317 @@
<?php declare(strict_types=1);
namespace mglaman\PHPStanDrupal\Rules\Drupal;
use Drupal\Core\Render\Element\RenderCallbackInterface;
use Drupal\Core\Render\PlaceholderGenerator;
use Drupal\Core\Render\Renderer;
use Drupal\Core\Security\Attribute\TrustedCallback;
use Drupal\Core\Security\TrustedCallbackInterface;
use mglaman\PHPStanDrupal\Drupal\ServiceMap;
use PhpParser\Node;
use PhpParser\Node\Name;
use PHPStan\Analyser\Scope;
use PHPStan\Reflection\ClassReflection;
use PHPStan\Reflection\ReflectionProvider;
use PHPStan\Rules\Rule;
use PHPStan\Rules\RuleErrorBuilder;
use PHPStan\TrinaryLogic;
use PHPStan\Type\ClosureType;
use PHPStan\Type\Constant\ConstantArrayType;
use PHPStan\Type\Constant\ConstantIntegerType;
use PHPStan\Type\Constant\ConstantStringType;
use PHPStan\Type\Generic\GenericClassStringType;
use PHPStan\Type\IntersectionType;
use PHPStan\Type\ObjectType;
use PHPStan\Type\StaticType;
use PHPStan\Type\Type;
use PHPStan\Type\UnionType;
use PHPStan\Type\VerbosityLevel;
use function array_map;
use function array_merge;
use function class_exists;
use function count;
use function explode;
use function preg_match;
use function sprintf;
use function substr_count;
/**
* @implements Rule<Node\Expr\ArrayItem>
*/
final class RenderCallbackRule implements Rule
{
private ReflectionProvider $reflectionProvider;
private ServiceMap $serviceMap;
private array $supportedKeys = [
'#pre_render',
'#post_render',
'#access_callback',
'#lazy_builder',
'#date_time_callbacks',
'#date_date_callbacks',
];
public function __construct(ReflectionProvider $reflectionProvider, ServiceMap $serviceMap)
{
$this->reflectionProvider = $reflectionProvider;
$this->serviceMap = $serviceMap;
}
public function getNodeType(): string
{
return Node\Expr\ArrayItem::class;
}
public function processNode(Node $node, Scope $scope): array
{
$key = $node->key;
if (!$key instanceof Node\Scalar\String_) {
return [];
}
// @see https://www.drupal.org/node/2966725
$keySearch = array_search($key->value, $this->supportedKeys, true);
if ($keySearch === false) {
return [];
}
$keyChecked = $this->supportedKeys[$keySearch];
$value = $node->value;
if ($keyChecked === '#access_callback') {
return $this->doProcessNode($node->value, $scope, $keyChecked, 0);
}
if ($keyChecked === '#lazy_builder') {
if ($scope->isInClass()) {
$classReflection = $scope->getClassReflection();
$classType = new ObjectType($classReflection->getName());
// These classes use #lazy_builder in array_intersect_key. With
// PHPStan 1.6, nodes do not track their parent/next/prev which
// saves a lot of memory. But makes it harder to detect if we're
// in a call to array_intersect_key. This is an easier workaround.
$allowedTypes = new UnionType([
new ObjectType(PlaceholderGenerator::class),
new ObjectType(Renderer::class),
new ObjectType('Drupal\Tests\Core\Render\RendererPlaceholdersTest'),
]);
if ($allowedTypes->isSuperTypeOf($classType)->yes()) {
return [];
}
}
if (!$value instanceof Node\Expr\Array_) {
return [
RuleErrorBuilder::message(sprintf('The "%s" expects a callable array with arguments.', $keyChecked))
->line($node->getStartLine())->build()
];
}
if (count($value->items) === 0) {
return [];
}
// @todo take $value->items[1] and validate parameters against the callback.
return $this->doProcessNode($value->items[0]->value, $scope, $keyChecked, 0);
}
if (!$value instanceof Node\Expr\Array_) {
return [
RuleErrorBuilder::message(sprintf('The "%s" render array value expects an array of callbacks.', $keyChecked))
->line($node->getStartLine())->build()
];
}
if (count($value->items) === 0) {
return [];
}
$errors = [];
foreach ($value->items as $pos => $item) {
$errors[] = $this->doProcessNode($item->value, $scope, $keyChecked, $pos);
}
return array_merge(...$errors);
}
/**
@return (string|\PHPStan\Rules\RuleError)[] errors
*/
private function doProcessNode(Node\Expr $node, Scope $scope, string $keyChecked, int $pos): array
{
$checkIsCallable = true;
$trustedCallbackType = new UnionType([
new ObjectType(TrustedCallbackInterface::class),
new ObjectType(RenderCallbackInterface::class),
]);
$errors = [];
$errorLine = $node->getStartLine();
$type = $this->getType($node, $scope);
foreach ($type->getConstantStrings() as $constantStringType) {
if (!$constantStringType->isCallable()->yes()) {
$errors[] = RuleErrorBuilder::message(
sprintf("%s callback %s at key '%s' is not callable.", $keyChecked, $constantStringType->describe(VerbosityLevel::value()), $pos)
)->line($errorLine)->build();
} elseif ($this->reflectionProvider->hasFunction(new Name($constantStringType->getValue()), null)) {
// We can determine if the callback is callable through the type system. However, we cannot determine
// if it is just a function or a static class call (MyClass::staticFunc).
$errors[] = RuleErrorBuilder::message(
sprintf("%s callback %s at key '%s' is not trusted.", $keyChecked, $constantStringType->describe(VerbosityLevel::value()), $pos)
)->line($errorLine)
->tip('Change record: https://www.drupal.org/node/2966725.')
->build();
} else {
// @see \PHPStan\Type\Constant\ConstantStringType::isCallable
preg_match('#^([a-zA-Z_\\x7f-\\xff\\\\][a-zA-Z0-9_\\x7f-\\xff\\\\]*)::([a-zA-Z_\\x7f-\\xff][a-zA-Z0-9_\\x7f-\\xff]*)\\z#', $constantStringType->getValue(), $matches);
if (count($matches) === 0) {
$errors[] = RuleErrorBuilder::message(
sprintf("%s callback %s at key '%s' is not callable.", $keyChecked, $constantStringType->describe(VerbosityLevel::value()), $pos)
)->line($errorLine)->build();
} elseif (!$trustedCallbackType->isSuperTypeOf(new ObjectType($matches[1]))->yes()) {
$errors[] = RuleErrorBuilder::message(
sprintf("%s callback class %s at key '%s' does not implement Drupal\Core\Security\TrustedCallbackInterface.", $keyChecked, $constantStringType->describe(VerbosityLevel::value()), $pos)
)->line($errorLine)->tip('Change record: https://www.drupal.org/node/2966725.')->build();
}
}
}
foreach ($type->getConstantArrays() as $constantArrayType) {
if (!$constantArrayType->isCallable()->yes()) {
// If the right-hand side of the array is a variable, we cannot
// determine if it is callable. Bail now.
$itemType = $constantArrayType->getItemType();
if ($itemType instanceof UnionType) {
$unionConstantStrings = array_merge(...array_map(static function (Type $type) {
return $type->getConstantStrings();
}, $itemType->getTypes()));
if (count($unionConstantStrings) === 0) {
// Right-hand side of UnionType is not a constant string. We cannot determine if the dynamic
// value is callable or not.
$checkIsCallable = false;
break;
}
}
$errors[] = RuleErrorBuilder::message(
sprintf("%s callback %s at key '%s' is not callable.", $keyChecked, $constantArrayType->describe(VerbosityLevel::value()), $pos)
)->line($errorLine)->build();
continue;
}
$typeAndMethodNames = $constantArrayType->findTypeAndMethodNames();
if ($typeAndMethodNames === []) {
continue;
}
foreach ($typeAndMethodNames as $typeAndMethodName) {
$isTrustedCallbackAttribute = TrinaryLogic::createNo()->lazyOr(
$typeAndMethodName->getType()->getObjectClassReflections(),
function (ClassReflection $reflection) use ($typeAndMethodName) {
if (!class_exists(TrustedCallback::class)) {
return TrinaryLogic::createNo();
}
$hasAttribute = $reflection->getNativeReflection()
->getMethod($typeAndMethodName->getMethod())
->getAttributes(TrustedCallback::class);
return TrinaryLogic::createFromBoolean(count($hasAttribute) > 0);
}
);
$isTrustedCallbackInterfaceType = $trustedCallbackType->isSuperTypeOf($typeAndMethodName->getType())->yes();
if (!$isTrustedCallbackInterfaceType && !$isTrustedCallbackAttribute->yes()) {
if (class_exists(TrustedCallback::class)) {
$errors[] = RuleErrorBuilder::message(
sprintf(
"%s callback method '%s' at key '%s' does not implement attribute \Drupal\Core\Security\Attribute\TrustedCallback.",
$keyChecked,
$constantArrayType->describe(VerbosityLevel::value()),
$pos
)
)->line($errorLine)->tip('Change record: https://www.drupal.org/node/3349470')->build();
} else {
$errors[] = RuleErrorBuilder::message(
sprintf(
"%s callback class '%s' at key '%s' does not implement Drupal\Core\Security\TrustedCallbackInterface.",
$keyChecked,
$typeAndMethodName->getType()->describe(VerbosityLevel::value()),
$pos
)
)->line($errorLine)->tip('Change record: https://www.drupal.org/node/2966725.')->build();
}
}
}
}
// @todo move to its own rule for 1.2.0, FormClosureSerializationRule.
if (($type instanceof ClosureType) && $scope->isInClass()) {
$classReflection = $scope->getClassReflection();
$classType = new ObjectType($classReflection->getName());
$formType = new ObjectType('\Drupal\Core\Form\FormInterface');
if ($formType->isSuperTypeOf($classType)->yes()) {
$errors[] = RuleErrorBuilder::message(
sprintf("%s may not contain a closure at key '%s' as forms may be serialized and serialization of closures is not allowed.", $keyChecked, $pos)
)->line($errorLine)->build();
}
}
if (count($errors) === 0 && ($checkIsCallable && !$type->isCallable()->yes())) {
$errors[] = RuleErrorBuilder::message(
sprintf("%s value '%s' at key '%s' is invalid.", $keyChecked, $type->describe(VerbosityLevel::value()), $pos)
)->line($errorLine)->build();
}
return $errors;
}
// @todo move to a helper, as Drupal uses `service:method` references a lot.
private function getType(Node\Expr $node, Scope $scope): Type
{
$type = $scope->getType($node);
if ($type instanceof IntersectionType) {
// Covers concatenation of static::class . '::methodName'.
if ($node instanceof Node\Expr\BinaryOp\Concat) {
$leftType = $scope->getType($node->left);
$rightType = $scope->getType($node->right);
if ($rightType instanceof ConstantStringType && $leftType instanceof GenericClassStringType && $leftType->getGenericType() instanceof StaticType) {
return new ConstantArrayType(
[new ConstantIntegerType(0), new ConstantIntegerType(1)],
[
$leftType->getGenericType(),
new ConstantStringType(ltrim($rightType->getValue(), ':'))
]
);
}
}
} elseif ($type instanceof ConstantStringType) {
if ($type->isClassStringType()->yes()) {
return $type;
}
// Covers \Drupal\Core\Controller\ControllerResolver::createController.
if (substr_count($type->getValue(), ':') === 1) {
[$class_or_service, $method] = explode(':', $type->getValue(), 2);
$serviceDefinition = $this->serviceMap->getService($class_or_service);
if ($serviceDefinition === null || $serviceDefinition->getClass() === null) {
return $type;
}
return new ConstantArrayType(
[new ConstantIntegerType(0), new ConstantIntegerType(1)],
[
new ObjectType($serviceDefinition->getClass()),
new ConstantStringType($method)
]
);
}
// @see \PHPStan\Type\Constant\ConstantStringType::isCallable
preg_match('#^([a-zA-Z_\\x7f-\\xff\\\\][a-zA-Z0-9_\\x7f-\\xff\\\\]*)::([a-zA-Z_\\x7f-\\xff][a-zA-Z0-9_\\x7f-\\xff]*)\\z#', $type->getValue(), $matches);
if (count($matches) > 0) {
return new ConstantArrayType(
[new ConstantIntegerType(0), new ConstantIntegerType(1)],
[
new StaticType($this->reflectionProvider->getClass($matches[1])),
new ConstantStringType($matches[2])
]
);
}
}
return $type;
}
}

View File

@@ -0,0 +1,60 @@
<?php declare(strict_types=1);
namespace mglaman\PHPStanDrupal\Rules\Drupal;
use Drupal;
use mglaman\PHPStanDrupal\Internal\DeprecatedScopeCheck;
use PhpParser\Node;
use PHPStan\Analyser\Scope;
use PHPStan\Rules\Rule;
use PHPStan\Rules\RuleErrorBuilder;
use PHPStan\Type\ObjectType;
use Symfony\Component\HttpFoundation\RequestStack as SymfonyRequestStack;
use function explode;
use function sprintf;
/**
* @implements Rule<Node\Expr\MethodCall>
*/
final class RequestStackGetMainRequestRule implements Rule
{
public function getNodeType(): string
{
return Node\Expr\MethodCall::class;
}
public function processNode(Node $node, Scope $scope): array
{
if (DeprecatedScopeCheck::inDeprecatedScope($scope)) {
return [];
}
[$major, $minor] = explode('.', Drupal::VERSION, 3);
// Only valid for 9.3 -> 9.5. Deprecated in Drupal 10.
if (($major !== '9' || (int) $minor < 3)) {
return [];
}
if (!$node->name instanceof Node\Identifier) {
return [];
}
$method_name = $node->name->toString();
if ($method_name !== 'getMasterRequest') {
return [];
}
$type = $scope->getType($node->var);
$symfonyRequestStackType = new ObjectType(SymfonyRequestStack::class);
if ($symfonyRequestStackType->isSuperTypeOf($type)->yes()) {
$message = sprintf(
'%s::getMasterRequest() is deprecated in drupal:9.3.0 and is removed from drupal:10.0.0 for Symfony 6 compatibility. Use the forward compatibility shim class %s and its getMainRequest() method instead.',
SymfonyRequestStack::class,
'Drupal\Core\Http\RequestStack'
);
return [
RuleErrorBuilder::message($message)
->tip('Change record: https://www.drupal.org/node/3253744')
->build(),
];
}
return [];
}
}

View File

@@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
namespace mglaman\PHPStanDrupal\Rules\Drupal;
use PhpParser\Node;
use PHPStan\Analyser\Scope;
use PHPStan\Node\ClassPropertyNode;
use PHPStan\Rules\Rule;
use PHPStan\Rules\RuleErrorBuilder;
use PHPUnit\Framework\TestCase;
use function in_array;
use function sprintf;
/**
* @implements Rule<ClassPropertyNode>
*/
class TestClassesProtectedPropertyModulesRule implements Rule
{
public function getNodeType(): string
{
return ClassPropertyNode::class;
}
/**
* @throws \PHPStan\ShouldNotHappenException
*/
public function processNode(Node $node, Scope $scope): array
{
if ($node->getName() !== 'modules') {
return [];
}
$scopeClassReflection = $node->getClassReflection();
if ($scopeClassReflection->isAnonymous()) {
return [];
}
if (!in_array(TestCase::class, $scopeClassReflection->getParentClassesNames(), true)) {
return [];
}
if ($node->isPublic()) {
return [
RuleErrorBuilder::message(
sprintf('Property %s::$modules property must be protected.', $scopeClassReflection->getDisplayName())
)->tip('Change record: https://www.drupal.org/node/2909426')->build(),
];
}
return [];
}
}

View File

@@ -0,0 +1,112 @@
<?php declare(strict_types=1);
namespace mglaman\PHPStanDrupal\Rules\Drupal\Tests;
use PhpParser\Node;
use PHPStan\Analyser\Scope;
use PHPStan\Rules\Rule;
use PHPStan\Rules\RuleErrorBuilder;
use PHPStan\Type\ObjectType;
use PHPStan\Type\TypeCombinator;
use PHPUnit\Framework\Test;
use function count;
use function in_array;
use function interface_exists;
use function method_exists;
use function substr_compare;
/**
* @implements Rule<Node\Stmt\Class_>
*/
final class BrowserTestBaseDefaultThemeRule implements Rule
{
public function getNodeType(): string
{
return Node\Stmt\Class_::class;
}
public function processNode(Node $node, Scope $scope): array
{
if (!interface_exists(Test::class)) {
return [];
}
if ($node->extends === null) {
return [];
}
if ($node->namespacedName === null) {
return [];
}
// Only inspect tests.
// @todo replace this str_ends_with() when php 8 is required.
if (0 !== substr_compare($node->namespacedName->getLast(), 'Test', -4)) {
return [];
}
// Do some cheap preflight tests to make sure the class is in a
// namespace that makes sense to inspect.
// @phpstan-ignore-next-line
$parts = method_exists($node->namespacedName, 'getParts') ? $node->namespacedName->getParts() : $node->namespacedName->parts;
// The namespace is too short to be a test so skip inspection.
if (count($parts) < 3) {
return [];
}
// If the 4th component matches it's a module test. If the 2nd, core.
if ($parts[3] !== 'Functional'
&& $parts [3] !== 'FunctionalJavascript'
&& $parts[1] !== 'FunctionalTests'
&& $parts[1] !== 'FunctionalJavascriptTests') {
return [];
}
$classType = $scope->resolveTypeByName($node->namespacedName);
assert($classType instanceof ObjectType);
$browserTestBaseType = new ObjectType('Drupal\\Tests\\BrowserTestBase');
if (!$browserTestBaseType->isSuperTypeOf($classType)->yes()) {
return [];
}
$excludedTestTypes = TypeCombinator::union(
new ObjectType('Drupal\\FunctionalTests\\Update\\UpdatePathTestBase'),
new ObjectType('Drupal\\FunctionalTests\\Installer\\InstallerConfigDirectoryTestBase'),
new ObjectType('Drupal\\FunctionalTests\\Installer\\InstallerExistingConfigTestBase')
);
if ($excludedTestTypes->isSuperTypeOf($classType)->yes()) {
return [];
}
$reflection = $classType->getClassReflection();
assert($reflection !== null);
if ($reflection->isAbstract()) {
return [];
}
$defaultProperties = $reflection->getNativeReflection()->getDefaultProperties();
$profile = $defaultProperties['profile'] ?? null;
$testingProfilesWithoutThemes = [
'testing',
'nightwatch_testing',
'testing_config_overrides',
'testing_missing_dependencies',
'testing_multilingual',
'testing_multilingual_with_english',
'testing_requirements',
];
if ($profile !== null && !in_array($profile, $testingProfilesWithoutThemes, true)) {
return [];
}
$defaultTheme = $defaultProperties['defaultTheme'] ?? null;
if ($defaultTheme === null || $defaultTheme === '') {
return [
RuleErrorBuilder::message('Drupal\Tests\BrowserTestBase::$defaultTheme is required. See https://www.drupal.org/node/3083055, which includes recommendations on which theme to use.')
->line($node->getStartLine())->build(),
];
}
return [];
}
}

View File

@@ -0,0 +1,69 @@
<?php
declare(strict_types=1);
namespace mglaman\PHPStanDrupal\Rules\Drupal\Tests;
use PhpParser\Node;
use PHPStan\Analyser\Scope;
use PHPStan\Rules\Rule;
use PHPStan\Rules\RuleErrorBuilder;
use PHPStan\Type\ObjectType;
use PHPUnit\Framework\TestCase;
/**
* Implements rule that all non-abstract test classes name should end with "Test".
*
* @implements Rule<Node\Stmt\Class_>
*/
final class TestClassSuffixNameRule implements Rule
{
public function getNodeType(): string
{
return Node\Stmt\Class_::class;
}
public function processNode(Node $node, Scope $scope): array
{
// We're not interested in non-extending classes.
if ($node->extends === null) {
return [];
}
// We're not interested in abstract classes.
if ($node->isAbstract()) {
return [];
}
// We need a namespaced class name.
if ($node->namespacedName === null) {
return [];
}
// We're only interested in \PHPUnit\Framework\TestCase subtype classes.
$classType = $scope->resolveTypeByName($node->namespacedName);
$phpUnitFrameworkTestCaseType = new ObjectType(TestCase::class);
if (!$phpUnitFrameworkTestCaseType->isSuperTypeOf($classType)->yes()) {
return [];
}
// Check class name has suffix "Test".
// @todo replace this str_ends_with() when php 8 is required.
if (substr_compare($node->namespacedName->getLast(), 'Test', -4) === 0) {
return [];
}
return [
RuleErrorBuilder::message(
sprintf(
'Non-abstract test classes names should always have the suffix "Test", found incorrect class name "%s".',
$node->name,
)
)
->line($node->getStartLine())
->tip('See https://www.drupal.org/docs/develop/standards/php/object-oriented-code#naming')
->build()
];
}
}

View File

@@ -0,0 +1,101 @@
<?php declare(strict_types=1);
namespace mglaman\PHPStanDrupal\Type;
use mglaman\PHPStanDrupal\Drupal\ServiceMap;
use PhpParser\Node\Expr\MethodCall;
use PHPStan\Analyser\Scope;
use PHPStan\Reflection\MethodReflection;
use PHPStan\Reflection\ParametersAcceptorSelector;
use PHPStan\Type\Constant\ConstantBooleanType;
use PHPStan\Type\DynamicMethodReturnTypeExtension;
use PHPStan\Type\NullType;
use PHPStan\Type\Type;
use PHPStan\Type\TypeCombinator;
use Symfony\Component\DependencyInjection\ContainerInterface;
use function count;
use function in_array;
class ContainerDynamicReturnTypeExtension implements DynamicMethodReturnTypeExtension
{
/**
* @var ServiceMap
*/
private ServiceMap $serviceMap;
public function __construct(ServiceMap $serviceMap)
{
$this->serviceMap = $serviceMap;
}
public function getClass(): string
{
return ContainerInterface::class;
}
public function isMethodSupported(MethodReflection $methodReflection): bool
{
return in_array($methodReflection->getName(), ['get', 'has'], true);
}
public function getTypeFromMethodCall(
MethodReflection $methodReflection,
MethodCall $methodCall,
Scope $scope
): Type {
$returnType = ParametersAcceptorSelector::selectFromArgs(
$scope,
$methodCall->getArgs(),
$methodReflection->getVariants()
)->getReturnType();
$methodName = $methodReflection->getName();
if ($methodName === 'has') {
$args = $methodCall->getArgs();
if (count($args) !== 1) {
return $returnType;
}
$types = [];
$argType = $scope->getType($args[0]->value);
foreach ($argType->getConstantStrings() as $constantStringType) {
$serviceId = $constantStringType->getValue();
$service = $this->serviceMap->getService($serviceId);
$types[] = new ConstantBooleanType($service !== null);
}
return TypeCombinator::union(...$types);
} elseif ($methodName === 'get') {
$args = $methodCall->getArgs();
if (count($args) === 0) {
return $returnType;
}
$types = [];
if (isset($args[1])) {
$invalidBehaviour = $scope->getType($args[1]->value);
foreach ($invalidBehaviour->getConstantScalarValues() as $value) {
if ($value === ContainerInterface::NULL_ON_INVALID_REFERENCE) {
$types[] = new NullType();
break;
}
}
}
$argType = $scope->getType($args[0]->value);
foreach ($argType->getConstantStrings() as $constantStringType) {
$serviceId = $constantStringType->getValue();
$service = $this->serviceMap->getService($serviceId);
$types[] = $service !== null ? $service->getType() : $returnType;
}
return TypeCombinator::union(...$types);
}
return $returnType;
}
}

View File

@@ -0,0 +1,52 @@
<?php declare(strict_types=1);
namespace mglaman\PHPStanDrupal\Type;
use Drupal\Core\DependencyInjection\ClassResolverInterface;
use mglaman\PHPStanDrupal\Drupal\ServiceMap;
use PhpParser\Node\Expr\MethodCall;
use PHPStan\Analyser\Scope;
use PHPStan\Reflection\MethodReflection;
use PHPStan\Reflection\ParametersAcceptorSelector;
use PHPStan\Type\DynamicMethodReturnTypeExtension;
use PHPStan\Type\Type;
use function count;
class DrupalClassResolverDynamicReturnTypeExtension implements DynamicMethodReturnTypeExtension
{
/**
* @var ServiceMap
*/
private $serviceMap;
public function __construct(ServiceMap $serviceMap)
{
$this->serviceMap = $serviceMap;
}
public function getClass(): string
{
return ClassResolverInterface::class;
}
public function isMethodSupported(MethodReflection $methodReflection): bool
{
return $methodReflection->getName() === 'getInstanceFromDefinition';
}
public function getTypeFromMethodCall(
MethodReflection $methodReflection,
MethodCall $methodCall,
Scope $scope
): Type {
if (0 === count($methodCall->getArgs())) {
return ParametersAcceptorSelector::selectFromArgs(
$scope,
$methodCall->getArgs(),
$methodReflection->getVariants()
)->getReturnType();
}
return DrupalClassResolverReturnType::getType($methodReflection, $methodCall, $scope, $this->serviceMap);
}
}

View File

@@ -0,0 +1,49 @@
<?php declare(strict_types=1);
namespace mglaman\PHPStanDrupal\Type;
use Drupal;
use Drupal\Core\DependencyInjection\ClassResolverInterface;
use mglaman\PHPStanDrupal\Drupal\ServiceMap;
use PhpParser\Node\Expr\StaticCall;
use PHPStan\Analyser\Scope;
use PHPStan\Reflection\MethodReflection;
use PHPStan\Type\DynamicStaticMethodReturnTypeExtension;
use PHPStan\Type\ObjectType;
use PHPStan\Type\Type;
use function count;
class DrupalClassResolverDynamicStaticReturnTypeExtension implements DynamicStaticMethodReturnTypeExtension
{
/**
* @var ServiceMap
*/
private $serviceMap;
public function __construct(ServiceMap $serviceMap)
{
$this->serviceMap = $serviceMap;
}
public function getClass(): string
{
return Drupal::class;
}
public function isStaticMethodSupported(MethodReflection $methodReflection): bool
{
return $methodReflection->getName() === 'classResolver';
}
public function getTypeFromStaticMethodCall(
MethodReflection $methodReflection,
StaticCall $methodCall,
Scope $scope
): Type {
if (0 === count($methodCall->getArgs())) {
return new ObjectType(ClassResolverInterface::class);
}
return DrupalClassResolverReturnType::getType($methodReflection, $methodCall, $scope, $this->serviceMap);
}
}

View File

@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace mglaman\PHPStanDrupal\Type;
use mglaman\PHPStanDrupal\Drupal\DrupalServiceDefinition;
use mglaman\PHPStanDrupal\Drupal\ServiceMap;
use PhpParser\Node\Expr\CallLike;
use PHPStan\Analyser\Scope;
use PHPStan\Reflection\MethodReflection;
use PHPStan\Reflection\ParametersAcceptorSelector;
use PHPStan\Type\ObjectType;
use PHPStan\Type\Type;
use function count;
final class DrupalClassResolverReturnType
{
public static function getType(
MethodReflection $methodReflection,
CallLike $methodCall,
Scope $scope,
ServiceMap $serviceMap
): Type {
$arg1 = $scope->getType($methodCall->getArgs()[0]->value);
if (count($arg1->getConstantStrings()) === 0) {
return ParametersAcceptorSelector::selectFromArgs(
$scope,
$methodCall->getArgs(),
$methodReflection->getVariants()
)->getReturnType();
}
$serviceName = $arg1->getConstantStrings()[0];
$serviceDefinition = $serviceMap->getService($serviceName->getValue());
if ($serviceDefinition instanceof DrupalServiceDefinition) {
return $serviceDefinition->getType();
}
return new ObjectType($serviceName->getValue());
}
}

View File

@@ -0,0 +1,85 @@
<?php declare(strict_types=1);
namespace mglaman\PHPStanDrupal\Type;
use Drupal;
use mglaman\PHPStanDrupal\Drupal\DrupalServiceDefinition;
use mglaman\PHPStanDrupal\Drupal\ServiceMap;
use PhpParser\Node\Expr\ClassConstFetch;
use PhpParser\Node\Expr\StaticCall;
use PhpParser\Node\Name\FullyQualified;
use PhpParser\Node\Scalar\String_;
use PhpParser\Node\VariadicPlaceholder;
use PHPStan\Analyser\Scope;
use PHPStan\Reflection\MethodReflection;
use PHPStan\Reflection\ParametersAcceptorSelector;
use PHPStan\ShouldNotHappenException;
use PHPStan\Type\DynamicStaticMethodReturnTypeExtension;
use PHPStan\Type\Type;
class DrupalServiceDynamicReturnTypeExtension implements DynamicStaticMethodReturnTypeExtension
{
/**
* @var ServiceMap
*/
private $serviceMap;
public function __construct(ServiceMap $serviceMap)
{
$this->serviceMap = $serviceMap;
}
public function getClass(): string
{
return Drupal::class;
}
public function isStaticMethodSupported(MethodReflection $methodReflection): bool
{
return $methodReflection->getName() === 'service';
}
public function getTypeFromStaticMethodCall(
MethodReflection $methodReflection,
StaticCall $methodCall,
Scope $scope
): Type {
$returnType = ParametersAcceptorSelector::selectFromArgs(
$scope,
$methodCall->getArgs(),
$methodReflection->getVariants()
)->getReturnType();
if (!isset($methodCall->args[0])) {
return $returnType;
}
$arg1 = $methodCall->args[0];
if ($arg1 instanceof VariadicPlaceholder) {
throw new ShouldNotHappenException();
}
$arg1 = $arg1->value;
if ($arg1 instanceof String_) {
$serviceId = $arg1->value;
return $this->getServiceType($serviceId) ?? $returnType;
}
if ($arg1 instanceof ClassConstFetch && $arg1->class instanceof FullyQualified) {
$serviceId = (string) $arg1->class;
return $this->getServiceType($serviceId) ?? $returnType;
}
return $returnType;
}
protected function getServiceType(string $serviceId): ?Type
{
$service = $this->serviceMap->getService($serviceId);
if ($service instanceof DrupalServiceDefinition) {
return $service->getType();
}
return null;
}
}

View File

@@ -0,0 +1,99 @@
<?php declare(strict_types=1);
namespace mglaman\PHPStanDrupal\Type;
use Drupal;
use Drupal\Core\Config\Entity\ConfigEntityStorageInterface;
use Drupal\Core\Entity\ContentEntityStorageInterface;
use mglaman\PHPStanDrupal\Drupal\EntityDataRepository;
use mglaman\PHPStanDrupal\Type\EntityQuery\ConfigEntityQueryType;
use mglaman\PHPStanDrupal\Type\EntityQuery\ContentEntityQueryType;
use mglaman\PHPStanDrupal\Type\EntityQuery\EntityQueryType;
use PhpParser\Node\Expr\StaticCall;
use PHPStan\Analyser\Scope;
use PHPStan\Reflection\MethodReflection;
use PHPStan\Reflection\ParametersAcceptorSelector;
use PHPStan\Type\DynamicStaticMethodReturnTypeExtension;
use PHPStan\Type\ObjectType;
use PHPStan\Type\Type;
class DrupalStaticEntityQueryDynamicReturnTypeExtension implements DynamicStaticMethodReturnTypeExtension
{
/**
* @var EntityDataRepository
*/
private $entityDataRepository;
public function __construct(EntityDataRepository $entityDataRepository)
{
$this->entityDataRepository = $entityDataRepository;
}
public function getClass(): string
{
return Drupal::class;
}
public function isStaticMethodSupported(MethodReflection $methodReflection): bool
{
return $methodReflection->getName() === 'entityQuery';
}
public function getTypeFromStaticMethodCall(
MethodReflection $methodReflection,
StaticCall $methodCall,
Scope $scope
): Type {
$returnType = ParametersAcceptorSelector::selectFromArgs(
$scope,
$methodCall->getArgs(),
$methodReflection->getVariants()
)->getReturnType();
if (!$returnType instanceof ObjectType) {
return $returnType;
}
$args = $methodCall->getArgs();
if (count($args) !== 1) {
return $returnType;
}
$type = $scope->getType($args[0]->value);
if (count($type->getConstantStrings()) === 0) {
// We're unsure what specific EntityQueryType it is, so let's stick
// with the general class itself to ensure it gets access checked.
return new EntityQueryType(
$returnType->getClassName(),
$returnType->getSubtractedType(),
$returnType->getClassReflection()
);
}
$entityTypeId = $type->getConstantStrings()[0]->getValue();
$entityType = $this->entityDataRepository->get($entityTypeId);
$entityStorageType = $entityType->getStorageType();
if ($entityStorageType === null) {
return $returnType;
}
if ((new ObjectType(ContentEntityStorageInterface::class))->isSuperTypeOf($entityStorageType)->yes()) {
return new ContentEntityQueryType(
$returnType->getClassName(),
$returnType->getSubtractedType(),
$returnType->getClassReflection()
);
}
if ((new ObjectType(ConfigEntityStorageInterface::class))->isSuperTypeOf($entityStorageType)->yes()) {
return new ConfigEntityQueryType(
$returnType->getClassName(),
$returnType->getSubtractedType(),
$returnType->getClassReflection()
);
}
return new EntityQueryType(
$returnType->getClassName(),
$returnType->getSubtractedType(),
$returnType->getClassReflection()
);
}
}

View File

@@ -0,0 +1,56 @@
<?php declare(strict_types=1);
namespace mglaman\PHPStanDrupal\Type;
use Drupal\Core\Access\AccessResultInterface;
use Drupal\Core\Entity\EntityAccessControlHandlerInterface;
use PhpParser\Node\Expr\MethodCall;
use PHPStan\Analyser\Scope;
use PHPStan\Reflection\MethodReflection;
use PHPStan\Type\BooleanType;
use PHPStan\Type\DynamicMethodReturnTypeExtension;
use PHPStan\Type\ObjectType;
use PHPStan\Type\Type;
use function count;
use function in_array;
final class EntityAccessControlHandlerReturnTypeExtension implements DynamicMethodReturnTypeExtension
{
public function getClass(): string
{
return EntityAccessControlHandlerInterface::class;
}
public function isMethodSupported(MethodReflection $methodReflection): bool
{
return in_array($methodReflection->getName(), ['access', 'createAccess', 'fieldAccess'], true);
}
public function getTypeFromMethodCall(MethodReflection $methodReflection, MethodCall $methodCall, Scope $scope): Type
{
$returnType = new BooleanType();
$args = $methodCall->getArgs();
$arg = null;
if ($methodReflection->getName() === 'access' && count($args) === 4) {
$arg = $args[3];
}
if ($methodReflection->getName() === 'createAccess' && count($args) === 4) {
$arg = $args[3];
}
if ($methodReflection->getName() === 'fieldAccess' && count($args) === 5) {
$arg = $args[4];
}
if ($arg === null) {
return $returnType;
}
$returnAsObjectArg = $scope->getType($arg->value);
if (!$returnAsObjectArg->isBoolean()->yes()) {
return $returnType;
}
return $returnAsObjectArg->isTrue()->yes() ? new ObjectType(AccessResultInterface::class) : new BooleanType();
}
}

View File

@@ -0,0 +1,59 @@
<?php declare(strict_types=1);
namespace mglaman\PHPStanDrupal\Type\EntityQuery;
use Drupal\Core\Entity\Query\QueryInterface;
use PhpParser\Node\Expr\MethodCall;
use PHPStan\Analyser\Scope;
use PHPStan\Analyser\SpecifiedTypes;
use PHPStan\Analyser\TypeSpecifier;
use PHPStan\Analyser\TypeSpecifierAwareExtension;
use PHPStan\Analyser\TypeSpecifierContext;
use PHPStan\Reflection\MethodReflection;
use PHPStan\Reflection\ParametersAcceptorSelector;
use PHPStan\Type\MethodTypeSpecifyingExtension;
final class AccessCheckTypeSpecifyingExtension implements MethodTypeSpecifyingExtension, TypeSpecifierAwareExtension
{
private TypeSpecifier $typeSpecifier;
public function setTypeSpecifier(TypeSpecifier $typeSpecifier) : void
{
$this->typeSpecifier = $typeSpecifier;
}
public function getClass(): string
{
return QueryInterface::class;
}
public function isMethodSupported(
MethodReflection $methodReflection,
MethodCall $node,
TypeSpecifierContext $context
): bool {
return $methodReflection->getName() === 'accessCheck';
}
public function specifyTypes(
MethodReflection $methodReflection,
MethodCall $node,
Scope $scope,
TypeSpecifierContext $context
): SpecifiedTypes {
$returnType = ParametersAcceptorSelector::selectFromArgs(
$scope,
[],
$methodReflection->getVariants()
)->getReturnType();
$expr = $node->var;
if (!$returnType instanceof EntityQueryType) {
return new SpecifiedTypes([]);
}
return $this->typeSpecifier->create(
$expr,
$returnType->withAccessCheck(),
TypeSpecifierContext::createTruthy(),
true
);
}
}

View File

@@ -0,0 +1,11 @@
<?php declare(strict_types=1);
namespace mglaman\PHPStanDrupal\Type\EntityQuery;
/**
* Type used to represent an entity query instance for config entity query.
*/
final class ConfigEntityQueryType extends EntityQueryType
{
}

View File

@@ -0,0 +1,11 @@
<?php declare(strict_types=1);
namespace mglaman\PHPStanDrupal\Type\EntityQuery;
/**
* Type used to represent an entity query instance for content entity query.
*/
final class ContentEntityQueryType extends EntityQueryType
{
}

View File

@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace mglaman\PHPStanDrupal\Type\EntityQuery;
use Drupal\Core\Entity\Query\QueryInterface;
use PhpParser\Node\Expr\MethodCall;
use PHPStan\Analyser\Scope;
use PHPStan\Reflection\MethodReflection;
use PHPStan\Reflection\ParametersAcceptorSelector;
use PHPStan\Type\DynamicMethodReturnTypeExtension;
use PHPStan\Type\Type;
class EntityQueryAccessCheckDynamicReturnTypeExtension implements DynamicMethodReturnTypeExtension
{
public function getClass(): string
{
return QueryInterface::class;
}
public function isMethodSupported(MethodReflection $methodReflection): bool
{
return 'accessCheck' === $methodReflection->getName();
}
public function getTypeFromMethodCall(
MethodReflection $methodReflection,
MethodCall $methodCall,
Scope $scope
): Type {
$varType = $scope->getType($methodCall->var);
if (!$varType instanceof EntityQueryType) {
return ParametersAcceptorSelector::selectFromArgs(
$scope,
$methodCall->getArgs(),
$methodReflection->getVariants()
)->getReturnType();
}
return $varType->withAccessCheck();
}
}

View File

@@ -0,0 +1,11 @@
<?php declare(strict_types=1);
namespace mglaman\PHPStanDrupal\Type\EntityQuery;
/**
* Type used to represent an entity query instance as count query.
*/
final class EntityQueryCountType extends EntityQueryType
{
}

View File

@@ -0,0 +1,86 @@
<?php declare(strict_types=1);
namespace mglaman\PHPStanDrupal\Type\EntityQuery;
use Drupal\Core\Entity\Query\QueryInterface;
use PhpParser\Node\Expr\MethodCall;
use PHPStan\Analyser\Scope;
use PHPStan\Reflection\MethodReflection;
use PHPStan\Reflection\ParametersAcceptorSelector;
use PHPStan\Type\ArrayType;
use PHPStan\Type\DynamicMethodReturnTypeExtension;
use PHPStan\Type\IntegerType;
use PHPStan\Type\ObjectType;
use PHPStan\Type\StringType;
use PHPStan\Type\Type;
use function in_array;
class EntityQueryDynamicReturnTypeExtension implements DynamicMethodReturnTypeExtension
{
public function getClass(): string
{
return QueryInterface::class;
}
public function isMethodSupported(MethodReflection $methodReflection): bool
{
return in_array($methodReflection->getName(), [
'count',
'execute',
], true);
}
public function getTypeFromMethodCall(
MethodReflection $methodReflection,
MethodCall $methodCall,
Scope $scope
): Type {
$defaultReturnType = ParametersAcceptorSelector::selectFromArgs(
$scope,
$methodCall->getArgs(),
$methodReflection->getVariants()
)->getReturnType();
$varType = $scope->getType($methodCall->var);
$methodName = $methodReflection->getName();
if (!$varType instanceof ObjectType) {
return $defaultReturnType;
}
if ($methodName === 'count') {
if ($varType instanceof EntityQueryType) {
return $varType->asCount();
}
// By now we are sure we can't determine anything about what query
// the count method is on, so we ignore it.
return $defaultReturnType;
}
if ($methodName === 'execute') {
if (!$varType instanceof EntityQueryType) {
return $defaultReturnType;
}
if ($varType->isCount()) {
return $varType->hasAccessCheck()
? new IntegerType()
: new EntityQueryExecuteWithoutAccessCheckCountType();
}
if ($varType instanceof ConfigEntityQueryType) {
return $varType->hasAccessCheck()
? new ArrayType(new StringType(), new StringType())
: new EntityQueryExecuteWithoutAccessCheckType(new StringType(), new StringType());
}
if ($varType instanceof ContentEntityQueryType) {
return $varType->hasAccessCheck()
? new ArrayType(new IntegerType(), new StringType())
: new EntityQueryExecuteWithoutAccessCheckType(new IntegerType(), new StringType());
}
return $varType->hasAccessCheck()
? new ArrayType(new IntegerType(), new StringType())
: new EntityQueryExecuteWithoutAccessCheckType(new IntegerType(), new StringType());
}
return $defaultReturnType;
}
}

View File

@@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace mglaman\PHPStanDrupal\Type\EntityQuery;
use PHPStan\Type\IntegerType;
final class EntityQueryExecuteWithoutAccessCheckCountType extends IntegerType
{
}

View File

@@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace mglaman\PHPStanDrupal\Type\EntityQuery;
use PHPStan\Type\ArrayType;
final class EntityQueryExecuteWithoutAccessCheckType extends ArrayType
{
}

View File

@@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
namespace mglaman\PHPStanDrupal\Type\EntityQuery;
use PHPStan\Type\ObjectType;
use function implode;
class EntityQueryType extends ObjectType
{
private bool $hasAccessCheck = false;
private bool $isCount = false;
public function hasAccessCheck(): bool
{
return $this->hasAccessCheck;
}
public function isCount(): bool
{
return $this->isCount;
}
public function withAccessCheck(): self
{
// The constructor of ObjectType is under backward compatibility promise.
// @see https://phpstan.org/developing-extensions/backward-compatibility-promise
// @phpstan-ignore-next-line
$type = new static(
$this->getClassName(),
$this->getSubtractedType(),
$this->getClassReflection()
);
$type->hasAccessCheck = true;
$type->isCount = $this->isCount;
return $type;
}
public function asCount(): self
{
// @phpstan-ignore-next-line
$type = new static(
$this->getClassName(),
$this->getSubtractedType(),
$this->getClassReflection()
);
$type->hasAccessCheck = $this->hasAccessCheck;
$type->isCount = true;
return $type;
}
protected function describeAdditionalCacheKey(): string
{
$parts = [
$this->hasAccessCheck ? 'with-access-check' : 'without-access-check',
$this->isCount ? '' : 'count'
];
return implode('-', $parts);
}
}

View File

@@ -0,0 +1,100 @@
<?php
declare(strict_types=1);
namespace mglaman\PHPStanDrupal\Type;
use Drupal\Core\Config\Entity\ConfigEntityInterface;
use Drupal\Core\Entity\EntityRepositoryInterface;
use mglaman\PHPStanDrupal\Drupal\EntityDataRepository;
use PhpParser\Node\Expr\MethodCall;
use PHPStan\Analyser\Scope;
use PHPStan\Reflection\MethodReflection;
use PHPStan\Reflection\ParametersAcceptorSelector;
use PHPStan\Type\ArrayType;
use PHPStan\Type\DynamicMethodReturnTypeExtension;
use PHPStan\Type\IntegerType;
use PHPStan\Type\ObjectType;
use PHPStan\Type\StringType;
use PHPStan\Type\Type;
use PHPStan\Type\TypeCombinator;
final class EntityRepositoryReturnTypeExtension implements DynamicMethodReturnTypeExtension
{
/**
* @var EntityDataRepository
*/
private $entityDataRepository;
public function __construct(EntityDataRepository $entityDataRepository)
{
$this->entityDataRepository = $entityDataRepository;
}
public function getClass(): string
{
return EntityRepositoryInterface::class;
}
public function isMethodSupported(MethodReflection $methodReflection): bool
{
return in_array(
$methodReflection->getName(),
[
'getTranslationFromContext',
'loadEntityByUuid',
'loadEntityByConfigTarget',
'getActive',
'getActiveMultiple',
'getCanonical',
'getCanonicalMultiple',
],
true
);
}
public function getTypeFromMethodCall(
MethodReflection $methodReflection,
MethodCall $methodCall,
Scope $scope
): ?Type {
$methodName = $methodReflection->getName();
$methodArgs = $methodCall->getArgs();
$returnType = ParametersAcceptorSelector::selectFromArgs(
$scope,
$methodCall->getArgs(),
$methodReflection->getVariants()
)->getReturnType();
if (count($methodArgs) === 0) {
return $returnType;
}
if ($methodName === 'getTranslationFromContext') {
return $scope->getType($methodArgs[0]->value);
}
$entityObjectTypes = [];
$entityIdArg = $scope->getType($methodArgs[0]->value);
foreach ($entityIdArg->getConstantStrings() as $constantStringType) {
$entityObjectTypes[] = $this->entityDataRepository->get($constantStringType->getValue())->getClassType() ?? $returnType;
}
$entityTypes = TypeCombinator::union(...$entityObjectTypes);
if ($returnType->isArray()->no()) {
if ($returnType->isNull()->maybe()) {
$entityTypes = TypeCombinator::addNull($entityTypes);
}
return $entityTypes;
}
if ((new ObjectType(ConfigEntityInterface::class))->isSuperTypeOf($entityTypes)->yes()) {
$keyType = new StringType();
} else {
$keyType = new IntegerType();
}
return new ArrayType($keyType, $entityTypes);
}
}

View File

@@ -0,0 +1,10 @@
<?php
declare(strict_types=1);
namespace mglaman\PHPStanDrupal\Type\EntityStorage;
final class ConfigEntityStorageType extends EntityStorageType
{
}

View File

@@ -0,0 +1,10 @@
<?php
declare(strict_types=1);
namespace mglaman\PHPStanDrupal\Type\EntityStorage;
final class ContentEntityStorageType extends EntityStorageType
{
}

View File

@@ -0,0 +1,111 @@
<?php declare(strict_types=1);
namespace mglaman\PHPStanDrupal\Type\EntityStorage;
use Drupal\Core\Config\Entity\ConfigEntityStorageInterface;
use Drupal\Core\Entity\EntityStorageInterface;
use mglaman\PHPStanDrupal\Drupal\EntityDataRepository;
use PhpParser\Node\Expr\MethodCall;
use PHPStan\Analyser\Scope;
use PHPStan\Reflection\MethodReflection;
use PHPStan\Reflection\ParametersAcceptorSelector;
use PHPStan\Type\ArrayType;
use PHPStan\Type\DynamicMethodReturnTypeExtension;
use PHPStan\Type\IntegerType;
use PHPStan\Type\ObjectType;
use PHPStan\Type\StringType;
use PHPStan\Type\Type;
use PHPStan\Type\TypeCombinator;
use function in_array;
class EntityStorageDynamicReturnTypeExtension implements DynamicMethodReturnTypeExtension
{
/**
* @var EntityDataRepository
*/
private $entityDataRepository;
public function __construct(EntityDataRepository $entityDataRepository)
{
$this->entityDataRepository = $entityDataRepository;
}
public function getClass(): string
{
return EntityStorageInterface::class;
}
public function isMethodSupported(MethodReflection $methodReflection): bool
{
return in_array(
$methodReflection->getName(),
[
'create',
'load',
'loadMultiple',
'loadByProperties',
'loadUnchanged',
],
true
);
}
public function getTypeFromMethodCall(
MethodReflection $methodReflection,
MethodCall $methodCall,
Scope $scope
): Type {
$callerType = $scope->getType($methodCall->var);
if (!$callerType instanceof ObjectType) {
return ParametersAcceptorSelector::selectFromArgs(
$scope,
$methodCall->getArgs(),
$methodReflection->getVariants()
)->getReturnType();
}
if (!$callerType instanceof EntityStorageType) {
$resolvedEntityType = $this->entityDataRepository->resolveFromStorage($callerType);
if ($resolvedEntityType === null) {
return ParametersAcceptorSelector::selectFromArgs(
$scope,
$methodCall->getArgs(),
$methodReflection->getVariants()
)->getReturnType();
}
$type = $resolvedEntityType->getClassType();
} else {
$type = $this->entityDataRepository->get($callerType->getEntityTypeId())->getClassType();
}
if ($type === null) {
return ParametersAcceptorSelector::selectFromArgs(
$scope,
$methodCall->getArgs(),
$methodReflection->getVariants()
)->getReturnType();
}
if (in_array($methodReflection->getName(), ['load', 'loadUnchanged'], true)) {
return TypeCombinator::addNull($type);
}
if (in_array($methodReflection->getName(), ['loadMultiple', 'loadByProperties'], true)) {
if ((new ObjectType(ConfigEntityStorageInterface::class))->isSuperTypeOf($callerType)->yes()) {
return new ArrayType(new StringType(), $type);
}
return new ArrayType(new IntegerType(), $type);
}
if ($methodReflection->getName() === 'create') {
return $type;
}
return ParametersAcceptorSelector::selectFromArgs(
$scope,
$methodCall->getArgs(),
$methodReflection->getVariants()
)->getReturnType();
}
}

View File

@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace mglaman\PHPStanDrupal\Type\EntityStorage;
use PHPStan\Reflection\ClassReflection;
use PHPStan\Type\ObjectType;
use PHPStan\Type\Type;
class EntityStorageType extends ObjectType
{
/**
* @var string
*/
private $entityTypeId;
public function __construct(
string $entityTypeId,
string $className,
?Type $subtractedType = null,
?ClassReflection $classReflection = null
) {
parent::__construct($className, $subtractedType, $classReflection);
$this->entityTypeId = $entityTypeId;
}
public function getEntityTypeId(): string
{
return $this->entityTypeId;
}
}

View File

@@ -0,0 +1,75 @@
<?php declare(strict_types=1);
namespace mglaman\PHPStanDrupal\Type\EntityStorage;
use Drupal\Core\Config\Entity\ConfigEntityStorageInterface;
use Drupal\Core\Entity\ContentEntityStorageInterface;
use Drupal\Core\Entity\EntityStorageInterface;
use mglaman\PHPStanDrupal\Type\EntityQuery\ConfigEntityQueryType;
use mglaman\PHPStanDrupal\Type\EntityQuery\ContentEntityQueryType;
use mglaman\PHPStanDrupal\Type\EntityQuery\EntityQueryType;
use PhpParser\Node\Expr\MethodCall;
use PHPStan\Analyser\Scope;
use PHPStan\Reflection\MethodReflection;
use PHPStan\Reflection\ParametersAcceptorSelector;
use PHPStan\Type\DynamicMethodReturnTypeExtension;
use PHPStan\Type\ObjectType;
use PHPStan\Type\Type;
use function in_array;
final class GetQueryReturnTypeExtension implements DynamicMethodReturnTypeExtension
{
public function getClass(): string
{
return EntityStorageInterface::class;
}
public function isMethodSupported(MethodReflection $methodReflection): bool
{
return in_array($methodReflection->getName(), [
'getQuery',
'getAggregateQuery',
], true);
}
public function getTypeFromMethodCall(
MethodReflection $methodReflection,
MethodCall $methodCall,
Scope $scope
): Type {
$returnType = ParametersAcceptorSelector::selectFromArgs(
$scope,
$methodCall->getArgs(),
$methodReflection->getVariants()
)->getReturnType();
if (!$returnType instanceof ObjectType) {
return $returnType;
}
$callerType = $scope->getType($methodCall->var);
if (!$callerType->isObject()->yes()) {
return $returnType;
}
if ((new ObjectType(ContentEntityStorageInterface::class))->isSuperTypeOf($callerType)->yes()) {
return new ContentEntityQueryType(
$returnType->getClassName(),
$returnType->getSubtractedType(),
$returnType->getClassReflection()
);
}
if ((new ObjectType(ConfigEntityStorageInterface::class))->isSuperTypeOf($callerType)->yes()) {
return new ConfigEntityQueryType(
$returnType->getClassName(),
$returnType->getSubtractedType(),
$returnType->getClassReflection()
);
}
return new EntityQueryType(
$returnType->getClassName(),
$returnType->getSubtractedType(),
$returnType->getClassReflection()
);
}
}

View File

@@ -0,0 +1,93 @@
<?php declare(strict_types=1);
namespace mglaman\PHPStanDrupal\Type;
use mglaman\PHPStanDrupal\Drupal\EntityDataRepository;
use mglaman\PHPStanDrupal\Type\EntityStorage\EntityStorageType;
use PhpParser\Node\Expr\BinaryOp\Concat;
use PhpParser\Node\Expr\MethodCall;
use PhpParser\Node\VariadicPlaceholder;
use PHPStan\Analyser\Scope;
use PHPStan\Reflection\MethodReflection;
use PHPStan\Reflection\ParametersAcceptorSelector;
use PHPStan\ShouldNotHappenException;
use PHPStan\Type\DynamicMethodReturnTypeExtension;
use PHPStan\Type\ObjectType;
use PHPStan\Type\Type;
class EntityTypeManagerGetStorageDynamicReturnTypeExtension implements DynamicMethodReturnTypeExtension
{
/**
* @var EntityDataRepository
*/
private $entityDataRepository;
/**
* EntityTypeManagerGetStorageDynamicReturnTypeExtension constructor.
*
* @param EntityDataRepository $entityDataRepository
*/
public function __construct(EntityDataRepository $entityDataRepository)
{
$this->entityDataRepository = $entityDataRepository;
}
public function getClass(): string
{
return 'Drupal\Core\Entity\EntityTypeManagerInterface';
}
public function isMethodSupported(MethodReflection $methodReflection): bool
{
return $methodReflection->getName() === 'getStorage';
}
public function getTypeFromMethodCall(
MethodReflection $methodReflection,
MethodCall $methodCall,
Scope $scope
): Type {
$returnType = ParametersAcceptorSelector::selectFromArgs(
$scope,
$methodCall->getArgs(),
$methodReflection->getVariants()
)->getReturnType();
if (!isset($methodCall->args[0])) {
// Parameter is required.
throw new ShouldNotHappenException();
}
$arg1 = $methodCall->args[0];
if ($arg1 instanceof VariadicPlaceholder) {
throw new ShouldNotHappenException();
}
$arg1 = $arg1->value;
// @todo handle where the first param is EntityTypeInterface::id()
if ($arg1 instanceof MethodCall) {
// There may not be much that can be done, since it's a generic EntityTypeInterface.
return $returnType;
}
// @todo handle concat ie: entity_{$display_context}_display for entity_form_display or entity_view_display
if ($arg1 instanceof Concat) {
return $returnType;
}
$type = $scope->getType($arg1);
if (count($type->getConstantStrings()) === 0) {
return $returnType;
}
$entityTypeId = $type->getConstantStrings()[0]->getValue();
$storageType = $this->entityDataRepository->get($entityTypeId)->getStorageType();
if ($storageType !== null) {
return $storageType;
}
if ($returnType instanceof ObjectType) {
return new EntityStorageType($entityTypeId, $returnType->getClassName());
}
return $returnType;
}
}

View File

@@ -0,0 +1,7 @@
<?php
namespace Drupal\Component\Datetime;
class DateTimePlus {
}

View File

@@ -0,0 +1,6 @@
<?php
namespace Drupal\Component\Plugin;
interface PluginInspectionInterface {
}

View File

@@ -0,0 +1,6 @@
<?php
namespace Drupal\Component\Render;
interface MarkupInterface extends \JsonSerializable, \Stringable {
}

View File

@@ -0,0 +1,21 @@
<?php
namespace Drupal\Core\Access;
use Drupal\Core\Cache\RefinableCacheableDependencyInterface;
abstract class AccessResult implements AccessResultInterface, RefinableCacheableDependencyInterface {
/**
* @return ($condition is true ? \Drupal\Core\Access\AccessResultAllowed : \Drupal\Core\Access\AccessResultNeutral)
*/
public static function allowedIf(bool $condition) {
}
/**
* @return ($condition is true ? \Drupal\Core\Access\AccessResultForbidden : \Drupal\Core\Access\AccessResultNeutral)
*/
public static function forbiddenIf(bool $condition, ?string $reason = NULL) {
}
}

Some files were not shown because too many files have changed in this diff Show More