diff --git a/.gitattributes b/.gitattributes index aae3fd19..581d6e31 100644 --- a/.gitattributes +++ b/.gitattributes @@ -9,3 +9,4 @@ /phpstan.neon export-ignore /phpstan-baseline.neon export-ignore /phpunit.xml export-ignore +/CLAUDE.md export-ignore diff --git a/.github/actionlint-matcher.json b/.github/actionlint-matcher.json new file mode 100644 index 00000000..4613e161 --- /dev/null +++ b/.github/actionlint-matcher.json @@ -0,0 +1,17 @@ +{ + "problemMatcher": [ + { + "owner": "actionlint", + "pattern": [ + { + "regexp": "^(?:\\x1b\\[\\d+m)?(.+?)(?:\\x1b\\[\\d+m)*:(?:\\x1b\\[\\d+m)*(\\d+)(?:\\x1b\\[\\d+m)*:(?:\\x1b\\[\\d+m)*(\\d+)(?:\\x1b\\[\\d+m)*: (?:\\x1b\\[\\d+m)*(.+?)(?:\\x1b\\[\\d+m)* \\[(.+?)\\]$", + "file": 1, + "line": 2, + "column": 3, + "message": 4, + "code": 5 + } + ] + } + ] +} diff --git a/.github/actionlint.yaml b/.github/actionlint.yaml new file mode 100644 index 00000000..b24efa8b --- /dev/null +++ b/.github/actionlint.yaml @@ -0,0 +1,22 @@ +self-hosted-runner: + # Labels of self-hosted runner in array of strings. + labels: [] + +# Configuration variables in array of strings defined in your repository or +# organization. `null` means disabling configuration variables check. +# Empty array means no configuration variable is allowed. +config-variables: null + +# Configuration for file paths. The keys are glob patterns to match to file +# paths relative to the repository root. The values are the configurations for +# the file paths. Note that the path separator is always '/'. +# The following configurations are available. +# +# "ignore" is an array of regular expression patterns. Matched error messages +# are ignored. This is similar to the "-ignore" command line option. +paths: + .github/workflows/**/*.{yml,yaml}: + # List of regular expressions to filter errors by the error messages. + ignore: + # Ignore the specific error from shellcheck + - 'shellcheck reported issue in this script: SC2129:.+' diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 979caa44..5e9d53f2 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -26,13 +26,19 @@ jobs: - "8.2" - "8.3" - "8.4" + - "8.5" steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2 + with: + egress-policy: audit + - name: "Checkout" - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: "Install PHP" - uses: "shivammathur/setup-php@v2" + uses: "shivammathur/setup-php@44454db4f0199b8b9685a5d763dc37cbf79108e1" # v2 with: coverage: "none" php-version: "${{ matrix.php-version }}" @@ -52,18 +58,23 @@ jobs: runs-on: "ubuntu-latest" steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2 + with: + egress-policy: audit + - name: "Checkout" - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: "Checkout build-cs" - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: repository: "phpstan/build-cs" path: "build-cs" ref: "2.x" - name: "Install PHP" - uses: "shivammathur/setup-php@v2" + uses: "shivammathur/setup-php@44454db4f0199b8b9685a5d763dc37cbf79108e1" # v2 with: coverage: "none" php-version: "8.2" @@ -98,6 +109,7 @@ jobs: - "8.2" - "8.3" - "8.4" + - "8.5" dependencies: - "lowest" - "highest" @@ -127,11 +139,16 @@ jobs: phpunit-version: "^12.0.9" steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2 + with: + egress-policy: audit + - name: "Checkout" - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: "Install PHP" - uses: "shivammathur/setup-php@v2" + uses: "shivammathur/setup-php@44454db4f0199b8b9685a5d763dc37cbf79108e1" # v2 with: coverage: "none" php-version: "${{ matrix.php-version }}" @@ -164,6 +181,7 @@ jobs: - "8.2" - "8.3" - "8.4" + - "8.5" dependencies: - "lowest" - "highest" @@ -193,11 +211,16 @@ jobs: phpunit-version: "^12.0.9" steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2 + with: + egress-policy: audit + - name: "Checkout" - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: "Install PHP" - uses: "shivammathur/setup-php@v2" + uses: "shivammathur/setup-php@44454db4f0199b8b9685a5d763dc37cbf79108e1" # v2 with: coverage: "none" php-version: "${{ matrix.php-version }}" @@ -230,14 +253,20 @@ jobs: - "8.2" - "8.3" - "8.4" + - "8.5" operating-system: [ubuntu-latest] steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2 + with: + egress-policy: audit + - name: "Checkout" - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: "Checkout build-infection" - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: repository: "phpstan/build-infection" path: "build-infection" @@ -259,15 +288,10 @@ jobs: run: | php build-infection/bin/infection-config.php \ > infection.json5 - cat infection.json5 | jq - - - name: "Determine default branch" - id: default-branch - run: | - echo "name=$(git remote show origin | sed -n '/HEAD branch/s/.*: //p')" >> $GITHUB_OUTPUT + jq . infection.json5 - name: "Restore result cache" - uses: actions/cache/restore@v4 + uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 with: path: ./tmp key: "result-cache-v1-${{ matrix.php-version }}-${{ github.run_id }}" @@ -276,9 +300,9 @@ jobs: - name: "Run infection" run: | - git fetch --depth=1 origin ${{ steps.default-branch.outputs.name }} + git fetch --depth=1 origin "$GITHUB_BASE_REF" infection \ - --git-diff-base=origin/${{ steps.default-branch.outputs.name }} \ + --git-diff-base=origin/"$GITHUB_BASE_REF" \ --git-diff-lines \ --ignore-msi-with-no-mutations \ --min-msi=100 \ @@ -288,7 +312,7 @@ jobs: --logger-text=php://stdout - name: "Save result cache" - uses: actions/cache/save@v4 + uses: actions/cache/save@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 if: ${{ !cancelled() }} with: path: ./tmp diff --git a/.github/workflows/create-tag.yml b/.github/workflows/create-tag.yml index a946f1c7..07b7281e 100644 --- a/.github/workflows/create-tag.yml +++ b/.github/workflows/create-tag.yml @@ -20,33 +20,38 @@ jobs: name: "Create tag" runs-on: "ubuntu-latest" steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2 + with: + egress-policy: audit + - name: "Checkout" - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 0 token: ${{ secrets.PHPSTAN_BOT_TOKEN }} - name: 'Get Previous tag' id: previoustag - uses: "WyriHaximus/github-action-get-previous-tag@v1" + uses: "WyriHaximus/github-action-get-previous-tag@61819f33034117e6c686e6a31dba995a85afc9de" # v2.0.0 env: GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}" - name: 'Get next versions' id: semvers - uses: "WyriHaximus/github-action-next-semvers@v1" + uses: "WyriHaximus/github-action-next-semvers@d079934efaf011a4cf8912d4637097fe35d32b93" # v1 with: version: ${{ steps.previoustag.outputs.tag }} - name: "Create new minor tag" - uses: rickstaa/action-create-tag@v1 + uses: rickstaa/action-create-tag@a1c7777fcb2fee4f19b0f283ba888afa11678b72 # v1.7.2 if: inputs.version == 'minor' with: tag: ${{ steps.semvers.outputs.minor }} message: ${{ steps.semvers.outputs.minor }} - name: "Create new patch tag" - uses: rickstaa/action-create-tag@v1 + uses: rickstaa/action-create-tag@a1c7777fcb2fee4f19b0f283ba888afa11678b72 # v1.7.2 if: inputs.version == 'patch' with: tag: ${{ steps.semvers.outputs.patch }} diff --git a/.github/workflows/lint-workflows.yml b/.github/workflows/lint-workflows.yml new file mode 100644 index 00000000..92a57293 --- /dev/null +++ b/.github/workflows/lint-workflows.yml @@ -0,0 +1,113 @@ +# Configuration from: +# https://github.com/johnbillion/plugin-infrastructure/blob/571cba96190304963285181e2b928d941b9ec7c4/.github/workflows/reusable-workflow-lint.yml + +name: Lint GitHub Actions workflows +on: + pull_request: + push: + branches: + - "2.0.x" + +permissions: {} + +jobs: + actionlint: + runs-on: ubuntu-latest + steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2 + with: + egress-policy: audit + + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Check workflow files + run: | + echo "::add-matcher::.github/actionlint-matcher.json" + bash <(curl https://raw.githubusercontent.com/rhysd/actionlint/main/scripts/download-actionlint.bash) + ./actionlint -color + shell: bash + + octoscan: + name: Octoscan + runs-on: ubuntu-latest + permissions: + security-events: write # Required for codeql-action/upload-sarif to upload SARIF files. + timeout-minutes: 10 + steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2 + with: + egress-policy: audit + + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Run octoscan + id: octoscan + uses: synacktiv/action-octoscan@6b1cf2343893dfb9e5f75652388bd2dc83f456b0 # v1.0.0 + with: + filter_triggers: '' + + - name: Upload SARIF file to GitHub + uses: github/codeql-action/upload-sarif@89a39a4e59826350b863aa6b6252a07ad50cf83e # v4.32.4 + with: + sarif_file: "${{steps.octoscan.outputs.sarif_output}}" + category: octoscan + wait-for-processing: false + + poutine: + name: Poutine + runs-on: ubuntu-latest + permissions: + security-events: write # Required for codeql-action/upload-sarif to upload SARIF files. + steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2 + with: + egress-policy: audit + + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Run Poutine + uses: boostsecurityio/poutine-action@84c0a0d32e8d57ae12651222be1eb15351429228 # v0.15.2 + + - name: Upload poutine SARIF file + uses: github/codeql-action/upload-sarif@89a39a4e59826350b863aa6b6252a07ad50cf83e # v4.32.4 + with: + sarif_file: results.sarif + category: poutine + wait-for-processing: false + + zizmor: + name: Zizmor + runs-on: ubuntu-latest + permissions: + security-events: write # Required for codeql-action/upload-sarif to upload SARIF files. + steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2 + with: + egress-policy: audit + + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Install the latest version of uv + uses: astral-sh/setup-uv@eac588ad8def6316056a12d4907a9d4d84ff7a3b # v7.3.0 + with: + enable-cache: false + + - name: Run zizmor + run: uvx zizmor@1.20.0 --persona=auditor --format=sarif --strict-collection . > results.sarif + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Upload SARIF file + uses: github/codeql-action/upload-sarif@5d4e8d1aca955e8d8589aabd499c5cae939e33c7 # v4.31.9 + with: + sarif_file: results.sarif + category: zizmor + wait-for-processing: false diff --git a/.github/workflows/lock-closed-issues.yml b/.github/workflows/lock-closed-issues.yml index 9a8fea7e..c5d27f4f 100644 --- a/.github/workflows/lock-closed-issues.yml +++ b/.github/workflows/lock-closed-issues.yml @@ -4,11 +4,21 @@ on: schedule: - cron: '7 0 * * *' +permissions: + contents: read + jobs: lock: + permissions: + issues: write # for dessant/lock-threads to lock issues runs-on: ubuntu-latest steps: - - uses: dessant/lock-threads@v5 + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2 + with: + egress-policy: audit + + - uses: dessant/lock-threads@7266a7ce5c1df01b1c6db85bf8cd86c737dadbe7 # v6.0.0 with: github-token: ${{ github.token }} issue-inactive-days: '31' diff --git a/.github/workflows/release-toot.yml b/.github/workflows/release-toot.yml index 1ba4fd77..21f09519 100644 --- a/.github/workflows/release-toot.yml +++ b/.github/workflows/release-toot.yml @@ -10,7 +10,12 @@ jobs: toot: runs-on: ubuntu-latest steps: - - uses: cbrgm/mastodon-github-action@v2 + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2 + with: + egress-policy: audit + + - uses: cbrgm/mastodon-github-action@845250b56b82d94e26bf23984d5e0cf5ced6d18f # v2.1.25 if: ${{ !github.event.repository.private }} with: # GitHub event payload diff --git a/.github/workflows/release-tweet.yml b/.github/workflows/release-tweet.yml index d81f34ca..18545f03 100644 --- a/.github/workflows/release-tweet.yml +++ b/.github/workflows/release-tweet.yml @@ -10,7 +10,12 @@ jobs: tweet: runs-on: ubuntu-latest steps: - - uses: Eomm/why-don-t-you-tweet@v2 + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2 + with: + egress-policy: audit + + - uses: Eomm/why-don-t-you-tweet@d9ec12835f4d494dda920f95f885df3dba380493 # v2.0.0 if: ${{ !github.event.repository.private }} with: # GitHub event payload diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index efb9bbeb..be3c2734 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -13,18 +13,23 @@ jobs: runs-on: "ubuntu-latest" steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2 + with: + egress-policy: audit + - name: "Checkout" - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Generate changelog id: changelog - uses: metcalfc/changelog-generator@v4.6.2 + uses: metcalfc/changelog-generator@3f82cef08fe5dcf57c591fe165e70e1d5032e15a # v4.6.2 with: myToken: ${{ secrets.PHPSTAN_BOT_TOKEN }} - name: "Create release" id: create-release - uses: actions/create-release@v1 + uses: actions/create-release@0cb9c9b65d5d1901c1f53e5e66eaf4afd303e70e # v1.1.4 env: GITHUB_TOKEN: ${{ secrets.PHPSTAN_BOT_TOKEN }} with: diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..8421eed2 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,130 @@ +# CLAUDE.md + +This file provides guidance for AI assistants working with the phpstan/phpstan-phpunit repository. + +## Project Overview + +This is a PHPStan extension that provides advanced static analysis support for PHPUnit test suites. It offers: + +- **Type extensions**: Correct return types for `createMock()`, `getMockForAbstractClass()`, `getMockFromWsdl()`, `MockBuilder::getMock()`, etc., returning intersection types (e.g., `MockObject&Foo`) so both mock and original class methods are available. +- **PHPDoc interpretation**: Converts `Foo|MockObject` union types in phpDocs to intersection types. +- **Assert type narrowing**: Specifies types of expressions passed to `assertInstanceOf`, `assertTrue`, `assertInternalType`, etc. +- **Early terminating methods**: Defines `fail()`, `markTestIncomplete()`, `markTestSkipped()` as early terminating to prevent false positive undefined variable errors. +- **Strict rules** (in `rules.neon`): Checks for better assertion usage (e.g., prefer `assertTrue()` over `assertSame(true, ...)`), `@covers` validation, data provider declaration checks, and more. + +## PHP Version Requirements + +This repository supports **PHP 7.4+**. Do not use language features unavailable in PHP 7.4 (e.g., enums, fibers, readonly properties, intersection types in code — though they appear in stubs/phpDocs). + +## PHPUnit Compatibility + +The extension supports multiple PHPUnit versions: **^9.5, ^10.5, ^11.5, ^12.0**. Code must be compatible across all these versions. The CI matrix tests all combinations. + +## Common Commands + +```bash +# Install dependencies +composer install + +# Run all checks (lint, coding standard, tests, PHPStan) +make check + +# Run tests only +make tests + +# Run PHPStan analysis +make phpstan + +# Run linting +make lint + +# Install coding standard tool (first time only) +make cs-install + +# Run coding standard checks +make cs + +# Fix coding standard issues +make cs-fix + +# Generate PHPStan baseline +make phpstan-generate-baseline +``` + +## Project Structure + +``` +src/ +├── PhpDoc/PHPUnit/ # PHPDoc type resolution extensions +├── Rules/PHPUnit/ # Static analysis rules for PHPUnit +└── Type/PHPUnit/ # Type-specifying and dynamic return type extensions + └── Assert/ # Assert method type narrowing + +tests/ +├── Rules/PHPUnit/ # Rule tests and test data (data/ subdirectory) +├── Rules/Methods/ # Method call rule tests +├── Type/PHPUnit/ # Type extension tests and test data (data/ subdirectory) +└── bootstrap.php # Test bootstrap (loads Composer autoloader) + +stubs/ # PHPUnit stub files for type definitions +``` + +## Configuration Files + +- **`extension.neon`** — Main extension configuration registered via phpstan/extension-installer. Defines parameters, services (type extensions, helpers), and stub files. +- **`rules.neon`** — Strict PHPUnit-specific rules. Loaded separately; users opt in by including this file. +- **`phpstan.neon`** — Self-analysis configuration (level 8, with strict rules and deprecation rules). +- **`phpstan-baseline.neon`** — Baseline for known PHPStan errors in the project itself. +- **`phpunit.xml`** — PHPUnit configuration for running the test suite. + +## Architecture + +### Type Extensions (`src/Type/PHPUnit/`) + +These implement PHPStan interfaces to provide correct types: + +- `MockBuilderDynamicReturnTypeExtension` — Preserves `MockBuilder` generic type through chained method calls. +- `MockForIntersectionDynamicReturnTypeExtension` — Returns `MockObject&T` intersection types for mock creation methods. +- `Assert/AssertMethodTypeSpecifyingExtension` (and function/static variants) — Narrows types after assert calls (e.g., after `assertInstanceOf(Foo::class, $x)`, `$x` is known to be `Foo`). + +### Rules (`src/Rules/PHPUnit/`) + +These implement `PHPStan\Rules\Rule` to report errors: + +- `AssertSameBooleanExpectedRule`, `AssertSameNullExpectedRule` — Suggest specific assertions over generic `assertSame`. +- `AssertSameWithCountRule` — Suggest `assertCount()` over `assertSame(count(...), ...)`. +- `ClassCoversExistsRule`, `ClassMethodCoversExistsRule` — Validate `@covers` annotations reference existing code. +- `DataProviderDeclarationRule`, `DataProviderDataRule` — Validate data provider declarations and data. +- `MockMethodCallRule` — Check mock method calls are valid. +- `ShouldCallParentMethodsRule` — Verify `setUp()`/`tearDown()` call parent methods. + +### Stubs (`stubs/`) + +PHPStan stub files that provide generic type information for PHPUnit classes (e.g., `TestCase::createMock()` returns `MockObject&T`). + +## Writing Tests + +- **Rule tests** extend `PHPStan\Testing\RuleTestCase`. They implement `getRule()` and call `$this->analyse()` with a test data file path and expected errors array. Test data files live in `tests/Rules/PHPUnit/data/`. +- **Type tests** extend `PHPStan\Testing\TypeInferenceTestCase`. They use `@dataProvider` with `self::gatherAssertTypes()` or `self::dataFileAsserts()` and call `$this->assertFileAsserts()`. Test data files live in `tests/Type/PHPUnit/data/`. +- Both types override `getAdditionalConfigFiles()` to return the path to `extension.neon` (and sometimes `rules.neon`). + +## Coding Standards + +- Uses tabs for indentation (PHP, XML, NEON files). +- Uses spaces for YAML files (indent size 2). +- Coding standard is enforced via [phpstan/build-cs](https://github.com/phpstan/build-cs) (PHPCS with a custom standard). +- Run `make cs` to check, `make cs-fix` to auto-fix. + +## CI Pipeline + +The GitHub Actions workflow (`.github/workflows/build.yml`) runs on the `2.0.x` branch and pull requests: + +1. **Lint** — PHP syntax check across PHP 7.4–8.5. +2. **Coding Standard** — PHPCS checks using build-cs. +3. **Tests** — PHPUnit across PHP 7.4–8.5 × lowest/highest dependencies × PHPUnit 9.5/10.5/11.5/12.0 (with version-appropriate exclusions). +4. **Static Analysis** — PHPStan self-analysis with the same matrix. +5. **Mutation Testing** — Infection framework on PHP 8.2–8.5, requires 100% MSI on changed lines. + +## Development Branch + +The main development branch is `2.0.x`. diff --git a/composer.json b/composer.json index 39d7a030..f842bb44 100644 --- a/composer.json +++ b/composer.json @@ -5,9 +5,10 @@ "license": [ "MIT" ], + "keywords": ["static analysis"], "require": { "php": "^7.4 || ^8.0", - "phpstan/phpstan": "^2.1.32" + "phpstan/phpstan": "^2.1.41" }, "conflict": { "phpunit/phpunit": "<7.0" diff --git a/extension.neon b/extension.neon index 23ae52cf..665d85c3 100644 --- a/extension.neon +++ b/extension.neon @@ -1,7 +1,7 @@ parameters: phpunit: convertUnionToIntersectionType: true - checkDataProviderData: %featureToggles.bleedingEdge% + reportMissingDataProviderReturnType: false additionalConstructors: - PHPUnit\Framework\TestCase::setUp earlyTerminatingMethodCalls: @@ -24,8 +24,8 @@ parameters: parametersSchema: phpunit: structure([ - convertUnionToIntersectionType: bool() - checkDataProviderData: bool(), + convertUnionToIntersectionType: bool(), + reportMissingDataProviderReturnType: bool(), ]) services: @@ -47,6 +47,11 @@ services: class: PHPStan\Type\PHPUnit\MockBuilderDynamicReturnTypeExtension tags: - phpstan.broker.dynamicMethodReturnTypeExtension + - + class: PHPStan\Type\PHPUnit\MockForIntersectionDynamicReturnTypeExtension + tags: + - phpstan.broker.dynamicMethodReturnTypeExtension + - phpstan.broker.dynamicStaticMethodReturnTypeExtension - class: PHPStan\Rules\PHPUnit\CoversHelper - @@ -72,8 +77,13 @@ services: - class: PHPStan\Type\PHPUnit\DataProviderReturnTypeIgnoreExtension + - + class: PHPStan\Type\PHPUnit\DynamicCallToAssertionIgnoreExtension + conditionalTags: PHPStan\PhpDoc\PHPUnit\MockObjectTypeNodeResolverExtension: phpstan.phpDoc.typeNodeResolverExtension: %phpunit.convertUnionToIntersectionType% PHPStan\Type\PHPUnit\DataProviderReturnTypeIgnoreExtension: - phpstan.ignoreErrorExtension: %phpunit.checkDataProviderData% + phpstan.ignoreErrorExtension: [%featureToggles.bleedingEdge%, not(%phpunit.reportMissingDataProviderReturnType%)] + PHPStan\Type\PHPUnit\DynamicCallToAssertionIgnoreExtension: + phpstan.ignoreErrorExtension: %featureToggles.bleedingEdge% diff --git a/rules.neon b/rules.neon index 8272f47a..7bff0161 100644 --- a/rules.neon +++ b/rules.neon @@ -14,7 +14,7 @@ conditionalTags: phpstan.rules.rule: [%strictRulesInstalled%, %featureToggles.bleedingEdge%] PHPStan\Rules\PHPUnit\DataProviderDataRule: - phpstan.rules.rule: %phpunit.checkDataProviderData% + phpstan.rules.rule: %featureToggles.bleedingEdge% services: - @@ -25,6 +25,13 @@ services: tags: - phpstan.rules.rule + - + class: PHPStan\Rules\PHPUnit\AttributeRequiresPhpVersionRule + arguments: + deprecationRulesInstalled: %deprecationRulesInstalled% + tags: + - phpstan.rules.rule + - class: PHPStan\Rules\PHPUnit\AssertEqualsIsDiscouragedRule diff --git a/src/Rules/PHPUnit/AnnotationHelper.php b/src/Rules/PHPUnit/AnnotationHelper.php index 21623cab..dc89bdb7 100644 --- a/src/Rules/PHPUnit/AnnotationHelper.php +++ b/src/Rules/PHPUnit/AnnotationHelper.php @@ -5,7 +5,6 @@ use PhpParser\Comment\Doc; use PHPStan\Rules\IdentifierRuleError; use PHPStan\Rules\RuleErrorBuilder; -use function array_key_exists; use function in_array; use function preg_match; use function preg_split; @@ -43,14 +42,10 @@ public function processDocComment(Doc $docComment): array foreach ($docCommentLines as $docCommentLine) { // These annotations can't be retrieved using the getResolvedPhpDoc method on the FileTypeMapper as they are not present when they are invalid $annotation = preg_match('/(?@(?[a-zA-Z]+)(?\s*)(?.*))/', $docCommentLine, $matches); - if ($annotation === false) { + if ($annotation === false || $matches === []) { continue; // Line without annotation } - if (array_key_exists('property', $matches) === false || array_key_exists('whitespace', $matches) === false || array_key_exists('annotation', $matches) === false) { - continue; - } - if (!in_array($matches['property'], self::ANNOTATIONS_WITH_PARAMS, true) || $matches['whitespace'] !== '') { continue; } diff --git a/src/Rules/PHPUnit/AttributeRequiresPhpVersionRule.php b/src/Rules/PHPUnit/AttributeRequiresPhpVersionRule.php new file mode 100644 index 00000000..899ff1cb --- /dev/null +++ b/src/Rules/PHPUnit/AttributeRequiresPhpVersionRule.php @@ -0,0 +1,93 @@ + + */ +class AttributeRequiresPhpVersionRule implements Rule +{ + + private PHPUnitVersion $PHPUnitVersion; + + private TestMethodsHelper $testMethodsHelper; + + /** + * When phpstan-deprecation-rules is installed, it reports deprecated usages. + */ + private bool $deprecationRulesInstalled; + + public function __construct( + PHPUnitVersion $PHPUnitVersion, + TestMethodsHelper $testMethodsHelper, + bool $deprecationRulesInstalled + ) + { + $this->PHPUnitVersion = $PHPUnitVersion; + $this->testMethodsHelper = $testMethodsHelper; + $this->deprecationRulesInstalled = $deprecationRulesInstalled; + } + + public function getNodeType(): string + { + return InClassMethodNode::class; + } + + public function processNode(Node $node, Scope $scope): array + { + $classReflection = $scope->getClassReflection(); + if ($classReflection === null || $classReflection->is(TestCase::class) === false) { + return []; + } + + $reflectionMethod = $this->testMethodsHelper->getTestMethodReflection($classReflection, $node->getMethodReflection(), $scope); + if ($reflectionMethod === null) { + return []; + } + + $errors = []; + foreach ($reflectionMethod->getAttributesByName('PHPUnit\Framework\Attributes\RequiresPhp') as $attr) { + $args = $attr->getArguments(); + if (count($args) !== 1) { + continue; + } + + if ( + !is_numeric($args[0]) + ) { + continue; + } + + if ($this->PHPUnitVersion->requiresPhpversionAttributeWithOperator()->yes()) { + $errors[] = RuleErrorBuilder::message( + sprintf('Version requirement is missing operator.'), + ) + ->identifier('phpunit.attributeRequiresPhpVersion') + ->build(); + } elseif ( + $this->deprecationRulesInstalled + && $this->PHPUnitVersion->deprecatesPhpversionAttributeWithoutOperator()->yes() + ) { + $errors[] = RuleErrorBuilder::message( + sprintf('Version requirement without operator is deprecated.'), + ) + ->identifier('phpunit.attributeRequiresPhpVersion') + ->build(); + } + + } + + return $errors; + } + +} diff --git a/src/Rules/PHPUnit/DataProviderDataRule.php b/src/Rules/PHPUnit/DataProviderDataRule.php index ce994676..ac800f57 100644 --- a/src/Rules/PHPUnit/DataProviderDataRule.php +++ b/src/Rules/PHPUnit/DataProviderDataRule.php @@ -58,18 +58,13 @@ public function processNode(Node $node, Scope $scope): array return []; } - $arraysTypes = $this->buildArrayTypesFromNode($node, $scope); - if ($arraysTypes === []) { - return []; - } - - $method = $scope->getFunction(); $classReflection = $scope->getClassReflection(); if ($classReflection === null) { return []; } $testsWithProvider = []; + $method = $scope->getFunction(); $testMethods = $this->testMethodsHelper->getTestMethods($classReflection, $scope); foreach ($testMethods as $testMethod) { foreach ($this->dataProviderHelper->getDataProviderMethods($scope, $testMethod, $classReflection) as [, $providerMethodName]) { @@ -84,6 +79,11 @@ public function processNode(Node $node, Scope $scope): array return []; } + $arraysTypes = $this->buildArrayTypesFromNode($node, $scope); + if ($arraysTypes === []) { + return []; + } + $maxNumberOfParameters = null; foreach ($testsWithProvider as $testMethod) { $num = $testMethod->getNumberOfParameters(); diff --git a/src/Rules/PHPUnit/DataProviderHelper.php b/src/Rules/PHPUnit/DataProviderHelper.php index d40d05e4..a05c59cf 100644 --- a/src/Rules/PHPUnit/DataProviderHelper.php +++ b/src/Rules/PHPUnit/DataProviderHelper.php @@ -11,6 +11,7 @@ use PhpParser\Node\Stmt\ClassMethod; use PhpParser\NodeFinder; use PHPStan\Analyser\Scope; +use PHPStan\BetterReflection\Reflection\ReflectionMethod; use PHPStan\Parser\Parser; use PHPStan\PhpDoc\ResolvedPhpDocBlock; use PHPStan\PhpDocParser\Ast\PhpDoc\PhpDocTagNode; @@ -20,11 +21,9 @@ use PHPStan\Rules\IdentifierRuleError; use PHPStan\Rules\RuleErrorBuilder; use PHPStan\Type\FileTypeMapper; -use ReflectionMethod; use function array_merge; use function count; use function explode; -use function method_exists; use function preg_match; use function sprintf; @@ -282,21 +281,13 @@ private function yieldDataProviderAttributes($node, ClassReflection $classReflec if ( $node instanceof ReflectionMethod ) { - /** @phpstan-ignore function.alreadyNarrowedType */ - if (!method_exists($node, 'getAttributes')) { - return; - } - - foreach ($node->getAttributes('PHPUnit\Framework\Attributes\DataProvider') as $attr) { + foreach ($node->getAttributesByName('PHPUnit\Framework\Attributes\DataProvider') as $attr) { $args = $attr->getArguments(); if (count($args) !== 1) { continue; } $startLine = $node->getStartLine(); - if ($startLine === false) { - $startLine = -1; - } yield [$classReflection, $args[0], $startLine]; } @@ -329,7 +320,7 @@ private function yieldDataProviderAttributes($node, ClassReflection $classReflec private function yieldDataProviderAnnotations($node, Scope $scope, ClassReflection $classReflection): iterable { $docComment = $node->getDocComment(); - if ($docComment === null || $docComment === false) { + if ($docComment === null) { return; } @@ -348,10 +339,6 @@ private function yieldDataProviderAnnotations($node, Scope $scope, ClassReflecti } $startLine = $node->getStartLine(); - if ($startLine === false) { - $startLine = -1; - } - $dataProviderMethod = $this->parseDataProviderAnnotationValue($scope, $dataProviderValue); $dataProviderMethod[] = $startLine; diff --git a/src/Rules/PHPUnit/PHPUnitVersion.php b/src/Rules/PHPUnit/PHPUnitVersion.php index b7259a84..56eb7558 100644 --- a/src/Rules/PHPUnit/PHPUnitVersion.php +++ b/src/Rules/PHPUnit/PHPUnitVersion.php @@ -9,9 +9,12 @@ class PHPUnitVersion private ?int $majorVersion; - public function __construct(?int $majorVersion) + private ?int $minorVersion; + + public function __construct(?int $majorVersion, ?int $minorVersion) { $this->majorVersion = $majorVersion; + $this->minorVersion = $minorVersion; } public function supportsDataProviderAttribute(): TrinaryLogic @@ -46,4 +49,34 @@ public function supportsNamedArgumentsInDataProvider(): TrinaryLogic return TrinaryLogic::createFromBoolean($this->majorVersion >= 11); } + public function requiresPhpversionAttributeWithOperator(): TrinaryLogic + { + if ($this->majorVersion === null) { + return TrinaryLogic::createMaybe(); + } + return TrinaryLogic::createFromBoolean($this->majorVersion >= 13); + } + + public function deprecatesPhpversionAttributeWithoutOperator(): TrinaryLogic + { + return $this->minVersion(12, 4); + } + + private function minVersion(int $major, int $minor): TrinaryLogic + { + if ($this->majorVersion === null || $this->minorVersion === null) { + return TrinaryLogic::createMaybe(); + } + + if ($this->majorVersion > $major) { + return TrinaryLogic::createYes(); + } + + if ($this->majorVersion === $major && $this->minorVersion >= $minor) { + return TrinaryLogic::createYes(); + } + + return TrinaryLogic::createNo(); + } + } diff --git a/src/Rules/PHPUnit/PHPUnitVersionDetector.php b/src/Rules/PHPUnit/PHPUnitVersionDetector.php index f0e2c4b9..d35c3e72 100644 --- a/src/Rules/PHPUnit/PHPUnitVersionDetector.php +++ b/src/Rules/PHPUnit/PHPUnitVersionDetector.php @@ -2,47 +2,49 @@ namespace PHPStan\Rules\PHPUnit; -use PHPStan\Reflection\ReflectionProvider; use PHPUnit\Framework\TestCase; +use ReflectionClass; +use ReflectionException; use function dirname; use function explode; use function file_get_contents; -use function is_file; use function json_decode; class PHPUnitVersionDetector { - private ReflectionProvider $reflectionProvider; - - public function __construct(ReflectionProvider $reflectionProvider) - { - $this->reflectionProvider = $reflectionProvider; - } - public function createPHPUnitVersion(): PHPUnitVersion { + $file = false; $majorVersion = null; - if ($this->reflectionProvider->hasClass(TestCase::class)) { - $testCase = $this->reflectionProvider->getClass(TestCase::class); - $file = $testCase->getFileName(); - if ($file !== null) { - $phpUnitRoot = dirname($file, 3); - $phpUnitComposer = $phpUnitRoot . '/composer.json'; - if (is_file($phpUnitComposer)) { - $composerJson = @file_get_contents($phpUnitComposer); - if ($composerJson !== false) { - $json = json_decode($composerJson, true); - $version = $json['extra']['branch-alias']['dev-main'] ?? null; - if ($version !== null) { - $majorVersion = (int) explode('.', $version)[0]; - } - } + $minorVersion = null; + + try { + // uses runtime reflection to reduce unnecessary work while bootstrapping PHPStan. + // static reflection would need to AST parse and build up reflection for a lot of files otherwise. + $reflection = new ReflectionClass(TestCase::class); + $file = $reflection->getFileName(); + } catch (ReflectionException $e) { + // PHPUnit might not be installed + } + + if ($file !== false) { + $phpUnitRoot = dirname($file, 3); + $phpUnitComposer = $phpUnitRoot . '/composer.json'; + + $composerJson = @file_get_contents($phpUnitComposer); + if ($composerJson !== false) { + $json = json_decode($composerJson, true); + $version = $json['extra']['branch-alias']['dev-main'] ?? null; + if ($version !== null) { + $versionParts = explode('.', $version); + $majorVersion = (int) $versionParts[0]; + $minorVersion = (int) $versionParts[1]; } } } - return new PHPUnitVersion($majorVersion); + return new PHPUnitVersion($majorVersion, $minorVersion); } } diff --git a/src/Rules/PHPUnit/TestMethodsHelper.php b/src/Rules/PHPUnit/TestMethodsHelper.php index 5eb274e0..f1efc97f 100644 --- a/src/Rules/PHPUnit/TestMethodsHelper.php +++ b/src/Rules/PHPUnit/TestMethodsHelper.php @@ -3,11 +3,13 @@ namespace PHPStan\Rules\PHPUnit; use PHPStan\Analyser\Scope; +use PHPStan\BetterReflection\Reflection\ReflectionMethod; use PHPStan\PhpDoc\ResolvedPhpDocBlock; use PHPStan\Reflection\ClassReflection; +use PHPStan\Reflection\MethodReflection; use PHPStan\Type\FileTypeMapper; use PHPUnit\Framework\TestCase; -use ReflectionMethod; +use function array_key_exists; use function str_starts_with; use function strtolower; @@ -18,6 +20,9 @@ final class TestMethodsHelper private PHPUnitVersion $PHPUnitVersion; + /** @var array> */ + private array $methodCache = []; + public function __construct( FileTypeMapper $fileTypeMapper, PHPUnitVersion $PHPUnitVersion @@ -27,17 +32,32 @@ public function __construct( $this->PHPUnitVersion = $PHPUnitVersion; } + public function getTestMethodReflection(ClassReflection $classReflection, MethodReflection $methodReflection, Scope $scope): ?ReflectionMethod + { + foreach ($this->getTestMethods($classReflection, $scope) as $testMethod) { + if ($testMethod->getName() === $methodReflection->getName()) { + return $testMethod; + } + } + + return null; + } + /** * @return array */ public function getTestMethods(ClassReflection $classReflection, Scope $scope): array { + $className = $classReflection->getName(); + if (array_key_exists($className, $this->methodCache)) { + return $this->methodCache[$className]; + } if (!$classReflection->is(TestCase::class)) { - return []; + return $this->methodCache[$className] = []; } $testMethods = []; - foreach ($classReflection->getNativeReflection()->getMethods() as $reflectionMethod) { + foreach ($classReflection->getNativeReflection()->getBetterReflection()->getImmediateMethods() as $reflectionMethod) { if (!$reflectionMethod->isPublic()) { continue; } @@ -48,10 +68,10 @@ public function getTestMethods(ClassReflection $classReflection, Scope $scope): } $docComment = $reflectionMethod->getDocComment(); - if ($docComment !== false) { + if ($docComment !== null) { $methodPhpDoc = $this->fileTypeMapper->getResolvedPhpDoc( $scope->getFile(), - $classReflection->getName(), + $className, $scope->isInTrait() ? $scope->getTraitReflection()->getName() : null, $reflectionMethod->getName(), $docComment, @@ -67,7 +87,7 @@ public function getTestMethods(ClassReflection $classReflection, Scope $scope): continue; } - $testAttributes = $reflectionMethod->getAttributes('PHPUnit\Framework\Attributes\Test'); // @phpstan-ignore argument.type + $testAttributes = $reflectionMethod->getAttributesByName('PHPUnit\Framework\Attributes\Test'); // @phpstan-ignore argument.type if ($testAttributes === []) { continue; } @@ -75,7 +95,7 @@ public function getTestMethods(ClassReflection $classReflection, Scope $scope): $testMethods[] = $reflectionMethod; } - return $testMethods; + return $this->methodCache[$className] = $testMethods; } private function hasTestAnnotation(?ResolvedPhpDocBlock $phpDoc): bool diff --git a/src/Type/PHPUnit/DataProviderReturnTypeIgnoreExtension.php b/src/Type/PHPUnit/DataProviderReturnTypeIgnoreExtension.php index be6af678..ebe30d54 100644 --- a/src/Type/PHPUnit/DataProviderReturnTypeIgnoreExtension.php +++ b/src/Type/PHPUnit/DataProviderReturnTypeIgnoreExtension.php @@ -8,6 +8,7 @@ use PHPStan\Analyser\Scope; use PHPStan\Rules\PHPUnit\DataProviderHelper; use PHPStan\Rules\PHPUnit\TestMethodsHelper; +use function in_array; final class DataProviderReturnTypeIgnoreExtension implements IgnoreErrorExtension { @@ -27,7 +28,10 @@ public function __construct( public function shouldIgnore(Error $error, Node $node, Scope $scope): bool { - if ($error->getIdentifier() !== 'missingType.iterableValue') { + if (!in_array($error->getIdentifier(), [ + 'missingType.iterableValue', + 'missingType.generics', + ], true)) { return false; } diff --git a/src/Type/PHPUnit/DynamicCallToAssertionIgnoreExtension.php b/src/Type/PHPUnit/DynamicCallToAssertionIgnoreExtension.php new file mode 100644 index 00000000..ccf5b0f4 --- /dev/null +++ b/src/Type/PHPUnit/DynamicCallToAssertionIgnoreExtension.php @@ -0,0 +1,49 @@ +var instanceof Node\Expr\Variable) { + return false; + } + + if (!is_string($node->var->name) || $node->var->name !== 'this') { + return false; + } + + if ($error->getIdentifier() !== 'staticMethod.dynamicCall') { + return false; + } + + if ( + !$node->name instanceof Node\Identifier + || !str_starts_with($node->name->name, 'assert') + ) { + return false; + } + + if (!$scope->isInClass()) { + return false; + } + + $classReflection = $scope->getClassReflection(); + return $classReflection->is(TestCase::class); + } + +} diff --git a/src/Type/PHPUnit/MockForIntersectionDynamicReturnTypeExtension.php b/src/Type/PHPUnit/MockForIntersectionDynamicReturnTypeExtension.php new file mode 100644 index 00000000..66d8758a --- /dev/null +++ b/src/Type/PHPUnit/MockForIntersectionDynamicReturnTypeExtension.php @@ -0,0 +1,91 @@ +getName() === 'createMockForIntersectionOfInterfaces'; + } + + public function isStaticMethodSupported(MethodReflection $methodReflection): bool + { + return $methodReflection->getName() === 'createStubForIntersectionOfInterfaces'; + } + + public function getTypeFromStaticMethodCall(MethodReflection $methodReflection, StaticCall $methodCall, Scope $scope): ?Type + { + return $this->getTypeFromCall($methodReflection, $methodCall->getArgs(), $scope); + } + + public function getTypeFromMethodCall(MethodReflection $methodReflection, MethodCall $methodCall, Scope $scope): ?Type + { + return $this->getTypeFromCall($methodReflection, $methodCall->getArgs(), $scope); + } + + /** + * @param array $args + */ + private function getTypeFromCall(MethodReflection $methodReflection, array $args, Scope $scope): ?Type + { + if (!isset($args[0])) { + return null; + } + + $interfaces = $scope->getType($args[0]->value); + $constantArrays = $interfaces->getConstantArrays(); + if (count($constantArrays) !== 1) { + return null; + } + + $constantArray = $constantArrays[0]; + if (count($constantArray->getOptionalKeys()) > 0) { + return null; + } + + $result = []; + if ($methodReflection->getName() === 'createMockForIntersectionOfInterfaces') { + $result[] = new ObjectType(MockObject::class); + } else { + $result[] = new ObjectType(Stub::class); + } + + foreach ($constantArray->getValueTypes() as $valueType) { + if (!$valueType->isClassString()->yes()) { + return null; + } + + $values = $valueType->getConstantScalarValues(); + if (count($values) !== 1) { + return null; + } + + $result[] = new ObjectType((string) $values[0]); + } + + return TypeCombinator::intersect(...$result); + } + +} diff --git a/tests/Rules/PHPUnit/AttributeRequiresPhpVersionRuleTest.php b/tests/Rules/PHPUnit/AttributeRequiresPhpVersionRuleTest.php new file mode 100644 index 00000000..92d9715c --- /dev/null +++ b/tests/Rules/PHPUnit/AttributeRequiresPhpVersionRuleTest.php @@ -0,0 +1,95 @@ + + */ +final class AttributeRequiresPhpVersionRuleTest extends RuleTestCase +{ + + private ?int $phpunitMajorVersion; + + private ?int $phpunitMinorVersion; + + private bool $deprecationRulesInstalled = true; + + public function testRuleOnPHPUnitUnknown(): void + { + $this->phpunitMajorVersion = null; + $this->phpunitMinorVersion = null; + + $this->analyse([__DIR__ . '/data/requires-php-version.php'], []); + } + + public function testRuleOnPHPUnit115(): void + { + $this->phpunitMajorVersion = 11; + $this->phpunitMinorVersion = 5; + + $this->analyse([__DIR__ . '/data/requires-php-version.php'], []); + } + + public function testRuleOnPHPUnit123(): void + { + $this->phpunitMajorVersion = 12; + $this->phpunitMinorVersion = 3; + + $this->analyse([__DIR__ . '/data/requires-php-version.php'], []); + } + + public function testRuleOnPHPUnit124DeprecationsOn(): void + { + $this->phpunitMajorVersion = 12; + $this->phpunitMinorVersion = 4; + $this->deprecationRulesInstalled = true; + + $this->analyse([__DIR__ . '/data/requires-php-version.php'], [ + [ + 'Version requirement without operator is deprecated.', + 12, + ], + ]); + } + + public function testRuleOnPHPUnit124DeprecationsOff(): void + { + $this->phpunitMajorVersion = 12; + $this->phpunitMinorVersion = 4; + $this->deprecationRulesInstalled = false; + + $this->analyse([__DIR__ . '/data/requires-php-version.php'], []); + } + + public function testRuleOnPHPUnit13(): void + { + $this->phpunitMajorVersion = 13; + $this->phpunitMinorVersion = 0; + + $this->analyse([__DIR__ . '/data/requires-php-version.php'], [ + [ + 'Version requirement is missing operator.', + 12, + ], + ]); + } + + protected function getRule(): Rule + { + $phpunitVersion = new PHPUnitVersion($this->phpunitMajorVersion, $this->phpunitMinorVersion); + + return new AttributeRequiresPhpVersionRule( + $phpunitVersion, + new TestMethodsHelper( + self::getContainer()->getByType(FileTypeMapper::class), + $phpunitVersion, + ), + $this->deprecationRulesInstalled, + ); + } + +} diff --git a/tests/Rules/PHPUnit/DataProviderDataRuleTest.php b/tests/Rules/PHPUnit/DataProviderDataRuleTest.php index cca88e7f..012fce70 100644 --- a/tests/Rules/PHPUnit/DataProviderDataRuleTest.php +++ b/tests/Rules/PHPUnit/DataProviderDataRuleTest.php @@ -22,7 +22,7 @@ class DataProviderDataRuleTest extends RuleTestCase protected function getRule(): Rule { $reflectionProvider = $this->createReflectionProvider(); - $phpunitVersion = new PHPUnitVersion($this->phpunitVersion); + $phpunitVersion = new PHPUnitVersion($this->phpunitVersion, 0); /** @var list> $rules */ $rules = [ diff --git a/tests/Rules/PHPUnit/DataProviderDeclarationRuleTest.php b/tests/Rules/PHPUnit/DataProviderDeclarationRuleTest.php index 2bf9d870..f63c9e65 100644 --- a/tests/Rules/PHPUnit/DataProviderDeclarationRuleTest.php +++ b/tests/Rules/PHPUnit/DataProviderDeclarationRuleTest.php @@ -24,7 +24,7 @@ protected function getRule(): Rule $reflection, self::getContainer()->getByType(FileTypeMapper::class), self::getContainer()->getService('defaultAnalysisParser'), - new PHPUnitVersion($this->phpunitVersion) + new PHPUnitVersion($this->phpunitVersion, 0) ), true, true diff --git a/tests/Rules/PHPUnit/MockMethodCallRuleTest.php b/tests/Rules/PHPUnit/MockMethodCallRuleTest.php index f7e89c7a..363f4380 100644 --- a/tests/Rules/PHPUnit/MockMethodCallRuleTest.php +++ b/tests/Rules/PHPUnit/MockMethodCallRuleTest.php @@ -4,6 +4,8 @@ use PHPStan\Rules\Rule; use PHPStan\Testing\RuleTestCase; +use PHPUnit\Framework\TestCase; +use function method_exists; use const PHP_VERSION_ID; /** @@ -34,6 +36,17 @@ public function testRule(): void ], ]; + if (method_exists(TestCase::class, 'createMockForIntersectionOfInterfaces')) { // @phpstan-ignore function.alreadyNarrowedType + $expectedErrors[] = [ + 'Trying to mock an undefined method bazMethod() on class MockMethodCall\FooInterface&MockMethodCall\BarInterface.', + 49, + ]; + $expectedErrors[] = [ + 'Trying to mock an undefined method bazMethod() on class MockMethodCall\FooInterface&MockMethodCall\BarInterface.', + 57, + ]; + } + $this->analyse([__DIR__ . '/data/mock-method-call.php'], $expectedErrors); } diff --git a/tests/Rules/PHPUnit/data/mock-method-call.php b/tests/Rules/PHPUnit/data/mock-method-call.php index a4f5aaae..51de6a3f 100644 --- a/tests/Rules/PHPUnit/data/mock-method-call.php +++ b/tests/Rules/PHPUnit/data/mock-method-call.php @@ -41,6 +41,22 @@ public function testMockObject(\PHPUnit\Framework\MockObject\MockObject $mock) $mock->method('doFoo'); } + public function testMockForIntersection() + { + $mock = $this->createMockForIntersectionOfInterfaces([FooInterface::class, BarInterface::class]); + $mock->method('fooMethod'); + $mock->method('barMethod'); + $mock->method('bazMethod'); + } + + public function testStubForIntersection() + { + $stub = static::createStubForIntersectionOfInterfaces([FooInterface::class, BarInterface::class]); + $stub->method('fooMethod'); + $stub->method('barMethod'); + $stub->method('bazMethod'); + } + } class Bar { @@ -71,3 +87,11 @@ public function testMockFinalClass() } } + +interface FooInterface { + public function fooMethod(): int; +} + +interface BarInterface { + public function barMethod(): string; +} diff --git a/tests/Rules/PHPUnit/data/requires-php-version.php b/tests/Rules/PHPUnit/data/requires-php-version.php new file mode 100644 index 00000000..5550edff --- /dev/null +++ b/tests/Rules/PHPUnit/data/requires-php-version.php @@ -0,0 +1,24 @@ +=8.0')] + public function testHappyPath(): void { + + } +} diff --git a/tests/Type/PHPUnit/DataProviderReturnTypeIgnoreExtensionTest.php b/tests/Type/PHPUnit/DataProviderReturnTypeIgnoreExtensionTest.php index fb5b927a..5335fc0c 100644 --- a/tests/Type/PHPUnit/DataProviderReturnTypeIgnoreExtensionTest.php +++ b/tests/Type/PHPUnit/DataProviderReturnTypeIgnoreExtensionTest.php @@ -5,6 +5,7 @@ use PHPStan\Rules\Methods\MissingMethodReturnTypehintRule; use PHPStan\Rules\Rule; use PHPStan\Testing\RuleTestCase; +use function array_merge; /** * @extends RuleTestCase @@ -23,7 +24,7 @@ public function testRule(): void $this->analyse([__DIR__ . '/data/data-provider-iterable-value.php'], [ [ 'Method DataProviderIterableValueTest\Foo::notADataProvider() return type has no value type specified in iterable type iterable.', - 32, + 41, 'See: https://phpstan.org/blog/solving-phpstan-no-value-type-specified-in-iterable-type' ], ]); @@ -31,8 +32,11 @@ public function testRule(): void static public function getAdditionalConfigFiles(): array { - return [ - __DIR__ . '/data/data-provider-iterable-value.neon' - ]; + return array_merge( + parent::getAdditionalConfigFiles(), + [ + __DIR__ . '/data/data-provider-iterable-value.neon' + ], + ); } } diff --git a/tests/Type/PHPUnit/DynamicCallToAssertionIgnoreExtensionTest.php b/tests/Type/PHPUnit/DynamicCallToAssertionIgnoreExtensionTest.php new file mode 100644 index 00000000..2e789ba0 --- /dev/null +++ b/tests/Type/PHPUnit/DynamicCallToAssertionIgnoreExtensionTest.php @@ -0,0 +1,38 @@ + + */ +class DynamicCallToAssertionIgnoreExtensionTest extends RuleTestCase +{ + + protected function getRule(): Rule + { + /** @phpstan-ignore phpstanApi.classConstant */ + return self::getContainer()->getByType(DynamicCallOnStaticMethodsRule::class); + } + + public function testRule(): void + { + $this->analyse([__DIR__ . '/data/dynamic-call-to-assertion.php'], [ + [ + 'Dynamic call to static method DynamicCallToAssertion\Foo::staticFn().', + 17, + ], + ]); + } + + public static function getAdditionalConfigFiles(): array + { + return [ + __DIR__ . '/data/dynamic-call-to-assertion.neon', + ]; + } + +} diff --git a/tests/Type/PHPUnit/MockForIntersectionDynamicReturnTypeExtensionTest.php b/tests/Type/PHPUnit/MockForIntersectionDynamicReturnTypeExtensionTest.php new file mode 100644 index 00000000..dff53849 --- /dev/null +++ b/tests/Type/PHPUnit/MockForIntersectionDynamicReturnTypeExtensionTest.php @@ -0,0 +1,42 @@ +assertFileAsserts($assertType, $file, ...$args); + } + + public static function getAdditionalConfigFiles(): array + { + return [__DIR__ . '/../../../extension.neon']; + } + +} diff --git a/tests/Type/PHPUnit/data/data-provider-iterable-value.neon b/tests/Type/PHPUnit/data/data-provider-iterable-value.neon index eed12a5b..e5597bc2 100644 --- a/tests/Type/PHPUnit/data/data-provider-iterable-value.neon +++ b/tests/Type/PHPUnit/data/data-provider-iterable-value.neon @@ -1,6 +1,6 @@ parameters: - phpunit: - checkDataProviderData: true + featureToggles: + bleedingEdge: true includes: - ../../../../extension.neon diff --git a/tests/Type/PHPUnit/data/data-provider-iterable-value.php b/tests/Type/PHPUnit/data/data-provider-iterable-value.php index 613d3b14..8b2f6f3d 100644 --- a/tests/Type/PHPUnit/data/data-provider-iterable-value.php +++ b/tests/Type/PHPUnit/data/data-provider-iterable-value.php @@ -2,12 +2,21 @@ namespace DataProviderIterableValueTest; +use ArrayObject; +use Generator; +use Iterator; +use IteratorAggregate; use PHPUnit\Framework\TestCase; +use Traversable; class Foo extends TestCase { /** * @dataProvider dataProvider * @dataProvider dataProvider2 + * @dataProvider dataProvider3 + * @dataProvider dataProvider4 + * @dataProvider dataProvider5 + * @dataProvider dataProvider6 */ public function testFoo():void { @@ -36,4 +45,30 @@ public function notADataProvider(): iterable { [5, 6], ]; } + + public function dataProvider3(): Iterator { + $i = rand(0, 10); + + yield [$i, 2]; + } + + public function dataProvider4(): IteratorAggregate { + $i = rand(0, 10); + + return new ArrayObject([ + [$i, 2], + ]); + } + + public function dataProvider5(): Generator { + $i = rand(0, 10); + + yield [$i, 2]; + } + + public function dataProvider6(): Traversable { + $i = rand(0, 10); + + yield [$i, 2]; + } } diff --git a/tests/Type/PHPUnit/data/dynamic-call-to-assertion.neon b/tests/Type/PHPUnit/data/dynamic-call-to-assertion.neon new file mode 100644 index 00000000..9ed078fb --- /dev/null +++ b/tests/Type/PHPUnit/data/dynamic-call-to-assertion.neon @@ -0,0 +1,10 @@ +parameters: + featureToggles: + bleedingEdge: true + +includes: + - ../../../../extension.neon + +services: + - + class: PHPStan\Rules\StrictCalls\DynamicCallOnStaticMethodsRule diff --git a/tests/Type/PHPUnit/data/dynamic-call-to-assertion.php b/tests/Type/PHPUnit/data/dynamic-call-to-assertion.php new file mode 100644 index 00000000..2ffcb06d --- /dev/null +++ b/tests/Type/PHPUnit/data/dynamic-call-to-assertion.php @@ -0,0 +1,23 @@ +assertTrue($b); + } + + public function testBar(bool $b):void { + self::assertTrue($b); + } + + public function foo():void { + $x = $this->staticFn(); + } + + static protected function staticFn():bool { + return true; + } +} diff --git a/tests/Type/PHPUnit/data/mock-for-intersection.php b/tests/Type/PHPUnit/data/mock-for-intersection.php new file mode 100644 index 00000000..e6b72ec6 --- /dev/null +++ b/tests/Type/PHPUnit/data/mock-for-intersection.php @@ -0,0 +1,37 @@ +createMockForIntersectionOfInterfaces([FooInterface::class, BarInterface::class]), + ); + assertType( + 'MockForIntersection\BarInterface&MockForIntersection\FooInterface&PHPUnit\Framework\MockObject\Stub', + self::createStubForIntersectionOfInterfaces([FooInterface::class, BarInterface::class]), + ); + + + assertType( + 'PHPUnit\Framework\MockObject\MockObject', + $this->createMockForIntersectionOfInterfaces($bool ? [FooInterface::class, BarInterface::class] : [FooInterface::class]), + ); + assertType( + 'PHPUnit\Framework\MockObject\MockObject', + $this->createMockForIntersectionOfInterfaces($bool ? [FooInterface::class] : [BarInterface::class]), + ); + } + +} + +interface FooInterface {} +interface BarInterface {}