diff --git a/.gitattributes b/.gitattributes index ed98b8a4c8a..ba520f3418f 100644 --- a/.gitattributes +++ b/.gitattributes @@ -2,3 +2,5 @@ *.stub linguist-language=PHP tests/PHPStan/Command/ErrorFormatter/data/WindowsNewlines.php eol=crlf + +.github/workflows/*.lock.yml linguist-generated=true merge=ours \ No newline at end of file diff --git a/.github/workflows/apiref-infra.yml b/.github/workflows/apiref-infra.yml new file mode 100644 index 00000000000..23cc6ed1265 --- /dev/null +++ b/.github/workflows/apiref-infra.yml @@ -0,0 +1,96 @@ +# https://help.github.com/en/categories/automating-your-workflow-with-github-actions + +name: "API Reference Infra" + +on: + workflow_dispatch: + pull_request: + paths: + - '.github/workflows/apiref-infra.yml' + - 'apigen/infra/**' + push: + branches: + - "2.2.x" + paths: + - '.github/workflows/apiref-infra.yml' + - 'apigen/infra/**' + +concurrency: apiref-infra + +jobs: + test: + name: "Test" + runs-on: "ubuntu-latest" + permissions: + contents: read + + 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@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + + - name: "Install Node" + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: "22" + cache: "npm" + cache-dependency-path: apigen/infra/package-lock.json + + - name: "Install dependencies" + working-directory: ./apigen/infra + run: "npm ci" + + - name: "TypeScript check" + working-directory: ./apigen/infra + run: "npm run check" + + - name: "Unit tests" + working-directory: ./apigen/infra + run: "npm test" + + - name: "CDK synth" + working-directory: ./apigen/infra + run: "npx cdk synth --all --quiet" + + deploy: + name: "Deploy" + runs-on: "ubuntu-latest" + needs: test + if: "github.event_name == 'push' && github.ref == 'refs/heads/2.2.x'" + permissions: + id-token: write + contents: read + + 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@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + + - name: "Install Node" + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: "22" + cache: "npm" + cache-dependency-path: apigen/infra/package-lock.json + + - name: "Install dependencies" + working-directory: ./apigen/infra + run: "npm ci" + + - name: "Configure AWS credentials" + uses: aws-actions/configure-aws-credentials@d979d5b3a71173a29b74b5b88418bfda9437d885 # v6.1.1 + with: + role-to-assume: ${{ vars.APIREF_INFRA_DEPLOY_ROLE_ARN }} + aws-region: us-east-1 + + - name: "CDK deploy" + working-directory: ./apigen/infra + run: "npx cdk deploy --all --require-approval never" diff --git a/.github/workflows/apiref.yml b/.github/workflows/apiref.yml index 889ccbf5443..aa32551687d 100644 --- a/.github/workflows/apiref.yml +++ b/.github/workflows/apiref.yml @@ -11,6 +11,7 @@ on: - 'src/**' - 'composer.lock' - 'apigen/**' + - '!apigen/infra/**' - '.github/workflows/apiref.yml' env: @@ -64,43 +65,38 @@ jobs: - apigen if: github.repository_owner == 'phpstan' runs-on: "ubuntu-latest" + permissions: + id-token: write + contents: read steps: - name: Harden the runner (Audit all outbound calls) uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2 with: egress-policy: audit - - name: "Install Node" - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 - with: - node-version: "16" - - name: "Download docs" uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: name: docs path: docs - - name: "Sync with S3" - uses: jakejarvis/s3-sync-action@be0c4ab89158cac4278689ebedd8407dd5f35a83 # v0.5.1 + - name: "Configure AWS credentials" + uses: aws-actions/configure-aws-credentials@d979d5b3a71173a29b74b5b88418bfda9437d885 # v6.1.1 with: - args: --exclude '.git*/*' --follow-symlinks - env: - SOURCE_DIR: './docs' - DEST_DIR: ${{ github.ref_name }} - AWS_REGION: 'eu-west-1' - AWS_S3_BUCKET: "web-apiref.phpstan.org" - AWS_ACCESS_KEY_ID: ${{ secrets.APIREF_AWS_ACCESS_KEY_ID }} - AWS_SECRET_ACCESS_KEY: ${{ secrets.APIREF_AWS_SECRET_ACCESS_KEY }} + role-to-assume: ${{ vars.APIREF_DEPLOY_ROLE_ARN }} + aws-region: us-east-1 + + - name: "Sync with S3" + run: | + aws s3 sync ./docs "s3://${{ vars.APIREF_BUCKET }}/${{ github.ref_name }}" \ + --exclude '.git*/*' \ + --follow-symlinks - name: "Invalidate CloudFront" - uses: chetan/invalidate-cloudfront-action@12d242edc7752fca9140c2034be28792ad22c5a8 # v2.4.1 - env: - DISTRIBUTION: "E37G1C2KWNAPBD" - PATHS: '/${{ github.ref_name }}/*' - AWS_REGION: 'eu-west-1' - AWS_ACCESS_KEY_ID: ${{ secrets.APIREF_AWS_ACCESS_KEY_ID }} - AWS_SECRET_ACCESS_KEY: ${{ secrets.APIREF_AWS_SECRET_ACCESS_KEY }} + run: | + aws cloudfront create-invalidation \ + --distribution-id "${{ vars.APIREF_DISTRIBUTION_ID }}" \ + --paths "/${{ github.ref_name }}/*" - uses: peter-evans/repository-dispatch@ff45666b9427631e3450c54a1bcbee4d9ff4d7c0 # v3.0.0 with: diff --git a/.github/workflows/backward-compatibility.yml b/.github/workflows/backward-compatibility.yml index f928b68c51f..cb5ff5f0fa4 100644 --- a/.github/workflows/backward-compatibility.yml +++ b/.github/workflows/backward-compatibility.yml @@ -6,7 +6,7 @@ on: pull_request: push: branches: - - "2.1.x" + - "2.2.x" paths: - 'src/**' - '.github/workflows/backward-compatibility.yml' diff --git a/.github/workflows/bench.yml b/.github/workflows/bench.yml index 4a2a72ab9c1..d368a34dd17 100644 --- a/.github/workflows/bench.yml +++ b/.github/workflows/bench.yml @@ -8,7 +8,7 @@ on: - 'tests/bench/**' push: branches: - - "2.1.x" + - "2.2.x" paths: - 'src/**' - '.github/workflows/bench.yml' diff --git a/.github/workflows/build-issue-bot.yml b/.github/workflows/build-issue-bot.yml index 899c5b9b091..f634d4e9752 100644 --- a/.github/workflows/build-issue-bot.yml +++ b/.github/workflows/build-issue-bot.yml @@ -9,7 +9,7 @@ on: - '.github/workflows/build-issue-bot.yml' push: branches: - - "2.1.x" + - "2.2.x" paths: - 'issue-bot/**' - '.github/workflows/build-issue-bot.yml' diff --git a/.github/workflows/changelog-generator.yml b/.github/workflows/changelog-generator.yml index 19e406007f9..2dd24c145b5 100644 --- a/.github/workflows/changelog-generator.yml +++ b/.github/workflows/changelog-generator.yml @@ -9,7 +9,7 @@ on: - '.github/workflows/changelog-generator.yml' push: branches: - - "2.1.x" + - "2.2.x" paths: - 'changelog-generator/**' - '.github/workflows/changelog-generator.yml' diff --git a/.github/workflows/claude-update-config-parameters-docs-on-change.yml b/.github/workflows/claude-update-config-parameters-docs-on-change.yml new file mode 100644 index 00000000000..1d1ec046e39 --- /dev/null +++ b/.github/workflows/claude-update-config-parameters-docs-on-change.yml @@ -0,0 +1,26 @@ +name: "Claude Update Config Parameters Docs On Change" + +on: + push: + branches: + - "2.2.x" + paths: + - '.github/workflows/claude-update-config-parameters-docs-on-change.yml' + - 'conf/parametersSchema.neon' + +jobs: + fix: + runs-on: ubuntu-latest + permissions: + contents: read + actions: write + steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2 + with: + egress-policy: audit + + - name: Trigger Claude Update Config Parameters Docs + env: + GH_TOKEN: ${{ secrets.PHPSTAN_BOT_TOKEN }} + run: gh workflow run claude-update-config-parameters-docs.yml --repo phpstan-bot/phpstan-src diff --git a/.github/workflows/claude-update-phpdoc-tags-docs-on-change.yml b/.github/workflows/claude-update-phpdoc-tags-docs-on-change.yml new file mode 100644 index 00000000000..63d7f87e088 --- /dev/null +++ b/.github/workflows/claude-update-phpdoc-tags-docs-on-change.yml @@ -0,0 +1,26 @@ +name: "Claude Update PHPDoc Tags Docs On Change" + +on: + push: + branches: + - "2.2.x" + paths: + - '.github/workflows/claude-update-phpdoc-tags-docs-on-change.yml' + - 'src/PhpDoc/PhpDocNodeResolver.php' + +jobs: + fix: + runs-on: ubuntu-latest + permissions: + contents: read + actions: write + steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2 + with: + egress-policy: audit + + - name: Trigger Claude Update PHPDoc Tags Docs + env: + GH_TOKEN: ${{ secrets.PHPSTAN_BOT_TOKEN }} + run: gh workflow run claude-update-phpdoc-tags-docs.yml --repo phpstan-bot/phpstan-src diff --git a/.github/workflows/claude-update-phpdoc-types-docs-on-change.yml b/.github/workflows/claude-update-phpdoc-types-docs-on-change.yml new file mode 100644 index 00000000000..f7f623896de --- /dev/null +++ b/.github/workflows/claude-update-phpdoc-types-docs-on-change.yml @@ -0,0 +1,26 @@ +name: "Claude Update PHPDoc Types Docs On Change" + +on: + push: + branches: + - "2.2.x" + paths: + - '.github/workflows/claude-update-phpdoc-types-docs-on-change.yml' + - 'src/PhpDoc/TypeNodeResolver.php' + +jobs: + fix: + runs-on: ubuntu-latest + permissions: + contents: read + actions: write + steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2 + with: + egress-policy: audit + + - name: Trigger Claude Update PHPDoc Types Docs + env: + GH_TOKEN: ${{ secrets.PHPSTAN_BOT_TOKEN }} + run: gh workflow run claude-update-phpdoc-types-docs.yml --repo phpstan-bot/phpstan-src diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index 9f4e46ed86b..f5989b740b8 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -11,7 +11,7 @@ on: - 'issue-bot/**' push: branches: - - "2.1.x" + - "2.2.x" paths-ignore: - 'compiler/**' - 'apigen/**' @@ -291,6 +291,12 @@ jobs: cd e2e/bug-11857 composer install ../../bin/phpstan + - script: | + cd e2e/in-trait + OUTPUT=$(../bashunit -a exit_code "1" "../../bin/phpstan --error-format=raw") + ../bashunit -a contains 'FooTrait.php:10:Strict comparison using === between int<0, max> and false will always evaluate to false.' "$OUTPUT" + ../bashunit -a contains 'FooTrait.php (in context of class E2EInTrait\Bar):18:Strict comparison using === between E2EInTrait\Bar and null will always evaluate to false.' "$OUTPUT" + ../bashunit -a contains 'FooTrait.php (in context of class E2EInTrait\Foo):18:Strict comparison using === between E2EInTrait\Foo and null will always evaluate to false.' "$OUTPUT" - script: | cd e2e/result-cache-meta-extension composer install diff --git a/.github/workflows/lint-workflows.yml b/.github/workflows/lint-workflows.yml index 5dd4964d5ee..94b083a9da5 100644 --- a/.github/workflows/lint-workflows.yml +++ b/.github/workflows/lint-workflows.yml @@ -6,7 +6,7 @@ on: pull_request: push: branches: - - "2.1.x" + - "2.2.x" permissions: {} diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 0dcd57fc111..a48cc8087d4 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -6,7 +6,7 @@ on: pull_request: push: branches: - - "2.1.x" + - "2.2.x" concurrency: group: lint-${{ github.head_ref || github.run_id }} # will be canceled on subsequent pushes in pull requests but not branches diff --git a/.github/workflows/phar.yml b/.github/workflows/phar.yml index abc8cc94166..98132502013 100644 --- a/.github/workflows/phar.yml +++ b/.github/workflows/phar.yml @@ -6,9 +6,9 @@ on: pull_request: push: branches: - - "2.1.x" + - "2.2.x" tags: - - '2.1.*' + - '2.2.*' concurrency: group: phar-${{ github.ref }} # will be canceled on subsequent pushes in both branches and pull requests @@ -95,14 +95,14 @@ jobs: - uses: "ramsey/composer-install@3cf229dc2919194e9e36783941438d17239e8520" # v3 env: - COMPOSER_ROOT_VERSION: "2.1.x-dev" + COMPOSER_ROOT_VERSION: "2.2.x-dev" - name: "Compile PHAR for checksum" working-directory: "compiler/build" run: "php ../box/vendor/bin/box compile --no-parallel --sort-compiled-files" env: PHAR_CHECKSUM: "1" - COMPOSER_ROOT_VERSION: "2.1.x-dev" + COMPOSER_ROOT_VERSION: "2.2.x-dev" - name: "Re-sign PHAR" run: "php compiler/build/resign.php tmp/phpstan.phar" @@ -134,25 +134,25 @@ jobs: integration-tests: if: github.event_name == 'pull_request' needs: compiler-tests - uses: phpstan/phpstan/.github/workflows/integration-tests.yml@2.1.x + uses: phpstan/phpstan/.github/workflows/integration-tests.yml@2.2.x with: - ref: 2.1.x + ref: 2.2.x phar-checksum: ${{needs.compiler-tests.outputs.checksum}} extension-tests: if: github.event_name == 'pull_request' needs: compiler-tests - uses: phpstan/phpstan/.github/workflows/extension-tests.yml@2.1.x + uses: phpstan/phpstan/.github/workflows/extension-tests.yml@2.2.x with: - ref: 2.1.x + ref: 2.2.x phar-checksum: ${{needs.compiler-tests.outputs.checksum}} other-tests: if: github.event_name == 'pull_request' needs: compiler-tests - uses: phpstan/phpstan/.github/workflows/other-tests.yml@2.1.x + uses: phpstan/phpstan/.github/workflows/other-tests.yml@2.2.x with: - ref: 2.1.x + ref: 2.2.x phar-checksum: ${{needs.compiler-tests.outputs.checksum}} download-base-sha-phar: @@ -298,7 +298,7 @@ jobs: commit: name: "Commit PHAR" - if: "github.repository_owner == 'phpstan' && (github.ref == 'refs/heads/2.1.x' || startsWith(github.ref, 'refs/tags/'))" + if: "github.repository_owner == 'phpstan' && (github.ref == 'refs/heads/2.2.x' || startsWith(github.ref, 'refs/tags/'))" needs: compiler-tests runs-on: "ubuntu-latest" timeout-minutes: 60 @@ -325,7 +325,7 @@ jobs: repository: phpstan/phpstan path: phpstan-dist token: ${{ secrets.PHPSTAN_BOT_TOKEN }} - ref: 2.1.x + ref: 2.2.x - name: "Get previous pushed dist commit" id: previous-commit diff --git a/.github/workflows/reflection-golden-test.yml b/.github/workflows/reflection-golden-test.yml index 601066e21a1..94fd1ee263a 100644 --- a/.github/workflows/reflection-golden-test.yml +++ b/.github/workflows/reflection-golden-test.yml @@ -11,7 +11,7 @@ on: - 'issue-bot/**' push: branches: - - "2.1.x" + - "2.2.x" paths-ignore: - 'compiler/**' - 'apigen/**' diff --git a/.github/workflows/spelling.yml b/.github/workflows/spelling.yml index a8c84e09814..442cd7347e5 100644 --- a/.github/workflows/spelling.yml +++ b/.github/workflows/spelling.yml @@ -6,7 +6,7 @@ on: pull_request: push: branches: - - "2.1.x" + - "2.2.x" permissions: contents: read diff --git a/.github/workflows/static-analysis.yml b/.github/workflows/static-analysis.yml index 8162d58468e..2f79a0d3377 100644 --- a/.github/workflows/static-analysis.yml +++ b/.github/workflows/static-analysis.yml @@ -9,7 +9,7 @@ on: - 'apigen/**' push: branches: - - "2.1.x" + - "2.2.x" paths-ignore: - 'compiler/**' - 'apigen/**' diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index bdd01803733..ff3083cd87b 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -11,7 +11,7 @@ on: - 'issue-bot/**' push: branches: - - "2.1.x" + - "2.2.x" paths-ignore: - 'compiler/**' - 'apigen/**' diff --git a/apigen/infra/.gitignore b/apigen/infra/.gitignore new file mode 100644 index 00000000000..cd37b1e3441 --- /dev/null +++ b/apigen/infra/.gitignore @@ -0,0 +1,7 @@ +node_modules +*.js +!functions/*.js +*.d.ts +cdk.out +.cdk.staging +coverage diff --git a/apigen/infra/CLAUDE.md b/apigen/infra/CLAUDE.md new file mode 100644 index 00000000000..82e182cd54b --- /dev/null +++ b/apigen/infra/CLAUDE.md @@ -0,0 +1,133 @@ +# apiref.phpstan.org infrastructure (CDK) + +AWS CDK app (TypeScript) that defines the production infra for +[apiref.phpstan.org](https://apiref.phpstan.org) — the auto-generated ApiGen +reference for the PHPStan codebase. S3 origin, CloudFront distribution, edge +function for per-version landing-page redirects, security headers policy, ACM +cert, and the IAM roles that GitHub Actions assumes via OIDC. + +See `README.md` for the bootstrap, cutover, and cleanup runbook. + +This stack mirrors the main-site infra at +[`phpstan-dist`/website/infra](https://github.com/phpstan/phpstan/tree/2.2.x/website/infra) +— same patterns, same conventions; reach for that repo first when looking for +prior art. + +## Stacks + +Both stacks deploy to `us-east-1` (required for CloudFront + ACM). + +| Stack | Defined in | Resources | +| --- | --- | --- | +| `PhpstanApirefOidcRoles` | `lib/oidc-roles-stack.ts` | `phpstan-apiref-infra-deploy` IAM role used by `apiref-infra.yml`. **Reuses** the account-wide OIDC provider — does NOT create a new one (IAM rejects duplicates of the same provider URL). | +| `PhpstanApirefWebsite` | `lib/apiref-stack.ts` | Private S3 bucket (OAC, versioned), CloudFront distribution, CF Function 2.0, Response Headers Policy, DNS-validated ACM cert for `apiref.phpstan.org`, and `phpstan-apiref-deploy` IAM role used by `apiref.yml`. | + +`bin/infra.ts` is the CDK app entrypoint. It hard-codes the account/region/repo/zone constants and reads one CDK context flag, `productionAlias`, that toggles whether `apiref.phpstan.org` is attached to the distribution. + +## The `productionAlias` flag + +Defined in `cdk.json` under `context`, default `false`. + +- `false` (pre-cutover): distribution has no aliases and no ACM cert attached. CloudFormation can create the distribution without conflict even while the legacy `E37G1C2KWNAPBD` still owns the alias. The distribution serves on its `*.cloudfront.net` domain for pre-cutover testing. +- `true` (post-cutover): distribution carries `apiref.phpstan.org` and uses the ACM cert. + +The CDK code generates `Aliases: null` and `ViewerCertificate: null` when `productionAlias: false`. CloudFormation treats both as absent. + +## Out-of-band resources + +The Route 53 record for `apiref.phpstan.org` is **not** managed by CDK. It was +created/updated out-of-band during the cutover (raw `change-resource-record-sets`), +and CloudFormation cannot UPSERT a record that already exists outside its own +state. Same pattern as apex/www on the main site. + +## Edge function + +`functions/apiref-version-redirects.js` is the CloudFront Function 2.0 source. +It's a lookup-table version of the legacy `apiref-phpstan-org-viewer-request` +JS 1.0 function — same job: 301-redirect bare version URIs (e.g. `/2.2.x` or +`/2.2.x/`) to that version's landing page (`/namespace-PHPStan.html`), +and `/` to the current "latest" (2.2.x in this migration). + +302 → 301 was an intentional change to match the main site's redirects. + +The lookup table `VERSION_REDIRECTS` is hand-curated. When a new release branch +is added (say 2.3.x), append three entries: `'/2.3.x'`, `'/2.3.x/'`, both +mapping to `/2.3.x/namespace-PHPStan.html`. If 2.3.x should become the new +latest, also update the `'/'` entry. Then `npm test` ensures the lookup table +size and `/` mapping stay in sync. + +The file ends with `if (typeof module !== 'undefined') module.exports = {...}` +so it can be imported by Node-based unit tests. In the CloudFront runtime +`module` is undefined, so the export is silently skipped. + +## Project layout + +``` +apigen/infra/ +├── bin/infra.ts # CDK app entrypoint — wires both stacks +├── lib/ +│ ├── oidc-roles-stack.ts # IAM role (reuses existing OIDC provider) +│ └── apiref-stack.ts # everything that serves traffic +├── functions/ +│ └── apiref-version-redirects.js # CloudFront Function 2.0 source +├── test/ +│ ├── apiref-version-redirects.test.ts # Vitest: 25 redirect cases +│ └── apiref-stack.test.ts # Vitest: 11 CDK assertions +├── cdk.json # CDK config + context (incl. productionAlias) +├── package.json +├── tsconfig.json +├── vitest.config.ts +├── README.md # bootstrap + cutover runbook (human-facing) +└── CLAUDE.md # this file +``` + +## Conventions + +Same as the main-site infra: + +- **Tabs for indentation** in TS, JSON, and JS files. +- **2-space indent** for YAML workflows. +- **Pin GitHub Actions to commit SHAs** with the version in a trailing comment — matches the repo style and what `step-security/harden-runner` audits. +- **No `module.exports` / ESM imports in `functions/*.js`** — they run in the CloudFront Function runtime, not Node. The only allowed exception is the `typeof module` guard for unit-test interop. +- Resource IDs in CDK use **PascalCase**. Resource *names* (`bucketName`, `roleName`, `functionName`, `responseHeadersPolicyName`) use **kebab-case** with the `phpstan-apiref-` prefix so they're easy to spot in the console. +- Output exports use the `PhpstanApiref…` prefix. + +## Commands + +```sh +npm ci # install (run after pulling) +npm run check # tsc --noEmit +npm test # vitest run — 36 tests (redirect fn + stack assertions) +npm run synth # cdk synth --all (no AWS creds needed) +npm run diff # cdk diff --all (needs AWS creds for the target account) +npm run deploy # cdk deploy --all +``` + +`npm test` is the gate before any deploy — the CI workflow runs `check` + `test` + `synth` in a `test` job and blocks `diff` and `deploy` on it via `needs: test`. + +## CI + +`.github/workflows/apiref-infra.yml` triggers on PRs and pushes that touch +`apigen/infra/**` or the workflow file itself. Three jobs (same as the main +site's `website-infra.yml`): + +1. `test` — `npm ci && npm run check && npm test && npx cdk synth --all` (no AWS creds). +2. `diff` (needs: `test`) — assumes `APIREF_INFRA_DEPLOY_ROLE_ARN` via OIDC, runs `cdk diff --all`, posts a sticky PR comment. +3. `deploy` (needs: `[test, diff]`, only on push to `2.2.x`) — assumes the same role, runs `cdk deploy --all --require-approval never`. + +The `apiref.yml` workflow (the actual content deploy) uses `paths-ignore` via the inline `!apigen/infra/**` form so infra-only edits don't kick off a (slow) ApiGen rebuild. + +## When to edit what + +- **New release branch** (need a `/X.Y.x` → `/X.Y.x/namespace-PHPStan.html` redirect) → add three entries to `VERSION_REDIRECTS` in `functions/apiref-version-redirects.js` plus three test cases in `test/apiref-version-redirects.test.ts`. If it's the new latest, update `'/'` too. +- **Changing security headers** → `lib/apiref-stack.ts` (`responseHeadersPolicy` block), not the function. +- **Adding cache behaviors or new functions** → `lib/apiref-stack.ts`. Extend `test/apiref-stack.test.ts`. +- **Changing the trust policy** (e.g. allowing another branch to deploy) → `lib/oidc-roles-stack.ts` for infra deploys, or `lib/apiref-stack.ts` for the content deploy role. +- **Cutover flag** → `cdk.json` `context.productionAlias`. Only flip after the cutover script has done its work. + +## What lives elsewhere + +- The ApiGen tool, theme, and PHP filters — `../` (`apigen/apigen.neon`, `apigen/src/`, `apigen/theme/`). +- The PHP source code that ApiGen reads — `../../src/`. +- The build + publish pipeline — `.github/workflows/apiref.yml`. +- The main-site (`phpstan.org`) infra — separate repo `phpstan/phpstan` (the "dist" repo), under `website/infra/`. Identical patterns; consult it first when wondering "how did we solve X for the main site?". diff --git a/apigen/infra/README.md b/apigen/infra/README.md new file mode 100644 index 00000000000..2085f4daa67 --- /dev/null +++ b/apigen/infra/README.md @@ -0,0 +1,61 @@ +# apiref.phpstan.org infrastructure (CDK) + +CDK app that defines the AWS infrastructure for [apiref.phpstan.org](https://apiref.phpstan.org) +— the auto-generated ApiGen reference for the PHPStan codebase. Private S3 bucket +with OAC, CloudFront distribution, CloudFront Function 2.0 for per-version +landing-page redirects, Response Headers Policy, ACM cert, and the IAM role +assumed by `apiref.yml` via OIDC. + +Same shape as the main-site infra at [`phpstan-dist`/website/infra](https://github.com/phpstan/phpstan/tree/2.2.x/website/infra). + +## Stacks + +| Stack | Resources | +| --- | --- | +| `PhpstanApirefOidcRoles` | `phpstan-apiref-infra-deploy` role (used by `apiref-infra.yml`). Reuses the account-wide OIDC provider — does NOT create a new one. | +| `PhpstanApirefWebsite` | S3 bucket (OAC, private, versioned), CloudFront distribution, CF Function 2.0, Response Headers Policy, ACM cert, `phpstan-apiref-deploy` role used by `apiref.yml`. | + +Region: `us-east-1` (required for CloudFront + ACM). + +## The `productionAlias` flag + +Defined in `cdk.json` under `context`. Currently `true` — the distribution +carries `apiref.phpstan.org` as its alias and uses the CDK-issued ACM cert. + +It exists for the original cutover (it was `false` for the first deploy so the +distribution could be created while the legacy `E37G1C2KWNAPBD` still owned the +alias). It should stay `true`; only set it back to `false` if you ever need to +detach the alias for a rebuild. + +## Out-of-band resources + +The Route 53 records for `apiref.phpstan.org` are **not** managed by CDK — they +were created directly via `change-resource-record-sets` during the cutover, and +CloudFormation can't UPSERT records that already exist outside its state. If the +distribution's CloudFront domain ever changes (e.g. a recreate), update the +`apiref.phpstan.org` A/AAAA alias records by hand. Same pattern as apex/www on +the main site. + +## GitHub repo variables + +Set under Settings → Secrets and variables → Actions → Variables in `phpstan/phpstan-src`: + +| Variable | Value | Used by | +|---|---|---| +| `APIREF_INFRA_DEPLOY_ROLE_ARN` | `InfraDeployRoleArn` output of `PhpstanApirefOidcRoles` | `apiref-infra.yml` | +| `APIREF_DEPLOY_ROLE_ARN` | `DeployRoleArn` output of `PhpstanApirefWebsite` | `apiref.yml` | +| `APIREF_BUCKET` | `phpstan-apiref-web` | `apiref.yml` | +| `APIREF_DISTRIBUTION_ID` | `DistributionId` output of `PhpstanApirefWebsite` | `apiref.yml` | + +## Local development + +```sh +npm ci +npm run check # tsc --noEmit +npm test # vitest: 25 redirect-fn tests + 11 stack assertions +npm run synth # cdk synth --all +npm run diff # cdk diff --all (needs AWS creds for the target account) +``` + +Changes merged to `2.2.x` under `apigen/infra/**` are deployed automatically by +`.github/workflows/apiref-infra.yml`. diff --git a/apigen/infra/bin/infra.ts b/apigen/infra/bin/infra.ts new file mode 100644 index 00000000000..433a065933d --- /dev/null +++ b/apigen/infra/bin/infra.ts @@ -0,0 +1,49 @@ +#!/usr/bin/env node +import * as cdk from 'aws-cdk-lib'; +import { ApirefStack } from '../lib/apiref-stack'; +import { OidcRolesStack } from '../lib/oidc-roles-stack'; + +const app = new cdk.App(); + +const account = process.env.CDK_DEFAULT_ACCOUNT ?? '928192134594'; +const region = 'us-east-1'; +const env = { account, region }; + +const githubOrg = 'phpstan'; +const githubRepo = 'phpstan-src'; +const deployBranch = '2.2.x'; + +// Account-wide OIDC provider, created originally in the phpstan-dist repo's +// CDK app. We reference it by ARN — never instantiate a new one, IAM rejects +// duplicates of the same provider URL. +const oidcProviderArn = `arn:aws:iam::${account}:oidc-provider/token.actions.githubusercontent.com`; + +const hostedZoneId = 'Z3OJGVJEUUWZDN'; +const hostedZoneName = 'phpstan.org'; +const apirefDomain = 'apiref.phpstan.org'; + +const productionAlias = app.node.tryGetContext('productionAlias') === true; + +new OidcRolesStack(app, 'PhpstanApirefOidcRoles', { + env, + githubOrg, + githubRepo, + deployBranch, + oidcProviderArn, + description: 'IAM role for the apiref-infra GitHub Actions workflow (OIDC).', +}); + +new ApirefStack(app, 'PhpstanApirefWebsite', { + env, + githubOrg, + githubRepo, + deployBranch, + oidcProviderArn, + hostedZoneId, + hostedZoneName, + apirefDomain, + productionAlias, + description: `apiref.phpstan.org website (S3 + CloudFront + CF Function). productionAlias=${productionAlias}`, +}); + +app.synth(); diff --git a/apigen/infra/cdk.json b/apigen/infra/cdk.json new file mode 100644 index 00000000000..bcb3acdeb9d --- /dev/null +++ b/apigen/infra/cdk.json @@ -0,0 +1,30 @@ +{ + "app": "npx ts-node --prefer-ts-exts bin/infra.ts", + "watch": { + "include": ["**"], + "exclude": [ + "README.md", + "cdk*.json", + "**/*.d.ts", + "**/*.js", + "tsconfig.json", + "package*.json", + "node_modules", + "test" + ] + }, + "context": { + "productionAlias": true, + "@aws-cdk/aws-lambda:recognizeLayerVersion": true, + "@aws-cdk/core:checkSecretUsage": true, + "@aws-cdk/core:target-partitions": ["aws"], + "@aws-cdk/aws-iam:minimizePolicies": true, + "@aws-cdk/core:validateSnapshotRemovalPolicy": true, + "@aws-cdk/aws-s3:createDefaultLoggingPolicy": true, + "@aws-cdk/aws-cloudfront:defaultSecurityPolicyTLSv1.2_2021": true, + "@aws-cdk/aws-iam:standardizedServicePrincipals": true, + "@aws-cdk/aws-ec2:restrictDefaultSecurityGroup": true, + "@aws-cdk/core:includePrefixInUniqueNameGeneration": true, + "@aws-cdk/aws-lambda-nodejs:useLatestRuntimeVersion": true + } +} diff --git a/apigen/infra/functions/apiref-version-redirects.js b/apigen/infra/functions/apiref-version-redirects.js new file mode 100644 index 00000000000..69133d4d6d4 --- /dev/null +++ b/apigen/infra/functions/apiref-version-redirects.js @@ -0,0 +1,52 @@ +// CloudFront Function (runtime cloudfront-js-2.0), viewer-request. +// +// Replaces the legacy `apiref-phpstan-org-viewer-request` JS 1.0 function. +// Same job: redirect bare-version URIs (e.g. `/2.2.x` or `/2.2.x/`) to that +// version's landing page. `/` redirects to the current "latest" — bumped +// from 2.1.x to 2.2.x in this migration. +// +// When a new branch lands, add three entries here: `/X.Y.x`, `/X.Y.x/`, and +// update the `/` mapping if it should become the new latest. +// +// CloudFront runs this file directly and looks for a top-level `function handler`. +// The trailing `module.exports` is gated on `typeof module` so the same source +// can be imported into Node-based unit tests; in the CF runtime `module` is not +// defined, so the export is silently skipped. + +var VERSION_REDIRECTS = { + '/': '/2.2.x/namespace-PHPStan.html', + '/2.2.x': '/2.2.x/namespace-PHPStan.html', + '/2.2.x/': '/2.2.x/namespace-PHPStan.html', + '/2.1.x': '/2.1.x/namespace-PHPStan.html', + '/2.1.x/': '/2.1.x/namespace-PHPStan.html', + '/2.0.x': '/2.0.x/namespace-PHPStan.html', + '/2.0.x/': '/2.0.x/namespace-PHPStan.html', + '/1.12.x': '/1.12.x/namespace-PHPStan.html', + '/1.12.x/': '/1.12.x/namespace-PHPStan.html', + '/1.11.x': '/1.11.x/namespace-PHPStan.html', + '/1.11.x/': '/1.11.x/namespace-PHPStan.html', + '/1.10.x': '/1.10.x/namespace-PHPStan.html', + '/1.10.x/': '/1.10.x/namespace-PHPStan.html', + '/1.9.x': '/1.9.x/namespace-PHPStan.html', + '/1.9.x/': '/1.9.x/namespace-PHPStan.html', + // 1.8.x exists in the bucket but had no landing-page redirect in the + // legacy function; preserve that gap. +}; + +function handler(event) { + var target = VERSION_REDIRECTS[event.request.uri]; + if (target) { + return { + statusCode: 301, + statusDescription: 'Moved Permanently', + headers: { + location: { value: target }, + }, + }; + } + return event.request; +} + +if (typeof module !== 'undefined') { + module.exports = { handler: handler, VERSION_REDIRECTS: VERSION_REDIRECTS }; +} diff --git a/apigen/infra/lib/apiref-stack.ts b/apigen/infra/lib/apiref-stack.ts new file mode 100644 index 00000000000..2f70bf7495e --- /dev/null +++ b/apigen/infra/lib/apiref-stack.ts @@ -0,0 +1,181 @@ +import * as cdk from 'aws-cdk-lib'; +import * as acm from 'aws-cdk-lib/aws-certificatemanager'; +import * as cloudfront from 'aws-cdk-lib/aws-cloudfront'; +import * as origins from 'aws-cdk-lib/aws-cloudfront-origins'; +import * as iam from 'aws-cdk-lib/aws-iam'; +import * as route53 from 'aws-cdk-lib/aws-route53'; +import * as s3 from 'aws-cdk-lib/aws-s3'; +import { Construct } from 'constructs'; +import * as path from 'node:path'; + +export interface ApirefStackProps extends cdk.StackProps { + readonly githubOrg: string; + readonly githubRepo: string; + readonly deployBranch: string; + readonly oidcProviderArn: string; + readonly hostedZoneId: string; + readonly hostedZoneName: string; + readonly apirefDomain: string; + readonly productionAlias: boolean; +} + +// The apiref.phpstan.org infrastructure: private S3 bucket served via +// CloudFront with OAC, a CloudFront Function 2.0 for per-version landing-page +// redirects, a Response Headers Policy for security headers, an ACM cert +// (DNS-validated), and the IAM role used by the apiref.yml workflow. +// +// The `productionAlias` flag toggles whether `apiref.phpstan.org` is attached +// to the distribution. We start at `false` so the first deploy succeeds while +// the alias still lives on the legacy distribution. After the cutover script +// moves the alias, we flip to `true` so CDK code matches reality. +// +// Route 53: the `apiref.phpstan.org` CNAME is created and managed out-of-band +// during the cutover (matches the apex/www pattern from the main site). +export class ApirefStack extends cdk.Stack { + readonly bucket: s3.Bucket; + readonly distribution: cloudfront.Distribution; + readonly deployRole: iam.Role; + + constructor(scope: Construct, id: string, props: ApirefStackProps) { + super(scope, id, props); + + this.bucket = new s3.Bucket(this, 'ApirefBucket', { + bucketName: 'phpstan-apiref-web', + blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL, + encryption: s3.BucketEncryption.S3_MANAGED, + versioned: true, + removalPolicy: cdk.RemovalPolicy.RETAIN, + enforceSSL: true, + }); + + const hostedZone = route53.HostedZone.fromHostedZoneAttributes(this, 'HostedZone', { + hostedZoneId: props.hostedZoneId, + zoneName: props.hostedZoneName, + }); + + const certificate = new acm.Certificate(this, 'Certificate', { + domainName: props.apirefDomain, + validation: acm.CertificateValidation.fromDns(hostedZone), + }); + + const edgeFunction = new cloudfront.Function(this, 'VersionRedirectsFunction', { + functionName: 'apiref-version-redirects', + comment: 'Viewer-request: per-version landing-page redirects for apiref.phpstan.org.', + runtime: cloudfront.FunctionRuntime.JS_2_0, + code: cloudfront.FunctionCode.fromFile({ + filePath: path.join(__dirname, '..', 'functions', 'apiref-version-redirects.js'), + }), + }); + + const responseHeadersPolicy = new cloudfront.ResponseHeadersPolicy(this, 'SecurityHeadersPolicy', { + responseHeadersPolicyName: 'apiref-security-headers', + comment: 'HSTS, X-Content-Type-Options, X-Frame-Options, Referrer-Policy for apiref.phpstan.org.', + securityHeadersBehavior: { + strictTransportSecurity: { + accessControlMaxAge: cdk.Duration.days(365), + includeSubdomains: true, + preload: true, + override: true, + }, + contentTypeOptions: { override: true }, + frameOptions: { + frameOption: cloudfront.HeadersFrameOption.SAMEORIGIN, + override: true, + }, + referrerPolicy: { + referrerPolicy: cloudfront.HeadersReferrerPolicy.STRICT_ORIGIN_WHEN_CROSS_ORIGIN, + override: true, + }, + }, + }); + + const domainNames = props.productionAlias ? [props.apirefDomain] : undefined; + const distributionCertificate = props.productionAlias ? certificate : undefined; + + this.distribution = new cloudfront.Distribution(this, 'Distribution', { + comment: `apiref.phpstan.org (productionAlias=${props.productionAlias})`, + domainNames, + certificate: distributionCertificate, + defaultRootObject: 'index.html', + minimumProtocolVersion: cloudfront.SecurityPolicyProtocol.TLS_V1_2_2021, + priceClass: cloudfront.PriceClass.PRICE_CLASS_100, + httpVersion: cloudfront.HttpVersion.HTTP2_AND_3, + enableIpv6: true, + defaultBehavior: { + origin: origins.S3BucketOrigin.withOriginAccessControl(this.bucket), + viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS, + allowedMethods: cloudfront.AllowedMethods.ALLOW_GET_HEAD, + cachedMethods: cloudfront.CachedMethods.CACHE_GET_HEAD, + compress: true, + cachePolicy: cloudfront.CachePolicy.CACHING_OPTIMIZED, + responseHeadersPolicy, + functionAssociations: [{ + function: edgeFunction, + eventType: cloudfront.FunctionEventType.VIEWER_REQUEST, + }], + }, + }); + + this.deployRole = this.createDeployRole(props); + + new cdk.CfnOutput(this, 'BucketName', { + value: this.bucket.bucketName, + description: 'S3 bucket name for the apiref content', + exportName: 'PhpstanApirefBucketName', + }); + new cdk.CfnOutput(this, 'DistributionId', { + value: this.distribution.distributionId, + description: 'CloudFront distribution ID for apiref', + exportName: 'PhpstanApirefDistributionId', + }); + new cdk.CfnOutput(this, 'DistributionDomain', { + value: this.distribution.distributionDomainName, + description: 'CloudFront default domain (used for pre-cutover testing)', + }); + new cdk.CfnOutput(this, 'DeployRoleArn', { + value: this.deployRole.roleArn, + description: 'Role ARN for the apiref content GitHub Actions workflow', + exportName: 'PhpstanApirefDeployRoleArn', + }); + new cdk.CfnOutput(this, 'CertificateArn', { + value: certificate.certificateArn, + description: 'ACM cert for apiref.phpstan.org (attached only when productionAlias=true)', + }); + } + + private createDeployRole(props: ApirefStackProps): iam.Role { + const role = new iam.Role(this, 'DeployRole', { + roleName: 'phpstan-apiref-deploy', + description: 'Assumed by the apiref.yml GitHub Actions workflow to sync the bucket and invalidate CloudFront.', + assumedBy: new iam.FederatedPrincipal( + props.oidcProviderArn, + { + StringEquals: { + 'token.actions.githubusercontent.com:aud': 'sts.amazonaws.com', + }, + StringLike: { + 'token.actions.githubusercontent.com:sub': `repo:${props.githubOrg}/${props.githubRepo}:ref:refs/heads/${props.deployBranch}`, + }, + }, + 'sts:AssumeRoleWithWebIdentity', + ), + maxSessionDuration: cdk.Duration.hours(1), + }); + + this.bucket.grantReadWrite(role); + this.bucket.grantDelete(role); + + role.addToPolicy(new iam.PolicyStatement({ + actions: [ + 'cloudfront:CreateInvalidation', + 'cloudfront:GetInvalidation', + 'cloudfront:ListInvalidations', + ], + resources: [ + `arn:aws:cloudfront::${this.account}:distribution/${this.distribution.distributionId}`, + ], + })); + + return role; + } +} diff --git a/apigen/infra/lib/oidc-roles-stack.ts b/apigen/infra/lib/oidc-roles-stack.ts new file mode 100644 index 00000000000..e9e94d89fd7 --- /dev/null +++ b/apigen/infra/lib/oidc-roles-stack.ts @@ -0,0 +1,57 @@ +import * as cdk from 'aws-cdk-lib'; +import * as iam from 'aws-cdk-lib/aws-iam'; +import { Construct } from 'constructs'; + +export interface OidcRolesStackProps extends cdk.StackProps { + readonly githubOrg: string; + readonly githubRepo: string; + readonly deployBranch: string; + readonly oidcProviderArn: string; +} + +// IAM role used by the apiref-infra GitHub Actions workflow to run +// `cdk diff` / `cdk deploy`. Reuses the account-wide OIDC provider that +// already exists (created by the phpstan-dist repo's CDK app); we do NOT +// create a new `OpenIdConnectProvider` because IAM rejects duplicates. +export class OidcRolesStack extends cdk.Stack { + readonly infraDeployRole: iam.Role; + + constructor(scope: Construct, id: string, props: OidcRolesStackProps) { + super(scope, id, props); + + const subjectPrefix = `repo:${props.githubOrg}/${props.githubRepo}`; + const allowedSubjects = [ + `${subjectPrefix}:ref:refs/heads/${props.deployBranch}`, + `${subjectPrefix}:pull_request`, + ]; + + this.infraDeployRole = new iam.Role(this, 'InfraDeployRole', { + roleName: 'phpstan-apiref-infra-deploy', + description: 'Assumed by the apiref-infra GitHub Actions workflow to run cdk diff / cdk deploy.', + assumedBy: new iam.FederatedPrincipal( + props.oidcProviderArn, + { + StringEquals: { + 'token.actions.githubusercontent.com:aud': 'sts.amazonaws.com', + }, + StringLike: { + 'token.actions.githubusercontent.com:sub': allowedSubjects, + }, + }, + 'sts:AssumeRoleWithWebIdentity', + ), + maxSessionDuration: cdk.Duration.hours(1), + }); + + this.infraDeployRole.addToPolicy(new iam.PolicyStatement({ + actions: ['sts:AssumeRole'], + resources: [`arn:aws:iam::${this.account}:role/cdk-*`], + })); + + new cdk.CfnOutput(this, 'InfraDeployRoleArn', { + value: this.infraDeployRole.roleArn, + description: 'Role ARN for the apiref-infra GitHub Actions workflow', + exportName: 'PhpstanApirefInfraDeployRoleArn', + }); + } +} diff --git a/apigen/infra/package-lock.json b/apigen/infra/package-lock.json new file mode 100644 index 00000000000..b8fd040ef02 --- /dev/null +++ b/apigen/infra/package-lock.json @@ -0,0 +1,2295 @@ +{ + "name": "phpstan-apiref-infra", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "phpstan-apiref-infra", + "version": "1.0.0", + "license": "MIT", + "bin": { + "infra": "bin/infra.js" + }, + "devDependencies": { + "@types/node": "^22.10.0", + "aws-cdk": "^2.1010.0", + "aws-cdk-lib": "^2.180.0", + "constructs": "^10.4.2", + "ts-node": "^10.9.2", + "typescript": "^5.7.2", + "vitest": "^3.0.0" + } + }, + "node_modules/@aws-cdk/asset-awscli-v1": { + "version": "2.2.273", + "resolved": "https://registry.npmjs.org/@aws-cdk/asset-awscli-v1/-/asset-awscli-v1-2.2.273.tgz", + "integrity": "sha512-X57HYUtHt9BQrlrzUNcMyRsDUCoakYNnY6qh5lNwRCHPtQoTfXmuISkfLk0AjLkcbS5lw1LLTQFiQhTDXfiTvg==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/@aws-cdk/asset-node-proxy-agent-v6": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@aws-cdk/asset-node-proxy-agent-v6/-/asset-node-proxy-agent-v6-2.1.1.tgz", + "integrity": "sha512-We4bmHaowOPHr+IQR4/FyTGjRfjgBj4ICMjtqmJeBDWad3Q/6St12NT07leNtyuukv2qMhtSZJQorD8KpKTwRA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/@aws-cdk/cloud-assembly-schema": { + "version": "53.22.0", + "resolved": "https://registry.npmjs.org/@aws-cdk/cloud-assembly-schema/-/cloud-assembly-schema-53.22.0.tgz", + "integrity": "sha512-2GLhjjf7Db697rRyYt6rjN06fwbBIiewPo/jxSCyUN259ejF7NQQRwvrdAQb9Aq2jPaIIp1cfHSoEdXyA6Bb7Q==", + "bundleDependencies": [ + "jsonschema", + "semver" + ], + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "jsonschema": "~1.4.1", + "semver": "^7.7.4" + }, + "engines": { + "node": ">= 18.0.0" + } + }, + "node_modules/@aws-cdk/cloud-assembly-schema/node_modules/jsonschema": { + "version": "1.4.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/@aws-cdk/cloud-assembly-schema/node_modules/semver": { + "version": "7.7.4", + "dev": true, + "inBundle": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", + "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", + "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", + "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", + "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", + "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", + "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", + "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", + "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", + "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", + "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", + "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", + "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", + "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", + "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", + "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", + "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", + "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", + "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", + "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", + "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", + "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", + "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", + "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", + "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", + "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", + "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.3.tgz", + "integrity": "sha512-x35CNW/ANXG3hE/EZpRU8MXX1JDN86hBb2wMGAtltkz7pc6cxgjpy1OMMfDosOQ+2hWqIkag/fGok1Yady9nGw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.3.tgz", + "integrity": "sha512-xw3xtkDApIOGayehp2+Rz4zimfkaX65r4t47iy+ymQB2G4iJCBBfj0ogVg5jpvjpn8UWn/+q9tprxleYeNp3Hw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.3.tgz", + "integrity": "sha512-vo6Y5Qfpx7/5EaamIwi0WqW2+zfiusVihKatLvtN1VFVy3D13uERk/6gZLU1UiHRL6fDXqj/ELIeVRGnvcTE1g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.3.tgz", + "integrity": "sha512-D+0QGcZhBzTN82weOnsSlY7V7+RMmPuF1CkbxyMAGE8+ZHeUjyb76ZiWmBlCu//AQQONvxcqRbwZTajZKqjuOw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.3.tgz", + "integrity": "sha512-6HnvHCT7fDyj6R0Ph7A6x8dQS/S38MClRWeDLqc0MdfWkxjiu1HSDYrdPhqSILzjTIC/pnXbbJbo+ft+gy/9hQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.3.tgz", + "integrity": "sha512-KHLgC3WKlUYW3ShFKnnosZDOJ0xjg9zp7au3sIm2bs/tGBeC2ipmvRh/N7JKi0t9Ue20C0dpEshi8WUubg+cnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.3.tgz", + "integrity": "sha512-DV6fJoxEYWJOvaZIsok7KrYl0tPvga5OZ2yvKHNNYyk/2roMLqQAbGhr78EQ5YhHpnhLKJD3S1WFusAkmUuV5g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.3.tgz", + "integrity": "sha512-mQKoJAzvuOs6F+TZybQO4GOTSMUu7v0WdxEk24krQ/uUxXoPTtHjuaUuPmFhtBcM4K0ons8nrE3JyhTuCFtT/w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.3.tgz", + "integrity": "sha512-Whjj2qoiJ6+OOJMGptTYazaJvjOJm+iKHpXQM1P3LzGjt7Ff++Tp7nH4N8J/BUA7R9IHfDyx4DJIflifwnbmIA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.3.tgz", + "integrity": "sha512-4YTNHKqGng5+yiZt3mg77nmyuCfmNfX4fPmyUapBcIk+BdwSwmCWGXOUxhXbBEkFHtoN5boLj/5NON+u5QC9tg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.3.tgz", + "integrity": "sha512-SU3kNlhkpI4UqlUc2VXPGK9o886ZsSeGfMAX2ba2b8DKmMXq4AL7KUrkSWVbb7koVqx41Yczx6dx5PNargIrEA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.3.tgz", + "integrity": "sha512-6lDLl5h4TXpB1mTf2rQWnAk/LcXrx9vBfu/DT5TIPhvMhRWaZ5MxkIc8u4lJAmBo6klTe1ywXIUHFjylW505sg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.3.tgz", + "integrity": "sha512-BMo8bOw8evlup/8G+cj5xWtPyp93xPdyoSN16Zy90Q2QZ0ZYRhCt6ZJSwbrRzG9HApFabjwj2p25TUPDWrhzqQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.3.tgz", + "integrity": "sha512-E0L8X1dZN1/Rph+5VPF6Xj2G7JJvMACVXtamTJIDrVI44Y3K+G8gQaMEAavbqCGTa16InptiVrX6eM6pmJ+7qA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.3.tgz", + "integrity": "sha512-oZJ/WHaVfHUiRAtmTAeo3DcevNsVvH8mbvodjZy7D5QKvCefO371SiKRpxoDcCxB3PTRTLayWBkvmDQKTcX/sw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.3.tgz", + "integrity": "sha512-Dhbyh7j9FybM3YaTgaHmVALwA8AkUwTPccyCQ79TG9AJUsMQqgN1DDEZNr4+QUfwiWvLDumW5vdwzoeUF+TNxQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.3.tgz", + "integrity": "sha512-cJd1X5XhHHlltkaypz1UcWLA8AcoIi1aWhsvaWDskD1oz2eKCypnqvTQ8ykMNI0RSmm7NkTdSqSSD7zM0xa6Ig==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.3.tgz", + "integrity": "sha512-DAZDBHQfG2oQuhY7mc6I3/qB4LU2fQCjRvxbDwd/Jdvb9fypP4IJ4qmtu6lNjes6B531AI8cg1aKC2di97bUxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.3.tgz", + "integrity": "sha512-cRxsE8c13mZOh3vP+wLDxpQBRrOHDIGOWyDL93Sy0Ga8y515fBcC2pjUfFwUe5T7tqvTvWbCpg1URM/AXdWIXA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.3.tgz", + "integrity": "sha512-QaWcIgRxqEdQdhJqW4DJctsH6HCmo5vHxY0krHSX4jMtOqfzC+dqDGuHM87bu4H8JBeibWx7jFz+h6/4C8wA5Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.3.tgz", + "integrity": "sha512-AaXwSvUi3QIPtroAUw1t5yHGIyqKEXwH54WUocFolZhpGDruJcs8c+xPNDRn4XiQsS7MEwnYsHW2l0MBLDMkWg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.3.tgz", + "integrity": "sha512-65LAKM/bAWDqKNEelHlcHvm2V+Vfb8C6INFxQXRHCvaVN1rJfwr4NvdP4FyzUaLqWfaCGaadf6UbTm8xJeYfEg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.3.tgz", + "integrity": "sha512-EEM2gyhBF5MFnI6vMKdX1LAosE627RGBzIoGMdLloPZkXrUN0Ckqgr2Qi8+J3zip/8NVVro3/FjB+tjhZUgUHA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.3.tgz", + "integrity": "sha512-E5Eb5H/DpxaoXH++Qkv28RcUJboMopmdDUALBczvHMf7hNIxaDZqwY5lK12UK1BHacSmvupoEWGu+n993Z0y1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.3.tgz", + "integrity": "sha512-hPt/bgL5cE+Qp+/TPHBqptcAgPzgj46mPcg/16zNUmbQk0j+mOEQV/+Lqu8QRtDV3Ek95Q6FeFITpuhl6OTsAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@tsconfig/node10": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz", + "integrity": "sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node12": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", + "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node14": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", + "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node16": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz", + "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.19.19", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.19.tgz", + "integrity": "sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@vitest/expect": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz", + "integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz", + "integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.2.4", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz", + "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz", + "integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.2.4", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz", + "integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.4", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz", + "integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz", + "integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.4", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-walk": { + "version": "8.3.5", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz", + "integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.11.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/arg": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", + "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", + "dev": true, + "license": "MIT" + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/aws-cdk": { + "version": "2.1121.0", + "resolved": "https://registry.npmjs.org/aws-cdk/-/aws-cdk-2.1121.0.tgz", + "integrity": "sha512-cG7CHt/SytYTfwrK+BUNQpqmS1dwhjt8z6ExKL6GK4n+8/6ZCwFzxlZWA/jUd2+Y9xPc+Q8cLKfMqGmgxEXbkg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "cdk": "bin/cdk" + }, + "engines": { + "node": ">= 18.0.0" + } + }, + "node_modules/aws-cdk-lib": { + "version": "2.253.1", + "resolved": "https://registry.npmjs.org/aws-cdk-lib/-/aws-cdk-lib-2.253.1.tgz", + "integrity": "sha512-vy+hA15/ZfSQpivkNdlIn2ZDA2hesp3WJgmtIZJDFwu6xzwv7wH7glbAdu5xCHGcOjepOaTKZSvCPC6sN+0/Vw==", + "bundleDependencies": [ + "@balena/dockerignore", + "@aws-cdk/cloud-assembly-api", + "case", + "fs-extra", + "ignore", + "jsonschema", + "minimatch", + "punycode", + "semver", + "table", + "yaml", + "mime-types" + ], + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-cdk/asset-awscli-v1": "2.2.273", + "@aws-cdk/asset-node-proxy-agent-v6": "^2.1.1", + "@aws-cdk/cloud-assembly-api": "^2.2.2", + "@aws-cdk/cloud-assembly-schema": "^53.18.0", + "@balena/dockerignore": "^1.0.2", + "case": "1.6.3", + "fs-extra": "^11.3.3", + "ignore": "^5.3.2", + "jsonschema": "^1.5.0", + "mime-types": "^2.1.35", + "minimatch": "^10.2.3", + "punycode": "^2.3.1", + "semver": "^7.7.4", + "table": "^6.9.0", + "yaml": "1.10.3" + }, + "engines": { + "node": ">= 20.0.0" + }, + "peerDependencies": { + "constructs": "^10.5.0" + } + }, + "node_modules/aws-cdk-lib/node_modules/@aws-cdk/cloud-assembly-api": { + "version": "2.2.2", + "bundleDependencies": [ + "jsonschema", + "semver" + ], + "dev": true, + "inBundle": true, + "license": "Apache-2.0", + "dependencies": { + "jsonschema": "~1.4.1", + "semver": "^7.7.4" + }, + "engines": { + "node": ">= 18.0.0" + }, + "peerDependencies": { + "@aws-cdk/cloud-assembly-schema": ">=53.15.0" + } + }, + "node_modules/aws-cdk-lib/node_modules/@balena/dockerignore": { + "version": "1.0.2", + "dev": true, + "inBundle": true, + "license": "Apache-2.0" + }, + "node_modules/aws-cdk-lib/node_modules/ajv": { + "version": "8.18.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/aws-cdk-lib/node_modules/ansi-regex": { + "version": "5.0.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/aws-cdk-lib/node_modules/ansi-styles": { + "version": "4.3.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/aws-cdk-lib/node_modules/astral-regex": { + "version": "2.0.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/aws-cdk-lib/node_modules/balanced-match": { + "version": "4.0.4", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/aws-cdk-lib/node_modules/brace-expansion": { + "version": "5.0.5", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/aws-cdk-lib/node_modules/case": { + "version": "1.6.3", + "dev": true, + "inBundle": true, + "license": "(MIT OR GPL-3.0-or-later)", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/aws-cdk-lib/node_modules/color-convert": { + "version": "2.0.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/aws-cdk-lib/node_modules/color-name": { + "version": "1.1.4", + "dev": true, + "inBundle": true, + "license": "MIT" + }, + "node_modules/aws-cdk-lib/node_modules/emoji-regex": { + "version": "8.0.0", + "dev": true, + "inBundle": true, + "license": "MIT" + }, + "node_modules/aws-cdk-lib/node_modules/fast-deep-equal": { + "version": "3.1.3", + "dev": true, + "inBundle": true, + "license": "MIT" + }, + "node_modules/aws-cdk-lib/node_modules/fast-uri": { + "version": "3.1.0", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "inBundle": true, + "license": "BSD-3-Clause" + }, + "node_modules/aws-cdk-lib/node_modules/fs-extra": { + "version": "11.3.3", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/aws-cdk-lib/node_modules/graceful-fs": { + "version": "4.2.11", + "dev": true, + "inBundle": true, + "license": "ISC" + }, + "node_modules/aws-cdk-lib/node_modules/ignore": { + "version": "5.3.2", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/aws-cdk-lib/node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/aws-cdk-lib/node_modules/json-schema-traverse": { + "version": "1.0.0", + "dev": true, + "inBundle": true, + "license": "MIT" + }, + "node_modules/aws-cdk-lib/node_modules/jsonfile": { + "version": "6.2.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/aws-cdk-lib/node_modules/jsonschema": { + "version": "1.5.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/aws-cdk-lib/node_modules/lodash.truncate": { + "version": "4.4.2", + "dev": true, + "inBundle": true, + "license": "MIT" + }, + "node_modules/aws-cdk-lib/node_modules/mime-db": { + "version": "1.52.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/aws-cdk-lib/node_modules/mime-types": { + "version": "2.1.35", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/aws-cdk-lib/node_modules/minimatch": { + "version": "10.2.5", + "dev": true, + "inBundle": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/aws-cdk-lib/node_modules/punycode": { + "version": "2.3.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/aws-cdk-lib/node_modules/require-from-string": { + "version": "2.0.2", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/aws-cdk-lib/node_modules/semver": { + "version": "7.7.4", + "dev": true, + "inBundle": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/aws-cdk-lib/node_modules/slice-ansi": { + "version": "4.0.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "astral-regex": "^2.0.0", + "is-fullwidth-code-point": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, + "node_modules/aws-cdk-lib/node_modules/string-width": { + "version": "4.2.3", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/aws-cdk-lib/node_modules/strip-ansi": { + "version": "6.0.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/aws-cdk-lib/node_modules/table": { + "version": "6.9.0", + "dev": true, + "inBundle": true, + "license": "BSD-3-Clause", + "dependencies": { + "ajv": "^8.0.1", + "lodash.truncate": "^4.4.2", + "slice-ansi": "^4.0.0", + "string-width": "^4.2.3", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/aws-cdk-lib/node_modules/universalify": { + "version": "2.0.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/aws-cdk-lib/node_modules/yaml": { + "version": "1.10.3", + "dev": true, + "inBundle": true, + "license": "ISC", + "engines": { + "node": ">= 6" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/constructs": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/constructs/-/constructs-10.6.0.tgz", + "integrity": "sha512-TxHOnBO5zMo/G76ykzGF/wMpEHu257TbWiIxP9K0Yv/+t70UzgBQiTqjkAsWOPC6jW91DzJI0+ehQV6xDRNBuQ==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/create-require": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", + "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/diff": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz", + "integrity": "sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", + "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.7", + "@esbuild/android-arm": "0.27.7", + "@esbuild/android-arm64": "0.27.7", + "@esbuild/android-x64": "0.27.7", + "@esbuild/darwin-arm64": "0.27.7", + "@esbuild/darwin-x64": "0.27.7", + "@esbuild/freebsd-arm64": "0.27.7", + "@esbuild/freebsd-x64": "0.27.7", + "@esbuild/linux-arm": "0.27.7", + "@esbuild/linux-arm64": "0.27.7", + "@esbuild/linux-ia32": "0.27.7", + "@esbuild/linux-loong64": "0.27.7", + "@esbuild/linux-mips64el": "0.27.7", + "@esbuild/linux-ppc64": "0.27.7", + "@esbuild/linux-riscv64": "0.27.7", + "@esbuild/linux-s390x": "0.27.7", + "@esbuild/linux-x64": "0.27.7", + "@esbuild/netbsd-arm64": "0.27.7", + "@esbuild/netbsd-x64": "0.27.7", + "@esbuild/openbsd-arm64": "0.27.7", + "@esbuild/openbsd-x64": "0.27.7", + "@esbuild/openharmony-arm64": "0.27.7", + "@esbuild/sunos-x64": "0.27.7", + "@esbuild/win32-arm64": "0.27.7", + "@esbuild/win32-ia32": "0.27.7", + "@esbuild/win32-x64": "0.27.7" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/make-error": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", + "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", + "dev": true, + "license": "ISC" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.14", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", + "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rollup": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.3.tgz", + "integrity": "sha512-pAQK9HalE84QSm4Po3EmWIZPd3FnjkShVkiMlz1iligWYkWQ7wHYd1PF/T7QZ5TVSD6uSTon5gBVMSM4JfBV+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.3", + "@rollup/rollup-android-arm64": "4.60.3", + "@rollup/rollup-darwin-arm64": "4.60.3", + "@rollup/rollup-darwin-x64": "4.60.3", + "@rollup/rollup-freebsd-arm64": "4.60.3", + "@rollup/rollup-freebsd-x64": "4.60.3", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.3", + "@rollup/rollup-linux-arm-musleabihf": "4.60.3", + "@rollup/rollup-linux-arm64-gnu": "4.60.3", + "@rollup/rollup-linux-arm64-musl": "4.60.3", + "@rollup/rollup-linux-loong64-gnu": "4.60.3", + "@rollup/rollup-linux-loong64-musl": "4.60.3", + "@rollup/rollup-linux-ppc64-gnu": "4.60.3", + "@rollup/rollup-linux-ppc64-musl": "4.60.3", + "@rollup/rollup-linux-riscv64-gnu": "4.60.3", + "@rollup/rollup-linux-riscv64-musl": "4.60.3", + "@rollup/rollup-linux-s390x-gnu": "4.60.3", + "@rollup/rollup-linux-x64-gnu": "4.60.3", + "@rollup/rollup-linux-x64-musl": "4.60.3", + "@rollup/rollup-openbsd-x64": "4.60.3", + "@rollup/rollup-openharmony-arm64": "4.60.3", + "@rollup/rollup-win32-arm64-msvc": "4.60.3", + "@rollup/rollup-win32-ia32-msvc": "4.60.3", + "@rollup/rollup-win32-x64-gnu": "4.60.3", + "@rollup/rollup-win32-x64-msvc": "4.60.3", + "fsevents": "~2.3.2" + } + }, + "node_modules/rollup/node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/strip-literal": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", + "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/ts-node": { + "version": "10.9.2", + "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", + "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspotcode/source-map-support": "^0.8.0", + "@tsconfig/node10": "^1.0.7", + "@tsconfig/node12": "^1.0.7", + "@tsconfig/node14": "^1.0.0", + "@tsconfig/node16": "^1.0.2", + "acorn": "^8.4.1", + "acorn-walk": "^8.1.1", + "arg": "^4.1.0", + "create-require": "^1.1.0", + "diff": "^4.0.1", + "make-error": "^1.1.1", + "v8-compile-cache-lib": "^3.0.1", + "yn": "3.1.1" + }, + "bin": { + "ts-node": "dist/bin.js", + "ts-node-cwd": "dist/bin-cwd.js", + "ts-node-esm": "dist/bin-esm.js", + "ts-node-script": "dist/bin-script.js", + "ts-node-transpile-only": "dist/bin-transpile.js", + "ts-script": "dist/bin-script-deprecated.js" + }, + "peerDependencies": { + "@swc/core": ">=1.2.50", + "@swc/wasm": ">=1.2.50", + "@types/node": "*", + "typescript": ">=2.7" + }, + "peerDependenciesMeta": { + "@swc/core": { + "optional": true + }, + "@swc/wasm": { + "optional": true + } + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/v8-compile-cache-lib": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", + "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "7.3.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.3.tgz", + "integrity": "sha512-/4XH147Ui7OGTjg3HbdWe5arnZQSbfuRzdr9Ec7TQi5I7R+ir0Rlc9GIvD4v0XZurELqA035KVXJXpR61xhiTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz", + "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.4", + "@vitest/mocker": "3.2.4", + "@vitest/pretty-format": "^3.2.4", + "@vitest/runner": "3.2.4", + "@vitest/snapshot": "3.2.4", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.4", + "@vitest/ui": "3.2.4", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yn": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", + "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + } + } +} diff --git a/apigen/infra/package.json b/apigen/infra/package.json new file mode 100644 index 00000000000..4a94bba451f --- /dev/null +++ b/apigen/infra/package.json @@ -0,0 +1,29 @@ +{ + "name": "phpstan-apiref-infra", + "version": "1.0.0", + "license": "MIT", + "private": true, + "bin": { + "infra": "bin/infra.js" + }, + "scripts": { + "build": "tsc", + "watch": "tsc -w", + "check": "tsc -p tsconfig.json --noEmit", + "test": "vitest run", + "test:watch": "vitest", + "cdk": "cdk", + "synth": "cdk synth --all", + "diff": "cdk diff --all", + "deploy": "cdk deploy --all" + }, + "devDependencies": { + "@types/node": "^22.10.0", + "aws-cdk": "^2.1010.0", + "aws-cdk-lib": "^2.180.0", + "constructs": "^10.4.2", + "ts-node": "^10.9.2", + "typescript": "^5.7.2", + "vitest": "^3.0.0" + } +} diff --git a/apigen/infra/test/apiref-stack.test.ts b/apigen/infra/test/apiref-stack.test.ts new file mode 100644 index 00000000000..82b2cb9a59e --- /dev/null +++ b/apigen/infra/test/apiref-stack.test.ts @@ -0,0 +1,163 @@ +import { App } from 'aws-cdk-lib'; +import { Match, Template } from 'aws-cdk-lib/assertions'; +import { describe, expect, it } from 'vitest'; +import { ApirefStack } from '../lib/apiref-stack'; + +const baseProps = { + env: { account: '928192134594', region: 'us-east-1' }, + githubOrg: 'phpstan', + githubRepo: 'phpstan-src', + deployBranch: '2.2.x', + oidcProviderArn: 'arn:aws:iam::928192134594:oidc-provider/token.actions.githubusercontent.com', + hostedZoneId: 'Z3OJGVJEUUWZDN', + hostedZoneName: 'phpstan.org', + apirefDomain: 'apiref.phpstan.org', +}; + +function synth(productionAlias: boolean): Template { + const app = new App(); + const stack = new ApirefStack(app, 'TestApiref', { ...baseProps, productionAlias }); + return Template.fromStack(stack); +} + +describe('ApirefStack', () => { + describe('common (regardless of productionAlias)', () => { + const template = synth(false); + + it('creates a private S3 bucket with versioning and SSL enforcement', () => { + template.hasResourceProperties('AWS::S3::Bucket', { + BucketName: 'phpstan-apiref-web', + PublicAccessBlockConfiguration: { + BlockPublicAcls: true, + BlockPublicPolicy: true, + IgnorePublicAcls: true, + RestrictPublicBuckets: true, + }, + VersioningConfiguration: { Status: 'Enabled' }, + }); + }); + + it('denies insecure transport in the bucket policy', () => { + template.hasResourceProperties('AWS::S3::BucketPolicy', { + PolicyDocument: Match.objectLike({ + Statement: Match.arrayWith([ + Match.objectLike({ + Effect: 'Deny', + Condition: { Bool: { 'aws:SecureTransport': 'false' } }, + }), + ]), + }), + }); + }); + + it('creates an Origin Access Control', () => { + template.resourceCountIs('AWS::CloudFront::OriginAccessControl', 1); + }); + + it('creates the CloudFront Function on JS 2.0', () => { + template.hasResourceProperties('AWS::CloudFront::Function', { + Name: 'apiref-version-redirects', + FunctionConfig: Match.objectLike({ Runtime: 'cloudfront-js-2.0' }), + }); + }); + + it('creates a Response Headers Policy with HSTS, XCTO, XFO, Referrer-Policy and no X-XSS-Protection', () => { + template.hasResourceProperties('AWS::CloudFront::ResponseHeadersPolicy', { + ResponseHeadersPolicyConfig: Match.objectLike({ + Name: 'apiref-security-headers', + SecurityHeadersConfig: Match.objectLike({ + StrictTransportSecurity: Match.objectLike({ + AccessControlMaxAgeSec: 365 * 24 * 60 * 60, + IncludeSubdomains: true, + Preload: true, + Override: true, + }), + ContentTypeOptions: { Override: true }, + FrameOptions: { FrameOption: 'SAMEORIGIN', Override: true }, + ReferrerPolicy: { ReferrerPolicy: 'strict-origin-when-cross-origin', Override: true }, + }), + }), + }); + template.hasResourceProperties('AWS::CloudFront::ResponseHeadersPolicy', { + ResponseHeadersPolicyConfig: { + SecurityHeadersConfig: Match.not(Match.objectLike({ XSSProtection: Match.anyValue() })), + }, + }); + }); + + it('uses HTTP/2+3 and TLS 1.2_2021 minimum, with the function and headers policy attached', () => { + template.hasResourceProperties('AWS::CloudFront::Distribution', { + DistributionConfig: Match.objectLike({ + HttpVersion: 'http2and3', + IPV6Enabled: true, + DefaultCacheBehavior: Match.objectLike({ + ViewerProtocolPolicy: 'redirect-to-https', + Compress: true, + FunctionAssociations: Match.arrayWith([ + Match.objectLike({ EventType: 'viewer-request' }), + ]), + ResponseHeadersPolicyId: Match.anyValue(), + }), + }), + }); + }); + + it('issues a DNS-validated ACM cert for apiref.phpstan.org', () => { + template.hasResourceProperties('AWS::CertificateManager::Certificate', { + DomainName: 'apiref.phpstan.org', + ValidationMethod: 'DNS', + }); + }); + + it('creates the deploy role scoped to the phpstan-src 2.2.x branch via OIDC', () => { + template.hasResourceProperties('AWS::IAM::Role', { + RoleName: 'phpstan-apiref-deploy', + AssumeRolePolicyDocument: Match.objectLike({ + Statement: Match.arrayWith([ + Match.objectLike({ + Action: 'sts:AssumeRoleWithWebIdentity', + Condition: { + StringEquals: { 'token.actions.githubusercontent.com:aud': 'sts.amazonaws.com' }, + StringLike: { + 'token.actions.githubusercontent.com:sub': 'repo:phpstan/phpstan-src:ref:refs/heads/2.2.x', + }, + }, + }), + ]), + }), + }); + }); + + it('does not create any Route 53 records (apex/staging CNAME stays externally managed)', () => { + template.resourceCountIs('AWS::Route53::RecordSet', 0); + }); + }); + + describe('productionAlias: false (pre-cutover)', () => { + const template = synth(false); + + it('omits the alias and the ACM cert from the distribution (default CF cert is used)', () => { + const distributions = template.findResources('AWS::CloudFront::Distribution'); + const config = Object.values(distributions)[0].Properties.DistributionConfig; + // CDK synthesizes `null` for undefined optional properties; CFN treats it as absent. + expect(config.Aliases ?? null).toBeNull(); + expect(config.ViewerCertificate ?? null).toBeNull(); + }); + }); + + describe('productionAlias: true (post-cutover)', () => { + const template = synth(true); + + it('attaches apiref.phpstan.org as the alias and uses the ACM cert', () => { + template.hasResourceProperties('AWS::CloudFront::Distribution', { + DistributionConfig: Match.objectLike({ + Aliases: ['apiref.phpstan.org'], + ViewerCertificate: Match.objectLike({ + MinimumProtocolVersion: 'TLSv1.2_2021', + SslSupportMethod: 'sni-only', + }), + }), + }); + }); + }); +}); diff --git a/apigen/infra/test/apiref-version-redirects.test.ts b/apigen/infra/test/apiref-version-redirects.test.ts new file mode 100644 index 00000000000..779fb500360 --- /dev/null +++ b/apigen/infra/test/apiref-version-redirects.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from 'vitest'; +// eslint-disable-next-line @typescript-eslint/no-require-imports +const { handler, VERSION_REDIRECTS } = require('../functions/apiref-version-redirects.js'); + +interface CfEvent { + request: { + uri: string; + method?: string; + }; +} + +function event(uri: string): CfEvent { + return { request: { uri, method: 'GET' } }; +} + +describe('apiref-version-redirects handler', () => { + describe('version landing-page redirects', () => { + const cases: Array<[string, string]> = [ + ['/', '/2.2.x/namespace-PHPStan.html'], + ['/2.2.x', '/2.2.x/namespace-PHPStan.html'], + ['/2.2.x/', '/2.2.x/namespace-PHPStan.html'], + ['/2.1.x', '/2.1.x/namespace-PHPStan.html'], + ['/2.1.x/', '/2.1.x/namespace-PHPStan.html'], + ['/2.0.x', '/2.0.x/namespace-PHPStan.html'], + ['/2.0.x/', '/2.0.x/namespace-PHPStan.html'], + ['/1.12.x', '/1.12.x/namespace-PHPStan.html'], + ['/1.12.x/', '/1.12.x/namespace-PHPStan.html'], + ['/1.11.x', '/1.11.x/namespace-PHPStan.html'], + ['/1.11.x/', '/1.11.x/namespace-PHPStan.html'], + ['/1.10.x', '/1.10.x/namespace-PHPStan.html'], + ['/1.10.x/', '/1.10.x/namespace-PHPStan.html'], + ['/1.9.x', '/1.9.x/namespace-PHPStan.html'], + ['/1.9.x/', '/1.9.x/namespace-PHPStan.html'], + ]; + + for (const [uri, location] of cases) { + it(`${uri} -> 301 ${location}`, () => { + const result = handler(event(uri)); + expect(result.statusCode).toBe(301); + expect(result.statusDescription).toBe('Moved Permanently'); + expect(result.headers.location.value).toBe(location); + }); + } + + it('exposes the same lookup table to tests via module.exports', () => { + expect(VERSION_REDIRECTS['/']).toBe('/2.2.x/namespace-PHPStan.html'); + expect(Object.keys(VERSION_REDIRECTS)).toHaveLength(cases.length); + }); + + it('latest mapping points to 2.2.x (the post-migration default)', () => { + expect(VERSION_REDIRECTS['/']).toBe(VERSION_REDIRECTS['/2.2.x']); + }); + }); + + describe('pass-throughs', () => { + const passThrough = [ + '/2.2.x/namespace-PHPStan.html', + '/2.2.x/PHPStan/Analyser.html', + '/2.2.x/some/deep/path.html', + '/assets/style.css', + '/1.8.x', // 1.8.x is intentionally not in the redirect table + '/1.8.x/', + '/random', + '/random/path', + ]; + + for (const uri of passThrough) { + it(`${uri} passes through unchanged`, () => { + const result = handler(event(uri)); + expect(result.statusCode).toBeUndefined(); + expect(result.uri).toBe(uri); + }); + } + }); +}); diff --git a/apigen/infra/tsconfig.json b/apigen/infra/tsconfig.json new file mode 100644 index 00000000000..c2aae07b406 --- /dev/null +++ b/apigen/infra/tsconfig.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "commonjs", + "lib": ["es2022"], + "declaration": true, + "strict": true, + "noImplicitAny": true, + "strictNullChecks": true, + "noImplicitThis": true, + "alwaysStrict": true, + "noUnusedLocals": true, + "noUnusedParameters": false, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "inlineSourceMap": true, + "inlineSources": true, + "experimentalDecorators": true, + "strictPropertyInitialization": false, + "resolveJsonModule": true, + "esModuleInterop": true, + "skipLibCheck": true, + "typeRoots": ["./node_modules/@types"] + }, + "include": ["bin/**/*.ts", "lib/**/*.ts", "test/**/*.ts"], + "exclude": ["node_modules", "cdk.out"] +} diff --git a/apigen/infra/vitest.config.ts b/apigen/infra/vitest.config.ts new file mode 100644 index 00000000000..5ae75888702 --- /dev/null +++ b/apigen/infra/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + include: ['test/**/*.test.ts'], + }, +}); diff --git a/build/baseline-8.0.neon b/build/baseline-8.0.neon index 59bcf6e7156..e249b3ce3e6 100644 --- a/build/baseline-8.0.neon +++ b/build/baseline-8.0.neon @@ -12,24 +12,12 @@ parameters: count: 1 path: ../src/Type/ClosureTypeFactory.php - - - message: '#^Strict comparison using \=\=\= between list\ and false will always evaluate to false\.$#' - identifier: identical.alwaysFalse - count: 1 - path: ../src/Type/Php/MbFunctionsReturnTypeExtension.php - - message: '#^Strict comparison using \=\=\= between int\<0, max\> and false will always evaluate to false\.$#' identifier: identical.alwaysFalse count: 1 path: ../src/Type/Php/MbStrlenFunctionReturnTypeExtension.php - - - message: '#^Strict comparison using \=\=\= between list\ and false will always evaluate to false\.$#' - identifier: identical.alwaysFalse - count: 1 - path: ../src/Type/Php/MbStrlenFunctionReturnTypeExtension.php - - message: '#^Strict comparison using \=\=\= between list\ and false will always evaluate to false\.$#' identifier: identical.alwaysFalse diff --git a/conf/config.neon b/conf/config.neon index 3823fc7d9f4..df8f46567a1 100644 --- a/conf/config.neon +++ b/conf/config.neon @@ -86,6 +86,7 @@ parameters: reportWrongPhpDocTypeInVarTag: false reportAnyTypeWideningInVarTag: false reportNonIntStringArrayKey: false + reportUnsafeArrayStringKeyCasting: null reportPossiblyNonexistentGeneralArrayOffset: false reportPossiblyNonexistentConstantArrayOffset: false checkMissingOverrideMethodAttribute: false diff --git a/conf/parametersSchema.neon b/conf/parametersSchema.neon index 72e830010c6..dc07b020e09 100644 --- a/conf/parametersSchema.neon +++ b/conf/parametersSchema.neon @@ -95,6 +95,7 @@ parametersSchema: reportWrongPhpDocTypeInVarTag: bool() reportAnyTypeWideningInVarTag: bool() reportNonIntStringArrayKey: bool() + reportUnsafeArrayStringKeyCasting: schema(string(), pattern('detect|prevent'), nullable()) reportPossiblyNonexistentGeneralArrayOffset: bool() reportPossiblyNonexistentConstantArrayOffset: bool() checkMissingOverrideMethodAttribute: bool() diff --git a/e2e/in-trait/phpstan.neon b/e2e/in-trait/phpstan.neon new file mode 100644 index 00000000000..c308dcf5421 --- /dev/null +++ b/e2e/in-trait/phpstan.neon @@ -0,0 +1,4 @@ +parameters: + level: 8 + paths: + - src diff --git a/e2e/in-trait/src/Bar.php b/e2e/in-trait/src/Bar.php new file mode 100644 index 00000000000..a5a07b82067 --- /dev/null +++ b/e2e/in-trait/src/Bar.php @@ -0,0 +1,20 @@ +getSth() === null) { + + } + + if ($this->getSth2() === null) { + + } + } + +} diff --git a/issue-bot/playground.neon b/issue-bot/playground.neon index a252e3bac87..9fc2864ff3e 100644 --- a/issue-bot/playground.neon +++ b/issue-bot/playground.neon @@ -1,5 +1,7 @@ rules: + - PHPStan\Rules\Playground\ArrayDimCastRule - PHPStan\Rules\Playground\FunctionNeverRule + - PHPStan\Rules\Playground\LiteralArrayKeyCastRule - PHPStan\Rules\Playground\MethodNeverRule - PHPStan\Rules\Playground\NotAnalysedTraitRule - PHPStan\Rules\Playground\NoPhpCodeRule diff --git a/issue-bot/src/Console/RunCommand.php b/issue-bot/src/Console/RunCommand.php index 15b47ac80e7..b2291373095 100644 --- a/issue-bot/src/Console/RunCommand.php +++ b/issue-bot/src/Console/RunCommand.php @@ -194,6 +194,7 @@ private function analyseHash(LoopInterface $loop, OutputInterface $output, int $ 'checkTooWideReturnTypesInProtectedAndPublicMethods' => $options['checkTooWideTypesInProtectedAndPublicMethods'] ?? false, 'checkTooWideParameterOutInProtectedAndPublicMethods' => $options['checkTooWideTypesInProtectedAndPublicMethods'] ?? false, 'checkTooWideThrowTypesInProtectedAndPublicMethods' => $options['checkTooWideTypesInProtectedAndPublicMethods'] ?? false, + 'reportUnsafeArrayStringKeyCasting' => $options['reportUnsafeArrayStringKeyCasting'] ?? null, ]; $parameters['exceptions'] = [ 'implicitThrows' => $options['implicitThrows'] ?? true, diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index d7174f5db4b..a3f9ef6c389 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -48,12 +48,6 @@ parameters: count: 1 path: src/Analyser/ExprHandler/PreIncHandler.php - - - rawMessage: Cannot assign offset 'realCount' to array|string. - identifier: offsetAssign.dimType - count: 1 - path: src/Analyser/Ignore/IgnoredErrorHelperResult.php - - rawMessage: Casting to string something that's already string. identifier: cast.useless @@ -783,6 +777,12 @@ parameters: count: 1 path: src/Type/Accessory/AccessoryArrayListType.php + - + rawMessage: Doing instanceof PHPStan\Type\IntersectionType is error-prone and deprecated. + identifier: phpstanApi.instanceofType + count: 1 + path: src/Type/Accessory/AccessoryDecimalIntegerStringType.php + - rawMessage: Doing instanceof PHPStan\Type\IntersectionType is error-prone and deprecated. identifier: phpstanApi.instanceofType @@ -960,7 +960,7 @@ parameters: - rawMessage: 'Doing instanceof PHPStan\Type\Constant\ConstantStringType is error-prone and deprecated. Use Type::getConstantStrings() instead.' identifier: phpstanApi.instanceofType - count: 3 + count: 5 path: src/Type/Constant/ConstantArrayType.php - @@ -1716,7 +1716,7 @@ parameters: - rawMessage: 'Doing instanceof PHPStan\Type\Constant\ConstantArrayType is error-prone and deprecated. Use Type::getConstantArrays() instead.' identifier: phpstanApi.instanceofType - count: 19 + count: 21 path: src/Type/TypeCombinator.php - @@ -1734,7 +1734,7 @@ parameters: - rawMessage: Doing instanceof PHPStan\Type\IntersectionType is error-prone and deprecated. identifier: phpstanApi.instanceofType - count: 5 + count: 6 path: src/Type/TypeCombinator.php - diff --git a/resources/constantToFunctionParameterMap.php b/resources/constantToFunctionParameterMap.php new file mode 100644 index 00000000000..6766e6be3f7 --- /dev/null +++ b/resources/constantToFunctionParameterMap.php @@ -0,0 +1,2546 @@ + 'single' | 'bitmask' + * 'constants' => list of constant names valid for this parameter + * 'exclusiveGroups' => (optional, bitmask only) groups of constants that are mutually exclusive + */ +return [ + + // ———————————————————————————————————————————— + // JSON + // ———————————————————————————————————————————— + + 'json_encode' => [ + 'flags' => [ + 'type' => 'bitmask', + 'constants' => [ + 'JSON_HEX_QUOT', + 'JSON_HEX_TAG', + 'JSON_HEX_AMP', + 'JSON_HEX_APOS', + 'JSON_NUMERIC_CHECK', + 'JSON_PRETTY_PRINT', + 'JSON_UNESCAPED_SLASHES', + 'JSON_FORCE_OBJECT', + 'JSON_PRESERVE_ZERO_FRACTION', + 'JSON_UNESCAPED_UNICODE', + 'JSON_PARTIAL_OUTPUT_ON_ERROR', + 'JSON_UNESCAPED_LINE_TERMINATORS', + 'JSON_THROW_ON_ERROR', + 'JSON_INVALID_UTF8_IGNORE', + 'JSON_INVALID_UTF8_SUBSTITUTE', + ], + ], + ], + + 'json_decode' => [ + 'flags' => [ + 'type' => 'bitmask', + 'constants' => [ + 'JSON_BIGINT_AS_STRING', + 'JSON_OBJECT_AS_ARRAY', + 'JSON_THROW_ON_ERROR', + 'JSON_INVALID_UTF8_IGNORE', + 'JSON_INVALID_UTF8_SUBSTITUTE', + ], + ], + ], + + 'json_validate' => [ + 'flags' => [ + 'type' => 'bitmask', + 'constants' => [ + 'JSON_INVALID_UTF8_IGNORE', + ], + ], + ], + + // ———————————————————————————————————————————— + // PCRE + // ———————————————————————————————————————————— + + 'preg_match' => [ + 'flags' => [ + 'type' => 'bitmask', + 'constants' => [ + 'PREG_OFFSET_CAPTURE', + 'PREG_UNMATCHED_AS_NULL', + ], + ], + ], + + 'preg_match_all' => [ + 'flags' => [ + 'type' => 'bitmask', + 'constants' => [ + 'PREG_PATTERN_ORDER', + 'PREG_SET_ORDER', + 'PREG_OFFSET_CAPTURE', + 'PREG_UNMATCHED_AS_NULL', + ], + 'exclusiveGroups' => [ + ['PREG_PATTERN_ORDER', 'PREG_SET_ORDER'], + ], + ], + ], + + 'preg_split' => [ + 'flags' => [ + 'type' => 'bitmask', + 'constants' => [ + 'PREG_SPLIT_NO_EMPTY', + 'PREG_SPLIT_DELIM_CAPTURE', + 'PREG_SPLIT_OFFSET_CAPTURE', + ], + ], + ], + + 'preg_grep' => [ + 'flags' => [ + 'type' => 'single', + 'constants' => [ + 'PREG_GREP_INVERT', + ], + ], + ], + + // ———————————————————————————————————————————— + // Sorting + // ———————————————————————————————————————————— + + 'sort' => [ + 'flags' => [ + 'type' => 'bitmask', + 'constants' => [ + 'SORT_REGULAR', + 'SORT_NUMERIC', + 'SORT_STRING', + 'SORT_LOCALE_STRING', + 'SORT_NATURAL', + 'SORT_FLAG_CASE', + ], + 'exclusiveGroups' => [ + ['SORT_REGULAR', 'SORT_NUMERIC', 'SORT_STRING', 'SORT_LOCALE_STRING', 'SORT_NATURAL'], + ], + ], + ], + + 'rsort' => [ + 'flags' => [ + 'type' => 'bitmask', + 'constants' => [ + 'SORT_REGULAR', + 'SORT_NUMERIC', + 'SORT_STRING', + 'SORT_LOCALE_STRING', + 'SORT_NATURAL', + 'SORT_FLAG_CASE', + ], + 'exclusiveGroups' => [ + ['SORT_REGULAR', 'SORT_NUMERIC', 'SORT_STRING', 'SORT_LOCALE_STRING', 'SORT_NATURAL'], + ], + ], + ], + + 'asort' => [ + 'flags' => [ + 'type' => 'bitmask', + 'constants' => [ + 'SORT_REGULAR', + 'SORT_NUMERIC', + 'SORT_STRING', + 'SORT_LOCALE_STRING', + 'SORT_NATURAL', + 'SORT_FLAG_CASE', + ], + 'exclusiveGroups' => [ + ['SORT_REGULAR', 'SORT_NUMERIC', 'SORT_STRING', 'SORT_LOCALE_STRING', 'SORT_NATURAL'], + ], + ], + ], + + 'arsort' => [ + 'flags' => [ + 'type' => 'bitmask', + 'constants' => [ + 'SORT_REGULAR', + 'SORT_NUMERIC', + 'SORT_STRING', + 'SORT_LOCALE_STRING', + 'SORT_NATURAL', + 'SORT_FLAG_CASE', + ], + 'exclusiveGroups' => [ + ['SORT_REGULAR', 'SORT_NUMERIC', 'SORT_STRING', 'SORT_LOCALE_STRING', 'SORT_NATURAL'], + ], + ], + ], + + 'ksort' => [ + 'flags' => [ + 'type' => 'bitmask', + 'constants' => [ + 'SORT_REGULAR', + 'SORT_NUMERIC', + 'SORT_STRING', + 'SORT_LOCALE_STRING', + 'SORT_NATURAL', + 'SORT_FLAG_CASE', + ], + 'exclusiveGroups' => [ + ['SORT_REGULAR', 'SORT_NUMERIC', 'SORT_STRING', 'SORT_LOCALE_STRING', 'SORT_NATURAL'], + ], + ], + ], + + 'krsort' => [ + 'flags' => [ + 'type' => 'bitmask', + 'constants' => [ + 'SORT_REGULAR', + 'SORT_NUMERIC', + 'SORT_STRING', + 'SORT_LOCALE_STRING', + 'SORT_NATURAL', + 'SORT_FLAG_CASE', + ], + 'exclusiveGroups' => [ + ['SORT_REGULAR', 'SORT_NUMERIC', 'SORT_STRING', 'SORT_LOCALE_STRING', 'SORT_NATURAL'], + ], + ], + ], + + 'array_unique' => [ + 'flags' => [ + 'type' => 'single', + 'constants' => [ + 'SORT_REGULAR', + 'SORT_NUMERIC', + 'SORT_STRING', + 'SORT_LOCALE_STRING', + ], + ], + ], + + // ———————————————————————————————————————————— + // Array functions + // ———————————————————————————————————————————— + + 'array_change_key_case' => [ + 'case' => [ + 'type' => 'single', + 'constants' => [ + 'CASE_LOWER', + 'CASE_UPPER', + ], + ], + ], + + 'array_filter' => [ + 'mode' => [ + 'type' => 'single', + 'constants' => [ + 'ARRAY_FILTER_USE_KEY', + 'ARRAY_FILTER_USE_BOTH', + ], + ], + ], + + 'count' => [ + 'mode' => [ + 'type' => 'single', + 'constants' => [ + 'COUNT_NORMAL', + 'COUNT_RECURSIVE', + ], + ], + ], + + 'extract' => [ + 'flags' => [ + 'type' => 'bitmask', + 'constants' => [ + 'EXTR_OVERWRITE', + 'EXTR_SKIP', + 'EXTR_PREFIX_SAME', + 'EXTR_PREFIX_ALL', + 'EXTR_PREFIX_INVALID', + 'EXTR_IF_EXISTS', + 'EXTR_PREFIX_IF_EXISTS', + 'EXTR_REFS', + ], + 'exclusiveGroups' => [ + ['EXTR_OVERWRITE', 'EXTR_SKIP', 'EXTR_PREFIX_SAME', 'EXTR_PREFIX_ALL', 'EXTR_PREFIX_INVALID', 'EXTR_IF_EXISTS', 'EXTR_PREFIX_IF_EXISTS'], + ], + ], + ], + + // ———————————————————————————————————————————— + // HTML entities + // ———————————————————————————————————————————— + + 'htmlspecialchars' => [ + 'flags' => [ + 'type' => 'bitmask', + 'constants' => [ + 'ENT_COMPAT', + 'ENT_QUOTES', + 'ENT_NOQUOTES', + 'ENT_IGNORE', + 'ENT_SUBSTITUTE', + 'ENT_DISALLOWED', + 'ENT_HTML401', + 'ENT_XML1', + 'ENT_XHTML', + 'ENT_HTML5', + ], + 'exclusiveGroups' => [ + ['ENT_COMPAT', 'ENT_QUOTES', 'ENT_NOQUOTES'], + ['ENT_HTML401', 'ENT_XML1', 'ENT_XHTML', 'ENT_HTML5'], + ], + ], + ], + + 'htmlentities' => [ + 'flags' => [ + 'type' => 'bitmask', + 'constants' => [ + 'ENT_COMPAT', + 'ENT_QUOTES', + 'ENT_NOQUOTES', + 'ENT_IGNORE', + 'ENT_SUBSTITUTE', + 'ENT_DISALLOWED', + 'ENT_HTML401', + 'ENT_XML1', + 'ENT_XHTML', + 'ENT_HTML5', + ], + 'exclusiveGroups' => [ + ['ENT_COMPAT', 'ENT_QUOTES', 'ENT_NOQUOTES'], + ['ENT_HTML401', 'ENT_XML1', 'ENT_XHTML', 'ENT_HTML5'], + ], + ], + ], + + 'html_entity_decode' => [ + 'flags' => [ + 'type' => 'bitmask', + 'constants' => [ + 'ENT_COMPAT', + 'ENT_QUOTES', + 'ENT_NOQUOTES', + 'ENT_IGNORE', + 'ENT_SUBSTITUTE', + 'ENT_DISALLOWED', + 'ENT_HTML401', + 'ENT_XML1', + 'ENT_XHTML', + 'ENT_HTML5', + ], + 'exclusiveGroups' => [ + ['ENT_COMPAT', 'ENT_QUOTES', 'ENT_NOQUOTES'], + ['ENT_HTML401', 'ENT_XML1', 'ENT_XHTML', 'ENT_HTML5'], + ], + ], + ], + + 'htmlspecialchars_decode' => [ + 'flags' => [ + 'type' => 'bitmask', + 'constants' => [ + 'ENT_COMPAT', + 'ENT_QUOTES', + 'ENT_NOQUOTES', + 'ENT_IGNORE', + 'ENT_SUBSTITUTE', + 'ENT_DISALLOWED', + 'ENT_HTML401', + 'ENT_XML1', + 'ENT_XHTML', + 'ENT_HTML5', + ], + 'exclusiveGroups' => [ + ['ENT_COMPAT', 'ENT_QUOTES', 'ENT_NOQUOTES'], + ['ENT_HTML401', 'ENT_XML1', 'ENT_XHTML', 'ENT_HTML5'], + ], + ], + ], + + // ———————————————————————————————————————————— + // URL / Path + // ———————————————————————————————————————————— + + 'parse_url' => [ + 'component' => [ + 'type' => 'single', + 'constants' => [ + 'PHP_URL_SCHEME', + 'PHP_URL_HOST', + 'PHP_URL_PORT', + 'PHP_URL_USER', + 'PHP_URL_PASS', + 'PHP_URL_PATH', + 'PHP_URL_QUERY', + 'PHP_URL_FRAGMENT', + ], + ], + ], + + 'pathinfo' => [ + 'flags' => [ + 'type' => 'bitmask', + 'constants' => [ + 'PATHINFO_DIRNAME', + 'PATHINFO_BASENAME', + 'PATHINFO_EXTENSION', + 'PATHINFO_FILENAME', + 'PATHINFO_ALL', + ], + ], + ], + + 'http_build_query' => [ + 'encoding_type' => [ + 'type' => 'single', + 'constants' => [ + 'PHP_QUERY_RFC1738', + 'PHP_QUERY_RFC3986', + ], + ], + ], + + // ———————————————————————————————————————————— + // File operations + // ———————————————————————————————————————————— + + 'file_put_contents' => [ + 'flags' => [ + 'type' => 'bitmask', + 'constants' => [ + 'FILE_USE_INCLUDE_PATH', + 'FILE_APPEND', + 'LOCK_EX', + ], + ], + ], + + 'file' => [ + 'flags' => [ + 'type' => 'bitmask', + 'constants' => [ + 'FILE_USE_INCLUDE_PATH', + 'FILE_IGNORE_NEW_LINES', + 'FILE_SKIP_EMPTY_LINES', + 'FILE_NO_DEFAULT_CONTEXT', + ], + ], + ], + + 'flock' => [ + 'operation' => [ + 'type' => 'bitmask', + 'constants' => [ + 'LOCK_SH', + 'LOCK_EX', + 'LOCK_UN', + 'LOCK_NB', + ], + 'exclusiveGroups' => [ + ['LOCK_SH', 'LOCK_EX', 'LOCK_UN'], + ], + ], + ], + + 'glob' => [ + 'flags' => [ + 'type' => 'bitmask', + 'constants' => [ + 'GLOB_MARK', + 'GLOB_NOSORT', + 'GLOB_NOCHECK', + 'GLOB_NOESCAPE', + 'GLOB_BRACE', + 'GLOB_ONLYDIR', + 'GLOB_ERR', + ], + ], + ], + + 'fnmatch' => [ + 'flags' => [ + 'type' => 'bitmask', + 'constants' => [ + 'FNM_NOESCAPE', + 'FNM_PATHNAME', + 'FNM_PERIOD', + 'FNM_CASEFOLD', + ], + ], + ], + + 'scandir' => [ + 'sorting_order' => [ + 'type' => 'single', + 'constants' => [ + 'SCANDIR_SORT_ASCENDING', + 'SCANDIR_SORT_DESCENDING', + 'SCANDIR_SORT_NONE', + ], + ], + ], + + // ———————————————————————————————————————————— + // Math + // ———————————————————————————————————————————— + + 'round' => [ + 'mode' => [ + 'type' => 'single', + 'constants' => [ + 'PHP_ROUND_HALF_UP', + 'PHP_ROUND_HALF_DOWN', + 'PHP_ROUND_HALF_EVEN', + 'PHP_ROUND_HALF_ODD', + ], + ], + ], + + // ———————————————————————————————————————————— + // Random + // ———————————————————————————————————————————— + + 'srand' => [ + 'mode' => [ + 'type' => 'single', + 'constants' => [ + 'MT_RAND_MT19937', + 'MT_RAND_PHP', + ], + ], + ], + + 'mt_srand' => [ + 'mode' => [ + 'type' => 'single', + 'constants' => [ + 'MT_RAND_MT19937', + 'MT_RAND_PHP', + ], + ], + ], + + // ———————————————————————————————————————————— + // Filter + // ———————————————————————————————————————————— + + 'filter_var' => [ + 'filter' => [ + 'type' => 'single', + 'constants' => [ + 'FILTER_VALIDATE_INT', + 'FILTER_VALIDATE_BOOLEAN', + 'FILTER_VALIDATE_FLOAT', + 'FILTER_VALIDATE_REGEXP', + 'FILTER_VALIDATE_DOMAIN', + 'FILTER_VALIDATE_URL', + 'FILTER_VALIDATE_EMAIL', + 'FILTER_VALIDATE_IP', + 'FILTER_VALIDATE_MAC', + 'FILTER_SANITIZE_STRING', + 'FILTER_SANITIZE_STRIPPED', + 'FILTER_SANITIZE_ENCODED', + 'FILTER_SANITIZE_SPECIAL_CHARS', + 'FILTER_SANITIZE_FULL_SPECIAL_CHARS', + 'FILTER_SANITIZE_EMAIL', + 'FILTER_SANITIZE_URL', + 'FILTER_SANITIZE_NUMBER_INT', + 'FILTER_SANITIZE_NUMBER_FLOAT', + 'FILTER_SANITIZE_ADD_SLASHES', + 'FILTER_UNSAFE_RAW', + 'FILTER_DEFAULT', + 'FILTER_CALLBACK', + ], + ], + 'options' => [ + 'type' => 'bitmask', + 'constants' => [ + 'FILTER_REQUIRE_SCALAR', + 'FILTER_REQUIRE_ARRAY', + 'FILTER_FORCE_ARRAY', + 'FILTER_NULL_ON_FAILURE', + 'FILTER_FLAG_NONE', + 'FILTER_FLAG_ALLOW_OCTAL', + 'FILTER_FLAG_ALLOW_HEX', + 'FILTER_FLAG_STRIP_LOW', + 'FILTER_FLAG_STRIP_HIGH', + 'FILTER_FLAG_STRIP_BACKTICK', + 'FILTER_FLAG_ENCODE_LOW', + 'FILTER_FLAG_ENCODE_HIGH', + 'FILTER_FLAG_ENCODE_AMP', + 'FILTER_FLAG_NO_ENCODE_QUOTES', + 'FILTER_FLAG_EMPTY_STRING_NULL', + 'FILTER_FLAG_ALLOW_FRACTION', + 'FILTER_FLAG_ALLOW_THOUSAND', + 'FILTER_FLAG_ALLOW_SCIENTIFIC', + 'FILTER_FLAG_PATH_REQUIRED', + 'FILTER_FLAG_QUERY_REQUIRED', + 'FILTER_FLAG_IPV4', + 'FILTER_FLAG_IPV6', + 'FILTER_FLAG_NO_RES_RANGE', + 'FILTER_FLAG_NO_PRIV_RANGE', + 'FILTER_FLAG_GLOBAL_RANGE', + 'FILTER_FLAG_HOSTNAME', + 'FILTER_FLAG_EMAIL_UNICODE', + ], + ], + ], + + 'filter_input' => [ + 'type' => [ + 'type' => 'single', + 'constants' => [ + 'INPUT_POST', + 'INPUT_GET', + 'INPUT_COOKIE', + 'INPUT_ENV', + 'INPUT_SERVER', + ], + ], + 'filter' => [ + 'type' => 'single', + 'constants' => [ + 'FILTER_VALIDATE_INT', + 'FILTER_VALIDATE_BOOLEAN', + 'FILTER_VALIDATE_FLOAT', + 'FILTER_VALIDATE_REGEXP', + 'FILTER_VALIDATE_DOMAIN', + 'FILTER_VALIDATE_URL', + 'FILTER_VALIDATE_EMAIL', + 'FILTER_VALIDATE_IP', + 'FILTER_VALIDATE_MAC', + 'FILTER_SANITIZE_STRING', + 'FILTER_SANITIZE_STRIPPED', + 'FILTER_SANITIZE_ENCODED', + 'FILTER_SANITIZE_SPECIAL_CHARS', + 'FILTER_SANITIZE_FULL_SPECIAL_CHARS', + 'FILTER_SANITIZE_EMAIL', + 'FILTER_SANITIZE_URL', + 'FILTER_SANITIZE_NUMBER_INT', + 'FILTER_SANITIZE_NUMBER_FLOAT', + 'FILTER_SANITIZE_ADD_SLASHES', + 'FILTER_UNSAFE_RAW', + 'FILTER_DEFAULT', + 'FILTER_CALLBACK', + ], + ], + 'options' => [ + 'type' => 'bitmask', + 'constants' => [ + 'FILTER_REQUIRE_SCALAR', + 'FILTER_REQUIRE_ARRAY', + 'FILTER_FORCE_ARRAY', + 'FILTER_NULL_ON_FAILURE', + 'FILTER_FLAG_NONE', + 'FILTER_FLAG_ALLOW_OCTAL', + 'FILTER_FLAG_ALLOW_HEX', + 'FILTER_FLAG_STRIP_LOW', + 'FILTER_FLAG_STRIP_HIGH', + 'FILTER_FLAG_STRIP_BACKTICK', + 'FILTER_FLAG_ENCODE_LOW', + 'FILTER_FLAG_ENCODE_HIGH', + 'FILTER_FLAG_ENCODE_AMP', + 'FILTER_FLAG_NO_ENCODE_QUOTES', + 'FILTER_FLAG_EMPTY_STRING_NULL', + 'FILTER_FLAG_ALLOW_FRACTION', + 'FILTER_FLAG_ALLOW_THOUSAND', + 'FILTER_FLAG_ALLOW_SCIENTIFIC', + 'FILTER_FLAG_PATH_REQUIRED', + 'FILTER_FLAG_QUERY_REQUIRED', + 'FILTER_FLAG_IPV4', + 'FILTER_FLAG_IPV6', + 'FILTER_FLAG_NO_RES_RANGE', + 'FILTER_FLAG_NO_PRIV_RANGE', + 'FILTER_FLAG_GLOBAL_RANGE', + 'FILTER_FLAG_HOSTNAME', + 'FILTER_FLAG_EMAIL_UNICODE', + ], + ], + ], + + 'filter_input_array' => [ + 'type' => [ + 'type' => 'single', + 'constants' => [ + 'INPUT_POST', + 'INPUT_GET', + 'INPUT_COOKIE', + 'INPUT_ENV', + 'INPUT_SERVER', + ], + ], + ], + + // ———————————————————————————————————————————— + // Password hashing + // ———————————————————————————————————————————— + + 'password_hash' => [ + 'algo' => [ + 'type' => 'single', + 'constants' => [ + 'PASSWORD_DEFAULT', + 'PASSWORD_BCRYPT', + 'PASSWORD_ARGON2I', + 'PASSWORD_ARGON2ID', + ], + ], + ], + + 'password_needs_rehash' => [ + 'algo' => [ + 'type' => 'single', + 'constants' => [ + 'PASSWORD_DEFAULT', + 'PASSWORD_BCRYPT', + 'PASSWORD_ARGON2I', + 'PASSWORD_ARGON2ID', + ], + ], + ], + + // ———————————————————————————————————————————— + // Error handling + // ———————————————————————————————————————————— + + 'error_reporting' => [ + 'error_level' => [ + 'type' => 'bitmask', + 'constants' => [ + 'E_ALL', + 'E_ERROR', + 'E_WARNING', + 'E_PARSE', + 'E_NOTICE', + 'E_STRICT', + 'E_RECOVERABLE_ERROR', + 'E_DEPRECATED', + 'E_CORE_ERROR', + 'E_CORE_WARNING', + 'E_COMPILE_ERROR', + 'E_COMPILE_WARNING', + 'E_USER_ERROR', + 'E_USER_WARNING', + 'E_USER_NOTICE', + 'E_USER_DEPRECATED', + ], + ], + ], + + 'trigger_error' => [ + 'error_level' => [ + 'type' => 'single', + 'constants' => [ + 'E_USER_NOTICE', + 'E_USER_WARNING', + 'E_USER_ERROR', + 'E_USER_DEPRECATED', + ], + ], + ], + + 'user_error' => [ + 'error_level' => [ + 'type' => 'single', + 'constants' => [ + 'E_USER_NOTICE', + 'E_USER_WARNING', + 'E_USER_ERROR', + 'E_USER_DEPRECATED', + ], + ], + ], + + // ———————————————————————————————————————————— + // Multibyte string + // ———————————————————————————————————————————— + + 'mb_convert_case' => [ + 'mode' => [ + 'type' => 'single', + 'constants' => [ + 'MB_CASE_UPPER', + 'MB_CASE_LOWER', + 'MB_CASE_TITLE', + 'MB_CASE_FOLD', + 'MB_CASE_UPPER_SIMPLE', + 'MB_CASE_LOWER_SIMPLE', + 'MB_CASE_TITLE_SIMPLE', + 'MB_CASE_FOLD_SIMPLE', + ], + ], + ], + + // ———————————————————————————————————————————— + // Fileinfo + // ———————————————————————————————————————————— + + 'finfo_file' => [ + 'flags' => [ + 'type' => 'bitmask', + 'constants' => [ + 'FILEINFO_NONE', + 'FILEINFO_SYMLINK', + 'FILEINFO_MIME', + 'FILEINFO_MIME_TYPE', + 'FILEINFO_MIME_ENCODING', + 'FILEINFO_DEVICES', + 'FILEINFO_CONTINUE', + 'FILEINFO_PRESERVE_ATIME', + 'FILEINFO_RAW', + 'FILEINFO_EXTENSION', + 'FILEINFO_APPLE', + ], + ], + ], + + // ———————————————————————————————————————————— + // Debug + // ———————————————————————————————————————————— + + 'debug_backtrace' => [ + 'options' => [ + 'type' => 'bitmask', + 'constants' => [ + 'DEBUG_BACKTRACE_PROVIDE_OBJECT', + 'DEBUG_BACKTRACE_IGNORE_ARGS', + ], + ], + ], + + 'debug_print_backtrace' => [ + 'options' => [ + 'type' => 'single', + 'constants' => [ + 'DEBUG_BACKTRACE_IGNORE_ARGS', + ], + ], + ], + + // ———————————————————————————————————————————— + // Tokenizer + // ———————————————————————————————————————————— + + 'token_get_all' => [ + 'flags' => [ + 'type' => 'single', + 'constants' => [ + 'TOKEN_PARSE', + ], + ], + ], + + // cURL constants are excluded from this map because the constant lists + // are large and grow with each PHP/libcurl release, making them impractical + // to maintain without false positives. + + + 'image_type_to_extension' => [ + 'image_type' => [ + 'type' => 'single', + 'constants' => [ + 'IMAGETYPE_GIF', + 'IMAGETYPE_JPEG', + 'IMAGETYPE_PNG', + 'IMAGETYPE_SWF', + 'IMAGETYPE_PSD', + 'IMAGETYPE_BMP', + 'IMAGETYPE_WBMP', + 'IMAGETYPE_XBM', + 'IMAGETYPE_TIFF_II', + 'IMAGETYPE_TIFF_MM', + 'IMAGETYPE_ICO', + 'IMAGETYPE_WEBP', + 'IMAGETYPE_AVIF', + 'IMAGETYPE_JPC', + 'IMAGETYPE_JP2', + 'IMAGETYPE_JPX', + 'IMAGETYPE_JB2', + 'IMAGETYPE_SWC', + 'IMAGETYPE_IFF', + ], + ], + ], + + 'image_type_to_mime_type' => [ + 'image_type' => [ + 'type' => 'single', + 'constants' => [ + 'IMAGETYPE_GIF', + 'IMAGETYPE_JPEG', + 'IMAGETYPE_PNG', + 'IMAGETYPE_SWF', + 'IMAGETYPE_PSD', + 'IMAGETYPE_BMP', + 'IMAGETYPE_WBMP', + 'IMAGETYPE_XBM', + 'IMAGETYPE_TIFF_II', + 'IMAGETYPE_TIFF_MM', + 'IMAGETYPE_ICO', + 'IMAGETYPE_WEBP', + 'IMAGETYPE_AVIF', + 'IMAGETYPE_JPC', + 'IMAGETYPE_JP2', + 'IMAGETYPE_JPX', + 'IMAGETYPE_JB2', + 'IMAGETYPE_SWC', + 'IMAGETYPE_IFF', + ], + ], + ], + + // ———————————————————————————————————————————— + // GD image functions + // ———————————————————————————————————————————— + + 'imagecropauto' => [ + 'mode' => [ + 'type' => 'single', + 'constants' => [ + 'IMG_CROP_DEFAULT', + 'IMG_CROP_TRANSPARENT', + 'IMG_CROP_BLACK', + 'IMG_CROP_WHITE', + 'IMG_CROP_SIDES', + 'IMG_CROP_THRESHOLD', + ], + ], + ], + + 'imagelayereffect' => [ + 'effect' => [ + 'type' => 'single', + 'constants' => [ + 'IMG_EFFECT_REPLACE', + 'IMG_EFFECT_ALPHABLEND', + 'IMG_EFFECT_NORMAL', + 'IMG_EFFECT_OVERLAY', + 'IMG_EFFECT_MULTIPLY', + ], + ], + ], + + 'imageflip' => [ + 'mode' => [ + 'type' => 'single', + 'constants' => [ + 'IMG_FLIP_HORIZONTAL', + 'IMG_FLIP_VERTICAL', + 'IMG_FLIP_BOTH', + ], + ], + ], + + 'imagefilter' => [ + 'filter' => [ + 'type' => 'single', + 'constants' => [ + 'IMG_FILTER_NEGATE', + 'IMG_FILTER_GRAYSCALE', + 'IMG_FILTER_BRIGHTNESS', + 'IMG_FILTER_CONTRAST', + 'IMG_FILTER_COLORIZE', + 'IMG_FILTER_EDGEDETECT', + 'IMG_FILTER_GAUSSIAN_BLUR', + 'IMG_FILTER_SELECTIVE_BLUR', + 'IMG_FILTER_EMBOSS', + 'IMG_FILTER_MEAN_REMOVAL', + 'IMG_FILTER_SMOOTH', + 'IMG_FILTER_PIXELATE', + 'IMG_FILTER_SCATTER', + ], + ], + ], + + // ———————————————————————————————————————————— + // Iconv + // ———————————————————————————————————————————— + + 'iconv_mime_decode' => [ + 'mode' => [ + 'type' => 'bitmask', + 'constants' => [ + 'ICONV_MIME_DECODE_STRICT', + 'ICONV_MIME_DECODE_CONTINUE_ON_ERROR', + ], + ], + ], + + 'iconv_mime_decode_headers' => [ + 'mode' => [ + 'type' => 'bitmask', + 'constants' => [ + 'ICONV_MIME_DECODE_STRICT', + 'ICONV_MIME_DECODE_CONTINUE_ON_ERROR', + ], + ], + ], + + // ———————————————————————————————————————————— + // Output buffering + // ———————————————————————————————————————————— + + 'ob_start' => [ + 'flags' => [ + 'type' => 'bitmask', + 'constants' => [ + 'PHP_OUTPUT_HANDLER_CLEANABLE', + 'PHP_OUTPUT_HANDLER_FLUSHABLE', + 'PHP_OUTPUT_HANDLER_REMOVABLE', + 'PHP_OUTPUT_HANDLER_STDFLAGS', + ], + ], + ], + + // ———————————————————————————————————————————— + // Streams + // ———————————————————————————————————————————— + + 'stream_socket_client' => [ + 'flags' => [ + 'type' => 'bitmask', + 'constants' => [ + 'STREAM_CLIENT_CONNECT', + 'STREAM_CLIENT_ASYNC_CONNECT', + 'STREAM_CLIENT_PERSISTENT', + ], + ], + ], + + 'stream_socket_server' => [ + 'flags' => [ + 'type' => 'bitmask', + 'constants' => [ + 'STREAM_SERVER_BIND', + 'STREAM_SERVER_LISTEN', + ], + ], + ], + + 'stream_socket_recvfrom' => [ + 'flags' => [ + 'type' => 'bitmask', + 'constants' => [ + 'STREAM_OOB', + 'STREAM_PEEK', + ], + ], + ], + + 'stream_socket_sendto' => [ + 'flags' => [ + 'type' => 'bitmask', + 'constants' => [ + 'STREAM_OOB', + ], + ], + ], + + 'stream_wrapper_register' => [ + 'flags' => [ + 'type' => 'single', + 'constants' => [ + 'STREAM_IS_URL', + ], + ], + ], + + 'stream_socket_shutdown' => [ + 'mode' => [ + 'type' => 'single', + 'constants' => [ + 'STREAM_SHUT_RD', + 'STREAM_SHUT_WR', + 'STREAM_SHUT_RDWR', + ], + ], + ], + + // ———————————————————————————————————————————— + // Syslog + // ———————————————————————————————————————————— + + 'openlog' => [ + 'flags' => [ + 'type' => 'bitmask', + 'constants' => [ + 'LOG_CONS', + 'LOG_NDELAY', + 'LOG_ODELAY', + 'LOG_NOWAIT', + 'LOG_PERROR', + 'LOG_PID', + ], + ], + 'facility' => [ + 'type' => 'single', + 'constants' => [ + 'LOG_AUTH', + 'LOG_AUTHPRIV', + 'LOG_CRON', + 'LOG_DAEMON', + 'LOG_KERN', + 'LOG_LOCAL0', + 'LOG_LOCAL1', + 'LOG_LOCAL2', + 'LOG_LOCAL3', + 'LOG_LOCAL4', + 'LOG_LOCAL5', + 'LOG_LOCAL6', + 'LOG_LOCAL7', + 'LOG_LPR', + 'LOG_MAIL', + 'LOG_NEWS', + 'LOG_SYSLOG', + 'LOG_USER', + 'LOG_UUCP', + ], + ], + ], + + 'syslog' => [ + 'priority' => [ + 'type' => 'single', + 'constants' => [ + 'LOG_EMERG', + 'LOG_ALERT', + 'LOG_CRIT', + 'LOG_ERR', + 'LOG_WARNING', + 'LOG_NOTICE', + 'LOG_INFO', + 'LOG_DEBUG', + ], + ], + ], + + // ———————————————————————————————————————————— + // Sockets + // ———————————————————————————————————————————— + + 'socket_recv' => [ + 'flags' => [ + 'type' => 'bitmask', + 'constants' => [ + 'MSG_OOB', + 'MSG_PEEK', + 'MSG_WAITALL', + 'MSG_DONTWAIT', + ], + ], + ], + + 'socket_recvfrom' => [ + 'flags' => [ + 'type' => 'bitmask', + 'constants' => [ + 'MSG_OOB', + 'MSG_PEEK', + 'MSG_WAITALL', + 'MSG_DONTWAIT', + ], + ], + ], + + 'socket_send' => [ + 'flags' => [ + 'type' => 'bitmask', + 'constants' => [ + 'MSG_OOB', + 'MSG_EOR', + 'MSG_EOF', + 'MSG_DONTROUTE', + ], + ], + ], + + 'socket_sendto' => [ + 'flags' => [ + 'type' => 'bitmask', + 'constants' => [ + 'MSG_OOB', + 'MSG_EOR', + 'MSG_EOF', + 'MSG_DONTROUTE', + ], + ], + ], + + // ———————————————————————————————————————————— + // DNS + // ———————————————————————————————————————————— + + 'dns_get_record' => [ + 'type' => [ + 'type' => 'bitmask', + 'constants' => [ + 'DNS_ANY', + 'DNS_ALL', + 'DNS_A', + 'DNS_AAAA', + 'DNS_CNAME', + 'DNS_HINFO', + 'DNS_MX', + 'DNS_NS', + 'DNS_PTR', + 'DNS_SOA', + 'DNS_SRV', + 'DNS_TXT', + 'DNS_NAPTR', + 'DNS_A6', + 'DNS_CAA', + ], + ], + ], + + // ———————————————————————————————————————————— + // FTP + // ———————————————————————————————————————————— + + 'ftp_get' => [ + 'mode' => [ + 'type' => 'single', + 'constants' => [ + 'FTP_ASCII', + 'FTP_BINARY', + ], + ], + ], + + 'ftp_fget' => [ + 'mode' => [ + 'type' => 'single', + 'constants' => [ + 'FTP_ASCII', + 'FTP_BINARY', + ], + ], + ], + + 'ftp_put' => [ + 'mode' => [ + 'type' => 'single', + 'constants' => [ + 'FTP_ASCII', + 'FTP_BINARY', + ], + ], + ], + + 'ftp_fput' => [ + 'mode' => [ + 'type' => 'single', + 'constants' => [ + 'FTP_ASCII', + 'FTP_BINARY', + ], + ], + ], + + // ———————————————————————————————————————————— + // IMAP + // ———————————————————————————————————————————— + + 'imap_close' => [ + 'flags' => [ + 'type' => 'single', + 'constants' => [ + 'CL_EXPUNGE', + ], + ], + ], + + // ———————————————————————————————————————————— + // OpenSSL + // ———————————————————————————————————————————— + + 'openssl_pkcs7_verify' => [ + 'flags' => [ + 'type' => 'bitmask', + 'constants' => [ + 'PKCS7_TEXT', + 'PKCS7_BINARY', + 'PKCS7_NOINTERN', + 'PKCS7_NOVERIFY', + 'PKCS7_NOCHAIN', + 'PKCS7_NOCERTS', + 'PKCS7_NOATTR', + 'PKCS7_DETACHED', + 'PKCS7_NOSIGS', + ], + ], + ], + + 'openssl_pkcs7_sign' => [ + 'flags' => [ + 'type' => 'bitmask', + 'constants' => [ + 'PKCS7_TEXT', + 'PKCS7_BINARY', + 'PKCS7_NOINTERN', + 'PKCS7_NOVERIFY', + 'PKCS7_NOCHAIN', + 'PKCS7_NOCERTS', + 'PKCS7_NOATTR', + 'PKCS7_DETACHED', + 'PKCS7_NOSIGS', + ], + ], + ], + + 'openssl_pkcs7_encrypt' => [ + 'flags' => [ + 'type' => 'bitmask', + 'constants' => [ + 'PKCS7_TEXT', + 'PKCS7_BINARY', + 'PKCS7_NOINTERN', + 'PKCS7_NOVERIFY', + 'PKCS7_NOCHAIN', + 'PKCS7_NOCERTS', + 'PKCS7_NOATTR', + 'PKCS7_DETACHED', + 'PKCS7_NOSIGS', + ], + ], + 'cipher_algo' => [ + 'type' => 'single', + 'constants' => [ + 'OPENSSL_CIPHER_RC2_40', + 'OPENSSL_CIPHER_RC2_128', + 'OPENSSL_CIPHER_RC2_64', + 'OPENSSL_CIPHER_DES', + 'OPENSSL_CIPHER_3DES', + 'OPENSSL_CIPHER_AES_128_CBC', + 'OPENSSL_CIPHER_AES_192_CBC', + 'OPENSSL_CIPHER_AES_256_CBC', + ], + ], + ], + + // ———————————————————————————————————————————— + // IDN + // ———————————————————————————————————————————— + + 'idn_to_ascii' => [ + 'flags' => [ + 'type' => 'bitmask', + 'constants' => [ + 'IDNA_DEFAULT', + 'IDNA_ALLOW_UNASSIGNED', + 'IDNA_CHECK_BIDI', + 'IDNA_CHECK_CONTEXTJ', + 'IDNA_NONTRANSITIONAL_TO_ASCII', + 'IDNA_NONTRANSITIONAL_TO_UNICODE', + 'IDNA_USE_STD3_RULES', + ], + ], + 'variant' => [ + 'type' => 'single', + 'constants' => [ + 'INTL_IDNA_VARIANT_UTS46', + 'INTL_IDNA_VARIANT_2003', + ], + ], + ], + + 'idn_to_utf8' => [ + 'flags' => [ + 'type' => 'bitmask', + 'constants' => [ + 'IDNA_DEFAULT', + 'IDNA_ALLOW_UNASSIGNED', + 'IDNA_CHECK_BIDI', + 'IDNA_CHECK_CONTEXTJ', + 'IDNA_NONTRANSITIONAL_TO_ASCII', + 'IDNA_NONTRANSITIONAL_TO_UNICODE', + 'IDNA_USE_STD3_RULES', + ], + ], + 'variant' => [ + 'type' => 'single', + 'constants' => [ + 'INTL_IDNA_VARIANT_UTS46', + 'INTL_IDNA_VARIANT_2003', + ], + ], + ], + + // ———————————————————————————————————————————— + // String functions + // ———————————————————————————————————————————— + + 'str_pad' => [ + 'pad_type' => [ + 'type' => 'single', + 'constants' => [ + 'STR_PAD_RIGHT', + 'STR_PAD_LEFT', + 'STR_PAD_BOTH', + ], + ], + ], + + // ———————————————————————————————————————————— + // File seeking + // ———————————————————————————————————————————— + + 'fseek' => [ + 'whence' => [ + 'type' => 'single', + 'constants' => [ + 'SEEK_SET', + 'SEEK_CUR', + 'SEEK_END', + ], + ], + ], + + // ———————————————————————————————————————————— + // INI parsing + // ———————————————————————————————————————————— + + 'parse_ini_file' => [ + 'scanner_mode' => [ + 'type' => 'single', + 'constants' => [ + 'INI_SCANNER_NORMAL', + 'INI_SCANNER_RAW', + 'INI_SCANNER_TYPED', + ], + ], + ], + + 'parse_ini_string' => [ + 'scanner_mode' => [ + 'type' => 'single', + 'constants' => [ + 'INI_SCANNER_NORMAL', + 'INI_SCANNER_RAW', + 'INI_SCANNER_TYPED', + ], + ], + ], + + // ———————————————————————————————————————————— + // Message queues + // ———————————————————————————————————————————— + + 'msg_receive' => [ + 'flags' => [ + 'type' => 'bitmask', + 'constants' => [ + 'MSG_IPC_NOWAIT', + 'MSG_EXCEPT', + 'MSG_NOERROR', + ], + ], + ], + + // ———————————————————————————————————————————— + // Locale + // ———————————————————————————————————————————— + + 'setlocale' => [ + 'category' => [ + 'type' => 'single', + 'constants' => [ + 'LC_CTYPE', + 'LC_NUMERIC', + 'LC_TIME', + 'LC_COLLATE', + 'LC_MONETARY', + 'LC_MESSAGES', + 'LC_ALL', + ], + ], + ], + + // ———————————————————————————————————————————— + // libxml (functions) + // ———————————————————————————————————————————— + + 'simplexml_load_file' => [ + 'options' => [ + 'type' => 'bitmask', + 'constants' => [ + 'LIBXML_NOENT', + 'LIBXML_DTDLOAD', + 'LIBXML_DTDATTR', + 'LIBXML_DTDVALID', + 'LIBXML_NOERROR', + 'LIBXML_NOWARNING', + 'LIBXML_NOBLANKS', + 'LIBXML_XINCLUDE', + 'LIBXML_NSCLEAN', + 'LIBXML_NOCDATA', + 'LIBXML_NONET', + 'LIBXML_PEDANTIC', + 'LIBXML_COMPACT', + 'LIBXML_PARSEHUGE', + 'LIBXML_BIGLINES', + ], + ], + ], + + 'simplexml_load_string' => [ + 'options' => [ + 'type' => 'bitmask', + 'constants' => [ + 'LIBXML_NOENT', + 'LIBXML_DTDLOAD', + 'LIBXML_DTDATTR', + 'LIBXML_DTDVALID', + 'LIBXML_NOERROR', + 'LIBXML_NOWARNING', + 'LIBXML_NOBLANKS', + 'LIBXML_XINCLUDE', + 'LIBXML_NSCLEAN', + 'LIBXML_NOCDATA', + 'LIBXML_NONET', + 'LIBXML_PEDANTIC', + 'LIBXML_COMPACT', + 'LIBXML_PARSEHUGE', + 'LIBXML_BIGLINES', + ], + ], + ], + + // ———————————————————————————————————————————— + // mysqli (functions) + // ———————————————————————————————————————————— + + 'mysqli_begin_transaction' => [ + 'flags' => [ + 'type' => 'bitmask', + 'constants' => [ + 'MYSQLI_TRANS_START_READ_ONLY', + 'MYSQLI_TRANS_START_READ_WRITE', + 'MYSQLI_TRANS_START_WITH_CONSISTENT_SNAPSHOT', + ], + 'exclusiveGroups' => [ + ['MYSQLI_TRANS_START_READ_ONLY', 'MYSQLI_TRANS_START_READ_WRITE'], + ], + ], + ], + + 'mysqli_commit' => [ + 'flags' => [ + 'type' => 'bitmask', + 'constants' => [ + 'MYSQLI_TRANS_COR_AND_CHAIN', + 'MYSQLI_TRANS_COR_AND_NO_CHAIN', + 'MYSQLI_TRANS_COR_RELEASE', + 'MYSQLI_TRANS_COR_NO_RELEASE', + ], + 'exclusiveGroups' => [ + ['MYSQLI_TRANS_COR_AND_CHAIN', 'MYSQLI_TRANS_COR_AND_NO_CHAIN'], + ['MYSQLI_TRANS_COR_RELEASE', 'MYSQLI_TRANS_COR_NO_RELEASE'], + ], + ], + ], + + 'mysqli_rollback' => [ + 'flags' => [ + 'type' => 'bitmask', + 'constants' => [ + 'MYSQLI_TRANS_COR_AND_CHAIN', + 'MYSQLI_TRANS_COR_AND_NO_CHAIN', + 'MYSQLI_TRANS_COR_RELEASE', + 'MYSQLI_TRANS_COR_NO_RELEASE', + ], + 'exclusiveGroups' => [ + ['MYSQLI_TRANS_COR_AND_CHAIN', 'MYSQLI_TRANS_COR_AND_NO_CHAIN'], + ['MYSQLI_TRANS_COR_RELEASE', 'MYSQLI_TRANS_COR_NO_RELEASE'], + ], + ], + ], + + // ———————————————————————————————————————————— + // Methods with global constants + // ———————————————————————————————————————————— + + // finfo methods (FILEINFO_* global constants) + + 'finfo::__construct' => [ + 'flags' => [ + 'type' => 'bitmask', + 'constants' => [ + 'FILEINFO_NONE', + 'FILEINFO_SYMLINK', + 'FILEINFO_MIME', + 'FILEINFO_MIME_TYPE', + 'FILEINFO_MIME_ENCODING', + 'FILEINFO_DEVICES', + 'FILEINFO_CONTINUE', + 'FILEINFO_PRESERVE_ATIME', + 'FILEINFO_RAW', + 'FILEINFO_EXTENSION', + 'FILEINFO_APPLE', + ], + ], + ], + + 'finfo::file' => [ + 'flags' => [ + 'type' => 'bitmask', + 'constants' => [ + 'FILEINFO_NONE', + 'FILEINFO_SYMLINK', + 'FILEINFO_MIME', + 'FILEINFO_MIME_TYPE', + 'FILEINFO_MIME_ENCODING', + 'FILEINFO_DEVICES', + 'FILEINFO_CONTINUE', + 'FILEINFO_PRESERVE_ATIME', + 'FILEINFO_RAW', + 'FILEINFO_EXTENSION', + 'FILEINFO_APPLE', + ], + ], + ], + + 'finfo::buffer' => [ + 'flags' => [ + 'type' => 'bitmask', + 'constants' => [ + 'FILEINFO_NONE', + 'FILEINFO_SYMLINK', + 'FILEINFO_MIME', + 'FILEINFO_MIME_TYPE', + 'FILEINFO_MIME_ENCODING', + 'FILEINFO_DEVICES', + 'FILEINFO_CONTINUE', + 'FILEINFO_PRESERVE_ATIME', + 'FILEINFO_RAW', + 'FILEINFO_EXTENSION', + 'FILEINFO_APPLE', + ], + ], + ], + + 'finfo::set_flags' => [ + 'flags' => [ + 'type' => 'bitmask', + 'constants' => [ + 'FILEINFO_NONE', + 'FILEINFO_SYMLINK', + 'FILEINFO_MIME', + 'FILEINFO_MIME_TYPE', + 'FILEINFO_MIME_ENCODING', + 'FILEINFO_DEVICES', + 'FILEINFO_CONTINUE', + 'FILEINFO_PRESERVE_ATIME', + 'FILEINFO_RAW', + 'FILEINFO_EXTENSION', + 'FILEINFO_APPLE', + ], + ], + ], + + // SplFileObject methods (global constants) + + 'SplFileObject::flock' => [ + 'operation' => [ + 'type' => 'bitmask', + 'constants' => [ + 'LOCK_SH', + 'LOCK_EX', + 'LOCK_UN', + 'LOCK_NB', + ], + 'exclusiveGroups' => [ + ['LOCK_SH', 'LOCK_EX', 'LOCK_UN'], + ], + ], + ], + + 'SplFileObject::fseek' => [ + 'whence' => [ + 'type' => 'single', + 'constants' => [ + 'SEEK_SET', + 'SEEK_CUR', + 'SEEK_END', + ], + ], + ], + + // DOMDocument methods (LIBXML_* global constants) + + 'DOMDocument::load' => [ + 'options' => [ + 'type' => 'bitmask', + 'constants' => [ + 'LIBXML_NOENT', + 'LIBXML_DTDLOAD', + 'LIBXML_DTDATTR', + 'LIBXML_DTDVALID', + 'LIBXML_NOERROR', + 'LIBXML_NOWARNING', + 'LIBXML_NOBLANKS', + 'LIBXML_XINCLUDE', + 'LIBXML_NSCLEAN', + 'LIBXML_NOCDATA', + 'LIBXML_NONET', + 'LIBXML_PEDANTIC', + 'LIBXML_COMPACT', + 'LIBXML_PARSEHUGE', + 'LIBXML_BIGLINES', + ], + ], + ], + + 'DOMDocument::loadXML' => [ + 'options' => [ + 'type' => 'bitmask', + 'constants' => [ + 'LIBXML_NOENT', + 'LIBXML_DTDLOAD', + 'LIBXML_DTDATTR', + 'LIBXML_DTDVALID', + 'LIBXML_NOERROR', + 'LIBXML_NOWARNING', + 'LIBXML_NOBLANKS', + 'LIBXML_XINCLUDE', + 'LIBXML_NSCLEAN', + 'LIBXML_NOCDATA', + 'LIBXML_NONET', + 'LIBXML_PEDANTIC', + 'LIBXML_COMPACT', + 'LIBXML_PARSEHUGE', + 'LIBXML_BIGLINES', + ], + ], + ], + + 'DOMDocument::loadHTML' => [ + 'options' => [ + 'type' => 'bitmask', + 'constants' => [ + 'LIBXML_NOENT', + 'LIBXML_DTDLOAD', + 'LIBXML_DTDATTR', + 'LIBXML_DTDVALID', + 'LIBXML_NOERROR', + 'LIBXML_NOWARNING', + 'LIBXML_NOBLANKS', + 'LIBXML_XINCLUDE', + 'LIBXML_NSCLEAN', + 'LIBXML_NOCDATA', + 'LIBXML_NONET', + 'LIBXML_PEDANTIC', + 'LIBXML_COMPACT', + 'LIBXML_PARSEHUGE', + 'LIBXML_BIGLINES', + 'LIBXML_HTML_NOIMPLIED', + 'LIBXML_HTML_NODEFDTD', + ], + ], + ], + + 'DOMDocument::loadHTMLFile' => [ + 'options' => [ + 'type' => 'bitmask', + 'constants' => [ + 'LIBXML_NOENT', + 'LIBXML_DTDLOAD', + 'LIBXML_DTDATTR', + 'LIBXML_DTDVALID', + 'LIBXML_NOERROR', + 'LIBXML_NOWARNING', + 'LIBXML_NOBLANKS', + 'LIBXML_XINCLUDE', + 'LIBXML_NSCLEAN', + 'LIBXML_NOCDATA', + 'LIBXML_NONET', + 'LIBXML_PEDANTIC', + 'LIBXML_COMPACT', + 'LIBXML_PARSEHUGE', + 'LIBXML_BIGLINES', + 'LIBXML_HTML_NOIMPLIED', + 'LIBXML_HTML_NODEFDTD', + ], + ], + ], + + 'DOMDocument::save' => [ + 'options' => [ + 'type' => 'bitmask', + 'constants' => [ + 'LIBXML_NOEMPTYTAG', + ], + ], + ], + + 'DOMDocument::saveXML' => [ + 'options' => [ + 'type' => 'bitmask', + 'constants' => [ + 'LIBXML_NOEMPTYTAG', + ], + ], + ], + + 'DOMDocument::schemaValidate' => [ + 'options' => [ + 'type' => 'bitmask', + 'constants' => [ + 'LIBXML_SCHEMA_CREATE', + ], + ], + ], + + 'DOMDocument::schemaValidateSource' => [ + 'flags' => [ + 'type' => 'bitmask', + 'constants' => [ + 'LIBXML_SCHEMA_CREATE', + ], + ], + ], + + // XMLReader methods (LIBXML_* global constants) + + 'XMLReader::open' => [ + 'flags' => [ + 'type' => 'bitmask', + 'constants' => [ + 'LIBXML_NOENT', + 'LIBXML_DTDLOAD', + 'LIBXML_DTDATTR', + 'LIBXML_DTDVALID', + 'LIBXML_NOERROR', + 'LIBXML_NOWARNING', + 'LIBXML_NOBLANKS', + 'LIBXML_XINCLUDE', + 'LIBXML_NSCLEAN', + 'LIBXML_NOCDATA', + 'LIBXML_NONET', + 'LIBXML_PEDANTIC', + 'LIBXML_COMPACT', + 'LIBXML_PARSEHUGE', + 'LIBXML_BIGLINES', + ], + ], + ], + + 'XMLReader::XML' => [ + 'flags' => [ + 'type' => 'bitmask', + 'constants' => [ + 'LIBXML_NOENT', + 'LIBXML_DTDLOAD', + 'LIBXML_DTDATTR', + 'LIBXML_DTDVALID', + 'LIBXML_NOERROR', + 'LIBXML_NOWARNING', + 'LIBXML_NOBLANKS', + 'LIBXML_XINCLUDE', + 'LIBXML_NSCLEAN', + 'LIBXML_NOCDATA', + 'LIBXML_NONET', + 'LIBXML_PEDANTIC', + 'LIBXML_COMPACT', + 'LIBXML_PARSEHUGE', + 'LIBXML_BIGLINES', + ], + ], + ], + + // mysqli methods (global constants) + + 'mysqli::begin_transaction' => [ + 'flags' => [ + 'type' => 'bitmask', + 'constants' => [ + 'MYSQLI_TRANS_START_READ_ONLY', + 'MYSQLI_TRANS_START_READ_WRITE', + 'MYSQLI_TRANS_START_WITH_CONSISTENT_SNAPSHOT', + ], + 'exclusiveGroups' => [ + ['MYSQLI_TRANS_START_READ_ONLY', 'MYSQLI_TRANS_START_READ_WRITE'], + ], + ], + ], + + 'mysqli::commit' => [ + 'flags' => [ + 'type' => 'bitmask', + 'constants' => [ + 'MYSQLI_TRANS_COR_AND_CHAIN', + 'MYSQLI_TRANS_COR_AND_NO_CHAIN', + 'MYSQLI_TRANS_COR_RELEASE', + 'MYSQLI_TRANS_COR_NO_RELEASE', + ], + 'exclusiveGroups' => [ + ['MYSQLI_TRANS_COR_AND_CHAIN', 'MYSQLI_TRANS_COR_AND_NO_CHAIN'], + ['MYSQLI_TRANS_COR_RELEASE', 'MYSQLI_TRANS_COR_NO_RELEASE'], + ], + ], + ], + + 'mysqli::rollback' => [ + 'flags' => [ + 'type' => 'bitmask', + 'constants' => [ + 'MYSQLI_TRANS_COR_AND_CHAIN', + 'MYSQLI_TRANS_COR_AND_NO_CHAIN', + 'MYSQLI_TRANS_COR_RELEASE', + 'MYSQLI_TRANS_COR_NO_RELEASE', + ], + 'exclusiveGroups' => [ + ['MYSQLI_TRANS_COR_AND_CHAIN', 'MYSQLI_TRANS_COR_AND_NO_CHAIN'], + ['MYSQLI_TRANS_COR_RELEASE', 'MYSQLI_TRANS_COR_NO_RELEASE'], + ], + ], + ], + + // Collator methods (class constants) + + 'Collator::sort' => [ + 'flags' => [ + 'type' => 'single', + 'constants' => [ + 'Collator::SORT_REGULAR', + 'Collator::SORT_STRING', + 'Collator::SORT_NUMERIC', + ], + ], + ], + + 'Collator::asort' => [ + 'flags' => [ + 'type' => 'single', + 'constants' => [ + 'Collator::SORT_REGULAR', + 'Collator::SORT_STRING', + 'Collator::SORT_NUMERIC', + ], + ], + ], + + 'Collator::setAttribute' => [ + 'attribute' => [ + 'type' => 'single', + 'constants' => [ + 'Collator::FRENCH_COLLATION', + 'Collator::ALTERNATE_HANDLING', + 'Collator::CASE_FIRST', + 'Collator::CASE_LEVEL', + 'Collator::NORMALIZATION_MODE', + 'Collator::STRENGTH', + 'Collator::HIRAGANA_QUATERNARY_MODE', + 'Collator::NUMERIC_COLLATION', + ], + ], + ], + + 'Collator::getAttribute' => [ + 'attribute' => [ + 'type' => 'single', + 'constants' => [ + 'Collator::FRENCH_COLLATION', + 'Collator::ALTERNATE_HANDLING', + 'Collator::CASE_FIRST', + 'Collator::CASE_LEVEL', + 'Collator::NORMALIZATION_MODE', + 'Collator::STRENGTH', + 'Collator::HIRAGANA_QUATERNARY_MODE', + 'Collator::NUMERIC_COLLATION', + ], + ], + ], + + // ———————————————————————————————————————————— + // Methods with class constants + // ———————————————————————————————————————————— + + // PDO::setAttribute/getAttribute are excluded because PDO drivers add + // their own attribute constants (PGSQL_ATTR_*, MYSQL_ATTR_*, etc.) + + // PDOStatement + + 'PDOStatement::fetch' => [ + 'mode' => [ + 'type' => 'single', + 'constants' => [ + 'PDO::FETCH_DEFAULT', + 'PDO::FETCH_LAZY', + 'PDO::FETCH_ASSOC', + 'PDO::FETCH_NUM', + 'PDO::FETCH_BOTH', + 'PDO::FETCH_OBJ', + 'PDO::FETCH_BOUND', + 'PDO::FETCH_COLUMN', + 'PDO::FETCH_CLASS', + 'PDO::FETCH_INTO', + 'PDO::FETCH_FUNC', + 'PDO::FETCH_GROUP', + 'PDO::FETCH_UNIQUE', + 'PDO::FETCH_KEY_PAIR', + 'PDO::FETCH_CLASSTYPE', + 'PDO::FETCH_SERIALIZE', + 'PDO::FETCH_PROPS_LATE', + 'PDO::FETCH_NAMED', + ], + ], + 'cursorOrientation' => [ + 'type' => 'single', + 'constants' => [ + 'PDO::FETCH_ORI_NEXT', + 'PDO::FETCH_ORI_PRIOR', + 'PDO::FETCH_ORI_FIRST', + 'PDO::FETCH_ORI_LAST', + 'PDO::FETCH_ORI_ABS', + 'PDO::FETCH_ORI_REL', + ], + ], + ], + + 'PDOStatement::fetchAll' => [ + 'mode' => [ + 'type' => 'single', + 'constants' => [ + 'PDO::FETCH_DEFAULT', + 'PDO::FETCH_LAZY', + 'PDO::FETCH_ASSOC', + 'PDO::FETCH_NUM', + 'PDO::FETCH_BOTH', + 'PDO::FETCH_OBJ', + 'PDO::FETCH_BOUND', + 'PDO::FETCH_COLUMN', + 'PDO::FETCH_CLASS', + 'PDO::FETCH_INTO', + 'PDO::FETCH_FUNC', + 'PDO::FETCH_GROUP', + 'PDO::FETCH_UNIQUE', + 'PDO::FETCH_KEY_PAIR', + 'PDO::FETCH_CLASSTYPE', + 'PDO::FETCH_SERIALIZE', + 'PDO::FETCH_PROPS_LATE', + 'PDO::FETCH_NAMED', + ], + ], + ], + + 'PDOStatement::setFetchMode' => [ + 'mode' => [ + 'type' => 'single', + 'constants' => [ + 'PDO::FETCH_DEFAULT', + 'PDO::FETCH_LAZY', + 'PDO::FETCH_ASSOC', + 'PDO::FETCH_NUM', + 'PDO::FETCH_BOTH', + 'PDO::FETCH_OBJ', + 'PDO::FETCH_BOUND', + 'PDO::FETCH_COLUMN', + 'PDO::FETCH_CLASS', + 'PDO::FETCH_INTO', + 'PDO::FETCH_FUNC', + 'PDO::FETCH_GROUP', + 'PDO::FETCH_UNIQUE', + 'PDO::FETCH_KEY_PAIR', + 'PDO::FETCH_CLASSTYPE', + 'PDO::FETCH_SERIALIZE', + 'PDO::FETCH_PROPS_LATE', + 'PDO::FETCH_NAMED', + ], + ], + ], + + 'PDOStatement::bindColumn' => [ + 'type' => [ + 'type' => 'single', + 'constants' => [ + 'PDO::PARAM_NULL', + 'PDO::PARAM_BOOL', + 'PDO::PARAM_INT', + 'PDO::PARAM_STR', + 'PDO::PARAM_LOB', + 'PDO::PARAM_STMT', + 'PDO::PARAM_INPUT_OUTPUT', + 'PDO::PARAM_STR_NATL', + 'PDO::PARAM_STR_CHAR', + ], + ], + ], + + 'PDOStatement::bindParam' => [ + 'type' => [ + 'type' => 'single', + 'constants' => [ + 'PDO::PARAM_NULL', + 'PDO::PARAM_BOOL', + 'PDO::PARAM_INT', + 'PDO::PARAM_STR', + 'PDO::PARAM_LOB', + 'PDO::PARAM_STMT', + 'PDO::PARAM_INPUT_OUTPUT', + 'PDO::PARAM_STR_NATL', + 'PDO::PARAM_STR_CHAR', + ], + ], + ], + + 'PDOStatement::bindValue' => [ + 'type' => [ + 'type' => 'single', + 'constants' => [ + 'PDO::PARAM_NULL', + 'PDO::PARAM_BOOL', + 'PDO::PARAM_INT', + 'PDO::PARAM_STR', + 'PDO::PARAM_LOB', + 'PDO::PARAM_STMT', + 'PDO::PARAM_INPUT_OUTPUT', + 'PDO::PARAM_STR_NATL', + 'PDO::PARAM_STR_CHAR', + ], + ], + ], + + // ZipArchive + + 'ZipArchive::open' => [ + 'flags' => [ + 'type' => 'bitmask', + 'constants' => [ + 'ZipArchive::CREATE', + 'ZipArchive::EXCL', + 'ZipArchive::CHECKCONS', + 'ZipArchive::OVERWRITE', + 'ZipArchive::RDONLY', + ], + ], + ], + + 'ZipArchive::setCompressionName' => [ + 'method' => [ + 'type' => 'single', + 'constants' => [ + 'ZipArchive::CM_DEFAULT', + 'ZipArchive::CM_STORE', + 'ZipArchive::CM_SHRINK', + 'ZipArchive::CM_REDUCE_1', + 'ZipArchive::CM_REDUCE_2', + 'ZipArchive::CM_REDUCE_3', + 'ZipArchive::CM_REDUCE_4', + 'ZipArchive::CM_IMPLODE', + 'ZipArchive::CM_DEFLATE', + 'ZipArchive::CM_DEFLATE64', + 'ZipArchive::CM_PKWARE_IMPLODE', + 'ZipArchive::CM_BZIP2', + 'ZipArchive::CM_LZMA', + 'ZipArchive::CM_LZMA2', + 'ZipArchive::CM_ZSTD', + 'ZipArchive::CM_XZ', + ], + ], + ], + + 'ZipArchive::setCompressionIndex' => [ + 'method' => [ + 'type' => 'single', + 'constants' => [ + 'ZipArchive::CM_DEFAULT', + 'ZipArchive::CM_STORE', + 'ZipArchive::CM_SHRINK', + 'ZipArchive::CM_REDUCE_1', + 'ZipArchive::CM_REDUCE_2', + 'ZipArchive::CM_REDUCE_3', + 'ZipArchive::CM_REDUCE_4', + 'ZipArchive::CM_IMPLODE', + 'ZipArchive::CM_DEFLATE', + 'ZipArchive::CM_DEFLATE64', + 'ZipArchive::CM_PKWARE_IMPLODE', + 'ZipArchive::CM_BZIP2', + 'ZipArchive::CM_LZMA', + 'ZipArchive::CM_LZMA2', + 'ZipArchive::CM_ZSTD', + 'ZipArchive::CM_XZ', + ], + ], + ], + + 'ZipArchive::setEncryptionName' => [ + 'method' => [ + 'type' => 'single', + 'constants' => [ + 'ZipArchive::EM_NONE', + 'ZipArchive::EM_TRAD_PKWARE', + 'ZipArchive::EM_AES_128', + 'ZipArchive::EM_AES_192', + 'ZipArchive::EM_AES_256', + ], + ], + ], + + 'ZipArchive::setEncryptionIndex' => [ + 'method' => [ + 'type' => 'single', + 'constants' => [ + 'ZipArchive::EM_NONE', + 'ZipArchive::EM_TRAD_PKWARE', + 'ZipArchive::EM_AES_128', + 'ZipArchive::EM_AES_192', + 'ZipArchive::EM_AES_256', + ], + ], + ], + + // IntlDateFormatter + + 'IntlDateFormatter::__construct' => [ + 'dateType' => [ + 'type' => 'single', + 'constants' => [ + 'IntlDateFormatter::FULL', + 'IntlDateFormatter::LONG', + 'IntlDateFormatter::MEDIUM', + 'IntlDateFormatter::SHORT', + 'IntlDateFormatter::NONE', + 'IntlDateFormatter::RELATIVE_FULL', + 'IntlDateFormatter::RELATIVE_LONG', + 'IntlDateFormatter::RELATIVE_MEDIUM', + 'IntlDateFormatter::RELATIVE_SHORT', + ], + ], + 'timeType' => [ + 'type' => 'single', + 'constants' => [ + 'IntlDateFormatter::FULL', + 'IntlDateFormatter::LONG', + 'IntlDateFormatter::MEDIUM', + 'IntlDateFormatter::SHORT', + 'IntlDateFormatter::NONE', + 'IntlDateFormatter::RELATIVE_FULL', + 'IntlDateFormatter::RELATIVE_LONG', + 'IntlDateFormatter::RELATIVE_MEDIUM', + 'IntlDateFormatter::RELATIVE_SHORT', + ], + ], + ], + + 'IntlDateFormatter::create' => [ + 'dateType' => [ + 'type' => 'single', + 'constants' => [ + 'IntlDateFormatter::FULL', + 'IntlDateFormatter::LONG', + 'IntlDateFormatter::MEDIUM', + 'IntlDateFormatter::SHORT', + 'IntlDateFormatter::NONE', + 'IntlDateFormatter::RELATIVE_FULL', + 'IntlDateFormatter::RELATIVE_LONG', + 'IntlDateFormatter::RELATIVE_MEDIUM', + 'IntlDateFormatter::RELATIVE_SHORT', + ], + ], + 'timeType' => [ + 'type' => 'single', + 'constants' => [ + 'IntlDateFormatter::FULL', + 'IntlDateFormatter::LONG', + 'IntlDateFormatter::MEDIUM', + 'IntlDateFormatter::SHORT', + 'IntlDateFormatter::NONE', + 'IntlDateFormatter::RELATIVE_FULL', + 'IntlDateFormatter::RELATIVE_LONG', + 'IntlDateFormatter::RELATIVE_MEDIUM', + 'IntlDateFormatter::RELATIVE_SHORT', + ], + ], + ], + + // NumberFormatter + + 'NumberFormatter::__construct' => [ + 'style' => [ + 'type' => 'single', + 'constants' => [ + 'NumberFormatter::PATTERN_DECIMAL', + 'NumberFormatter::DECIMAL', + 'NumberFormatter::CURRENCY', + 'NumberFormatter::PERCENT', + 'NumberFormatter::SCIENTIFIC', + 'NumberFormatter::SPELLOUT', + 'NumberFormatter::ORDINAL', + 'NumberFormatter::DURATION', + 'NumberFormatter::PATTERN_RULEBASED', + 'NumberFormatter::IGNORE', + 'NumberFormatter::CURRENCY_ACCOUNTING', + 'NumberFormatter::DEFAULT_STYLE', + ], + ], + ], + + 'NumberFormatter::create' => [ + 'style' => [ + 'type' => 'single', + 'constants' => [ + 'NumberFormatter::PATTERN_DECIMAL', + 'NumberFormatter::DECIMAL', + 'NumberFormatter::CURRENCY', + 'NumberFormatter::PERCENT', + 'NumberFormatter::SCIENTIFIC', + 'NumberFormatter::SPELLOUT', + 'NumberFormatter::ORDINAL', + 'NumberFormatter::DURATION', + 'NumberFormatter::PATTERN_RULEBASED', + 'NumberFormatter::IGNORE', + 'NumberFormatter::CURRENCY_ACCOUNTING', + 'NumberFormatter::DEFAULT_STYLE', + ], + ], + ], + + 'NumberFormatter::format' => [ + 'type' => [ + 'type' => 'single', + 'constants' => [ + 'NumberFormatter::TYPE_DEFAULT', + 'NumberFormatter::TYPE_INT32', + 'NumberFormatter::TYPE_INT64', + 'NumberFormatter::TYPE_DOUBLE', + 'NumberFormatter::TYPE_CURRENCY', + ], + ], + ], + + 'NumberFormatter::setAttribute' => [ + 'attribute' => [ + 'type' => 'single', + 'constants' => [ + 'NumberFormatter::PARSE_INT_ONLY', + 'NumberFormatter::GROUPING_USED', + 'NumberFormatter::DECIMAL_ALWAYS_SHOWN', + 'NumberFormatter::MAX_INTEGER_DIGITS', + 'NumberFormatter::MIN_INTEGER_DIGITS', + 'NumberFormatter::INTEGER_DIGITS', + 'NumberFormatter::MAX_FRACTION_DIGITS', + 'NumberFormatter::MIN_FRACTION_DIGITS', + 'NumberFormatter::FRACTION_DIGITS', + 'NumberFormatter::MULTIPLIER', + 'NumberFormatter::GROUPING_SIZE', + 'NumberFormatter::ROUNDING_MODE', + 'NumberFormatter::ROUNDING_INCREMENT', + 'NumberFormatter::FORMAT_WIDTH', + 'NumberFormatter::PADDING_POSITION', + 'NumberFormatter::SECONDARY_GROUPING_SIZE', + 'NumberFormatter::SIGNIFICANT_DIGITS_USED', + 'NumberFormatter::MIN_SIGNIFICANT_DIGITS', + 'NumberFormatter::MAX_SIGNIFICANT_DIGITS', + 'NumberFormatter::LENIENT_PARSE', + ], + ], + ], + + 'NumberFormatter::getAttribute' => [ + 'attribute' => [ + 'type' => 'single', + 'constants' => [ + 'NumberFormatter::PARSE_INT_ONLY', + 'NumberFormatter::GROUPING_USED', + 'NumberFormatter::DECIMAL_ALWAYS_SHOWN', + 'NumberFormatter::MAX_INTEGER_DIGITS', + 'NumberFormatter::MIN_INTEGER_DIGITS', + 'NumberFormatter::INTEGER_DIGITS', + 'NumberFormatter::MAX_FRACTION_DIGITS', + 'NumberFormatter::MIN_FRACTION_DIGITS', + 'NumberFormatter::FRACTION_DIGITS', + 'NumberFormatter::MULTIPLIER', + 'NumberFormatter::GROUPING_SIZE', + 'NumberFormatter::ROUNDING_MODE', + 'NumberFormatter::ROUNDING_INCREMENT', + 'NumberFormatter::FORMAT_WIDTH', + 'NumberFormatter::PADDING_POSITION', + 'NumberFormatter::SECONDARY_GROUPING_SIZE', + 'NumberFormatter::SIGNIFICANT_DIGITS_USED', + 'NumberFormatter::MIN_SIGNIFICANT_DIGITS', + 'NumberFormatter::MAX_SIGNIFICANT_DIGITS', + 'NumberFormatter::LENIENT_PARSE', + ], + ], + ], + + 'NumberFormatter::setTextAttribute' => [ + 'attribute' => [ + 'type' => 'single', + 'constants' => [ + 'NumberFormatter::POSITIVE_PREFIX', + 'NumberFormatter::POSITIVE_SUFFIX', + 'NumberFormatter::NEGATIVE_PREFIX', + 'NumberFormatter::NEGATIVE_SUFFIX', + 'NumberFormatter::PADDING_CHARACTER', + 'NumberFormatter::CURRENCY_CODE', + 'NumberFormatter::DEFAULT_RULESET', + 'NumberFormatter::PUBLIC_RULESETS', + ], + ], + ], + + 'NumberFormatter::getTextAttribute' => [ + 'attribute' => [ + 'type' => 'single', + 'constants' => [ + 'NumberFormatter::POSITIVE_PREFIX', + 'NumberFormatter::POSITIVE_SUFFIX', + 'NumberFormatter::NEGATIVE_PREFIX', + 'NumberFormatter::NEGATIVE_SUFFIX', + 'NumberFormatter::PADDING_CHARACTER', + 'NumberFormatter::CURRENCY_CODE', + 'NumberFormatter::DEFAULT_RULESET', + 'NumberFormatter::PUBLIC_RULESETS', + ], + ], + ], + + // SplPriorityQueue + + 'SplPriorityQueue::setExtractFlags' => [ + 'flags' => [ + 'type' => 'single', + 'constants' => [ + 'SplPriorityQueue::EXTR_BOTH', + 'SplPriorityQueue::EXTR_PRIORITY', + 'SplPriorityQueue::EXTR_DATA', + ], + ], + ], + + // FilesystemIterator / GlobIterator / RecursiveDirectoryIterator + + 'FilesystemIterator::__construct' => [ + 'flags' => [ + 'type' => 'bitmask', + 'constants' => [ + 'FilesystemIterator::CURRENT_AS_PATHNAME', + 'FilesystemIterator::CURRENT_AS_FILEINFO', + 'FilesystemIterator::CURRENT_AS_SELF', + 'FilesystemIterator::KEY_AS_PATHNAME', + 'FilesystemIterator::KEY_AS_FILENAME', + 'FilesystemIterator::FOLLOW_SYMLINKS', + 'FilesystemIterator::NEW_CURRENT_AND_KEY', + 'FilesystemIterator::SKIP_DOTS', + 'FilesystemIterator::UNIX_PATHS', + ], + 'exclusiveGroups' => [ + ['FilesystemIterator::CURRENT_AS_PATHNAME', 'FilesystemIterator::CURRENT_AS_FILEINFO', 'FilesystemIterator::CURRENT_AS_SELF'], + ['FilesystemIterator::KEY_AS_PATHNAME', 'FilesystemIterator::KEY_AS_FILENAME'], + ], + ], + ], + + 'FilesystemIterator::setFlags' => [ + 'flags' => [ + 'type' => 'bitmask', + 'constants' => [ + 'FilesystemIterator::CURRENT_AS_PATHNAME', + 'FilesystemIterator::CURRENT_AS_FILEINFO', + 'FilesystemIterator::CURRENT_AS_SELF', + 'FilesystemIterator::KEY_AS_PATHNAME', + 'FilesystemIterator::KEY_AS_FILENAME', + 'FilesystemIterator::FOLLOW_SYMLINKS', + 'FilesystemIterator::NEW_CURRENT_AND_KEY', + 'FilesystemIterator::SKIP_DOTS', + 'FilesystemIterator::UNIX_PATHS', + ], + 'exclusiveGroups' => [ + ['FilesystemIterator::CURRENT_AS_PATHNAME', 'FilesystemIterator::CURRENT_AS_FILEINFO', 'FilesystemIterator::CURRENT_AS_SELF'], + ['FilesystemIterator::KEY_AS_PATHNAME', 'FilesystemIterator::KEY_AS_FILENAME'], + ], + ], + ], + + 'GlobIterator::__construct' => [ + 'flags' => [ + 'type' => 'bitmask', + 'constants' => [ + 'FilesystemIterator::CURRENT_AS_PATHNAME', + 'FilesystemIterator::CURRENT_AS_FILEINFO', + 'FilesystemIterator::CURRENT_AS_SELF', + 'FilesystemIterator::KEY_AS_PATHNAME', + 'FilesystemIterator::KEY_AS_FILENAME', + 'FilesystemIterator::FOLLOW_SYMLINKS', + 'FilesystemIterator::NEW_CURRENT_AND_KEY', + 'FilesystemIterator::SKIP_DOTS', + 'FilesystemIterator::UNIX_PATHS', + ], + 'exclusiveGroups' => [ + ['FilesystemIterator::CURRENT_AS_PATHNAME', 'FilesystemIterator::CURRENT_AS_FILEINFO', 'FilesystemIterator::CURRENT_AS_SELF'], + ['FilesystemIterator::KEY_AS_PATHNAME', 'FilesystemIterator::KEY_AS_FILENAME'], + ], + ], + ], + + 'RecursiveDirectoryIterator::__construct' => [ + 'flags' => [ + 'type' => 'bitmask', + 'constants' => [ + 'FilesystemIterator::CURRENT_AS_PATHNAME', + 'FilesystemIterator::CURRENT_AS_FILEINFO', + 'FilesystemIterator::CURRENT_AS_SELF', + 'FilesystemIterator::KEY_AS_PATHNAME', + 'FilesystemIterator::KEY_AS_FILENAME', + 'FilesystemIterator::FOLLOW_SYMLINKS', + 'FilesystemIterator::NEW_CURRENT_AND_KEY', + 'FilesystemIterator::SKIP_DOTS', + 'FilesystemIterator::UNIX_PATHS', + ], + 'exclusiveGroups' => [ + ['FilesystemIterator::CURRENT_AS_PATHNAME', 'FilesystemIterator::CURRENT_AS_FILEINFO', 'FilesystemIterator::CURRENT_AS_SELF'], + ['FilesystemIterator::KEY_AS_PATHNAME', 'FilesystemIterator::KEY_AS_FILENAME'], + ], + ], + ], + + // RecursiveIteratorIterator + + 'RecursiveIteratorIterator::__construct' => [ + 'mode' => [ + 'type' => 'single', + 'constants' => [ + 'RecursiveIteratorIterator::LEAVES_ONLY', + 'RecursiveIteratorIterator::SELF_FIRST', + 'RecursiveIteratorIterator::CHILD_FIRST', + ], + ], + 'flags' => [ + 'type' => 'bitmask', + 'constants' => [ + 'RecursiveIteratorIterator::CATCH_GET_CHILD', + ], + ], + ], + + // DatePeriod + + 'DatePeriod::__construct' => [ + 'options' => [ + 'type' => 'bitmask', + 'constants' => [ + 'DatePeriod::EXCLUDE_START_DATE', + 'DatePeriod::INCLUDE_END_DATE', + ], + ], + ], +]; diff --git a/resources/functionMap.php b/resources/functionMap.php index 3c28d09faa4..8674d733188 100644 --- a/resources/functionMap.php +++ b/resources/functionMap.php @@ -8670,7 +8670,7 @@ 'Redis::hRandField' => ['__benevolent>', 'key'=>'string', 'options'=>'?array{COUNT?:int,WITHVALUES?:bool}'], 'Redis::hscan' => ['__benevolent|bool>', 'key'=>'string', '&iterator'=>'?int', 'pattern='=>'?string', 'count='=>'int'], 'Redis::hSet' => ['__benevolent', 'key'=>'string', 'member'=>'string', 'value'=>'mixed'], -'Redis::hSetNx' => ['__benevolent', 'key'=>'string', 'field'=>'string', 'value'=>'string'], +'Redis::hSetNx' => ['__benevolent', 'key'=>'string', 'field'=>'string', 'value'=>'mixed'], 'Redis::hStrLen' => ['__benevolent', 'key'=>'string', 'field'=>'string'], 'Redis::hVals' => ['__benevolent', 'key'=>'string'], 'Redis::incr' => ['__benevolent', 'key'=>'string', 'by='=>'int'], @@ -8876,8 +8876,8 @@ 'RedisCluster::hMGet' => ['array', 'key'=>'string', 'hashKeys'=>'array'], 'RedisCluster::hMSet' => ['bool', 'key'=>'string', 'hashKeys'=>'array'], 'RedisCluster::hScan' => ['array', 'key'=>'string', '&iterator'=>'int', 'pattern='=>'string', 'count='=>'int'], -'RedisCluster::hSet' => ['int', 'key'=>'string', 'hashKey'=>'string', 'value'=>'string'], -'RedisCluster::hSetNx' => ['bool', 'key'=>'string', 'hashKey'=>'string', 'value'=>'string'], +'RedisCluster::hSet' => ['int', 'key'=>'string', 'hashKey'=>'string', 'value'=>'mixed'], +'RedisCluster::hSetNx' => ['bool', 'key'=>'string', 'hashKey'=>'string', 'value'=>'mixed'], 'RedisCluster::hVals' => ['array', 'key'=>'string'], 'RedisCluster::incr' => ['int', 'key'=>'string', 'by='=>'int'], 'RedisCluster::incrBy' => ['int', 'key'=>'string', 'value'=>'int'], diff --git a/src/Analyser/CollectedDataEmitter.php b/src/Analyser/CollectedDataEmitter.php new file mode 100644 index 00000000000..1f5be5f4def --- /dev/null +++ b/src/Analyser/CollectedDataEmitter.php @@ -0,0 +1,42 @@ +emitCollectedData(MyCollector::class, ['some', 'data']); + * ``` + * + * @api + */ +interface CollectedDataEmitter +{ + + /** + * @template TCollector of Collector + * @param class-string $collectorType + * @param template-type $data + */ + public function emitCollectedData(string $collectorType, mixed $data): void; + +} diff --git a/src/Analyser/Error.php b/src/Analyser/Error.php index 9af5f2b297d..107db06f08a 100644 --- a/src/Analyser/Error.php +++ b/src/Analyser/Error.php @@ -106,6 +106,28 @@ public function changeTraitFilePath(string $newFilePath): self ); } + public function removeTraitContext(): self + { + if ($this->traitFilePath === null) { + throw new ShouldNotHappenException(); + } + + return new self( + $this->message, + $this->traitFilePath, + $this->line, + $this->canBeIgnored, + $this->filePath, + null, + $this->tip, + $this->nodeLine, + $this->nodeType, + $this->identifier, + $this->metadata, + $this->fixedErrorDiff, + ); + } + public function getTraitFilePath(): ?string { return $this->traitFilePath; diff --git a/src/Analyser/ExprHandler/FuncCallHandler.php b/src/Analyser/ExprHandler/FuncCallHandler.php index 5df15e3fd91..c784dda48c0 100644 --- a/src/Analyser/ExprHandler/FuncCallHandler.php +++ b/src/Analyser/ExprHandler/FuncCallHandler.php @@ -699,19 +699,60 @@ static function (?Type $offsetType, Type $valueType, bool $optional) use (&$arra $constantArray->isOptionalKey($k), ); } + + $unsealedTypes = $constantArray->getUnsealedTypes(); + if ($unsealedTypes !== null) { + $arrayTypeBuilder->makeUnsealed($unsealedTypes[0], $unsealedTypes[1]); + } } $constantArray = $arrayTypeBuilder->getArray(); if ($constantArray->isConstantArray()->yes() && $nonConstantArrayWasUnpacked) { - $array = new ArrayType($constantArray->generalize(GeneralizePrecision::lessSpecific())->getIterableKeyType(), $constantArray->getIterableValueType()); - $isList = $constantArray->isList()->yes(); - $constantArray = $constantArray->isIterableAtLeastOnce()->yes() - ? new IntersectionType([$array, new NonEmptyArrayType()]) - : $array; - $constantArray = $isList - ? TypeCombinator::intersect($constantArray, new AccessoryArrayListType()) - : $constantArray; + $constantArrays = $constantArray->getConstantArrays(); + if ($constantArray->isList()->yes()) { + // A list can't preserve precise indices when an + // unknown number of values is prepended/appended — + // every index would be shifted by an unknown + // amount. Degrade to a `non-empty-list<...>` of + // the value union. + $array = new ArrayType($constantArray->generalize(GeneralizePrecision::lessSpecific())->getIterableKeyType(), $constantArray->getIterableValueType()); + $constantArray = $constantArray->isIterableAtLeastOnce()->yes() + ? new IntersectionType([$array, new NonEmptyArrayType()]) + : $array; + $constantArray = TypeCombinator::intersect($constantArray, new AccessoryArrayListType()); + } elseif (count($constantArrays) === 1) { + // Associative input — string keys keep their + // precise values and the unknown count of + // unpacked items lives in an unsealed `int` slot + // of the result. Drops the auto-indexed + // representatives that the unpacked-arg loop + // inserted (they stand in for "0..N-1 of the + // unpack value type" and are now subsumed by the + // unsealed slot). + $builder = ConstantArrayTypeBuilder::createEmpty(); + $intValues = []; + foreach ($constantArrays[0]->getKeyTypes() as $i => $keyType) { + $valueType = $constantArrays[0]->getValueTypes()[$i]; + if ($keyType->isString()->yes()) { + $builder->setOffsetValueType($keyType, $valueType, $constantArrays[0]->isOptionalKey($i)); + continue; + } + $intValues[] = $valueType; + } + + $unsealedKey = new IntegerType(); + $unsealedValue = count($intValues) > 0 ? TypeCombinator::union(...$intValues) : new MixedType(); + if ($constantArrays[0]->isUnsealed()->yes()) { + $existing = $constantArrays[0]->getUnsealedTypes(); + if ($existing !== null) { + $unsealedKey = TypeCombinator::union($unsealedKey, $existing[0]); + $unsealedValue = TypeCombinator::union($unsealedValue, $existing[1]); + } + } + $builder->makeUnsealed($unsealedKey, $unsealedValue); + $constantArray = $builder->getArray(); + } } $newArrayTypes[] = $constantArray; diff --git a/src/Analyser/FileAnalyserCallback.php b/src/Analyser/FileAnalyserCallback.php index 304513f7f4e..07e5371a04e 100644 --- a/src/Analyser/FileAnalyserCallback.php +++ b/src/Analyser/FileAnalyserCallback.php @@ -11,6 +11,7 @@ use PHPStan\Collectors\Registry as CollectorRegistry; use PHPStan\Dependency\DependencyResolver; use PHPStan\Dependency\RootExportedNode; +use PHPStan\Node\EmitCollectedDataNode; use PHPStan\Node\InClassNode; use PHPStan\Node\InTraitNode; use PHPStan\Parser\Parser; @@ -77,9 +78,14 @@ public function __construct( public function __invoke(Node $node, Scope $scope): void { + if ($node instanceof EmitCollectedDataNode) { + $this->fileCollectedData[$scope->getFile()][$node->getCollectorType()][] = $node->getData(); + return; + } + $parserNodes = $this->parserNodes; - /** @var Scope&NodeCallbackInvoker $scope */ + /** @var Scope&NodeCallbackInvoker&CollectedDataEmitter $scope */ if ($node instanceof Node\Stmt\Trait_) { foreach (array_keys($this->linesToIgnore[$this->file] ?? []) as $lineToIgnore) { if ($lineToIgnore < $node->getStartLine() || $lineToIgnore > $node->getEndLine()) { diff --git a/src/Analyser/Ignore/IgnoredError.php b/src/Analyser/Ignore/IgnoredError.php index 33fd610a02d..9476547059d 100644 --- a/src/Analyser/Ignore/IgnoredError.php +++ b/src/Analyser/Ignore/IgnoredError.php @@ -14,11 +14,14 @@ use function sprintf; use function str_replace; +/** + * @phpstan-import-type ExpandedIgnoredErrorData from IgnoredErrorHelperResult + */ final class IgnoredError { /** - * @param array{message?: string, rawMessage?: string, identifier?: string, identifiers?: list, path?: string, paths?: list}|string $ignoredError + * @param ExpandedIgnoredErrorData|string $ignoredError */ public static function getIgnoredErrorLabel(array|string $ignoredError): string { diff --git a/src/Analyser/Ignore/IgnoredErrorHelper.php b/src/Analyser/Ignore/IgnoredErrorHelper.php index d3394bcb0bb..dd165407ba6 100644 --- a/src/Analyser/Ignore/IgnoredErrorHelper.php +++ b/src/Analyser/Ignore/IgnoredErrorHelper.php @@ -14,12 +14,26 @@ use function is_file; use function sprintf; +/** + * @phpstan-type IgnoredErrorData = array{ + * message?: string, + * messages?: list, + * rawMessage?: string, + * rawMessages?: list, + * identifier?: string, + * identifiers?: list, + * path?: string, + * paths?: list, + * count?: int, + * reportUnmatched?: bool, + * } + */ #[AutowiredService] final class IgnoredErrorHelper { /** - * @param (string|mixed[])[] $ignoreErrors + * @param (string|IgnoredErrorData)[] $ignoreErrors */ public function __construct( private FileHelper $fileHelper, @@ -106,7 +120,7 @@ public function initialize(): IgnoredErrorHelperResult continue; } - $reportUnmatched = (bool) ($uniquedExpandedIgnoreErrors[$key]['reportUnmatched'] ?? $this->reportUnmatchedIgnoredErrors); + $reportUnmatched = $uniquedExpandedIgnoreErrors[$key]['reportUnmatched'] ?? $this->reportUnmatchedIgnoredErrors; if (!$reportUnmatched) { $reportUnmatched = $ignoreError['reportUnmatched'] ?? $this->reportUnmatchedIgnoredErrors; } diff --git a/src/Analyser/Ignore/IgnoredErrorHelperResult.php b/src/Analyser/Ignore/IgnoredErrorHelperResult.php index ea4c1295309..5334fb7f6ea 100644 --- a/src/Analyser/Ignore/IgnoredErrorHelperResult.php +++ b/src/Analyser/Ignore/IgnoredErrorHelperResult.php @@ -13,14 +13,39 @@ use function is_string; use function sprintf; +/** + * `IgnoredErrorHelper` may collapse several configured ignores into one + * merged entry, so `message`/`rawMessage`/`identifier` are nullable here. + * It also attaches `realPath` once the configured path is resolved. The + * `messages`/`rawMessages`/`identifiers` keys remain in the inferred shape + * even after expansion + unset (PHPStan does not strip optional keys via + * negative isset on sealed shapes), so the type lists them explicitly here + * — they are never read, only tolerated. `paths` is `array, + * string>` rather than `list` because `process()` unsets matched + * entries by index, breaking list-ness. + * + * @phpstan-type ExpandedIgnoredErrorData = array{ + * message?: string|null, + * rawMessage?: string|null, + * identifier?: string|null, + * messages?: list, + * rawMessages?: list, + * identifiers?: list, + * path?: string, + * paths?: array, string>, + * count?: int, + * reportUnmatched?: bool, + * realPath?: string, + * } + */ final class IgnoredErrorHelperResult { /** * @param list $errors - * @param array> $otherIgnoreErrors - * @param array>> $ignoreErrorsByFile - * @param (string|mixed[])[] $ignoreErrors + * @param array, ignoreError: string|ExpandedIgnoredErrorData}> $otherIgnoreErrors + * @param array, ignoreError: string|ExpandedIgnoredErrorData}>> $ignoreErrorsByFile + * @param (string|ExpandedIgnoredErrorData)[] $ignoreErrors */ public function __construct( private FileHelper $fileHelper, @@ -55,7 +80,14 @@ public function process( $unmatchedIgnoredErrors = $this->ignoreErrors; $stringErrors = []; - $processIgnoreError = function (Error $error, int $i, $ignore) use (&$unmatchedIgnoredErrors, &$stringErrors): bool { + // Per-entry runtime state for `count`-bounded ignores. Tracked in side + // maps keyed by the same index so `$unmatchedIgnoredErrors` keeps the + // `(string|ExpandedIgnoredErrorData)[]` shape across the closure's + // offset writes — otherwise PHPStan widens it to `array`. + $realCounts = []; + $matchedAt = []; + + $processIgnoreError = function (Error $error, int $i, $ignore) use (&$unmatchedIgnoredErrors, &$stringErrors, &$realCounts, &$matchedAt): bool { $shouldBeIgnored = false; if (is_string($ignore)) { $shouldBeIgnored = IgnoredError::shouldIgnore($this->fileHelper, $error, ignoredErrorPattern: $ignore, ignoredErrorMessage: null, identifier: null, path: null); @@ -67,13 +99,11 @@ public function process( $shouldBeIgnored = IgnoredError::shouldIgnore($this->fileHelper, $error, ignoredErrorPattern: $ignore['message'] ?? null, ignoredErrorMessage: $ignore['rawMessage'] ?? null, identifier: $ignore['identifier'] ?? null, path: $ignore['path']); if ($shouldBeIgnored) { if (isset($ignore['count'])) { - $realCount = $unmatchedIgnoredErrors[$i]['realCount'] ?? 0; - $realCount++; - $unmatchedIgnoredErrors[$i]['realCount'] = $realCount; + $realCount = ($realCounts[$i] ?? 0) + 1; + $realCounts[$i] = $realCount; - if (!isset($unmatchedIgnoredErrors[$i]['file'])) { - $unmatchedIgnoredErrors[$i]['file'] = $error->getFile(); - $unmatchedIgnoredErrors[$i]['line'] = $error->getLine(); + if (!isset($matchedAt[$i])) { + $matchedAt[$i] = ['file' => $error->getFile(), 'line' => $error->getLine()]; } if ($realCount > $ignore['count']) { @@ -171,48 +201,59 @@ public function process( $errors = array_values($errors); - foreach ($unmatchedIgnoredErrors as $unmatchedIgnoredError) { - if (!isset($unmatchedIgnoredError['count']) || !isset($unmatchedIgnoredError['realCount'])) { + foreach ($unmatchedIgnoredErrors as $i => $unmatchedIgnoredError) { + if (!is_array($unmatchedIgnoredError) || !isset($unmatchedIgnoredError['count']) || !isset($realCounts[$i])) { continue; } - if ($unmatchedIgnoredError['realCount'] <= $unmatchedIgnoredError['count']) { + $realCount = $realCounts[$i]; + if ($realCount <= $unmatchedIgnoredError['count']) { continue; } + $matchedFile = $matchedAt[$i]['file'] ?? null; + $matchedLine = $matchedAt[$i]['line'] ?? null; + $errors[] = (new Error(sprintf( '%s %s is expected to occur %d %s, but occurred %d %s.', IgnoredError::getIgnoredErrorLabel($unmatchedIgnoredError), IgnoredError::stringifyPattern($unmatchedIgnoredError), $unmatchedIgnoredError['count'], $unmatchedIgnoredError['count'] === 1 ? 'time' : 'times', - $unmatchedIgnoredError['realCount'], - $unmatchedIgnoredError['realCount'] === 1 ? 'time' : 'times', - ), $unmatchedIgnoredError['file'], $unmatchedIgnoredError['line'], false))->withIdentifier('ignore.count'); + $realCount, + $realCount === 1 ? 'time' : 'times', + ), $matchedFile ?? '', $matchedLine, false))->withIdentifier('ignore.count'); } $analysedFilesKeys = array_fill_keys($analysedFiles, true); if (!$hasInternalErrors) { - foreach ($unmatchedIgnoredErrors as $unmatchedIgnoredError) { - $reportUnmatched = $unmatchedIgnoredError['reportUnmatched'] ?? $this->reportUnmatchedIgnoredErrors; + foreach ($unmatchedIgnoredErrors as $i => $unmatchedIgnoredError) { + $reportUnmatched = is_array($unmatchedIgnoredError) + ? ($unmatchedIgnoredError['reportUnmatched'] ?? $this->reportUnmatchedIgnoredErrors) + : $this->reportUnmatchedIgnoredErrors; if ($reportUnmatched === false) { continue; } + $realCount = $realCounts[$i] ?? null; if ( - isset($unmatchedIgnoredError['count'], $unmatchedIgnoredError['realCount']) + isset($unmatchedIgnoredError['count']) + && $realCount !== null && (isset($unmatchedIgnoredError['realPath']) || !$onlyFiles) ) { - if ($unmatchedIgnoredError['realCount'] < $unmatchedIgnoredError['count']) { + if ($realCount < $unmatchedIgnoredError['count']) { + $matchedFile = $matchedAt[$i]['file'] ?? null; + $matchedLine = $matchedAt[$i]['line'] ?? null; + // $realCount is at least 1 (it was incremented in the closure) + // and strictly less than count, so count is always >= 2. $errors[] = (new Error(sprintf( - '%s %s is expected to occur %d %s, but occurred only %d %s.', + '%s %s is expected to occur %d times, but occurred only %d %s.', IgnoredError::getIgnoredErrorLabel($unmatchedIgnoredError), IgnoredError::stringifyPattern($unmatchedIgnoredError), $unmatchedIgnoredError['count'], - $unmatchedIgnoredError['count'] === 1 ? 'time' : 'times', - $unmatchedIgnoredError['realCount'], - $unmatchedIgnoredError['realCount'] === 1 ? 'time' : 'times', - ), $unmatchedIgnoredError['file'], $unmatchedIgnoredError['line'], false))->withIdentifier('ignore.count'); + $realCount, + $realCount === 1 ? 'time' : 'times', + ), $matchedFile ?? '', $matchedLine, false))->withIdentifier('ignore.count'); } } elseif (isset($unmatchedIgnoredError['realPath'])) { if (!array_key_exists($unmatchedIgnoredError['realPath'], $analysedFilesKeys)) { diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index ef55f3683fc..4e1d044454b 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -23,7 +23,9 @@ use PhpParser\Node\Stmt\Function_; use PhpParser\NodeFinder; use PHPStan\Analyser\Traverser\TransformStaticTypeTraverser; +use PHPStan\Collectors\Collector; use PHPStan\DependencyInjection\Container; +use PHPStan\Node\EmitCollectedDataNode; use PHPStan\Node\Expr\AlwaysRememberedExpr; use PHPStan\Node\Expr\GetIterableKeyTypeExpr; use PHPStan\Node\Expr\IntertwinedVariableByReferenceWithExpr; @@ -134,7 +136,7 @@ use const PHP_INT_MIN; use const PHP_VERSION_ID; -class MutatingScope implements Scope, NodeCallbackInvoker +class MutatingScope implements Scope, NodeCallbackInvoker, CollectedDataEmitter { public const KEEP_VOID_ATTRIBUTE_NAME = 'keepVoid'; @@ -145,10 +147,10 @@ class MutatingScope implements Scope, NodeCallbackInvoker /** @var Type[] */ private array $resolvedTypes = []; - /** @var array */ + /** @var array */ private array $truthyScopes = []; - /** @var array */ + /** @var array */ private array $falseyScopes = []; private ?self $fiberScope = null; @@ -2663,6 +2665,26 @@ public function assignVariable(string $variableName, Type $type, Type $nativeTyp continue; } + // When the byref's dim is non-constant AND not enumerable as a + // finite set of scalars (e.g. general `int` or `mixed`), the just- + // performed write to $array might or might not have hit the byref's + // slot. Union the new $array[dim] read with the byref's previous + // type and the pre-write $array[dim] so values that could still be + // at the slot (unmodified or shadowed by an explicit-key overwrite) + // survive. For finitely-enumerable dims (e.g. `bool`, `int<0, 5>`) + // the array literal builder enumerates all possibilities, so the + // new $array[dim] read already covers every reachable slot. + $unionWithOld = false; + if ($assignedExpr instanceof Expr\ArrayDimFetch && $assignedExpr->dim !== null) { + $dimType = $scope->getType($assignedExpr->dim); + if (count($dimType->getConstantScalarValues()) !== 1 && count($dimType->getFiniteTypes()) === 0) { + $unionWithOld = true; + } + } + + $assignedType = $scope->getType($assignedExpr); + $assignedNativeType = $scope->getNativeType($assignedExpr); + $has = $scope->hasExpressionType($expressionType->getExpr()->getExpr()); if ( $expressionType->getExpr()->getExpr() instanceof Variable @@ -2673,10 +2695,23 @@ public function assignVariable(string $variableName, Type $type, Type $nativeTyp if (in_array($targetVarName, $intertwinedPropagatedFrom, true)) { continue; } + if ($unionWithOld) { + $targetVarNode = new Variable($targetVarName); + $assignedType = TypeCombinator::union( + $assignedType, + $this->getType($assignedExpr), + $scope->getType($targetVarNode), + ); + $assignedNativeType = TypeCombinator::union( + $assignedNativeType, + $this->getNativeType($assignedExpr), + $scope->getNativeType($targetVarNode), + ); + } $scope = $scope->assignVariable( $targetVarName, - $scope->getType($expressionType->getExpr()->getAssignedExpr()), - $scope->getNativeType($expressionType->getExpr()->getAssignedExpr()), + $assignedType, + $assignedNativeType, $has, array_merge($intertwinedPropagatedFrom, [$variableName]), ); @@ -2687,8 +2722,8 @@ public function assignVariable(string $variableName, Type $type, Type $nativeTyp } $scope = $scope->assignExpression( $expressionType->getExpr()->getExpr(), - $scope->getType($expressionType->getExpr()->getAssignedExpr()), - $scope->getNativeType($expressionType->getExpr()->getAssignedExpr()), + $assignedType, + $assignedNativeType, ); } } @@ -3239,6 +3274,9 @@ public function filterByFalseyValue(Expr $expr): self return $scope; } + /** + * @return static + */ public function filterBySpecifiedTypes(SpecifiedTypes $specifiedTypes): self { $typeSpecifications = []; @@ -3386,6 +3424,7 @@ public function filterBySpecifiedTypes(SpecifiedTypes $specifiedTypes): self } } + /** @var static */ return $scope->scopeFactory->create( $scope->context, $scope->isDeclareStrictTypes(), @@ -4236,9 +4275,41 @@ private function generalizeType(Type $a, Type $b, int $depth): Type $resultTypes[] = $resultArrayBuilder->getArray(); } else { + // Both inputs are sealed constant array shapes — their key + // sets are finite by construction. On the fall-through + // ArrayType path, recursing into `generalizeType` would + // widen e.g. `0|1` to `int<0, max>` — for both the keys and + // the values — losing the loop's per-iteration precision. + // Keep the literal union instead so the loop's bounds stay + // visible. (Scoped to sealed shapes so the general + // `generalize()` widening contract for legacy arrays — see + // ScopeTest::testGeneralize — is unaffected.) + $bothSealed = true; + foreach ([...$constantArrays['a'], ...$constantArrays['b']] as $constantArrayCheck) { + foreach ($constantArrayCheck->getConstantArrays() as $constantArrayInstance) { + if (!$constantArrayInstance->isSealed()->yes()) { + $bothSealed = false; + break 2; + } + } + } + if ($bothSealed) { + $resultKeyType = TypeCombinator::union($constantArraysA->getIterableKeyType(), $constantArraysB->getIterableKeyType()); + $resultValueType = TypeCombinator::union($constantArraysA->getIterableValueType(), $constantArraysB->getIterableValueType()); + if ($resultValueType->isOversizedArray()->yes()) { + // The literal value union outgrew the shape limit (a + // deeply/widely nested value): fall back to generalizing + // it into a bounded range-keyed array rather than + // keeping an oversized literal shape. + $resultValueType = TypeCombinator::union($this->generalizeType($constantArraysA->getIterableValueType(), $constantArraysB->getIterableValueType(), $depth + 1)); + } + } else { + $resultKeyType = TypeCombinator::union($this->generalizeType($constantArraysA->getIterableKeyType(), $constantArraysB->getIterableKeyType(), $depth + 1)); + $resultValueType = TypeCombinator::union($this->generalizeType($constantArraysA->getIterableValueType(), $constantArraysB->getIterableValueType(), $depth + 1)); + } $resultType = new ArrayType( - TypeCombinator::union($this->generalizeType($constantArraysA->getIterableKeyType(), $constantArraysB->getIterableKeyType(), $depth + 1)), - TypeCombinator::union($this->generalizeType($constantArraysA->getIterableValueType(), $constantArraysB->getIterableValueType(), $depth + 1)), + $resultKeyType, + $resultValueType, ); $accessories = []; if ( @@ -4881,4 +4952,20 @@ public function invokeNodeCallback(Node $node): void $nodeCallback($node, $this); } + /** + * @template TNodeType of Node + * @template TValue + * @param class-string> $collectorType + * @param TValue $data + */ + public function emitCollectedData(string $collectorType, mixed $data): void + { + $nodeCallback = $this->nodeCallback; + if ($nodeCallback === null) { + throw new ShouldNotHappenException('Node callback is not present in this scope'); + } + + $nodeCallback(new EmitCollectedDataNode($collectorType, $data), $this); + } + } diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index a52b1c94f2d..d8cca60376a 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -4108,8 +4108,13 @@ private function tryProcessUnrolledConstantArrayForeach( } $totalKeys = 0; + $hasUnsealed = false; foreach ($constantArrays as $constantArray) { $totalKeys += count($constantArray->getKeyTypes()); + if (!$constantArray->isUnsealed()->yes()) { + continue; + } + $hasUnsealed = true; } if ($totalKeys === 0 || $totalKeys > self::FOREACH_UNROLL_LIMIT) { return null; @@ -4242,6 +4247,40 @@ private function tryProcessUnrolledConstantArrayForeach( $endScope = $endScope->mergeWith($breakScope); } + // Unsealed shapes describe zero-or-more additional entries beyond the + // explicit keys. Run the scope-generalizing loop on top of the + // unrolled explicit iterations so body-scope variables (e.g. counters) + // account for the extra iterations while keeping the lower bound + // established by the non-optional explicit keys. + if ($hasUnsealed) { + $loopScope = $endScope; + $count = 0; + do { + $prevLoopScope = $loopScope; + $iterStorage = $originalStorage->duplicate(); + $iterBodyScope = $loopScope->mergeWith($endScope); + $iterBodyScope = $this->enterForeach($iterBodyScope, $iterStorage, $originalScope, $stmt, new NoopNodeCallback()); + $iterBodyScopeResult = $this->processStmtNodesInternal($stmt, $stmt->stmts, $iterBodyScope, $iterStorage, new NoopNodeCallback(), $context->enterDeep())->filterOutLoopExitPoints(); + $loopScope = $iterBodyScopeResult->getScope(); + foreach ($iterBodyScopeResult->getExitPointsByType(Continue_::class) as $continueExitPoint) { + $loopScope = $loopScope->mergeWith($continueExitPoint->getScope()); + } + foreach ($iterBodyScopeResult->getExitPointsByType(Break_::class) as $breakExitPoint) { + $endScope = $endScope->mergeWith($breakExitPoint->getScope()); + } + $bodyScope = $bodyScope->mergeWith($loopScope); + if ($loopScope->equals($prevLoopScope)) { + break; + } + if ($count >= self::GENERALIZE_AFTER_ITERATION) { + $loopScope = $prevLoopScope->generalizeWith($loopScope); + } + $count++; + } while ($count < self::LOOP_SCOPE_ITERATIONS); + + $endScope = $endScope->mergeWith($loopScope); + } + return ['bodyScope' => $bodyScope, 'endScope' => $endScope]; } diff --git a/src/Analyser/RuleErrorTransformer.php b/src/Analyser/RuleErrorTransformer.php index 12ad5043201..4213d4c6314 100644 --- a/src/Analyser/RuleErrorTransformer.php +++ b/src/Analyser/RuleErrorTransformer.php @@ -22,6 +22,7 @@ use PHPStan\Rules\MetadataRuleError; use PHPStan\Rules\NonIgnorableRuleError; use PHPStan\Rules\RuleError; +use PHPStan\Rules\RuleErrors\TransformedRuleError; use PHPStan\Rules\TipRuleError; use PHPStan\ShouldNotHappenException; use SebastianBergmann\Diff\Differ; @@ -55,6 +56,10 @@ public function transform( Node $node, ): Error { + if ($ruleError instanceof TransformedRuleError) { + return $ruleError->getError(); + } + $line = $node->getStartLine(); $canBeIgnored = true; $fileName = $scope->getFileDescription(); diff --git a/src/Analyser/Scope.php b/src/Analyser/Scope.php index 6f616acd802..8e78a30d4c2 100644 --- a/src/Analyser/Scope.php +++ b/src/Analyser/Scope.php @@ -307,6 +307,8 @@ public function isUndefinedExpressionAllowed(Expr $expr): bool; * if-branch of `if ($x instanceof Foo)`. * * Uses the TypeSpecifier internally to determine type narrowing. + * + * @return static */ public function filterByTruthyValue(Expr $expr): self; @@ -316,6 +318,8 @@ public function filterByTruthyValue(Expr $expr): self; * The opposite of filterByTruthyValue(). Given `$x instanceof Foo`, returns * a scope where $x is known NOT to be of type Foo. This is the scope used * in the else-branch of `if ($x instanceof Foo)`. + * + * @return static */ public function filterByFalseyValue(Expr $expr): self; diff --git a/src/Analyser/TypeSpecifier.php b/src/Analyser/TypeSpecifier.php index a945627ef64..8127523a504 100644 --- a/src/Analyser/TypeSpecifier.php +++ b/src/Analyser/TypeSpecifier.php @@ -1429,27 +1429,27 @@ private function specifyTypesForCountFuncCall( continue; } - // `truncateListToSize` rebuilds the inner array as a list shape - // — that's only sound when the *outer* type is definitely a - // list. The inner array alone may have `isList()` answer `Maybe` - // (e.g. `ArrayType, T>` inside a - // `non-empty-list` intersection), so the gate has to live - // here, not on the per-array method. $resultTypes[] = $isList->yes() ? $arrayType->truncateListToSize($sizeType) : TypeCombinator::intersect($arrayType, new NonEmptyArrayType()); } if ($context->truthy() && $isConstantArray->yes() && $isList->yes()) { - $hasOptionalKeys = false; + $hasOptionalKeysOrUnsealed = false; foreach ($type->getConstantArrays() as $arrayType) { - if ($arrayType->getOptionalKeys() !== []) { - $hasOptionalKeys = true; + if ($arrayType->getOptionalKeys() !== [] || $arrayType->isUnsealed()->yes()) { + // Unsealed CATs can't be narrowed via the + // `HasOffsetValueType`-only shortcut below — the + // intersection of an unsealed shape with a single-slot + // constraint produces `NeverType`. Fall through to + // the full builder-based narrowing, which carries the + // unsealed slot via the loop above. + $hasOptionalKeysOrUnsealed = true; break; } } - if (!$hasOptionalKeys) { + if (!$hasOptionalKeysOrUnsealed) { $argExpr = $countFuncCall->getArgs()[0]->value; $argExprString = $this->exprPrinter->printExpr($argExpr); diff --git a/src/DependencyInjection/ContainerFactory.php b/src/DependencyInjection/ContainerFactory.php index cac88d0e39b..6e8f0a385e7 100644 --- a/src/DependencyInjection/ContainerFactory.php +++ b/src/DependencyInjection/ContainerFactory.php @@ -202,6 +202,7 @@ public static function postInitializeContainer(Container $container): void $container->getService('typeSpecifier'); BleedingEdgeToggle::setBleedingEdge($container->getParameter('featureToggles')['bleedingEdge']); + ReportUnsafeArrayStringKeyCastingToggle::setLevel($container->getParameter('reportUnsafeArrayStringKeyCasting')); } public function getCurrentWorkingDirectory(): string diff --git a/src/DependencyInjection/ReportUnsafeArrayStringKeyCastingToggle.php b/src/DependencyInjection/ReportUnsafeArrayStringKeyCastingToggle.php new file mode 100644 index 00000000000..e2f13563fec --- /dev/null +++ b/src/DependencyInjection/ReportUnsafeArrayStringKeyCastingToggle.php @@ -0,0 +1,34 @@ +getSubNodeNames() as $subNodeName) { + foreach (['attrGroups', 'flags', 'extends', 'implements', 'stmts'] as $subNodeName) { $subNodes[$subNodeName] = $node->$subNodeName; } diff --git a/src/Node/EmitCollectedDataNode.php b/src/Node/EmitCollectedDataNode.php new file mode 100644 index 00000000000..b8b235adefd --- /dev/null +++ b/src/Node/EmitCollectedDataNode.php @@ -0,0 +1,60 @@ +> $collectorType + * @param TValue $data + */ + public function __construct( + private string $collectorType, + private mixed $data, + ) + { + parent::__construct([]); + } + + /** + * @return class-string> + */ + public function getCollectorType(): string + { + return $this->collectorType; + } + + /** + * @return TValue + */ + public function getData(): mixed + { + return $this->data; + } + + #[Override] + public function getType(): string + { + return 'PHPStan_Node_EmitCollectedDataNode'; + } + + /** + * @return list + */ + #[Override] + public function getSubNodeNames(): array + { + return []; + } + +} diff --git a/src/PhpDoc/TypeNodeResolver.php b/src/PhpDoc/TypeNodeResolver.php index bd243ef7d26..a704d15a289 100644 --- a/src/PhpDoc/TypeNodeResolver.php +++ b/src/PhpDoc/TypeNodeResolver.php @@ -10,7 +10,9 @@ use PhpParser\Node\Name; use PHPStan\Analyser\ConstantResolver; use PHPStan\Analyser\NameScope; +use PHPStan\DependencyInjection\AutowiredParameter; use PHPStan\DependencyInjection\AutowiredService; +use PHPStan\DependencyInjection\ReportUnsafeArrayStringKeyCastingToggle; use PHPStan\PhpDoc\Tag\TemplateTag; use PHPStan\PhpDocParser\Ast\ConstExpr\ConstExprArrayNode; use PHPStan\PhpDocParser\Ast\ConstExpr\ConstExprFalseNode; @@ -47,6 +49,7 @@ use PHPStan\ShouldNotHappenException; use PHPStan\TrinaryLogic; use PHPStan\Type\Accessory\AccessoryArrayListType; +use PHPStan\Type\Accessory\AccessoryDecimalIntegerStringType; use PHPStan\Type\Accessory\AccessoryLiteralStringType; use PHPStan\Type\Accessory\AccessoryLowercaseStringType; use PHPStan\Type\Accessory\AccessoryNonEmptyStringType; @@ -106,6 +109,7 @@ use PHPStan\Type\TypeAliasResolver; use PHPStan\Type\TypeAliasResolverProvider; use PHPStan\Type\TypeCombinator; +use PHPStan\Type\TypeTraverser; use PHPStan\Type\TypeUtils; use PHPStan\Type\UnionType; use PHPStan\Type\ValueOfType; @@ -128,6 +132,9 @@ use function strtolower; use function substr; +/** + * @phpstan-import-type Level from ReportUnsafeArrayStringKeyCastingToggle as ReportUnsafeArrayStringKeyCastingLevel + */ #[AutowiredService] final class TypeNodeResolver { @@ -135,12 +142,17 @@ final class TypeNodeResolver /** @var array */ private array $genericTypeResolvingStack = []; + /** + * @param ReportUnsafeArrayStringKeyCastingLevel $reportUnsafeArrayStringKeyCasting + */ public function __construct( private TypeNodeResolverExtensionRegistryProvider $extensionRegistryProvider, private ReflectionProvider\ReflectionProviderProvider $reflectionProviderProvider, private TypeAliasResolverProvider $typeAliasResolverProvider, private ConstantResolver $constantResolver, private InitializerExprTypeResolver $initializerExprTypeResolver, + #[AutowiredParameter] + private ?string $reportUnsafeArrayStringKeyCasting, ) { } @@ -236,6 +248,15 @@ private function resolveIdentifierTypeNode(IdentifierTypeNode $typeNode, NameSco case 'string': return new StringType(); + case 'decimal-int-string': + return new IntersectionType([new StringType(), new AccessoryDecimalIntegerStringType()]); + + case 'non-decimal-int-string': + return new IntersectionType([ + new StringType(), + new AccessoryDecimalIntegerStringType(inverse: true), + ]); + case 'lowercase-string': return new IntersectionType([new StringType(), new AccessoryLowercaseStringType()]); @@ -657,7 +678,7 @@ private function resolveConditionalTypeForParameterNode(ConditionalTypeForParame private function resolveArrayTypeNode(ArrayTypeNode $typeNode, NameScope $nameScope): Type { $itemType = $this->resolve($typeNode->type, $nameScope); - return new ArrayType(new BenevolentUnionType([new IntegerType(), new StringType()]), $itemType); + return new ArrayType((new BenevolentUnionType([new IntegerType(), new StringType()]))->toArrayKey(), $itemType); } private function resolveGenericTypeNode(GenericTypeNode $typeNode, NameScope $nameScope): Type @@ -682,12 +703,9 @@ static function (string $variance): TemplateTypeVariance { if (in_array($mainTypeName, ['array', 'non-empty-array'], true)) { if (count($genericTypes) === 1) { // array - $arrayType = new ArrayType(new BenevolentUnionType([new IntegerType(), new StringType()]), $genericTypes[0]); + $arrayType = new ArrayType((new BenevolentUnionType([new IntegerType(), new StringType()]))->toArrayKey(), $genericTypes[0]); } elseif (count($genericTypes) === 2) { // array - $keyType = TypeCombinator::intersect($genericTypes[0]->toArrayKey(), new UnionType([ - new IntegerType(), - new StringType(), - ]))->toArrayKey(); + $keyType = $this->transformUnsafeArrayKey($genericTypes[0]); $finiteTypes = $keyType->getFiniteTypes(); if ( count($finiteTypes) === 1 @@ -967,6 +985,30 @@ static function (string $variance): TemplateTypeVariance { return new ErrorType(); } + private function transformUnsafeArrayKey(Type $keyType): Type + { + if ($this->reportUnsafeArrayStringKeyCasting === ReportUnsafeArrayStringKeyCastingToggle::PREVENT) { + if (!$keyType->isSuperTypeOf(new IntegerType())->yes()) { + $keyType = TypeTraverser::map($keyType, static function (Type $type, callable $traverse) { + if ($type instanceof UnionType || $type instanceof IntersectionType) { + return $traverse($type); + } + + if ($type instanceof StringType) { + return TypeCombinator::intersect($type, new AccessoryDecimalIntegerStringType(inverse: true)); + } + + return $type; + }); + } + } + + return TypeCombinator::intersect($keyType->toArrayKey(), new UnionType([ + new IntegerType(), + new StringType(), + ]))->toArrayKey(); + } + private function resolveCallableTypeNode(CallableTypeNode $typeNode, NameScope $nameScope): Type { $templateTags = []; @@ -1066,13 +1108,48 @@ private function resolveArrayShapeNode(ArrayShapeNode $typeNode, NameScope $name $builder->setOffsetValueType($offsetType, $this->resolve($itemNode->valueType, $nameScope), $itemNode->optional); } + $isList = in_array($typeNode->kind, [ + ArrayShapeNode::KIND_LIST, + ArrayShapeNode::KIND_NON_EMPTY_LIST, + ], true); + + if (!$typeNode->sealed) { + if ($typeNode->unsealedType === null) { + if ($isList) { + $unsealedKeyType = IntegerRangeType::createAllGreaterThanOrEqualTo(0); + } else { + $unsealedKeyType = (new BenevolentUnionType([new IntegerType(), new StringType()]))->toArrayKey(); + } + $builder->makeUnsealed( + $unsealedKeyType, + new MixedType(), + ); + } else { + if ($typeNode->unsealedType->keyType === null) { + if ($isList) { + $unsealedKeyType = IntegerRangeType::createAllGreaterThanOrEqualTo(0); + } else { + $unsealedKeyType = (new BenevolentUnionType([new IntegerType(), new StringType()]))->toArrayKey(); + } + } else { + $unsealedKeyType = $this->transformUnsafeArrayKey($this->resolve($typeNode->unsealedType->keyType, $nameScope)); + } + $unsealedKeyFiniteTypes = $unsealedKeyType->getFiniteTypes(); + $unsealedValueType = $this->resolve($typeNode->unsealedType->valueType, $nameScope); + if (count($unsealedKeyFiniteTypes) > 0) { + foreach ($unsealedKeyFiniteTypes as $unsealedKeyFiniteType) { + $builder->setOffsetValueType($unsealedKeyFiniteType, $unsealedValueType, true); + } + } else { + $builder->makeUnsealed($unsealedKeyType, $unsealedValueType); + } + } + } + $arrayType = $builder->getArray(); $accessories = []; - if (in_array($typeNode->kind, [ - ArrayShapeNode::KIND_LIST, - ArrayShapeNode::KIND_NON_EMPTY_LIST, - ], true)) { + if ($isList) { $accessories[] = new AccessoryArrayListType(); } diff --git a/src/Reflection/AllowedConstantsResult.php b/src/Reflection/AllowedConstantsResult.php new file mode 100644 index 00000000000..8dd9f315a4d --- /dev/null +++ b/src/Reflection/AllowedConstantsResult.php @@ -0,0 +1,55 @@ + $disallowedConstants + * @param list> $violatedExclusiveGroups + */ + public function __construct( + private array $disallowedConstants, + private array $violatedExclusiveGroups, + private bool $bitmaskNotAllowed, + ) + { + } + + public function isOk(): bool + { + return $this->disallowedConstants === [] && $this->violatedExclusiveGroups === [] && !$this->bitmaskNotAllowed; + } + + public function isBitmaskNotAllowed(): bool + { + return $this->bitmaskNotAllowed; + } + + /** + * @return list + */ + public function getDisallowedConstants(): array + { + return $this->disallowedConstants; + } + + /** + * @return list> + */ + public function getViolatedExclusiveGroups(): array + { + return $this->violatedExclusiveGroups; + } + +} diff --git a/src/Reflection/Annotations/AnnotationsMethodParameterReflection.php b/src/Reflection/Annotations/AnnotationsMethodParameterReflection.php index b01a6db6ff8..93941cf0698 100644 --- a/src/Reflection/Annotations/AnnotationsMethodParameterReflection.php +++ b/src/Reflection/Annotations/AnnotationsMethodParameterReflection.php @@ -2,7 +2,9 @@ namespace PHPStan\Reflection\Annotations; +use PHPStan\Reflection\AllowedConstantsResult; use PHPStan\Reflection\ExtendedParameterReflection; +use PHPStan\Reflection\ParameterAllowedConstants; use PHPStan\Reflection\PassedByReference; use PHPStan\TrinaryLogic; use PHPStan\Type\MixedType; @@ -80,4 +82,14 @@ public function getAttributes(): array return []; } + public function getAllowedConstants(): ?ParameterAllowedConstants + { + return null; + } + + public function checkAllowedConstants(array $constants): AllowedConstantsResult + { + return new AllowedConstantsResult([], [], false); + } + } diff --git a/src/Reflection/BetterReflection/BetterReflectionProvider.php b/src/Reflection/BetterReflection/BetterReflectionProvider.php index b514bfa1a93..c7d4bac0df4 100644 --- a/src/Reflection/BetterReflection/BetterReflectionProvider.php +++ b/src/Reflection/BetterReflection/BetterReflectionProvider.php @@ -444,6 +444,7 @@ public function getConstant(Node\Name $nameNode, ?NamespaceAnswerer $namespaceAn array_map(static fn (BetterReflectionAttribute $betterReflectionAttribute) => ReflectionAttributeFactory::create($betterReflectionAttribute), $constantReflection->getAttributes()), InitializerExprContext::fromGlobalConstant($constantReflection), ), + $constantReflection->isInternal(), ); } diff --git a/src/Reflection/Constant/RuntimeConstantReflection.php b/src/Reflection/Constant/RuntimeConstantReflection.php index 0cbe1eb1db2..1f643c28a33 100644 --- a/src/Reflection/Constant/RuntimeConstantReflection.php +++ b/src/Reflection/Constant/RuntimeConstantReflection.php @@ -20,6 +20,7 @@ public function __construct( private TrinaryLogic $isDeprecated, private ?string $deprecatedDescription, private array $attributes, + private bool $internal, ) { } @@ -29,6 +30,16 @@ public function getName(): string return $this->name; } + public function describe(): string + { + return $this->name; + } + + public function isBuiltin(): TrinaryLogic + { + return TrinaryLogic::createFromBoolean($this->internal); + } + public function getValueType(): Type { return $this->valueType; diff --git a/src/Reflection/ConstantReflection.php b/src/Reflection/ConstantReflection.php index 01aea117ea2..34fceef8467 100644 --- a/src/Reflection/ConstantReflection.php +++ b/src/Reflection/ConstantReflection.php @@ -20,6 +20,10 @@ interface ConstantReflection public function getName(): string; + public function describe(): string; + + public function isBuiltin(): TrinaryLogic; + public function getValueType(): Type; public function isDeprecated(): TrinaryLogic; diff --git a/src/Reflection/Dummy/DummyClassConstantReflection.php b/src/Reflection/Dummy/DummyClassConstantReflection.php index 768c5bdf275..8436abf76c0 100644 --- a/src/Reflection/Dummy/DummyClassConstantReflection.php +++ b/src/Reflection/Dummy/DummyClassConstantReflection.php @@ -12,6 +12,7 @@ use PHPStan\Type\MixedType; use PHPStan\Type\Type; use stdClass; +use function sprintf; final class DummyClassConstantReflection implements ClassConstantReflection { @@ -62,6 +63,16 @@ public function getName(): string return $this->name; } + public function describe(): string + { + return sprintf('%s::%s', $this->getDeclaringClass()->getDisplayName(), $this->name); + } + + public function isBuiltin(): TrinaryLogic + { + return TrinaryLogic::createFromBoolean($this->getDeclaringClass()->isBuiltin()); + } + public function getValueType(): Type { return new MixedType(); diff --git a/src/Reflection/ExtendedParameterReflection.php b/src/Reflection/ExtendedParameterReflection.php index 890b0493469..1ccd1d4b935 100644 --- a/src/Reflection/ExtendedParameterReflection.php +++ b/src/Reflection/ExtendedParameterReflection.php @@ -29,4 +29,11 @@ public function getClosureThisType(): ?Type; */ public function getAttributes(): array; + public function getAllowedConstants(): ?ParameterAllowedConstants; + + /** + * @param list $constants Global and/or class constant reflections + */ + public function checkAllowedConstants(array $constants): AllowedConstantsResult; + } diff --git a/src/Reflection/GenericParametersAcceptorResolver.php b/src/Reflection/GenericParametersAcceptorResolver.php index c72afeedbb5..1ceb4d66669 100644 --- a/src/Reflection/GenericParametersAcceptorResolver.php +++ b/src/Reflection/GenericParametersAcceptorResolver.php @@ -108,6 +108,7 @@ public static function resolve(array $argTypes, ParametersAcceptor $parametersAc TrinaryLogic::createMaybe(), null, [], + null, ), $parameters), $parametersAcceptor->isVariadic(), $returnType, diff --git a/src/Reflection/Native/ExtendedNativeParameterReflection.php b/src/Reflection/Native/ExtendedNativeParameterReflection.php index 00e2ea1a99e..5539d9132a1 100644 --- a/src/Reflection/Native/ExtendedNativeParameterReflection.php +++ b/src/Reflection/Native/ExtendedNativeParameterReflection.php @@ -2,8 +2,10 @@ namespace PHPStan\Reflection\Native; +use PHPStan\Reflection\AllowedConstantsResult; use PHPStan\Reflection\AttributeReflection; use PHPStan\Reflection\ExtendedParameterReflection; +use PHPStan\Reflection\ParameterAllowedConstants; use PHPStan\Reflection\PassedByReference; use PHPStan\TrinaryLogic; use PHPStan\Type\MixedType; @@ -28,6 +30,7 @@ public function __construct( private TrinaryLogic $immediatelyInvokedCallable, private ?Type $closureThisType, private array $attributes, + private ?ParameterAllowedConstants $allowedConstants, ) { } @@ -97,4 +100,18 @@ public function getAttributes(): array return $this->attributes; } + public function getAllowedConstants(): ?ParameterAllowedConstants + { + return $this->allowedConstants; + } + + public function checkAllowedConstants(array $constants): AllowedConstantsResult + { + if ($this->allowedConstants === null) { + return new AllowedConstantsResult([], [], false); + } + + return $this->allowedConstants->check($constants); + } + } diff --git a/src/Reflection/ParameterAllowedConstants.php b/src/Reflection/ParameterAllowedConstants.php new file mode 100644 index 00000000000..c784e3c29af --- /dev/null +++ b/src/Reflection/ParameterAllowedConstants.php @@ -0,0 +1,98 @@ + $constants + * @param list> $exclusiveGroups + */ + public function __construct( + private string $type, + private array $constants, + private array $exclusiveGroups, + ) + { + } + + public function isBitmask(): bool + { + return $this->type === 'bitmask'; + } + + /** + * @return list> + */ + public function getExclusiveGroups(): array + { + return $this->exclusiveGroups; + } + + /** + * @param list $constants + */ + public function check(array $constants): AllowedConstantsResult + { + $bitmaskNotAllowed = !$this->isBitmask() && count($constants) > 1; + + $disallowed = []; + $names = []; + + foreach ($constants as $constant) { + if ($constant->isBuiltin()->no()) { + continue; + } + + $name = $constant->describe(); + $names[] = $name; + + if (in_array($name, $this->constants, true)) { + continue; + } + + $disallowed[] = $constant; + } + + $violated = []; + if ($this->isBitmask()) { + foreach ($this->exclusiveGroups as $group) { + $matched = []; + foreach ($names as $name) { + if (!in_array($name, $group, true)) { + continue; + } + + $matched[] = $name; + } + + if (count($matched) < 2) { + continue; + } + + $violated[] = $matched; + } + } + + return new AllowedConstantsResult($disallowed, $violated, $bitmaskNotAllowed); + } + +} diff --git a/src/Reflection/ParameterAllowedConstantsMapProvider.php b/src/Reflection/ParameterAllowedConstantsMapProvider.php new file mode 100644 index 00000000000..22c0a9fd31e --- /dev/null +++ b/src/Reflection/ParameterAllowedConstantsMapProvider.php @@ -0,0 +1,50 @@ +, exclusiveGroups?: list>}>>|null */ + private ?array $map = null; + + public function getForFunctionParameter(string $functionName, string $parameterName): ?ParameterAllowedConstants + { + return $this->get($functionName, $parameterName); + } + + public function getForMethodParameter(string $className, string $methodName, string $parameterName): ?ParameterAllowedConstants + { + return $this->get($className . '::' . $methodName, $parameterName); + } + + private function get(string $key, string $parameterName): ?ParameterAllowedConstants + { + $map = $this->getMap(); + + if (!isset($map[$key][$parameterName])) { + return null; + } + + /** @var array{type: 'single'|'bitmask', constants: list, exclusiveGroups?: list>} $config */ + $config = $map[$key][$parameterName]; + + return new ParameterAllowedConstants( + $config['type'], + $config['constants'], + $config['exclusiveGroups'] ?? [], + ); + } + + /** + * @return array, exclusiveGroups?: list>}>> + */ + private function getMap(): array + { + return $this->map ??= require __DIR__ . '/../../resources/constantToFunctionParameterMap.php'; + } + +} diff --git a/src/Reflection/ParametersAcceptorSelector.php b/src/Reflection/ParametersAcceptorSelector.php index 27fb82d0267..611de3ec7b4 100644 --- a/src/Reflection/ParametersAcceptorSelector.php +++ b/src/Reflection/ParametersAcceptorSelector.php @@ -765,6 +765,7 @@ public static function combineAcceptors(array $acceptors): ExtendedParametersAcc $parameter instanceof ExtendedParameterReflection ? $parameter->isImmediatelyInvokedCallable() : TrinaryLogic::createMaybe(), $parameter instanceof ExtendedParameterReflection ? $parameter->getClosureThisType() : null, $parameter instanceof ExtendedParameterReflection ? $parameter->getAttributes() : [], + $parameter instanceof ExtendedParameterReflection ? $parameter->getAllowedConstants() : null, ); continue; } @@ -824,6 +825,7 @@ public static function combineAcceptors(array $acceptors): ExtendedParametersAcc $immediatelyInvokedCallable, $closureThisType, $attributes, + null, ); if ($isVariadic) { @@ -922,6 +924,7 @@ private static function wrapParameter(ParameterReflection $parameter): ExtendedP TrinaryLogic::createMaybe(), null, [], + null, ); } @@ -1249,6 +1252,7 @@ private static function overrideParameterType(ParameterReflection $original, Typ $wrapped->isImmediatelyInvokedCallable(), $wrapped->getClosureThisType(), $wrapped->getAttributes(), + $wrapped->getAllowedConstants(), ); } diff --git a/src/Reflection/Php/ClosureCallMethodReflection.php b/src/Reflection/Php/ClosureCallMethodReflection.php index b15ec9401b9..41c278f08dd 100644 --- a/src/Reflection/Php/ClosureCallMethodReflection.php +++ b/src/Reflection/Php/ClosureCallMethodReflection.php @@ -98,6 +98,7 @@ public function getVariants(): array TrinaryLogic::createMaybe(), null, [], + null, ), $parameters), $this->closureType->isVariadic(), $this->closureType->getReturnType(), diff --git a/src/Reflection/Php/ExitFunctionReflection.php b/src/Reflection/Php/ExitFunctionReflection.php index 731a848fe60..8303f1084f4 100644 --- a/src/Reflection/Php/ExitFunctionReflection.php +++ b/src/Reflection/Php/ExitFunctionReflection.php @@ -59,6 +59,7 @@ public function getVariants(): array TrinaryLogic::createNo(), null, [], + null, ), ], false, diff --git a/src/Reflection/Php/ExtendedDummyParameter.php b/src/Reflection/Php/ExtendedDummyParameter.php index 19a917e0a17..69a19ccbf3a 100644 --- a/src/Reflection/Php/ExtendedDummyParameter.php +++ b/src/Reflection/Php/ExtendedDummyParameter.php @@ -2,8 +2,10 @@ namespace PHPStan\Reflection\Php; +use PHPStan\Reflection\AllowedConstantsResult; use PHPStan\Reflection\AttributeReflection; use PHPStan\Reflection\ExtendedParameterReflection; +use PHPStan\Reflection\ParameterAllowedConstants; use PHPStan\Reflection\PassedByReference; use PHPStan\TrinaryLogic; use PHPStan\Type\MixedType; @@ -28,6 +30,7 @@ public function __construct( private TrinaryLogic $immediatelyInvokedCallable, private ?Type $closureThisType, private array $attributes, + private ?ParameterAllowedConstants $allowedConstants, ) { parent::__construct($name, $type, $optional, $passedByReference, $variadic, $defaultValue); @@ -68,4 +71,18 @@ public function getAttributes(): array return $this->attributes; } + public function getAllowedConstants(): ?ParameterAllowedConstants + { + return $this->allowedConstants; + } + + public function checkAllowedConstants(array $constants): AllowedConstantsResult + { + if ($this->allowedConstants === null) { + return new AllowedConstantsResult([], [], false); + } + + return $this->allowedConstants->check($constants); + } + } diff --git a/src/Reflection/Php/PhpClassReflectionExtension.php b/src/Reflection/Php/PhpClassReflectionExtension.php index c4ad8bfbfad..8d856b59a7d 100644 --- a/src/Reflection/Php/PhpClassReflectionExtension.php +++ b/src/Reflection/Php/PhpClassReflectionExtension.php @@ -31,6 +31,7 @@ use PHPStan\Reflection\MethodReflection; use PHPStan\Reflection\Native\ExtendedNativeParameterReflection; use PHPStan\Reflection\Native\NativeMethodReflection; +use PHPStan\Reflection\ParameterAllowedConstantsMapProvider; use PHPStan\Reflection\ReflectionProvider; use PHPStan\Reflection\SignatureMap\FunctionSignature; use PHPStan\Reflection\SignatureMap\ParameterSignature; @@ -100,6 +101,7 @@ public function __construct( private ReflectionProvider\ReflectionProviderProvider $reflectionProviderProvider, private FileTypeMapper $fileTypeMapper, private AttributeReflectionFactory $attributeReflectionFactory, + private ParameterAllowedConstantsMapProvider $allowedConstantsMapProvider, private bool $inferPrivatePropertyTypeFromConstructor, ) { @@ -730,7 +732,7 @@ private function createMethod( } } } - $variantsByType[$signatureType][] = $this->createNativeMethodVariant($methodSignature, $phpDocParameterTypes, $phpDocReturnType, $phpDocParameterNameMapping, $phpDocParameterOutTypes, $immediatelyInvokedCallableParameters, $closureThisParameters, $phpDocFromStubs, $signatureType !== 'named'); + $variantsByType[$signatureType][] = $this->createNativeMethodVariant($declaringClassName, $methodReflection->getName(), $methodSignature, $phpDocParameterTypes, $phpDocReturnType, $phpDocParameterNameMapping, $phpDocParameterOutTypes, $immediatelyInvokedCallableParameters, $closureThisParameters, $phpDocFromStubs, $signatureType !== 'named'); } } @@ -984,6 +986,8 @@ public function createUserlandMethodReflection(ClassReflection $fileDeclaringCla * @param array $closureThisParameters */ private function createNativeMethodVariant( + string $declaringClassName, + string $methodName, FunctionSignature $methodSignature, array $phpDocParameterTypes, ?Type $phpDocReturnType, @@ -1038,6 +1042,7 @@ private function createNativeMethodVariant( $immediatelyInvoked, $closureThisType, [], + $this->allowedConstantsMapProvider->getForMethodParameter($declaringClassName, $methodName, $parameterSignature->getName()), ); } diff --git a/src/Reflection/Php/PhpFunctionReflection.php b/src/Reflection/Php/PhpFunctionReflection.php index 7dbd7ca3c47..2dcb0c7b870 100644 --- a/src/Reflection/Php/PhpFunctionReflection.php +++ b/src/Reflection/Php/PhpFunctionReflection.php @@ -16,6 +16,7 @@ use PHPStan\Reflection\FunctionReflectionFactory; use PHPStan\Reflection\InitializerExprContext; use PHPStan\Reflection\InitializerExprTypeResolver; +use PHPStan\Reflection\ParameterAllowedConstantsMapProvider; use PHPStan\TrinaryLogic; use PHPStan\Type\Generic\TemplateTypeMap; use PHPStan\Type\MixedType; @@ -44,6 +45,7 @@ public function __construct( private InitializerExprTypeResolver $initializerExprTypeResolver, private ReflectionFunction $reflection, private AttributeReflectionFactory $attributeReflectionFactory, + private ParameterAllowedConstantsMapProvider $allowedConstantsMapProvider, private TemplateTypeMap $templateTypeMap, private array $phpDocParameterTypes, private ?Type $phpDocReturnType, @@ -127,6 +129,7 @@ private function getParameters(): array $immediatelyInvokedCallable, $this->phpDocParameterClosureThisTypes[$reflection->getName()] ?? null, $this->attributeReflectionFactory->fromNativeReflection($reflection->getAttributes(), InitializerExprContext::fromReflectionParameter($reflection)), + $this->allowedConstantsMapProvider->getForFunctionParameter(strtolower($this->reflection->getName()), $reflection->getName()), ); }, $this->reflection->getParameters()); } diff --git a/src/Reflection/Php/PhpMethodReflection.php b/src/Reflection/Php/PhpMethodReflection.php index 34f28635007..d24532340d4 100644 --- a/src/Reflection/Php/PhpMethodReflection.php +++ b/src/Reflection/Php/PhpMethodReflection.php @@ -19,6 +19,7 @@ use PHPStan\Reflection\InitializerExprContext; use PHPStan\Reflection\InitializerExprTypeResolver; use PHPStan\Reflection\MethodPrototypeReflection; +use PHPStan\Reflection\ParameterAllowedConstantsMapProvider; use PHPStan\Reflection\ReflectionProvider; use PHPStan\TrinaryLogic; use PHPStan\Type\ArrayType; @@ -70,6 +71,7 @@ public function __construct( private ReflectionMethod $reflection, private ReflectionProvider $reflectionProvider, private AttributeReflectionFactory $attributeReflectionFactory, + private ParameterAllowedConstantsMapProvider $allowedConstantsMapProvider, private TemplateTypeMap $templateTypeMap, private array $phpDocParameterTypes, private ?Type $phpDocReturnType, @@ -226,6 +228,7 @@ private function getParameters(): array $this->immediatelyInvokedCallableParameters[$reflection->getName()] ?? TrinaryLogic::createMaybe(), $this->phpDocClosureThisTypeParameters[$reflection->getName()] ?? null, $this->attributeReflectionFactory->fromNativeReflection($reflection->getAttributes(), InitializerExprContext::fromReflectionParameter($reflection)), + $this->allowedConstantsMapProvider->getForMethodParameter($this->declaringClass->getName(), $this->reflection->getName(), $reflection->getName()), ), $this->reflection->getParameters()); } @@ -411,6 +414,7 @@ public function changePropertyGetHookPhpDocType(Type $phpDocType): self $this->reflection, $this->reflectionProvider, $this->attributeReflectionFactory, + $this->allowedConstantsMapProvider, $this->templateTypeMap, $this->phpDocParameterTypes, $phpDocType, @@ -444,6 +448,7 @@ public function changePropertySetHookPhpDocType(string $parameterName, Type $php $this->reflection, $this->reflectionProvider, $this->attributeReflectionFactory, + $this->allowedConstantsMapProvider, $this->templateTypeMap, $phpDocParameterTypes, $this->phpDocReturnType, diff --git a/src/Reflection/Php/PhpParameterFromParserNodeReflection.php b/src/Reflection/Php/PhpParameterFromParserNodeReflection.php index f048ea71006..7061d7f63e9 100644 --- a/src/Reflection/Php/PhpParameterFromParserNodeReflection.php +++ b/src/Reflection/Php/PhpParameterFromParserNodeReflection.php @@ -2,8 +2,10 @@ namespace PHPStan\Reflection\Php; +use PHPStan\Reflection\AllowedConstantsResult; use PHPStan\Reflection\AttributeReflection; use PHPStan\Reflection\ExtendedParameterReflection; +use PHPStan\Reflection\ParameterAllowedConstants; use PHPStan\Reflection\PassedByReference; use PHPStan\TrinaryLogic; use PHPStan\Type\MixedType; @@ -113,4 +115,14 @@ public function getAttributes(): array return $this->attributes; } + public function getAllowedConstants(): ?ParameterAllowedConstants + { + return null; + } + + public function checkAllowedConstants(array $constants): AllowedConstantsResult + { + return new AllowedConstantsResult([], [], false); + } + } diff --git a/src/Reflection/Php/PhpParameterReflection.php b/src/Reflection/Php/PhpParameterReflection.php index 8469f7bef44..17b55295159 100644 --- a/src/Reflection/Php/PhpParameterReflection.php +++ b/src/Reflection/Php/PhpParameterReflection.php @@ -3,11 +3,13 @@ namespace PHPStan\Reflection\Php; use PHPStan\BetterReflection\Reflection\Adapter\ReflectionParameter; +use PHPStan\Reflection\AllowedConstantsResult; use PHPStan\Reflection\AttributeReflection; use PHPStan\Reflection\ClassReflection; use PHPStan\Reflection\ExtendedParameterReflection; use PHPStan\Reflection\InitializerExprContext; use PHPStan\Reflection\InitializerExprTypeResolver; +use PHPStan\Reflection\ParameterAllowedConstants; use PHPStan\Reflection\PassedByReference; use PHPStan\TrinaryLogic; use PHPStan\Type\MixedType; @@ -34,6 +36,7 @@ public function __construct( private TrinaryLogic $immediatelyInvokedCallable, private ?Type $closureThisType, private array $attributes, + private ?ParameterAllowedConstants $allowedConstants, ) { } @@ -143,4 +146,18 @@ public function getAttributes(): array return $this->attributes; } + public function getAllowedConstants(): ?ParameterAllowedConstants + { + return $this->allowedConstants; + } + + public function checkAllowedConstants(array $constants): AllowedConstantsResult + { + if ($this->allowedConstants === null) { + return new AllowedConstantsResult([], [], false); + } + + return $this->allowedConstants->check($constants); + } + } diff --git a/src/Reflection/RealClassClassConstantReflection.php b/src/Reflection/RealClassClassConstantReflection.php index d0b69f5eedc..c565feb0811 100644 --- a/src/Reflection/RealClassClassConstantReflection.php +++ b/src/Reflection/RealClassClassConstantReflection.php @@ -9,6 +9,7 @@ use PHPStan\TrinaryLogic; use PHPStan\Type\Type; use PHPStan\Type\TypehintHelper; +use function sprintf; final class RealClassClassConstantReflection implements ClassConstantReflection { @@ -39,6 +40,16 @@ public function getName(): string return $this->reflection->getName(); } + public function describe(): string + { + return sprintf('%s::%s', $this->declaringClass->getDisplayName(), $this->getName()); + } + + public function isBuiltin(): TrinaryLogic + { + return TrinaryLogic::createFromBoolean($this->declaringClass->isBuiltin()); + } + public function getFileName(): ?string { return $this->declaringClass->getFileName(); diff --git a/src/Reflection/ResolvedFunctionVariantWithOriginal.php b/src/Reflection/ResolvedFunctionVariantWithOriginal.php index b29a897c45f..9d451a57148 100644 --- a/src/Reflection/ResolvedFunctionVariantWithOriginal.php +++ b/src/Reflection/ResolvedFunctionVariantWithOriginal.php @@ -121,6 +121,7 @@ function (ExtendedParameterReflection $param): ExtendedParameterReflection { $param->isImmediatelyInvokedCallable(), $closureThisType, $param->getAttributes(), + $param->getAllowedConstants(), ); }, $this->parametersAcceptor->getParameters(), diff --git a/src/Reflection/SignatureMap/NativeFunctionReflectionProvider.php b/src/Reflection/SignatureMap/NativeFunctionReflectionProvider.php index a2f12f6daad..efb3b508db3 100644 --- a/src/Reflection/SignatureMap/NativeFunctionReflectionProvider.php +++ b/src/Reflection/SignatureMap/NativeFunctionReflectionProvider.php @@ -16,6 +16,7 @@ use PHPStan\Reflection\InitializerExprContext; use PHPStan\Reflection\Native\ExtendedNativeParameterReflection; use PHPStan\Reflection\Native\NativeFunctionReflection; +use PHPStan\Reflection\ParameterAllowedConstantsMapProvider; use PHPStan\TrinaryLogic; use PHPStan\Type\FileTypeMapper; use PHPStan\Type\Generic\TemplateTypeMap; @@ -41,6 +42,7 @@ public function __construct( private FileTypeMapper $fileTypeMapper, private StubPhpDocProvider $stubPhpDocProvider, private AttributeReflectionFactory $attributeReflectionFactory, + private ParameterAllowedConstantsMapProvider $allowedConstantsMapProvider, ) { } @@ -107,13 +109,14 @@ public function findFunctionReflection(string $functionName): ?NativeFunctionRef $acceptsNamedArguments = $phpDoc->acceptsNamedArguments(); } + $allowedConstantsMapProvider = $this->allowedConstantsMapProvider; $variantsByType = ['positional' => []]; foreach ($functionSignaturesResult as $signatureType => $functionSignatures) { foreach ($functionSignatures ?? [] as $functionSignature) { $variantsByType[$signatureType][] = new ExtendedFunctionVariant( TemplateTypeMap::createEmpty(), null, - array_map(static function (ParameterSignature $parameterSignature) use ($phpDoc): ExtendedNativeParameterReflection { + array_map(static function (ParameterSignature $parameterSignature) use ($phpDoc, $lowerCasedFunctionName, $allowedConstantsMapProvider): ExtendedNativeParameterReflection { $type = $parameterSignature->getType(); $phpDocType = null; @@ -144,6 +147,7 @@ public function findFunctionReflection(string $functionName): ?NativeFunctionRef $immediatelyInvokedCallable, $closureThisType, [], + $allowedConstantsMapProvider->getForFunctionParameter($lowerCasedFunctionName, $parameterSignature->getName()), ); }, $functionSignature->getParameters()), $functionSignature->isVariadic(), diff --git a/src/Reflection/Type/CallbackUnresolvedMethodPrototypeReflection.php b/src/Reflection/Type/CallbackUnresolvedMethodPrototypeReflection.php index 1f183844d53..026d3c36fb2 100644 --- a/src/Reflection/Type/CallbackUnresolvedMethodPrototypeReflection.php +++ b/src/Reflection/Type/CallbackUnresolvedMethodPrototypeReflection.php @@ -118,6 +118,7 @@ function (ExtendedParameterReflection $parameter): ExtendedParameterReflection { $parameter->isImmediatelyInvokedCallable(), $parameter->getClosureThisType() !== null ? $this->transformStaticType($parameter->getClosureThisType()) : null, $parameter->getAttributes(), + $parameter->getAllowedConstants(), ); }, $acceptor->getParameters(), diff --git a/src/Reflection/Type/CalledOnTypeUnresolvedMethodPrototypeReflection.php b/src/Reflection/Type/CalledOnTypeUnresolvedMethodPrototypeReflection.php index 68d3200bec0..8198ea1f954 100644 --- a/src/Reflection/Type/CalledOnTypeUnresolvedMethodPrototypeReflection.php +++ b/src/Reflection/Type/CalledOnTypeUnresolvedMethodPrototypeReflection.php @@ -105,6 +105,7 @@ private function transformMethodWithStaticType(ClassReflection $declaringClass, $parameter->isImmediatelyInvokedCallable(), $parameter->getClosureThisType() !== null ? $this->transformStaticType($parameter->getClosureThisType()) : null, $parameter->getAttributes(), + $parameter->getAllowedConstants(), ), $acceptor->getParameters(), ), diff --git a/src/Reflection/WrappedExtendedMethodReflection.php b/src/Reflection/WrappedExtendedMethodReflection.php index d028d80d04b..7711a799580 100644 --- a/src/Reflection/WrappedExtendedMethodReflection.php +++ b/src/Reflection/WrappedExtendedMethodReflection.php @@ -77,6 +77,7 @@ public function getVariants(): array TrinaryLogic::createMaybe(), null, [], + null, ), $variant->getParameters()), $variant->isVariadic(), $variant->getReturnType(), diff --git a/src/Rules/Arrays/NonexistentOffsetInArrayDimFetchCheck.php b/src/Rules/Arrays/NonexistentOffsetInArrayDimFetchCheck.php index 263a62aebf9..15ad08f0e30 100644 --- a/src/Rules/Arrays/NonexistentOffsetInArrayDimFetchCheck.php +++ b/src/Rules/Arrays/NonexistentOffsetInArrayDimFetchCheck.php @@ -109,13 +109,14 @@ public function check( $report = true; break; } - if ( - $this->reportPossiblyNonexistentConstantArrayOffset - && $innerType->isConstantArray()->yes() - && !$innerType->hasOffsetValueType($dimTypeToCheck)->yes() - ) { - $report = true; - break; + if ($innerType->isConstantArray()->yes() && !$innerType->hasOffsetValueType($dimTypeToCheck)->yes()) { + if ($this->reportPossiblyNonexistentConstantArrayOffset) { + $report = true; + break; + } elseif ($dimTypeToCheck->isConstantScalarValue()->yes()) { + $report = true; + break; + } } if ($dimTypeToCheck instanceof BenevolentUnionType) { $flattenedInnerTypes = [$dimTypeToCheck]; diff --git a/src/Rules/AttributesCheck.php b/src/Rules/AttributesCheck.php index 36b3c6faa61..ee47292ed5c 100644 --- a/src/Rules/AttributesCheck.php +++ b/src/Rules/AttributesCheck.php @@ -5,6 +5,8 @@ use Attribute; use PhpParser\Node\AttributeGroup; use PhpParser\Node\Expr\New_; +use PHPStan\Analyser\CollectedDataEmitter; +use PHPStan\Analyser\NodeCallbackInvoker; use PHPStan\Analyser\Scope; use PHPStan\DependencyInjection\AutowiredParameter; use PHPStan\DependencyInjection\AutowiredService; @@ -36,7 +38,7 @@ public function __construct( * @return list */ public function check( - Scope $scope, + Scope&NodeCallbackInvoker&CollectedDataEmitter $scope, array $attrGroups, int $requiredTarget, string $targetName, @@ -157,6 +159,10 @@ public function check( 'Return type of call to ' . $attributeClassName . ' constructor contains unresolvable type.', '%s of attribute class ' . $attributeClassName . ' constructor contains unresolvable type.', 'Attribute class ' . $attributeClassName . ' constructor invoked with %s, but it\'s not allowed because of @no-named-arguments.', + 'Constant %s is not allowed for %s of attribute class ' . $attributeClassName . ' constructor.', + 'Constants %s cannot be combined for %s of attribute class ' . $attributeClassName . ' constructor.', + 'Combining constants with | is not allowed for %s of attribute class ' . $attributeClassName . ' constructor.', + null, ); foreach ($parameterErrors as $error) { diff --git a/src/Rules/Classes/ClassAttributesRule.php b/src/Rules/Classes/ClassAttributesRule.php index 197987ddf1c..9ceea3ce4f1 100644 --- a/src/Rules/Classes/ClassAttributesRule.php +++ b/src/Rules/Classes/ClassAttributesRule.php @@ -4,6 +4,8 @@ use Attribute; use PhpParser\Node; +use PHPStan\Analyser\CollectedDataEmitter; +use PHPStan\Analyser\NodeCallbackInvoker; use PHPStan\Analyser\Scope; use PHPStan\DependencyInjection\RegisteredRule; use PHPStan\Node\InClassNode; @@ -30,7 +32,7 @@ public function getNodeType(): string return InClassNode::class; } - public function processNode(Node $node, Scope $scope): array + public function processNode(Node $node, Scope&NodeCallbackInvoker&CollectedDataEmitter $scope): array { $classReflection = $node->getClassReflection(); diff --git a/src/Rules/Classes/ClassConstantAttributesRule.php b/src/Rules/Classes/ClassConstantAttributesRule.php index 3beaf3d0ea3..a6c0506443f 100644 --- a/src/Rules/Classes/ClassConstantAttributesRule.php +++ b/src/Rules/Classes/ClassConstantAttributesRule.php @@ -4,6 +4,8 @@ use Attribute; use PhpParser\Node; +use PHPStan\Analyser\CollectedDataEmitter; +use PHPStan\Analyser\NodeCallbackInvoker; use PHPStan\Analyser\Scope; use PHPStan\DependencyInjection\RegisteredRule; use PHPStan\Rules\AttributesCheck; @@ -25,7 +27,7 @@ public function getNodeType(): string return Node\Stmt\ClassConst::class; } - public function processNode(Node $node, Scope $scope): array + public function processNode(Node $node, Scope&NodeCallbackInvoker&CollectedDataEmitter $scope): array { return $this->attributesCheck->check( $scope, diff --git a/src/Rules/Classes/ImpossibleInstanceOfRule.php b/src/Rules/Classes/ImpossibleInstanceOfRule.php index 2adbba15a26..8dbee61149e 100644 --- a/src/Rules/Classes/ImpossibleInstanceOfRule.php +++ b/src/Rules/Classes/ImpossibleInstanceOfRule.php @@ -3,10 +3,14 @@ namespace PHPStan\Rules\Classes; use PhpParser\Node; +use PHPStan\Analyser\CollectedDataEmitter; +use PHPStan\Analyser\NodeCallbackInvoker; use PHPStan\Analyser\Scope; use PHPStan\DependencyInjection\AutowiredParameter; use PHPStan\DependencyInjection\RegisteredRule; use PHPStan\Parser\LastConditionVisitor; +use PHPStan\Rules\Comparison\ConstantConditionInTraitHelper; +use PHPStan\Rules\Comparison\PossiblyImpureTipHelper; use PHPStan\Rules\Rule; use PHPStan\Rules\RuleErrorBuilder; use PHPStan\Rules\RuleLevelHelper; @@ -29,6 +33,8 @@ final class ImpossibleInstanceOfRule implements Rule public function __construct( private RuleLevelHelper $ruleLevelHelper, + private PossiblyImpureTipHelper $possiblyImpureTipHelper, + private ConstantConditionInTraitHelper $constantConditionInTraitHelper, #[AutowiredParameter] private bool $treatPhpDocTypesAsCertain, #[AutowiredParameter] @@ -44,7 +50,7 @@ public function getNodeType(): string return Node\Expr\Instanceof_::class; } - public function processNode(Node $node, Scope $scope): array + public function processNode(Node $node, Scope&NodeCallbackInvoker&CollectedDataEmitter $scope): array { if ($node->class instanceof Node\Name) { $className = $scope->resolveName($node->class); @@ -74,40 +80,48 @@ public function processNode(Node $node, Scope $scope): array $instanceofType = $this->treatPhpDocTypesAsCertain ? $scope->getType($node) : $scope->getNativeType($node); if (!$instanceofType instanceof ConstantBooleanType) { + $this->constantConditionInTraitHelper->emitNoError(self::class, $scope, $node); return []; } $addTip = function (RuleErrorBuilder $ruleErrorBuilder) use ($scope, $node): RuleErrorBuilder { if (!$this->treatPhpDocTypesAsCertain) { - return $ruleErrorBuilder; + return $this->possiblyImpureTipHelper->addTip($scope, $node, $ruleErrorBuilder); } $instanceofTypeWithoutPhpDocs = $scope->getNativeType($node); if ($instanceofTypeWithoutPhpDocs instanceof ConstantBooleanType) { - return $ruleErrorBuilder; + return $this->possiblyImpureTipHelper->addTip($scope, $node, $ruleErrorBuilder); } if (!$this->treatPhpDocTypesAsCertainTip) { - return $ruleErrorBuilder; + return $this->possiblyImpureTipHelper->addTip($scope, $node, $ruleErrorBuilder); } - return $ruleErrorBuilder->treatPhpDocTypesAsCertainTip(); + $ruleErrorBuilder = $ruleErrorBuilder->treatPhpDocTypesAsCertainTip(); + + return $this->possiblyImpureTipHelper->addTip($scope, $node, $ruleErrorBuilder); }; if (!$instanceofType->getValue()) { $exprType = $this->treatPhpDocTypesAsCertain ? $scope->getType($node->expr) : $scope->getNativeType($node->expr); - return [ - $addTip(RuleErrorBuilder::message(sprintf( - 'Instanceof between %s and %s will always evaluate to false.', - $exprType->describe(VerbosityLevel::typeOnly()), - $classType->describe(VerbosityLevel::getRecommendedLevelByType($classType)), - )))->identifier('instanceof.alwaysFalse')->build(), - ]; + $ruleError = $addTip(RuleErrorBuilder::message(sprintf( + 'Instanceof between %s and %s will always evaluate to false.', + $exprType->describe(VerbosityLevel::typeOnly()), + $classType->describe(VerbosityLevel::getRecommendedLevelByType($classType)), + )))->identifier('instanceof.alwaysFalse')->build(); + if ($scope->isInTrait()) { + $this->constantConditionInTraitHelper->emitError(self::class, $scope, $node, false, $ruleError); + return []; + } + + return [$ruleError]; } $isLast = $node->getAttribute(LastConditionVisitor::ATTRIBUTE_NAME); if ($isLast === true && !$this->reportAlwaysTrueInLastCondition) { + $this->constantConditionInTraitHelper->emitNoError(self::class, $scope, $node); return []; } @@ -123,7 +137,13 @@ public function processNode(Node $node, Scope $scope): array $errorBuilder->identifier('instanceof.alwaysTrue'); - return [$errorBuilder->build()]; + $ruleError = $errorBuilder->build(); + if ($scope->isInTrait()) { + $this->constantConditionInTraitHelper->emitError(self::class, $scope, $node, true, $ruleError); + return []; + } + + return [$ruleError]; } } diff --git a/src/Rules/Classes/InstantiationRule.php b/src/Rules/Classes/InstantiationRule.php index e92e18a03d4..296bb78954a 100644 --- a/src/Rules/Classes/InstantiationRule.php +++ b/src/Rules/Classes/InstantiationRule.php @@ -4,6 +4,8 @@ use PhpParser\Node; use PhpParser\Node\Expr\New_; +use PHPStan\Analyser\CollectedDataEmitter; +use PHPStan\Analyser\NodeCallbackInvoker; use PHPStan\Analyser\Scope; use PHPStan\DependencyInjection\AutowiredParameter; use PHPStan\DependencyInjection\Container; @@ -58,7 +60,7 @@ public function getNodeType(): string return New_::class; } - public function processNode(Node $node, Scope $scope): array + public function processNode(Node $node, Scope&NodeCallbackInvoker&CollectedDataEmitter $scope): array { $errors = []; foreach ($this->getClassNames($node, $scope) as [$class, $isName]) { @@ -71,7 +73,7 @@ public function processNode(Node $node, Scope $scope): array * @param Node\Expr\New_ $node * @return list */ - private function checkClassName(string $class, bool $isName, Node $node, Scope $scope): array + private function checkClassName(string $class, bool $isName, Node $node, Scope&NodeCallbackInvoker&CollectedDataEmitter $scope): array { $lowercasedClass = strtolower($class); $messages = []; @@ -269,6 +271,10 @@ private function checkClassName(string $class, bool $isName, Node $node, Scope $ 'Return type of call to ' . $classDisplayName . ' constructor contains unresolvable type.', '%s of class ' . $classDisplayName . ' constructor contains unresolvable type.', 'Class ' . $classDisplayName . ' constructor invoked with %s, but it\'s not allowed because of @no-named-arguments.', + 'Constant %s is not allowed for %s of class ' . $classDisplayName . ' constructor.', + 'Constants %s cannot be combined for %s of class ' . $classDisplayName . ' constructor.', + 'Combining constants with | is not allowed for %s of class ' . $classDisplayName . ' constructor.', + null, )); } diff --git a/src/Rules/Classes/LocalTypeAliasesCheck.php b/src/Rules/Classes/LocalTypeAliasesCheck.php index a849ecac8c4..8f991484e8b 100644 --- a/src/Rules/Classes/LocalTypeAliasesCheck.php +++ b/src/Rules/Classes/LocalTypeAliasesCheck.php @@ -194,10 +194,9 @@ public function checkInTraitDefinitionContext(ClassReflection $reflection): arra continue; } - foreach ($this->missingTypehintCheck->getIterableTypesWithMissingValueTypehint($resolvedType) as $iterableType) { - $iterableTypeDescription = $iterableType->describe(VerbosityLevel::typeOnly()); + foreach ($this->missingTypehintCheck->getIterableTypesWithMissingValueTypehint($resolvedType) as $iterableTypeDescription) { $errors[] = RuleErrorBuilder::message(sprintf( - '%s %s has type alias %s with no value type specified in iterable type %s.', + '%s %s has type alias %s with no value type specified in %s.', $reflection->getClassTypeDescription(), $reflection->getDisplayName(), $aliasName, diff --git a/src/Rules/Classes/MethodTagCheck.php b/src/Rules/Classes/MethodTagCheck.php index 88e5e3a4508..8928d8c694b 100644 --- a/src/Rules/Classes/MethodTagCheck.php +++ b/src/Rules/Classes/MethodTagCheck.php @@ -190,10 +190,9 @@ private function checkMethodTypeInTraitDefinitionContext(ClassReflection $classR ->build(); } - foreach ($this->missingTypehintCheck->getIterableTypesWithMissingValueTypehint($type) as $iterableType) { - $iterableTypeDescription = $iterableType->describe(VerbosityLevel::typeOnly()); + foreach ($this->missingTypehintCheck->getIterableTypesWithMissingValueTypehint($type) as $iterableTypeDescription) { $errors[] = RuleErrorBuilder::message(sprintf( - '%s %s has PHPDoc tag @method for method %s() %s with no value type specified in iterable type %s.', + '%s %s has PHPDoc tag @method for method %s() %s with no value type specified in %s.', $classReflection->getClassTypeDescription(), $classReflection->getDisplayName(), $methodName, diff --git a/src/Rules/Classes/MixinCheck.php b/src/Rules/Classes/MixinCheck.php index ecdfff0d92b..eb73d677265 100644 --- a/src/Rules/Classes/MixinCheck.php +++ b/src/Rules/Classes/MixinCheck.php @@ -76,10 +76,9 @@ public function checkInTraitDefinitionContext(ClassReflection $classReflection): continue; } - foreach ($this->missingTypehintCheck->getIterableTypesWithMissingValueTypehint($type) as $iterableType) { - $iterableTypeDescription = $iterableType->describe(VerbosityLevel::typeOnly()); + foreach ($this->missingTypehintCheck->getIterableTypesWithMissingValueTypehint($type) as $iterableTypeDescription) { $errors[] = RuleErrorBuilder::message(sprintf( - '%s %s has PHPDoc tag @mixin with no value type specified in iterable type %s.', + '%s %s has PHPDoc tag @mixin with no value type specified in %s.', $classReflection->getClassTypeDescription(), $classReflection->getDisplayName(), $iterableTypeDescription, diff --git a/src/Rules/Classes/PropertyTagCheck.php b/src/Rules/Classes/PropertyTagCheck.php index 6b4a42c905a..ccaed0038bc 100644 --- a/src/Rules/Classes/PropertyTagCheck.php +++ b/src/Rules/Classes/PropertyTagCheck.php @@ -171,10 +171,9 @@ private function checkPropertyTypeInTraitDefinitionContext(ClassReflection $clas ->build(); } - foreach ($this->missingTypehintCheck->getIterableTypesWithMissingValueTypehint($type) as $iterableType) { - $iterableTypeDescription = $iterableType->describe(VerbosityLevel::typeOnly()); + foreach ($this->missingTypehintCheck->getIterableTypesWithMissingValueTypehint($type) as $iterableTypeDescription) { $errors[] = RuleErrorBuilder::message(sprintf( - '%s %s has PHPDoc tag %s for property $%s with no value type specified in iterable type %s.', + '%s %s has PHPDoc tag %s for property $%s with no value type specified in %s.', $classReflection->getClassTypeDescription(), $classReflection->getDisplayName(), $tagName, diff --git a/src/Rules/Comparison/BooleanAndConstantConditionRule.php b/src/Rules/Comparison/BooleanAndConstantConditionRule.php index 15546825909..60e88caa527 100644 --- a/src/Rules/Comparison/BooleanAndConstantConditionRule.php +++ b/src/Rules/Comparison/BooleanAndConstantConditionRule.php @@ -3,6 +3,8 @@ namespace PHPStan\Rules\Comparison; use PhpParser\Node; +use PHPStan\Analyser\CollectedDataEmitter; +use PHPStan\Analyser\NodeCallbackInvoker; use PHPStan\Analyser\Scope; use PHPStan\DependencyInjection\AutowiredParameter; use PHPStan\DependencyInjection\RegisteredRule; @@ -24,6 +26,7 @@ final class BooleanAndConstantConditionRule implements Rule public function __construct( private ConstantConditionRuleHelper $helper, private PossiblyImpureTipHelper $possiblyImpureTipHelper, + private ConstantConditionInTraitHelper $constantConditionInTraitHelper, #[AutowiredParameter] private bool $treatPhpDocTypesAsCertain, #[AutowiredParameter] @@ -41,7 +44,7 @@ public function getNodeType(): string public function processNode( Node $node, - Scope $scope, + Scope&NodeCallbackInvoker&CollectedDataEmitter $scope, ): array { $errors = []; @@ -49,6 +52,8 @@ public function processNode( $nodeText = $originalNode->getOperatorSigil(); $leftType = $this->helper->getBooleanType($scope, $originalNode->left); $identifierType = $originalNode instanceof Node\Expr\BinaryOp\BooleanAnd ? 'booleanAnd' : 'logicalAnd'; + $isInTrait = $scope->isInTrait(); + $hasLeftOrRightError = false; if ($leftType instanceof ConstantBooleanType) { $addTipLeft = function (RuleErrorBuilder $ruleErrorBuilder) use ($scope, $originalNode): RuleErrorBuilder { if (!$this->treatPhpDocTypesAsCertain) { @@ -80,8 +85,18 @@ public function processNode( if ($leftType->getValue() && $isLast === false && !$this->reportAlwaysTrueInLastCondition) { $errorBuilder->tip('Remove remaining cases below this one and this error will disappear too.'); } - $errors[] = $errorBuilder->build(); + $ruleError = $errorBuilder->build(); + $hasLeftOrRightError = true; + if ($isInTrait) { + $this->constantConditionInTraitHelper->emitError(self::class, $scope, $originalNode->left, $leftType->getValue(), $ruleError); + } else { + $errors[] = $ruleError; + } + } else { + $this->constantConditionInTraitHelper->emitNoError(self::class, $scope, $originalNode->left); } + } else { + $this->constantConditionInTraitHelper->emitNoError(self::class, $scope, $originalNode->left); } $rightScope = $node->getRightScope(); @@ -123,11 +138,21 @@ public function processNode( if ($rightType->getValue() && $isLast === false && !$this->reportAlwaysTrueInLastCondition) { $errorBuilder->tip('Remove remaining cases below this one and this error will disappear too.'); } - $errors[] = $errorBuilder->build(); + $ruleError = $errorBuilder->build(); + $hasLeftOrRightError = true; + if ($isInTrait) { + $this->constantConditionInTraitHelper->emitError(self::class, $scope, $originalNode->right, $rightType->getValue(), $ruleError); + } else { + $errors[] = $ruleError; + } + } else { + $this->constantConditionInTraitHelper->emitNoError(self::class, $scope, $originalNode->right); } + } else { + $this->constantConditionInTraitHelper->emitNoError(self::class, $scope, $originalNode->right); } - if (count($errors) === 0 && !$scope->isInFirstLevelStatement()) { + if (count($errors) === 0 && !$hasLeftOrRightError && !$scope->isInFirstLevelStatement()) { $nodeType = $this->treatPhpDocTypesAsCertain ? $scope->getType($originalNode) : $scope->getNativeType($originalNode); if ($nodeType instanceof ConstantBooleanType) { $addTip = function (RuleErrorBuilder $ruleErrorBuilder) use ($scope, $originalNode): RuleErrorBuilder { @@ -161,8 +186,17 @@ public function processNode( $errorBuilder->identifier(sprintf('%s.always%s', $identifierType, $nodeType->getValue() ? 'True' : 'False')); - $errors[] = $errorBuilder->build(); + $ruleError = $errorBuilder->build(); + if ($isInTrait) { + $this->constantConditionInTraitHelper->emitError(self::class, $scope, $originalNode, $nodeType->getValue(), $ruleError); + } else { + $errors[] = $ruleError; + } + } else { + $this->constantConditionInTraitHelper->emitNoError(self::class, $scope, $originalNode); } + } else { + $this->constantConditionInTraitHelper->emitNoError(self::class, $scope, $originalNode); } } diff --git a/src/Rules/Comparison/BooleanNotConstantConditionRule.php b/src/Rules/Comparison/BooleanNotConstantConditionRule.php index 0f62704ebdb..fe786dd3c18 100644 --- a/src/Rules/Comparison/BooleanNotConstantConditionRule.php +++ b/src/Rules/Comparison/BooleanNotConstantConditionRule.php @@ -3,6 +3,8 @@ namespace PHPStan\Rules\Comparison; use PhpParser\Node; +use PHPStan\Analyser\CollectedDataEmitter; +use PHPStan\Analyser\NodeCallbackInvoker; use PHPStan\Analyser\Scope; use PHPStan\DependencyInjection\AutowiredParameter; use PHPStan\DependencyInjection\RegisteredRule; @@ -22,6 +24,7 @@ final class BooleanNotConstantConditionRule implements Rule public function __construct( private ConstantConditionRuleHelper $helper, private PossiblyImpureTipHelper $possiblyImpureTipHelper, + private ConstantConditionInTraitHelper $constantConditionInTraitHelper, #[AutowiredParameter] private bool $treatPhpDocTypesAsCertain, #[AutowiredParameter] @@ -39,7 +42,7 @@ public function getNodeType(): string public function processNode( Node $node, - Scope $scope, + Scope&NodeCallbackInvoker&CollectedDataEmitter $scope, ): array { $exprType = $this->helper->getBooleanType($scope, $node->expr); @@ -74,12 +77,17 @@ public function processNode( $errorBuilder->identifier(sprintf('booleanNot.always%s', $exprType->getValue() ? 'False' : 'True')); - return [ - $errorBuilder->build(), - ]; + $ruleError = $errorBuilder->build(); + if ($scope->isInTrait()) { + $this->constantConditionInTraitHelper->emitError(self::class, $scope, $node->expr, !$exprType->getValue(), $ruleError); + return []; + } + + return [$ruleError]; } } + $this->constantConditionInTraitHelper->emitNoError(self::class, $scope, $node->expr); return []; } diff --git a/src/Rules/Comparison/BooleanOrConstantConditionRule.php b/src/Rules/Comparison/BooleanOrConstantConditionRule.php index 8d2e1b86107..cc9fc93efa1 100644 --- a/src/Rules/Comparison/BooleanOrConstantConditionRule.php +++ b/src/Rules/Comparison/BooleanOrConstantConditionRule.php @@ -3,6 +3,8 @@ namespace PHPStan\Rules\Comparison; use PhpParser\Node; +use PHPStan\Analyser\CollectedDataEmitter; +use PHPStan\Analyser\NodeCallbackInvoker; use PHPStan\Analyser\Scope; use PHPStan\DependencyInjection\AutowiredParameter; use PHPStan\DependencyInjection\RegisteredRule; @@ -24,6 +26,7 @@ final class BooleanOrConstantConditionRule implements Rule public function __construct( private ConstantConditionRuleHelper $helper, private PossiblyImpureTipHelper $possiblyImpureTipHelper, + private ConstantConditionInTraitHelper $constantConditionInTraitHelper, #[AutowiredParameter] private bool $treatPhpDocTypesAsCertain, #[AutowiredParameter] @@ -41,7 +44,7 @@ public function getNodeType(): string public function processNode( Node $node, - Scope $scope, + Scope&NodeCallbackInvoker&CollectedDataEmitter $scope, ): array { $originalNode = $node->getOriginalNode(); @@ -49,6 +52,8 @@ public function processNode( $messages = []; $leftType = $this->helper->getBooleanType($scope, $originalNode->left); $identifierType = $originalNode instanceof Node\Expr\BinaryOp\BooleanOr ? 'booleanOr' : 'logicalOr'; + $isInTrait = $scope->isInTrait(); + $hasLeftOrRightError = false; if ($leftType instanceof ConstantBooleanType) { $addTipLeft = function (RuleErrorBuilder $ruleErrorBuilder) use ($scope, $originalNode): RuleErrorBuilder { if (!$this->treatPhpDocTypesAsCertain) { @@ -80,8 +85,18 @@ public function processNode( if ($leftType->getValue() && $isLast === false && !$this->reportAlwaysTrueInLastCondition) { $errorBuilder->tip('Remove remaining cases below this one and this error will disappear too.'); } - $messages[] = $errorBuilder->build(); + $ruleError = $errorBuilder->build(); + $hasLeftOrRightError = true; + if ($isInTrait) { + $this->constantConditionInTraitHelper->emitError(self::class, $scope, $originalNode->left, $leftType->getValue(), $ruleError); + } else { + $messages[] = $ruleError; + } + } else { + $this->constantConditionInTraitHelper->emitNoError(self::class, $scope, $originalNode->left); } + } else { + $this->constantConditionInTraitHelper->emitNoError(self::class, $scope, $originalNode->left); } $rightScope = $node->getRightScope(); @@ -123,11 +138,21 @@ public function processNode( if ($rightType->getValue() && $isLast === false && !$this->reportAlwaysTrueInLastCondition) { $errorBuilder->tip('Remove remaining cases below this one and this error will disappear too.'); } - $messages[] = $errorBuilder->build(); + $ruleError = $errorBuilder->build(); + $hasLeftOrRightError = true; + if ($isInTrait) { + $this->constantConditionInTraitHelper->emitError(self::class, $scope, $originalNode->right, $rightType->getValue(), $ruleError); + } else { + $messages[] = $ruleError; + } + } else { + $this->constantConditionInTraitHelper->emitNoError(self::class, $scope, $originalNode->right); } + } else { + $this->constantConditionInTraitHelper->emitNoError(self::class, $scope, $originalNode->right); } - if (count($messages) === 0 && !$scope->isInFirstLevelStatement()) { + if (count($messages) === 0 && !$hasLeftOrRightError && !$scope->isInFirstLevelStatement()) { $nodeType = $this->treatPhpDocTypesAsCertain ? $scope->getType($originalNode) : $scope->getNativeType($originalNode); if ($nodeType instanceof ConstantBooleanType) { $addTip = function (RuleErrorBuilder $ruleErrorBuilder) use ($scope, $originalNode): RuleErrorBuilder { @@ -161,8 +186,17 @@ public function processNode( $errorBuilder->identifier(sprintf('%s.always%s', $identifierType, $nodeType->getValue() ? 'True' : 'False')); - $messages[] = $errorBuilder->build(); + $ruleError = $errorBuilder->build(); + if ($isInTrait) { + $this->constantConditionInTraitHelper->emitError(self::class, $scope, $originalNode, $nodeType->getValue(), $ruleError); + } else { + $messages[] = $ruleError; + } + } else { + $this->constantConditionInTraitHelper->emitNoError(self::class, $scope, $originalNode); } + } else { + $this->constantConditionInTraitHelper->emitNoError(self::class, $scope, $originalNode); } } diff --git a/src/Rules/Comparison/ConstantConditionInTraitCollector.php b/src/Rules/Comparison/ConstantConditionInTraitCollector.php new file mode 100644 index 00000000000..7e2f75990d6 --- /dev/null +++ b/src/Rules/Comparison/ConstantConditionInTraitCollector.php @@ -0,0 +1,28 @@ +>, trait-string, string, null}|array{class-string>, trait-string, string, bool, Error|array}> + */ +final class ConstantConditionInTraitCollector implements Collector +{ + + public function getNodeType(): string + { + throw new ShouldNotHappenException(); + } + + public function processNode(Node $node, Scope $scope) + { + throw new ShouldNotHappenException(); + } + +} diff --git a/src/Rules/Comparison/ConstantConditionInTraitHelper.php b/src/Rules/Comparison/ConstantConditionInTraitHelper.php new file mode 100644 index 00000000000..31f70c70cc6 --- /dev/null +++ b/src/Rules/Comparison/ConstantConditionInTraitHelper.php @@ -0,0 +1,81 @@ +> $ruleName + */ + public function emitNoError( + string $ruleName, + Scope&NodeCallbackInvoker&CollectedDataEmitter $scope, + Expr $expr, + ): void + { + if (!$scope->isInTrait()) { + return; + } + + $exprString = sprintf('%s:%d', $this->exprPrinter->printExpr($expr), $expr->getStartLine()); + $scope->emitCollectedData(ConstantConditionInTraitCollector::class, [ + $ruleName, + $scope->getTraitReflection()->getName(), + $exprString, + null, + ]); + } + + /** + * @param class-string> $ruleName + */ + public function emitError( + string $ruleName, + Scope&NodeCallbackInvoker&CollectedDataEmitter $scope, + Expr $expr, + bool $value, + RuleError $ruleError, + ): void + { + if ($ruleError instanceof FixableNodeRuleError) { + throw new ShouldNotHappenException('Fixable errors are not supported by ConstantConditionInTraitHelper.'); + } + + if (!$scope->isInTrait()) { + return; + } + + $exprString = sprintf('%s:%d', $this->exprPrinter->printExpr($expr), $expr->getStartLine()); + $scope->emitCollectedData(ConstantConditionInTraitCollector::class, [ + $ruleName, + $scope->getTraitReflection()->getName(), + $exprString, + $value, + $this->ruleErrorTransformer->transform($ruleError, $scope, [], $expr), + ]); + } + +} diff --git a/src/Rules/Comparison/ConstantConditionInTraitRule.php b/src/Rules/Comparison/ConstantConditionInTraitRule.php new file mode 100644 index 00000000000..fd595520e12 --- /dev/null +++ b/src/Rules/Comparison/ConstantConditionInTraitRule.php @@ -0,0 +1,95 @@ + + */ +#[RegisteredRule(level: 0)] +final class ConstantConditionInTraitRule implements Rule +{ + + public function getNodeType(): string + { + return CollectedDataNode::class; + } + + public function processNode(Node $node, Scope $scope): array + { + $errorsByRuleTraitExprValue = []; + foreach ($node->get(ConstantConditionInTraitCollector::class) as $fileData) { + foreach ($fileData as $data) { + $ruleName = $data[0]; + $traitName = $data[1]; + $exprString = $data[2]; + $value = $data[3]; + $valueKey = var_export($value, true); + if ($data[3] === null) { + $errorsByRuleTraitExprValue[$ruleName][$traitName][$exprString][$valueKey][] = null; + // no error reported + continue; + } + + $error = $data[4]; + $errorsByRuleTraitExprValue[$ruleName][$traitName][$exprString][$valueKey][] = $error; + } + } + + $transformedErrors = []; + foreach ($errorsByRuleTraitExprValue as $ruleData) { + foreach ($ruleData as $traitData) { + foreach ($traitData as $valueData) { + if (count($valueData) > 1) { + continue; + } + + $uniquedErrors = []; + foreach ($valueData as $errors) { + foreach ($errors as $errorObject) { + if ($errorObject === null) { + continue; + } + if (is_array($errorObject)) { + $errorObject = Error::decode($errorObject); + } + + $message = $errorObject->getMessage(); + $uniquedErrors[$message] = $errorObject; + } + } + + $uniquedErrors = array_values($uniquedErrors); + if (count($uniquedErrors) === 0) { + continue; + } + + if (count($uniquedErrors) === 1) { + // report directly in trait, no "in context of" + $transformedErrors[] = new TransformedRuleError($uniquedErrors[0]->removeTraitContext()); + continue; + } + + // report each error in its context + foreach ($uniquedErrors as $uniquedError) { + $transformedErrors[] = new TransformedRuleError($uniquedError); + } + } + } + } + + return $transformedErrors; + } + +} diff --git a/src/Rules/Comparison/ConstantLooseComparisonRule.php b/src/Rules/Comparison/ConstantLooseComparisonRule.php index dde16af74d6..d872f60a016 100644 --- a/src/Rules/Comparison/ConstantLooseComparisonRule.php +++ b/src/Rules/Comparison/ConstantLooseComparisonRule.php @@ -3,6 +3,8 @@ namespace PHPStan\Rules\Comparison; use PhpParser\Node; +use PHPStan\Analyser\CollectedDataEmitter; +use PHPStan\Analyser\NodeCallbackInvoker; use PHPStan\Analyser\Scope; use PHPStan\DependencyInjection\AutowiredParameter; use PHPStan\DependencyInjection\RegisteredRule; @@ -21,6 +23,7 @@ final class ConstantLooseComparisonRule implements Rule public function __construct( private PossiblyImpureTipHelper $possiblyImpureTipHelper, + private ConstantConditionInTraitHelper $constantConditionInTraitHelper, #[AutowiredParameter] private bool $treatPhpDocTypesAsCertain, #[AutowiredParameter] @@ -36,7 +39,7 @@ public function getNodeType(): string return Node\Expr\BinaryOp::class; } - public function processNode(Node $node, Scope $scope): array + public function processNode(Node $node, Scope&NodeCallbackInvoker&CollectedDataEmitter $scope): array { if (!$node instanceof Node\Expr\BinaryOp\Equal && !$node instanceof Node\Expr\BinaryOp\NotEqual) { return []; @@ -44,6 +47,7 @@ public function processNode(Node $node, Scope $scope): array $nodeType = $this->treatPhpDocTypesAsCertain ? $scope->getType($node) : $scope->getNativeType($node); if (!$nodeType->isTrue()->yes() && !$nodeType->isFalse()->yes()) { + $this->constantConditionInTraitHelper->emitNoError(self::class, $scope, $node); return []; } @@ -66,18 +70,23 @@ public function processNode(Node $node, Scope $scope): array }; if ($nodeType->isFalse()->yes()) { - return [ - $addTip(RuleErrorBuilder::message(sprintf( - 'Loose comparison using %s between %s and %s will always evaluate to false.', - $node->getOperatorSigil(), - $scope->getType($node->left)->describe(VerbosityLevel::value()), - $scope->getType($node->right)->describe(VerbosityLevel::value()), - )))->identifier(sprintf('%s.alwaysFalse', $node instanceof Node\Expr\BinaryOp\Equal ? 'equal' : 'notEqual'))->build(), - ]; + $ruleError = $addTip(RuleErrorBuilder::message(sprintf( + 'Loose comparison using %s between %s and %s will always evaluate to false.', + $node->getOperatorSigil(), + $scope->getType($node->left)->describe(VerbosityLevel::value()), + $scope->getType($node->right)->describe(VerbosityLevel::value()), + )))->identifier(sprintf('%s.alwaysFalse', $node instanceof Node\Expr\BinaryOp\Equal ? 'equal' : 'notEqual'))->build(); + if ($scope->isInTrait()) { + $this->constantConditionInTraitHelper->emitError(self::class, $scope, $node, false, $ruleError); + return []; + } + + return [$ruleError]; } $isLast = $node->getAttribute(LastConditionVisitor::ATTRIBUTE_NAME); if ($isLast === true && !$this->reportAlwaysTrueInLastCondition) { + $this->constantConditionInTraitHelper->emitNoError(self::class, $scope, $node); return []; } @@ -93,7 +102,13 @@ public function processNode(Node $node, Scope $scope): array $errorBuilder->identifier(sprintf('%s.alwaysTrue', $node instanceof Node\Expr\BinaryOp\Equal ? 'equal' : 'notEqual')); - return [$errorBuilder->build()]; + $ruleError = $errorBuilder->build(); + if ($scope->isInTrait()) { + $this->constantConditionInTraitHelper->emitError(self::class, $scope, $node, true, $ruleError); + return []; + } + + return [$ruleError]; } } diff --git a/src/Rules/Comparison/DoWhileLoopConstantConditionRule.php b/src/Rules/Comparison/DoWhileLoopConstantConditionRule.php index 973d49295eb..bff27fcfe20 100644 --- a/src/Rules/Comparison/DoWhileLoopConstantConditionRule.php +++ b/src/Rules/Comparison/DoWhileLoopConstantConditionRule.php @@ -6,6 +6,8 @@ use PhpParser\Node\Scalar\Int_; use PhpParser\Node\Stmt\Break_; use PhpParser\Node\Stmt\Continue_; +use PHPStan\Analyser\CollectedDataEmitter; +use PHPStan\Analyser\NodeCallbackInvoker; use PHPStan\Analyser\Scope; use PHPStan\DependencyInjection\AutowiredParameter; use PHPStan\DependencyInjection\RegisteredRule; @@ -25,6 +27,7 @@ final class DoWhileLoopConstantConditionRule implements Rule public function __construct( private ConstantConditionRuleHelper $helper, private PossiblyImpureTipHelper $possiblyImpureTipHelper, + private ConstantConditionInTraitHelper $constantConditionInTraitHelper, #[AutowiredParameter] private bool $treatPhpDocTypesAsCertain, #[AutowiredParameter(ref: '%tips.treatPhpDocTypesAsCertain%')] @@ -38,23 +41,26 @@ public function getNodeType(): string return DoWhileLoopConditionNode::class; } - public function processNode(Node $node, Scope $scope): array + public function processNode(Node $node, Scope&NodeCallbackInvoker&CollectedDataEmitter $scope): array { $exprType = $this->helper->getBooleanType($scope, $node->getCond()); if ($exprType instanceof ConstantBooleanType) { if ($exprType->getValue()) { if ($node->hasYield()) { + $this->constantConditionInTraitHelper->emitNoError(self::class, $scope, $node->getCond()); return []; } foreach ($node->getExitPoints() as $exitPoint) { $statement = $exitPoint->getStatement(); if (!$statement instanceof Continue_) { + $this->constantConditionInTraitHelper->emitNoError(self::class, $scope, $node->getCond()); return []; } if (!$statement->num instanceof Int_) { continue; } if ($statement->num->value > 1) { + $this->constantConditionInTraitHelper->emitNoError(self::class, $scope, $node->getCond()); return []; } } @@ -62,6 +68,7 @@ public function processNode(Node $node, Scope $scope): array foreach ($node->getExitPoints() as $exitPoint) { $statement = $exitPoint->getStatement(); if ($statement instanceof Break_) { + $this->constantConditionInTraitHelper->emitNoError(self::class, $scope, $node->getCond()); return []; } } @@ -85,17 +92,22 @@ public function processNode(Node $node, Scope $scope): array return $this->possiblyImpureTipHelper->addTip($scope, $node->getCond(), $ruleErrorBuilder); }; - return [ - $addTip(RuleErrorBuilder::message(sprintf( - 'Do-while loop condition is always %s.', - $exprType->getValue() ? 'true' : 'false', - ))) - ->line($node->getCond()->getStartLine()) - ->identifier(sprintf('doWhile.always%s', $exprType->getValue() ? 'True' : 'False')) - ->build(), - ]; + $ruleError = $addTip(RuleErrorBuilder::message(sprintf( + 'Do-while loop condition is always %s.', + $exprType->getValue() ? 'true' : 'false', + ))) + ->line($node->getCond()->getStartLine()) + ->identifier(sprintf('doWhile.always%s', $exprType->getValue() ? 'True' : 'False')) + ->build(); + if ($scope->isInTrait()) { + $this->constantConditionInTraitHelper->emitError(self::class, $scope, $node->getCond(), $exprType->getValue(), $ruleError); + return []; + } + + return [$ruleError]; } + $this->constantConditionInTraitHelper->emitNoError(self::class, $scope, $node->getCond()); return []; } diff --git a/src/Rules/Comparison/ElseIfConstantConditionRule.php b/src/Rules/Comparison/ElseIfConstantConditionRule.php index 10df54ef12c..22c19dd1ec0 100644 --- a/src/Rules/Comparison/ElseIfConstantConditionRule.php +++ b/src/Rules/Comparison/ElseIfConstantConditionRule.php @@ -3,6 +3,8 @@ namespace PHPStan\Rules\Comparison; use PhpParser\Node; +use PHPStan\Analyser\CollectedDataEmitter; +use PHPStan\Analyser\NodeCallbackInvoker; use PHPStan\Analyser\Scope; use PHPStan\DependencyInjection\AutowiredParameter; use PHPStan\DependencyInjection\RegisteredRule; @@ -22,6 +24,7 @@ final class ElseIfConstantConditionRule implements Rule public function __construct( private ConstantConditionRuleHelper $helper, private PossiblyImpureTipHelper $possiblyImpureTipHelper, + private ConstantConditionInTraitHelper $constantConditionInTraitHelper, #[AutowiredParameter] private bool $treatPhpDocTypesAsCertain, #[AutowiredParameter] @@ -39,7 +42,7 @@ public function getNodeType(): string public function processNode( Node $node, - Scope $scope, + Scope&NodeCallbackInvoker&CollectedDataEmitter $scope, ): array { $exprType = $this->helper->getBooleanType($scope, $node->cond); @@ -75,10 +78,17 @@ public function processNode( $errorBuilder->identifier(sprintf('elseif.always%s', $exprType->getValue() ? 'True' : 'False')); - return [$errorBuilder->build()]; + $ruleError = $errorBuilder->build(); + if ($scope->isInTrait()) { + $this->constantConditionInTraitHelper->emitError(self::class, $scope, $node->cond, $exprType->getValue(), $ruleError); + return []; + } + + return [$ruleError]; } } + $this->constantConditionInTraitHelper->emitNoError(self::class, $scope, $node->cond); return []; } diff --git a/src/Rules/Comparison/IfConstantConditionRule.php b/src/Rules/Comparison/IfConstantConditionRule.php index 19ee79df134..a1eb712e653 100644 --- a/src/Rules/Comparison/IfConstantConditionRule.php +++ b/src/Rules/Comparison/IfConstantConditionRule.php @@ -3,6 +3,8 @@ namespace PHPStan\Rules\Comparison; use PhpParser\Node; +use PHPStan\Analyser\CollectedDataEmitter; +use PHPStan\Analyser\NodeCallbackInvoker; use PHPStan\Analyser\Scope; use PHPStan\DependencyInjection\AutowiredParameter; use PHPStan\DependencyInjection\RegisteredRule; @@ -21,6 +23,7 @@ final class IfConstantConditionRule implements Rule public function __construct( private ConstantConditionRuleHelper $helper, private PossiblyImpureTipHelper $possiblyImpureTipHelper, + private ConstantConditionInTraitHelper $constantConditionInTraitHelper, #[AutowiredParameter] private bool $treatPhpDocTypesAsCertain, #[AutowiredParameter(ref: '%tips.treatPhpDocTypesAsCertain%')] @@ -36,7 +39,7 @@ public function getNodeType(): string public function processNode( Node $node, - Scope $scope, + Scope&NodeCallbackInvoker&CollectedDataEmitter $scope, ): array { $exprType = $this->helper->getBooleanType($scope, $node->cond); @@ -59,16 +62,21 @@ public function processNode( return $this->possiblyImpureTipHelper->addTip($scope, $node->cond, $ruleErrorBuilder); }; - return [ - $addTip(RuleErrorBuilder::message(sprintf( - 'If condition is always %s.', - $exprType->getValue() ? 'true' : 'false', - ))) - ->identifier(sprintf('if.always%s', $exprType->getValue() ? 'True' : 'False')) - ->line($node->cond->getStartLine())->build(), - ]; + $ruleError = $addTip(RuleErrorBuilder::message(sprintf( + 'If condition is always %s.', + $exprType->getValue() ? 'true' : 'false', + ))) + ->identifier(sprintf('if.always%s', $exprType->getValue() ? 'True' : 'False')) + ->line($node->cond->getStartLine())->build(); + if ($scope->isInTrait()) { + $this->constantConditionInTraitHelper->emitError(self::class, $scope, $node->cond, $exprType->getValue(), $ruleError); + return []; + } + + return [$ruleError]; } + $this->constantConditionInTraitHelper->emitNoError(self::class, $scope, $node->cond); return []; } diff --git a/src/Rules/Comparison/ImpossibleCheckTypeFunctionCallRule.php b/src/Rules/Comparison/ImpossibleCheckTypeFunctionCallRule.php index 61e3b57c303..8b3d6919c4c 100644 --- a/src/Rules/Comparison/ImpossibleCheckTypeFunctionCallRule.php +++ b/src/Rules/Comparison/ImpossibleCheckTypeFunctionCallRule.php @@ -3,6 +3,8 @@ namespace PHPStan\Rules\Comparison; use PhpParser\Node; +use PHPStan\Analyser\CollectedDataEmitter; +use PHPStan\Analyser\NodeCallbackInvoker; use PHPStan\Analyser\Scope; use PHPStan\DependencyInjection\AutowiredParameter; use PHPStan\DependencyInjection\RegisteredRule; @@ -21,6 +23,7 @@ final class ImpossibleCheckTypeFunctionCallRule implements Rule public function __construct( private ImpossibleCheckTypeHelper $impossibleCheckTypeHelper, private PossiblyImpureTipHelper $possiblyImpureTipHelper, + private ConstantConditionInTraitHelper $constantConditionInTraitHelper, #[AutowiredParameter] private bool $treatPhpDocTypesAsCertain, #[AutowiredParameter] @@ -36,7 +39,7 @@ public function getNodeType(): string return Node\Expr\FuncCall::class; } - public function processNode(Node $node, Scope $scope): array + public function processNode(Node $node, Scope&NodeCallbackInvoker&CollectedDataEmitter $scope): array { if (!$node->name instanceof Node\Name) { return []; @@ -45,6 +48,7 @@ public function processNode(Node $node, Scope $scope): array $functionName = (string) $node->name; $isAlways = $this->impossibleCheckTypeHelper->findSpecifiedType($scope, $node); if ($isAlways === null) { + $this->constantConditionInTraitHelper->emitNoError(self::class, $scope, $node); return []; } @@ -67,17 +71,22 @@ public function processNode(Node $node, Scope $scope): array }; if (!$isAlways) { - return [ - $addTip(RuleErrorBuilder::message(sprintf( - 'Call to function %s()%s will always evaluate to false.', - $functionName, - $this->impossibleCheckTypeHelper->getArgumentsDescription($scope, $node->getArgs()), - )))->identifier('function.impossibleType')->build(), - ]; + $ruleError = $addTip(RuleErrorBuilder::message(sprintf( + 'Call to function %s()%s will always evaluate to false.', + $functionName, + $this->impossibleCheckTypeHelper->getArgumentsDescription($scope, $node->getArgs()), + )))->identifier('function.impossibleType')->build(); + if ($scope->isInTrait()) { + $this->constantConditionInTraitHelper->emitError(self::class, $scope, $node, false, $ruleError); + return []; + } + + return [$ruleError]; } $isLast = $node->getAttribute(LastConditionVisitor::ATTRIBUTE_NAME); if ($isLast === true && !$this->reportAlwaysTrueInLastCondition) { + $this->constantConditionInTraitHelper->emitNoError(self::class, $scope, $node); return []; } @@ -92,7 +101,13 @@ public function processNode(Node $node, Scope $scope): array $errorBuilder->identifier('function.alreadyNarrowedType'); - return [$errorBuilder->build()]; + $ruleError = $errorBuilder->build(); + if ($scope->isInTrait()) { + $this->constantConditionInTraitHelper->emitError(self::class, $scope, $node, true, $ruleError); + return []; + } + + return [$ruleError]; } } diff --git a/src/Rules/Comparison/ImpossibleCheckTypeMethodCallRule.php b/src/Rules/Comparison/ImpossibleCheckTypeMethodCallRule.php index bc8284d1111..650f15d5a69 100644 --- a/src/Rules/Comparison/ImpossibleCheckTypeMethodCallRule.php +++ b/src/Rules/Comparison/ImpossibleCheckTypeMethodCallRule.php @@ -4,6 +4,8 @@ use PhpParser\Node; use PhpParser\Node\Expr; +use PHPStan\Analyser\CollectedDataEmitter; +use PHPStan\Analyser\NodeCallbackInvoker; use PHPStan\Analyser\Scope; use PHPStan\DependencyInjection\AutowiredParameter; use PHPStan\DependencyInjection\RegisteredRule; @@ -24,6 +26,7 @@ final class ImpossibleCheckTypeMethodCallRule implements Rule public function __construct( private ImpossibleCheckTypeHelper $impossibleCheckTypeHelper, private PossiblyImpureTipHelper $possiblyImpureTipHelper, + private ConstantConditionInTraitHelper $constantConditionInTraitHelper, #[AutowiredParameter] private bool $treatPhpDocTypesAsCertain, #[AutowiredParameter] @@ -39,7 +42,7 @@ public function getNodeType(): string return Node\Expr\MethodCall::class; } - public function processNode(Node $node, Scope $scope): array + public function processNode(Node $node, Scope&NodeCallbackInvoker&CollectedDataEmitter $scope): array { if (!$node->name instanceof Node\Identifier) { return []; @@ -47,6 +50,7 @@ public function processNode(Node $node, Scope $scope): array $isAlways = $this->impossibleCheckTypeHelper->findSpecifiedType($scope, $node); if ($isAlways === null) { + $this->constantConditionInTraitHelper->emitNoError(self::class, $scope, $node); return []; } @@ -70,18 +74,23 @@ public function processNode(Node $node, Scope $scope): array if (!$isAlways) { $method = $this->getMethod($node->var, $node->name->name, $scope); - return [ - $addTip(RuleErrorBuilder::message(sprintf( - 'Call to method %s::%s()%s will always evaluate to false.', - $method->getDeclaringClass()->getDisplayName(), - $method->getName(), - $this->impossibleCheckTypeHelper->getArgumentsDescription($scope, $node->getArgs()), - )))->identifier('method.impossibleType')->build(), - ]; + $ruleError = $addTip(RuleErrorBuilder::message(sprintf( + 'Call to method %s::%s()%s will always evaluate to false.', + $method->getDeclaringClass()->getDisplayName(), + $method->getName(), + $this->impossibleCheckTypeHelper->getArgumentsDescription($scope, $node->getArgs()), + )))->identifier('method.impossibleType')->build(); + if ($scope->isInTrait()) { + $this->constantConditionInTraitHelper->emitError(self::class, $scope, $node, false, $ruleError); + return []; + } + + return [$ruleError]; } $isLast = $node->getAttribute(LastConditionVisitor::ATTRIBUTE_NAME); if ($isLast === true && !$this->reportAlwaysTrueInLastCondition) { + $this->constantConditionInTraitHelper->emitNoError(self::class, $scope, $node); return []; } @@ -98,7 +107,13 @@ public function processNode(Node $node, Scope $scope): array $errorBuilder->identifier('method.alreadyNarrowedType'); - return [$errorBuilder->build()]; + $ruleError = $errorBuilder->build(); + if ($scope->isInTrait()) { + $this->constantConditionInTraitHelper->emitError(self::class, $scope, $node, true, $ruleError); + return []; + } + + return [$ruleError]; } private function getMethod( diff --git a/src/Rules/Comparison/ImpossibleCheckTypeStaticMethodCallRule.php b/src/Rules/Comparison/ImpossibleCheckTypeStaticMethodCallRule.php index 3c24b381762..3b41e6221a0 100644 --- a/src/Rules/Comparison/ImpossibleCheckTypeStaticMethodCallRule.php +++ b/src/Rules/Comparison/ImpossibleCheckTypeStaticMethodCallRule.php @@ -4,6 +4,8 @@ use PhpParser\Node; use PhpParser\Node\Expr; +use PHPStan\Analyser\CollectedDataEmitter; +use PHPStan\Analyser\NodeCallbackInvoker; use PHPStan\Analyser\Scope; use PHPStan\DependencyInjection\AutowiredParameter; use PHPStan\DependencyInjection\RegisteredRule; @@ -24,6 +26,7 @@ final class ImpossibleCheckTypeStaticMethodCallRule implements Rule public function __construct( private ImpossibleCheckTypeHelper $impossibleCheckTypeHelper, private PossiblyImpureTipHelper $possiblyImpureTipHelper, + private ConstantConditionInTraitHelper $constantConditionInTraitHelper, #[AutowiredParameter] private bool $treatPhpDocTypesAsCertain, #[AutowiredParameter] @@ -39,7 +42,7 @@ public function getNodeType(): string return Node\Expr\StaticCall::class; } - public function processNode(Node $node, Scope $scope): array + public function processNode(Node $node, Scope&NodeCallbackInvoker&CollectedDataEmitter $scope): array { if (!$node->name instanceof Node\Identifier) { return []; @@ -47,6 +50,7 @@ public function processNode(Node $node, Scope $scope): array $isAlways = $this->impossibleCheckTypeHelper->findSpecifiedType($scope, $node); if ($isAlways === null) { + $this->constantConditionInTraitHelper->emitNoError(self::class, $scope, $node); return []; } @@ -71,18 +75,23 @@ public function processNode(Node $node, Scope $scope): array if (!$isAlways) { $method = $this->getMethod($node->class, $node->name->name, $scope); - return [ - $addTip(RuleErrorBuilder::message(sprintf( - 'Call to static method %s::%s()%s will always evaluate to false.', - $method->getDeclaringClass()->getDisplayName(), - $method->getName(), - $this->impossibleCheckTypeHelper->getArgumentsDescription($scope, $node->getArgs()), - )))->identifier('staticMethod.impossibleType')->build(), - ]; + $ruleError = $addTip(RuleErrorBuilder::message(sprintf( + 'Call to static method %s::%s()%s will always evaluate to false.', + $method->getDeclaringClass()->getDisplayName(), + $method->getName(), + $this->impossibleCheckTypeHelper->getArgumentsDescription($scope, $node->getArgs()), + )))->identifier('staticMethod.impossibleType')->build(); + if ($scope->isInTrait()) { + $this->constantConditionInTraitHelper->emitError(self::class, $scope, $node, false, $ruleError); + return []; + } + + return [$ruleError]; } $isLast = $node->getAttribute(LastConditionVisitor::ATTRIBUTE_NAME); if ($isLast === true && !$this->reportAlwaysTrueInLastCondition) { + $this->constantConditionInTraitHelper->emitNoError(self::class, $scope, $node); return []; } @@ -99,7 +108,13 @@ public function processNode(Node $node, Scope $scope): array $errorBuilder->identifier('staticMethod.alreadyNarrowedType'); - return [$errorBuilder->build()]; + $ruleError = $errorBuilder->build(); + if ($scope->isInTrait()) { + $this->constantConditionInTraitHelper->emitError(self::class, $scope, $node, true, $ruleError); + return []; + } + + return [$ruleError]; } /** diff --git a/src/Rules/Comparison/LogicalXorConstantConditionRule.php b/src/Rules/Comparison/LogicalXorConstantConditionRule.php index 3618a9f9643..16589f28886 100644 --- a/src/Rules/Comparison/LogicalXorConstantConditionRule.php +++ b/src/Rules/Comparison/LogicalXorConstantConditionRule.php @@ -4,6 +4,8 @@ use PhpParser\Node; use PhpParser\Node\Expr\BinaryOp\LogicalXor; +use PHPStan\Analyser\CollectedDataEmitter; +use PHPStan\Analyser\NodeCallbackInvoker; use PHPStan\Analyser\Scope; use PHPStan\DependencyInjection\AutowiredParameter; use PHPStan\DependencyInjection\RegisteredRule; @@ -23,6 +25,7 @@ final class LogicalXorConstantConditionRule implements Rule public function __construct( private ConstantConditionRuleHelper $helper, private PossiblyImpureTipHelper $possiblyImpureTipHelper, + private ConstantConditionInTraitHelper $constantConditionInTraitHelper, #[AutowiredParameter] private bool $treatPhpDocTypesAsCertain, #[AutowiredParameter] @@ -38,9 +41,10 @@ public function getNodeType(): string return LogicalXor::class; } - public function processNode(Node $node, Scope $scope): array + public function processNode(Node $node, Scope&NodeCallbackInvoker&CollectedDataEmitter $scope): array { $errors = []; + $isInTrait = $scope->isInTrait(); $leftType = $this->helper->getBooleanType($scope, $node->left); if ($leftType instanceof ConstantBooleanType) { $addTipLeft = function (RuleErrorBuilder $ruleErrorBuilder) use ($scope, $node): RuleErrorBuilder { @@ -72,8 +76,17 @@ public function processNode(Node $node, Scope $scope): array if ($leftType->getValue() && $isLast === false && !$this->reportAlwaysTrueInLastCondition) { $errorBuilder->tip('Remove remaining cases below this one and this error will disappear too.'); } - $errors[] = $errorBuilder->build(); + $ruleError = $errorBuilder->build(); + if ($isInTrait) { + $this->constantConditionInTraitHelper->emitError(self::class, $scope, $node->left, $leftType->getValue(), $ruleError); + } else { + $errors[] = $ruleError; + } + } else { + $this->constantConditionInTraitHelper->emitNoError(self::class, $scope, $node->left); } + } else { + $this->constantConditionInTraitHelper->emitNoError(self::class, $scope, $node->left); } $rightType = $this->helper->getBooleanType($scope, $node->right); @@ -110,8 +123,17 @@ public function processNode(Node $node, Scope $scope): array if ($rightType->getValue() && $isLast === false && !$this->reportAlwaysTrueInLastCondition) { $errorBuilder->tip('Remove remaining cases below this one and this error will disappear too.'); } - $errors[] = $errorBuilder->build(); + $ruleError = $errorBuilder->build(); + if ($isInTrait) { + $this->constantConditionInTraitHelper->emitError(self::class, $scope, $node->right, $rightType->getValue(), $ruleError); + } else { + $errors[] = $ruleError; + } + } else { + $this->constantConditionInTraitHelper->emitNoError(self::class, $scope, $node->right); } + } else { + $this->constantConditionInTraitHelper->emitNoError(self::class, $scope, $node->right); } return $errors; diff --git a/src/Rules/Comparison/MatchExpressionRule.php b/src/Rules/Comparison/MatchExpressionRule.php index 665b7af1e34..d89fe2dd87c 100644 --- a/src/Rules/Comparison/MatchExpressionRule.php +++ b/src/Rules/Comparison/MatchExpressionRule.php @@ -3,6 +3,8 @@ namespace PHPStan\Rules\Comparison; use PhpParser\Node; +use PHPStan\Analyser\CollectedDataEmitter; +use PHPStan\Analyser\NodeCallbackInvoker; use PHPStan\Analyser\Scope; use PHPStan\DependencyInjection\AutowiredParameter; use PHPStan\DependencyInjection\RegisteredRule; @@ -31,6 +33,7 @@ final class MatchExpressionRule implements Rule public function __construct( private ConstantConditionRuleHelper $constantConditionRuleHelper, private PossiblyImpureTipHelper $possiblyImpureTipHelper, + private ConstantConditionInTraitHelper $constantConditionInTraitHelper, #[AutowiredParameter] private bool $treatPhpDocTypesAsCertain, ) @@ -42,7 +45,7 @@ public function getNodeType(): string return MatchExpressionNode::class; } - public function processNode(Node $node, Scope $scope): array + public function processNode(Node $node, Scope&NodeCallbackInvoker&CollectedDataEmitter $scope): array { $matchCondition = $node->getCondition(); $matchConditionType = $scope->getType($matchCondition); @@ -71,6 +74,7 @@ public function processNode(Node $node, Scope $scope): array $armConditionResult = $armConditionScope->getType($armConditionExpr); if (!$armConditionResult instanceof ConstantBooleanType) { + $this->constantConditionInTraitHelper->emitNoError(self::class, $scope, $armConditionExpr); continue; } if ($armConditionResult->getValue()) { @@ -80,6 +84,7 @@ public function processNode(Node $node, Scope $scope): array if (!$this->treatPhpDocTypesAsCertain) { $armConditionNativeResult = $armConditionScope->getNativeType($armConditionExpr); if (!$armConditionNativeResult instanceof ConstantBooleanType) { + $this->constantConditionInTraitHelper->emitNoError(self::class, $scope, $armConditionExpr); continue; } if ($armConditionNativeResult->getValue()) { @@ -90,6 +95,7 @@ public function processNode(Node $node, Scope $scope): array if ($matchConditionType instanceof ConstantBooleanType) { $armConditionStandaloneResult = $this->constantConditionRuleHelper->getBooleanType($armConditionScope, $armCondition->getCondition()); if (!$armConditionStandaloneResult instanceof ConstantBooleanType) { + $this->constantConditionInTraitHelper->emitNoError(self::class, $scope, $armConditionExpr); continue; } } @@ -102,11 +108,17 @@ public function processNode(Node $node, Scope $scope): array $armConditionScope->getType($armCondition->getCondition())->describe(VerbosityLevel::value()), ))->line($armLine)->identifier('match.alwaysFalse'); $this->possiblyImpureTipHelper->addTip($armConditionScope, $armConditionExpr, $errorBuilder); - $errors[] = $errorBuilder->build(); + $ruleError = $errorBuilder->build(); + if ($scope->isInTrait()) { + $this->constantConditionInTraitHelper->emitError(self::class, $scope, $armConditionExpr, false, $ruleError); + } else { + $errors[] = $ruleError; + } continue; } if ($i === $armsCount - 1) { + $this->constantConditionInTraitHelper->emitNoError(self::class, $scope, $armConditionExpr); continue; } @@ -120,7 +132,12 @@ public function processNode(Node $node, Scope $scope): array ->identifier('match.alwaysTrue') ->tip('Remove remaining cases below this one and this error will disappear too.'); $this->possiblyImpureTipHelper->addTip($armConditionScope, $armConditionExpr, $errorBuilder); - $errors[] = $errorBuilder->build(); + $ruleError = $errorBuilder->build(); + if ($scope->isInTrait()) { + $this->constantConditionInTraitHelper->emitError(self::class, $scope, $armConditionExpr, true, $ruleError); + } else { + $errors[] = $ruleError; + } } } diff --git a/src/Rules/Comparison/NumberComparisonOperatorsConstantConditionRule.php b/src/Rules/Comparison/NumberComparisonOperatorsConstantConditionRule.php index 5cd0ab417e3..392e702f497 100644 --- a/src/Rules/Comparison/NumberComparisonOperatorsConstantConditionRule.php +++ b/src/Rules/Comparison/NumberComparisonOperatorsConstantConditionRule.php @@ -4,6 +4,8 @@ use PhpParser\Node; use PhpParser\Node\Expr\BinaryOp; +use PHPStan\Analyser\CollectedDataEmitter; +use PHPStan\Analyser\NodeCallbackInvoker; use PHPStan\Analyser\Scope; use PHPStan\DependencyInjection\AutowiredParameter; use PHPStan\DependencyInjection\RegisteredRule; @@ -24,6 +26,7 @@ final class NumberComparisonOperatorsConstantConditionRule implements Rule public function __construct( private PossiblyImpureTipHelper $possiblyImpureTipHelper, + private ConstantConditionInTraitHelper $constantConditionInTraitHelper, #[AutowiredParameter] private bool $treatPhpDocTypesAsCertain, #[AutowiredParameter(ref: '%tips.treatPhpDocTypesAsCertain%')] @@ -39,7 +42,7 @@ public function getNodeType(): string public function processNode( Node $node, - Scope $scope, + Scope&NodeCallbackInvoker&CollectedDataEmitter $scope, ): array { if ( @@ -88,17 +91,22 @@ public function processNode( throw new ShouldNotHappenException(); } - return [ - $addTip(RuleErrorBuilder::message(sprintf( - 'Comparison operation "%s" between %s and %s is always %s.', - $node->getOperatorSigil(), - $scope->getType($node->left)->describe(VerbosityLevel::value()), - $scope->getType($node->right)->describe(VerbosityLevel::value()), - $exprType->getValue() ? 'true' : 'false', - )))->identifier(sprintf('%s.always%s', $nodeType, $exprType->getValue() ? 'True' : 'False'))->build(), - ]; + $ruleError = $addTip(RuleErrorBuilder::message(sprintf( + 'Comparison operation "%s" between %s and %s is always %s.', + $node->getOperatorSigil(), + $scope->getType($node->left)->describe(VerbosityLevel::value()), + $scope->getType($node->right)->describe(VerbosityLevel::value()), + $exprType->getValue() ? 'true' : 'false', + )))->identifier(sprintf('%s.always%s', $nodeType, $exprType->getValue() ? 'True' : 'False'))->build(); + if ($scope->isInTrait()) { + $this->constantConditionInTraitHelper->emitError(self::class, $scope, $node, $exprType->getValue(), $ruleError); + return []; + } + + return [$ruleError]; } + $this->constantConditionInTraitHelper->emitNoError(self::class, $scope, $node); return []; } diff --git a/src/Rules/Comparison/StrictComparisonOfDifferentTypesRule.php b/src/Rules/Comparison/StrictComparisonOfDifferentTypesRule.php index b8b0ca09db2..dc2889aa170 100644 --- a/src/Rules/Comparison/StrictComparisonOfDifferentTypesRule.php +++ b/src/Rules/Comparison/StrictComparisonOfDifferentTypesRule.php @@ -3,7 +3,9 @@ namespace PHPStan\Rules\Comparison; use PhpParser\Node; +use PHPStan\Analyser\CollectedDataEmitter; use PHPStan\Analyser\MutatingScope; +use PHPStan\Analyser\NodeCallbackInvoker; use PHPStan\Analyser\RicherScopeGetTypeHelper; use PHPStan\Analyser\Scope; use PHPStan\DependencyInjection\AutowiredParameter; @@ -28,6 +30,7 @@ final class StrictComparisonOfDifferentTypesRule implements Rule public function __construct( private RicherScopeGetTypeHelper $richerScopeGetTypeHelper, private PossiblyImpureTipHelper $possiblyImpureTipHelper, + private ConstantConditionInTraitHelper $constantConditionInTraitHelper, #[AutowiredParameter] private bool $treatPhpDocTypesAsCertain, #[AutowiredParameter] @@ -43,7 +46,7 @@ public function getNodeType(): string return Node\Expr\BinaryOp::class; } - public function processNode(Node $node, Scope $scope): array + public function processNode(Node $node, Scope&NodeCallbackInvoker&CollectedDataEmitter $scope): array { if (!$scope instanceof MutatingScope) { throw new ShouldNotHappenException(); @@ -59,6 +62,7 @@ public function processNode(Node $node, Scope $scope): array $nodeType = $nodeTypeResult->type; if (!$nodeType instanceof ConstantBooleanType) { + $this->constantConditionInTraitHelper->emitNoError(self::class, $scope, $node); return []; } @@ -116,18 +120,26 @@ public function processNode(Node $node, Scope $scope): array } if (!$nodeType->getValue()) { - return [ - $addTip(RuleErrorBuilder::message(sprintf( - 'Strict comparison using %s between %s and %s will always evaluate to false.', - $node->getOperatorSigil(), - $leftType->describe($verbosity), - $rightType->describe($verbosity), - )))->identifier(sprintf('%s.alwaysFalse', $node instanceof Node\Expr\BinaryOp\Identical ? 'identical' : 'notIdentical'))->build(), - ]; + $ruleError = $addTip(RuleErrorBuilder::message(sprintf( + 'Strict comparison using %s between %s and %s will always evaluate to false.', + $node->getOperatorSigil(), + $leftType->describe($verbosity), + $rightType->describe($verbosity), + )))->identifier(sprintf('%s.alwaysFalse', $node instanceof Node\Expr\BinaryOp\Identical ? 'identical' : 'notIdentical'))->build(); + if ($scope->isInTrait()) { + $this->constantConditionInTraitHelper->emitError(self::class, $scope, $node, false, $ruleError); + return []; + } + + return [$ruleError]; } $isLast = $node->getAttribute(LastConditionVisitor::ATTRIBUTE_NAME); if ($isLast === true && !$this->reportAlwaysTrueInLastCondition) { + if ($scope->isInTrait()) { + $this->constantConditionInTraitHelper->emitNoError(self::class, $scope, $node); + return []; + } return []; } @@ -150,10 +162,13 @@ public function processNode(Node $node, Scope $scope): array } $errorBuilder->identifier(sprintf('%s.alwaysTrue', $node instanceof Node\Expr\BinaryOp\Identical ? 'identical' : 'notIdentical')); + $ruleError = $errorBuilder->build(); + if ($scope->isInTrait()) { + $this->constantConditionInTraitHelper->emitError(self::class, $scope, $node, true, $ruleError); + return []; + } - return [ - $errorBuilder->build(), - ]; + return [$ruleError]; } } diff --git a/src/Rules/Comparison/TernaryOperatorConstantConditionRule.php b/src/Rules/Comparison/TernaryOperatorConstantConditionRule.php index 273405ee36b..ddb606965c9 100644 --- a/src/Rules/Comparison/TernaryOperatorConstantConditionRule.php +++ b/src/Rules/Comparison/TernaryOperatorConstantConditionRule.php @@ -3,6 +3,8 @@ namespace PHPStan\Rules\Comparison; use PhpParser\Node; +use PHPStan\Analyser\CollectedDataEmitter; +use PHPStan\Analyser\NodeCallbackInvoker; use PHPStan\Analyser\Scope; use PHPStan\DependencyInjection\AutowiredParameter; use PHPStan\DependencyInjection\RegisteredRule; @@ -21,6 +23,7 @@ final class TernaryOperatorConstantConditionRule implements Rule public function __construct( private ConstantConditionRuleHelper $helper, private PossiblyImpureTipHelper $possiblyImpureTipHelper, + private ConstantConditionInTraitHelper $constantConditionInTraitHelper, #[AutowiredParameter] private bool $treatPhpDocTypesAsCertain, #[AutowiredParameter(ref: '%tips.treatPhpDocTypesAsCertain%')] @@ -36,7 +39,7 @@ public function getNodeType(): string public function processNode( Node $node, - Scope $scope, + Scope&NodeCallbackInvoker&CollectedDataEmitter $scope, ): array { $exprType = $this->helper->getBooleanType($scope, $node->cond); @@ -58,14 +61,19 @@ public function processNode( return $this->possiblyImpureTipHelper->addTip($scope, $node->cond, $ruleErrorBuilder); }; - return [ - $addTip(RuleErrorBuilder::message(sprintf( - 'Ternary operator condition is always %s.', - $exprType->getValue() ? 'true' : 'false', - )))->identifier(sprintf('ternary.always%s', $exprType->getValue() ? 'True' : 'False'))->build(), - ]; + $ruleError = $addTip(RuleErrorBuilder::message(sprintf( + 'Ternary operator condition is always %s.', + $exprType->getValue() ? 'true' : 'false', + )))->identifier(sprintf('ternary.always%s', $exprType->getValue() ? 'True' : 'False'))->build(); + if ($scope->isInTrait()) { + $this->constantConditionInTraitHelper->emitError(self::class, $scope, $node->cond, $exprType->getValue(), $ruleError); + return []; + } + + return [$ruleError]; } + $this->constantConditionInTraitHelper->emitNoError(self::class, $scope, $node->cond); return []; } diff --git a/src/Rules/Comparison/WhileLoopAlwaysFalseConditionRule.php b/src/Rules/Comparison/WhileLoopAlwaysFalseConditionRule.php index 77b9b7543b5..d6bd7479dc8 100644 --- a/src/Rules/Comparison/WhileLoopAlwaysFalseConditionRule.php +++ b/src/Rules/Comparison/WhileLoopAlwaysFalseConditionRule.php @@ -4,6 +4,8 @@ use PhpParser\Node; use PhpParser\Node\Stmt\While_; +use PHPStan\Analyser\CollectedDataEmitter; +use PHPStan\Analyser\NodeCallbackInvoker; use PHPStan\Analyser\Scope; use PHPStan\DependencyInjection\AutowiredParameter; use PHPStan\DependencyInjection\RegisteredRule; @@ -21,6 +23,7 @@ final class WhileLoopAlwaysFalseConditionRule implements Rule public function __construct( private ConstantConditionRuleHelper $helper, private PossiblyImpureTipHelper $possiblyImpureTipHelper, + private ConstantConditionInTraitHelper $constantConditionInTraitHelper, #[AutowiredParameter] private bool $treatPhpDocTypesAsCertain, #[AutowiredParameter(ref: '%tips.treatPhpDocTypesAsCertain%')] @@ -36,7 +39,7 @@ public function getNodeType(): string public function processNode( Node $node, - Scope $scope, + Scope&NodeCallbackInvoker&CollectedDataEmitter $scope, ): array { $exprType = $this->helper->getBooleanType($scope, $node->cond); @@ -59,13 +62,18 @@ public function processNode( return $this->possiblyImpureTipHelper->addTip($scope, $node->cond, $ruleErrorBuilder); }; - return [ - $addTip(RuleErrorBuilder::message('While loop condition is always false.'))->line($node->cond->getStartLine()) - ->identifier('while.alwaysFalse') - ->build(), - ]; + $ruleError = $addTip(RuleErrorBuilder::message('While loop condition is always false.'))->line($node->cond->getStartLine()) + ->identifier('while.alwaysFalse') + ->build(); + if ($scope->isInTrait()) { + $this->constantConditionInTraitHelper->emitError(self::class, $scope, $node->cond, false, $ruleError); + return []; + } + + return [$ruleError]; } + $this->constantConditionInTraitHelper->emitNoError(self::class, $scope, $node->cond); return []; } diff --git a/src/Rules/Comparison/WhileLoopAlwaysTrueConditionRule.php b/src/Rules/Comparison/WhileLoopAlwaysTrueConditionRule.php index bfc50e3e48a..ef942cfe0cf 100644 --- a/src/Rules/Comparison/WhileLoopAlwaysTrueConditionRule.php +++ b/src/Rules/Comparison/WhileLoopAlwaysTrueConditionRule.php @@ -6,6 +6,8 @@ use PhpParser\Node\Scalar\Int_; use PhpParser\Node\Stmt\Break_; use PhpParser\Node\Stmt\Continue_; +use PHPStan\Analyser\CollectedDataEmitter; +use PHPStan\Analyser\NodeCallbackInvoker; use PHPStan\Analyser\Scope; use PHPStan\DependencyInjection\AutowiredParameter; use PHPStan\DependencyInjection\RegisteredRule; @@ -25,6 +27,7 @@ final class WhileLoopAlwaysTrueConditionRule implements Rule public function __construct( private ConstantConditionRuleHelper $helper, private PossiblyImpureTipHelper $possiblyImpureTipHelper, + private ConstantConditionInTraitHelper $constantConditionInTraitHelper, #[AutowiredParameter] private bool $treatPhpDocTypesAsCertain, #[AutowiredParameter(ref: '%tips.treatPhpDocTypesAsCertain%')] @@ -40,7 +43,7 @@ public function getNodeType(): string public function processNode( Node $node, - Scope $scope, + Scope&NodeCallbackInvoker&CollectedDataEmitter $scope, ): array { foreach ($node->getExitPoints() as $exitPoint) { @@ -70,12 +73,14 @@ public function processNode( $exprType = $this->helper->getBooleanType($scope, $originalNode->cond); if ($exprType->isTrue()->yes()) { if ($node->hasYield()) { + $this->constantConditionInTraitHelper->emitNoError(self::class, $scope, $originalNode->cond); return []; } $ref = $scope->getFunction() ?? $scope->getAnonymousFunctionReflection(); if ($ref !== null && $ref->getReturnType() instanceof NeverType) { + $this->constantConditionInTraitHelper->emitNoError(self::class, $scope, $originalNode->cond); return []; } @@ -97,13 +102,18 @@ public function processNode( return $this->possiblyImpureTipHelper->addTip($scope, $originalNode->cond, $ruleErrorBuilder); }; - return [ - $addTip(RuleErrorBuilder::message('While loop condition is always true.'))->line($originalNode->cond->getStartLine()) - ->identifier('while.alwaysTrue') - ->build(), - ]; + $ruleError = $addTip(RuleErrorBuilder::message('While loop condition is always true.'))->line($originalNode->cond->getStartLine()) + ->identifier('while.alwaysTrue') + ->build(); + if ($scope->isInTrait()) { + $this->constantConditionInTraitHelper->emitError(self::class, $scope, $originalNode->cond, true, $ruleError); + return []; + } + + return [$ruleError]; } + $this->constantConditionInTraitHelper->emitNoError(self::class, $scope, $originalNode->cond); return []; } diff --git a/src/Rules/Constants/ConstantAttributesRule.php b/src/Rules/Constants/ConstantAttributesRule.php index f944a7981ee..f076769bf9b 100644 --- a/src/Rules/Constants/ConstantAttributesRule.php +++ b/src/Rules/Constants/ConstantAttributesRule.php @@ -4,6 +4,8 @@ use Attribute; use PhpParser\Node; +use PHPStan\Analyser\CollectedDataEmitter; +use PHPStan\Analyser\NodeCallbackInvoker; use PHPStan\Analyser\Scope; use PHPStan\DependencyInjection\RegisteredRule; use PHPStan\Php\PhpVersion; @@ -31,7 +33,7 @@ public function getNodeType(): string return Node\Stmt\Const_::class; } - public function processNode(Node $node, Scope $scope): array + public function processNode(Node $node, Scope&NodeCallbackInvoker&CollectedDataEmitter $scope): array { if ($node->attrGroups === []) { return []; diff --git a/src/Rules/Constants/MissingClassConstantTypehintRule.php b/src/Rules/Constants/MissingClassConstantTypehintRule.php index bb2d10164bc..d8887c773df 100644 --- a/src/Rules/Constants/MissingClassConstantTypehintRule.php +++ b/src/Rules/Constants/MissingClassConstantTypehintRule.php @@ -58,10 +58,9 @@ private function processSingleConstant(ClassReflection $classReflection, string } $errors = []; - foreach ($this->missingTypehintCheck->getIterableTypesWithMissingValueTypehint($constantType) as $iterableType) { - $iterableTypeDescription = $iterableType->describe(VerbosityLevel::typeOnly()); + foreach ($this->missingTypehintCheck->getIterableTypesWithMissingValueTypehint($constantType) as $iterableTypeDescription) { $errors[] = RuleErrorBuilder::message(sprintf( - 'Constant %s::%s type has no value type specified in iterable type %s.', + 'Constant %s::%s type has no value type specified in %s.', $constantReflection->getDeclaringClass()->getDisplayName(), $constantName, $iterableTypeDescription, diff --git a/src/Rules/EnumCases/EnumCaseAttributesRule.php b/src/Rules/EnumCases/EnumCaseAttributesRule.php index f6489f2e871..b0e670af317 100644 --- a/src/Rules/EnumCases/EnumCaseAttributesRule.php +++ b/src/Rules/EnumCases/EnumCaseAttributesRule.php @@ -4,6 +4,8 @@ use Attribute; use PhpParser\Node; +use PHPStan\Analyser\CollectedDataEmitter; +use PHPStan\Analyser\NodeCallbackInvoker; use PHPStan\Analyser\Scope; use PHPStan\DependencyInjection\RegisteredRule; use PHPStan\Rules\AttributesCheck; @@ -25,7 +27,7 @@ public function getNodeType(): string return Node\Stmt\EnumCase::class; } - public function processNode(Node $node, Scope $scope): array + public function processNode(Node $node, Scope&NodeCallbackInvoker&CollectedDataEmitter $scope): array { return $this->attributesCheck->check( $scope, diff --git a/src/Rules/FunctionCallParametersCheck.php b/src/Rules/FunctionCallParametersCheck.php index 4308efe56c3..05ba58a069b 100644 --- a/src/Rules/FunctionCallParametersCheck.php +++ b/src/Rules/FunctionCallParametersCheck.php @@ -4,15 +4,19 @@ use PhpParser\Node; use PhpParser\Node\Expr; +use PHPStan\Analyser\CollectedDataEmitter; use PHPStan\Analyser\MutatingScope; +use PHPStan\Analyser\NodeCallbackInvoker; use PHPStan\Analyser\Scope; use PHPStan\DependencyInjection\AutowiredParameter; use PHPStan\DependencyInjection\AutowiredService; +use PHPStan\Reflection\ConstantReflection; use PHPStan\Reflection\ExtendedParameterReflection; use PHPStan\Reflection\ParameterReflection; use PHPStan\Reflection\ParametersAcceptor; use PHPStan\Reflection\ReflectionProvider; use PHPStan\Reflection\ResolvedFunctionVariant; +use PHPStan\Rules\Methods\NamedArgumentParameterMethodCallsCollector; use PHPStan\Rules\PhpDoc\UnresolvableTypeHelper; use PHPStan\Rules\Properties\PropertyReflectionFinder; use PHPStan\ShouldNotHappenException; @@ -31,11 +35,13 @@ use function array_fill; use function array_key_exists; use function array_last; +use function array_merge; use function count; use function implode; use function in_array; use function is_int; use function is_string; +use function lcfirst; use function max; use function sprintf; @@ -63,11 +69,12 @@ public function __construct( /** * @param 'attribute'|'callable'|'method'|'staticMethod'|'function'|'new' $nodeType + * @param array{class-string, string}|null $renamedNamedArgumentParameterData * @return list */ public function check( ParametersAcceptor $parametersAcceptor, - Scope $scope, + Scope&NodeCallbackInvoker&CollectedDataEmitter $scope, bool $isBuiltin, Node\Expr\FuncCall|Node\Expr\MethodCall|Node\Expr\StaticCall|Node\Expr\New_ $funcCall, string $nodeType, @@ -87,6 +94,10 @@ public function check( string $unresolvableReturnTypeMessage, string $unresolvableParameterTypeMessage, string $namedArgumentMessage, + string $invalidConstantMessage, + string $exclusiveConstantsMessage, + string $bitmaskNotAllowedMessage, + ?array $renamedNamedArgumentParameterData, ): array { if ($funcCall instanceof Node\Expr\MethodCall || $funcCall instanceof Node\Expr\StaticCall || $funcCall instanceof Node\Expr\FuncCall) { @@ -97,7 +108,14 @@ public function check( $functionParametersMinCount = 0; $functionParametersMaxCount = 0; + $allowedConstantsTypes = []; foreach ($parametersAcceptor->getParameters() as $parameter) { + if ( + $parameter instanceof ExtendedParameterReflection + && $parameter->getAllowedConstants() !== null + ) { + $allowedConstantsTypes[] = $parameter->getType(); + } if (!$parameter->isOptional()) { $functionParametersMinCount++; } @@ -105,6 +123,11 @@ public function check( $functionParametersMaxCount++; } + $allowedConstantsType = null; + if (count($allowedConstantsTypes) > 0) { + $allowedConstantsType = TypeCombinator::union(...$allowedConstantsTypes); + } + if ($parametersAcceptor->isVariadic()) { $functionParametersMaxCount = -1; } @@ -354,6 +377,11 @@ public function check( ->build(); } } + } elseif ($argumentName !== null && $renamedNamedArgumentParameterData !== null) { + $scope->emitCollectedData(NamedArgumentParameterMethodCallsCollector::class, array_merge( + $renamedNamedArgumentParameterData, + [$parameter->getName(), $argumentLine], + )); } if ($this->checkArgumentTypes) { @@ -410,6 +438,61 @@ public function check( ->line($argumentLine) ->build(); } + + if ( + $parameter instanceof ExtendedParameterReflection + && $scope->getPhpVersion()->supportsNamedArguments()->yes() + ) { + $constantReflections = $this->resolveConstantReflections($argumentValue, $scope); + if ($constantReflections !== null) { + if ($parameter->getAllowedConstants() !== null) { + $result = $parameter->checkAllowedConstants($constantReflections); + foreach ($result->getDisallowedConstants() as $disallowedConstant) { + $errors[] = RuleErrorBuilder::message(sprintf( + $invalidConstantMessage, + $disallowedConstant->describe(), + lcfirst($this->describeParameter($parameter, $argumentName ?? $i + 1)), + )) + ->identifier('argument.invalidConstant') + ->line($argumentLine) + ->build(); + } + foreach ($result->getViolatedExclusiveGroups() as $group) { + $errors[] = RuleErrorBuilder::message(sprintf( + $exclusiveConstantsMessage, + implode(', ', $group), + lcfirst($this->describeParameter($parameter, $argumentName ?? $i + 1)), + )) + ->identifier('argument.exclusiveConstants') + ->line($argumentLine) + ->build(); + } + if ($result->isBitmaskNotAllowed()) { + $errors[] = RuleErrorBuilder::message(sprintf( + $bitmaskNotAllowedMessage, + lcfirst($this->describeParameter($parameter, $argumentName ?? $i + 1)), + )) + ->identifier('argument.bitmaskNotAllowed') + ->line($argumentLine) + ->build(); + } + } elseif ($isBuiltin && $allowedConstantsType !== null && $allowedConstantsType->isSuperTypeOf($parameterType)->yes()) { + foreach ($constantReflections as $constantReflection) { + if ($constantReflection->isBuiltin()->no()) { + continue; + } + $errors[] = RuleErrorBuilder::message(sprintf( + $invalidConstantMessage, + $constantReflection->describe(), + lcfirst($this->describeParameter($parameter, $argumentName ?? $i + 1)), + )) + ->identifier('argument.invalidConstant') + ->line($argumentLine) + ->build(); + } + } + } + } } if ( @@ -705,6 +788,58 @@ private function describeParameter(ParameterReflection $parameter, int|string|nu return implode(' ', $parts); } + /** + * @return list|null Null when the expression is not a constant or bitmask of constants + */ + private function resolveConstantReflections(Expr $expr, Scope $scope): ?array + { + if ($expr instanceof Expr\ConstFetch) { + $lowerName = $expr->name->toLowerString(); + if (in_array($lowerName, ['null', 'true', 'false'], true)) { + return null; + } + + if (!$this->reflectionProvider->hasConstant($expr->name, $scope)) { + return null; + } + + return [$this->reflectionProvider->getConstant($expr->name, $scope)]; + } + + if ($expr instanceof Expr\ClassConstFetch) { + if (!$expr->class instanceof Node\Name) { + return null; + } + if (!$expr->name instanceof Node\Identifier) { + return null; + } + + $className = $scope->resolveName($expr->class); + if (!$this->reflectionProvider->hasClass($className)) { + return null; + } + + $classReflection = $this->reflectionProvider->getClass($className); + if (!$classReflection->hasConstant($expr->name->name)) { + return null; + } + + return [$classReflection->getConstant($expr->name->name)]; + } + + if ($expr instanceof Expr\BinaryOp\BitwiseOr) { + $left = $this->resolveConstantReflections($expr->left, $scope); + $right = $this->resolveConstantReflections($expr->right, $scope); + if ($left === null || $right === null) { + return null; + } + + return [...$left, ...$right]; + } + + return null; + } + private function callReturnsByReference(Expr $expr, Scope $scope): bool { if ($expr instanceof Node\Expr\MethodCall) { diff --git a/src/Rules/Functions/ArrowFunctionAttributesRule.php b/src/Rules/Functions/ArrowFunctionAttributesRule.php index 092758eaad9..862583c6c55 100644 --- a/src/Rules/Functions/ArrowFunctionAttributesRule.php +++ b/src/Rules/Functions/ArrowFunctionAttributesRule.php @@ -4,6 +4,8 @@ use Attribute; use PhpParser\Node; +use PHPStan\Analyser\CollectedDataEmitter; +use PHPStan\Analyser\NodeCallbackInvoker; use PHPStan\Analyser\Scope; use PHPStan\DependencyInjection\RegisteredRule; use PHPStan\Node\InArrowFunctionNode; @@ -26,7 +28,7 @@ public function getNodeType(): string return InArrowFunctionNode::class; } - public function processNode(Node $node, Scope $scope): array + public function processNode(Node $node, Scope&NodeCallbackInvoker&CollectedDataEmitter $scope): array { return $this->attributesCheck->check( $scope, diff --git a/src/Rules/Functions/CallCallablesRule.php b/src/Rules/Functions/CallCallablesRule.php index 4ab0af4b014..85c33490b1b 100644 --- a/src/Rules/Functions/CallCallablesRule.php +++ b/src/Rules/Functions/CallCallablesRule.php @@ -3,6 +3,8 @@ namespace PHPStan\Rules\Functions; use PhpParser\Node; +use PHPStan\Analyser\CollectedDataEmitter; +use PHPStan\Analyser\NodeCallbackInvoker; use PHPStan\Analyser\NullsafeOperatorHelper; use PHPStan\Analyser\Scope; use PHPStan\DependencyInjection\AutowiredParameter; @@ -47,7 +49,7 @@ public function getNodeType(): string public function processNode( Node $node, - Scope $scope, + Scope&NodeCallbackInvoker&CollectedDataEmitter $scope, ): array { if (!$node->name instanceof Node\Expr) { @@ -139,6 +141,10 @@ public function processNode( 'Return type of call to ' . $callableDescription . ' contains unresolvable type.', '%s of ' . $callableDescription . ' contains unresolvable type.', ucfirst($callableDescription) . ' invoked with %s, but it\'s not allowed because of @no-named-arguments.', + 'Constant %s is not allowed for %s of ' . $callableDescription . '.', + 'Constants %s cannot be combined for %s of ' . $callableDescription . '.', + 'Combining constants with | is not allowed for %s of ' . $callableDescription . '.', + null, ), ); } diff --git a/src/Rules/Functions/CallToFunctionParametersRule.php b/src/Rules/Functions/CallToFunctionParametersRule.php index 39f6f7cfeac..f01a081fdae 100644 --- a/src/Rules/Functions/CallToFunctionParametersRule.php +++ b/src/Rules/Functions/CallToFunctionParametersRule.php @@ -4,6 +4,8 @@ use PhpParser\Node; use PhpParser\Node\Expr\FuncCall; +use PHPStan\Analyser\CollectedDataEmitter; +use PHPStan\Analyser\NodeCallbackInvoker; use PHPStan\Analyser\Scope; use PHPStan\DependencyInjection\RegisteredRule; use PHPStan\Internal\SprintfHelper; @@ -28,7 +30,7 @@ public function getNodeType(): string return FuncCall::class; } - public function processNode(Node $node, Scope $scope): array + public function processNode(Node $node, Scope&NodeCallbackInvoker&CollectedDataEmitter $scope): array { if (!($node->name instanceof Node\Name)) { return []; @@ -68,6 +70,10 @@ public function processNode(Node $node, Scope $scope): array 'Return type of call to function ' . $functionName . ' contains unresolvable type.', '%s of function ' . $functionName . ' contains unresolvable type.', 'Function ' . $functionName . ' invoked with %s, but it\'s not allowed because of @no-named-arguments.', + 'Constant %s is not allowed for %s of function ' . $functionName . '.', + 'Constants %s cannot be combined for %s of function ' . $functionName . '.', + 'Combining constants with | is not allowed for %s of function ' . $functionName . '.', + null, ); } diff --git a/src/Rules/Functions/CallUserFuncRule.php b/src/Rules/Functions/CallUserFuncRule.php index 3757f826fcd..cfc8f1955be 100644 --- a/src/Rules/Functions/CallUserFuncRule.php +++ b/src/Rules/Functions/CallUserFuncRule.php @@ -5,6 +5,8 @@ use PhpParser\Node; use PhpParser\Node\Expr\FuncCall; use PHPStan\Analyser\ArgumentsNormalizer; +use PHPStan\Analyser\CollectedDataEmitter; +use PHPStan\Analyser\NodeCallbackInvoker; use PHPStan\Analyser\Scope; use PHPStan\DependencyInjection\RegisteredRule; use PHPStan\Reflection\ReflectionProvider; @@ -33,7 +35,7 @@ public function getNodeType(): string return FuncCall::class; } - public function processNode(Node $node, Scope $scope): array + public function processNode(Node $node, Scope&NodeCallbackInvoker&CollectedDataEmitter $scope): array { if (!$node->name instanceof Node\Name) { return []; @@ -92,6 +94,10 @@ public function processNode(Node $node, Scope $scope): array 'Return type of call to ' . $callableDescription . ' contains unresolvable type.', '%s of ' . $callableDescription . ' contains unresolvable type.', ucfirst($callableDescription) . ' invoked with %s, but it\'s not allowed because of @no-named-arguments.', + 'Constant %s is not allowed for %s of ' . $callableDescription . '.', + 'Constants %s cannot be combined for %s of ' . $callableDescription . '.', + 'Combining constants with | is not allowed for %s of ' . $callableDescription . '.', + null, ); } diff --git a/src/Rules/Functions/ClosureAttributesRule.php b/src/Rules/Functions/ClosureAttributesRule.php index d9dd348f9c3..54ee5218644 100644 --- a/src/Rules/Functions/ClosureAttributesRule.php +++ b/src/Rules/Functions/ClosureAttributesRule.php @@ -4,6 +4,8 @@ use Attribute; use PhpParser\Node; +use PHPStan\Analyser\CollectedDataEmitter; +use PHPStan\Analyser\NodeCallbackInvoker; use PHPStan\Analyser\Scope; use PHPStan\DependencyInjection\RegisteredRule; use PHPStan\Node\InClosureNode; @@ -26,7 +28,7 @@ public function getNodeType(): string return InClosureNode::class; } - public function processNode(Node $node, Scope $scope): array + public function processNode(Node $node, Scope&NodeCallbackInvoker&CollectedDataEmitter $scope): array { return $this->attributesCheck->check( $scope, diff --git a/src/Rules/Functions/FunctionAttributesRule.php b/src/Rules/Functions/FunctionAttributesRule.php index a7b6547cb01..605982c5865 100644 --- a/src/Rules/Functions/FunctionAttributesRule.php +++ b/src/Rules/Functions/FunctionAttributesRule.php @@ -4,6 +4,8 @@ use Attribute; use PhpParser\Node; +use PHPStan\Analyser\CollectedDataEmitter; +use PHPStan\Analyser\NodeCallbackInvoker; use PHPStan\Analyser\Scope; use PHPStan\DependencyInjection\RegisteredRule; use PHPStan\Node\InFunctionNode; @@ -26,7 +28,7 @@ public function getNodeType(): string return InFunctionNode::class; } - public function processNode(Node $node, Scope $scope): array + public function processNode(Node $node, Scope&NodeCallbackInvoker&CollectedDataEmitter $scope): array { return $this->attributesCheck->check( $scope, diff --git a/src/Rules/Functions/MissingFunctionParameterTypehintRule.php b/src/Rules/Functions/MissingFunctionParameterTypehintRule.php index 1246dc81e9c..4476427835c 100644 --- a/src/Rules/Functions/MissingFunctionParameterTypehintRule.php +++ b/src/Rules/Functions/MissingFunctionParameterTypehintRule.php @@ -80,10 +80,9 @@ private function checkFunctionParameter(FunctionReflection $functionReflection, } $messages = []; - foreach ($this->missingTypehintCheck->getIterableTypesWithMissingValueTypehint($parameterType) as $iterableType) { - $iterableTypeDescription = $iterableType->describe(VerbosityLevel::typeOnly()); + foreach ($this->missingTypehintCheck->getIterableTypesWithMissingValueTypehint($parameterType) as $iterableTypeDescription) { $messages[] = RuleErrorBuilder::message(sprintf( - 'Function %s() has %s with no value type specified in iterable type %s.', + 'Function %s() has %s with no value type specified in %s.', $functionReflection->getName(), $parameterMessage, $iterableTypeDescription, diff --git a/src/Rules/Functions/MissingFunctionReturnTypehintRule.php b/src/Rules/Functions/MissingFunctionReturnTypehintRule.php index 0fd30c79f99..09c276a9b49 100644 --- a/src/Rules/Functions/MissingFunctionReturnTypehintRule.php +++ b/src/Rules/Functions/MissingFunctionReturnTypehintRule.php @@ -48,9 +48,8 @@ public function processNode(Node $node, Scope $scope): array } $messages = []; - foreach ($this->missingTypehintCheck->getIterableTypesWithMissingValueTypehint($returnType) as $iterableType) { - $iterableTypeDescription = $iterableType->describe(VerbosityLevel::typeOnly()); - $messages[] = RuleErrorBuilder::message(sprintf('Function %s() return type has no value type specified in iterable type %s.', $functionReflection->getName(), $iterableTypeDescription)) + foreach ($this->missingTypehintCheck->getIterableTypesWithMissingValueTypehint($returnType) as $iterableTypeDescription) { + $messages[] = RuleErrorBuilder::message(sprintf('Function %s() return type has no value type specified in %s.', $functionReflection->getName(), $iterableTypeDescription)) ->tip(MissingTypehintCheck::MISSING_ITERABLE_VALUE_TYPE_TIP) ->identifier('missingType.iterableValue') ->build(); diff --git a/src/Rules/Functions/ParamAttributesRule.php b/src/Rules/Functions/ParamAttributesRule.php index ad67abb22b7..c04f2452bbb 100644 --- a/src/Rules/Functions/ParamAttributesRule.php +++ b/src/Rules/Functions/ParamAttributesRule.php @@ -4,6 +4,8 @@ use Attribute; use PhpParser\Node; +use PHPStan\Analyser\CollectedDataEmitter; +use PHPStan\Analyser\NodeCallbackInvoker; use PHPStan\Analyser\Scope; use PHPStan\DependencyInjection\RegisteredRule; use PHPStan\Rules\AttributesCheck; @@ -25,7 +27,7 @@ public function getNodeType(): string return Node\Param::class; } - public function processNode(Node $node, Scope $scope): array + public function processNode(Node $node, Scope&NodeCallbackInvoker&CollectedDataEmitter $scope): array { $targetName = 'parameter'; $targetType = Attribute::TARGET_PARAMETER; diff --git a/src/Rules/Methods/CallMethodsRule.php b/src/Rules/Methods/CallMethodsRule.php index 1f042288f0e..51881bfbea2 100644 --- a/src/Rules/Methods/CallMethodsRule.php +++ b/src/Rules/Methods/CallMethodsRule.php @@ -6,6 +6,8 @@ use PhpParser\Node\Expr\BinaryOp\Identical; use PhpParser\Node\Expr\MethodCall; use PhpParser\Node\Scalar\String_; +use PHPStan\Analyser\CollectedDataEmitter; +use PHPStan\Analyser\NodeCallbackInvoker; use PHPStan\Analyser\Scope; use PHPStan\DependencyInjection\RegisteredRule; use PHPStan\Internal\SprintfHelper; @@ -34,7 +36,7 @@ public function getNodeType(): string return MethodCall::class; } - public function processNode(Node $node, Scope $scope): array + public function processNode(Node $node, Scope&NodeCallbackInvoker&CollectedDataEmitter $scope): array { $errors = []; if ($node->name instanceof Node\Identifier) { @@ -62,7 +64,7 @@ public function processNode(Node $node, Scope $scope): array /** * @return list */ - private function processSingleMethodCall(Scope $scope, MethodCall $node, string $methodName): array + private function processSingleMethodCall(Scope&NodeCallbackInvoker&CollectedDataEmitter $scope, MethodCall $node, string $methodName): array { [$errors, $methodReflection] = $this->methodCallCheck->check($scope, $methodName, $node->var, $node->name); if ($methodReflection === null) { @@ -99,6 +101,13 @@ private function processSingleMethodCall(Scope $scope, MethodCall $node, string 'Return type of call to method ' . $messagesMethodName . ' contains unresolvable type.', '%s of method ' . $messagesMethodName . ' contains unresolvable type.', 'Method ' . $messagesMethodName . ' invoked with %s, but it\'s not allowed because of @no-named-arguments.', + 'Constant %s is not allowed for %s of method ' . $messagesMethodName . '.', + 'Constants %s cannot be combined for %s of method ' . $messagesMethodName . '.', + 'Combining constants with | is not allowed for %s of method ' . $messagesMethodName . '.', + !$methodReflection->isPrivate() && !$declaringClass->isFinal() ? [ + $declaringClass->getName(), + $methodReflection->getName(), + ] : null, )); } diff --git a/src/Rules/Methods/CallStaticMethodsRule.php b/src/Rules/Methods/CallStaticMethodsRule.php index 166ad74aced..a275d3fb731 100644 --- a/src/Rules/Methods/CallStaticMethodsRule.php +++ b/src/Rules/Methods/CallStaticMethodsRule.php @@ -6,6 +6,8 @@ use PhpParser\Node\Expr\BinaryOp\Identical; use PhpParser\Node\Expr\StaticCall; use PhpParser\Node\Scalar\String_; +use PHPStan\Analyser\CollectedDataEmitter; +use PHPStan\Analyser\NodeCallbackInvoker; use PHPStan\Analyser\Scope; use PHPStan\DependencyInjection\RegisteredRule; use PHPStan\Internal\SprintfHelper; @@ -35,7 +37,7 @@ public function getNodeType(): string return StaticCall::class; } - public function processNode(Node $node, Scope $scope): array + public function processNode(Node $node, Scope&NodeCallbackInvoker&CollectedDataEmitter $scope): array { $errors = []; if ($node->name instanceof Node\Identifier) { @@ -63,7 +65,7 @@ public function processNode(Node $node, Scope $scope): array /** * @return list */ - private function processSingleMethodCall(Scope $scope, StaticCall $node, string $methodName): array + private function processSingleMethodCall(Scope&NodeCallbackInvoker&CollectedDataEmitter $scope, StaticCall $node, string $methodName): array { [$errors, $method] = $this->methodCallCheck->check($scope, $methodName, $node->class, $node->name); if ($method === null) { @@ -108,6 +110,10 @@ private function processSingleMethodCall(Scope $scope, StaticCall $node, string 'Return type of call to ' . $lowercasedMethodName . ' contains unresolvable type.', '%s of ' . $lowercasedMethodName . ' contains unresolvable type.', $displayMethodName . ' invoked with %s, but it\'s not allowed because of @no-named-arguments.', + 'Constant %s is not allowed for %s of ' . $lowercasedMethodName . '.', + 'Constants %s cannot be combined for %s of ' . $lowercasedMethodName . '.', + 'Combining constants with | is not allowed for %s of ' . $lowercasedMethodName . '.', + null, )); return $errors; diff --git a/src/Rules/Methods/ConsistentConstructorRule.php b/src/Rules/Methods/ConsistentConstructorRule.php index 5eace25f3a6..e3eb6d1513d 100644 --- a/src/Rules/Methods/ConsistentConstructorRule.php +++ b/src/Rules/Methods/ConsistentConstructorRule.php @@ -3,6 +3,8 @@ namespace PHPStan\Rules\Methods; use PhpParser\Node; +use PHPStan\Analyser\CollectedDataEmitter; +use PHPStan\Analyser\NodeCallbackInvoker; use PHPStan\Analyser\Scope; use PHPStan\DependencyInjection\RegisteredRule; use PHPStan\Node\InClassMethodNode; @@ -29,7 +31,7 @@ public function getNodeType(): string return InClassMethodNode::class; } - public function processNode(Node $node, Scope $scope): array + public function processNode(Node $node, Scope&NodeCallbackInvoker&CollectedDataEmitter $scope): array { $method = $node->getMethodReflection(); if (strtolower($method->getName()) !== '__construct') { @@ -47,7 +49,7 @@ public function processNode(Node $node, Scope $scope): array } return array_merge( - $this->methodParameterComparisonHelper->compare($parentConstructor, $parentConstructor->getDeclaringClass(), $method, true), + $this->methodParameterComparisonHelper->compare($parentConstructor, $parentConstructor->getDeclaringClass(), $method, $scope, true), $this->methodVisibilityComparisonHelper->compare($parentConstructor, $parentConstructor->getDeclaringClass(), $method), ); } diff --git a/src/Rules/Methods/MethodAttributesRule.php b/src/Rules/Methods/MethodAttributesRule.php index 56bb6016a1b..ecec4569c95 100644 --- a/src/Rules/Methods/MethodAttributesRule.php +++ b/src/Rules/Methods/MethodAttributesRule.php @@ -4,6 +4,8 @@ use Attribute; use PhpParser\Node; +use PHPStan\Analyser\CollectedDataEmitter; +use PHPStan\Analyser\NodeCallbackInvoker; use PHPStan\Analyser\Scope; use PHPStan\DependencyInjection\RegisteredRule; use PHPStan\Node\InClassMethodNode; @@ -26,7 +28,7 @@ public function getNodeType(): string return InClassMethodNode::class; } - public function processNode(Node $node, Scope $scope): array + public function processNode(Node $node, Scope&NodeCallbackInvoker&CollectedDataEmitter $scope): array { return $this->attributesCheck->check( $scope, diff --git a/src/Rules/Methods/MethodCallWithPossiblyRenamedNamedArgumentRule.php b/src/Rules/Methods/MethodCallWithPossiblyRenamedNamedArgumentRule.php new file mode 100644 index 00000000000..55d7e9d8ad1 --- /dev/null +++ b/src/Rules/Methods/MethodCallWithPossiblyRenamedNamedArgumentRule.php @@ -0,0 +1,78 @@ + + */ +#[RegisteredRule(level: 0)] +final class MethodCallWithPossiblyRenamedNamedArgumentRule implements Rule +{ + + public function getNodeType(): string + { + return CollectedDataNode::class; + } + + public function processNode(Node $node, NodeCallbackInvoker&Scope&CollectedDataEmitter $scope): array + { + $calls = []; + foreach ($node->get(NamedArgumentParameterMethodCallsCollector::class) as $file => $data) { + foreach ($data as [$declaringClassName, $methodName, $parameterName, $callLine]) { + $calls[$declaringClassName][$methodName][$parameterName][] = [$file, $callLine]; + } + } + + $errors = []; + foreach ($node->get(OverridingMethodRenamesParameterCollector::class) as $data) { + foreach ($data as [$prototypeDeclaringClassName, $methodName, $methodDeclaringClassName, $prototypeParameterName, $methodParameterName]) { + if (!array_key_exists($prototypeDeclaringClassName, $calls)) { + continue; + } + + $prototypeClassCalls = $calls[$prototypeDeclaringClassName]; + if (!array_key_exists($methodName, $prototypeClassCalls)) { + continue; + } + + $prototypeMethodCalls = $prototypeClassCalls[$methodName]; + if (!array_key_exists($prototypeParameterName, $prototypeMethodCalls)) { + continue; + } + + if (!array_key_exists($prototypeParameterName, $prototypeMethodCalls)) { + continue; + } + + $callsWithParameter = $prototypeMethodCalls[$prototypeParameterName]; + foreach ($callsWithParameter as [$file, $line]) { + $errors[] = RuleErrorBuilder::message(sprintf( + 'Call to %s::%s() uses named argument for parameter $%s, but %s renames it to $%s.', + $prototypeDeclaringClassName, + $methodName, + $prototypeParameterName, + $methodDeclaringClassName, + $methodParameterName, + ))->identifier('argument.parameterRenamedInSubtype') + ->file($file) + ->line($line) + ->build(); + } + } + } + + return $errors; + } + +} diff --git a/src/Rules/Methods/MethodParameterComparisonHelper.php b/src/Rules/Methods/MethodParameterComparisonHelper.php index 41a9c985cfb..c86a76cacfc 100644 --- a/src/Rules/Methods/MethodParameterComparisonHelper.php +++ b/src/Rules/Methods/MethodParameterComparisonHelper.php @@ -2,6 +2,9 @@ namespace PHPStan\Rules\Methods; +use PHPStan\Analyser\CollectedDataEmitter; +use PHPStan\Analyser\NodeCallbackInvoker; +use PHPStan\Analyser\Scope; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Php\PhpVersion; use PHPStan\Reflection\ClassReflection; @@ -33,7 +36,13 @@ public function __construct(private PhpVersion $phpVersion) /** * @return list */ - public function compare(ExtendedMethodReflection $prototype, ClassReflection $prototypeDeclaringClass, PhpMethodFromParserNodeReflection $method, bool $ignorable): array + public function compare( + ExtendedMethodReflection $prototype, + ClassReflection $prototypeDeclaringClass, + PhpMethodFromParserNodeReflection $method, + Scope&NodeCallbackInvoker&CollectedDataEmitter $scope, + bool $ignorable, + ): array { /** @var list $messages */ $messages = []; @@ -64,6 +73,17 @@ public function compare(ExtendedMethodReflection $prototype, ClassReflection $pr } $methodParameter = $methodParameters[$i]; + if ($prototype->acceptsNamedArguments()->yes()) { + if ($prototypeParameter->getName() !== $methodParameter->getName()) { + $scope->emitCollectedData(OverridingMethodRenamesParameterCollector::class, [ + $prototypeDeclaringClass->getName(), + $prototype->getName(), + $method->getDeclaringClass()->getName(), + $prototypeParameter->getName(), + $methodParameter->getName(), + ]); + } + } if ($prototypeParameter->passedByReference()->no()) { if (!$methodParameter->passedByReference()->no()) { $error = RuleErrorBuilder::message(sprintf( diff --git a/src/Rules/Methods/MissingMethodParameterTypehintRule.php b/src/Rules/Methods/MissingMethodParameterTypehintRule.php index 38516866320..a0a5cd3b936 100644 --- a/src/Rules/Methods/MissingMethodParameterTypehintRule.php +++ b/src/Rules/Methods/MissingMethodParameterTypehintRule.php @@ -81,10 +81,9 @@ private function checkMethodParameter(MethodReflection $methodReflection, string } $messages = []; - foreach ($this->missingTypehintCheck->getIterableTypesWithMissingValueTypehint($parameterType) as $iterableType) { - $iterableTypeDescription = $iterableType->describe(VerbosityLevel::typeOnly()); + foreach ($this->missingTypehintCheck->getIterableTypesWithMissingValueTypehint($parameterType) as $iterableTypeDescription) { $messages[] = RuleErrorBuilder::message(sprintf( - 'Method %s::%s() has %s with no value type specified in iterable type %s.', + 'Method %s::%s() has %s with no value type specified in %s.', $methodReflection->getDeclaringClass()->getDisplayName(), $methodReflection->getName(), $parameterMessage, diff --git a/src/Rules/Methods/MissingMethodReturnTypehintRule.php b/src/Rules/Methods/MissingMethodReturnTypehintRule.php index e127a143613..ea40b006706 100644 --- a/src/Rules/Methods/MissingMethodReturnTypehintRule.php +++ b/src/Rules/Methods/MissingMethodReturnTypehintRule.php @@ -54,10 +54,9 @@ public function processNode(Node $node, Scope $scope): array } $messages = []; - foreach ($this->missingTypehintCheck->getIterableTypesWithMissingValueTypehint($returnType) as $iterableType) { - $iterableTypeDescription = $iterableType->describe(VerbosityLevel::typeOnly()); + foreach ($this->missingTypehintCheck->getIterableTypesWithMissingValueTypehint($returnType) as $iterableTypeDescription) { $messages[] = RuleErrorBuilder::message(sprintf( - 'Method %s::%s() return type has no value type specified in iterable type %s.', + 'Method %s::%s() return type has no value type specified in %s.', $methodReflection->getDeclaringClass()->getDisplayName(), $methodReflection->getName(), $iterableTypeDescription, diff --git a/src/Rules/Methods/MissingMethodSelfOutTypeRule.php b/src/Rules/Methods/MissingMethodSelfOutTypeRule.php index 63966055b63..72fb8a1c1dc 100644 --- a/src/Rules/Methods/MissingMethodSelfOutTypeRule.php +++ b/src/Rules/Methods/MissingMethodSelfOutTypeRule.php @@ -45,10 +45,9 @@ public function processNode(Node $node, Scope $scope): array $phpDocTagMessage = 'PHPDoc tag @phpstan-self-out'; $messages = []; - foreach ($this->missingTypehintCheck->getIterableTypesWithMissingValueTypehint($selfOutType) as $iterableType) { - $iterableTypeDescription = $iterableType->describe(VerbosityLevel::typeOnly()); + foreach ($this->missingTypehintCheck->getIterableTypesWithMissingValueTypehint($selfOutType) as $iterableTypeDescription) { $messages[] = RuleErrorBuilder::message(sprintf( - 'Method %s::%s() has %s with no value type specified in iterable type %s.', + 'Method %s::%s() has %s with no value type specified in %s.', $classReflection->getDisplayName(), $methodReflection->getName(), $phpDocTagMessage, diff --git a/src/Rules/Methods/NamedArgumentParameterMethodCallsCollector.php b/src/Rules/Methods/NamedArgumentParameterMethodCallsCollector.php new file mode 100644 index 00000000000..6da034b7617 --- /dev/null +++ b/src/Rules/Methods/NamedArgumentParameterMethodCallsCollector.php @@ -0,0 +1,26 @@ + + */ +final class NamedArgumentParameterMethodCallsCollector implements Collector +{ + + public function getNodeType(): string + { + throw new ShouldNotHappenException(); + } + + public function processNode(Node $node, Scope $scope) + { + throw new ShouldNotHappenException(); + } + +} diff --git a/src/Rules/Methods/OverridingMethodRenamesParameterCollector.php b/src/Rules/Methods/OverridingMethodRenamesParameterCollector.php new file mode 100644 index 00000000000..d076548882b --- /dev/null +++ b/src/Rules/Methods/OverridingMethodRenamesParameterCollector.php @@ -0,0 +1,26 @@ + + */ +final class OverridingMethodRenamesParameterCollector implements Collector +{ + + public function getNodeType(): string + { + throw new ShouldNotHappenException(); + } + + public function processNode(Node $node, Scope $scope) + { + throw new ShouldNotHappenException(); + } + +} diff --git a/src/Rules/Methods/OverridingMethodRule.php b/src/Rules/Methods/OverridingMethodRule.php index 22074cd0ec2..7f232657f58 100644 --- a/src/Rules/Methods/OverridingMethodRule.php +++ b/src/Rules/Methods/OverridingMethodRule.php @@ -4,6 +4,7 @@ use PhpParser\Node; use PhpParser\Node\Attribute; +use PHPStan\Analyser\CollectedDataEmitter; use PHPStan\Analyser\NodeCallbackInvoker; use PHPStan\Analyser\Scope; use PHPStan\DependencyInjection\AutowiredParameter; @@ -51,7 +52,7 @@ public function getNodeType(): string return InClassMethodNode::class; } - public function processNode(Node $node, Scope&NodeCallbackInvoker $scope): array + public function processNode(Node $node, Scope&NodeCallbackInvoker&CollectedDataEmitter $scope): array { $method = $node->getMethodReflection(); $prototypeData = $this->methodPrototypeFinder->findPrototype($node->getClassReflection(), $method->getName()); @@ -260,7 +261,7 @@ public function processNode(Node $node, Scope&NodeCallbackInvoker $scope): array } } - $messages = array_merge($messages, $this->methodParameterComparisonHelper->compare($prototype, $prototypeDeclaringClass, $method, false)); + $messages = array_merge($messages, $this->methodParameterComparisonHelper->compare($prototype, $prototypeDeclaringClass, $method, $scope, false)); if (!$prototypeVariant instanceof ExtendedFunctionVariant) { return $this->addErrors($messages, $node, $scope); @@ -364,7 +365,7 @@ private function filterOverrideAttribute(array $attrGroups): array private function addErrors( array $errors, InClassMethodNode $classMethod, - Scope&NodeCallbackInvoker $scope, + Scope&NodeCallbackInvoker&CollectedDataEmitter $scope, ): array { if (count($errors) > 0) { diff --git a/src/Rules/MissingTypehintCheck.php b/src/Rules/MissingTypehintCheck.php index b43d51b6dd8..d5f765507db 100644 --- a/src/Rules/MissingTypehintCheck.php +++ b/src/Rules/MissingTypehintCheck.php @@ -14,6 +14,7 @@ use PHPStan\Type\ClosureType; use PHPStan\Type\ConditionalType; use PHPStan\Type\ConditionalTypeForParameter; +use PHPStan\Type\Constant\ConstantArrayType; use PHPStan\Type\Generic\GenericObjectType; use PHPStan\Type\Generic\GenericStaticType; use PHPStan\Type\Generic\TemplateType; @@ -23,6 +24,8 @@ use PHPStan\Type\ObjectType; use PHPStan\Type\Type; use PHPStan\Type\TypeTraverser; +use PHPStan\Type\UnionType; +use PHPStan\Type\VerbosityLevel; use Traversable; use function array_filter; use function array_keys; @@ -61,12 +64,16 @@ public function __construct( } /** - * @return Type[] + * Each returned string is a fully formatted phrase describing the + * offending type — e.g. `iterable type array` — so callers can drop it + * straight into their error message without further formatting. + * + * @return string[] */ public function getIterableTypesWithMissingValueTypehint(Type $type): array { - $iterablesWithMissingValueTypehint = []; - TypeTraverser::map($type, function (Type $type, callable $traverse) use (&$iterablesWithMissingValueTypehint): Type { + $descriptions = []; + TypeTraverser::map($type, function (Type $type, callable $traverse) use (&$descriptions): Type { if ($type instanceof TemplateType) { return $type; } @@ -91,8 +98,8 @@ public function getIterableTypesWithMissingValueTypehint(Type $type): array return $traverse(new IntersectionType($nonArrayInner)); } if ($type instanceof ConditionalType || $type instanceof ConditionalTypeForParameter) { - $iterablesWithMissingValueTypehint = array_merge( - $iterablesWithMissingValueTypehint, + $descriptions = array_merge( + $descriptions, $this->getIterableTypesWithMissingValueTypehint($type->getIf()), $this->getIterableTypesWithMissingValueTypehint($type->getElse()), ); @@ -100,9 +107,29 @@ public function getIterableTypesWithMissingValueTypehint(Type $type): array return $type; } if ($type->isIterable()->yes()) { + if ($type->isConstantArray()->yes()) { + $type = TypeTraverser::map($type, static function (Type $type, callable $traverse) use (&$descriptions) { + if ($type instanceof UnionType || $type instanceof IntersectionType) { + return $traverse($type); + } + + if ($type instanceof ConstantArrayType) { + $unsealed = $type->getUnsealedTypes(); + if ($unsealed !== null) { + $iterableUnsealedValue = $unsealed[1]; + if ($iterableUnsealedValue instanceof MixedType && !$iterableUnsealedValue->isExplicitMixed()) { + $descriptions[] = 'unsealed extra keys (...)'; + } + return $traverse($type->dropUnsealedTypes()); + } + } + + return $traverse($type); + }); + } $iterableValue = $type->getIterableValueType(); if ($iterableValue instanceof MixedType && !$iterableValue->isExplicitMixed()) { - $iterablesWithMissingValueTypehint[] = $type; + $descriptions[] = sprintf('iterable type %s', $type->describe(VerbosityLevel::typeOnly())); } if ($type instanceof IntersectionType) { if ($type->isList()->yes()) { @@ -115,7 +142,7 @@ public function getIterableTypesWithMissingValueTypehint(Type $type): array return $traverse($type); }); - return $iterablesWithMissingValueTypehint; + return $descriptions; } /** diff --git a/src/Rules/PhpDoc/AssertRuleHelper.php b/src/Rules/PhpDoc/AssertRuleHelper.php index 0dc14b638ae..41296e7e667 100644 --- a/src/Rules/PhpDoc/AssertRuleHelper.php +++ b/src/Rules/PhpDoc/AssertRuleHelper.php @@ -175,10 +175,9 @@ public function check( continue; } - foreach ($this->missingTypehintCheck->getIterableTypesWithMissingValueTypehint($assertedType) as $iterableType) { - $iterableTypeDescription = $iterableType->describe(VerbosityLevel::typeOnly()); + foreach ($this->missingTypehintCheck->getIterableTypesWithMissingValueTypehint($assertedType) as $iterableTypeDescription) { $errors[] = RuleErrorBuilder::message(sprintf( - 'PHPDoc tag %s for %s has no value type specified in iterable type %s.', + 'PHPDoc tag %s for %s has no value type specified in %s.', $tagName, $assertedExprString, $iterableTypeDescription, diff --git a/src/Rules/PhpDoc/InvalidPhpDocVarTagTypeRule.php b/src/Rules/PhpDoc/InvalidPhpDocVarTagTypeRule.php index 7d6090f70a0..37e31c19a49 100644 --- a/src/Rules/PhpDoc/InvalidPhpDocVarTagTypeRule.php +++ b/src/Rules/PhpDoc/InvalidPhpDocVarTagTypeRule.php @@ -16,7 +16,6 @@ use PHPStan\Rules\Rule; use PHPStan\Rules\RuleErrorBuilder; use PHPStan\Type\FileTypeMapper; -use PHPStan\Type\VerbosityLevel; use function array_map; use function array_merge; use function is_string; @@ -99,10 +98,9 @@ public function processNode(Node $node, Scope $scope): array } if ($this->checkMissingVarTagTypehint) { - foreach ($this->missingTypehintCheck->getIterableTypesWithMissingValueTypehint($varTagType) as $iterableType) { - $iterableTypeDescription = $iterableType->describe(VerbosityLevel::typeOnly()); + foreach ($this->missingTypehintCheck->getIterableTypesWithMissingValueTypehint($varTagType) as $iterableTypeDescription) { $errors[] = RuleErrorBuilder::message(sprintf( - '%s has no value type specified in iterable type %s.', + '%s has no value type specified in %s.', $identifier, $iterableTypeDescription, )) diff --git a/src/Rules/Playground/ArrayDimCastRule.php b/src/Rules/Playground/ArrayDimCastRule.php new file mode 100644 index 00000000000..0f16a2d4616 --- /dev/null +++ b/src/Rules/Playground/ArrayDimCastRule.php @@ -0,0 +1,63 @@ + + */ +final class ArrayDimCastRule implements Rule +{ + + public function getNodeType(): string + { + return ArrayDimFetch::class; + } + + public function processNode(Node $node, Scope $scope): array + { + if ($node->dim === null) { + return []; + } + + $varType = $scope->getType($node->var); + if ($varType->isArray()->no()) { + return []; + } + + $dimType = $scope->getType($node->dim); + if (!$dimType->isConstantScalarValue()->yes()) { + return []; + } + + $constantScalars = $dimType->getConstantScalarTypes(); + $errors = []; + foreach ($constantScalars as $constantScalar) { + $arrayKeyType = $constantScalar->toArrayKey(); + if ($arrayKeyType->equals($constantScalar)) { + continue; + } + + $errors[] = RuleErrorBuilder::message(sprintf( + 'Key %s (%s) will be cast to %s (%s) in the array access.', + $constantScalar->describe(VerbosityLevel::value()), + $constantScalar->generalize(GeneralizePrecision::lessSpecific())->describe(VerbosityLevel::typeOnly()), + $arrayKeyType->describe(VerbosityLevel::value()), + $arrayKeyType->describe(VerbosityLevel::typeOnly()), + ))->identifier('phpstanPlayground.arrayDimFetchCast') + ->tip('Learn more: https://phpstan.org/blog/why-array-string-keys-are-not-type-safe') + ->build(); + } + + return $errors; + } + +} diff --git a/src/Rules/Playground/LiteralArrayKeyCastRule.php b/src/Rules/Playground/LiteralArrayKeyCastRule.php new file mode 100644 index 00000000000..807754d35cd --- /dev/null +++ b/src/Rules/Playground/LiteralArrayKeyCastRule.php @@ -0,0 +1,61 @@ + + */ +final class LiteralArrayKeyCastRule implements Rule +{ + + public function getNodeType(): string + { + return Array_::class; + } + + public function processNode(Node $node, Scope $scope): array + { + $errors = []; + foreach ($node->items as $item) { + if ($item->key === null) { + continue; + } + + $keyType = $scope->getType($item->key); + if (!$keyType->isConstantScalarValue()->yes()) { + continue; + } + + $constantScalars = $keyType->getConstantScalarTypes(); + foreach ($constantScalars as $constantScalar) { + $arrayKeyType = $constantScalar->toArrayKey(); + if ($arrayKeyType->equals($constantScalar)) { + continue; + } + + $errors[] = RuleErrorBuilder::message(sprintf( + 'Key %s (%s) will be cast to %s (%s) in the array.', + $constantScalar->describe(VerbosityLevel::value()), + $constantScalar->generalize(GeneralizePrecision::lessSpecific())->describe(VerbosityLevel::typeOnly()), + $arrayKeyType->describe(VerbosityLevel::value()), + $arrayKeyType->describe(VerbosityLevel::typeOnly()), + ))->identifier('phpstanPlayground.arrayKeyCast') + ->tip('Learn more: https://phpstan.org/blog/why-array-string-keys-are-not-type-safe') + ->line($item->getStartLine()) + ->build(); + } + } + + return $errors; + } + +} diff --git a/src/Rules/Playground/PromoteParameterRule.php b/src/Rules/Playground/PromoteParameterRule.php index 8dc1d329165..d01b77c530e 100644 --- a/src/Rules/Playground/PromoteParameterRule.php +++ b/src/Rules/Playground/PromoteParameterRule.php @@ -3,6 +3,7 @@ namespace PHPStan\Rules\Playground; use PhpParser\Node; +use PHPStan\Analyser\CollectedDataEmitter; use PHPStan\Analyser\NodeCallbackInvoker; use PHPStan\Analyser\Scope; use PHPStan\DependencyInjection\Container; @@ -88,7 +89,7 @@ private function getOriginalRule(): ?Rule return $this->originalRule = $originalRule; } - public function processNode(Node $node, Scope&NodeCallbackInvoker $scope): array + public function processNode(Node $node, Scope&NodeCallbackInvoker&CollectedDataEmitter $scope): array { if ($this->parameterValue) { return []; diff --git a/src/Rules/Properties/MissingPropertyTypehintRule.php b/src/Rules/Properties/MissingPropertyTypehintRule.php index 889092b699b..56692cac98e 100644 --- a/src/Rules/Properties/MissingPropertyTypehintRule.php +++ b/src/Rules/Properties/MissingPropertyTypehintRule.php @@ -52,10 +52,9 @@ public function processNode(Node $node, Scope $scope): array } $messages = []; - foreach ($this->missingTypehintCheck->getIterableTypesWithMissingValueTypehint($propertyType) as $iterableType) { - $iterableTypeDescription = $iterableType->describe(VerbosityLevel::typeOnly()); + foreach ($this->missingTypehintCheck->getIterableTypesWithMissingValueTypehint($propertyType) as $iterableTypeDescription) { $messages[] = RuleErrorBuilder::message(sprintf( - 'Property %s::$%s type has no value type specified in iterable type %s.', + 'Property %s::$%s type has no value type specified in %s.', $propertyReflection->getDeclaringClass()->getDisplayName(), $node->getName(), $iterableTypeDescription, diff --git a/src/Rules/Properties/PropertyAttributesRule.php b/src/Rules/Properties/PropertyAttributesRule.php index 4ce2080d863..c6dbc29b452 100644 --- a/src/Rules/Properties/PropertyAttributesRule.php +++ b/src/Rules/Properties/PropertyAttributesRule.php @@ -4,6 +4,8 @@ use Attribute; use PhpParser\Node; +use PHPStan\Analyser\CollectedDataEmitter; +use PHPStan\Analyser\NodeCallbackInvoker; use PHPStan\Analyser\Scope; use PHPStan\DependencyInjection\RegisteredRule; use PHPStan\Node\ClassPropertyNode; @@ -32,7 +34,7 @@ public function getNodeType(): string return ClassPropertyNode::class; } - public function processNode(Node $node, Scope $scope): array + public function processNode(Node $node, Scope&NodeCallbackInvoker&CollectedDataEmitter $scope): array { if (!$this->phpVersion->supportsOverrideAttributeOnProperty()) { $propertyReflection = $node->getClassReflection()->getNativeProperty($node->getName()); diff --git a/src/Rules/Properties/PropertyHookAttributesRule.php b/src/Rules/Properties/PropertyHookAttributesRule.php index 2eb1c11f604..79e48aa03fa 100644 --- a/src/Rules/Properties/PropertyHookAttributesRule.php +++ b/src/Rules/Properties/PropertyHookAttributesRule.php @@ -4,6 +4,8 @@ use Attribute; use PhpParser\Node; +use PHPStan\Analyser\CollectedDataEmitter; +use PHPStan\Analyser\NodeCallbackInvoker; use PHPStan\Analyser\Scope; use PHPStan\DependencyInjection\RegisteredRule; use PHPStan\Node\InPropertyHookNode; @@ -29,7 +31,7 @@ public function getNodeType(): string return InPropertyHookNode::class; } - public function processNode(Node $node, Scope $scope): array + public function processNode(Node $node, Scope&NodeCallbackInvoker&CollectedDataEmitter $scope): array { $attrGroups = $node->getOriginalNode()->attrGroups; $errors = $this->attributesCheck->check( diff --git a/src/Rules/Properties/SetPropertyHookParameterRule.php b/src/Rules/Properties/SetPropertyHookParameterRule.php index 82f89362b82..1bc7cf1b965 100644 --- a/src/Rules/Properties/SetPropertyHookParameterRule.php +++ b/src/Rules/Properties/SetPropertyHookParameterRule.php @@ -119,10 +119,9 @@ public function processNode(Node $node, Scope $scope): array return $errors; } - foreach ($this->missingTypehintCheck->getIterableTypesWithMissingValueTypehint($parameterType) as $iterableType) { - $iterableTypeDescription = $iterableType->describe(VerbosityLevel::typeOnly()); + foreach ($this->missingTypehintCheck->getIterableTypesWithMissingValueTypehint($parameterType) as $iterableTypeDescription) { $errors[] = RuleErrorBuilder::message(sprintf( - 'Set hook for property %s::$%s has parameter $%s with no value type specified in iterable type %s.', + 'Set hook for property %s::$%s has parameter $%s with no value type specified in %s.', $classReflection->getDisplayName(), $hookReflection->getHookedPropertyName(), $parameter->getName(), diff --git a/src/Rules/RestrictedUsage/RewrittenDeclaringClassClassConstantReflection.php b/src/Rules/RestrictedUsage/RewrittenDeclaringClassClassConstantReflection.php index a92a6172737..02f45dbbb69 100644 --- a/src/Rules/RestrictedUsage/RewrittenDeclaringClassClassConstantReflection.php +++ b/src/Rules/RestrictedUsage/RewrittenDeclaringClassClassConstantReflection.php @@ -8,6 +8,7 @@ use PHPStan\Reflection\ClassReflection; use PHPStan\TrinaryLogic; use PHPStan\Type\Type; +use function sprintf; final class RewrittenDeclaringClassClassConstantReflection implements ClassConstantReflection { @@ -89,6 +90,16 @@ public function getName(): string return $this->constantReflection->getName(); } + public function describe(): string + { + return sprintf('%s::%s', $this->getDeclaringClass()->getDisplayName(), $this->getName()); + } + + public function isBuiltin(): TrinaryLogic + { + return TrinaryLogic::createFromBoolean($this->getDeclaringClass()->isBuiltin()); + } + public function getValueType(): Type { return $this->constantReflection->getValueType(); diff --git a/src/Rules/Rule.php b/src/Rules/Rule.php index 03a5a047b03..fd1d3333e50 100644 --- a/src/Rules/Rule.php +++ b/src/Rules/Rule.php @@ -3,6 +3,7 @@ namespace PHPStan\Rules; use PhpParser\Node; +use PHPStan\Analyser\CollectedDataEmitter; use PHPStan\Analyser\NodeCallbackInvoker; use PHPStan\Analyser\Scope; @@ -35,6 +36,6 @@ public function getNodeType(): string; * @param TNodeType $node * @return list */ - public function processNode(Node $node, Scope&NodeCallbackInvoker $scope): array; + public function processNode(Node $node, Scope&NodeCallbackInvoker&CollectedDataEmitter $scope): array; } diff --git a/src/Rules/RuleErrors/TransformedRuleError.php b/src/Rules/RuleErrors/TransformedRuleError.php new file mode 100644 index 00000000000..0a69d7387fc --- /dev/null +++ b/src/Rules/RuleErrors/TransformedRuleError.php @@ -0,0 +1,39 @@ +error; + } + + public function getIdentifier(): string + { + $identifier = $this->error->getIdentifier(); + if ($identifier === null) { + throw new ShouldNotHappenException(); + } + + return $identifier; + } + + public function getMessage(): string + { + return $this->error->getMessage(); + } + +} diff --git a/src/Rules/Traits/TraitAttributesRule.php b/src/Rules/Traits/TraitAttributesRule.php index 8006203180d..7d6c6fd6e75 100644 --- a/src/Rules/Traits/TraitAttributesRule.php +++ b/src/Rules/Traits/TraitAttributesRule.php @@ -4,6 +4,8 @@ use Attribute; use PhpParser\Node; +use PHPStan\Analyser\CollectedDataEmitter; +use PHPStan\Analyser\NodeCallbackInvoker; use PHPStan\Analyser\Scope; use PHPStan\DependencyInjection\RegisteredRule; use PHPStan\Node\InTraitNode; @@ -32,7 +34,7 @@ public function getNodeType(): string return InTraitNode::class; } - public function processNode(Node $node, Scope $scope): array + public function processNode(Node $node, Scope&NodeCallbackInvoker&CollectedDataEmitter $scope): array { if (!$this->phpVersion->supportsDeprecatedTraits()) { if (count($node->getTraitReflection()->getNativeReflection()->getAttributes('Deprecated')) > 0) { diff --git a/src/Testing/CompositeRule.php b/src/Testing/CompositeRule.php index c83fb047b52..269ed259cc4 100644 --- a/src/Testing/CompositeRule.php +++ b/src/Testing/CompositeRule.php @@ -3,6 +3,7 @@ namespace PHPStan\Testing; use PhpParser\Node; +use PHPStan\Analyser\CollectedDataEmitter; use PHPStan\Analyser\NodeCallbackInvoker; use PHPStan\Analyser\Scope; use PHPStan\Rules\DirectRegistry; @@ -37,7 +38,7 @@ public function getNodeType(): string return Node::class; } - public function processNode(Node $node, Scope&NodeCallbackInvoker $scope): array + public function processNode(Node $node, Scope&NodeCallbackInvoker&CollectedDataEmitter $scope): array { $errors = []; diff --git a/src/Testing/DelayedRule.php b/src/Testing/DelayedRule.php index 27b35cb2f40..17909e3721b 100644 --- a/src/Testing/DelayedRule.php +++ b/src/Testing/DelayedRule.php @@ -3,6 +3,7 @@ namespace PHPStan\Testing; use PhpParser\Node; +use PHPStan\Analyser\CollectedDataEmitter; use PHPStan\Analyser\NodeCallbackInvoker; use PHPStan\Analyser\Scope; use PHPStan\Rules\DirectRegistry; @@ -43,7 +44,7 @@ public function getDelayedErrors(): array return $this->errors; } - public function processNode(Node $node, Scope&NodeCallbackInvoker $scope): array + public function processNode(Node $node, Scope&NodeCallbackInvoker&CollectedDataEmitter $scope): array { $nodeType = get_class($node); foreach ($this->registry->getRules($nodeType) as $rule) { diff --git a/src/Testing/PHPStanTestCase.php b/src/Testing/PHPStanTestCase.php index ac028fdcf39..03b2e4335c7 100644 --- a/src/Testing/PHPStanTestCase.php +++ b/src/Testing/PHPStanTestCase.php @@ -149,7 +149,7 @@ protected function assertNoErrors(array $errors): void $messages = []; foreach ($errors as $error) { if ($error instanceof Error) { - $messages[] = sprintf("- %s\n in %s on line %d\n", rtrim($error->getMessage(), '.'), $error->getFile(), $error->getLine() ?? 0); + $messages[] = sprintf("- %s\n in %s on line %d%s\n", rtrim($error->getMessage(), '.'), $error->getFile(), $error->getLine() ?? 0, $error->getTip() !== null ? sprintf("\n💡 %s", $error->getTip()) : ''); } else { $messages[] = $error; } diff --git a/src/Type/Accessory/AccessoryArrayListType.php b/src/Type/Accessory/AccessoryArrayListType.php index 98dc1b0773c..094d98280a6 100644 --- a/src/Type/Accessory/AccessoryArrayListType.php +++ b/src/Type/Accessory/AccessoryArrayListType.php @@ -431,6 +431,11 @@ public function isNumericString(): TrinaryLogic return TrinaryLogic::createNo(); } + public function isDecimalIntegerString(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + public function isNonEmptyString(): TrinaryLogic { return TrinaryLogic::createNo(); diff --git a/src/Type/Accessory/AccessoryDecimalIntegerStringType.php b/src/Type/Accessory/AccessoryDecimalIntegerStringType.php new file mode 100644 index 00000000000..d965eb0ebc9 --- /dev/null +++ b/src/Type/Accessory/AccessoryDecimalIntegerStringType.php @@ -0,0 +1,474 @@ +isDecimalIntegerString(); + + if ( + $type->isString()->yes() + && ($this->inverse ? $isDecimalIntegerString->no() : $isDecimalIntegerString->yes()) + ) { + return AcceptsResult::createYes(); + } + + if ($type instanceof CompoundType) { + return $type->isAcceptedBy($this, $strictTypes); + } + + $result = $type->isString()->and($this->inverse ? $isDecimalIntegerString->negate() : $isDecimalIntegerString); + + return new AcceptsResult($result, []); + } + + public function isSuperTypeOf(Type $type): IsSuperTypeOfResult + { + if ($type instanceof CompoundType) { + return $type->isSubTypeOf($this); + } + + if ($this->equals($type)) { + return IsSuperTypeOfResult::createYes(); + } + + $isDecimalIntegerString = $type->isDecimalIntegerString(); + $result = $type->isString()->and($this->inverse ? $isDecimalIntegerString->negate() : $isDecimalIntegerString); + + return new IsSuperTypeOfResult($result, []); + } + + public function isSubTypeOf(Type $otherType): IsSuperTypeOfResult + { + if ($otherType instanceof UnionType || $otherType instanceof IntersectionType) { + return $otherType->isSuperTypeOf($this); + } + + if ( + ( + $otherType instanceof AccessoryNumericStringType + || $otherType instanceof AccessoryLowercaseStringType + || $otherType instanceof AccessoryUppercaseStringType + ) + && !$this->inverse + ) { + return IsSuperTypeOfResult::createYes(); + } + + $otherTypeResult = $otherType->isString()->and($this->inverse ? $otherType->isDecimalIntegerString()->negate() : $otherType->isDecimalIntegerString()); + + return new IsSuperTypeOfResult( + $otherTypeResult->and($otherType->equals($this) ? TrinaryLogic::createYes() : TrinaryLogic::createMaybe()), + [], + ); + } + + public function isAcceptedBy(Type $acceptingType, bool $strictTypes): AcceptsResult + { + return $this->isSubTypeOf($acceptingType)->toAcceptsResult(); + } + + public function equals(Type $type): bool + { + return $type instanceof self && $this->inverse === $type->inverse; + } + + public function describe(VerbosityLevel $level): string + { + return $this->inverse ? 'non-decimal-int-string' : 'decimal-int-string'; + } + + public function isOffsetAccessible(): TrinaryLogic + { + return TrinaryLogic::createYes(); + } + + public function isOffsetAccessLegal(): TrinaryLogic + { + return TrinaryLogic::createYes(); + } + + public function hasOffsetValueType(Type $offsetType): TrinaryLogic + { + return $offsetType->isInteger()->and(TrinaryLogic::createMaybe()); + } + + public function getOffsetValueType(Type $offsetType): Type + { + if ($this->hasOffsetValueType($offsetType)->no()) { + return new ErrorType(); + } + + return new StringType(); + } + + public function setOffsetValueType(?Type $offsetType, Type $valueType, bool $unionValues = true): Type + { + $stringOffset = (new StringType())->setOffsetValueType($offsetType, $valueType, $unionValues); + + if ($stringOffset instanceof ErrorType) { + return $stringOffset; + } + + return $this; + } + + public function setExistingOffsetValueType(Type $offsetType, Type $valueType): Type + { + return $this; + } + + public function unsetOffset(Type $offsetType): Type + { + return new ErrorType(); + } + + public function toNumber(): Type + { + if ($this->inverse) { + return new UnionType([ + $this->toInteger(), + $this->toFloat(), + ]); + } + + return $this->toInteger(); + } + + public function toAbsoluteNumber(): Type + { + return $this->toNumber()->toAbsoluteNumber(); + } + + public function toBitwiseNotType(): Type + { + // Decimal integer strings are non-empty when not inverted + // (`"0"` / `"123"` are still at least one character). `~$s` + // returns a string of the same length, so the non-empty flag + // survives. The decimal-integer property doesn't survive the + // bitwise-not, hence we drop the accessory. + return $this->isNonEmptyString()->yes() + ? new IntersectionType([new StringType(), new AccessoryNonEmptyStringType()]) + : new StringType(); + } + + public function toBoolean(): BooleanType + { + return $this->isNonFalsyString()->negate()->toBooleanType(); + } + + public function toInteger(): Type + { + return new IntegerType(); + } + + public function toFloat(): Type + { + return new FloatType(); + } + + public function toString(): Type + { + return $this; + } + + public function toArray(): Type + { + return new ConstantArrayType( + [new ConstantIntegerType(0)], + [$this], + [1], + isList: TrinaryLogic::createYes(), + ); + } + + public function toArrayKey(): Type + { + if ($this->inverse) { + return $this; + } + + return new IntegerType(); + } + + public function toCoercedArgumentType(bool $strictTypes): Type + { + if (!$strictTypes) { + return TypeCombinator::union($this->toInteger(), $this->toFloat(), $this, $this->toBoolean()); + } + + return $this; + } + + public function isNull(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isConstantValue(): TrinaryLogic + { + return TrinaryLogic::createMaybe(); + } + + public function isConstantScalarValue(): TrinaryLogic + { + return TrinaryLogic::createMaybe(); + } + + public function getConstantScalarTypes(): array + { + return []; + } + + public function getConstantScalarValues(): array + { + return []; + } + + public function isCallable(): TrinaryLogic + { + return $this->inverse ? TrinaryLogic::createMaybe() : TrinaryLogic::createNo(); + } + + public function getCallableParametersAcceptors(ClassMemberAccessAnswerer $scope): array + { + if ($this->inverse) { + return [new TrivialParametersAcceptor()]; + } + + throw new ShouldNotHappenException(); + } + + public function isTrue(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isFalse(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isBoolean(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isFloat(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isInteger(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isString(): TrinaryLogic + { + return TrinaryLogic::createYes(); + } + + public function isNumericString(): TrinaryLogic + { + return $this->inverse ? TrinaryLogic::createMaybe() : TrinaryLogic::createYes(); + } + + public function isDecimalIntegerString(): TrinaryLogic + { + return TrinaryLogic::createFromBoolean(!$this->inverse); + } + + public function isNonEmptyString(): TrinaryLogic + { + return $this->inverse ? TrinaryLogic::createMaybe() : TrinaryLogic::createYes(); + } + + public function isNonFalsyString(): TrinaryLogic + { + return TrinaryLogic::createMaybe(); + } + + public function isLiteralString(): TrinaryLogic + { + return TrinaryLogic::createMaybe(); + } + + public function isLowercaseString(): TrinaryLogic + { + return $this->inverse ? TrinaryLogic::createMaybe() : TrinaryLogic::createYes(); + } + + public function isUppercaseString(): TrinaryLogic + { + return $this->inverse ? TrinaryLogic::createMaybe() : TrinaryLogic::createYes(); + } + + public function isClassString(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function getClassStringObjectType(): Type + { + return new ErrorType(); + } + + public function getObjectTypeOrClassStringObjectType(): Type + { + return new ErrorType(); + } + + public function isVoid(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isScalar(): TrinaryLogic + { + return TrinaryLogic::createYes(); + } + + public function looseCompare(Type $type, PhpVersion $phpVersion): BooleanType + { + if ($type->isNull()->yes()) { + return new ConstantBooleanType(false); + } + + if ($type->isString()->yes()) { + if ($this->inverse) { + if ($type->isDecimalIntegerString()->yes()) { + return new ConstantBooleanType(false); + } + } elseif ($type->isDecimalIntegerString()->no()) { + return new ConstantBooleanType(false); + } + } + + return new BooleanType(); + } + + public function traverse(callable $cb): Type + { + return $this; + } + + public function traverseSimultaneously(Type $right, callable $cb): Type + { + return $this; + } + + public function generalize(GeneralizePrecision $precision): Type + { + return new StringType(); + } + + public function exponentiate(Type $exponent): Type + { + return new BenevolentUnionType([ + new FloatType(), + new IntegerType(), + ]); + } + + public function getFiniteTypes(): array + { + return []; + } + + public function getDefaultBaseType(): Type + { + return new StringType(); + } + + public function toPhpDocNode(): TypeNode + { + return new IdentifierTypeNode($this->inverse ? 'non-decimal-int-string' : 'decimal-int-string'); + } + + public function hasTemplateOrLateResolvableType(): bool + { + return false; + } + +} diff --git a/src/Type/Accessory/AccessoryLiteralStringType.php b/src/Type/Accessory/AccessoryLiteralStringType.php index c2bfc1775a0..7303823661e 100644 --- a/src/Type/Accessory/AccessoryLiteralStringType.php +++ b/src/Type/Accessory/AccessoryLiteralStringType.php @@ -295,6 +295,11 @@ public function isNumericString(): TrinaryLogic return TrinaryLogic::createMaybe(); } + public function isDecimalIntegerString(): TrinaryLogic + { + return TrinaryLogic::createMaybe(); + } + public function isNonEmptyString(): TrinaryLogic { return TrinaryLogic::createMaybe(); diff --git a/src/Type/Accessory/AccessoryLowercaseStringType.php b/src/Type/Accessory/AccessoryLowercaseStringType.php index c28a3fe6874..cf54d8b7576 100644 --- a/src/Type/Accessory/AccessoryLowercaseStringType.php +++ b/src/Type/Accessory/AccessoryLowercaseStringType.php @@ -292,6 +292,11 @@ public function isNumericString(): TrinaryLogic return TrinaryLogic::createMaybe(); } + public function isDecimalIntegerString(): TrinaryLogic + { + return TrinaryLogic::createMaybe(); + } + public function isNonEmptyString(): TrinaryLogic { return TrinaryLogic::createMaybe(); diff --git a/src/Type/Accessory/AccessoryNonEmptyStringType.php b/src/Type/Accessory/AccessoryNonEmptyStringType.php index 76e929e2c30..25feb9270a5 100644 --- a/src/Type/Accessory/AccessoryNonEmptyStringType.php +++ b/src/Type/Accessory/AccessoryNonEmptyStringType.php @@ -292,6 +292,11 @@ public function isNumericString(): TrinaryLogic return TrinaryLogic::createMaybe(); } + public function isDecimalIntegerString(): TrinaryLogic + { + return TrinaryLogic::createMaybe(); + } + public function isNonEmptyString(): TrinaryLogic { return TrinaryLogic::createYes(); diff --git a/src/Type/Accessory/AccessoryNonFalsyStringType.php b/src/Type/Accessory/AccessoryNonFalsyStringType.php index 12e38968979..4310fd8e40b 100644 --- a/src/Type/Accessory/AccessoryNonFalsyStringType.php +++ b/src/Type/Accessory/AccessoryNonFalsyStringType.php @@ -296,6 +296,11 @@ public function isNumericString(): TrinaryLogic return TrinaryLogic::createMaybe(); } + public function isDecimalIntegerString(): TrinaryLogic + { + return TrinaryLogic::createMaybe(); + } + public function isNonEmptyString(): TrinaryLogic { return TrinaryLogic::createYes(); diff --git a/src/Type/Accessory/AccessoryNumericStringType.php b/src/Type/Accessory/AccessoryNumericStringType.php index facf50c3f18..ca2e060c673 100644 --- a/src/Type/Accessory/AccessoryNumericStringType.php +++ b/src/Type/Accessory/AccessoryNumericStringType.php @@ -2,6 +2,7 @@ namespace PHPStan\Type\Accessory; +use PHPStan\DependencyInjection\ReportUnsafeArrayStringKeyCastingToggle; use PHPStan\Php\PhpVersion; use PHPStan\PhpDocParser\Ast\Type\IdentifierTypeNode; use PHPStan\PhpDocParser\Ast\Type\TypeNode; @@ -214,12 +215,20 @@ public function toArray(): Type public function toArrayKey(): Type { + $level = ReportUnsafeArrayStringKeyCastingToggle::getLevel(); + if ($level !== ReportUnsafeArrayStringKeyCastingToggle::PREVENT) { + new UnionType([ + new IntegerType(), + new IntersectionType([ + new StringType(), + new AccessoryNumericStringType(), + ]), + ]); + } + return new UnionType([ new IntegerType(), - new IntersectionType([ - new StringType(), - new AccessoryNumericStringType(), - ]), + new IntersectionType([new StringType(), new AccessoryDecimalIntegerStringType(inverse: true)]), ]); } @@ -292,6 +301,11 @@ public function isNumericString(): TrinaryLogic return TrinaryLogic::createYes(); } + public function isDecimalIntegerString(): TrinaryLogic + { + return TrinaryLogic::createMaybe(); + } + public function isNonEmptyString(): TrinaryLogic { return TrinaryLogic::createYes(); diff --git a/src/Type/Accessory/AccessoryUppercaseStringType.php b/src/Type/Accessory/AccessoryUppercaseStringType.php index ff30f9b472b..8217024fbf0 100644 --- a/src/Type/Accessory/AccessoryUppercaseStringType.php +++ b/src/Type/Accessory/AccessoryUppercaseStringType.php @@ -292,6 +292,11 @@ public function isNumericString(): TrinaryLogic return TrinaryLogic::createMaybe(); } + public function isDecimalIntegerString(): TrinaryLogic + { + return TrinaryLogic::createMaybe(); + } + public function isNonEmptyString(): TrinaryLogic { return TrinaryLogic::createMaybe(); diff --git a/src/Type/Accessory/NonEmptyArrayType.php b/src/Type/Accessory/NonEmptyArrayType.php index 3d65af278b9..a01bdeaf19e 100644 --- a/src/Type/Accessory/NonEmptyArrayType.php +++ b/src/Type/Accessory/NonEmptyArrayType.php @@ -416,6 +416,11 @@ public function isNumericString(): TrinaryLogic return TrinaryLogic::createNo(); } + public function isDecimalIntegerString(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + public function isNonEmptyString(): TrinaryLogic { return TrinaryLogic::createNo(); diff --git a/src/Type/Accessory/OversizedArrayType.php b/src/Type/Accessory/OversizedArrayType.php index 2e1a04bf01b..24cb69c75ec 100644 --- a/src/Type/Accessory/OversizedArrayType.php +++ b/src/Type/Accessory/OversizedArrayType.php @@ -385,6 +385,11 @@ public function isNumericString(): TrinaryLogic return TrinaryLogic::createNo(); } + public function isDecimalIntegerString(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + public function isNonEmptyString(): TrinaryLogic { return TrinaryLogic::createNo(); diff --git a/src/Type/ArrayType.php b/src/Type/ArrayType.php index ebed9eedc2b..b38fb858c45 100644 --- a/src/Type/ArrayType.php +++ b/src/Type/ArrayType.php @@ -35,9 +35,11 @@ use PHPStan\Type\Traits\NonObjectTypeTrait; use PHPStan\Type\Traits\UndecidedBooleanTypeTrait; use PHPStan\Type\Traits\UndecidedComparisonTypeTrait; +use PHPStan\Type\Traverser\UnsafeArrayStringKeyCastingTraverser; use function array_map; use function array_merge; use function count; +use function in_array; use function sprintf; use function strtolower; use function strtoupper; @@ -59,16 +61,18 @@ class ArrayType implements Type private Type $keyType; + private ?Type $cachedIterableKeyType = null; + private ?TrinaryLogic $isList = null; /** @api */ public function __construct(Type $keyType, private Type $itemType) { - if ($keyType->describe(VerbosityLevel::value()) === '(int|string)') { + if (in_array($keyType->describe(VerbosityLevel::value()), ['(int|string)', '(int|non-decimal-int-string)'], true)) { $keyType = new MixedType(); } if ($keyType instanceof StrictMixedType && !$keyType instanceof TemplateStrictMixedType) { - $keyType = new UnionType([new StringType(), new IntegerType()]); + $keyType = (new UnionType([new StringType(), new IntegerType()]))->toArrayKey(); } $this->keyType = $keyType; @@ -223,15 +227,18 @@ public function getArraySize(): Type public function getIterableKeyType(): Type { + if ($this->cachedIterableKeyType !== null) { + return $this->cachedIterableKeyType; + } $keyType = $this->keyType; if ($keyType instanceof MixedType && !$keyType instanceof TemplateMixedType) { - return new BenevolentUnionType([new IntegerType(), new StringType()]); + $keyType = (new BenevolentUnionType([new IntegerType(), new StringType()]))->toArrayKey(); } if ($keyType instanceof StrictMixedType) { - return new BenevolentUnionType([new IntegerType(), new StringType()]); + $keyType = (new BenevolentUnionType([new IntegerType(), new StringType()]))->toArrayKey(); } - return $keyType; + return $this->cachedIterableKeyType = UnsafeArrayStringKeyCastingTraverser::castKeyType($keyType); } public function getFirstIterableKeyType(): Type diff --git a/src/Type/CallableType.php b/src/Type/CallableType.php index c2760ef583e..389d8cef21d 100644 --- a/src/Type/CallableType.php +++ b/src/Type/CallableType.php @@ -693,6 +693,11 @@ public function isNumericString(): TrinaryLogic return TrinaryLogic::createNo(); } + public function isDecimalIntegerString(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + public function isNonEmptyString(): TrinaryLogic { return TrinaryLogic::createMaybe(); diff --git a/src/Type/ClassStringType.php b/src/Type/ClassStringType.php index c5dae4f1958..55f24671a20 100644 --- a/src/Type/ClassStringType.php +++ b/src/Type/ClassStringType.php @@ -46,7 +46,12 @@ public function isString(): TrinaryLogic public function isNumericString(): TrinaryLogic { - return TrinaryLogic::createMaybe(); + return TrinaryLogic::createNo(); + } + + public function isDecimalIntegerString(): TrinaryLogic + { + return TrinaryLogic::createNo(); } public function isNonEmptyString(): TrinaryLogic diff --git a/src/Type/ClosureType.php b/src/Type/ClosureType.php index a819526021f..8944a05f381 100644 --- a/src/Type/ClosureType.php +++ b/src/Type/ClosureType.php @@ -824,6 +824,11 @@ public function isNumericString(): TrinaryLogic return TrinaryLogic::createNo(); } + public function isDecimalIntegerString(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + public function isNonEmptyString(): TrinaryLogic { return TrinaryLogic::createNo(); diff --git a/src/Type/Constant/ConstantArrayType.php b/src/Type/Constant/ConstantArrayType.php index 8850f7f45a2..d772d54771e 100644 --- a/src/Type/Constant/ConstantArrayType.php +++ b/src/Type/Constant/ConstantArrayType.php @@ -4,11 +4,13 @@ use Nette\Utils\Strings; use PHPStan\Analyser\OutOfClassScope; +use PHPStan\DependencyInjection\BleedingEdgeToggle; use PHPStan\Php\PhpVersion; use PHPStan\PhpDocParser\Ast\ConstExpr\ConstExprIntegerNode; use PHPStan\PhpDocParser\Ast\ConstExpr\ConstExprStringNode; use PHPStan\PhpDocParser\Ast\Type\ArrayShapeItemNode; use PHPStan\PhpDocParser\Ast\Type\ArrayShapeNode; +use PHPStan\PhpDocParser\Ast\Type\ArrayShapeUnsealedTypeNode; use PHPStan\PhpDocParser\Ast\Type\ConstTypeNode; use PHPStan\PhpDocParser\Ast\Type\IdentifierTypeNode; use PHPStan\PhpDocParser\Ast\Type\TypeNode; @@ -23,33 +25,48 @@ use PHPStan\TrinaryLogic; use PHPStan\Type\AcceptsResult; use PHPStan\Type\Accessory\AccessoryArrayListType; +use PHPStan\Type\Accessory\AccessoryLowercaseStringType; +use PHPStan\Type\Accessory\AccessoryNonEmptyStringType; +use PHPStan\Type\Accessory\AccessoryNonFalsyStringType; +use PHPStan\Type\Accessory\AccessoryNumericStringType; +use PHPStan\Type\Accessory\AccessoryUppercaseStringType; use PHPStan\Type\Accessory\HasOffsetType; use PHPStan\Type\Accessory\HasOffsetValueType; use PHPStan\Type\Accessory\NonEmptyArrayType; use PHPStan\Type\ArrayType; +use PHPStan\Type\BenevolentUnionType; use PHPStan\Type\BooleanType; +use PHPStan\Type\ClassStringType; use PHPStan\Type\CompoundType; use PHPStan\Type\ConstantScalarType; use PHPStan\Type\ErrorType; use PHPStan\Type\GeneralizePrecision; +use PHPStan\Type\Generic\TemplateMixedType; +use PHPStan\Type\Generic\TemplateStrictMixedType; use PHPStan\Type\Generic\TemplateType; use PHPStan\Type\Generic\TemplateTypeMap; use PHPStan\Type\Generic\TemplateTypeVariance; use PHPStan\Type\IntegerRangeType; +use PHPStan\Type\IntegerType; use PHPStan\Type\IntersectionType; use PHPStan\Type\IsSuperTypeOfResult; use PHPStan\Type\MixedType; use PHPStan\Type\NeverType; use PHPStan\Type\NullType; +use PHPStan\Type\ObjectWithoutClassType; use PHPStan\Type\RecursionGuard; use PHPStan\Type\StaticTypeFactory; +use PHPStan\Type\StrictMixedType; +use PHPStan\Type\StringType; use PHPStan\Type\Traits\ArrayTypeTrait; use PHPStan\Type\Traits\NonObjectTypeTrait; use PHPStan\Type\Traits\UndecidedComparisonTypeTrait; +use PHPStan\Type\Traverser\UnsafeArrayStringKeyCastingTraverser; use PHPStan\Type\Type; use PHPStan\Type\TypeCombinator; use PHPStan\Type\UnionType; use PHPStan\Type\VerbosityLevel; +use function array_key_exists; use function array_keys; use function array_map; use function array_merge; @@ -64,6 +81,7 @@ use function in_array; use function is_int; use function is_string; +use function max; use function min; use function pow; use function range; @@ -72,6 +90,7 @@ use function str_contains; use function strtolower; use function strtoupper; +use function usort; use const CASE_LOWER; use const CASE_UPPER; @@ -92,6 +111,9 @@ class ConstantArrayType implements Type private TrinaryLogic $isList; + /** @var array{Type, Type}|null */ + private ?array $unsealed; // phpcs:ignore + /** @var self[]|null */ private ?array $allArrays = null; @@ -99,6 +121,8 @@ class ConstantArrayType implements Type private ?Type $iterableValueType = null; + private ?Type $keyTypesUnion = null; + /** @var array|null */ private ?array $keyIndexMap = null; @@ -108,6 +132,7 @@ class ConstantArrayType implements Type * @param array $valueTypes * @param list $nextAutoIndexes * @param int[] $optionalKeys + * @param array{Type, Type}|null $unsealed */ public function __construct( private array $keyTypes, @@ -115,19 +140,89 @@ public function __construct( private array $nextAutoIndexes = [0], private array $optionalKeys = [], ?TrinaryLogic $isList = null, + ?array $unsealed = null, ) { assert(count($keyTypes) === count($valueTypes)); - $keyTypesCount = count($this->keyTypes); - if ($keyTypesCount === 0) { - $isList = TrinaryLogic::createYes(); - } - + // Fill in `$isList` from the shape when the caller didn't pass one. + // For empty CATs the answer derives from the unsealed key type + // (no explicit keys to inspect); for non-empty ones the default + // is `No` and the caller is expected to assert list-ness via + // `makeList()` if appropriate. if ($isList === null) { - $isList = TrinaryLogic::createNo(); + if (count($this->keyTypes) === 0) { + if ($unsealed === null) { + $isList = TrinaryLogic::createYes(); + } else { + [$unsealedKeyType] = $unsealed; + if ($unsealedKeyType instanceof NeverType && $unsealedKeyType->isExplicit()) { + $isList = TrinaryLogic::createYes(); + } elseif ($unsealedKeyType->isInteger()->yes()) { + $isList = TrinaryLogic::createMaybe(); + } else { + $isList = TrinaryLogic::createNo(); + } + } + } else { + $isList = TrinaryLogic::createNo(); + } } $this->isList = $isList; + + if ($unsealed !== null) { + if (in_array($unsealed[0]->describe(VerbosityLevel::value()), ['(int|string)', '(int|non-decimal-int-string)'], true)) { + $unsealed[0] = new MixedType(); + } + if ($unsealed[0] instanceof StrictMixedType && !$unsealed[0] instanceof TemplateStrictMixedType) { + $unsealed[0] = (new UnionType([new StringType(), new IntegerType()]))->toArrayKey(); + } + } elseif (BleedingEdgeToggle::isBleedingEdge()) { + $never = new NeverType(true); + $unsealed = [$never, $never]; + } + $this->unsealed = $unsealed; + } + + public function isSealed(): TrinaryLogic + { + return $this->isUnsealed()->negate(); + } + + public function isUnsealed(): TrinaryLogic + { + $unsealed = $this->unsealed; + if ($unsealed === null) { + return TrinaryLogic::createMaybe(); + } + + [$keyType] = $unsealed; + + return TrinaryLogic::createFromBoolean(!$keyType instanceof NeverType || !$keyType->isExplicit()); + } + + /** + * @phpstan-pure + * @return array{Type, Type}|null + */ + public function getUnsealedTypes(): ?array + { + return $this->unsealed; + } + + /** + * @internal + */ + public function dropUnsealedTypes(): self + { + return $this->recreate( + $this->keyTypes, + $this->valueTypes, + $this->nextAutoIndexes, + $this->optionalKeys, + $this->isList, + null, + ); } /** @@ -135,16 +230,18 @@ public function __construct( * @param array $valueTypes * @param list $nextAutoIndexes * @param int[] $optionalKeys + * @param array{Type, Type}|null $unsealed */ protected function recreate( array $keyTypes, array $valueTypes, - array $nextAutoIndexes = [0], - array $optionalKeys = [], - ?TrinaryLogic $isList = null, + array $nextAutoIndexes, + array $optionalKeys, + ?TrinaryLogic $isList, + ?array $unsealed, ): self { - return new self($keyTypes, $valueTypes, $nextAutoIndexes, $optionalKeys, $isList); + return new self($keyTypes, $valueTypes, $nextAutoIndexes, $optionalKeys, $isList, $unsealed); } public function getConstantArrays(): array @@ -167,6 +264,16 @@ public function getReferencedClasses(): array } } + if ($this->unsealed !== null) { + [$unsealedKeyType, $unsealedValueType] = $this->unsealed; + foreach ($unsealedKeyType->getReferencedClasses() as $referencedClass) { + $referencedClasses[] = $referencedClass; + } + foreach ($unsealedValueType->getReferencedClasses() as $referencedClass) { + $referencedClasses[] = $referencedClass; + } + } + return $referencedClasses; } @@ -185,7 +292,17 @@ public function getIterableKeyType(): Type $keyType = new UnionType($this->keyTypes); } - return $this->iterableKeyType = $keyType; + if ($this->isUnsealed()->yes() && $this->unsealed !== null) { + $unsealedKeyType = $this->unsealed[0]; + if ($unsealedKeyType instanceof MixedType && !$unsealedKeyType instanceof TemplateMixedType) { + $unsealedKeyType = (new BenevolentUnionType([new IntegerType(), new StringType()]))->toArrayKey(); + } elseif ($unsealedKeyType instanceof StrictMixedType && !$unsealedKeyType instanceof TemplateStrictMixedType) { + $unsealedKeyType = (new BenevolentUnionType([new IntegerType(), new StringType()]))->toArrayKey(); + } + $keyType = TypeCombinator::union($keyType, $unsealedKeyType); + } + + return $this->iterableKeyType = UnsafeArrayStringKeyCastingTraverser::castKeyType($keyType); } public function getIterableValueType(): Type @@ -194,7 +311,19 @@ public function getIterableValueType(): Type return $this->iterableValueType; } - return $this->iterableValueType = count($this->valueTypes) > 0 ? TypeCombinator::union(...$this->valueTypes) : new NeverType(true); + $valueType = count($this->valueTypes) > 0 ? TypeCombinator::union(...$this->valueTypes) : new NeverType(true); + if ($this->isUnsealed()->yes() && $this->unsealed !== null) { + $valueType = TypeCombinator::union($valueType, $this->unsealed[1]); + } + + return $this->iterableValueType = $valueType; + } + + private function getKeyTypesUnion(): Type + { + return $this->keyTypesUnion ??= count($this->keyTypes) > 0 + ? TypeCombinator::union(...$this->keyTypes) + : new NeverType(); } public function getKeyType(): Type @@ -209,6 +338,10 @@ public function getItemType(): Type public function isConstantValue(): TrinaryLogic { + if ($this->isUnsealed()->yes()) { + return TrinaryLogic::createNo(); + } + return TrinaryLogic::createYes(); } @@ -265,11 +398,23 @@ public function getAllArrays(): array continue; } + if (count($keys) === 0 && $this->isUnsealed()->yes() && $this->unsealed !== null) { + // Variant with no explicit keys but real unsealed extras: the + // builder's getArray() would degrade this to a general + // ArrayType. Construct the CAT directly so the variant keeps + // its extras for downstream consumers (e.g. flattenTypes). + $arrays[] = new ConstantArrayType([], [], unsealed: $this->unsealed); + continue; + } + $builder = ConstantArrayTypeBuilder::createEmpty(); $builder->disableArrayDegradation(); foreach ($keys as $i) { $builder->setOffsetValueType($this->keyTypes[$i], $this->valueTypes[$i]); } + if ($this->isUnsealed()->yes() && $this->unsealed !== null) { + $builder->makeUnsealed($this->unsealed[0], $this->unsealed[1]); + } $array = $builder->getArray(); if (!$array instanceof self) { @@ -329,16 +474,213 @@ public function isOptionalKey(int $i): bool return in_array($i, $this->optionalKeys, true); } + public function sortKeys(): self + { + $indices = array_keys($this->keyTypes); + usort($indices, fn (int $a, int $b): int => $this->keyTypes[$a]->getValue() <=> $this->keyTypes[$b]->getValue()); + + $newKeyTypes = []; + $newValueTypes = []; + $indexMap = []; + foreach ($indices as $newIdx => $oldIdx) { + $newKeyTypes[] = $this->keyTypes[$oldIdx]; + $newValueTypes[] = $this->valueTypes[$oldIdx]; + $indexMap[$oldIdx] = $newIdx; + } + + $newOptionalKeys = []; + foreach ($this->optionalKeys as $oldIdx) { + $newOptionalKeys[] = $indexMap[$oldIdx]; + } + sort($newOptionalKeys); + + return $this->recreate( + $newKeyTypes, + $newValueTypes, + $this->nextAutoIndexes, + $newOptionalKeys, + $this->isList, + $this->unsealed, + ); + } + public function accepts(Type $type, bool $strictTypes): AcceptsResult { if ($type instanceof CompoundType && !$type instanceof IntersectionType) { return $type->isAcceptedBy($this, $strictTypes); } - if ($type instanceof self && count($this->keyTypes) === 0) { - return AcceptsResult::createFromBoolean(count($type->keyTypes) === 0); + $isUnsealed = $this->isUnsealed(); + if (!$isUnsealed->yes()) { + if ($type instanceof self && count($this->keyTypes) === 0) { + return AcceptsResult::createFromBoolean(count($type->keyTypes) === 0); + } + } + + $result = $this->checkOurKeys($type, $strictTypes)->and(new AcceptsResult($type->isArray(), [])); + if ($this->unsealed === null) { + if ($type->isOversizedArray()->yes()) { + if (!$result->no()) { + return AcceptsResult::createYes(); + } + } + + return $result; + } + + if ($result->no()) { + return $result; + } + + [$unsealedKeyType, $unsealedValueType] = $this->unsealed; + + if ($isUnsealed->no()) { + if (!$type->isConstantArray()->yes()) { + return $result->and(AcceptsResult::createNo([ + 'Sealed array shape can only accept a constant array. Extra keys are not allowed.', + ])); + } + + $constantArrays = $type->getConstantArrays(); + if (count($constantArrays) !== 1) { + throw new ShouldNotHappenException('Type with more than one constant array occurred, should have been eliminated with `instanceof CompoundType` above.'); + } + + $keys = []; + foreach ($constantArrays[0]->getKeyTypes() as $otherKeyType) { + $keys[$otherKeyType->getValue()] = $otherKeyType; + } + + foreach ($this->keyTypes as $keyType) { + unset($keys[$keyType->getValue()]); + } + + foreach ($keys as $extraKey) { + $result = $result->and(AcceptsResult::createNo([ + sprintf('Sealed array shape does not accept array with extra key %s.', $extraKey->describe(VerbosityLevel::precise())), + ])); + } + + if (!$constantArrays[0]->isUnsealed()->no()) { + $result = $result->and(AcceptsResult::createNo([ + 'Sealed array shape does not accept unsealed array shape.', + ])); + } + + return $result; + } + + if (!$type->isConstantArray()->yes()) { + return $result->and($unsealedKeyType->accepts($type->getIterableKeyType(), $strictTypes)) + ->and($unsealedValueType->accepts($type->getIterableValueType(), $strictTypes)); + } + + $constantArrays = $type->getConstantArrays(); + if (count($constantArrays) !== 1) { + throw new ShouldNotHappenException('Type with more than one constant array occurred, should have been eliminated with `instanceof CompoundType` above.'); + } + + $keys = []; + $constantArray = $constantArrays[0]; + foreach ($constantArray->getKeyTypes() as $i => $otherKeyType) { + $keys[$otherKeyType->getValue()] = [$i, $otherKeyType]; + } + + foreach ($this->keyTypes as $keyType) { + unset($keys[$keyType->getValue()]); + } + + foreach ($keys as [$i, $extraKeyType]) { + $acceptsKey = $unsealedKeyType->accepts($extraKeyType, $strictTypes)->decorateReasons( + static fn (string $reason) => sprintf( + 'Unsealed array key type %s does not accept extra key type %s: %s', + $unsealedKeyType->describe(VerbosityLevel::value()), + $extraKeyType->describe(VerbosityLevel::value()), + $reason, + ), + ); + if (!$acceptsKey->yes() && count($acceptsKey->reasons) === 0) { + $acceptsKey = new AcceptsResult($acceptsKey->result, [ + sprintf( + 'Unsealed array key type %s does not accept extra key type %s.', + $unsealedKeyType->describe(VerbosityLevel::value()), + $extraKeyType->describe(VerbosityLevel::value()), + ), + ]); + } + $result = $result->and($acceptsKey); + + $extraValueType = $constantArray->getValueTypes()[$i]; + $acceptsValue = $unsealedValueType->accepts($extraValueType, $strictTypes)->decorateReasons( + static fn (string $reason) => sprintf( + 'Unsealed array value type %s does not accept extra offset %s with value type %s: %s', + $unsealedValueType->describe(VerbosityLevel::value()), + $extraKeyType->describe(VerbosityLevel::value()), + $extraValueType->describe(VerbosityLevel::value()), + $reason, + ), + ); + if (!$acceptsValue->yes() && count($acceptsValue->reasons) === 0) { + $acceptsValue = new AcceptsResult($acceptsValue->result, [ + sprintf( + 'Unsealed array value type %s does not accept extra offset %s with value type %s.', + $unsealedValueType->describe(VerbosityLevel::value()), + $extraKeyType->describe(VerbosityLevel::value()), + $extraValueType->describe(VerbosityLevel::value()), + ), + ]); + } + $result = $result->and($acceptsValue); + } + + $otherUnsealed = $constantArray->unsealed; + if ($otherUnsealed !== null && !$constantArray->isUnsealed()->no()) { + [$otherUnsealedKeyType, $otherUnsealedValueType] = $otherUnsealed; + + $acceptsUnsealedKey = $unsealedKeyType->accepts($otherUnsealedKeyType, $strictTypes)->decorateReasons( + static fn (string $reason) => sprintf( + 'Unsealed array key type %s does not accept unsealed array key type %s: %s', + $unsealedKeyType->describe(VerbosityLevel::value()), + $otherUnsealedKeyType->describe(VerbosityLevel::value()), + $reason, + ), + ); + if (!$acceptsUnsealedKey->yes() && count($acceptsUnsealedKey->reasons) === 0) { + $acceptsUnsealedKey = new AcceptsResult($acceptsUnsealedKey->result, [ + sprintf( + 'Unsealed array key type %s does not accept unsealed array key type %s.', + $unsealedKeyType->describe(VerbosityLevel::value()), + $otherUnsealedKeyType->describe(VerbosityLevel::value()), + ), + ]); + } + $result = $result->and($acceptsUnsealedKey); + + $acceptsUnsealedValue = $unsealedValueType->accepts($otherUnsealedValueType, $strictTypes)->decorateReasons( + static fn (string $reason) => sprintf( + 'Unsealed array value type %s does not accept unsealed array value type %s: %s', + $unsealedValueType->describe(VerbosityLevel::value()), + $otherUnsealedValueType->describe(VerbosityLevel::value()), + $reason, + ), + ); + if (!$acceptsUnsealedValue->yes() && count($acceptsUnsealedValue->reasons) === 0) { + $acceptsUnsealedValue = new AcceptsResult($acceptsUnsealedValue->result, [ + sprintf( + 'Unsealed array value type %s does not accept unsealed array value type %s.', + $unsealedValueType->describe(VerbosityLevel::value()), + $otherUnsealedValueType->describe(VerbosityLevel::value()), + ), + ]); + } + $result = $result->and($acceptsUnsealedValue); } + return $result; + } + + private function checkOurKeys(Type $type, bool $strictTypes): AcceptsResult + { $result = AcceptsResult::createYes(); foreach ($this->keyTypes as $i => $keyType) { $valueType = $this->valueTypes[$i]; @@ -385,26 +727,35 @@ public function accepts(Type $type, bool $strictTypes): AcceptsResult $result = $result->and($acceptsValue); } - $result = $result->and(new AcceptsResult($type->isArray(), [])); - if ($type->isOversizedArray()->yes()) { - if (!$result->no()) { - return AcceptsResult::createYes(); - } - } - return $result; } public function isSuperTypeOf(Type $type): IsSuperTypeOfResult { if ($type instanceof self) { + $thisUnsealedness = $this->isUnsealed(); + $typeUnsealedness = $type->isUnsealed(); + $bothDefinite = $this->unsealed !== null && $type->unsealed !== null; + if (count($this->keyTypes) === 0) { - return new IsSuperTypeOfResult($type->isIterableAtLeastOnce()->negate(), []); + if (!$bothDefinite) { + return new IsSuperTypeOfResult($type->isIterableAtLeastOnce()->negate(), []); + } + if ($thisUnsealedness->no()) { + return new IsSuperTypeOfResult($type->isIterableAtLeastOnce()->negate(), []); + } + // $this is unsealed with no known keys — fall through to extras/unsealed-part checks below } $results = []; foreach ($this->keyTypes as $i => $keyType) { $hasOffset = $type->hasOffsetValueType($keyType); + if ($bothDefinite && $hasOffset->no() && $typeUnsealedness->yes()) { + [$typeUnsealedKey] = $type->unsealed; + if (!$typeUnsealedKey->isSuperTypeOf($keyType)->no()) { + $hasOffset = TrinaryLogic::createMaybe(); + } + } if ($hasOffset->no()) { if (!$this->isOptionalKey($i)) { return IsSuperTypeOfResult::createNo(); @@ -416,13 +767,69 @@ public function isSuperTypeOf(Type $type): IsSuperTypeOfResult $results[] = IsSuperTypeOfResult::createMaybe(); } - $isValueSuperType = $this->valueTypes[$i]->isSuperTypeOf($type->getOffsetValueType($keyType)); + $otherValueType = $type->getOffsetValueType($keyType); + if ($otherValueType instanceof ErrorType && $bothDefinite && $typeUnsealedness->yes()) { + [, $typeUnsealedValue] = $type->unsealed; + $otherValueType = $typeUnsealedValue; + } + $isValueSuperType = $this->valueTypes[$i]->isSuperTypeOf($otherValueType); if ($isValueSuperType->no()) { return $isValueSuperType->decorateReasons(static fn (string $reason) => sprintf('Offset %s: %s', $keyType->describe(VerbosityLevel::value()), $reason)); } $results[] = $isValueSuperType; } + if ($bothDefinite) { + $thisKeyValues = []; + foreach ($this->keyTypes as $thisKeyType) { + $thisKeyValues[$thisKeyType->getValue()] = true; + } + + foreach ($type->getKeyTypes() as $i => $typeKey) { + if (array_key_exists($typeKey->getValue(), $thisKeyValues)) { + continue; + } + + if ($thisUnsealedness->no()) { + if (!$type->isOptionalKey($i)) { + return IsSuperTypeOfResult::createNo(); + } + $results[] = IsSuperTypeOfResult::createMaybe(); + continue; + } + + [$thisUnsealedKey, $thisUnsealedValue] = $this->unsealed; + $keyCheck = $thisUnsealedKey->isSuperTypeOf($typeKey); + if ($keyCheck->no()) { + if ($type->isOptionalKey($i)) { + $results[] = IsSuperTypeOfResult::createMaybe(); + continue; + } + return IsSuperTypeOfResult::createNo(); + } + $valueCheck = $thisUnsealedValue->isSuperTypeOf($type->getValueTypes()[$i]); + if ($valueCheck->no()) { + if ($type->isOptionalKey($i)) { + $results[] = IsSuperTypeOfResult::createMaybe(); + continue; + } + return IsSuperTypeOfResult::createNo(); + } + $results[] = $keyCheck->and($valueCheck); + } + + if ($typeUnsealedness->yes()) { + if ($thisUnsealedness->no()) { + $results[] = IsSuperTypeOfResult::createMaybe(); + } else { + [$thisUnsealedKey, $thisUnsealedValue] = $this->unsealed; + [$typeUnsealedKey, $typeUnsealedValue] = $type->unsealed; + $results[] = $thisUnsealedKey->isSuperTypeOf($typeUnsealedKey); + $results[] = $thisUnsealedValue->isSuperTypeOf($typeUnsealedValue); + } + } + } + return IsSuperTypeOfResult::createYes()->and(...$results); } @@ -497,6 +904,29 @@ public function equals(Type $type): bool return false; } + // Both `unsealed === null` (legacy / pre-bleeding-edge, where + // `isUnsealed()` answers `Maybe`) and `unsealed === [explicitNever, + // explicitNever]` (the fresh bleeding-edge sealed marker, where + // `isUnsealed()` answers `No`) mean "no real extras". Treat them as + // equivalent here — use `!isUnsealed()->yes()` rather than + // `isUnsealed()->no()`, otherwise a legacy-null shape and a + // marker-sealed shape compare unequal. Only compare the actual + // extras when both sides genuinely have them. + $thisHasExtras = $this->isUnsealed()->yes(); + $otherHasExtras = $type->isUnsealed()->yes(); + if ($thisHasExtras !== $otherHasExtras) { + return false; + } + + if ($thisHasExtras && $this->unsealed !== null && $type->unsealed !== null) { + if (!$this->unsealed[0]->equals($type->unsealed[0])) { + return false; + } + if (!$this->unsealed[1]->equals($type->unsealed[1])) { + return false; + } + } + return true; } @@ -567,7 +997,17 @@ public function findTypeAndMethodNames(): array /** @return ConstantArrayTypeAndMethod[] */ private function doFindTypeAndMethodNames(bool &$hasNonExistentMethod = false): array { - if (count($this->keyTypes) !== 2) { + $isUnsealed = $this->isUnsealed()->yes(); + + // Sealed: must have exactly the two callable slots, no more, no less. + // Unsealed: explicit keys may cover 0, 1, both, or neither — but any + // explicit key outside {0, 1} immediately disqualifies, because the + // callable shape `[classOrObject, method]` has no room for other + // keys. + if (!$isUnsealed && count($this->keyTypes) !== 2) { + return []; + } + if (count($this->keyTypes) > 2) { return []; } @@ -579,11 +1019,47 @@ private function doFindTypeAndMethodNames(bool &$hasNonExistentMethod = false): continue; } - if (!$keyType->isSuperTypeOf(new ConstantIntegerType(1))->yes()) { + if ($keyType->isSuperTypeOf(new ConstantIntegerType(1))->yes()) { + $method = $this->valueTypes[$i]; continue; } - $method = $this->valueTypes[$i]; + // Explicit key is something other than 0 or 1 — not callable. + return []; + } + + // Try to fill missing callable slots from the unsealed extras: an + // unsealed array `array{0: object, ...}` *might* turn + // into a callable if the actual value carries a `1 => 'method'` + // extra. Require that the unsealed key range covers the missing + // slot and that the unsealed value type can overlap with the + // type required for that slot (object|class-string for key 0, + // non-falsy-string for key 1) — otherwise no concrete value of + // this CAT can ever be callable. + if ($isUnsealed && $this->unsealed !== null) { + [$unsealedKey, $unsealedValue] = $this->unsealed; + + if ($classOrObject === null) { + if ($unsealedKey->isSuperTypeOf(new ConstantIntegerType(0))->no()) { + return []; + } + $expected = TypeCombinator::union(new ObjectWithoutClassType(), new ClassStringType()); + if ($expected->isSuperTypeOf($unsealedValue)->no()) { + return []; + } + $classOrObject = $unsealedValue; + } + + if ($method === null) { + if ($unsealedKey->isSuperTypeOf(new ConstantIntegerType(1))->no()) { + return []; + } + $expected = TypeCombinator::intersect(new StringType(), new AccessoryNonFalsyStringType()); + if ($expected->isSuperTypeOf($unsealedValue)->no()) { + return []; + } + $method = $unsealedValue; + } } if ($classOrObject === null || $method === null) { @@ -631,6 +1107,13 @@ private function doFindTypeAndMethodNames(bool &$hasNonExistentMethod = false): $has = $has->and(TrinaryLogic::createMaybe()); } + // Unsealed: the actual value may carry extras beyond keys 0/1, + // which would void the callable shape. The CAT itself describes + // "zero or more extras", so callable-ness is uncertain. + if ($isUnsealed) { + $has = $has->and(TrinaryLogic::createMaybe()); + } + $typeAndMethods[] = ConstantArrayTypeAndMethod::createConcrete($type, $methodName->getValue(), $has); } @@ -675,10 +1158,16 @@ private function recursiveHasOffsetValueType(Type $offsetType): TrinaryLogic $result = TrinaryLogic::createNo(); foreach ($this->keyTypes as $i => $keyType) { + // PHP coerces decimal-integer strings to int when used as array + // keys ("123" → 123), so a non-constant string offset *could* hit + // a constant-integer slot. Skip the upgrade when the offset is + // definitely a non-decimal-integer string — those stay as strings + // and can never collide with an int key. if ( $keyType instanceof ConstantIntegerType && !$offsetType->isString()->no() && $offsetType->isConstantScalarValue()->no() + && !$offsetType->isDecimalIntegerString()->no() ) { return TrinaryLogic::createMaybe(); } @@ -697,12 +1186,26 @@ private function recursiveHasOffsetValueType(Type $offsetType): TrinaryLogic $result = TrinaryLogic::createMaybe(); } + // Unsealed extras (zero-or-more additional entries) can never make a + // hit definite — they're uncertain by construction. They only matter + // when no explicit key matched ($result is No): if the unsealed key + // range overlaps the offset, upgrade No → Maybe. Explicit keys take + // precedence at any slot they cover (PHP keys are unique), so a + // non-No $result already reflects the strongest answer the unsealed + // extras could contribute. + if ($result->no() && $this->isUnsealed()->yes() && $this->unsealed !== null) { + [$unsealedKeyType] = $this->unsealed; + if (!$unsealedKeyType->isSuperTypeOf($offsetType)->no()) { + $result = TrinaryLogic::createMaybe(); + } + } + return $result; } public function getOffsetValueType(Type $offsetType): Type { - if (count($this->keyTypes) === 0) { + if (count($this->keyTypes) === 0 && !$this->isUnsealed()->yes()) { return new ErrorType(); } @@ -728,7 +1231,19 @@ public function getOffsetValueType(Type $offsetType): Type $matchingValueTypes[] = $this->valueTypes[$i]; } - if ($all) { + // Unsealed extras describe entries at keys NOT in the explicit set — + // PHP array keys are unique, so an explicit key fully owns its slot. + // Only include the unsealed value when the offset has parts not + // covered by any explicit key AND those parts overlap the unsealed + // key range. + if ($this->isUnsealed()->yes() && $this->unsealed !== null) { + [$unsealedKeyType, $unsealedValueType] = $this->unsealed; + if (!$this->getKeyTypesUnion()->isSuperTypeOf($offsetType)->yes() && !$unsealedKeyType->isSuperTypeOf($offsetType)->no()) { + $matchingValueTypes[] = $unsealedValueType; + } + } + + if ($all && !$this->isUnsealed()->yes()) { return $this->getIterableValueType(); } @@ -816,7 +1331,7 @@ public function unsetOffset(Type $offsetType, bool $preserveListCertainty = fals return new NeverType(); } - return $this->recreate($newKeyTypes, $newValueTypes, $this->nextAutoIndexes, $newOptionalKeys, $newIsList); + return $this->recreate($newKeyTypes, $newValueTypes, $this->nextAutoIndexes, $newOptionalKeys, $newIsList, $this->unsealed); } return $this; @@ -861,7 +1376,7 @@ public function unsetOffset(Type $offsetType, bool $preserveListCertainty = fals $newIsList = $newIsList->and(TrinaryLogic::createMaybe()); } - return $this->recreate($this->keyTypes, $this->valueTypes, $this->nextAutoIndexes, $optionalKeys, $newIsList); + return $this->recreate($this->keyTypes, $this->valueTypes, $this->nextAutoIndexes, $optionalKeys, $newIsList, $this->unsealed); } $optionalKeys = $this->optionalKeys; @@ -891,7 +1406,7 @@ public function unsetOffset(Type $offsetType, bool $preserveListCertainty = fals return new NeverType(); } - return $this->recreate($this->keyTypes, $this->valueTypes, $this->nextAutoIndexes, $optionalKeys, $newIsList); + return $this->recreate($this->keyTypes, $this->valueTypes, $this->nextAutoIndexes, $optionalKeys, $newIsList, $this->unsealed); } /** @@ -930,6 +1445,15 @@ private static function isListAfterUnset(array $newKeyTypes, array $newOptionalK public function chunkArray(Type $lengthType, TrinaryLogic $preserveKeys): Type { + // With real unsealed extras, we can't precisely enumerate the + // chunks — the source has an unknown number of extras that + // could form additional partial or full chunks. Fall back to + // the general `list>` shape produced by + // the trait, which is correct (just less precise). + if ($this->isUnsealed()->yes()) { + return $this->traitChunkArray($lengthType, $preserveKeys); + } + $biggerOne = IntegerRangeType::fromInterval(1, null); $finiteTypes = $lengthType->getFiniteTypes(); if ($biggerOne->isSuperTypeOf($lengthType)->yes() && count($finiteTypes) < self::CHUNK_FINITE_TYPES_LIMIT) { @@ -975,6 +1499,11 @@ public function fillKeysArray(Type $valueType): Type } } + if ($this->isUnsealed()->yes() && $this->unsealed !== null) { + [, $unsealedValue] = $this->unsealed; + $builder->makeUnsealed($unsealedValue->toArrayKey(), $valueType); + } + return $builder->getArray(); } @@ -991,6 +1520,11 @@ public function flipArray(): Type ); } + if ($this->isUnsealed()->yes() && $this->unsealed !== null) { + [$unsealedKey, $unsealedValue] = $this->unsealed; + $builder->makeUnsealed($unsealedValue->toArrayKey(), $unsealedKey); + } + return $builder->getArray(); } @@ -1007,6 +1541,18 @@ public function intersectKeyArray(Type $otherArraysType): Type $builder->setOffsetValueType($keyType, $valueType, $this->isOptionalKey($i) || !$has->yes()); } + if ($this->isUnsealed()->yes() && $this->unsealed !== null) { + [$unsealedKey, $unsealedValue] = $this->unsealed; + // An unsealed extra at key K survives only if `$other` can + // also have key K. Narrow the unsealed key to the intersection + // of our extras-range and `$other`'s key type. If they don't + // overlap, the unsealed slot is dropped. + $narrowedKey = TypeCombinator::intersect($unsealedKey, $otherArraysType->getIterableKeyType()); + if (!$narrowedKey instanceof NeverType) { + $builder->makeUnsealed($narrowedKey, $unsealedValue); + } + } + return $builder->getArray(); } @@ -1026,6 +1572,14 @@ public function reverseArray(TrinaryLogic $preserveKeys): Type $builder->setOffsetValueType($offsetType, $this->valueTypes[$i], $this->isOptionalKey($i)); } + if ($this->isUnsealed()->yes() && $this->unsealed !== null) { + // `array_reverse` only permutes positions; the unsealed slot + // is "zero or more extras at unspecified positions" both + // before and after. + [$unsealedKey, $unsealedValue] = $this->unsealed; + $builder->makeUnsealed($unsealedKey, $unsealedValue); + } + return $builder->getArray(); } @@ -1060,6 +1614,23 @@ public function searchArray(Type $needleType, ?TrinaryLogic $strict = null): Typ $matches[] = $this->keyTypes[$index]; } + // Unsealed extras can host additional entries beyond the explicit + // keys, so the search may also find the needle there. The unsealed + // extras' presence is uncertain by definition (zero or more + // entries), so they can never make the needle "definitely found" + // (`hasIdenticalValue` stays false) — `false` always remains a + // possible result. + if ($this->isUnsealed()->yes() && $this->unsealed !== null) { + [$unsealedKeyType, $unsealedValueType] = $this->unsealed; + $considerUnsealed = true; + if ($strict->yes()) { + $considerUnsealed = !$unsealedValueType->isSuperTypeOf($needleType)->no(); + } + if ($considerUnsealed) { + $matches[] = $unsealedKeyType; + } + } + if (count($matches) > 0) { if ($hasIdenticalValue) { return TypeCombinator::union(...$matches); @@ -1115,7 +1686,7 @@ public function sliceArray(Type $offsetType, Type $lengthType, TrinaryLogic $pre if ($length === 0 || ($offset < 0 && $length < 0 && $offset - $length >= 0)) { // 0 / 0, 3 / 0 or e.g. -3 / -3 or -3 / -4 and so on never extract anything - return $this->recreate([], []); + return $this->recreate([], [], [0], [], null, [new NeverType(true), new NeverType(true)]); } if ($length < 0) { @@ -1178,6 +1749,19 @@ public function sliceArray(Type $offsetType, Type $lengthType, TrinaryLogic $pre $builder->setOffsetValueType($offsetType, $this->valueTypes[$i], $isOptional); } + // When the requested length runs past the explicit keys, the + // missing trailing slots could be filled by the source's + // unsealed extras (or be absent). Carry the unsealed slot + // through so the result still describes those potential extras. + if ( + $this->isUnsealed()->yes() + && $this->unsealed !== null + && $nonOptionalElementsCount < $length + ) { + [$unsealedKey, $unsealedValue] = $this->unsealed; + $builder->makeUnsealed($unsealedKey, $unsealedValue); + } + return $builder->getArray(); } @@ -1282,6 +1866,16 @@ public function spliceArray(Type $offsetType, Type $lengthType, Type $replacemen ); } + // `array_splice` removes a slice at an explicit offset and + // inserts a replacement there. Real unsealed extras live at + // positions past the explicit keys, so they're unaffected + // by the operation (re-indexing of int keys keeps the + // `` range intact). Carry the slot through. + if ($this->isUnsealed()->yes() && $this->unsealed !== null) { + [$unsealedKey, $unsealedValue] = $this->unsealed; + $builder->makeUnsealed($unsealedKey, $unsealedValue); + } + $builtType = $builder->getArray(); if ($allKeysInteger && !$builtType->isList()->yes()) { $builtType = TypeCombinator::intersect($builtType, new AccessoryArrayListType()); @@ -1331,12 +1925,21 @@ public function truncateListToSize(Type $sizeType): Type // Unbounded max: probe explicit keys from `$min` onward until // `hasOffsetValueType` answers `no`. Each probe contributes one // optional (or required, when `hasOffsetValueType` is `yes`) slot. + $isUnsealed = $this->isUnsealed()->yes(); for ($i = $min;; $i++) { $offsetType = new ConstantIntegerType($i); $hasOffset = $this->hasOffsetValueType($offsetType); if ($hasOffset->no()) { break; } + // Real unsealed extras make `hasOffsetValueType` answer + // `Maybe` for *any* in-range key, so the probe would + // otherwise run until `ARRAY_COUNT_LIMIT` bails (slow + + // lossy). Stop once the explicit keys are exhausted; the + // unsealed slot attached below covers further entries. + if ($isUnsealed && !$hasOffset->yes()) { + break; + } $builderData[] = [$offsetType, $this->getOffsetValueType($offsetType), !$hasOffset->yes()]; } } @@ -1350,6 +1953,13 @@ public function truncateListToSize(Type $sizeType): Type $builder->setOffsetValueType($offsetType, $valueType, $optional); } + // Carry the unsealed slot through only for the unbounded-max + // branch — a bounded-max range caps the result size and the + // unsealed extras can't fit. + if ($max === null && $this->isUnsealed()->yes() && $this->unsealed !== null) { + $builder->makeUnsealed($this->unsealed[0], $this->unsealed[1]); + } + $builtArray = $builder->getArray(); // `setOffsetValueType` on a brand-new builder produces a list when // the resulting offsets are sequential ints — but it may not preserve @@ -1389,7 +1999,10 @@ public function isIterableAtLeastOnce(): TrinaryLogic { $keysCount = count($this->keyTypes); if ($keysCount === 0) { - return TrinaryLogic::createNo(); + if (!$this->isUnsealed()->yes()) { + return TrinaryLogic::createNo(); + } + return TrinaryLogic::createMaybe(); } $optionalKeysCount = count($this->optionalKeys); @@ -1404,11 +2017,16 @@ public function getArraySize(): Type { $optionalKeysCount = count($this->optionalKeys); $totalKeysCount = count($this->getKeyTypes()); - if ($optionalKeysCount === 0) { - return new ConstantIntegerType($totalKeysCount); + if (!$this->isUnsealed()->yes()) { + if ($optionalKeysCount === 0) { + return new ConstantIntegerType($totalKeysCount); + } + $max = $totalKeysCount; + } else { + $max = null; } - return IntegerRangeType::fromInterval($totalKeysCount - $optionalKeysCount, $totalKeysCount); + return IntegerRangeType::fromInterval($totalKeysCount - $optionalKeysCount, $max); } public function getFirstIterableKeyType(): Type @@ -1421,6 +2039,16 @@ public function getFirstIterableKeyType(): Type } } + if ($this->isUnsealed()->yes() && $this->unsealed !== null) { + $unsealedKeyType = $this->unsealed[0]; + if ($unsealedKeyType instanceof MixedType && !$unsealedKeyType instanceof TemplateMixedType) { + $unsealedKeyType = (new BenevolentUnionType([new IntegerType(), new StringType()]))->toArrayKey(); + } elseif ($unsealedKeyType instanceof StrictMixedType && !$unsealedKeyType instanceof TemplateStrictMixedType) { + $unsealedKeyType = (new BenevolentUnionType([new IntegerType(), new StringType()]))->toArrayKey(); + } + $keyTypes[] = $unsealedKeyType; + } + return TypeCombinator::union(...$keyTypes); } @@ -1434,6 +2062,16 @@ public function getLastIterableKeyType(): Type } } + if ($this->isUnsealed()->yes() && $this->unsealed !== null) { + $unsealedKeyType = $this->unsealed[0]; + if ($unsealedKeyType instanceof MixedType && !$unsealedKeyType instanceof TemplateMixedType) { + $unsealedKeyType = (new BenevolentUnionType([new IntegerType(), new StringType()]))->toArrayKey(); + } elseif ($unsealedKeyType instanceof StrictMixedType && !$unsealedKeyType instanceof TemplateStrictMixedType) { + $unsealedKeyType = (new BenevolentUnionType([new IntegerType(), new StringType()]))->toArrayKey(); + } + $keyTypes[] = $unsealedKeyType; + } + return TypeCombinator::union(...$keyTypes); } @@ -1447,6 +2085,10 @@ public function getFirstIterableValueType(): Type } } + if ($this->isUnsealed()->yes() && $this->unsealed !== null) { + $valueTypes[] = $this->unsealed[1]; + } + return TypeCombinator::union(...$valueTypes); } @@ -1460,6 +2102,10 @@ public function getLastIterableValueType(): Type } } + if ($this->isUnsealed()->yes() && $this->unsealed !== null) { + $valueTypes[] = $this->unsealed[1]; + } + return TypeCombinator::union(...$valueTypes); } @@ -1481,6 +2127,33 @@ private function removeLastElements(int $length): self return $this; } + // With real unsealed extras on the source, the elements being + // "removed" might come from the unsealed range rather than from + // the trailing explicit keys — the array might have zero extras + // (so the trailing explicit keys are popped) or one+ extras (so + // they're popped instead, leaving the explicit keys intact). + // Encode this by marking the trailing keys as optional and + // keeping the unsealed slot in place. + if ($this->isUnsealed()->yes()) { + $optionalKeys = $this->optionalKeys; + $newLength = $keyTypesCount - $length; + for ($i = $keyTypesCount - 1; $i >= max($newLength, 0); $i--) { + if (in_array($i, $optionalKeys, true)) { + continue; + } + $optionalKeys[] = $i; + } + + return $this->recreate( + $this->keyTypes, + $this->valueTypes, + $this->nextAutoIndexes, + array_values($optionalKeys), + $this->isList, + $this->unsealed, + ); + } + $keyTypes = $this->keyTypes; $valueTypes = $this->valueTypes; $optionalKeys = $this->optionalKeys; @@ -1524,6 +2197,7 @@ private function removeLastElements(int $length): self $nextAutoindexes, array_values($optionalKeys), $this->isList, + $this->unsealed, ); } @@ -1555,6 +2229,16 @@ private function removeFirstElements(int $length, bool $reindex = true): Type $builder->setOffsetValueType($keyType, $valueType, $isOptional); } + if ($this->isUnsealed()->yes() && $this->unsealed !== null) { + // `array_shift` removes the *first* element. The explicit + // keys precede the unsealed extras in insertion order, so + // the shift always lands on an explicit key (when there is + // one); the unsealed slot is unaffected. Re-indexing of int + // keys doesn't change the unsealed range — it stays ``. + [$unsealedKey, $unsealedValue] = $this->unsealed; + $builder->makeUnsealed($unsealedKey, $unsealedValue); + } + return $builder->getArray(); } @@ -1575,7 +2259,8 @@ public function toFloat(): Type public function generalize(GeneralizePrecision $precision): Type { - if (count($this->keyTypes) === 0) { + // No explicit keys and no real extras — actually empty, return as-is. + if (count($this->keyTypes) === 0 && !$this->isUnsealed()->yes()) { return $this; } @@ -1600,7 +2285,14 @@ public function generalize(GeneralizePrecision $precision): Type $accessoryTypes[] = new HasOffsetValueType($keyType, $this->valueTypes[$i]->generalize($precision)); } - } elseif ($keyTypesCount > $optionalKeysCount) { + } elseif ($this->isIterableAtLeastOnce()->yes()) { + // Previously gated on `keyTypesCount > optionalKeysCount`, + // which mishandles "no explicit keys + real unsealed + // extras" (`isIterableAtLeastOnce()` answers `Maybe` — + // extras might be empty — and correctly skips + // `NonEmptyArrayType`). The new gate also covers the + // usual sealed-with-required-keys case, so behaviour for + // existing CAT shapes is unchanged. $accessoryTypes[] = new NonEmptyArrayType(); } @@ -1622,7 +2314,13 @@ public function generalizeValues(): self $valueTypes[] = $valueType->generalize(GeneralizePrecision::lessSpecific()); } - return $this->recreate($this->keyTypes, $valueTypes, $this->nextAutoIndexes, $this->optionalKeys, $this->isList); + $unsealed = $this->unsealed; + if ($unsealed !== null) { + [$unsealedKey, $unsealedValue] = $unsealed; + $unsealed = [$unsealedKey, $unsealedValue->generalize(GeneralizePrecision::lessSpecific())]; + } + + return $this->recreate($this->keyTypes, $valueTypes, $this->nextAutoIndexes, $this->optionalKeys, $this->isList, $unsealed); } private function degradeToGeneralArray(): Type @@ -1635,7 +2333,7 @@ private function degradeToGeneralArray(): Type public function getKeysArrayFiltered(Type $filterValueType, TrinaryLogic $strict): Type { - $keysArray = $this->getKeysOrValuesArray($this->keyTypes); + $keysArray = $this->getKeysOrValuesArray($this->keyTypes, $this->unsealed[0] ?? null); return new IntersectionType([ new ArrayType( @@ -1648,29 +2346,41 @@ public function getKeysArrayFiltered(Type $filterValueType, TrinaryLogic $strict public function getKeysArray(): self { - return $this->getKeysOrValuesArray($this->keyTypes); + return $this->getKeysOrValuesArray($this->keyTypes, $this->unsealed[0] ?? null); } public function getValuesArray(): self { - return $this->getKeysOrValuesArray($this->valueTypes); + return $this->getKeysOrValuesArray($this->valueTypes, $this->unsealed[1] ?? null); } /** * @param array $types */ - private function getKeysOrValuesArray(array $types): self + private function getKeysOrValuesArray(array $types, ?Type $unsealedSourceType): self { $count = count($types); $autoIndexes = range($count - count($this->optionalKeys), $count); + // The result is always a list — the source's keys/values are + // numbered sequentially. The new unsealed slot (if the source + // has real extras) describes "zero or more extras at int + // positions >= 0 whose values are the source's unsealed + // key/value type". `int<0, max>` is the conventional unsealed + // key for list-shaped extras; it also enables the short-form + // `` describe. + $resultUnsealed = null; + if ($this->isUnsealed()->yes() && $unsealedSourceType !== null) { + $resultUnsealed = [IntegerRangeType::createAllGreaterThanOrEqualTo(0), $unsealedSourceType]; + } + if ($this->isList->yes()) { // Optimized version for lists: Assume that if a later key exists, then earlier keys also exist. $keyTypes = array_map( static fn (int $i): ConstantIntegerType => new ConstantIntegerType($i), array_keys($types), ); - return $this->recreate($keyTypes, $types, $autoIndexes, $this->optionalKeys, TrinaryLogic::createYes()); + return $this->recreate($keyTypes, $types, $autoIndexes, $this->optionalKeys, TrinaryLogic::createYes(), $resultUnsealed); } $keyTypes = []; @@ -1699,7 +2409,7 @@ private function getKeysOrValuesArray(array $types): self $maxIndex++; } - return $this->recreate($keyTypes, $valueTypes, $autoIndexes, $optionalKeys, TrinaryLogic::createYes()); + return $this->recreate($keyTypes, $valueTypes, $autoIndexes, $optionalKeys, TrinaryLogic::createYes(), $resultUnsealed); } public function describe(VerbosityLevel $level): string @@ -1744,6 +2454,23 @@ public function describe(VerbosityLevel $level): string $append = ', ...'; } + if ($this->isUnsealed()->yes() && $this->unsealed !== null) { + if (count($items) > 0) { + $append .= ', '; + } + $append .= '...'; + $keyDescription = $this->unsealed[0]->describe(VerbosityLevel::precise()); + $isMixedKeyType = $this->unsealed[0] instanceof MixedType && $keyDescription === 'mixed' && !$this->unsealed[0]->isExplicitMixed(); + $isMixedItemType = $this->unsealed[1] instanceof MixedType && $this->unsealed[1]->describe(VerbosityLevel::precise()) === 'mixed' && !$this->unsealed[1]->isExplicitMixed(); + if ($isMixedKeyType || ($this->isList()->yes() && $keyDescription === 'int<0, max>')) { + if (!$isMixedItemType) { + $append .= sprintf('<%s>', $this->unsealed[1]->describe($level)); + } + } else { + $append .= sprintf('<%s, %s>', $this->unsealed[0]->describe($level), $this->unsealed[1]->describe($level)); + } + } + return sprintf( '%s{%s%s}', $arrayName, @@ -1792,6 +2519,43 @@ public function inferTemplateTypes(Type $receivedType): TemplateTypeMap $typeMap = $typeMap->union($valueType->inferTemplateTypes($receivedValueType)); } + $unsealed = $this->getUnsealedTypes(); + if ($unsealed !== null) { + [$unsealedKeyType, $unsealedValueType] = $unsealed; + + // Received's explicit keys not in $this's explicit keys are + // candidates for matching $this's unsealed extras pattern. + // Only contribute when the key type matches; mismatched explicit + // keys are extra entries the parameter wouldn't accept anyway, + // surfaced by the regular argument-type check. + $receivedKeyTypes = $receivedType->getKeyTypes(); + $receivedValueTypes = $receivedType->getValueTypes(); + foreach ($receivedKeyTypes as $j => $receivedKeyType) { + if ($this->hasOffsetValueType($receivedKeyType)->yes()) { + continue; + } + if (!$unsealedKeyType->isSuperTypeOf($receivedKeyType)->yes()) { + continue; + } + $typeMap = $typeMap->union($unsealedKeyType->inferTemplateTypes($receivedKeyType)); + $typeMap = $typeMap->union($unsealedValueType->inferTemplateTypes($receivedValueTypes[$j])); + } + + // Received's own unsealed extras describe "all the rest" — when + // the key type doesn't fit $this's unsealed key pattern there + // is no valid template assignment, so force NEVER. + $receivedUnsealed = $receivedType->getUnsealedTypes(); + if ($receivedUnsealed !== null) { + [$receivedUnsealedKey, $receivedUnsealedValue] = $receivedUnsealed; + if ($unsealedKeyType->isSuperTypeOf($receivedUnsealedKey)->no()) { + $typeMap = $typeMap->union($unsealedValueType->inferTemplateTypes(new NeverType())); + } else { + $typeMap = $typeMap->union($unsealedKeyType->inferTemplateTypes($receivedUnsealedKey)); + $typeMap = $typeMap->union($unsealedValueType->inferTemplateTypes($receivedUnsealedValue)); + } + } + } + return $typeMap; } @@ -1822,6 +2586,16 @@ public function getReferencedTemplateTypes(TemplateTypeVariance $positionVarianc } } + if ($this->unsealed !== null) { + [$unsealedKeyType, $unsealedValueType] = $this->unsealed; + foreach ($unsealedKeyType->getReferencedTemplateTypes($variance) as $reference) { + $references[] = $reference; + } + foreach ($unsealedValueType->getReferencedTemplateTypes($variance) as $reference) { + $references[] = $reference; + } + } + return $references; } @@ -1864,11 +2638,21 @@ public function traverse(callable $cb): Type $valueTypes[] = $transformedValueType; } + $unsealed = $this->unsealed; + if ($unsealed !== null) { + [$unsealedKeyType, $unsealedValueType] = $unsealed; + $transformedUnsealedValueType = $cb($unsealedValueType); + if ($transformedUnsealedValueType !== $unsealedValueType) { + $stillOriginal = false; + $unsealed = [$unsealedKeyType, $transformedUnsealedValueType]; + } + } + if ($stillOriginal) { return $this; } - return $this->recreate($this->keyTypes, $valueTypes, $this->nextAutoIndexes, $this->optionalKeys, $this->isList); + return $this->recreate($this->keyTypes, $valueTypes, $this->nextAutoIndexes, $this->optionalKeys, $this->isList, $unsealed); } public function traverseSimultaneously(Type $right, callable $cb): Type @@ -1890,14 +2674,103 @@ public function traverseSimultaneously(Type $right, callable $cb): Type $valueTypes[] = $transformedValueType; } + $unsealed = $this->unsealed; + if ($unsealed !== null) { + [$unsealedKeyType, $unsealedValueType] = $unsealed; + $transformedUnsealedValueType = $cb($unsealedValueType, $right->getIterableValueType()); + if ($transformedUnsealedValueType !== $unsealedValueType) { + $stillOriginal = false; + $unsealed = [$unsealedKeyType, $transformedUnsealedValueType]; + } + } + if ($stillOriginal) { return $this; } - return $this->recreate($this->keyTypes, $valueTypes, $this->nextAutoIndexes, $this->optionalKeys, $this->isList); + return $this->recreate($this->keyTypes, $valueTypes, $this->nextAutoIndexes, $this->optionalKeys, $this->isList, $unsealed); } public function isKeysSupersetOf(self $otherArray): bool + { + if ($this->unsealed === null || $otherArray->unsealed === null) { + return $this->legacyIsKeysSupersetOf($otherArray); + } + + [$thisUnsealedKey, $thisUnsealedValue] = $this->unsealed; + [$otherUnsealedKey, $otherUnsealedValue] = $otherArray->unsealed; + $thisHasExtras = $this->isUnsealed()->yes(); + $otherHasExtras = $otherArray->isUnsealed()->yes(); + + $otherHasRequiredKeys = false; + foreach ($otherArray->keyTypes as $j => $keyType) { + if ($otherArray->isOptionalKey($j)) { + continue; + } + $otherHasRequiredKeys = true; + break; + } + + // Sealed empty $other (no keys, no extras): absorbing it is lossless iff $this + // already accepts []. i.e., all of $this's known keys are optional. Otherwise + // merge would add [] as a new instance. + if (!$otherHasRequiredKeys && !$otherHasExtras && count($otherArray->keyTypes) === 0) { + foreach ($this->keyTypes as $i => $keyType) { + if (!$this->isOptionalKey($i)) { + return false; + } + } + return true; + } + + // With real unsealed extras on both sides that can absorb each other's + // required keys, merging is acceptable regardless of which keys overlap. + if ($thisHasExtras && $otherHasExtras) { + return true; + } + + // Asymmetric extras: one side has real extras that can absorb the other's keys. + if ($thisHasExtras) { + if ($this->legacyIsKeysSupersetOf($otherArray)) { + return true; + } + foreach ($otherArray->keyTypes as $j => $keyType) { + if ($otherArray->isOptionalKey($j)) { + continue; + } + if ($thisUnsealedKey->isSuperTypeOf($keyType)->no()) { + return false; + } + if ($thisUnsealedValue->isSuperTypeOf($otherArray->valueTypes[$j])->no()) { + return false; + } + } + return true; + } + + if ($otherHasExtras) { + if ($this->legacyIsKeysSupersetOf($otherArray)) { + return true; + } + foreach ($this->keyTypes as $i => $keyType) { + if ($this->isOptionalKey($i)) { + continue; + } + if ($otherUnsealedKey->isSuperTypeOf($keyType)->no()) { + return false; + } + if ($otherUnsealedValue->isSuperTypeOf($this->valueTypes[$i])->no()) { + return false; + } + } + return true; + } + + // Both sealed: fall back to the legacy key/value shape check. + return $this->legacyIsKeysSupersetOf($otherArray); + } + + private function legacyIsKeysSupersetOf(self $otherArray): bool { $keyTypesCount = count($this->keyTypes); $otherKeyTypesCount = count($otherArray->keyTypes); @@ -1957,6 +2830,114 @@ public function isKeysSupersetOf(self $otherArray): bool public function mergeWith(self $otherArray): self { // only call this after verifying isKeysSupersetOf, or if losing tagged unions is not an issue + if ($this->unsealed === null || $otherArray->unsealed === null) { + return $this->legacyMergeWith($otherArray); + } + + [$thisUnsealedKey, $thisUnsealedValue] = $this->unsealed; + [$otherUnsealedKey, $otherUnsealedValue] = $otherArray->unsealed; + + $mergedUnsealedKey = TypeCombinator::union($thisUnsealedKey, $otherUnsealedKey); + $mergedUnsealedValue = TypeCombinator::union($thisUnsealedValue, $otherUnsealedValue); + + $absorbIntoExtras = static function (Type $keyType, Type $valueType) use (&$mergedUnsealedKey, &$mergedUnsealedValue): void { + $mergedUnsealedKey = TypeCombinator::union($mergedUnsealedKey, $keyType); + $mergedUnsealedValue = TypeCombinator::union($mergedUnsealedValue, $valueType); + }; + + $canAbsorb = static function (self $side, Type $keyType, Type $valueType): bool { + if (!$side->isUnsealed()->yes()) { + return false; + } + if ($side->unsealed === null) { + return false; + } + [$sideUnsealedKey, $sideUnsealedValue] = $side->unsealed; + if ($sideUnsealedKey->isSuperTypeOf($keyType)->no()) { + return false; + } + if ($sideUnsealedValue->isSuperTypeOf($valueType)->no()) { + return false; + } + return true; + }; + + $keyTypes = []; + $valueTypes = []; + $optionalKeys = []; + $nextAutoIndexes = [0]; + + $otherKeyIndexMap = $otherArray->getKeyIndexMap(); + $processed = []; + + foreach ($this->keyTypes as $i => $keyType) { + $keyValue = $keyType->getValue(); + $processed[$keyValue] = true; + $valueType = $this->valueTypes[$i]; + + if (array_key_exists($keyValue, $otherKeyIndexMap)) { + $j = $otherKeyIndexMap[$keyValue]; + $otherValueType = $otherArray->valueTypes[$j]; + $mergedValue = TypeCombinator::union($valueType, $otherValueType); + $optional = $this->isOptionalKey($i) || $otherArray->isOptionalKey($j); + + $keyTypes[] = $keyType; + $valueTypes[] = $mergedValue; + if ($optional) { + $optionalKeys[] = count($keyTypes) - 1; + } + continue; + } + + if ($canAbsorb($otherArray, $keyType, $valueType)) { + $absorbIntoExtras($keyType, $valueType); + continue; + } + + $keyTypes[] = $keyType; + $valueTypes[] = $valueType; + $optionalKeys[] = count($keyTypes) - 1; + } + + foreach ($otherArray->keyTypes as $j => $keyType) { + $keyValue = $keyType->getValue(); + if (array_key_exists($keyValue, $processed)) { + continue; + } + $valueType = $otherArray->valueTypes[$j]; + + if ($canAbsorb($this, $keyType, $valueType)) { + $absorbIntoExtras($keyType, $valueType); + continue; + } + + $keyTypes[] = $keyType; + $valueTypes[] = $valueType; + $optionalKeys[] = count($keyTypes) - 1; + } + + $resultUnsealed = [$mergedUnsealedKey, $mergedUnsealedValue]; + + $nextAutoIndexes = array_values(array_unique(array_merge($this->nextAutoIndexes, $otherArray->nextAutoIndexes))); + sort($nextAutoIndexes); + + $optionalKeys = array_values(array_unique($optionalKeys)); + + /** @var list $keyTypes */ + $keyTypes = $keyTypes; + + return $this->recreate( + $keyTypes, + $valueTypes, + $nextAutoIndexes, + $optionalKeys, + $this->isList->and($otherArray->isList), + $resultUnsealed, + ); + } + + private function legacyMergeWith(self $otherArray): self + { $valueTypes = $this->valueTypes; $optionalKeys = $this->optionalKeys; foreach ($this->keyTypes as $i => $keyType) { @@ -1977,7 +2958,7 @@ public function mergeWith(self $otherArray): self $nextAutoIndexes = array_values(array_unique(array_merge($this->nextAutoIndexes, $otherArray->nextAutoIndexes))); sort($nextAutoIndexes); - return $this->recreate($this->keyTypes, $valueTypes, $nextAutoIndexes, $optionalKeys, $this->isList->and($otherArray->isList)); + return $this->recreate($this->keyTypes, $valueTypes, $nextAutoIndexes, $optionalKeys, $this->isList->and($otherArray->isList), $this->unsealed); } /** @@ -2033,10 +3014,39 @@ public function makeOffsetRequired(Type $offsetType): self } if (count($this->optionalKeys) !== count($optionalKeys)) { - return $this->recreate($this->keyTypes, $this->valueTypes, $this->nextAutoIndexes, array_values($optionalKeys), $this->isList); + return $this->recreate($this->keyTypes, $this->valueTypes, $this->nextAutoIndexes, array_values($optionalKeys), $this->isList, $this->unsealed); } - break; + return $this; + } + + // Offset isn't in the explicit set. If the unsealed extras' key range + // covers it (e.g. `array{a: int, ...}` narrowing on + // `array_key_exists('b', $arr)`), promote it into the explicit set as + // a required slot with the unsealed value type. The unsealed extras + // stay around — additional entries at other matching keys are still + // possible. + if ( + $this->isUnsealed()->yes() + && $this->unsealed !== null + && ($offsetType instanceof ConstantIntegerType || $offsetType instanceof ConstantStringType) + ) { + [$unsealedKeyType, $unsealedValueType] = $this->unsealed; + if (!$unsealedKeyType->isSuperTypeOf($offsetType)->no()) { + $keyTypes = $this->keyTypes; + $valueTypes = $this->valueTypes; + $keyTypes[] = $offsetType; + $valueTypes[] = $unsealedValueType; + + return $this->recreate( + $keyTypes, + $valueTypes, + $this->nextAutoIndexes, + $this->optionalKeys, + TrinaryLogic::createNo(), + $this->unsealed, + ); + } } return $this; @@ -2052,7 +3062,7 @@ public function makeList(): Type return new NeverType(); } - return $this->recreate($this->keyTypes, $this->valueTypes, $this->nextAutoIndexes, $this->optionalKeys, TrinaryLogic::createYes()); + return $this->recreate($this->keyTypes, $this->valueTypes, $this->nextAutoIndexes, $this->optionalKeys, TrinaryLogic::createYes(), $this->unsealed); } public function makeListMaybe(): Type @@ -2067,6 +3077,7 @@ public function makeListMaybe(): Type $this->nextAutoIndexes, $this->optionalKeys, TrinaryLogic::createMaybe(), + $this->unsealed, ); } @@ -2077,12 +3088,17 @@ public function mapValueType(callable $cb): Type $newValueTypes[] = $cb($valueType); } + $newUnsealed = $this->unsealed === null + ? null + : [$this->unsealed[0], $cb($this->unsealed[1])]; + return $this->recreate( $this->keyTypes, $newValueTypes, $this->nextAutoIndexes, $this->optionalKeys, $this->isList, + $newUnsealed, ); } @@ -2108,6 +3124,7 @@ public function makeAllArrayKeysOptional(): Type $this->nextAutoIndexes, range(0, $keyCount - 1), $this->isList, + $this->unsealed, ); } @@ -2122,6 +3139,11 @@ public function changeKeyCaseArray(?int $case): Type } $builder->setOffsetValueType($newKeyType, $this->valueTypes[$i], $this->isOptionalKey($i)); } + + if ($this->unsealed !== null) { + $builder->makeUnsealed(self::foldUnsealedKeyCase($this->unsealed[0], $case), $this->unsealed[1]); + } + $result = $builder->getArray(); if ($this->isList()->yes()) { $result = TypeCombinator::intersect($result, new AccessoryArrayListType()); @@ -2146,6 +3168,13 @@ public function filterArrayRemovingFalsey(): Type $builder->setOffsetValueType($keyType, $value, $this->isOptionalKey($i)); } + if ($this->unsealed !== null) { + $unsealedValue = TypeCombinator::remove($this->unsealed[1], $falseyTypes); + if (!$unsealedValue instanceof NeverType) { + $builder->makeUnsealed($this->unsealed[0], $unsealedValue); + } + } + return $builder->getArray(); } @@ -2164,6 +3193,57 @@ private static function foldConstantStringKeyCase(ConstantStringType $type, ?int ); } + private static function foldUnsealedKeyCase(Type $key, ?int $case): Type + { + if ($key instanceof ConstantStringType) { + return self::foldConstantStringKeyCase($key, $case); + } + + if ($key instanceof UnionType) { + $folded = []; + foreach ($key->getTypes() as $innerKey) { + $folded[] = self::foldUnsealedKeyCase($innerKey, $case); + } + + return TypeCombinator::union(...$folded); + } + + // `array_change_key_case` only folds string keys — int keys + // (e.g. `...`) pass through unchanged. + if (!$key->isString()->yes()) { + return $key; + } + + // Rebuild from a clean `string` plus the non-case accessories that + // case-folding preserves (length is unchanged, so numeric / non- + // falsy / non-empty all survive). Any prior lowercase/uppercase + // accessory is dropped — matches the `ArrayType::changeKeyCaseArray` + // behavior where `strtoupper(lowercase-string)` reads as + // `uppercase-string`, not the contradictory intersection. + $preserved = [new StringType()]; + if ($key->isNumericString()->yes()) { + $preserved[] = new AccessoryNumericStringType(); + } elseif ($key->isNonFalsyString()->yes()) { + $preserved[] = new AccessoryNonFalsyStringType(); + } elseif ($key->isNonEmptyString()->yes()) { + $preserved[] = new AccessoryNonEmptyStringType(); + } + + if ($case === CASE_LOWER) { + return new IntersectionType([...$preserved, new AccessoryLowercaseStringType()]); + } + if ($case === CASE_UPPER) { + return new IntersectionType([...$preserved, new AccessoryUppercaseStringType()]); + } + + // `null` (PHP <8.4 / unspecified) yields lower- or upper-case + // keys; record both as a union. + return TypeCombinator::union( + new IntersectionType([...$preserved, new AccessoryLowercaseStringType()]), + new IntersectionType([...$preserved, new AccessoryUppercaseStringType()]), + ); + } + public function toPhpDocNode(): TypeNode { $items = []; @@ -2204,6 +3284,33 @@ public function toPhpDocNode(): TypeNode ); } + if ($this->isUnsealed()->yes() && $this->unsealed !== null) { + $unsealedKeyTypeDescription = $this->unsealed[0]->describe(VerbosityLevel::precise()); + $isMixedUnsealedKeyType = $this->unsealed[0] instanceof MixedType && $unsealedKeyTypeDescription === 'mixed' && !$this->unsealed[0]->isExplicitMixed(); + $isMixedUnsealedItemType = $this->unsealed[1] instanceof MixedType && $this->unsealed[1]->describe(VerbosityLevel::precise()) === 'mixed' && !$this->unsealed[1]->isExplicitMixed(); + if ($isMixedUnsealedKeyType || ($this->isList()->yes() && $unsealedKeyTypeDescription === 'int<0, max>')) { + if ($isMixedUnsealedItemType) { + return ArrayShapeNode::createUnsealed( + $exportValuesOnly ? $values : $items, + null, + $this->shouldBeDescribedAsAList() ? ArrayShapeNode::KIND_LIST : ArrayShapeNode::KIND_ARRAY, + ); + } + + return ArrayShapeNode::createUnsealed( + $exportValuesOnly ? $values : $items, + new ArrayShapeUnsealedTypeNode($this->unsealed[1]->toPhpDocNode(), null), + $this->shouldBeDescribedAsAList() ? ArrayShapeNode::KIND_LIST : ArrayShapeNode::KIND_ARRAY, + ); + } + + return ArrayShapeNode::createUnsealed( + $exportValuesOnly ? $values : $items, + new ArrayShapeUnsealedTypeNode($this->unsealed[1]->toPhpDocNode(), $this->unsealed[0]->toPhpDocNode()), + ArrayShapeNode::KIND_ARRAY, + ); + } + return ArrayShapeNode::createSealed( $exportValuesOnly ? $values : $items, $this->shouldBeDescribedAsAList() ? ArrayShapeNode::KIND_LIST : ArrayShapeNode::KIND_ARRAY, @@ -2219,6 +3326,10 @@ public static function isValidIdentifier(string $value): bool public function getFiniteTypes(): array { + if ($this->isUnsealed()->yes()) { + return []; + } + $limit = InitializerExprTypeResolver::CALCULATE_SCALARS_LIMIT; // Build finite array types incrementally, processing one key at a time. @@ -2279,6 +3390,15 @@ public function hasTemplateOrLateResolvableType(): bool return true; } + if ($this->unsealed !== null) { + if ($this->unsealed[0]->hasTemplateOrLateResolvableType()) { + return true; + } + if ($this->unsealed[1]->hasTemplateOrLateResolvableType()) { + return true; + } + } + return false; } diff --git a/src/Type/Constant/ConstantArrayTypeBuilder.php b/src/Type/Constant/ConstantArrayTypeBuilder.php index cd7f5aa0265..e81a4d694eb 100644 --- a/src/Type/Constant/ConstantArrayTypeBuilder.php +++ b/src/Type/Constant/ConstantArrayTypeBuilder.php @@ -2,6 +2,7 @@ namespace PHPStan\Type\Constant; +use PHPStan\DependencyInjection\BleedingEdgeToggle; use PHPStan\ShouldNotHappenException; use PHPStan\TrinaryLogic; use PHPStan\Type\Accessory\AccessoryArrayListType; @@ -11,6 +12,7 @@ use PHPStan\Type\CallableType; use PHPStan\Type\ClosureType; use PHPStan\Type\IntersectionType; +use PHPStan\Type\NeverType; use PHPStan\Type\Type; use PHPStan\Type\TypeCombinator; use PHPStan\Type\TypeUtils; @@ -48,6 +50,7 @@ final class ConstantArrayTypeBuilder * @param array $valueTypes * @param list $nextAutoIndexes * @param array $optionalKeys + * @param array{Type, Type}|null $unsealed */ private function __construct( private array $keyTypes, @@ -55,6 +58,7 @@ private function __construct( private array $nextAutoIndexes, private array $optionalKeys, private TrinaryLogic $isList, + private ?array $unsealed, ) { $this->isNonEmpty = TrinaryLogic::createNo(); @@ -62,7 +66,12 @@ private function __construct( public static function createEmpty(): self { - return new self([], [], [0], [], TrinaryLogic::createYes()); + $unsealed = null; + if (BleedingEdgeToggle::isBleedingEdge()) { + $never = new NeverType(true); + $unsealed = [$never, $never]; + } + return new self([], [], [0], [], TrinaryLogic::createYes(), $unsealed); } public static function createFromConstantArray(ConstantArrayType $startArrayType): self @@ -73,6 +82,7 @@ public static function createFromConstantArray(ConstantArrayType $startArrayType $startArrayType->getNextAutoIndexes(), $startArrayType->getOptionalKeys(), $startArrayType->isList(), + $startArrayType->getUnsealedTypes(), ); $builder->isNonEmpty = $startArrayType->isIterableAtLeastOnce(); @@ -83,6 +93,11 @@ public static function createFromConstantArray(ConstantArrayType $startArrayType return $builder; } + public function makeUnsealed(Type $keyType, Type $valueType): void + { + $this->unsealed = [$keyType, $valueType]; + } + public function setOffsetValueType(?Type $offsetType, Type $valueType, bool $optional = false): void { if ($offsetType !== null) { @@ -268,9 +283,8 @@ public function setOffsetValueType(?Type $offsetType, Type $valueType, bool $opt } } if (count($scalarTypes) > 0 && count($scalarTypes) < self::ARRAY_COUNT_LIMIT) { - $match = true; - $hasMatch = false; $valueTypes = $this->valueTypes; + $unmatchedScalars = []; foreach ($scalarTypes as $scalarType) { $offsetMatch = false; @@ -289,61 +303,97 @@ public function setOffsetValueType(?Type $offsetType, Type $valueType, bool $opt } if ($offsetMatch) { - $hasMatch = true; continue; } - $match = false; + $unmatchedScalars[] = $scalarType; } - if ($match) { - $this->valueTypes = $valueTypes; + $this->valueTypes = $valueTypes; + + if (count($unmatchedScalars) === 0) { return; } - if (!$hasMatch) { - foreach ($scalarTypes as $scalarType) { - $this->keyTypes[] = $scalarType; - $this->valueTypes[] = $valueType; - $this->optionalKeys[] = count($this->keyTypes) - 1; + foreach ($unmatchedScalars as $scalarType) { + $this->keyTypes[] = $scalarType; + $this->valueTypes[] = $valueType; + $this->optionalKeys[] = count($this->keyTypes) - 1; - if (!($scalarType instanceof ConstantIntegerType)) { - continue; - } + if (!($scalarType instanceof ConstantIntegerType)) { + continue; + } - if (count($this->nextAutoIndexes) === 0) { - continue; - } + if (count($this->nextAutoIndexes) === 0) { + continue; + } - $max = max($this->nextAutoIndexes); - $offsetValue = $scalarType->getValue(); - if ($offsetValue < $max) { - continue; - } + $max = max($this->nextAutoIndexes); + $offsetValue = $scalarType->getValue(); + if ($offsetValue < $max) { + continue; + } - /** @var int|float $newAutoIndex */ - $newAutoIndex = $offsetValue + 1; - if (is_float($newAutoIndex)) { - continue; - } - $this->nextAutoIndexes[] = $newAutoIndex; + /** @var int|float $newAutoIndex */ + $newAutoIndex = $offsetValue + 1; + if (is_float($newAutoIndex)) { + continue; } + $this->nextAutoIndexes[] = $newAutoIndex; + } - $this->isList = TrinaryLogic::createNo(); + $this->isList = TrinaryLogic::createNo(); + + if ( + !$this->disableArrayDegradation + && count($this->keyTypes) > self::ARRAY_COUNT_LIMIT + ) { + $this->degradeToGeneralArray = true; + $this->oversized = true; + } + + return; + } + + $this->isList = TrinaryLogic::createNo(); - if ( - !$this->disableArrayDegradation - && count($this->keyTypes) > self::ARRAY_COUNT_LIMIT - ) { - $this->degradeToGeneralArray = true; - $this->oversized = true; + // If the builder is already unsealed (e.g. fresh bleeding-edge + // builder, or a PHPDoc shape like `array{a: int, ...}`), + // fold the unknown offset/value into the existing unsealed + // extras instead of dropping per-key precision by degrading to a + // general array. The actual decision between unsealed + // ConstantArrayType and general ArrayType is then made in + // getArray() based on whether any constant keys ended up + // alongside these extras. + if ($this->unsealed !== null) { + // Existing keys whose value the new offset could overwrite + // must widen to a union of (existing, new) — the assignment + // might or might not have hit them. + $residualOffset = $offsetType; + foreach ($this->keyTypes as $i => $keyType) { + if ($offsetType->isSuperTypeOf($keyType)->no()) { + continue; } + $this->valueTypes[$i] = TypeCombinator::union($this->valueTypes[$i], $valueType); + $residualOffset = TypeCombinator::remove($residualOffset, $keyType); + } + if ($residualOffset instanceof NeverType) { return; } - } - $this->isList = TrinaryLogic::createNo(); + [$existingKey, $existingValue] = $this->unsealed; + $isExplicitNever = $existingKey instanceof NeverType && $existingKey->isExplicit(); + if ($isExplicitNever) { + $this->unsealed = [$residualOffset, $valueType]; + } else { + $this->unsealed = [ + TypeCombinator::union($existingKey, $residualOffset), + TypeCombinator::union($existingValue, $valueType), + ]; + } + return; + } } if ($offsetType === null) { @@ -386,13 +436,24 @@ public function getArray(): Type { $keyTypesCount = count($this->keyTypes); if ($keyTypesCount === 0) { - return new ConstantArrayType([], []); + if ($this->unsealed !== null) { + [$unsealedKey, $unsealedValue] = $this->unsealed; + $isExplicitNever = $unsealedKey instanceof NeverType && $unsealedKey->isExplicit(); + if (!$isExplicitNever) { + $arrayType = new ArrayType($unsealedKey, $unsealedValue); + if ($this->isNonEmpty->yes()) { + return TypeCombinator::intersect($arrayType, new NonEmptyArrayType()); + } + return $arrayType; + } + } + return new ConstantArrayType([], [], unsealed: $this->unsealed); } if (!$this->degradeToGeneralArray) { /** @var list $keyTypes */ $keyTypes = $this->keyTypes; - $array = new ConstantArrayType($keyTypes, $this->valueTypes, $this->nextAutoIndexes, $this->optionalKeys, $this->isList); + $array = new ConstantArrayType($keyTypes, $this->valueTypes, $this->nextAutoIndexes, $this->optionalKeys, $this->isList, $this->unsealed); if ($this->isNonEmpty->yes() && !$array->isIterableAtLeastOnce()->yes()) { return TypeCombinator::intersect($array, new NonEmptyArrayType()); } @@ -412,8 +473,21 @@ public function getArray(): Type $itemTypes = $this->valueTypes; } + $keyTypesForArray = $this->keyTypes; + // Real unsealed extras describe additional key/value pairs that + // belong in the degraded `ArrayType`'s key/value unions too — + // otherwise the degraded type silently drops them. + if ($this->unsealed !== null) { + [$unsealedKey, $unsealedValue] = $this->unsealed; + $isExplicitNever = $unsealedKey instanceof NeverType && $unsealedKey->isExplicit(); + if (!$isExplicitNever) { + $keyTypesForArray[] = $unsealedKey; + $itemTypes[] = $unsealedValue; + } + } + $array = new ArrayType( - TypeCombinator::union(...$this->keyTypes), + TypeCombinator::union(...$keyTypesForArray), TypeCombinator::union(...$itemTypes), ); diff --git a/src/Type/Constant/ConstantStringType.php b/src/Type/Constant/ConstantStringType.php index 23401fac77f..8e244477865 100644 --- a/src/Type/Constant/ConstantStringType.php +++ b/src/Type/Constant/ConstantStringType.php @@ -394,6 +394,11 @@ public function isNumericString(): TrinaryLogic return TrinaryLogic::createFromBoolean(is_numeric($this->getValue())); } + public function isDecimalIntegerString(): TrinaryLogic + { + return TrinaryLogic::createFromBoolean((string) (int) $this->value === $this->value); + } + public function isNonEmptyString(): TrinaryLogic { return TrinaryLogic::createFromBoolean($this->getValue() !== ''); diff --git a/src/Type/FileTypeMapper.php b/src/Type/FileTypeMapper.php index ddc6dc87af6..431954eb380 100644 --- a/src/Type/FileTypeMapper.php +++ b/src/Type/FileTypeMapper.php @@ -349,7 +349,7 @@ private function getNameScopeMap(string $fileName): array } $this->cache->save($cacheKey, $variableCacheKey, [$nameScopeMap, $filesWithHashes]); } else { - [$nameScopeMap, $files] = $cached; + [$nameScopeMap] = $cached; } if ($this->memoryCacheCount >= $this->nameScopeMapMemoryCacheCountMax) { $this->memoryCache = array_slice( @@ -360,7 +360,7 @@ private function getNameScopeMap(string $fileName): array $this->memoryCacheCount--; } - $this->memoryCache[$fileName] = [$nameScopeMap, $files]; + $this->memoryCache[$fileName] = [$nameScopeMap]; $this->memoryCacheCount++; } diff --git a/src/Type/FloatType.php b/src/Type/FloatType.php index b5439f8048c..2eccfbc7344 100644 --- a/src/Type/FloatType.php +++ b/src/Type/FloatType.php @@ -221,6 +221,11 @@ public function isNumericString(): TrinaryLogic return TrinaryLogic::createNo(); } + public function isDecimalIntegerString(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + public function isNonEmptyString(): TrinaryLogic { return TrinaryLogic::createNo(); diff --git a/src/Type/Generic/TemplateConstantArrayType.php b/src/Type/Generic/TemplateConstantArrayType.php index afb9ca61c03..dca27867fe4 100644 --- a/src/Type/Generic/TemplateConstantArrayType.php +++ b/src/Type/Generic/TemplateConstantArrayType.php @@ -39,9 +39,10 @@ public function __construct( protected function recreate( array $keyTypes, array $valueTypes, - array $nextAutoIndexes = [0], - array $optionalKeys = [], - ?TrinaryLogic $isList = null, + array $nextAutoIndexes, + array $optionalKeys, + ?TrinaryLogic $isList, + ?array $unsealed, ): ConstantArrayType { return new self( @@ -49,7 +50,7 @@ protected function recreate( $this->strategy, $this->variance, $this->name, - new ConstantArrayType($keyTypes, $valueTypes, $nextAutoIndexes, $optionalKeys, $isList), + new ConstantArrayType($keyTypes, $valueTypes, $nextAutoIndexes, $optionalKeys, $isList, $unsealed), $this->default, ); } diff --git a/src/Type/Generic/TemplateTypeTrait.php b/src/Type/Generic/TemplateTypeTrait.php index 694dd1d61af..3164fcc35d2 100644 --- a/src/Type/Generic/TemplateTypeTrait.php +++ b/src/Type/Generic/TemplateTypeTrait.php @@ -136,7 +136,7 @@ public function subtract(Type $typeToRemove): Type public function getTypeWithoutSubtractedType(): Type { $bound = $this->getBound(); - if (!$bound instanceof SubtractableType) { // @phpstan-ignore instanceof.alwaysTrue + if (!$bound instanceof SubtractableType) { return $this; } @@ -153,7 +153,7 @@ public function getTypeWithoutSubtractedType(): Type public function changeSubtractedType(?Type $subtractedType): Type { $bound = $this->getBound(); - if (!$bound instanceof SubtractableType) { // @phpstan-ignore instanceof.alwaysTrue + if (!$bound instanceof SubtractableType) { return $this; } @@ -170,7 +170,7 @@ public function changeSubtractedType(?Type $subtractedType): Type public function getSubtractedType(): ?Type { $bound = $this->getBound(); - if (!$bound instanceof SubtractableType) { // @phpstan-ignore instanceof.alwaysTrue + if (!$bound instanceof SubtractableType) { return null; } diff --git a/src/Type/IntersectionType.php b/src/Type/IntersectionType.php index e13f3e3bf4b..98d07e1ad0b 100644 --- a/src/Type/IntersectionType.php +++ b/src/Type/IntersectionType.php @@ -28,6 +28,7 @@ use PHPStan\ShouldNotHappenException; use PHPStan\TrinaryLogic; use PHPStan\Type\Accessory\AccessoryArrayListType; +use PHPStan\Type\Accessory\AccessoryDecimalIntegerStringType; use PHPStan\Type\Accessory\AccessoryLiteralStringType; use PHPStan\Type\Accessory\AccessoryLowercaseStringType; use PHPStan\Type\Accessory\AccessoryNonEmptyStringType; @@ -463,6 +464,7 @@ private function describeItself(VerbosityLevel $level, bool $skipAccessoryTypes) || $type instanceof AccessoryNonFalsyStringType || $type instanceof AccessoryLowercaseStringType || $type instanceof AccessoryUppercaseStringType + || $type instanceof AccessoryDecimalIntegerStringType ) { if ( ($type instanceof AccessoryLowercaseStringType || $type instanceof AccessoryUppercaseStringType) @@ -901,6 +903,11 @@ public function isNumericString(): TrinaryLogic return $this->intersectResults(static fn (Type $type): TrinaryLogic => $type->isNumericString()); } + public function isDecimalIntegerString(): TrinaryLogic + { + return $this->intersectResults(static fn (Type $type): TrinaryLogic => $type->isDecimalIntegerString()); + } + public function isNonEmptyString(): TrinaryLogic { if ($this->isCallable()->yes() && $this->isString()->yes()) { @@ -1546,6 +1553,10 @@ public function toArray(): Type public function toArrayKey(): Type { + if ($this->isDecimalIntegerString()->yes()) { + return new IntegerType(); + } + if ($this->isNumericString()->yes()) { return TypeCombinator::union( new IntegerType(), @@ -1754,6 +1765,7 @@ public function toPhpDocNode(): TypeNode || $type instanceof AccessoryNonFalsyStringType || $type instanceof AccessoryLowercaseStringType || $type instanceof AccessoryUppercaseStringType + || $type instanceof AccessoryDecimalIntegerStringType ) { if ($type instanceof AccessoryNonFalsyStringType) { $nonFalsyStr = true; diff --git a/src/Type/IterableType.php b/src/Type/IterableType.php index 80846cf0983..ea0cec018d9 100644 --- a/src/Type/IterableType.php +++ b/src/Type/IterableType.php @@ -372,6 +372,11 @@ public function isNumericString(): TrinaryLogic return TrinaryLogic::createNo(); } + public function isDecimalIntegerString(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + public function isNonEmptyString(): TrinaryLogic { return TrinaryLogic::createNo(); diff --git a/src/Type/JustNullableTypeTrait.php b/src/Type/JustNullableTypeTrait.php index 5435c540ff0..9697954fae3 100644 --- a/src/Type/JustNullableTypeTrait.php +++ b/src/Type/JustNullableTypeTrait.php @@ -124,6 +124,11 @@ public function isNumericString(): TrinaryLogic return TrinaryLogic::createNo(); } + public function isDecimalIntegerString(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + public function isNonEmptyString(): TrinaryLogic { return TrinaryLogic::createNo(); diff --git a/src/Type/MixedType.php b/src/Type/MixedType.php index 34c0bcbc40f..9b07022d5e4 100644 --- a/src/Type/MixedType.php +++ b/src/Type/MixedType.php @@ -21,6 +21,7 @@ use PHPStan\Reflection\Type\UnresolvedPropertyPrototypeReflection; use PHPStan\TrinaryLogic; use PHPStan\Type\Accessory\AccessoryArrayListType; +use PHPStan\Type\Accessory\AccessoryDecimalIntegerStringType; use PHPStan\Type\Accessory\AccessoryLiteralStringType; use PHPStan\Type\Accessory\AccessoryLowercaseStringType; use PHPStan\Type\Accessory\AccessoryNonEmptyStringType; @@ -1046,6 +1047,22 @@ public function isNumericString(): TrinaryLogic return TrinaryLogic::createMaybe(); } + public function isDecimalIntegerString(): TrinaryLogic + { + if ($this->subtractedType !== null) { + $decimalIntegerString = new IntersectionType([ + new StringType(), + new AccessoryDecimalIntegerStringType(), + ]); + + if ($this->subtractedType->isSuperTypeOf($decimalIntegerString)->yes()) { + return TrinaryLogic::createNo(); + } + } + + return TrinaryLogic::createMaybe(); + } + public function isNonEmptyString(): TrinaryLogic { if ($this->subtractedType !== null) { diff --git a/src/Type/NeverType.php b/src/Type/NeverType.php index 5639fa5168d..eb3ec2f8ba7 100644 --- a/src/Type/NeverType.php +++ b/src/Type/NeverType.php @@ -570,6 +570,11 @@ public function isNumericString(): TrinaryLogic return TrinaryLogic::createNo(); } + public function isDecimalIntegerString(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + public function isNonEmptyString(): TrinaryLogic { return TrinaryLogic::createNo(); diff --git a/src/Type/NullType.php b/src/Type/NullType.php index db804ebba79..2c09f1eff34 100644 --- a/src/Type/NullType.php +++ b/src/Type/NullType.php @@ -298,6 +298,11 @@ public function isNumericString(): TrinaryLogic return TrinaryLogic::createNo(); } + public function isDecimalIntegerString(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + public function isNonEmptyString(): TrinaryLogic { return TrinaryLogic::createNo(); diff --git a/src/Type/ObjectType.php b/src/Type/ObjectType.php index 7fc355d3ed0..c5603b98a1c 100644 --- a/src/Type/ObjectType.php +++ b/src/Type/ObjectType.php @@ -1341,6 +1341,11 @@ public function isNumericString(): TrinaryLogic return TrinaryLogic::createNo(); } + public function isDecimalIntegerString(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + public function isNonEmptyString(): TrinaryLogic { return TrinaryLogic::createNo(); diff --git a/src/Type/Php/ArrayColumnHelper.php b/src/Type/Php/ArrayColumnHelper.php index 824530751c7..aeb414875f0 100644 --- a/src/Type/Php/ArrayColumnHelper.php +++ b/src/Type/Php/ArrayColumnHelper.php @@ -118,6 +118,35 @@ public function handleConstantArray(ConstantArrayType $arrayType, Type $columnTy $builder->setOffsetValueType($keyType, $valueType, $arrayType->isOptionalKey($i)); } + if ($arrayType->isUnsealed()->yes()) { + $unsealedTypes = $arrayType->getUnsealedTypes(); + if ($unsealedTypes !== null) { + [$unsealedValueType, $unsealedCertainty] = $this->getOffsetOrProperty($unsealedTypes[1], $columnType, $scope); + if (!$unsealedCertainty->yes()) { + return null; + } + if (!$unsealedValueType instanceof NeverType) { + if (!$indexType->isNull()->yes()) { + [$unsealedKeyFromIndex, $unsealedKeyCertainty] = $this->getOffsetOrProperty($unsealedTypes[1], $indexType, $scope); + if ($unsealedKeyFromIndex instanceof NeverType) { + $unsealedKey = $unsealedTypes[0]; + } elseif ($unsealedKeyCertainty->yes()) { + $unsealedKey = $this->castToArrayKeyType($unsealedKeyFromIndex); + } else { + $unsealedKey = $this->castToArrayKeyType(TypeCombinator::union($unsealedKeyFromIndex, new IntegerType())); + } + } else { + // `null` indexType keeps integer-keyed list semantics — + // the unsealed range remains keyed by the source's + // unsealed keys (typically `int`). + $unsealedKey = $unsealedTypes[0]; + } + + $builder->makeUnsealed($unsealedKey, $unsealedValueType); + } + } + } + return $builder->getArray(); } diff --git a/src/Type/Php/ArrayCombineHelper.php b/src/Type/Php/ArrayCombineHelper.php index d0dc7f66d6b..b8bb3f445af 100644 --- a/src/Type/Php/ArrayCombineHelper.php +++ b/src/Type/Php/ArrayCombineHelper.php @@ -66,6 +66,22 @@ public function getReturnAndThrowType(Expr $firstArg, Expr $secondArg, Scope $sc $builder->setOffsetValueType($keyType, $valueType); } + // When both inputs carry unsealed extras (of matching, + // unbounded count) the extra positions pair up: the keys' + // unsealed value becomes a key, the values' unsealed value + // becomes its value. If only one side is unsealed, the + // sealed side caps the size, so no extras can survive. + $keysUnsealed = $constantKeysArray->getUnsealedTypes(); + $valuesUnsealed = $constantValueArrays->getUnsealedTypes(); + if ( + $constantKeysArray->isUnsealed()->yes() + && $constantValueArrays->isUnsealed()->yes() + && $keysUnsealed !== null + && $valuesUnsealed !== null + ) { + $builder->makeUnsealed($keysUnsealed[1]->toArrayKey(), $valuesUnsealed[1]); + } + $results[] = $builder->getArray(); } diff --git a/src/Type/Php/ArrayFilterFunctionReturnTypeHelper.php b/src/Type/Php/ArrayFilterFunctionReturnTypeHelper.php index 16e03fd68aa..83de6b7f622 100644 --- a/src/Type/Php/ArrayFilterFunctionReturnTypeHelper.php +++ b/src/Type/Php/ArrayFilterFunctionReturnTypeHelper.php @@ -181,6 +181,19 @@ private function filterByTruthyValue(Scope $scope, Error|Variable|null $itemVar, $builder->setOffsetValueType($newKeyType, $newItemType, true); } + if ($constantArray->isUnsealed()->yes()) { + $unsealedTypes = $constantArray->getUnsealedTypes(); + if ($unsealedTypes !== null) { + [$newKey, $newValue] = $this->processKeyAndItemType($scope, $unsealedTypes[0], $unsealedTypes[1], $itemVar, $keyVar, $expr); + // Drop the unsealed slot when the predicate + // rejects every possible extra (key or value + // narrows to `Never`). + if (!$newKey instanceof NeverType && !$newValue instanceof NeverType) { + $builder->makeUnsealed($newKey, $newValue); + } + } + } + $results[] = $builder->getArray(); } diff --git a/src/Type/Php/ArrayMergeFunctionDynamicReturnTypeExtension.php b/src/Type/Php/ArrayMergeFunctionDynamicReturnTypeExtension.php index 5ae8cbc48b9..b82ce7bb971 100644 --- a/src/Type/Php/ArrayMergeFunctionDynamicReturnTypeExtension.php +++ b/src/Type/Php/ArrayMergeFunctionDynamicReturnTypeExtension.php @@ -79,6 +79,8 @@ public function getTypeFromFunctionCall(FunctionReflection $functionReflection, if ($allConstant->yes()) { $newArrayBuilder = ConstantArrayTypeBuilder::createEmpty(); + $unsealedKeys = []; + $unsealedValues = []; foreach ($argTypes as $argIndex => $argType) { $isOptionalArg = in_array($argIndex, $optionalArgTypes, true); @@ -88,6 +90,17 @@ public function getTypeFromFunctionCall(FunctionReflection $functionReflection, foreach ($constantArray->getKeyTypes() as $keyType) { $keyTypes[$keyType->getValue()] = $keyType; } + if (!$constantArray->isUnsealed()->yes()) { + continue; + } + + $unsealedTypes = $constantArray->getUnsealedTypes(); + if ($unsealedTypes === null) { + continue; + } + + $unsealedKeys[] = $unsealedTypes[0]; + $unsealedValues[] = $unsealedTypes[1]; } foreach ($keyTypes as $keyType) { @@ -99,10 +112,37 @@ public function getTypeFromFunctionCall(FunctionReflection $functionReflection, } } + if (count($unsealedKeys) > 0) { + // Union all input unsealed slots — extras can come from + // any of the merged arrays at otherwise-unmentioned keys. + $newArrayBuilder->makeUnsealed( + TypeCombinator::union(...$unsealedKeys), + TypeCombinator::union(...$unsealedValues), + ); + } + return $newArrayBuilder->getArray(); } $offsetTypes = []; + $nonConstantArrayWasUnpacked = false; + $unsealedKeyTypes = []; + $unsealedValueTypes = []; + // Only switch to the unsealed-CAT result format when every CAT + // input has explicit sealedness (`isUnsealed` is `Yes` or `No`, + // i.e. bleeding-edge representation). Legacy CATs report + // `Maybe` and must keep the original `HasOffsetType`-style + // output to avoid changing the shape for non-bleeding-edge + // users. + $canRebuildAsUnsealedCat = true; + foreach ($argTypes as $argType) { + foreach ($argType->getConstantArrays() as $constantArray) { + if ($constantArray->isUnsealed()->maybe()) { + $canRebuildAsUnsealedCat = false; + break 2; + } + } + } foreach ($argTypes as $argIndex => $argType) { if (in_array($argIndex, $optionalArgTypes, true)) { continue; @@ -119,6 +159,27 @@ public function getTypeFromFunctionCall(FunctionReflection $functionReflection, ]; } } + } elseif ($canRebuildAsUnsealedCat) { + $nonConstantArrayWasUnpacked = true; + $iterableValue = $argType->getIterableValueType(); + $unsealedKeyTypes[] = $argType->getIterableKeyType(); + $unsealedValueTypes[] = $iterableValue; + foreach ($offsetTypes as $key => [$hasOffsetValue, $offsetValueType]) { + if (is_int($key)) { + // array_merge renumbers int keys instead of + // overwriting them, so a later non-constant + // input doesn't broaden a CAT's int-key value. + continue; + } + // Existing string offsets stay required (the sealed + // input contributed them) but their value broadens + // to include the unknown shape's iterable value — + // the unknown shape might overwrite the offset. + $offsetTypes[$key] = [ + $hasOffsetValue, + TypeCombinator::union($offsetValueType, $iterableValue), + ]; + } } else { foreach ($offsetTypes as $key => [$hasOffsetValue, $offsetValueType]) { // more precise values-types will be calculated elsewhere. @@ -171,6 +232,50 @@ public function getTypeFromFunctionCall(FunctionReflection $functionReflection, return new ConstantArrayType([], []); } + // Non-constant unpack contributes an unknown shape: rebuild as + // an unsealed CAT — explicit keys (from sealed inputs) on the + // CAT side, the unknown shape's iterable key/value as the + // unsealed slot. More idiomatic than the + // `ArrayType ∩ HasOffsetValueType ∩ ...` form for the same + // result. + if ($nonConstantArrayWasUnpacked) { + $builder = ConstantArrayTypeBuilder::createEmpty(); + $intKeyValuesFromCats = []; + foreach ($offsetTypes as $key => [$hasOffsetValue, $offsetType]) { + if (is_int($key)) { + // array_merge renumbers int keys. We can't track + // what they become, so push their values into the + // unsealed slot under an `int` key instead of + // dropping them. + if (!$hasOffsetValue->no()) { + $intKeyValuesFromCats[] = $offsetType; + } + continue; + } + if ($hasOffsetValue->no()) { + continue; + } + $builder->setOffsetValueType(new ConstantStringType($key), $offsetType, !$hasOffsetValue->yes()); + } + if ($intKeyValuesFromCats !== []) { + $unsealedKeyTypes[] = new IntegerType(); + $unsealedValueTypes[] = TypeCombinator::union(...$intKeyValuesFromCats); + } + $builder->makeUnsealed( + TypeCombinator::union(...$unsealedKeyTypes), + TypeCombinator::union(...$unsealedValueTypes), + ); + $arrayType = $builder->getArray(); + if ($nonEmpty) { + $arrayType = TypeCombinator::intersect($arrayType, new NonEmptyArrayType()); + } + if ($isList) { + $arrayType = TypeCombinator::intersect($arrayType, new AccessoryArrayListType()); + } + + return $arrayType; + } + $arrayType = new ArrayType( $keyType, TypeCombinator::union(...$valueTypes), diff --git a/src/Type/Php/ArrayReplaceFunctionReturnTypeExtension.php b/src/Type/Php/ArrayReplaceFunctionReturnTypeExtension.php index c53f0929d5d..834175222a2 100644 --- a/src/Type/Php/ArrayReplaceFunctionReturnTypeExtension.php +++ b/src/Type/Php/ArrayReplaceFunctionReturnTypeExtension.php @@ -79,6 +79,8 @@ public function getTypeFromFunctionCall(FunctionReflection $functionReflection, if ($allConstant->yes()) { $newArrayBuilder = ConstantArrayTypeBuilder::createEmpty(); + $unsealedKeys = []; + $unsealedValues = []; foreach ($argTypes as $argIndex => $argType) { $isOptionalArg = in_array($argIndex, $optionalArgTypes, true); @@ -89,6 +91,17 @@ public function getTypeFromFunctionCall(FunctionReflection $functionReflection, foreach ($constantArray->getKeyTypes() as $keyType) { $keyTypes[$keyType->getValue()] = $keyType; } + if (!$constantArray->isUnsealed()->yes()) { + continue; + } + + $unsealedTypes = $constantArray->getUnsealedTypes(); + if ($unsealedTypes === null) { + continue; + } + + $unsealedKeys[] = $unsealedTypes[0]; + $unsealedValues[] = $unsealedTypes[1]; } foreach ($keyTypes as $keyType) { @@ -100,10 +113,35 @@ public function getTypeFromFunctionCall(FunctionReflection $functionReflection, } } + if (count($unsealedKeys) > 0) { + // Union all input unsealed slots — extras can come from + // any of the input arrays at otherwise-unmentioned keys. + $newArrayBuilder->makeUnsealed( + TypeCombinator::union(...$unsealedKeys), + TypeCombinator::union(...$unsealedValues), + ); + } + return $newArrayBuilder->getArray(); } $offsetTypes = []; + $nonConstantArrayWasUnpacked = false; + $unsealedKeyTypes = []; + $unsealedValueTypes = []; + // Only switch to the unsealed-CAT result format when every CAT + // input has explicit sealedness — see the matching gate in + // `ArrayMergeFunctionDynamicReturnTypeExtension` for the + // rationale. + $canRebuildAsUnsealedCat = true; + foreach ($argTypes as $argType) { + foreach ($argType->getConstantArrays() as $constantArray) { + if ($constantArray->isUnsealed()->maybe()) { + $canRebuildAsUnsealedCat = false; + break 2; + } + } + } foreach ($argTypes as $argIndex => $argType) { if (in_array($argIndex, $optionalArgTypes, true)) { continue; @@ -120,6 +158,17 @@ public function getTypeFromFunctionCall(FunctionReflection $functionReflection, ]; } } + } elseif ($canRebuildAsUnsealedCat) { + $nonConstantArrayWasUnpacked = true; + $iterableValue = $argType->getIterableValueType(); + $unsealedKeyTypes[] = $argType->getIterableKeyType(); + $unsealedValueTypes[] = $iterableValue; + foreach ($offsetTypes as $key => [$hasOffsetValue, $offsetValueType]) { + $offsetTypes[$key] = [ + $hasOffsetValue, + TypeCombinator::union($offsetValueType, $iterableValue), + ]; + } } else { foreach ($offsetTypes as $key => [$hasOffsetValue, $offsetValueType]) { // more precise values-types will be calculated elsewhere. @@ -172,6 +221,30 @@ public function getTypeFromFunctionCall(FunctionReflection $functionReflection, return new ConstantArrayType([], []); } + if ($nonConstantArrayWasUnpacked) { + $builder = ConstantArrayTypeBuilder::createEmpty(); + foreach ($offsetTypes as $key => [$hasOffsetValue, $offsetType]) { + if ($hasOffsetValue->no()) { + continue; + } + $constKey = is_string($key) ? new ConstantStringType($key) : new ConstantIntegerType($key); + $builder->setOffsetValueType($constKey, $offsetType, !$hasOffsetValue->yes()); + } + $builder->makeUnsealed( + TypeCombinator::union(...$unsealedKeyTypes), + TypeCombinator::union(...$unsealedValueTypes), + ); + $arrayType = $builder->getArray(); + if ($nonEmpty) { + $arrayType = TypeCombinator::intersect($arrayType, new NonEmptyArrayType()); + } + if ($isList) { + $arrayType = TypeCombinator::intersect($arrayType, new AccessoryArrayListType()); + } + + return $arrayType; + } + $arrayType = new ArrayType( $keyType, TypeCombinator::union(...$valueTypes), diff --git a/src/Type/Php/ArraySumFunctionDynamicReturnTypeExtension.php b/src/Type/Php/ArraySumFunctionDynamicReturnTypeExtension.php index 2107666303c..3f5a1465a79 100644 --- a/src/Type/Php/ArraySumFunctionDynamicReturnTypeExtension.php +++ b/src/Type/Php/ArraySumFunctionDynamicReturnTypeExtension.php @@ -48,6 +48,18 @@ public function getTypeFromFunctionCall(FunctionReflection $functionReflection, } } + $unsealedTypes = $constantArray->getUnsealedTypes(); + if ($unsealedTypes !== null && $constantArray->isUnsealed()->yes()) { + // The unsealed slot holds zero-or-more further values. + // Add the zero-extras result (just the explicit sum) as + // its own variant so e.g. a float unsealed value doesn't + // erase the exact int sum, then extend with the + // one-or-more-extras case: explicit sum + value × count. + $resultTypes[] = $scope->getType($node); + $extrasNode = new Mul(new TypeExpr($unsealedTypes[1]), new TypeExpr(IntegerRangeType::fromInterval(1, null))); + $node = new Plus($node, $extrasNode); + } + $resultTypes[] = $scope->getType($node); } } else { diff --git a/src/Type/Php/CompactFunctionReturnTypeExtension.php b/src/Type/Php/CompactFunctionReturnTypeExtension.php index 0d7f40c9f75..0636d048919 100644 --- a/src/Type/Php/CompactFunctionReturnTypeExtension.php +++ b/src/Type/Php/CompactFunctionReturnTypeExtension.php @@ -75,6 +75,13 @@ private function findConstantStrings(Type $type): ?array } if ($type instanceof ConstantArrayType) { + // Unsealed extras are unknown further variable names that can't + // be enumerated — bail so the caller falls back to the general + // `compact()` signature. + if ($type->isUnsealed()->yes()) { + return null; + } + $result = []; foreach ($type->getValueTypes() as $valueType) { $constantStrings = $this->findConstantStrings($valueType); diff --git a/src/Type/Php/FilterVarArrayDynamicReturnTypeExtension.php b/src/Type/Php/FilterVarArrayDynamicReturnTypeExtension.php index a0442605d71..3be499415ca 100644 --- a/src/Type/Php/FilterVarArrayDynamicReturnTypeExtension.php +++ b/src/Type/Php/FilterVarArrayDynamicReturnTypeExtension.php @@ -173,6 +173,27 @@ public function getTypeFromFunctionCall(FunctionReflection $functionReflection, $valueTypesBuilder->setOffsetValueType($keyType, $valueType, $optional); } + // Carry the unsealed slot through from the input. The filter + // applies to every key, including those covered by the unsealed + // range — run the same filter resolution over the input's + // unsealed value type and attach the result. + if ($inputConstantArrayType !== null && $inputConstantArrayType->isUnsealed()->yes()) { + $unsealedTypes = $inputConstantArrayType->getUnsealedTypes(); + if ($unsealedTypes !== null) { + if ($filterArgType instanceof ConstantIntegerType) { + $unsealedFilter = $filterArgType; + $unsealedFlags = null; + } else { + [$unsealedFilter, $unsealedFlags] = $this->fetchFilter(new MixedType()); + } + $unsealedValueType = $this->filterFunctionReturnTypeHelper->getType($unsealedTypes[1], $unsealedFilter, $unsealedFlags); + if ($addEmpty) { + $unsealedValueType = TypeCombinator::addNull($unsealedValueType); + } + $valueTypesBuilder->makeUnsealed($unsealedTypes[0], $unsealedValueType); + } + } + return $valueTypesBuilder->getArray(); } diff --git a/src/Type/Php/ImplodeFunctionReturnTypeExtension.php b/src/Type/Php/ImplodeFunctionReturnTypeExtension.php index a23d8443e15..34f4080a9f5 100644 --- a/src/Type/Php/ImplodeFunctionReturnTypeExtension.php +++ b/src/Type/Php/ImplodeFunctionReturnTypeExtension.php @@ -113,6 +113,13 @@ private function implode(Type $arrayType, Type $separatorType): Type private function inferConstantType(ConstantArrayType $arrayType, ConstantStringType $separatorType, bool $isNonEmpty): ?Type { + // Unsealed extras can append further segments the constant fold + // can't see, so the exact string result would be unsound. Fall + // back to the accessory-based result. + if ($arrayType->isUnsealed()->yes()) { + return null; + } + $sep = $separatorType->getValue(); $valueTypes = $arrayType->getValueTypes(); $limit = InitializerExprTypeResolver::CALCULATE_SCALARS_LIMIT; diff --git a/src/Type/Php/MbConvertEncodingFunctionReturnTypeExtension.php b/src/Type/Php/MbConvertEncodingFunctionReturnTypeExtension.php index 310045be6e7..11c7e86fbe6 100644 --- a/src/Type/Php/MbConvertEncodingFunctionReturnTypeExtension.php +++ b/src/Type/Php/MbConvertEncodingFunctionReturnTypeExtension.php @@ -71,7 +71,11 @@ public function getTypeFromFunctionCall( $constantArrays = $fromEncodingArgType->getConstantArrays(); if (count($constantArrays) > 0) { foreach ($constantArrays as $constantArray) { - if (count($constantArray->getValueTypes()) > 1) { + // Unsealed extras can add further encoding candidates + // on top of the explicit ones, so the list may hold + // 2+ entries (auto-detect → may return false) even when + // only one explicit value is present. + if (count($constantArray->getValueTypes()) > 1 || $constantArray->isUnsealed()->yes()) { $returnFalseIfCannotDetectEncoding = true; break; } diff --git a/src/Type/Php/MinMaxFunctionReturnTypeExtension.php b/src/Type/Php/MinMaxFunctionReturnTypeExtension.php index b425174cd78..a142784318d 100644 --- a/src/Type/Php/MinMaxFunctionReturnTypeExtension.php +++ b/src/Type/Php/MinMaxFunctionReturnTypeExtension.php @@ -128,6 +128,14 @@ private function processArrayType(string $functionName, Type $argType): Type $argumentTypes[] = $innerType; } + $unsealedTypes = $constArrayType->getUnsealedTypes(); + if ($unsealedTypes !== null && $constArrayType->isUnsealed()->yes()) { + // Unsealed extras can hold further values, so the min/max + // must also range over the unsealed value type — otherwise + // the explicit entries would be reported as the answer. + $argumentTypes[] = $unsealedTypes[1]; + } + $resultTypes[] = $this->processType($functionName, $argumentTypes); } diff --git a/src/Type/StaticType.php b/src/Type/StaticType.php index 27bc205bdde..2af58ab2e87 100644 --- a/src/Type/StaticType.php +++ b/src/Type/StaticType.php @@ -690,6 +690,11 @@ public function isNumericString(): TrinaryLogic return $this->getStaticObjectType()->isNumericString(); } + public function isDecimalIntegerString(): TrinaryLogic + { + return $this->getStaticObjectType()->isDecimalIntegerString(); + } + public function isNonEmptyString(): TrinaryLogic { return $this->getStaticObjectType()->isNonEmptyString(); diff --git a/src/Type/StrictMixedType.php b/src/Type/StrictMixedType.php index 42ef8e71cc0..7477144c93d 100644 --- a/src/Type/StrictMixedType.php +++ b/src/Type/StrictMixedType.php @@ -288,6 +288,11 @@ public function isNumericString(): TrinaryLogic return TrinaryLogic::createNo(); } + public function isDecimalIntegerString(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + public function isNonEmptyString(): TrinaryLogic { return TrinaryLogic::createNo(); diff --git a/src/Type/StringType.php b/src/Type/StringType.php index 8c5fc17f4a8..d5fc72f6fbb 100644 --- a/src/Type/StringType.php +++ b/src/Type/StringType.php @@ -2,12 +2,14 @@ namespace PHPStan\Type; +use PHPStan\DependencyInjection\ReportUnsafeArrayStringKeyCastingToggle; use PHPStan\Php\PhpVersion; use PHPStan\PhpDocParser\Ast\Type\IdentifierTypeNode; use PHPStan\PhpDocParser\Ast\Type\TypeNode; use PHPStan\Reflection\ReflectionProviderStaticAccessor; use PHPStan\ShouldNotHappenException; use PHPStan\TrinaryLogic; +use PHPStan\Type\Accessory\AccessoryDecimalIntegerStringType; use PHPStan\Type\Accessory\AccessoryNonEmptyStringType; use PHPStan\Type\Constant\ConstantArrayType; use PHPStan\Type\Constant\ConstantBooleanType; @@ -182,7 +184,22 @@ public function toArray(): Type public function toArrayKey(): Type { - return $this; + $level = ReportUnsafeArrayStringKeyCastingToggle::getLevel(); + if ($level !== ReportUnsafeArrayStringKeyCastingToggle::PREVENT) { + return $this; + } + + $isDecimalIntString = $this->isDecimalIntegerString(); + if ($isDecimalIntString->no()) { + return $this; + } elseif ($isDecimalIntString->yes()) { + return new IntegerType(); + } + + return new UnionType([ + new IntegerType(), + TypeCombinator::intersect($this, new AccessoryDecimalIntegerStringType(inverse: true)), + ]); } public function toCoercedArgumentType(bool $strictTypes): Type @@ -237,6 +254,11 @@ public function isNumericString(): TrinaryLogic return TrinaryLogic::createMaybe(); } + public function isDecimalIntegerString(): TrinaryLogic + { + return TrinaryLogic::createMaybe(); + } + public function isNonEmptyString(): TrinaryLogic { return TrinaryLogic::createMaybe(); diff --git a/src/Type/Traits/ArrayTypeTrait.php b/src/Type/Traits/ArrayTypeTrait.php index 9ac5e1d97db..db527c5b697 100644 --- a/src/Type/Traits/ArrayTypeTrait.php +++ b/src/Type/Traits/ArrayTypeTrait.php @@ -145,6 +145,11 @@ public function isNumericString(): TrinaryLogic return TrinaryLogic::createNo(); } + public function isDecimalIntegerString(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + public function isNonEmptyString(): TrinaryLogic { return TrinaryLogic::createNo(); diff --git a/src/Type/Traits/LateResolvableTypeTrait.php b/src/Type/Traits/LateResolvableTypeTrait.php index 1bb2df4e81a..f7d79c79427 100644 --- a/src/Type/Traits/LateResolvableTypeTrait.php +++ b/src/Type/Traits/LateResolvableTypeTrait.php @@ -556,6 +556,11 @@ public function isNumericString(): TrinaryLogic return $this->resolve()->isNumericString(); } + public function isDecimalIntegerString(): TrinaryLogic + { + return $this->resolve()->isDecimalIntegerString(); + } + public function isNonEmptyString(): TrinaryLogic { return $this->resolve()->isNonEmptyString(); diff --git a/src/Type/Traits/MaybeStringTypeTrait.php b/src/Type/Traits/MaybeStringTypeTrait.php index caa9abacb42..fcd6628bd41 100644 --- a/src/Type/Traits/MaybeStringTypeTrait.php +++ b/src/Type/Traits/MaybeStringTypeTrait.php @@ -22,6 +22,11 @@ public function isNumericString(): TrinaryLogic return TrinaryLogic::createMaybe(); } + public function isDecimalIntegerString(): TrinaryLogic + { + return TrinaryLogic::createMaybe(); + } + public function isNonEmptyString(): TrinaryLogic { return TrinaryLogic::createMaybe(); diff --git a/src/Type/Traits/ObjectTypeTrait.php b/src/Type/Traits/ObjectTypeTrait.php index af087676454..c949351a6d9 100644 --- a/src/Type/Traits/ObjectTypeTrait.php +++ b/src/Type/Traits/ObjectTypeTrait.php @@ -274,6 +274,11 @@ public function isNumericString(): TrinaryLogic return TrinaryLogic::createNo(); } + public function isDecimalIntegerString(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + public function isNonEmptyString(): TrinaryLogic { return TrinaryLogic::createNo(); diff --git a/src/Type/Traverser/UnsafeArrayStringKeyCastingTraverser.php b/src/Type/Traverser/UnsafeArrayStringKeyCastingTraverser.php new file mode 100644 index 00000000000..2e5b5b1edb5 --- /dev/null +++ b/src/Type/Traverser/UnsafeArrayStringKeyCastingTraverser.php @@ -0,0 +1,53 @@ +isString()->yes() && !$type->isDecimalIntegerString()->no()) { + return TypeCombinator::union( + new IntegerType(), + TypeCombinator::intersect($type, new AccessoryDecimalIntegerStringType(inverse: true)), + ); + } + + return $type; + } + +} diff --git a/src/Type/Type.php b/src/Type/Type.php index da72fb4c077..b8c96ddcc4a 100644 --- a/src/Type/Type.php +++ b/src/Type/Type.php @@ -509,6 +509,17 @@ public function isString(): TrinaryLogic; public function isNumericString(): TrinaryLogic; + /** + * When isDecimalIntegerString() returns yes(), the type + * is guaranteed to be cast to an integer in an array key. + * Examples of constant values covered by this type: "0", "1", "1234", "-1" + * + * When isDecimalIntegerString() returns no(), the type represents strings containing non-decimal integers and other text. + * These are guaranteed to stay as string in an array key. + * Examples of constant values covered by this type: "+1", "00", "18E+3", "1.2", "1,3", "foo" + */ + public function isDecimalIntegerString(): TrinaryLogic; + public function isNonEmptyString(): TrinaryLogic; /** diff --git a/src/Type/TypeCombinator.php b/src/Type/TypeCombinator.php index df18959ae24..b3a0e1b7579 100644 --- a/src/Type/TypeCombinator.php +++ b/src/Type/TypeCombinator.php @@ -4,6 +4,7 @@ use PHPStan\TrinaryLogic; use PHPStan\Type\Accessory\AccessoryArrayListType; +use PHPStan\Type\Accessory\AccessoryDecimalIntegerStringType; use PHPStan\Type\Accessory\AccessoryLowercaseStringType; use PHPStan\Type\Accessory\AccessoryNonEmptyStringType; use PHPStan\Type\Accessory\AccessoryType; @@ -30,6 +31,7 @@ use function array_filter; use function array_key_exists; use function array_key_first; +use function array_keys; use function array_merge; use function array_slice; use function array_splice; @@ -634,6 +636,24 @@ private static function compareTypesInUnion(Type $a, Type $b): ?array } } + // numeric-string | non-decimal-int-string → string (preserving common accessories) + // Works because decimal-int-string ⊂ numeric-string, so together they cover all strings + if ($a->isString()->yes() && $b->isString()->yes()) { + $decimalIntString = new IntersectionType([new StringType(), new AccessoryDecimalIntegerStringType()]); + if ($b->isDecimalIntegerString()->no()) { + $bBase = self::removeDecimalIntStringAccessory($b); + if ($bBase->isSuperTypeOf($a)->yes() && $a->isSuperTypeOf($decimalIntString)->yes()) { + return [null, $bBase]; + } + } + if ($a->isDecimalIntegerString()->no()) { + $aBase = self::removeDecimalIntStringAccessory($a); + if ($aBase->isSuperTypeOf($b)->yes() && $b->isSuperTypeOf($decimalIntString)->yes()) { + return [$aBase, null]; + } + } + } + return null; } @@ -653,6 +673,18 @@ private static function getAccessoryCaseStringTypes(Type $type): array return $accessory; } + private static function removeDecimalIntStringAccessory(Type $type): Type + { + if (!$type instanceof IntersectionType) { + return $type; + } + + return self::intersect(...array_filter( + $type->getTypes(), + static fn (Type $t): bool => !$t instanceof AccessoryDecimalIntegerStringType, + )); + } + private static function unionWithSubtractedType( Type $type, ?Type $subtractedType, @@ -888,7 +920,7 @@ private static function processArrayTypes(array $arrayTypes): array $filledArrays++; } - if ($generalArrayOccurred || !$isConstantArray) { + if (!$isConstantArray) { foreach ($arrayType->getArrays() as $type) { $keyTypesForGeneralArray[] = $type->getIterableKeyType(); $valueTypesForGeneralArray[] = $type->getItemType(); @@ -1201,7 +1233,14 @@ private static function reduceArrays(array $constantArrays, bool $preserveTagged } if ($emptyArray !== null) { - $newArrays[] = $emptyArray; + if ($preserveTaggedUnions && $emptyArray instanceof ConstantArrayType) { + // Let the empty array participate in merging — the passes below will absorb + // it into any array that already accepts [] (all-optional keys, compatible + // unsealed extras). If no such array exists, it remains as-is in the result. + $arraysToProcess[] = $emptyArray; + } else { + $newArrays[] = $emptyArray; + } } $arraysToProcessPerKey = []; @@ -1286,6 +1325,100 @@ private static function reduceArrays(array $constantArrays, bool $preserveTagged } } + // Second pass: merge pairs that the eligibleCombinations loop above couldn't touch. + // That loop only considers pairs sharing at least one known key, so it never fires + // for e.g. `array{}` ∪ `array{a?: 1}` (disjoint, one empty) or for two + // unsealed-extras arrays with disjoint required keys. Both collapse losslessly if + // one side's extras or optional-key shape can absorb the other side's content. + // + // Performance: two sealed, non-empty, no-extras arrays with disjoint keys cannot + // merge losslessly (legacyIsKeysSupersetOf returns false immediately on the first + // missing key). Skip those pairs via a candidate flag to avoid an O(n²) scan that + // dominated analyse time on files accumulating many sealed ConstantArrayType + // variants (bug-7581 / bug-8146a). A pair is worth checking only if at least one + // side is (a) empty, or (b) has real unsealed extras, or (c) has optional keys — + // the last case covers the narrowing shape used by e.g. array_key_exists checks + // over large optional-key shapes (bug-14032). + $indices = array_keys($arraysToProcess); + $indicesCount = count($indices); + if ($indicesCount > 1) { + $candidateFlags = []; + foreach ($indices as $idx) { + $arr = $arraysToProcess[$idx]; + $unsealed = $arr->getUnsealedTypes(); + if ($unsealed === null) { + $candidateFlags[$idx] = false; + continue; + } + [$unsealedKey] = $unsealed; + $hasRealExtras = !($unsealedKey instanceof NeverType && $unsealedKey->isExplicit()); + if ($hasRealExtras) { + $candidateFlags[$idx] = true; + continue; + } + $keyTypesCount = count($arr->getKeyTypes()); + if ($keyTypesCount === 0) { + $candidateFlags[$idx] = true; + continue; + } + $hasOptional = count($arr->getOptionalKeys()) > 0; + $candidateFlags[$idx] = $hasOptional; + } + + for ($ii = 0; $ii < $indicesCount - 1; $ii++) { + $i = $indices[$ii]; + if (!array_key_exists($i, $arraysToProcess)) { + continue; + } + if ($arraysToProcess[$i]->getUnsealedTypes() === null) { + continue; + } + for ($jj = $ii + 1; $jj < $indicesCount; $jj++) { + $j = $indices[$jj]; + if (!array_key_exists($j, $arraysToProcess)) { + continue; + } + if (!$candidateFlags[$i] && !$candidateFlags[$j]) { + continue; + } + if ($arraysToProcess[$j]->getUnsealedTypes() === null) { + continue; + } + if ($arraysToProcess[$j]->isKeysSupersetOf($arraysToProcess[$i])) { + $arraysToProcess[$j] = $arraysToProcess[$j]->mergeWith($arraysToProcess[$i]); + unset($arraysToProcess[$i]); + continue 2; + } + if (!$arraysToProcess[$i]->isKeysSupersetOf($arraysToProcess[$j])) { + continue; + } + + $arraysToProcess[$i] = $arraysToProcess[$i]->mergeWith($arraysToProcess[$j]); + unset($arraysToProcess[$j]); + } + } + } + + // Final pass: if merging left us with a ConstantArrayType that has no known keys + // but has real unsealed extras, collapse it to a plain ArrayType (mirrors the same + // logic in ConstantArrayTypeBuilder::getArray — but applies to results produced by + // ConstantArrayType::mergeWith, which doesn't go through the builder). + foreach ($arraysToProcess as $idx => $arr) { + if (count($arr->getKeyTypes()) !== 0) { + continue; + } + $unsealed = $arr->getUnsealedTypes(); + if ($unsealed === null) { + continue; + } + [$unsealedKey, $unsealedValue] = $unsealed; + if ($unsealedKey instanceof NeverType && $unsealedKey->isExplicit()) { + continue; + } + $newArrays[] = new ArrayType($unsealedKey, $unsealedValue); + unset($arraysToProcess[$idx]); + } + // Final pass: collapse the loop-accumulator pattern where each iteration // produced a longer non-empty list variant. When several non-empty list // ConstantArrayTypes survive earlier merging and together push the @@ -1563,6 +1696,7 @@ public static function intersect(Type ...$types): Type && $types[$j] instanceof NonEmptyArrayType && (count($types[$i]->getKeyTypes()) === 1 || $types[$i]->isList()->yes()) && $types[$i]->isOptionalKey(0) + && !$types[$i]->isUnsealed()->yes() ) { $types[$i] = $types[$i]->makeOffsetRequired($types[$i]->getKeyTypes()[0]); array_splice($types, $j--, 1); @@ -1575,6 +1709,7 @@ public static function intersect(Type ...$types): Type && $types[$i] instanceof NonEmptyArrayType && (count($types[$j]->getKeyTypes()) === 1 || $types[$j]->isList()->yes()) && $types[$j]->isOptionalKey(0) + && !$types[$j]->isUnsealed()->yes() ) { $types[$j] = $types[$j]->makeOffsetRequired($types[$j]->getKeyTypes()[0]); array_splice($types, $i--, 1); @@ -1635,42 +1770,59 @@ public static function intersect(Type ...$types): Type continue 2; } - if ($types[$i] instanceof ConstantArrayType && ($types[$j] instanceof ArrayType || $types[$j] instanceof ConstantArrayType)) { - $newArray = ConstantArrayTypeBuilder::createEmpty(); - $valueTypes = $types[$i]->getValueTypes(); - foreach ($types[$i]->getKeyTypes() as $k => $keyType) { - $hasOffset = $types[$j]->hasOffsetValueType($keyType); - if ($hasOffset->no()) { - continue; + $constArrayIsI = $types[$i] instanceof ConstantArrayType && ($types[$j] instanceof ArrayType || $types[$j] instanceof ConstantArrayType); + $constArrayIsJ = $types[$j] instanceof ConstantArrayType && ($types[$i] instanceof ArrayType || $types[$i] instanceof ConstantArrayType); + if ($constArrayIsI || $constArrayIsJ) { + $constArray = $constArrayIsI ? $types[$i] : $types[$j]; + $otherArray = $constArrayIsI ? $types[$j] : $types[$i]; + + if ( + $otherArray instanceof ConstantArrayType + && !$constArray->isUnsealed()->maybe() + && !$otherArray->isUnsealed()->maybe() + ) { + $merged = self::intersectDefiniteConstantArrays($constArray, $otherArray); + if ($merged instanceof NeverType) { + return $merged; + } + $newArrayType = $merged; + } else { + $newArray = ConstantArrayTypeBuilder::createEmpty(); + // Preserve unsealed extras from the source shape so the + // rebuild doesn't silently turn `array{k: int, ...} & X` + // into a sealed `array{k: int}` — intersect with the other + // side's iterable key/value so the open part keeps both + // sides' refinements. + $constUnsealed = $constArray->getUnsealedTypes(); + if ($constUnsealed !== null && $constArray->isUnsealed()->yes()) { + $newUnsealedKey = self::intersect($constUnsealed[0], $otherArray->getIterableKeyType()); + $newUnsealedValue = self::intersect($constUnsealed[1], $otherArray->getIterableValueType()); + if (!$newUnsealedKey instanceof NeverType && !$newUnsealedValue instanceof NeverType) { + $newArray->makeUnsealed($newUnsealedKey, $newUnsealedValue); + } } - $newArray->setOffsetValueType( - self::intersect($keyType, $types[$j]->getIterableKeyType()), - self::intersect($valueTypes[$k], $types[$j]->getOffsetValueType($keyType)), - $types[$i]->isOptionalKey($k) && !$hasOffset->yes(), - ); + $valueTypes = $constArray->getValueTypes(); + foreach ($constArray->getKeyTypes() as $k => $keyType) { + $hasOffset = $otherArray->hasOffsetValueType($keyType); + if ($hasOffset->no()) { + continue; + } + $newArray->setOffsetValueType( + self::intersect($keyType, $otherArray->getIterableKeyType()), + self::intersect($valueTypes[$k], $otherArray->getOffsetValueType($keyType)), + $constArray->isOptionalKey($k) && !$hasOffset->yes(), + ); + } + $newArrayType = $newArray->getArray(); } - $types[$i] = $newArray->getArray(); - array_splice($types, $j--, 1); - $typesCount--; - continue 2; - } - if ($types[$j] instanceof ConstantArrayType && ($types[$i] instanceof ArrayType || $types[$i] instanceof ConstantArrayType)) { - $newArray = ConstantArrayTypeBuilder::createEmpty(); - $valueTypes = $types[$j]->getValueTypes(); - foreach ($types[$j]->getKeyTypes() as $k => $keyType) { - $hasOffset = $types[$i]->hasOffsetValueType($keyType); - if ($hasOffset->no()) { - continue; - } - $newArray->setOffsetValueType( - self::intersect($keyType, $types[$i]->getIterableKeyType()), - self::intersect($valueTypes[$k], $types[$i]->getOffsetValueType($keyType)), - $types[$j]->isOptionalKey($k) && !$hasOffset->yes(), - ); + if ($constArrayIsI) { + $types[$i] = $newArrayType; + array_splice($types, $j--, 1); + } else { + $types[$j] = $newArrayType; + array_splice($types, $i--, 1); } - $types[$j] = $newArray->getArray(); - array_splice($types, $i--, 1); $typesCount--; continue 2; } @@ -1748,6 +1900,124 @@ public static function intersect(Type ...$types): Type return new IntersectionType($types); } + private static function intersectDefiniteConstantArrays(ConstantArrayType $a, ConstantArrayType $b): Type + { + $aSealed = $a->isUnsealed()->no(); + $bSealed = $b->isUnsealed()->no(); + $bothUnsealed = !$aSealed && !$bSealed && $a->getUnsealedTypes() !== null && $b->getUnsealedTypes() !== null; + + $aKeyByValue = []; + foreach ($a->getKeyTypes() as $k => $keyType) { + $aKeyByValue[$keyType->getValue()] = $k; + } + $bKeyByValue = []; + foreach ($b->getKeyTypes() as $k => $keyType) { + $bKeyByValue[$keyType->getValue()] = $k; + } + + if ($aSealed && $bSealed) { + foreach ($aKeyByValue as $keyValue => $k) { + if (!$a->isOptionalKey($k) && !array_key_exists($keyValue, $bKeyByValue)) { + return new NeverType(); + } + } + foreach ($bKeyByValue as $keyValue => $k) { + if (!$b->isOptionalKey($k) && !array_key_exists($keyValue, $aKeyByValue)) { + return new NeverType(); + } + } + } + + $newArray = ConstantArrayTypeBuilder::createEmpty(); + + if ($bothUnsealed) { + $aUnsealed = $a->getUnsealedTypes(); + $bUnsealed = $b->getUnsealedTypes(); + $unsealedKey = self::intersect($aUnsealed[0], $bUnsealed[0]); + $unsealedValue = self::intersect($aUnsealed[1], $bUnsealed[1]); + if ($unsealedKey instanceof NeverType || $unsealedValue instanceof NeverType) { + return new NeverType(); + } + $newArray->makeUnsealed($unsealedKey, $unsealedValue); + } else { + $never = new NeverType(true); + $newArray->makeUnsealed($never, $never); + } + + $resolveOtherValue = static function (ConstantArrayType $other, Type $keyType): ?Type { + if ($other->hasOffsetValueType($keyType)->yes()) { + return $other->getOffsetValueType($keyType); + } + $otherUnsealed = $other->getUnsealedTypes(); + if ($otherUnsealed === null) { + return null; + } + [$unsealedKey, $unsealedValue] = $otherUnsealed; + if ($unsealedKey instanceof NeverType && $unsealedKey->isExplicit()) { + return null; + } + if ($unsealedKey->isSuperTypeOf($keyType)->no()) { + return null; + } + return $unsealedValue; + }; + + $keysToProcess = []; + foreach ($aKeyByValue as $keyValue => $k) { + $keysToProcess[$keyValue] = [$k, $bKeyByValue[$keyValue] ?? null]; + } + foreach ($bKeyByValue as $keyValue => $k) { + if (array_key_exists($keyValue, $keysToProcess)) { + continue; + } + + $keysToProcess[$keyValue] = [null, $k]; + } + + foreach ($keysToProcess as [$aIdx, $bIdx]) { + if ($aIdx !== null && $bIdx !== null) { + $keyType = $a->getKeyTypes()[$aIdx]; + $value = self::intersect($a->getValueTypes()[$aIdx], $b->getValueTypes()[$bIdx]); + $optional = $a->isOptionalKey($aIdx) && $b->isOptionalKey($bIdx); + } elseif ($aIdx !== null) { + $keyType = $a->getKeyTypes()[$aIdx]; + $aValue = $a->getValueTypes()[$aIdx]; + $bValue = $resolveOtherValue($b, $keyType); + if ($bValue === null) { + if ($a->isOptionalKey($aIdx)) { + continue; + } + return new NeverType(); + } + $value = self::intersect($aValue, $bValue); + $optional = $a->isOptionalKey($aIdx); + } else { + /** @var int<0, max> $bIdx */ + $keyType = $b->getKeyTypes()[$bIdx]; + $bValue = $b->getValueTypes()[$bIdx]; + $aValue = $resolveOtherValue($a, $keyType); + if ($aValue === null) { + if ($b->isOptionalKey($bIdx)) { + continue; + } + return new NeverType(); + } + $value = self::intersect($aValue, $bValue); + $optional = $b->isOptionalKey($bIdx); + } + + if ($value instanceof NeverType) { + if ($optional) { + continue; + } + return new NeverType(); + } + $newArray->setOffsetValueType($keyType, $value, $optional); + } + + return $newArray->getArray(); + } + /** * Merge two IntersectionTypes that have the same structure but differ * in HasOffsetValueType value types (matched by offset key). diff --git a/src/Type/UnionType.php b/src/Type/UnionType.php index 7c1bdaecbcd..422aea2d89a 100644 --- a/src/Type/UnionType.php +++ b/src/Type/UnionType.php @@ -7,6 +7,7 @@ use DateTimeInterface; use Error; use Exception; +use PHPStan\DependencyInjection\ReportUnsafeArrayStringKeyCastingToggle; use PHPStan\Php\PhpVersion; use PHPStan\PhpDocParser\Ast\Type\TypeNode; use PHPStan\PhpDocParser\Ast\Type\UnionTypeNode; @@ -731,6 +732,11 @@ public function isNumericString(): TrinaryLogic return $this->notBenevolentUnionResults(static fn (Type $type): TrinaryLogic => $type->isNumericString()); } + public function isDecimalIntegerString(): TrinaryLogic + { + return $this->notBenevolentUnionResults(static fn (Type $type): TrinaryLogic => $type->isDecimalIntegerString()); + } + public function isNonEmptyString(): TrinaryLogic { return $this->notBenevolentUnionResults(static fn (Type $type): TrinaryLogic => $type->isNonEmptyString()); @@ -1181,7 +1187,18 @@ public function toArray(): Type public function toArrayKey(): Type { - return $this->unionTypes(static fn (Type $type): Type => $type->toArrayKey()); + $level = ReportUnsafeArrayStringKeyCastingToggle::getLevel(); + if ($level !== ReportUnsafeArrayStringKeyCastingToggle::PREVENT || $this->isInteger()->no()) { + return $this->unionTypes(static fn (Type $type): Type => $type->toArrayKey()); + } + + return $this->unionTypes(static function (Type $type): Type { + if ($type instanceof StringType) { // @phpstan-ignore phpstanApi.instanceofType + return $type; + } + + return $type->toArrayKey(); + }); } public function toCoercedArgumentType(bool $strictTypes): Type diff --git a/src/Type/VerbosityLevel.php b/src/Type/VerbosityLevel.php index 32be9683a81..bf488ee83ad 100644 --- a/src/Type/VerbosityLevel.php +++ b/src/Type/VerbosityLevel.php @@ -3,6 +3,7 @@ namespace PHPStan\Type; use PHPStan\Type\Accessory\AccessoryArrayListType; +use PHPStan\Type\Accessory\AccessoryDecimalIntegerStringType; use PHPStan\Type\Accessory\AccessoryLiteralStringType; use PHPStan\Type\Accessory\AccessoryLowercaseStringType; use PHPStan\Type\Accessory\AccessoryNonEmptyStringType; @@ -140,10 +141,14 @@ public static function getRecommendedLevelByType(Type $acceptingType, ?Type $acc // Keep checking if we need to be very verbose. return $traverse($type); } - if ($type->isConstantValue()->yes() && $type->isNull()->no()) { + if ($type->isConstantArray()->yes()) { $moreVerbose = true; // For ConstantArrayType we need to keep checking if we need to be very verbose. + return $traverse($type); + } + if ($type->isConstantValue()->yes() && $type->isNull()->no()) { + $moreVerbose = true; if (!$type->isArray()->no()) { return $traverse($type); } @@ -156,6 +161,7 @@ public static function getRecommendedLevelByType(Type $acceptingType, ?Type $acc || $type instanceof AccessoryNonFalsyStringType || $type instanceof AccessoryLiteralStringType || $type instanceof AccessoryNumericStringType + || $type instanceof AccessoryDecimalIntegerStringType || $type instanceof NonEmptyArrayType || $type instanceof AccessoryArrayListType ) { diff --git a/src/Type/VoidType.php b/src/Type/VoidType.php index e8532c299cc..a3e5c991afc 100644 --- a/src/Type/VoidType.php +++ b/src/Type/VoidType.php @@ -194,6 +194,11 @@ public function isNumericString(): TrinaryLogic return TrinaryLogic::createNo(); } + public function isDecimalIntegerString(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + public function isNonEmptyString(): TrinaryLogic { return TrinaryLogic::createNo(); diff --git a/tests/PHPStan/Analyser/AnalyserIntegrationTest.php b/tests/PHPStan/Analyser/AnalyserIntegrationTest.php index 08b8f097f3a..42d04482597 100644 --- a/tests/PHPStan/Analyser/AnalyserIntegrationTest.php +++ b/tests/PHPStan/Analyser/AnalyserIntegrationTest.php @@ -847,7 +847,7 @@ public function testBug7094(): void $this->assertSame('Return type of call to method Bug7094\Foo::getAttribute() contains unresolvable type.', $errors[4]->getMessage()); $this->assertSame(79, $errors[4]->getLine()); - $this->assertSame('Parameter #1 $attr of method Bug7094\Foo::setAttributes() expects array{foo?: string, bar?: 5|6|7, baz?: bool}, non-empty-array<\'bar\'|\'baz\'|\'foo\'|K of string, 5|6|7|bool|string> given.', $errors[5]->getMessage()); + $this->assertSame('Parameter #1 $attr of method Bug7094\Foo::setAttributes() expects array{foo?: string, bar?: 5|6|7, baz?: bool}, non-empty-array{foo?: 5|6|7|bool|string, bar?: 5|6|7|bool|string, baz?: 5|6|7|bool|string, ...} given.', $errors[5]->getMessage()); $this->assertSame(29, $errors[5]->getLine()); } diff --git a/tests/PHPStan/Analyser/Ignore/IgnoreLexerTest.php b/tests/PHPStan/Analyser/Ignore/IgnoreLexerTest.php index 6e46630df6d..d2c34bad04f 100644 --- a/tests/PHPStan/Analyser/Ignore/IgnoreLexerTest.php +++ b/tests/PHPStan/Analyser/Ignore/IgnoreLexerTest.php @@ -81,7 +81,7 @@ public static function dataTokenize(): iterable } /** - * @param list $expectedTokens + * @param list $expectedTokens */ #[DataProvider('dataTokenize')] public function testTokenize(string $input, array $expectedTokens): void diff --git a/tests/PHPStan/Analyser/ReportUnsafeArrayStringKeyCastingDetectTypeAcceptanceTest.php b/tests/PHPStan/Analyser/ReportUnsafeArrayStringKeyCastingDetectTypeAcceptanceTest.php new file mode 100644 index 00000000000..21f6bf9f337 --- /dev/null +++ b/tests/PHPStan/Analyser/ReportUnsafeArrayStringKeyCastingDetectTypeAcceptanceTest.php @@ -0,0 +1,55 @@ + + */ +class ReportUnsafeArrayStringKeyCastingDetectTypeAcceptanceTest extends RuleTestCase +{ + + public function getRule(): Rule + { + return self::getContainer()->getByType(CallMethodsRule::class); + } + + public function testRule(): void + { + $this->analyse([__DIR__ . '/data/report-unsafe-array-string-key-casting-accepts.php'], [ + [ + 'Parameter #1 $a of method ReportUnsafeArrayStringKeyCastingAccepts\Foo::doBaz() expects array, non-empty-array given.', + 33, + ], + [ + 'Parameter #1 $a of method ReportUnsafeArrayStringKeyCastingAccepts\Foo::doBaz() expects array, non-empty-array given.', + 39, + ], + [ + 'Parameter #1 $a of method ReportUnsafeArrayStringKeyCastingAccepts\UnsealedArrayShape::doBaz() expects array{stdClass, ...}, array{stdClass, ...} given.', + 79, + 'Unsealed array key type non-decimal-int-string does not accept unsealed array key type string.', + ], + [ + 'Parameter #1 $a of method ReportUnsafeArrayStringKeyCastingAccepts\UnsealedArrayShape::doBaz() expects array{stdClass, ...}, array{stdClass, ...} given.', + 85, + 'Unsealed array key type non-decimal-int-string does not accept unsealed array key type string.', + ], + ]); + } + + public static function getAdditionalConfigFiles(): array + { + return array_merge( + parent::getAdditionalConfigFiles(), + [ + __DIR__ . '/reportUnsafeArrayStringKeyCastingDetect.neon', + ], + ); + } + +} diff --git a/tests/PHPStan/Analyser/ReportUnsafeArrayStringKeyCastingDetectTypeInferenceTest.php b/tests/PHPStan/Analyser/ReportUnsafeArrayStringKeyCastingDetectTypeInferenceTest.php new file mode 100644 index 00000000000..1ad96508af6 --- /dev/null +++ b/tests/PHPStan/Analyser/ReportUnsafeArrayStringKeyCastingDetectTypeInferenceTest.php @@ -0,0 +1,40 @@ +assertFileAsserts($assertType, $file, ...$args); + } + + public static function getAdditionalConfigFiles(): array + { + return array_merge( + parent::getAdditionalConfigFiles(), + [ + __DIR__ . '/reportUnsafeArrayStringKeyCastingDetect.neon', + ], + ); + } + +} diff --git a/tests/PHPStan/Analyser/ReportUnsafeArrayStringKeyCastingPreventTypeAcceptanceTest.php b/tests/PHPStan/Analyser/ReportUnsafeArrayStringKeyCastingPreventTypeAcceptanceTest.php new file mode 100644 index 00000000000..a7cf439c073 --- /dev/null +++ b/tests/PHPStan/Analyser/ReportUnsafeArrayStringKeyCastingPreventTypeAcceptanceTest.php @@ -0,0 +1,79 @@ + + */ +class ReportUnsafeArrayStringKeyCastingPreventTypeAcceptanceTest extends RuleTestCase +{ + + public function getRule(): Rule + { + // @phpstan-ignore argument.type + return new CompositeRule([ + self::getContainer()->getByType(CallMethodsRule::class), + self::getContainer()->getByType(ReturnTypeRule::class), + ]); + } + + public function testRule(): void + { + $this->analyse([__DIR__ . '/data/report-unsafe-array-string-key-casting-accepts.php'], [ + [ + 'Parameter #1 $a of method ReportUnsafeArrayStringKeyCastingAccepts\Foo::doFoo() expects array, non-empty-array given.', + 31, + ], + [ + 'Parameter #1 $a of method ReportUnsafeArrayStringKeyCastingAccepts\Foo::doBaz() expects array, non-empty-array given.', + 33, + ], + [ + 'Parameter #1 $a of method ReportUnsafeArrayStringKeyCastingAccepts\Foo::doFoo() expects array, non-empty-array given.', + 37, + ], + [ + 'Parameter #1 $a of method ReportUnsafeArrayStringKeyCastingAccepts\Foo::doBaz() expects array, non-empty-array given.', + 39, + ], + [ + 'Parameter #1 $a of method ReportUnsafeArrayStringKeyCastingAccepts\UnsealedArrayShape::doFoo() expects array{stdClass, ...}, array{stdClass, ...|int<1, max>|non-decimal-int-string, stdClass>} given.', + 77, + 'Unsealed array key type non-decimal-int-string does not accept unsealed array key type int|int<1, max>|non-decimal-int-string.', + ], + [ + 'Parameter #1 $a of method ReportUnsafeArrayStringKeyCastingAccepts\UnsealedArrayShape::doBaz() expects array{stdClass, ...}, array{stdClass, ...|int<1, max>|non-decimal-int-string, stdClass>} given.', + 79, + 'Unsealed array key type non-decimal-int-string does not accept unsealed array key type int|int<1, max>|non-decimal-int-string.', + ], + [ + 'Parameter #1 $a of method ReportUnsafeArrayStringKeyCastingAccepts\UnsealedArrayShape::doFoo() expects array{stdClass, ...}, array{stdClass, ...|int<1, max>|non-decimal-int-string, stdClass>} given.', + 83, + 'Unsealed array key type non-decimal-int-string does not accept unsealed array key type int|int<1, max>|non-decimal-int-string.', + ], + [ + 'Parameter #1 $a of method ReportUnsafeArrayStringKeyCastingAccepts\UnsealedArrayShape::doBaz() expects array{stdClass, ...}, array{stdClass, ...|int<1, max>|non-decimal-int-string, stdClass>} given.', + 85, + 'Unsealed array key type non-decimal-int-string does not accept unsealed array key type int|int<1, max>|non-decimal-int-string.', + ], + ]); + } + + public static function getAdditionalConfigFiles(): array + { + return array_merge( + parent::getAdditionalConfigFiles(), + [ + __DIR__ . '/reportUnsafeArrayStringKeyCastingPrevent.neon', + ], + ); + } + +} diff --git a/tests/PHPStan/Analyser/ReportUnsafeArrayStringKeyCastingPreventTypeInferenceTest.php b/tests/PHPStan/Analyser/ReportUnsafeArrayStringKeyCastingPreventTypeInferenceTest.php new file mode 100644 index 00000000000..0f8b64b4b51 --- /dev/null +++ b/tests/PHPStan/Analyser/ReportUnsafeArrayStringKeyCastingPreventTypeInferenceTest.php @@ -0,0 +1,40 @@ +assertFileAsserts($assertType, $file, ...$args); + } + + public static function getAdditionalConfigFiles(): array + { + return array_merge( + parent::getAdditionalConfigFiles(), + [ + __DIR__ . '/reportUnsafeArrayStringKeyCastingPrevent.neon', + ], + ); + } + +} diff --git a/tests/PHPStan/Analyser/ReportUnsafeArrayStringKeyCastingUnsafeTypeAcceptanceTest.php b/tests/PHPStan/Analyser/ReportUnsafeArrayStringKeyCastingUnsafeTypeAcceptanceTest.php new file mode 100644 index 00000000000..fcf3edfe0e5 --- /dev/null +++ b/tests/PHPStan/Analyser/ReportUnsafeArrayStringKeyCastingUnsafeTypeAcceptanceTest.php @@ -0,0 +1,44 @@ + + */ +class ReportUnsafeArrayStringKeyCastingUnsafeTypeAcceptanceTest extends RuleTestCase +{ + + public function getRule(): Rule + { + return self::getContainer()->getByType(CallMethodsRule::class); + } + + public function testRule(): void + { + $this->analyse([__DIR__ . '/data/report-unsafe-array-string-key-casting-accepts.php'], [ + [ + 'Parameter #1 $a of method ReportUnsafeArrayStringKeyCastingAccepts\Foo::doBaz() expects array, non-empty-array given.', + 33, + ], + [ + 'Parameter #1 $a of method ReportUnsafeArrayStringKeyCastingAccepts\Foo::doBaz() expects array, non-empty-array given.', + 39, + ], + [ + 'Parameter #1 $a of method ReportUnsafeArrayStringKeyCastingAccepts\UnsealedArrayShape::doBaz() expects array{stdClass, ...}, array{stdClass, ...} given.', + 79, + 'Unsealed array key type non-decimal-int-string does not accept unsealed array key type string.', + ], + [ + 'Parameter #1 $a of method ReportUnsafeArrayStringKeyCastingAccepts\UnsealedArrayShape::doBaz() expects array{stdClass, ...}, array{stdClass, ...} given.', + 85, + 'Unsealed array key type non-decimal-int-string does not accept unsealed array key type string.', + ], + ]); + } + +} diff --git a/tests/PHPStan/Analyser/data/bug-13978.php b/tests/PHPStan/Analyser/data/bug-13978.php index fde757bb025..534fbdeab71 100644 --- a/tests/PHPStan/Analyser/data/bug-13978.php +++ b/tests/PHPStan/Analyser/data/bug-13978.php @@ -11,6 +11,9 @@ * @param-out array{ * key1: int * }|array{ + * key1: int, + * key2: float + * }|array{ * key2: float * } $item * diff --git a/tests/PHPStan/Analyser/data/report-unsafe-array-string-key-casting-accepts.php b/tests/PHPStan/Analyser/data/report-unsafe-array-string-key-casting-accepts.php new file mode 100644 index 00000000000..0e240ad02a0 --- /dev/null +++ b/tests/PHPStan/Analyser/data/report-unsafe-array-string-key-casting-accepts.php @@ -0,0 +1,107 @@ + $a */ + public function doFoo(array $a): void + { + + } + + /** @param array $a */ + public function doBar(array $a): void + { + + } + + /** @param array $a */ + public function doBaz(array $a): void + { + + } + + public function doTest(string $s): void + { + $a = [$s => new stdClass()]; + $this->doFoo($a); + $this->doBar($a); + $this->doBaz($a); + + $b = []; + $b[$s] = new stdClass(); + $this->doFoo($b); + $this->doBar($b); + $this->doBaz($b); + } + +} + +class UnsealedArrayShape +{ + + /** + * @param array{stdClass, ...} $a + * @return void + */ + public function doFoo(array $a): void + { + + } + + /** + * @param array{stdClass, ...} $a + * @return void + */ + public function doBar(array $a): void + { + + } + + /** + * @param array{stdClass, ...} $a + * @return void + */ + public function doBaz(array $a): void + { + + } + + public function doTest(string $s): void + { + $a = [new stdClass(), $s => new stdClass()]; + $this->doFoo($a); + $this->doBar($a); + $this->doBaz($a); + + $b = [new stdClass()]; + $b[$s] = new stdClass(); + $this->doFoo($b); + $this->doBar($b); + $this->doBaz($b); + } + +} + +class ReleaseNoteParser +{ + + /** + * @param non-empty-string $s + * @return array + */ + public function buildCommitMap(string $s): array + { + /** @var array $map */ + $map = []; + + $map[$s] = ['section' => 'a', 'release' => 'b']; + + return $map; + } + +} diff --git a/tests/PHPStan/Analyser/data/report-unsafe-array-string-key-casting-detect.php b/tests/PHPStan/Analyser/data/report-unsafe-array-string-key-casting-detect.php new file mode 100644 index 00000000000..15c9387e99d --- /dev/null +++ b/tests/PHPStan/Analyser/data/report-unsafe-array-string-key-casting-detect.php @@ -0,0 +1,129 @@ + $a + */ + public function doFoo(array $a): void + { + assertType('array', $a); + foreach ($a as $k => $v) { + assertType('int|non-decimal-int-string', $k); + } + } + + /** + * @param array $a + */ + public function doBar(array $a): void + { + assertType('array', $a); + foreach ($a as $k => $v) { + assertType('(int|non-decimal-int-string)', $k); + } + } + + /** + * @param array $a + */ + public function doBaz(array $a): void + { + assertType('array', $a); + foreach ($a as $k => $v) { + assertType('int|non-decimal-int-string', $k); + } + } + + public function doArrayCreationAndAssign(string $s): void + { + $a = [$s => 1]; + assertType('non-empty-array', $a); + + $b = []; + $b[$s] = 2; + assertType('non-empty-array', $b); + } + +} + +class FooNonDecimalIntString +{ + + /** + * @param array $a + */ + public function doFoo(array $a): void + { + assertType('array', $a); + foreach ($a as $k => $v) { + assertType('non-decimal-int-string', $k); + } + } + + /** + * @param array $a + */ + public function doBaz(array $a): void + { + assertType('array', $a); + foreach ($a as $k => $v) { + assertType('int|non-decimal-int-string', $k); + } + } + + /** @param non-decimal-int-string $s */ + public function doArrayCreationAndAssign(string $s): void + { + $a = [$s => 1]; + assertType('non-empty-array', $a); + + $b = []; + $b[$s] = 2; + assertType('non-empty-array', $b); + } + +} + +class Unsealed +{ + + /** + * @param array{a: self, ...} $a + */ + public function doFoo(array $a): void + { + assertType('array{a: ReportUnsafeArrayStringKeyCastingDetect\Unsealed, ...}', $a); + foreach ($a as $k => $v) { + assertType('int|non-decimal-int-string', $k); + } + } + + /** + * @param array{a: self, ...} $a + */ + public function doBar(array $a): void + { + assertType('array{a: ReportUnsafeArrayStringKeyCastingDetect\Unsealed, ...}', $a); + foreach ($a as $k => $v) { + assertType('(int|non-decimal-int-string)', $k); + } + } + + /** + * @param array{a: self, ...} $a + */ + public function doBaz(array $a): void + { + assertType('array{a: ReportUnsafeArrayStringKeyCastingDetect\Unsealed, ...}', $a); + foreach ($a as $k => $v) { + assertType('int|non-decimal-int-string', $k); + } + } + +} diff --git a/tests/PHPStan/Analyser/data/report-unsafe-array-string-key-casting-prevent.php b/tests/PHPStan/Analyser/data/report-unsafe-array-string-key-casting-prevent.php new file mode 100644 index 00000000000..ba5f9f46650 --- /dev/null +++ b/tests/PHPStan/Analyser/data/report-unsafe-array-string-key-casting-prevent.php @@ -0,0 +1,129 @@ + $a + */ + public function doFoo(array $a): void + { + assertType('array', $a); + foreach ($a as $k => $v) { + assertType('non-decimal-int-string', $k); + } + } + + /** + * @param array $a + */ + public function doBar(array $a): void + { + assertType('array', $a); + foreach ($a as $k => $v) { + assertType('(int|string)', $k); + } + } + + /** + * @param array $a + */ + public function doBaz(array $a): void + { + assertType('array', $a); + foreach ($a as $k => $v) { + assertType('int|string', $k); + } + } + + public function doArrayCreationAndAssign(string $s): void + { + $a = [$s => 1]; + assertType('non-empty-array', $a); + + $b = []; + $b[$s] = 2; + assertType('non-empty-array', $b); + } + +} + +class FooNonDecimalIntString +{ + + /** + * @param array $a + */ + public function doFoo(array $a): void + { + assertType('array', $a); + foreach ($a as $k => $v) { + assertType('non-decimal-int-string', $k); + } + } + + /** + * @param array $a + */ + public function doBaz(array $a): void + { + assertType('array', $a); + foreach ($a as $k => $v) { + assertType('int|non-decimal-int-string', $k); + } + } + + /** @param non-decimal-int-string $s */ + public function doArrayCreationAndAssign(string $s): void + { + $a = [$s => 1]; + assertType('non-empty-array', $a); + + $b = []; + $b[$s] = 2; + assertType('non-empty-array', $b); + } + +} + +class Unsealed +{ + + /** + * @param array{a: int, ...} $a + */ + public function doFoo(array $a): void + { + assertType('array{a: int, ...}', $a); + foreach ($a as $k => $v) { + assertType('non-decimal-int-string', $k); + } + } + + /** + * @param array{a: int, ...} $a + */ + public function doBar(array $a): void + { + assertType('array{a: int, ...}', $a); + foreach ($a as $k => $v) { + assertType('(int|string)', $k); + } + } + + /** + * @param array{a: int, ...} $a + */ + public function doBaz(array $a): void + { + assertType('array{a: int, ...}', $a); + foreach ($a as $k => $v) { + assertType('int|string', $k); + } + } + +} diff --git a/tests/PHPStan/Analyser/nsrt/array-append-count.php b/tests/PHPStan/Analyser/nsrt/array-append-count.php new file mode 100644 index 00000000000..d39ed604943 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/array-append-count.php @@ -0,0 +1,32 @@ + 0) { + $types[] = 'x'; + } elseif ($a < 0) { + $types[] = 'y'; + } + if ($b > 0) { + $types[] = 'z'; + } + if ($c === 1) { + $types[] = 'p'; + } elseif ($c === 2) { + $types[] = 'q'; + } + + // $types could have 1 (just 'base'), or 2/3/4 depending on which + // elseif arms fire. count should at least allow 1. + assertType('int<1, 4>', count($types)); + + if (count($types) === 1) { + // reachable: all three ifs miss — $types stays as ['base']. + assertType("array{'base'}", $types); + } +} diff --git a/tests/PHPStan/Analyser/nsrt/array-combine-php8.php b/tests/PHPStan/Analyser/nsrt/array-combine-php8.php index 9eb9827154b..1a1a78c411b 100644 --- a/tests/PHPStan/Analyser/nsrt/array-combine-php8.php +++ b/tests/PHPStan/Analyser/nsrt/array-combine-php8.php @@ -162,3 +162,27 @@ function withUnionAsKey(int|bool $oneOrBool) assertType("array{1: 'bar'}", array_combine($keys, ['bar'])); } + +/** + * @param array{'a', 'b', ...} $keys + * @param array{1, 2, ...} $values + */ +function bothUnsealed(array $keys, array $values) +{ + // Both arrays carry unsealed extras of matching (unbounded) count; + // the extra key/value pairs become the result's unsealed slot: + // `` (the keys' unsealed value, as a key, mapped to the + // values' unsealed value). + assertType('array{a: 1, b: 2, ...}', array_combine($keys, $values)); +} + +/** + * @param array{'a', 'b', ...} $keys + */ +function onlyKeysUnsealed(array $keys) +{ + // The values array is sealed (exactly 2), so array_combine only + // succeeds when the keys array also has exactly 2 — the extras can't + // exist. Result stays sealed. + assertType('array{a: 1, b: 2}', array_combine($keys, [1, 2])); +} diff --git a/tests/PHPStan/Analyser/nsrt/array-functions.php b/tests/PHPStan/Analyser/nsrt/array-functions.php index ab06eb4256a..dbbbe0cf76c 100644 --- a/tests/PHPStan/Analyser/nsrt/array-functions.php +++ b/tests/PHPStan/Analyser/nsrt/array-functions.php @@ -246,8 +246,8 @@ assertType('list', array_values($generalStringKeys)); assertType('array{foo: stdClass, 0: stdClass}', array_merge($stringOrIntegerKeys)); assertType('array', array_merge($generalStringKeys, $generalDateTimeValues)); -assertType('non-empty-array<1|string, int|stdClass>&hasOffsetValue(\'foo\', stdClass)', array_merge($generalStringKeys, $stringOrIntegerKeys)); -assertType('non-empty-array<1|string, int|stdClass>&hasOffset(\'foo\')', array_merge($stringOrIntegerKeys, $generalStringKeys)); +assertType('array{foo: stdClass, ...}', array_merge($generalStringKeys, $stringOrIntegerKeys)); +assertType('array{foo: int|stdClass, ...}', array_merge($stringOrIntegerKeys, $generalStringKeys)); assertType('array{foo: stdClass, bar: stdClass, 0: stdClass}', array_merge($stringKeys, $stringOrIntegerKeys)); assertType('array{foo: \'foo\', 0: stdClass, bar: stdClass}', array_merge($stringOrIntegerKeys, $stringKeys)); assertType('array{foo: 1, bar: 2, 0: 2, 1: 3}', array_merge(['foo' => 4, 'bar' => 5], ...[['foo' => 1, 'bar' => 2], [2, 3]])); diff --git a/tests/PHPStan/Analyser/nsrt/array-merge-const-non-const.php b/tests/PHPStan/Analyser/nsrt/array-merge-const-non-const.php index 03286b936a2..1d4bf1bab41 100644 --- a/tests/PHPStan/Analyser/nsrt/array-merge-const-non-const.php +++ b/tests/PHPStan/Analyser/nsrt/array-merge-const-non-const.php @@ -6,21 +6,21 @@ function doFoo(array $post): void { assertType( - "non-empty-array&hasOffset('a')&hasOffset('b')", + 'array{a: mixed, b: mixed, ...}', array_merge(['a' => 1, 'b' => false, 10 => 99], $post) ); } function doBar(array $array): void { assertType( - "non-empty-array&hasOffsetValue('a', 1)&hasOffsetValue('b', false)", + 'array{a: 1, b: false, ...}', array_merge($array, ['a' => 1, 'b' => false, 10 => 99]) ); } function doFooBar(array $array): void { assertType( - "non-empty-array&hasOffset('x')&hasOffsetValue('a', 1)&hasOffsetValue('b', false)&hasOffsetValue('c', 'e')", + "array{c: 'e', x: mixed, a: 1, b: false, ...}", array_merge(['c' => 'd', 'x' => 'y'], $array, ['a' => 1, 'b' => false, 'c' => 'e']) ); } @@ -28,7 +28,7 @@ function doFooBar(array $array): void { function doFooInts(array $array): void { // int keys will be renumbered therefore we can't reason about them in case we don't know all arrays involved assertType( - "non-empty-array&hasOffsetValue('a', 1)&hasOffsetValue('c', 'e')", + "array{a: 1, c: 'e', ...}", array_merge([1 => 'd'], $array, ['a' => 1, 3 => false, 'c' => 'e']) ); } @@ -38,7 +38,7 @@ function doFooInts(array $array): void { */ function floatKey(array $array): void { assertType( - "non-empty-array&hasOffsetValue('a', '1')&hasOffsetValue('c', 'e')", + "array{a: '1', c: 'e', ...}", array_merge([4.23 => 'd'], $array, ['a' => '1', 3 => 'false', 'c' => 'e']) ); } @@ -55,14 +55,14 @@ function doOptKeys(array $array, array $arr2): void { * @param array{a?: 1, b: 2} $array */ function doOptShapeKeys(array $array, array $arr2): void { - assertType("non-empty-array&hasOffsetValue('b', 2)", array_merge($arr2, $array)); - assertType("non-empty-array&hasOffset('b')", array_merge($array, $arr2)); + assertType('array{b: 2, ...}', array_merge($arr2, $array)); + assertType('array{b: mixed, ...}', array_merge($array, $arr2)); } function hasOffsetKeys(array $array, array $arr2): void { if (array_key_exists('b', $array)) { - assertType("non-empty-array&hasOffsetValue('b', mixed)", array_merge($arr2, $array)); - assertType("non-empty-array&hasOffset('b')", array_merge($array, $arr2)); + assertType('array{b: mixed, ...}', array_merge($arr2, $array)); + assertType('array{b: mixed, ...}', array_merge($array, $arr2)); } } @@ -80,24 +80,24 @@ function hasOffsetValueKeys(array $hasB, array $mixedArray, array $hasC): void { $hasB['b'] = 123; $hasC['c'] = 'def'; - assertType("non-empty-array&hasOffsetValue('b', 123)", array_merge($mixedArray, $hasB)); - assertType("non-empty-array&hasOffset('b')", array_merge($hasB, $mixedArray)); + assertType('array{b: 123, ...}', array_merge($mixedArray, $hasB)); + assertType('array{b: mixed, ...}', array_merge($hasB, $mixedArray)); assertType( - "non-empty-array&hasOffset('b')&hasOffsetValue('c', 'def')", + "array{b: mixed, c: 'def', ...}", array_merge($mixedArray, $hasB, $hasC) ); assertType( - "non-empty-array&hasOffset('b')&hasOffsetValue('c', 'def')", + "array{b: mixed, c: 'def', ...}", array_merge($hasB, $mixedArray, $hasC) ); assertType( - "non-empty-array&hasOffset('c')&hasOffsetValue('b', 123)", + 'array{c: mixed, b: 123, ...}', array_merge($hasC, $mixedArray, $hasB) ); assertType( - "non-empty-array&hasOffset('b')&hasOffset('c')", + 'array{c: mixed, b: mixed, ...}', array_merge($hasC, $hasB, $mixedArray) ); @@ -116,12 +116,12 @@ function hasOffsetValueKeys(array $hasB, array $mixedArray, array $hasC): void { $differentCs = ['c' => 20]; } assertType('array{c: 10}|array{c: 20}', $differentCs); - assertType("non-empty-array&hasOffsetValue('c', 10|20)", array_merge($mixedArray, $differentCs)); - assertType("non-empty-array&hasOffset('c')", array_merge($differentCs, $mixedArray)); + assertType('array{c: 10|20, ...}', array_merge($mixedArray, $differentCs)); + assertType('array{c: mixed, ...}', array_merge($differentCs, $mixedArray)); - assertType("non-empty-array&hasOffsetValue('c', 10|20)", array_merge($mixedArray, $hasBorC, $differentCs)); + assertType('array{c: 10|20, ...}', array_merge($mixedArray, $hasBorC, $differentCs)); assertType("non-empty-array", array_merge($differentCs, $mixedArray, $hasBorC)); // could be non-empty-array&hasOffset('c') - assertType("non-empty-array&hasOffsetValue('c', 10|20)", array_merge($hasBorC, $mixedArray, $differentCs)); + assertType('array{c: 10|20, ...}', array_merge($hasBorC, $mixedArray, $differentCs)); assertType("non-empty-array", array_merge($differentCs, $hasBorC, $mixedArray)); // could be non-empty-array&hasOffset('c') } diff --git a/tests/PHPStan/Analyser/nsrt/array-replace-const-non-const.php b/tests/PHPStan/Analyser/nsrt/array-replace-const-non-const.php index 9420d6f13ba..657db686771 100644 --- a/tests/PHPStan/Analyser/nsrt/array-replace-const-non-const.php +++ b/tests/PHPStan/Analyser/nsrt/array-replace-const-non-const.php @@ -7,21 +7,21 @@ function doFoo(array $post): void { assertType( - "non-empty-array&hasOffset('a')&hasOffset('b')&hasOffset(10)", + 'array{a: mixed, b: mixed, 10: mixed, ...}', array_replace(['a' => 1, 'b' => false, 10 => 99], $post) ); } function doBar(array $array): void { assertType( - "non-empty-array&hasOffsetValue('a', 1)&hasOffsetValue('b', false)&hasOffsetValue(10, 99)", + 'array{a: 1, b: false, 10: 99, ...}', array_replace($array, ['a' => 1, 'b' => false, 10 => 99]) ); } function doFooBar(array $array): void { assertType( - "non-empty-array&hasOffset('x')&hasOffsetValue('a', 1)&hasOffsetValue('b', false)&hasOffsetValue('c', 'e')", + "array{c: 'e', x: mixed, a: 1, b: false, ...}", array_replace(['c' => 'd', 'x' => 'y'], $array, ['a' => 1, 'b' => false, 'c' => 'e']) ); } @@ -30,14 +30,14 @@ function doFooBar(array $array): void { * @param array{a?: 1, b: 2} $array */ function doOptShapeKeys(array $array, array $arr2): void { - assertType("non-empty-array&hasOffsetValue('b', 2)", array_replace($arr2, $array)); - assertType("non-empty-array&hasOffset('b')", array_replace($array, $arr2)); + assertType('array{b: 2, ...}', array_replace($arr2, $array)); + assertType('array{b: mixed, ...}', array_replace($array, $arr2)); } function hasOffsetKeys(array $array, array $arr2): void { if (array_key_exists('b', $array)) { - assertType("non-empty-array&hasOffsetValue('b', mixed)", array_replace($arr2, $array)); - assertType("non-empty-array&hasOffset('b')", array_replace($array, $arr2)); + assertType('array{b: mixed, ...}', array_replace($arr2, $array)); + assertType('array{b: mixed, ...}', array_replace($array, $arr2)); } } @@ -55,24 +55,24 @@ function hasOffsetValueKeys(array $hasB, array $mixedArray, array $hasC): void { $hasB['b'] = 123; $hasC['c'] = 'def'; - assertType("non-empty-array&hasOffsetValue('b', 123)", array_replace($mixedArray, $hasB)); - assertType("non-empty-array&hasOffset('b')", array_replace($hasB, $mixedArray)); + assertType('array{b: 123, ...}', array_replace($mixedArray, $hasB)); + assertType('array{b: mixed, ...}', array_replace($hasB, $mixedArray)); assertType( - "non-empty-array&hasOffset('b')&hasOffsetValue('c', 'def')", + "array{b: mixed, c: 'def', ...}", array_replace($mixedArray, $hasB, $hasC) ); assertType( - "non-empty-array&hasOffset('b')&hasOffsetValue('c', 'def')", + "array{b: mixed, c: 'def', ...}", array_replace($hasB, $mixedArray, $hasC) ); assertType( - "non-empty-array&hasOffset('c')&hasOffsetValue('b', 123)", + 'array{c: mixed, b: 123, ...}', array_replace($hasC, $mixedArray, $hasB) ); assertType( - "non-empty-array&hasOffset('b')&hasOffset('c')", + 'array{c: mixed, b: mixed, ...}', array_replace($hasC, $hasB, $mixedArray) ); @@ -91,12 +91,12 @@ function hasOffsetValueKeys(array $hasB, array $mixedArray, array $hasC): void { $differentCs = ['c' => 20]; } assertType('array{c: 10}|array{c: 20}', $differentCs); - assertType("non-empty-array&hasOffsetValue('c', 10|20)", array_replace($mixedArray, $differentCs)); - assertType("non-empty-array&hasOffset('c')", array_replace($differentCs, $mixedArray)); + assertType('array{c: 10|20, ...}', array_replace($mixedArray, $differentCs)); + assertType('array{c: mixed, ...}', array_replace($differentCs, $mixedArray)); - assertType("non-empty-array&hasOffsetValue('c', 10|20)", array_replace($mixedArray, $hasBorC, $differentCs)); + assertType('array{c: 10|20, ...}', array_replace($mixedArray, $hasBorC, $differentCs)); assertType("non-empty-array", array_replace($differentCs, $mixedArray, $hasBorC)); // could be non-empty-array&hasOffset('c') - assertType("non-empty-array&hasOffsetValue('c', 10|20)", array_replace($hasBorC, $mixedArray, $differentCs)); + assertType('array{c: 10|20, ...}', array_replace($hasBorC, $mixedArray, $differentCs)); assertType("non-empty-array", array_replace($differentCs, $hasBorC, $mixedArray)); // could be non-empty-array&hasOffset('c') } @@ -113,13 +113,13 @@ function withArrayReplacement(array $base): void { $replacements2 = [ 'citrus' => [ 'kumquat', 'citron' ], 'pome' => [ 'loquat' ] ]; $basket = array_replace($base, $replacements, $replacements2); - assertType("non-empty-array&hasOffsetValue('citrus', array{'kumquat', 'citron'})&hasOffsetValue('pome', array{'loquat'})", $basket); + assertType("array{citrus: array{'kumquat', 'citron'}, pome: array{'loquat'}, ...}", $basket); } /** * @param array{foo: int, x: string}|array{foo: string, y: 1} $arr1 */ function doUnions(array $arr1, array $arr2): void { - assertType("non-empty-array&hasOffset('foo')", array_replace($arr1, $arr2)); - assertType("non-empty-array&hasOffsetValue('foo', int|string)", array_replace($arr2, $arr1)); + assertType('array{foo: mixed, ...}', array_replace($arr1, $arr2)); + assertType('array{foo: int|string, ...}', array_replace($arr2, $arr1)); } diff --git a/tests/PHPStan/Analyser/nsrt/array-replace.php b/tests/PHPStan/Analyser/nsrt/array-replace.php index 0b22a8f74a2..6bf0d2b0bac 100644 --- a/tests/PHPStan/Analyser/nsrt/array-replace.php +++ b/tests/PHPStan/Analyser/nsrt/array-replace.php @@ -76,11 +76,11 @@ public function arrayReplaceUnionTypeArrayShapes($array1, $array2): void */ public function arrayReplaceArrayShapeAndGeneralArray($array1, $array2, $array3): void { - assertType("non-empty-array&hasOffset('bar')&hasOffset('foo')", array_replace($array1, $array2)); - assertType("non-empty-array&hasOffsetValue('bar', '2')&hasOffsetValue('foo', '1')", array_replace($array2, $array1)); + assertType("array{foo: '1'|int, bar: '2'|int, ...}", array_replace($array1, $array2)); + assertType("array{foo: '1', bar: '2', ...}", array_replace($array2, $array1)); - assertType("non-empty-array<'bar'|'foo'|int, string>&hasOffset('bar')&hasOffset('foo')", array_replace($array1, $array3)); - assertType("non-empty-array<'bar'|'foo'|int, string>&hasOffsetValue('bar', '2')&hasOffsetValue('foo', '1')", array_replace($array3, $array1)); + assertType("array{foo: string, bar: string, ...}", array_replace($array1, $array3)); + assertType("array{foo: '1', bar: '2', ...}", array_replace($array3, $array1)); assertType("array", array_replace($array2, $array3)); } diff --git a/tests/PHPStan/Analyser/nsrt/array-sum.php b/tests/PHPStan/Analyser/nsrt/array-sum.php index 3d53b450e33..7f9334f5bfd 100644 --- a/tests/PHPStan/Analyser/nsrt/array-sum.php +++ b/tests/PHPStan/Analyser/nsrt/array-sum.php @@ -264,3 +264,23 @@ function foo32($list) { assertType('(float|int)', array_sum($list)); } + +/** + * @param array{1, 2, ...} $list + */ +function foo33($list) +{ + // The explicit `1, 2` sum to 3, but the unsealed `` extras + // can add any number of further ints — the result must include them. + assertType('int', array_sum($list)); +} + +/** + * @param array{1, 2, ...} $list + */ +function foo34($list) +{ + // Zero extras keeps the exact int sum `3`; one-or-more float extras + // make it float. + assertType('3|float', array_sum($list)); +} diff --git a/tests/PHPStan/Analyser/nsrt/bug-12355.php b/tests/PHPStan/Analyser/nsrt/bug-12355.php index 4b7ee866cdc..ed67cce3e12 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-12355.php +++ b/tests/PHPStan/Analyser/nsrt/bug-12355.php @@ -20,11 +20,11 @@ abstract class Animal * @param AnimalData $arg */ public function __construct(array $arg) { - assertType('ValidType of array{name: string} (class Bug12355\Animal, argument)', $arg['animalSpecificData']); + assertType('ValidType of array{name: string, ...} (class Bug12355\Animal, argument)', $arg['animalSpecificData']); if (isset($arg['habitat'])) { //do things } - assertType('ValidType of array{name: string} (class Bug12355\Animal, argument)', $arg['animalSpecificData']); + assertType('ValidType of array{name: string, ...} (class Bug12355\Animal, argument)', $arg['animalSpecificData']); } } @@ -34,7 +34,7 @@ public function __construct(array $arg) { */ function testMergeWithDifferentObjects(array $arg): void { - assertType('T of array{name: string} (function Bug12355\testMergeWithDifferentObjects(), argument)', $arg['first']); + assertType('T of array{name: string, ...} (function Bug12355\testMergeWithDifferentObjects(), argument)', $arg['first']); // Modifying $arg in one branch causes different ConstantArrayType objects if (isset($arg['flag'])) { @@ -43,6 +43,6 @@ function testMergeWithDifferentObjects(array $arg): void // After scope merge, $arg's value types for 'first' and 'second' go through // ConstantArrayType::mergeWith() which uses new self() — stripping TemplateConstantArrayType - assertType('T of array{name: string} (function Bug12355\testMergeWithDifferentObjects(), argument)', $arg['first']); - assertType('T of array{name: string} (function Bug12355\testMergeWithDifferentObjects(), argument)', $arg['second']); + assertType('T of array{name: string, ...} (function Bug12355\testMergeWithDifferentObjects(), argument)', $arg['first']); + assertType('T of array{name: string, ...} (function Bug12355\testMergeWithDifferentObjects(), argument)', $arg['second']); } diff --git a/tests/PHPStan/Analyser/nsrt/bug-13637.php b/tests/PHPStan/Analyser/nsrt/bug-13637.php new file mode 100644 index 00000000000..0176f95a081 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-13637.php @@ -0,0 +1,38 @@ +`) is + // fixed: they stay `int<0, 4>`. The middle key degenerates to `int` rather + // than the ideal `int<0, 8>` — a minor key-precision residual in 3-level + // nesting, not the value-widening bug from the issue. + assertType('non-empty-array, non-empty-array, array{abc: int<0, 4>, def?: int<0, 4>, ghi?: int<0, 4>}>>>', $final); +} + +function thisWorks(): void +{ + $final = []; + + for ($i = 0; $i < 5; $i++) { + $j = $i * 2; + $final[$i][$j]['abc'] = $i; + $final[$i][$j]['def'] = $i; + $final[$i][$j]['ghi'] = $i; + } + + assertType('non-empty-array, non-empty-array, array{abc: int<0, 4>, def: int<0, 4>, ghi: int<0, 4>}>>', $final); +} diff --git a/tests/PHPStan/Analyser/nsrt/bug-13647.php b/tests/PHPStan/Analyser/nsrt/bug-13647.php new file mode 100644 index 00000000000..484f04b8128 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-13647.php @@ -0,0 +1,28 @@ +, array{int<0, 5>, int<1, 6>}>', $a); + + for ($i = 1; $i < 6; $i++) { + $b = $a; + + $b[$i][0] = $a[$i - 1][0]; + $b[$i][1] = $a[$i - 1][1]; + + $a = $b; + } + + assertType('non-empty-array, array{int<0, 5>, int<1, 6>}>', $a); +} diff --git a/tests/PHPStan/Analyser/nsrt/bug-14032.php b/tests/PHPStan/Analyser/nsrt/bug-14032.php new file mode 100644 index 00000000000..ceca2a00494 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-14032.php @@ -0,0 +1,47 @@ +&hasOffset('limit')&hasOffset('remove')", $settings); + assertType('array{remove: mixed, limit: mixed, ...}', $settings); if (!is_string($settings['remove'])) { throw $this->configException($settings, 'remove'); } - assertType("non-empty-array&hasOffset('limit')&hasOffsetValue('remove', string)", $settings); + assertType('array{remove: string, limit: mixed, ...}', $settings); $settings['remove'] = strtolower($settings['remove']); - assertType("non-empty-array&hasOffset('limit')&hasOffsetValue('remove', lowercase-string)", $settings); + assertType('array{remove: lowercase-string, limit: mixed, ...}', $settings); if (!in_array($settings['remove'], ['first', 'last', 'all'], true)) { throw $this->configException($settings, 'remove'); } - assertType("non-empty-array&hasOffset('limit')&hasOffsetValue('remove', 'all'|'first'|'last')", $settings); + assertType("array{remove: 'all'|'first'|'last', limit: mixed, ...}", $settings); if (!is_numeric($settings['limit']) || $settings['limit'] < 1) { throw $this->configException($settings, 'limit'); } - assertType("non-empty-array&hasOffsetValue('limit', float|int<1, max>|numeric-string)&hasOffsetValue('remove', 'all'|'first'|'last')", $settings); + assertType("array{remove: 'all'|'first'|'last', limit: float|int<1, max>|numeric-string, ...}", $settings); $settings['limit'] = (int) $settings['limit']; - assertType("non-empty-array&hasOffsetValue('limit', int)&hasOffsetValue('remove', 'all'|'first'|'last')", $settings); + assertType("array{remove: 'all'|'first'|'last', limit: int, ...}", $settings); return $settings; } @@ -110,19 +110,19 @@ private function getResultSettings(array $settings): array { $settings = array_merge(self::DEFAULT_SETTINGS, $settings); - assertType("non-empty-array&hasOffset('limit')&hasOffset('remove')", $settings); + assertType('array{remove: mixed, limit: mixed, ...}', $settings); if (!is_string($settings['remove'])) { throw new Exception(); } - assertType("non-empty-array&hasOffset('limit')&hasOffsetValue('remove', string)", $settings); + assertType('array{remove: string, limit: mixed, ...}', $settings); if (!is_int($settings['limit'])) { throw new Exception(); } - assertType("non-empty-array&hasOffsetValue('limit', int)&hasOffsetValue('remove', string)", $settings); + assertType('array{remove: string, limit: int, ...}', $settings); return $settings; } diff --git a/tests/PHPStan/Analyser/nsrt/bug-5584.php b/tests/PHPStan/Analyser/nsrt/bug-5584.php index 45e6efeaa3f..7800f1364a0 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-5584.php +++ b/tests/PHPStan/Analyser/nsrt/bug-5584.php @@ -19,6 +19,6 @@ public function unionSum(): void $b = ['b' => 6]; } - assertType('array{}|array{b?: 6, a?: 5}', $a + $b); + assertType('array{b?: 6, a?: 5}', $a + $b); } } diff --git a/tests/PHPStan/Analyser/nsrt/bug-9985.php b/tests/PHPStan/Analyser/nsrt/bug-9985.php index 09a7ad92eac..9f1e979c014 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-9985.php +++ b/tests/PHPStan/Analyser/nsrt/bug-9985.php @@ -17,7 +17,7 @@ function (): void { $warnings['c'] = true; } - assertType('array{}|array{a?: true, b: true}|array{a?: true, c?: true}', $warnings); + assertType('array{a?: true, b: true}|array{a?: true, c?: true}', $warnings); if (!empty($warnings)) { assertType('array{a?: true, b: true}|non-empty-array{a?: true, c?: true}', $warnings); diff --git a/tests/PHPStan/Analyser/nsrt/bug-yield-oversized-self-rejection.php b/tests/PHPStan/Analyser/nsrt/bug-yield-oversized-self-rejection.php index 435edbb767e..c5185f7af3c 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-yield-oversized-self-rejection.php +++ b/tests/PHPStan/Analyser/nsrt/bug-yield-oversized-self-rejection.php @@ -90,7 +90,7 @@ function build(string $eventClass): array ]; } - assertType("non-empty-array&oversized-array", $r); + assertType("non-empty-array&oversized-array", $r); return $r; } diff --git a/tests/PHPStan/Analyser/nsrt/compact.php b/tests/PHPStan/Analyser/nsrt/compact.php index b15f2f5eb43..d89cb5ceba7 100644 --- a/tests/PHPStan/Analyser/nsrt/compact.php +++ b/tests/PHPStan/Analyser/nsrt/compact.php @@ -20,3 +20,15 @@ function (string $dolor): void { assertType('array{}', compact([])); }; + +/** + * @param array{'foo', 'bar', ...} $names + */ +function unsealedNames(array $names): void { + $foo = 'x'; + $bar = 'y'; + // The unsealed `` extras are unknown further variable + // names, so the result can't be enumerated as a sealed shape — it + // must widen to the general `compact()` signature. + assertType('array', compact($names)); +}; diff --git a/tests/PHPStan/Analyser/nsrt/conditional-array-key-exists.php b/tests/PHPStan/Analyser/nsrt/conditional-array-key-exists.php new file mode 100644 index 00000000000..0f88f37807d --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/conditional-array-key-exists.php @@ -0,0 +1,25 @@ + $options */ +function apply(array $options): void +{ + $range = []; + if (isset($options['min_range'])) { + $range['min'] = 1; + } + if (isset($options['max_range'])) { + $range['max'] = 2; + } + + // $range can be {}, {min}, {max}, or {min, max} + assertType('array{min?: 1, max?: 2}', $range); + + if (array_key_exists('min', $range) || array_key_exists('max', $range)) { + // reachable: either key could be set. + assertType('non-empty-array{min?: 1, max?: 2}', $range); + } +} diff --git a/tests/PHPStan/Analyser/nsrt/constant-array-type-set.php b/tests/PHPStan/Analyser/nsrt/constant-array-type-set.php index 9ae0b88828a..77331734230 100644 --- a/tests/PHPStan/Analyser/nsrt/constant-array-type-set.php +++ b/tests/PHPStan/Analyser/nsrt/constant-array-type-set.php @@ -11,7 +11,7 @@ public function doFoo(int $i) { $a = [1, 2, 3]; $a[$i] = 4; - assertType('non-empty-array', $a); + assertType('array{1|4, 2|4, 3|4, ...|int<3, max>, 4>}', $a); $b = [1, 2, 3]; $b[3] = 4; @@ -33,7 +33,7 @@ public function doFoo(int $i) /** @var 0|1|2|3 $offset3 */ $offset3 = doFoo(); $e[$offset3] = true; - assertType('non-empty-array<0|1|2|3, bool>', $e); + assertType('array{0: bool, 1: bool, 2: bool, 3?: true}', $e); $f = [false, false, false]; /** @var 0|1 $offset4 */ @@ -72,7 +72,7 @@ public function doBar3(int $offset): void { $a = [false, false, false, false, false]; $a[$offset] = true; - assertType('non-empty-array, bool>', $a); + assertType('array{bool, bool, bool, bool, bool, ..., true>}', $a); } /** @@ -83,7 +83,7 @@ public function doBar4(int $offset): void { $a = [false, false, false, false, false]; $a[$offset] = true; - assertType('non-empty-array, bool>', $a); + assertType('array{bool, false, false, false, false, ..., true>}', $a); } /** @@ -94,7 +94,7 @@ public function doBar5(int $offset): void { $a = [false, false, false]; $a[$offset] = true; - assertType('non-empty-array, bool>', $a); + assertType('array{0: bool, 1: bool, 2: bool, 3?: true, 4?: true}', $a); } public function doBar6(bool $offset): void diff --git a/tests/PHPStan/Analyser/nsrt/decimal-int-string.php b/tests/PHPStan/Analyser/nsrt/decimal-int-string.php new file mode 100644 index 00000000000..fe5c5fdd529 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/decimal-int-string.php @@ -0,0 +1,58 @@ + 1]; + assertType('non-empty-array', $a); + + assertType('bool', (bool) $s); + + assertType('int', $s + $s); + } + + /** + * @param non-decimal-int-string $s + */ + public function doBar(string $s): void + { + assertType('non-decimal-int-string' ,$s); + $a = [$s => 1]; + assertType('non-empty-array', $a); + + assertType('bool', (bool) $s); + + assertType('float|int', $s + $s); + } + + public function doBaz(string $s): void + { + $a = [$s => 1]; + assertType('non-empty-array', $a); + + $b = []; + $b[$s] = 2; + assertType('non-empty-array', $b); + } + + /** + * @param non-decimal-int-string $s + */ + public function emptyStringIsNonDecimal(string $s): void + { + if ($s === '') { + assertType("''", $s); // '' is a valid non-decimal-int-string + } + } + +} diff --git a/tests/PHPStan/Analyser/nsrt/generalize-scope-recursive.php b/tests/PHPStan/Analyser/nsrt/generalize-scope-recursive.php index d4a82c8dcb4..d83076eba58 100644 --- a/tests/PHPStan/Analyser/nsrt/generalize-scope-recursive.php +++ b/tests/PHPStan/Analyser/nsrt/generalize-scope-recursive.php @@ -16,7 +16,7 @@ public function doFoo(array $array, array $values) } } - assertType('array{}|array{foo?: array}', $data); + assertType('array{}|array{foo: array>}', $data); } /** diff --git a/tests/PHPStan/Analyser/nsrt/has-offset-type-bug.php b/tests/PHPStan/Analyser/nsrt/has-offset-type-bug.php index 09955bde2ea..525fc619c8d 100644 --- a/tests/PHPStan/Analyser/nsrt/has-offset-type-bug.php +++ b/tests/PHPStan/Analyser/nsrt/has-offset-type-bug.php @@ -63,14 +63,14 @@ public function doBar(array $result): void */ public function testIsset($range): void { - assertType("array{}|array{min?: bool|float|int|string|null, max?: bool|float|int|string|null}", $range); + assertType("array{min?: bool|float|int|string|null, max?: bool|float|int|string|null}", $range); if (isset($range['min']) || isset($range['max'])) { assertType("non-empty-array{min?: bool|float|int|string|null, max?: bool|float|int|string|null}", $range); } else { - assertType("array{}|array{min?: bool|float|int|string|null, max?: bool|float|int|string|null}", $range); + assertType("array{min?: bool|float|int|string|null, max?: bool|float|int|string|null}", $range); } - assertType("array{}|array{min?: bool|float|int|string|null, max?: bool|float|int|string|null}", $range); + assertType("array{min?: bool|float|int|string|null, max?: bool|float|int|string|null}", $range); } } diff --git a/tests/PHPStan/Analyser/nsrt/implode.php b/tests/PHPStan/Analyser/nsrt/implode.php index 8e97e19f729..ecd36a0570f 100644 --- a/tests/PHPStan/Analyser/nsrt/implode.php +++ b/tests/PHPStan/Analyser/nsrt/implode.php @@ -68,4 +68,21 @@ public function constArrays6($constArr) { public function constArrays7($constArr) { assertType("'1a'|'1b'|'1c'|'2a'|'2b'|'2c'|'a'|'b'|'c'", implode('', $constArr)); } + + /** @param array{'a', 'b', ...} $unsealed */ + public function unsealedConstArr($unsealed) { + // The unsealed `` extras can append more segments, so + // the exact constant fold `'a,b'` is unsound. The result keeps only + // what's guaranteed: with a non-falsy separator and at least two + // explicit elements, the output always contains a comma. + assertType('non-falsy-string', implode(',', $unsealed)); + } + + /** @param array{'a', 'b', ...} $unsealed */ + public function unsealedConstArrEmptySeparator($unsealed) { + // Empty separator + a possibly-empty unsealed value type leaves no + // accessory PHPStan can prove, so the result widens to `string` + // (still sound — no bogus constant fold). + assertType('string', implode('', $unsealed)); + } } diff --git a/tests/PHPStan/Analyser/nsrt/list-count.php b/tests/PHPStan/Analyser/nsrt/list-count.php index 24bfc6fa63f..d9bd37e9b52 100644 --- a/tests/PHPStan/Analyser/nsrt/list-count.php +++ b/tests/PHPStan/Analyser/nsrt/list-count.php @@ -291,7 +291,7 @@ protected function testOptionalKeysInListsOfTaggedUnion($row): void } if (count($row) === 1) { - assertType('array{0: int, 1?: string|null}|array{string}', $row); + assertType('array{int}|array{string}', $row); } else { assertType('array{int, string|null}', $row); } @@ -299,7 +299,7 @@ protected function testOptionalKeysInListsOfTaggedUnion($row): void if (count($row) === 2) { assertType('array{int, string|null}', $row); } else { - assertType('array{0: int, 1?: string|null}|array{string}', $row); + assertType('array{int}|array{string}', $row); } if (count($row) === 3) { @@ -354,7 +354,7 @@ protected function testOptionalKeysInUnionListWithIntRange($row, $listRow, $twoO if (count($row) >= $twoOrThree) { assertType('list{0: int, 1: string|null, 2?: int|null, 3?: float|null}', $row); } else { - assertType('array{string}|list{0: int, 1?: string|null, 2?: int|null, 3?: float|null}', $row); + assertType('array{0: int, 1?: string|null}|array{string}', $row); } if (count($row) >= $tenOrEleven) { @@ -372,7 +372,7 @@ protected function testOptionalKeysInUnionListWithIntRange($row, $listRow, $twoO if (count($row) >= $maxThree) { assertType('array{string}|list{0: int, 1?: string|null, 2?: int|null, 3?: float|null}', $row); } else { - assertType('array{string}|list{0: int, 1?: string|null, 2?: int|null, 3?: float|null}', $row); + assertType('array{0: int, 1?: string|null}|array{string}', $row); } if (count($row) >= $threeOrMoreInRangeLimit) { diff --git a/tests/PHPStan/Analyser/nsrt/list-shapes.php b/tests/PHPStan/Analyser/nsrt/list-shapes.php index 62313ca8e77..8ea8b4c9cea 100644 --- a/tests/PHPStan/Analyser/nsrt/list-shapes.php +++ b/tests/PHPStan/Analyser/nsrt/list-shapes.php @@ -21,6 +21,6 @@ public function bar($l1, $l2, $l3, $l4, $l5, $l6): void assertType("array{'a'}", $l3); assertType("array{'a', 'b'}", $l4); assertType("array{0: 'a', 1?: 'b'}", $l5); - assertType("array{'a', 'b'}", $l6); + assertType("array{'a', 'b', ...}", $l6); } } diff --git a/tests/PHPStan/Analyser/nsrt/mb-convert-encoding-php8.php b/tests/PHPStan/Analyser/nsrt/mb-convert-encoding-php8.php index e96f8f0e1a0..b1366edd00a 100644 --- a/tests/PHPStan/Analyser/nsrt/mb-convert-encoding-php8.php +++ b/tests/PHPStan/Analyser/nsrt/mb-convert-encoding-php8.php @@ -8,6 +8,7 @@ * @param list $stringList * @param list $intList * @param 'foo'|'bar'|array{foo: string, bar: int, baz: 'foo'}|bool $union + * @param array{'FOO', ...} $unsealedEncodings */ function test_mb_convert_encoding( mixed $mixed, @@ -19,6 +20,7 @@ function test_mb_convert_encoding( array $intList, string|array|bool $union, int $int, + array $unsealedEncodings, ): void { \PHPStan\Testing\assertType('array|string', mb_convert_encoding($mixed, 'UTF-8')); \PHPStan\Testing\assertType('string', mb_convert_encoding($constantString, 'UTF-8')); @@ -45,4 +47,9 @@ function test_mb_convert_encoding( \PHPStan\Testing\assertType('string|false', mb_convert_encoding($string, 'UTF-8', 'auto')); \PHPStan\Testing\assertType('string|false', mb_convert_encoding($string, 'UTF-8', ' AUTO ')); + + // One explicit encoding, but the unsealed extras can add more, so the + // from-encoding list may hold 2+ candidates → auto-detect → false is + // possible. + \PHPStan\Testing\assertType('string|false', mb_convert_encoding($string, 'UTF-8', $unsealedEncodings)); }; diff --git a/tests/PHPStan/Analyser/nsrt/minmax.php b/tests/PHPStan/Analyser/nsrt/minmax.php index d4cbb77c446..0295aa58177 100644 --- a/tests/PHPStan/Analyser/nsrt/minmax.php +++ b/tests/PHPStan/Analyser/nsrt/minmax.php @@ -18,6 +18,18 @@ function dummy5(int $i, int $j): void assertType('array{1: true}', array_filter([false, true])); } +/** + * @param array{1, 2, ...} $unsealed + */ +function unsealedMinMax(array $unsealed): void +{ + // The unsealed `` extras can be any int, so min/max of + // `{1, 2} ∪ extras` is unbounded — the explicit `1`/`2` must not be + // reported as the result. + assertType('int', min($unsealed)); + assertType('int', max($unsealed)); +} + function dummy6(string $s, string $t): void { assertType('array{0?: non-falsy-string, 1?: non-falsy-string}', array_filter([$s, $t])); } diff --git a/tests/PHPStan/Analyser/nsrt/narrow-tagged-union.php b/tests/PHPStan/Analyser/nsrt/narrow-tagged-union.php index 8ecf3438e77..b8bdfe121c8 100644 --- a/tests/PHPStan/Analyser/nsrt/narrow-tagged-union.php +++ b/tests/PHPStan/Analyser/nsrt/narrow-tagged-union.php @@ -101,7 +101,7 @@ public function arrayIntRangeSize(): void } if (count($x) === 1) { - assertType("array{'xy'}|array{0: 'ab', 1?: 'xy'}", $x); + assertType("array{'ab'}|array{'xy'}", $x); } else { assertType("array{}|array{0: 'ab', 1?: 'xy'}", $x); } diff --git a/tests/PHPStan/Analyser/nsrt/unsealed-array-shapes.php b/tests/PHPStan/Analyser/nsrt/unsealed-array-shapes.php new file mode 100644 index 00000000000..58fe90c4603 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/unsealed-array-shapes.php @@ -0,0 +1,447 @@ +} $b + * @param array{a: int, ...} $c + * @param list{int, string, ...} $d + * @param list{int, string, 2?: string, 3?: string, ...} $e + * @param list{int, string, ...} $f + * @param list{int, string, 2?: string, 3?: string, ...} $g + */ + public function doFoo(array $a, array $b, array $c, array $d, array $e, array $f, array $g): void + { + assertType('array{a: int, ...}', $a); + foreach ($a as $k => $v) { + assertType('(int|string)', $k); + assertType('mixed', $v); + } + + assertType('array{a: int, ...}', $b); + foreach ($b as $k => $v) { + assertType('string', $k); + assertType('float|int', $v); + } + assertType('array{a: int, ...}', $c); + foreach ($c as $k => $v) { + assertType('(int|string)', $k); + assertType('float|int', $v); + } + + assertType('array{int, string, ...}', $d); + foreach ($d as $k => $v) { + assertType('int<0, max>', $k); + assertType('float|int|string', $v); + } + + assertType('list{0: int, 1: string, 2?: string, 3?: string, ...}', $e); + foreach ($e as $k => $v) { + assertType('int<0, max>', $k); + assertType('float|int|string', $v); + } + + assertType('array{int, string, ...}', $f); + foreach ($f as $k => $v) { + assertType('int<0, max>', $k); + assertType('mixed', $v); + } + + assertType('list{0: int, 1: string, 2?: string, 3?: string, ...}', $g); + foreach ($e as $k => $v) { + assertType('int<0, max>', $k); + assertType('float|int|string', $v); + } + } + + /** + * @param array{a: int, ...} $a + * @return void + */ + public function wrongKeyButResolvedToIntString(array $a): void + { + assertType('array{a: int, ...}', $a); + } + + /** + * @param array{...} $a + * @param array{a: int, ...<'b'|'c', string>} $b + * @param array{a: int, b: float, ...<'b'|'c', string>} $c + */ + public function edgeCases(array $a, array $b, array $c): void + { + assertType('array', $a); + assertType('array{a: int, b?: string, c?: string}', $b); + assertType('array{a: int, b: float|string, c?: string}', $c); + } + + /** + * @param array $a + * @param array $b + * @param array $c + * @return void + */ + public function generalArray(array $a, array $b, array $c): void + { + $a[1] = 'foo'; + assertType("non-empty-array&hasOffsetValue(1, 'foo')", $a); + + $b[1] = 'foo'; + assertType("non-empty-array<1|string, string>&hasOffsetValue(1, 'foo')", $b); + + $c['foo'] = 1; + assertType("non-empty-array&hasOffsetValue('foo', 1)", $c); + } + + public function sealedBecomesUnsealed(string $s, int $i): void + { + $a = []; + $a[] = 5; + assertType('array{5}', $a); + $a[$s] = 6; + assertType('array{5, ...}', $a); + $a[$i] = 7; + assertType('array{5|7, ...|int<1, max>|string, 6|7>}', $a); + + $b = []; + $b[$s] = 1; + assertType('non-empty-array', $b); + + $b[$i] = 2; + assertType('non-empty-array', $b); + + $c = [ + 1 => 'foo', + $s => 'bar', + ]; + assertType("array{1: 'foo', ...}", $c); + + $d = [ + $s => 'foo', + 1 => 'bar', + ]; + assertType("array{1: 'bar', ...}", $d); + + $e = [ + $s => 'foo', + ]; + assertType('non-empty-array', $e); + } + + /** + * Loop iteration's `generalizeType` previously widened the integer key + * of a constant array shape to `int<0, max>` whenever the prev/current + * iterations had different (but finite) key sets. With the fix that + * keeps the constant-array key union when both shapes are sealed, + * loop-bounded counters stay within their actual range. + */ + public function loopBoundedCounter(): void + { + $arr = []; + for ($i = 0; $i < 5; $i++) { + $arr[$i] = 'v'; + } + assertType("non-empty-array, 'v'>", $arr); + } + + public function loopBoundedCounterWithCondition(): void + { + $arr = []; + for ($i = 0; $i < 5; $i++) { + if (rand()) { + $arr[$i] = 'v'; + } + } + assertType("array, 'v'>", $arr); + } + + /** + * The existing `'x'` key keeps its sealed slot through all iterations + * while the int counter grows; generalize merges the two sealed shapes + * via key union (no widening to `int<0, max>`). + */ + public function loopWithExistingSealedKey(): void + { + $arr = ['x' => 0]; + for ($i = 0; $i < 5; $i++) { + $arr[$i] = $i; + } + assertType("non-empty-array<'x'|int<0, 4>, int<0, 4>>", $arr); + } + + /** + * Each iteration the body assigns a sealed constant key, then a + * non-constant offset — that second assignment promotes the array + * from sealed to unsealed (folding the unknown offset/value into the + * unsealed extras). The iteration's converged shape stays bounded by + * the loop's cond instead of widening to `int<0, max>`. + */ + public function loopSealedBecomesUnsealedEachIteration(string $s): void + { + $arr = []; + for ($i = 0; $i < 3; $i++) { + $arr[$i] = 'sealed'; + $arr[$s . '_' . $i] = 'unsealed'; + } + assertType("non-empty-array|non-falsy-string, 'sealed'|'unsealed'>", $arr); + } + + /** + * Starting from a PHPDoc-declared unsealed shape, a loop adds further + * non-constant entries. The sealed prefix (`a`) survives, the existing + * unsealed extras get unioned with the loop's per-iteration extras. + */ + public function loopMergesUnsealedExtras(string $key): void + { + /** @var array{a: int, ...} $arr */ + $arr = ['a' => 1]; + for ($i = 0; $i < 3; $i++) { + $arr[$key . $i] = $i; + } + assertType("array{a: int, ...}", $arr); + } + + /** + * Joining two unsealed shapes with disjoint sealed prefixes via + * scope merging collapses the result to a general array of + * `string => int` — neither sealed prefix survives because each is + * optional from the other branch's perspective and the unsealed + * extras of both sides cover the same key/value space. + * + * @param array{a: int, ...} $u1 + * @param array{b: int, ...} $u2 + */ + public function twoUnsealedJoined(array $u1, array $u2, bool $cond): void + { + if ($cond) { + $arr = $u1; + } else { + $arr = $u2; + } + assertType("non-empty-array", $arr); + } + + /** + * `array_search` on a constant array shape with unsealed extras must + * also consider the extras: a strict needle that matches the unsealed + * value type makes the unsealed key type a possible result. The + * extras are always uncertain (zero or more entries) so `false` stays + * a possible result even when an explicit value definitely matches. + * + * @param array{a: 'foo', b: 'bar', ...} $arr + */ + public function searchUnsealedExclusiveValue(array $arr): void + { + assertType("'a'", array_search('foo', $arr, true)); + assertType("'b'", array_search('bar', $arr, true)); + assertType("string|false", array_search('baz', $arr, true)); + assertType("false", array_search('quux', $arr, true)); + } + + /** + * Strict search: when the unsealed value type is a different type + * than any explicit value, only one side can match a given needle. + * + * @param array{a: int, b: string, ...} $arr + */ + public function searchUnsealedStrictTypes(array $arr): void + { + assertType("int|false", array_search(true, $arr, true)); + assertType("'a'|false", array_search(42, $arr, true)); + assertType("'b'|false", array_search('hi', $arr, true)); + } + + /** + * Both explicit values and the unsealed extras can match a generic + * `int` needle. The explicit string keys `'a'`/`'b'` simplify into + * the broader `string` from the unsealed extras' key type, so the + * union collapses to `string|false`. + * + * @param array{a: int, b: int, ...} $arr + */ + public function searchUnsealedNeedleInBothSides(array $arr): void + { + assertType("string|false", array_search(99, $arr, true)); + } + + /** + * Non-strict search skips the value-type filter — the unsealed + * extras are always considered, since loose comparison can succeed + * across many otherwise-mismatched value pairs. + * + * @param array{a: 1, b: 2, ...} $arr + */ + public function searchUnsealedNonStrict(array $arr): void + { + // `'a'` is a definite hit (constant value matches needle exactly, + // not optional) so `false` is excluded; the explicit-key match + // then merges into the unsealed-extras' broader `string` key. + assertType("string", array_search(1, $arr, false)); + assertType("string|false", array_search(99, $arr, false)); + } + + /** + * Sealed array shape: searchArray's unsealed branch is a no-op + * (the `[NEVER, NEVER]` extras marker is excluded). Only the + * explicit keys are considered. + */ + public function searchSealed(): void + { + $arr = ['a' => 'foo', 'b' => 'bar']; + assertType("'a'", array_search('foo', $arr, true)); + assertType("false", array_search('baz', $arr, true)); + } + +} + +class Generics +{ + + /** + * @template T + * @param T $a + * @return array{a: int, ...} + */ + public function replace($a): array + { + + } + + /** + * @template T + * @param array{a: int, ...} $a + * @return T + */ + public function infer(array $a) + { + + } + +} + +/** + * @param Generics $g + * @param array{a: 1, b: 2, ...} $a + * @param array{a: 1, b: 2, ...} $b + * @param array $c + * @param array $d + * @return void + */ +function doFoo(Generics $g, array $a, array $b, array $c, array $d): void { + assertType('array{a: int, ...}', $g->replace(new stdClass())); + assertType('1|2|3', $g->infer([1, 2, 3, 'a' => 4])); + assertType('stdClass', $g->infer($a)); + assertType('*NEVER*', $g->infer($b)); + assertType('stdClass', $g->infer($c)); + assertType('stdClass', $g->infer($d)); +}; + +/** + * @param array{a: int, b: string, ...} $a + * @return void + */ +function unsealedForeach(array $a): void +{ + $i = 0; + foreach ($a as $k => $v) { + assertType("'a'|'b'|int", $k); + assertType('float|int|string', $v); + $i++; + } + + assertType('int<2, max>', $i); +} + +/** + * Reading an offset from an unsealed array shape: explicit keys fully own + * their slots (PHP keys are unique), so the unsealed extras only contribute + * at offsets that fall outside the explicit set. Without this distinction, + * `$a['a']` would widen to `int|string` instead of the precise `int`. + * + * @param array{a: int, b: int, ...} $a + * @param array{a: int, ...} $b + * @param array{a: int, ...} $c + * @param array{a: int, ...} $d + * @param array{a: int, ...} $e + */ +function unsealedOffsetAccess(array $a, array $b, array $c, array $d, array $e, string $s, int $i): void +{ + // Explicit key fully covers offset → only the explicit value + assertType('int', $a['a']); + assertType('int', $a['b']); + + // Offset is a string constant not in the explicit set → unsealed value only + assertType('string', $b['z']); + + // Offset is a general string: 'a' part hits the explicit slot, every other + // string falls through to the unsealed extras → union of both + assertType('int|string', $c[$s]); + + // Unsealed key is `int`, offset is a non-matching string → only the + // explicit slot can contribute (string offset can't match unsealed `int` key) + assertType('int', $d['a']); + + // Open shape (`...` ≡ `...`): an int offset can never + // hit the explicit string key 'a', so it's purely from the unsealed extras + assertType('mixed', $e[$i]); +} + +/** + * `array_key_exists`/`isset` over an unsealed shape should promote the + * matching key out of the unsealed extras into a definite explicit slot, + * carrying the unsealed value type. The remaining unsealed extras stay + * around — there can still be additional entries at other keys. + * + * @param array{a: int, ...} $stringExtras + * @param array{a: int, ...} $intExtras + * @param array{a: int, ...} $open + * @param array{a?: int, ...} $optionalExplicit + */ +function unsealedNarrowing(array $stringExtras, array $intExtras, array $open, array $optionalExplicit, int $i): void +{ + // Promote 'b' (matches the unsealed string key) into the explicit set + // with the unsealed value type `float`. The unsealed extras remain. + if (array_key_exists('b', $stringExtras)) { + assertType('array{a: int, b: float, ...}', $stringExtras); + } + + if (isset($stringExtras['b'])) { + // `isset` additionally rules out null at the offset — but `float` + // already excludes null, so the shape is the same as above. + assertType('array{a: int, b: float, ...}', $stringExtras); + } + + // Same idea with an integer-keyed unsealed range: 5 gets pulled out. + if (array_key_exists(5, $intExtras)) { + assertType('array{a: int, 5: float, ...}', $intExtras); + } + + // Open shape `...` is `...`: any constant key matches + // the unsealed range, so we promote with `mixed`. + if (array_key_exists('foo', $open)) { + assertType('array{a: int, foo: mixed, ...}', $open); + } + + // Existing optional explicit key — promotion is the existing + // `optional → required` flip; no new key is added. + if (array_key_exists('a', $optionalExplicit)) { + assertType('array{a: int, ...}', $optionalExplicit); + } + + // `isset` produces a `HasOffsetValueType` whose offset doesn't match the + // only explicit key (`'a'`) and lies outside the unsealed key range + // (`int 5` vs. `string` extras). The array can't hold this offset under + // any concrete instance, so the truthy branch's intersection collapses + // to `*NEVER*`. + if (isset($stringExtras[5])) { + assertType('*NEVER*', $stringExtras); + } +} diff --git a/tests/PHPStan/Analyser/nsrt/unsealed-derivations.php b/tests/PHPStan/Analyser/nsrt/unsealed-derivations.php new file mode 100644 index 00000000000..6b85cae6bcf --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/unsealed-derivations.php @@ -0,0 +1,551 @@ +} $arr + */ + public function filterUnsealed(array $arr): void + { + // `array_filter` drops falsey entries from both the explicit slot + // and the unsealed extras. The unsealed value type must have the + // falsey union (`null|false|0|0.0|''|'0'|[]`) subtracted too — + // here `int|null` collapses to non-zero `int`. + assertType( + 'array{a?: int|int<1, max>, ...|int<1, max>>}', + array_filter($arr), + ); + } + +} + +class ChangeKeyCase +{ + + /** + * @param array{Foo: int, ...} $arr + */ + public function lowerCaseUnsealed(array $arr): void + { + // `array_change_key_case` folds explicit constant-string keys. + // The unsealed slot must be carried through — and the unsealed + // key picks up the matching `lowercase-string` accessory (every + // key after CASE_LOWER is lowercase). + assertType( + 'array{foo: int, ...}', + array_change_key_case($arr, CASE_LOWER), + ); + } + + /** + * @param array{Foo: int, ...} $arr + */ + public function upperCaseUnsealed(array $arr): void + { + assertType( + 'array{FOO: int, ...}', + array_change_key_case($arr, CASE_UPPER), + ); + } + + /** + * @param array{Foo: int, ...} $arr + */ + public function mixedKeyUnsealed(array $arr): void + { + // Int keys aren't affected by `array_change_key_case`; only the + // string portion of the unsealed key picks up the accessory. + assertType( + 'array{foo: int, ...}', + array_change_key_case($arr, CASE_LOWER), + ); + } + + /** + * @param array{a: int, ...} $arr + */ + public function lowercaseToUpper(array $arr): void + { + // CASE_UPPER on a `lowercase-string` unsealed key drops the + // lowercase property and replaces it with uppercase — + // `array_change_key_case` rewrites every key, so the prior case + // constraint no longer holds. + assertType( + 'array{A: int, ...}', + array_change_key_case($arr, CASE_UPPER), + ); + } + + /** + * @param array{a: int, ...} $arr + */ + public function preserveNonEmpty(array $arr): void + { + // Case-folding keeps the string length unchanged, so non-empty + // is preserved alongside the new case accessory on the unsealed + // key. + assertType( + 'array{a: int, ...}', + array_change_key_case($arr, CASE_LOWER), + ); + } + + /** + * @param array{Foo: int, BAR: string, ...} $arr + */ + public function multipleConstantKeys(array $arr): void + { + // Each `ConstantStringType` explicit key is independently folded. + assertType( + 'array{foo: int, bar: string, ...}', + array_change_key_case($arr, CASE_LOWER), + ); + } + + /** + * @param array{Foo: int, foo: string} $arr + */ + public function collidingConstantKeys(array $arr): void + { + // `Foo` and `foo` both fold to `foo`. PHP semantics: the later + // pair overwrites the earlier (the `foo: string` entry wins). + assertType( + 'array{foo: string}', + array_change_key_case($arr, CASE_LOWER), + ); + } + + /** + * @param array{Foo: int} $arr + */ + public function unknownCase(array $arr, int $case): void + { + // Non-constant `$case` — could be either CASE_LOWER or CASE_UPPER. + // `Foo` folds to `'foo'|'FOO'` and the builder splits the union + // into two optional keys, with at least one guaranteed present. + assertType( + 'non-empty-array{foo?: int, FOO?: int}', + array_change_key_case($arr, $case), + ); + } + +} + +class ArrayUnshift +{ + + /** + * @param list{int, string, ...} $arr + */ + public function prependPreservesUnsealed(array $arr): void + { + array_unshift($arr, true, null); + // `array_unshift` prepends the new values and re-indexes; the + // original list's unsealed tail (`...`) must be carried + // through so the result still tracks "extra entries are + // `float`". + assertType('array{true, null, int, string, ...}', $arr); + } + +} + +class ArrayFilterCallback +{ + + /** + * @param array{a: int, ...} $arr + */ + public function preserveUnsealed(array $arr): void + { + // `array_filter` with a callback narrows each entry by the + // predicate's truthy projection. The unsealed slot must follow + // the same narrowing — `int|null` minus `null` is `int`. + assertType( + 'array{a: int, ...}', + array_filter($arr, fn ($v) => $v !== null), + ); + } + +} + +class ArrayColumn +{ + + /** + * @param list{array{name: string, age: int}, array{name: string, age: int}, ...} $rows + */ + public function preserveUnsealed(array $rows): void + { + // `array_column` plucks the named field from every row, + // including rows from the unsealed tail. Each row's `name` + // is `string`, so the unsealed slot of the result is `string` + // at the original integer keys. + assertType( + 'array{string, string, ...}', + array_column($rows, 'name'), + ); + } + +} + +class FilterVarArray +{ + + /** + * @param array{a: int, ...} $arr + */ + public function preserveUnsealed(array $arr): void + { + // `filter_var_array` applies the filter to every value, + // including the unsealed extras. The unsealed value type + // becomes the filter's projected output (`int|false` for + // `FILTER_VALIDATE_INT` over `mixed`). + assertType( + 'array{a: int, ...}', + filter_var_array($arr, FILTER_VALIDATE_INT), + ); + } + +} + +class ArrayMerge +{ + + /** + * @param array{a: int, ...} $arr + */ + public function mergePreservesUnsealed(array $arr): void + { + // `array_merge` with a sealed second arg appends `b` and keeps + // the unsealed extras from the first array. + assertType( + 'array{a: int, b: true, ...}', + array_merge($arr, ['b' => true]), + ); + } + +} + +class ArrayReplace +{ + + /** + * @param array{a: int, ...} $arr + */ + public function replacePreservesUnsealed(array $arr): void + { + // `array_replace` overwrites by key, but the unsealed extras + // from `$arr` survive at any unmentioned keys. + assertType( + 'array{a: int, b: true, ...}', + array_replace($arr, ['b' => true]), + ); + } + +} + +class UnpackingMakesUnsealed +{ + + /** + * @param list{int, string} $sealed + * @param list $unknownItems + */ + public function unshiftListWithUnpack(array $sealed, array $unknownItems): void + { + array_unshift($sealed, ...$unknownItems); + // A list can't keep precise indices when an unknown number of + // values are prepended — every original index is shifted by an + // unknown amount. The shape collapses to "non-empty list of the + // value union" (current behavior, kept). + assertType('non-empty-list', $sealed); + } + + /** + * @param array{a: int, b: string} $sealed + * @param list $unknownItems + */ + public function unshiftAssocWithUnpack(array $sealed, array $unknownItems): void + { + array_unshift($sealed, ...$unknownItems); + // Associative input — string keys are preserved exactly. The + // unknown number of prepended values is reflected as an unsealed + // `int` slot on the resulting shape. + assertType('array{a: int, b: string, ...}', $sealed); + } + +} + +class FlipArray +{ + + /** + * @param array{a: 'foo', b: 'bar', ...} $arr + */ + public function flipPreservesUnsealed(array $arr): void + { + // `array_flip` swaps keys and values pair-by-pair, so the + // unsealed `` becomes `` — the value + // type passes through `toArrayKey()` to land in the new key slot. + assertType("array{foo: 'a', bar: 'b', ...}", array_flip($arr)); + } + + /** + * @param array{a: 1, b: 2} $sealed + */ + public function flipSealedStaysSealed(array $sealed): void + { + assertType("array{1: 'a', 2: 'b'}", array_flip($sealed)); + } + +} + +class FillKeysArray +{ + + /** + * @param array{a: 'foo', b: 'bar', ...} $arr + */ + public function fillKeysPreservesUnsealed(array $arr): void + { + // `array_fill_keys` uses the source's *values* as the result's + // keys (with `toArrayKey()` applied). Explicit `'foo'`, `'bar'` + // become keys; the unsealed `` contributes string + // values that become the result's unsealed key range — the new + // unsealed entry is `` with the fill value `42`. + assertType('array{foo: 42, bar: 42, ...}', array_fill_keys($arr, 42)); + } + +} + +class IntersectKeyArray +{ + + /** + * @param array{a: 1, b: 2, ...} $arr + * @param array $other + */ + public function intersectWithStringKeys(array $arr, array $other): void + { + // `array_intersect_key` keeps entries from `$arr` whose key is + // also a key of `$other`. The explicit `a`/`b` survive as + // optional (we don't know that `$other` has them). The unsealed + // `` range intersects with `` on + // the key side — `int ∩ string` is empty — so the unsealed slot + // disappears entirely. + assertType('array{a?: 1, b?: 2}', array_intersect_key($arr, $other)); + } + + /** + * @param array{a: 1, b: 2, ...} $arr + * @param array $other + */ + public function intersectWithIntKeys(array $arr, array $other): void + { + // `$other`'s int keys can match `$arr`'s unsealed int range, + // so the unsealed slot survives but its key narrows to the + // intersection (`int`). The explicit `a`/`b` are dropped — they + // are string keys, and `$other`'s key type is int. With no + // explicit keys left, the builder collapses the result to a + // plain `array`. + assertType('array', array_intersect_key($arr, $other)); + } + +} + +class ReverseArray +{ + + /** + * @param array{a: 1, b: 2, ...} $arr + */ + public function reversePreservesUnsealed(array $arr): void + { + // `array_reverse` only changes element order; the unsealed slot + // describes "zero or more extras at unspecified positions" — the + // reversed value has the same property. + assertType('array{b: 2, a: 1, ...}', array_reverse($arr)); + } + +} + +class PopArray +{ + + /** + * @param array{a: 1, b: 2, ...} $arr + */ + public function popMakesLastKeyOptional(array $arr): void + { + array_pop($arr); + // `array_pop` removes the last element. With unsealed extras, the + // last element might be one of those extras (the unsealed slot + // silently shrinks by one) or — if no extras existed — the last + // explicit key. So the last explicit key becomes optional and + // the unsealed slot is preserved (still "zero or more"). + assertType('array{a: 1, b?: 2, ...}', $arr); + } + +} + +class ShiftArray +{ + + /** + * @param array{a: 1, b: 2, ...} $arr + */ + public function shiftPreservesUnsealed(array $arr): void + { + array_shift($arr); + // `array_shift` removes the first element. With explicit keys in + // place, that's always the leading explicit key (`a`). The + // unsealed extras live "after" the explicit ones and are + // preserved. + assertType('array{b: 2, ...}', $arr); + } + +} + +class ArrayKeysValues +{ + + /** + * @param array{a: 1, b: 2, ...} $arr + */ + public function keysFromUnsealed(array $arr): void + { + // `array_keys` returns a list of the source's keys. Explicit + // keys land in the result's value slots; the source's unsealed + // *key* type fills the new unsealed value slot. The result is + // list-shaped, so its unsealed key range is `int<0, max>` (the + // describe collapses that to the `` short form). + assertType("array{'a', 'b', ...}", array_keys($arr)); + } + + /** + * @param array{a: 1, b: 2, ...} $arr + */ + public function valuesFromUnsealed(array $arr): void + { + // `array_values` returns a list of the source's values. The + // source's unsealed *value* type fills the new unsealed value + // slot. + assertType('array{1, 2, ...}', array_values($arr)); + } + + /** + * @param array{a: 1, b: 2, ...} $arr + */ + public function keysFromUnsealedWithStringKeys(array $arr): void + { + // Source's unsealed key type is `string`, so the result's + // unsealed values are strings. + assertType("array{'a', 'b', ...}", array_keys($arr)); + } + +} + +class SliceArray +{ + + /** + * @param array{a: 1, b: 2, ...} $arr + */ + public function sliceWithinExplicit(array $arr): void + { + // Slice fits entirely within the explicit keys — the unsealed + // slot doesn't come into play and the result is sealed. + assertType("array{a: 1}", array_slice($arr, 0, 1)); + } + + /** + * @param array{a: 1, b: 2, ...} $arr + */ + public function sliceBeyondExplicit(array $arr): void + { + // Slice extends past the explicit keys: the trailing positions + // could be filled by unsealed extras (or be absent), so the + // result is unsealed and carries the source's extras slot. + assertType('array{a: 1, b: 2, ...}', array_slice($arr, 0, 5)); + } + +} + +class ChunkArray +{ + + /** + * @param array{a: 1, b: 2, c: 3, d: 4, ...} $arr + */ + public function chunkPreservingKeys(array $arr): void + { + // With real unsealed extras the precise chunk count is unknown, + // so the type falls back to a general "non-empty list of chunks + // shaped like the source". Each chunk is described by the + // source's own shape (preserveKeys=true). + assertType( + 'non-empty-list}>', + array_chunk($arr, 2, true), + ); + } + +} + +class ShuffleArray +{ + + /** + * @param array{a: 1, b: 2, ...} $arr + */ + public function shufflePreservesUnsealedValues(array $arr): void + { + // `shuffle` reorders + reindexes. Through `getValuesArray()` + // the source's unsealed value type contributes to the result's + // value union — the final degraded list type includes `string` + // alongside the explicit values `1` and `2`. + shuffle($arr); + assertType('non-empty-list<1|2|string>', $arr); + } + +} + +class SpliceArray +{ + + /** + * @param list{int, int, int, ...} $arr + */ + public function splicePreservesUnsealed(array $arr): void + { + array_splice($arr, 1, 1); + // `array_splice` removes a slice from an explicit position; + // the unsealed extras at the tail are unaffected and survive + // on the result. + assertType('array{int, int, ...}', $arr); + } + +} + +class CountNarrowing +{ + + /** + * @param list{int, string, ...} $arr + */ + public function geMinPreservesUnsealed(array $arr): void + { + if (count($arr) >= 5) { + // `count >= 5` guarantees the first 5 entries exist (the + // explicit prefix `[int, string]` plus three values from the + // unsealed `` range). Beyond five, the unsealed slot + // is preserved so further entries can still appear. + assertType('array{int, string, float, float, float, ...}', $arr); + } + } + +} diff --git a/tests/PHPStan/Analyser/reportUnsafeArrayStringKeyCastingDetect.neon b/tests/PHPStan/Analyser/reportUnsafeArrayStringKeyCastingDetect.neon new file mode 100644 index 00000000000..1ea800ca917 --- /dev/null +++ b/tests/PHPStan/Analyser/reportUnsafeArrayStringKeyCastingDetect.neon @@ -0,0 +1,2 @@ +parameters: + reportUnsafeArrayStringKeyCasting: detect diff --git a/tests/PHPStan/Analyser/reportUnsafeArrayStringKeyCastingPrevent.neon b/tests/PHPStan/Analyser/reportUnsafeArrayStringKeyCastingPrevent.neon new file mode 100644 index 00000000000..f35820e8667 --- /dev/null +++ b/tests/PHPStan/Analyser/reportUnsafeArrayStringKeyCastingPrevent.neon @@ -0,0 +1,2 @@ +parameters: + reportUnsafeArrayStringKeyCasting: prevent diff --git a/tests/PHPStan/Command/ErrorFormatter/BaselineNeonErrorFormatterTest.php b/tests/PHPStan/Command/ErrorFormatter/BaselineNeonErrorFormatterTest.php index 1f9f4bfd27d..c9463f9675a 100644 --- a/tests/PHPStan/Command/ErrorFormatter/BaselineNeonErrorFormatterTest.php +++ b/tests/PHPStan/Command/ErrorFormatter/BaselineNeonErrorFormatterTest.php @@ -383,7 +383,7 @@ public function testOutputOrdering(array $errors): void } /** - * @return Generator}> + * @return Generator, existingBaselineContent: string, expectedNewlinesCount: int}> */ public static function endOfFileNewlinesProvider(): Generator { diff --git a/tests/PHPStan/Levels/data/acceptTypes-5.json b/tests/PHPStan/Levels/data/acceptTypes-5.json index 4bb076e0554..d64081a6c04 100644 --- a/tests/PHPStan/Levels/data/acceptTypes-5.json +++ b/tests/PHPStan/Levels/data/acceptTypes-5.json @@ -129,6 +129,16 @@ "line": 494, "ignorable": true }, + { + "message": "Parameter #1 $one of method Levels\\AcceptTypes\\ArrayShapes::doBar() expects array{foo: callable(): mixed}, array given.", + "line": 577, + "ignorable": true + }, + { + "message": "Parameter #1 $one of method Levels\\AcceptTypes\\ArrayShapes::doBar() expects array{foo: callable(): mixed}, array given.", + "line": 578, + "ignorable": true + }, { "message": "Parameter #1 $one of method Levels\\AcceptTypes\\ArrayShapes::doBar() expects array{foo: callable(): mixed}, array given.", "line": 579, @@ -144,6 +154,11 @@ "line": 582, "ignorable": true }, + { + "message": "Parameter #1 $one of method Levels\\AcceptTypes\\ArrayShapes::doBar() expects array{foo: callable(): mixed}, array{foo: 'date', bar: 'date'} given.", + "line": 583, + "ignorable": true + }, { "message": "Parameter #1 $one of method Levels\\AcceptTypes\\ArrayShapes::doBar() expects array{foo: callable(): mixed}, array{foo: 'nonexistent'} given.", "line": 584, @@ -154,6 +169,11 @@ "line": 585, "ignorable": true }, + { + "message": "Parameter #1 $one of method Levels\\AcceptTypes\\ArrayShapes::doBar() expects array{foo: callable(): mixed}, non-empty-array given.", + "line": 588, + "ignorable": true + }, { "message": "Parameter #1 $static of method Levels\\AcceptTypes\\RequireObjectWithoutClassType::requireStatic() expects static(Levels\\AcceptTypes\\RequireObjectWithoutClassType), object given.", "line": 648, @@ -189,4 +209,4 @@ "line": 763, "ignorable": true } -] +] \ No newline at end of file diff --git a/tests/PHPStan/Levels/data/acceptTypes-7.json b/tests/PHPStan/Levels/data/acceptTypes-7.json index 216fad89879..c9bcbcd7517 100644 --- a/tests/PHPStan/Levels/data/acceptTypes-7.json +++ b/tests/PHPStan/Levels/data/acceptTypes-7.json @@ -104,16 +104,6 @@ "line": 543, "ignorable": true }, - { - "message": "Parameter #1 $one of method Levels\\AcceptTypes\\ArrayShapes::doBar() expects array{foo: callable(): mixed}, array given.", - "line": 577, - "ignorable": true - }, - { - "message": "Parameter #1 $one of method Levels\\AcceptTypes\\ArrayShapes::doBar() expects array{foo: callable(): mixed}, array given.", - "line": 578, - "ignorable": true - }, { "message": "Parameter #1 $one of method Levels\\AcceptTypes\\ArrayShapes::doBar() expects array{foo: callable(): mixed}, array{}|array{foo: 'date'} given.", "line": 596, @@ -169,4 +159,4 @@ "line": 756, "ignorable": true } -] +] \ No newline at end of file diff --git a/tests/PHPStan/Reflection/ConstantToFunctionParameterMapTest.php b/tests/PHPStan/Reflection/ConstantToFunctionParameterMapTest.php new file mode 100644 index 00000000000..99a4041d6c7 --- /dev/null +++ b/tests/PHPStan/Reflection/ConstantToFunctionParameterMapTest.php @@ -0,0 +1,162 @@ += 8.0')] +class ConstantToFunctionParameterMapTest extends PHPStanTestCase +{ + + public function testMapIsValid(): void + { + $map = require __DIR__ . '/../../../resources/constantToFunctionParameterMap.php'; + $this->assertIsArray($map); + + $reflectionProvider = self::createReflectionProvider(); + + foreach ($map as $entry => $parameters) { + $this->assertIsString($entry, 'Entry key must be a string.'); + $this->assertIsArray($parameters, sprintf('Parameters for %s must be an array.', $entry)); + + if (str_contains($entry, '::')) { + // Method entry: Class::method + [$className, $methodName] = explode('::', $entry, 2); + + $this->assertTrue( + $reflectionProvider->hasClass($className), + sprintf('Class %s not found in reflection (from %s).', $className, $entry), + ); + + $classReflection = $reflectionProvider->getClass($className); + $this->assertTrue( + $classReflection->hasMethod($methodName), + sprintf('Method %s not found in reflection.', $entry), + ); + + $methodReflection = $classReflection->getNativeMethod($methodName); + $variants = $methodReflection->getVariants(); + $this->assertNotEmpty($variants, sprintf('Method %s has no variants.', $entry)); + + $reflectionParameters = $variants[0]->getParameters(); + } else { + $this->assertNotSame('', $entry); + // Function entry + $nameNode = new Name($entry); + $this->assertTrue( + $reflectionProvider->hasFunction($nameNode, null), + sprintf('Function %s() not found in reflection.', $entry), + ); + + $functionReflection = $reflectionProvider->getFunction($nameNode, null); + $variants = $functionReflection->getVariants(); + $this->assertNotEmpty($variants, sprintf('Function %s() has no variants.', $entry)); + + $reflectionParameters = $variants[0]->getParameters(); + } + + $reflectionParameterNames = []; + foreach ($reflectionParameters as $reflectionParameter) { + $reflectionParameterNames[] = $reflectionParameter->getName(); + } + + foreach ($parameters as $parameterName => $config) { + $this->assertIsString($parameterName, sprintf('Parameter name for %s must be a string.', $entry)); + $this->assertContains( + $parameterName, + $reflectionParameterNames, + sprintf( + 'Parameter $%s not found in %s. Available parameters: $%s', + $parameterName, + $entry, + implode(', $', $reflectionParameterNames), + ), + ); + + $this->assertIsArray($config, sprintf('Config for %s($%s) must be an array.', $entry, $parameterName)); + $this->assertArrayHasKey('type', $config, sprintf('Missing "type" key for %s($%s).', $entry, $parameterName)); + $this->assertContains($config['type'], ['single', 'bitmask'], sprintf('Invalid type "%s" for %s($%s).', $config['type'], $entry, $parameterName)); + $this->assertArrayHasKey('constants', $config, sprintf('Missing "constants" key for %s($%s).', $entry, $parameterName)); + $this->assertIsArray($config['constants'], sprintf('Constants for %s($%s) must be an array.', $entry, $parameterName)); + $this->assertNotEmpty($config['constants'], sprintf('Constants for %s($%s) must not be empty.', $entry, $parameterName)); + + foreach ($config['constants'] as $constantName) { + $this->assertIsString($constantName, sprintf('Constant name for %s($%s) must be a string.', $entry, $parameterName)); + + if (str_contains($constantName, '::')) { + // Class constant: Class::CONSTANT + [$constClassName, $constName] = explode('::', $constantName, 2); + $this->assertTrue( + $reflectionProvider->hasClass($constClassName), + sprintf('Class %s not found in reflection (constant %s used in %s($%s)).', $constClassName, $constantName, $entry, $parameterName), + ); + $constClassReflection = $reflectionProvider->getClass($constClassName); + $this->assertTrue( + $constClassReflection->hasConstant($constName), + sprintf('Constant %s not found in reflection (used in %s($%s)).', $constantName, $entry, $parameterName), + ); + } else { + $this->assertNotSame('', $constantName); + // Global constant + $constantNameNode = new Name($constantName); + $this->assertTrue( + $reflectionProvider->hasConstant($constantNameNode, null), + sprintf('Constant %s (used in %s($%s)) not found in reflection.', $constantName, $entry, $parameterName), + ); + } + } + + $allowedKeys = ['type', 'constants', 'exclusiveGroups']; + foreach (array_keys($config) as $key) { + $this->assertContains($key, $allowedKeys, sprintf('Unknown key "%s" in config for %s($%s).', $key, $entry, $parameterName)); + } + + if (!isset($config['exclusiveGroups'])) { + continue; + } + + $this->assertSame('bitmask', $config['type'], sprintf('exclusiveGroups only makes sense for bitmask type in %s($%s).', $entry, $parameterName)); + $this->assertIsArray($config['exclusiveGroups']); + + foreach ($config['exclusiveGroups'] as $groupIndex => $group) { + $this->assertIsArray($group, sprintf('Exclusive group #%d for %s($%s) must be an array.', $groupIndex, $entry, $parameterName)); + $this->assertGreaterThanOrEqual(2, count($group), sprintf('Exclusive group #%d for %s($%s) must have at least 2 constants.', $groupIndex, $entry, $parameterName)); + + foreach ($group as $constantName) { + $this->assertContains( + $constantName, + $config['constants'], + sprintf( + 'Constant %s in exclusive group #%d for %s($%s) is not in the constants list.', + $constantName, + $groupIndex, + $entry, + $parameterName, + ), + ); + } + } + } + } + } + + public static function getAdditionalConfigFiles(): array + { + return array_merge( + parent::getAdditionalConfigFiles(), + [ + __DIR__ . '/constantToFunctionParameterMap.neon', + ], + ); + } + +} diff --git a/tests/PHPStan/Reflection/ParameterAllowedConstantsTest.php b/tests/PHPStan/Reflection/ParameterAllowedConstantsTest.php new file mode 100644 index 00000000000..89f135dc78d --- /dev/null +++ b/tests/PHPStan/Reflection/ParameterAllowedConstantsTest.php @@ -0,0 +1,285 @@ += 8.0')] +class ParameterAllowedConstantsTest extends PHPStanTestCase +{ + + public function testJsonEncodeFlagsAllowsJsonConstant(): void + { + $reflectionProvider = self::createReflectionProvider(); + $function = $reflectionProvider->getFunction(new Name('json_encode'), null); + $flagsParam = $function->getVariants()[0]->getParameters()[1]; + + $this->assertSame('flags', $flagsParam->getName()); + $this->assertNotNull($flagsParam->getAllowedConstants()); + $this->assertTrue($flagsParam->getAllowedConstants()->isBitmask()); + + $jsonThrowOnError = $reflectionProvider->getConstant(new Name('JSON_THROW_ON_ERROR'), null); + $result = $flagsParam->checkAllowedConstants([$jsonThrowOnError]); + $this->assertTrue($result->isOk()); + + $sortRegular = $reflectionProvider->getConstant(new Name('SORT_REGULAR'), null); + $result = $flagsParam->checkAllowedConstants([$sortRegular]); + $this->assertFalse($result->isOk()); + $this->assertCount(1, $result->getDisallowedConstants()); + $this->assertSame('SORT_REGULAR', $result->getDisallowedConstants()[0]->getName()); + } + + public function testJsonDecodeDoesNotAllowEncodeOnlyConstants(): void + { + $reflectionProvider = self::createReflectionProvider(); + $function = $reflectionProvider->getFunction(new Name('json_decode'), null); + $flagsParam = $function->getVariants()[0]->getParameters()[3]; + + $this->assertSame('flags', $flagsParam->getName()); + + $jsonPrettyPrint = $reflectionProvider->getConstant(new Name('JSON_PRETTY_PRINT'), null); + $result = $flagsParam->checkAllowedConstants([$jsonPrettyPrint]); + $this->assertFalse($result->isOk()); + $this->assertCount(1, $result->getDisallowedConstants()); + + $jsonThrowOnError = $reflectionProvider->getConstant(new Name('JSON_THROW_ON_ERROR'), null); + $result = $flagsParam->checkAllowedConstants([$jsonThrowOnError]); + $this->assertTrue($result->isOk()); + } + + public function testSortFlagsExclusiveGroups(): void + { + $reflectionProvider = self::createReflectionProvider(); + $function = $reflectionProvider->getFunction(new Name('sort'), null); + $flagsParam = $function->getVariants()[0]->getParameters()[1]; + + $this->assertSame('flags', $flagsParam->getName()); + + $config = $flagsParam->getAllowedConstants(); + $this->assertNotNull($config); + $this->assertTrue($config->isBitmask()); + $this->assertCount(1, $config->getExclusiveGroups()); + $this->assertSame( + ['SORT_REGULAR', 'SORT_NUMERIC', 'SORT_STRING', 'SORT_LOCALE_STRING', 'SORT_NATURAL'], + $config->getExclusiveGroups()[0], + ); + + $sortFlagCase = $reflectionProvider->getConstant(new Name('SORT_FLAG_CASE'), null); + $result = $flagsParam->checkAllowedConstants([$sortFlagCase]); + $this->assertTrue($result->isOk()); + } + + public function testHtmlspecialcharsMultipleExclusiveGroups(): void + { + $reflectionProvider = self::createReflectionProvider(); + $function = $reflectionProvider->getFunction(new Name('htmlspecialchars'), null); + $flagsParam = $function->getVariants()[0]->getParameters()[1]; + + $this->assertSame('flags', $flagsParam->getName()); + + $config = $flagsParam->getAllowedConstants(); + $this->assertNotNull($config); + $this->assertCount(2, $config->getExclusiveGroups()); + $this->assertSame(['ENT_COMPAT', 'ENT_QUOTES', 'ENT_NOQUOTES'], $config->getExclusiveGroups()[0]); + $this->assertSame(['ENT_HTML401', 'ENT_XML1', 'ENT_XHTML', 'ENT_HTML5'], $config->getExclusiveGroups()[1]); + } + + public function testSingleTypeParameter(): void + { + $reflectionProvider = self::createReflectionProvider(); + $function = $reflectionProvider->getFunction(new Name('round'), null); + $modeParam = $function->getVariants()[0]->getParameters()[2]; + + $this->assertSame('mode', $modeParam->getName()); + + $config = $modeParam->getAllowedConstants(); + $this->assertNotNull($config); + $this->assertFalse($config->isBitmask()); + $this->assertSame([], $config->getExclusiveGroups()); + + $halfUp = $reflectionProvider->getConstant(new Name('PHP_ROUND_HALF_UP'), null); + $result = $modeParam->checkAllowedConstants([$halfUp]); + $this->assertTrue($result->isOk()); + } + + public function testUnmappedParameterReturnsOk(): void + { + $reflectionProvider = self::createReflectionProvider(); + $function = $reflectionProvider->getFunction(new Name('strlen'), null); + $param = $function->getVariants()[0]->getParameters()[0]; + + $this->assertNull($param->getAllowedConstants()); + + $anyConstant = $reflectionProvider->getConstant(new Name('JSON_THROW_ON_ERROR'), null); + $result = $param->checkAllowedConstants([$anyConstant]); + $this->assertTrue($result->isOk()); + } + + public function testMethodWithGlobalConstants(): void + { + $reflectionProvider = self::createReflectionProvider(); + $class = $reflectionProvider->getClass('finfo'); + $method = $class->getNativeMethod('file'); + $flagsParam = $method->getVariants()[0]->getParameters()[1]; + + $this->assertSame('flags', $flagsParam->getName()); + $this->assertNotNull($flagsParam->getAllowedConstants()); + $this->assertTrue($flagsParam->getAllowedConstants()->isBitmask()); + + $fileinfoMime = $reflectionProvider->getConstant(new Name('FILEINFO_MIME'), null); + $result = $flagsParam->checkAllowedConstants([$fileinfoMime]); + $this->assertTrue($result->isOk()); + + $sortRegular = $reflectionProvider->getConstant(new Name('SORT_REGULAR'), null); + $result = $flagsParam->checkAllowedConstants([$sortRegular]); + $this->assertFalse($result->isOk()); + $this->assertCount(1, $result->getDisallowedConstants()); + } + + public function testMethodWithClassConstants(): void + { + $reflectionProvider = self::createReflectionProvider(); + $class = $reflectionProvider->getClass('PDOStatement'); + $method = $class->getNativeMethod('fetch'); + $modeParam = $method->getVariants()[0]->getParameters()[0]; + + $this->assertSame('mode', $modeParam->getName()); + $this->assertNotNull($modeParam->getAllowedConstants()); + $this->assertFalse($modeParam->getAllowedConstants()->isBitmask()); + + $pdoClass = $reflectionProvider->getClass('PDO'); + + $fetchAssoc = $pdoClass->getConstant('FETCH_ASSOC'); + $result = $modeParam->checkAllowedConstants([$fetchAssoc]); + $this->assertTrue($result->isOk()); + + $attrErrmode = $pdoClass->getConstant('ATTR_ERRMODE'); + $result = $modeParam->checkAllowedConstants([$attrErrmode]); + $this->assertFalse($result->isOk()); + $this->assertCount(1, $result->getDisallowedConstants()); + } + + public function testClassConstantNotAllowedWhenGlobalConstantsExpected(): void + { + $reflectionProvider = self::createReflectionProvider(); + $function = $reflectionProvider->getFunction(new Name('json_encode'), null); + $flagsParam = $function->getVariants()[0]->getParameters()[1]; + + $pdoClass = $reflectionProvider->getClass('PDO'); + $fetchAssoc = $pdoClass->getConstant('FETCH_ASSOC'); + + $result = $flagsParam->checkAllowedConstants([$fetchAssoc]); + $this->assertFalse($result->isOk()); + $this->assertCount(1, $result->getDisallowedConstants()); + } + + public function testViolatedExclusiveGroupsSortFlags(): void + { + $reflectionProvider = self::createReflectionProvider(); + $function = $reflectionProvider->getFunction(new Name('sort'), null); + $flagsParam = $function->getVariants()[0]->getParameters()[1]; + + $sortNumeric = $reflectionProvider->getConstant(new Name('SORT_NUMERIC'), null); + $sortString = $reflectionProvider->getConstant(new Name('SORT_STRING'), null); + $sortFlagCase = $reflectionProvider->getConstant(new Name('SORT_FLAG_CASE'), null); + + // Two mutually exclusive sort types + $result = $flagsParam->checkAllowedConstants([$sortNumeric, $sortString]); + $this->assertFalse($result->isOk()); + $this->assertSame([], $result->getDisallowedConstants()); + $this->assertCount(1, $result->getViolatedExclusiveGroups()); + $this->assertSame(['SORT_NUMERIC', 'SORT_STRING'], $result->getViolatedExclusiveGroups()[0]); + + // Sort type + modifier is fine + $result = $flagsParam->checkAllowedConstants([$sortString, $sortFlagCase]); + $this->assertTrue($result->isOk()); + } + + public function testViolatedExclusiveGroupsHtmlEntities(): void + { + $reflectionProvider = self::createReflectionProvider(); + $function = $reflectionProvider->getFunction(new Name('htmlspecialchars'), null); + $flagsParam = $function->getVariants()[0]->getParameters()[1]; + + $entQuotes = $reflectionProvider->getConstant(new Name('ENT_QUOTES'), null); + $entNoquotes = $reflectionProvider->getConstant(new Name('ENT_NOQUOTES'), null); + $entHtml401 = $reflectionProvider->getConstant(new Name('ENT_HTML401'), null); + $entHtml5 = $reflectionProvider->getConstant(new Name('ENT_HTML5'), null); + $entSubstitute = $reflectionProvider->getConstant(new Name('ENT_SUBSTITUTE'), null); + + // Violates both exclusive groups + $result = $flagsParam->checkAllowedConstants([$entQuotes, $entNoquotes, $entHtml401, $entHtml5]); + $this->assertFalse($result->isOk()); + $this->assertSame([], $result->getDisallowedConstants()); + $this->assertCount(2, $result->getViolatedExclusiveGroups()); + $this->assertSame(['ENT_QUOTES', 'ENT_NOQUOTES'], $result->getViolatedExclusiveGroups()[0]); + $this->assertSame(['ENT_HTML401', 'ENT_HTML5'], $result->getViolatedExclusiveGroups()[1]); + + // One from each group is fine + $result = $flagsParam->checkAllowedConstants([$entQuotes, $entHtml5, $entSubstitute]); + $this->assertTrue($result->isOk()); + } + + public function testBitmaskNotAllowedOnSingleParameter(): void + { + $reflectionProvider = self::createReflectionProvider(); + $function = $reflectionProvider->getFunction(new Name('array_unique'), null); + $flagsParam = $function->getVariants()[0]->getParameters()[1]; + + $this->assertSame('flags', $flagsParam->getName()); + $this->assertNotNull($flagsParam->getAllowedConstants()); + $this->assertFalse($flagsParam->getAllowedConstants()->isBitmask()); + + $sortRegular = $reflectionProvider->getConstant(new Name('SORT_REGULAR'), null); + $sortNumeric = $reflectionProvider->getConstant(new Name('SORT_NUMERIC'), null); + + // Single constant is fine + $result = $flagsParam->checkAllowedConstants([$sortRegular]); + $this->assertTrue($result->isOk()); + $this->assertFalse($result->isBitmaskNotAllowed()); + + // Bitmask on single-value parameter is not allowed + $result = $flagsParam->checkAllowedConstants([$sortRegular, $sortNumeric]); + $this->assertFalse($result->isOk()); + $this->assertTrue($result->isBitmaskNotAllowed()); + } + + public function testBitmaskAllowedOnBitmaskParameter(): void + { + $reflectionProvider = self::createReflectionProvider(); + $function = $reflectionProvider->getFunction(new Name('json_encode'), null); + $flagsParam = $function->getVariants()[0]->getParameters()[1]; + + $this->assertNotNull($flagsParam->getAllowedConstants()); + $this->assertTrue($flagsParam->getAllowedConstants()->isBitmask()); + + $prettyPrint = $reflectionProvider->getConstant(new Name('JSON_PRETTY_PRINT'), null); + $unescaped = $reflectionProvider->getConstant(new Name('JSON_UNESCAPED_SLASHES'), null); + + $result = $flagsParam->checkAllowedConstants([$prettyPrint, $unescaped]); + $this->assertTrue($result->isOk()); + $this->assertFalse($result->isBitmaskNotAllowed()); + } + + public function testBothDisallowedAndExclusiveViolation(): void + { + $reflectionProvider = self::createReflectionProvider(); + $function = $reflectionProvider->getFunction(new Name('sort'), null); + $flagsParam = $function->getVariants()[0]->getParameters()[1]; + + $sortNumeric = $reflectionProvider->getConstant(new Name('SORT_NUMERIC'), null); + $sortString = $reflectionProvider->getConstant(new Name('SORT_STRING'), null); + $jsonThrowOnError = $reflectionProvider->getConstant(new Name('JSON_THROW_ON_ERROR'), null); + + // Wrong constant AND exclusive group violation + $result = $flagsParam->checkAllowedConstants([$sortNumeric, $sortString, $jsonThrowOnError]); + $this->assertFalse($result->isOk()); + $this->assertCount(1, $result->getDisallowedConstants()); + $this->assertSame('JSON_THROW_ON_ERROR', $result->getDisallowedConstants()[0]->getName()); + $this->assertCount(1, $result->getViolatedExclusiveGroups()); + $this->assertSame(['SORT_NUMERIC', 'SORT_STRING'], $result->getViolatedExclusiveGroups()[0]); + } + +} diff --git a/tests/PHPStan/Reflection/ParametersAcceptorSelectorTest.php b/tests/PHPStan/Reflection/ParametersAcceptorSelectorTest.php index 1926cb56e53..b00993697a6 100644 --- a/tests/PHPStan/Reflection/ParametersAcceptorSelectorTest.php +++ b/tests/PHPStan/Reflection/ParametersAcceptorSelectorTest.php @@ -92,6 +92,7 @@ public static function dataSelectFromTypes(): Generator $parameter->isImmediatelyInvokedCallable(), $parameter->getClosureThisType(), $parameter->getAttributes(), + $parameter->getAllowedConstants(), ), $datePeriodConstructorVariants[0]->getParameters()), false, new VoidType(), @@ -123,6 +124,7 @@ public static function dataSelectFromTypes(): Generator $parameter->isImmediatelyInvokedCallable(), $parameter->getClosureThisType(), $parameter->getAttributes(), + $parameter->getAllowedConstants(), ), $datePeriodConstructorVariants[1]->getParameters()), false, new VoidType(), diff --git a/tests/PHPStan/Reflection/constantToFunctionParameterMap.neon b/tests/PHPStan/Reflection/constantToFunctionParameterMap.neon new file mode 100644 index 00000000000..72ae924610a --- /dev/null +++ b/tests/PHPStan/Reflection/constantToFunctionParameterMap.neon @@ -0,0 +1,2 @@ +parameters: + phpVersion: 80500 diff --git a/tests/PHPStan/Rules/Arrays/NonexistentOffsetInArrayDimFetchRuleTest.php b/tests/PHPStan/Rules/Arrays/NonexistentOffsetInArrayDimFetchRuleTest.php index 4eb98a13a1e..2927248dd88 100644 --- a/tests/PHPStan/Rules/Arrays/NonexistentOffsetInArrayDimFetchRuleTest.php +++ b/tests/PHPStan/Rules/Arrays/NonexistentOffsetInArrayDimFetchRuleTest.php @@ -1348,4 +1348,69 @@ public function testBug13688(): void $this->analyse([__DIR__ . '/data/bug-13688.php'], []); } + public static function dataUnsealedArrayShapes(): iterable + { + foreach ([false, true] as $reportPossiblyNonexistentGeneralArrayOffset) { + yield [$reportPossiblyNonexistentGeneralArrayOffset, false, [ + [ + 'Offset 2 might not exist on array{a: int, ...}.', + 16, + ], + [ + 'Offset 1 might not exist on array{int, ...}.', + 22, + ], + [ + 'Offset non-decimal-int-string does not exist on array{int, ...}.', + 25, + ], + ]]; + yield [$reportPossiblyNonexistentGeneralArrayOffset, true, [ + [ + 'Offset 2 might not exist on array{a: int, ...}.', + 16, + ], + [ + 'Offset int might not exist on array{a: int, ...}.', + 17, + ], + [ + 'Offset string might not exist on array{a: int, ...}.', + 18, + ], + [ + 'Offset non-decimal-int-string might not exist on array{a: int, ...}.', + 19, + ], + [ + 'Offset 1 might not exist on array{int, ...}.', + 22, + ], + [ + 'Offset int might not exist on array{int, ...}.', + 23, + ], + [ + 'Offset string might not exist on array{int, ...}.', + 24, + ], + [ + 'Offset non-decimal-int-string does not exist on array{int, ...}.', + 25, + ], + ]]; + } + } + + /** + * @param list $expectedErrors + */ + #[DataProvider('dataUnsealedArrayShapes')] + public function testUnsealedArrayShapes(bool $reportPossiblyNonexistentGeneralArrayOffset, bool $reportPossiblyNonexistentConstantArrayOffset, array $expectedErrors): void + { + $this->reportPossiblyNonexistentGeneralArrayOffset = $reportPossiblyNonexistentGeneralArrayOffset; + $this->reportPossiblyNonexistentConstantArrayOffset = $reportPossiblyNonexistentConstantArrayOffset; + $this->analyse([__DIR__ . '/data/unsealed-array-shapes-has-offset.php'], $expectedErrors); + } + } diff --git a/tests/PHPStan/Rules/Arrays/data/unsealed-array-shapes-has-offset.php b/tests/PHPStan/Rules/Arrays/data/unsealed-array-shapes-has-offset.php new file mode 100644 index 00000000000..1f0bd437607 --- /dev/null +++ b/tests/PHPStan/Rules/Arrays/data/unsealed-array-shapes-has-offset.php @@ -0,0 +1,28 @@ +} $a + * @param array{0: int, ...} $b + * @param non-decimal-int-string $nonDecimalIntString + */ + public function doFoo(array $a, array $b, int $i, string $s, string $nonDecimalIntString): array + { + echo $a['a']; + echo $a[2]; + echo $a[$i]; + echo $a[$s]; + echo $a[$nonDecimalIntString]; + + echo $b[0]; + echo $b[1]; + echo $b[$i]; + echo $b[$s]; + echo $b[$nonDecimalIntString]; + } + +} diff --git a/tests/PHPStan/Rules/Classes/ImpossibleInstanceOfRuleTest.php b/tests/PHPStan/Rules/Classes/ImpossibleInstanceOfRuleTest.php index b272f9dd71f..e701bf91f4e 100644 --- a/tests/PHPStan/Rules/Classes/ImpossibleInstanceOfRuleTest.php +++ b/tests/PHPStan/Rules/Classes/ImpossibleInstanceOfRuleTest.php @@ -2,8 +2,12 @@ namespace PHPStan\Rules\Classes; +use PHPStan\Rules\Comparison\ConstantConditionInTraitHelper; +use PHPStan\Rules\Comparison\ConstantConditionInTraitRule; +use PHPStan\Rules\Comparison\PossiblyImpureTipHelper; use PHPStan\Rules\Rule; use PHPStan\Rules\RuleLevelHelper; +use PHPStan\Testing\CompositeRule; use PHPStan\Testing\RuleTestCase; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\RequiresPhp; @@ -11,7 +15,7 @@ use const PHP_VERSION_ID; /** - * @extends RuleTestCase + * @extends RuleTestCase */ class ImpossibleInstanceOfRuleTest extends RuleTestCase { @@ -33,12 +37,18 @@ protected function getRule(): Rule discoveringSymbolsTip: true, ); - return new ImpossibleInstanceOfRule( - $ruleLevelHelper, - treatPhpDocTypesAsCertain: $this->treatPhpDocTypesAsCertain, - reportAlwaysTrueInLastCondition: $this->reportAlwaysTrueInLastCondition, - treatPhpDocTypesAsCertainTip: true, - ); + // @phpstan-ignore argument.type + return new CompositeRule([ + new ImpossibleInstanceOfRule( + $ruleLevelHelper, + new PossiblyImpureTipHelper(true), + self::getContainer()->getByType(ConstantConditionInTraitHelper::class), + treatPhpDocTypesAsCertain: $this->treatPhpDocTypesAsCertain, + reportAlwaysTrueInLastCondition: $this->reportAlwaysTrueInLastCondition, + treatPhpDocTypesAsCertainTip: true, + ), + new ConstantConditionInTraitRule(), + ]); } protected function shouldTreatPhpDocTypesAsCertain(): bool @@ -539,6 +549,18 @@ public function testBug10036(): void ]); } + public function testBug10353(): void + { + $this->treatPhpDocTypesAsCertain = true; + $this->analyse([__DIR__ . '/data/bug-10353.php'], []); + } + + public function testBug12267(): void + { + $this->treatPhpDocTypesAsCertain = true; + $this->analyse([__DIR__ . '/data/bug-12267.php'], []); + } + #[RequiresPhp('>= 8.0.0')] public function testNewIsAlwaysFinalClass(): void { @@ -599,6 +621,44 @@ public function testBug13975(string $file): void $this->analyse([$file], []); } + public function testPossiblyImpureTip(): void + { + $this->treatPhpDocTypesAsCertain = true; + $learnMore = ' Learn more: https://phpstan.org/blog/remembering-and-forgetting-returned-values'; + $this->analyse([__DIR__ . '/data/possibly-impure-instanceof-tip.php'], [ + // maybe-impure: tip expected + [ + 'Instanceof between PossiblyImpureInstanceofTip\Cat and PossiblyImpureInstanceofTip\Cat will always evaluate to true.', + 41, + 'If PossiblyImpureInstanceofTip\Holder::maybeImpureMethod() is impure, add @phpstan-impure PHPDoc tag above its declaration.' . $learnMore, + ], + // pure: no tip, error explained by type + [ + 'Instanceof between PossiblyImpureInstanceofTip\Cat and PossiblyImpureInstanceofTip\Cat will always evaluate to true.', + 53, + ], + // impure: no error - $holder invalidated + ]); + } + + public function testInTrait(): void + { + $this->treatPhpDocTypesAsCertain = true; + $tipText = 'Because the type is coming from a PHPDoc, you can turn off this check by setting treatPhpDocTypesAsCertain: false in your %configurationFile%.'; + $this->analyse([__DIR__ . '/data/impossible-instanceof-in-trait.php'], [ + [ + 'Instanceof between ImpossibleInstanceofInTrait\Cat and stdClass will always evaluate to false.', + 25, + $tipText, + ], + [ + 'Instanceof between ImpossibleInstanceofInTrait\Dog and stdClass will always evaluate to false.', + 25, + $tipText, + ], + ]); + } + public function testBug5271(): void { $this->treatPhpDocTypesAsCertain = false; diff --git a/tests/PHPStan/Rules/Classes/InstantiationRuleTest.php b/tests/PHPStan/Rules/Classes/InstantiationRuleTest.php index 5f83c21b6e9..a3538302b56 100644 --- a/tests/PHPStan/Rules/Classes/InstantiationRuleTest.php +++ b/tests/PHPStan/Rules/Classes/InstantiationRuleTest.php @@ -472,6 +472,7 @@ public function testBug9946(): void $this->analyse([__DIR__ . '/data/bug-9946.php'], []); } + #[RequiresPhp('< 8.0')] public function testBug10324(): void { $this->analyse([__DIR__ . '/data/bug-10324.php'], [ @@ -482,6 +483,21 @@ public function testBug10324(): void ]); } + #[RequiresPhp('>= 8.0')] + public function testBug10324On80(): void + { + $this->analyse([__DIR__ . '/data/bug-10324.php'], [ + [ + 'Constant RecursiveIteratorIterator::CHILD_FIRST is not allowed for parameter #3 $flags of class RecursiveIteratorIterator constructor.', + 23, + ], + [ + 'Parameter #3 $flags of class RecursiveIteratorIterator constructor expects 0|16, 2 given.', + 23, + ], + ]); + } + public function testPhpstanInternalClass(): void { $tip = 'This is most likely unintentional. Did you mean to type \AClass?'; @@ -634,6 +650,21 @@ public function testBug11006(): void $this->analyse([__DIR__ . '/data/bug-11006.php'], []); } + #[RequiresPhp('>= 8.0.0')] + public function testConstantParameterCheckInstantiation(): void + { + $this->analyse([__DIR__ . '/data/constant-parameter-check-instantiation.php'], [ + [ + 'Constant SORT_REGULAR is not allowed for parameter #1 $flags of class finfo constructor.', + 12, + ], + [ + 'Constant IntlDateFormatter::GREGORIAN is not allowed for parameter #2 $dateType of class IntlDateFormatter constructor.', + 18, + ], + ]); + } + #[RequiresPhp('>= 8.0.0')] public function testBug14138(): void { diff --git a/tests/PHPStan/Rules/Classes/data/bug-10353.php b/tests/PHPStan/Rules/Classes/data/bug-10353.php new file mode 100644 index 00000000000..bf4305af417 --- /dev/null +++ b/tests/PHPStan/Rules/Classes/data/bug-10353.php @@ -0,0 +1,37 @@ +test(); + } +} + +class OtherClass +{ + use Foo; + + function bar(): string + { + return $this->test(); + } +} diff --git a/tests/PHPStan/Rules/Classes/data/bug-12267.php b/tests/PHPStan/Rules/Classes/data/bug-12267.php new file mode 100644 index 00000000000..8317471d39d --- /dev/null +++ b/tests/PHPStan/Rules/Classes/data/bug-12267.php @@ -0,0 +1,53 @@ + */ + protected function getFileExistsHelpBlock(string $field): array + { + if (!($this->model instanceof A11yPhase)) { + return []; + } + + return []; + } +} + +/** + * @extends Form + */ +class EditA11yPhaseForm extends Form +{ + use ContainsA11yPhaseResultFields; +} + +/** + * @extends Form + */ +class SubmitA11yAuditPhaseForm extends Form +{ + use ContainsA11yPhaseResultFields; +} diff --git a/tests/PHPStan/Rules/Classes/data/constant-parameter-check-instantiation.php b/tests/PHPStan/Rules/Classes/data/constant-parameter-check-instantiation.php new file mode 100644 index 00000000000..256c7639938 --- /dev/null +++ b/tests/PHPStan/Rules/Classes/data/constant-parameter-check-instantiation.php @@ -0,0 +1,18 @@ +animal instanceof Dog) { + + } + } + + public function doFoo2(): void + { + // always false + if ($this->animal instanceof \stdClass) { + + } + } + +} + +class Foo +{ + + /** @use FooTrait */ + use FooTrait; + + /** @var Dog */ + protected $animal; + +} + +class FooAnother +{ + + /** @use FooTrait */ + use FooTrait; + + /** @var Cat */ + protected $animal; + +} diff --git a/tests/PHPStan/Rules/Classes/data/possibly-impure-instanceof-tip.php b/tests/PHPStan/Rules/Classes/data/possibly-impure-instanceof-tip.php new file mode 100644 index 00000000000..bac1fbcce67 --- /dev/null +++ b/tests/PHPStan/Rules/Classes/data/possibly-impure-instanceof-tip.php @@ -0,0 +1,69 @@ +getAnimal() instanceof Cat) { + $holder->maybeImpureMethod(); + + // tip expected: maybeImpureMethod() might have changed the object + if ($holder->getAnimal() instanceof Cat) { + return; + } + } +} + +function testPure(Holder $holder): void +{ + if ($holder->getAnimal() instanceof Cat) { + $holder->pureMethod(); + + // no tip - pureMethod() cannot change anything + if ($holder->getAnimal() instanceof Cat) { + return; + } + } +} + +function testImpure(Holder $holder): void +{ + if ($holder->getAnimal() instanceof Cat) { + $holder->impureMethod(); + + // no error - $holder invalidated by impure call + if ($holder->getAnimal() instanceof Cat) { + return; + } + } +} diff --git a/tests/PHPStan/Rules/CollectedDataEmitterRule.php b/tests/PHPStan/Rules/CollectedDataEmitterRule.php new file mode 100644 index 00000000000..f8a08ca1b6e --- /dev/null +++ b/tests/PHPStan/Rules/CollectedDataEmitterRule.php @@ -0,0 +1,33 @@ + + */ +final class CollectedDataEmitterRule implements Rule +{ + + public function getNodeType(): string + { + return Node\Expr\MethodCall::class; + } + + public function processNode(Node $node, NodeCallbackInvoker&Scope&CollectedDataEmitter $scope): array + { + // same implementation as DummyCollector, but is actually a rule! + if (!$node->name instanceof Node\Identifier) { + return []; + } + + $scope->emitCollectedData(DummyCollector::class, $node->name->toString()); + + return []; + } + +} diff --git a/tests/PHPStan/Rules/CollectedDataEmitterTest.php b/tests/PHPStan/Rules/CollectedDataEmitterTest.php new file mode 100644 index 00000000000..99c642e4579 --- /dev/null +++ b/tests/PHPStan/Rules/CollectedDataEmitterTest.php @@ -0,0 +1,33 @@ + + */ +class CollectedDataEmitterTest extends RuleTestCase +{ + + protected function getRule(): Rule + { + // @phpstan-ignore argument.type + return new CompositeRule([ + new CollectedDataEmitterRule(), + new DummyCollectorRule(), + ]); + } + + public function testRule(): void + { + $this->analyse([__DIR__ . '/data/dummy-collector.php'], [ + [ + '2× doFoo, 2× doBar', + 5, + ], + ]); + } + +} diff --git a/tests/PHPStan/Rules/Comparison/BooleanAndConstantConditionRuleTest.php b/tests/PHPStan/Rules/Comparison/BooleanAndConstantConditionRuleTest.php index 114c61812b2..1097a8289cf 100644 --- a/tests/PHPStan/Rules/Comparison/BooleanAndConstantConditionRuleTest.php +++ b/tests/PHPStan/Rules/Comparison/BooleanAndConstantConditionRuleTest.php @@ -3,11 +3,12 @@ namespace PHPStan\Rules\Comparison; use PHPStan\Rules\Rule; +use PHPStan\Testing\CompositeRule; use PHPStan\Testing\RuleTestCase; use PHPUnit\Framework\Attributes\DataProvider; /** - * @extends RuleTestCase + * @extends RuleTestCase */ class BooleanAndConstantConditionRuleTest extends RuleTestCase { @@ -18,20 +19,25 @@ class BooleanAndConstantConditionRuleTest extends RuleTestCase protected function getRule(): Rule { - return new BooleanAndConstantConditionRule( - new ConstantConditionRuleHelper( - new ImpossibleCheckTypeHelper( - self::createReflectionProvider(), - $this->getTypeSpecifier(), + // @phpstan-ignore argument.type + return new CompositeRule([ + new BooleanAndConstantConditionRule( + new ConstantConditionRuleHelper( + new ImpossibleCheckTypeHelper( + self::createReflectionProvider(), + $this->getTypeSpecifier(), + $this->treatPhpDocTypesAsCertain, + ), $this->treatPhpDocTypesAsCertain, ), + new PossiblyImpureTipHelper(true), + self::getContainer()->getByType(ConstantConditionInTraitHelper::class), $this->treatPhpDocTypesAsCertain, + $this->reportAlwaysTrueInLastCondition, + true, ), - new PossiblyImpureTipHelper(true), - $this->treatPhpDocTypesAsCertain, - $this->reportAlwaysTrueInLastCondition, - true, - ); + new ConstantConditionInTraitRule(), + ]); } protected function shouldTreatPhpDocTypesAsCertain(): bool @@ -445,4 +451,16 @@ public function testBug8555(): void $this->analyse([__DIR__ . '/data/bug-8555.php'], []); } + public function testInTrait(): void + { + $this->treatPhpDocTypesAsCertain = true; + $this->reportAlwaysTrueInLastCondition = true; + $this->analyse([__DIR__ . '/data/boolean-and-in-trait.php'], [ + [ + 'Left side of && is always true.', + 19, + ], + ]); + } + } diff --git a/tests/PHPStan/Rules/Comparison/BooleanNotConstantConditionRuleTest.php b/tests/PHPStan/Rules/Comparison/BooleanNotConstantConditionRuleTest.php index fb5c504a94b..cfdaec1fd35 100644 --- a/tests/PHPStan/Rules/Comparison/BooleanNotConstantConditionRuleTest.php +++ b/tests/PHPStan/Rules/Comparison/BooleanNotConstantConditionRuleTest.php @@ -3,11 +3,12 @@ namespace PHPStan\Rules\Comparison; use PHPStan\Rules\Rule; +use PHPStan\Testing\CompositeRule; use PHPStan\Testing\RuleTestCase; use PHPUnit\Framework\Attributes\DataProvider; /** - * @extends RuleTestCase + * @extends RuleTestCase */ class BooleanNotConstantConditionRuleTest extends RuleTestCase { @@ -18,20 +19,25 @@ class BooleanNotConstantConditionRuleTest extends RuleTestCase protected function getRule(): Rule { - return new BooleanNotConstantConditionRule( - new ConstantConditionRuleHelper( - new ImpossibleCheckTypeHelper( - self::createReflectionProvider(), - $this->getTypeSpecifier(), + // @phpstan-ignore argument.type + return new CompositeRule([ + new BooleanNotConstantConditionRule( + new ConstantConditionRuleHelper( + new ImpossibleCheckTypeHelper( + self::createReflectionProvider(), + $this->getTypeSpecifier(), + $this->treatPhpDocTypesAsCertain, + ), $this->treatPhpDocTypesAsCertain, ), + new PossiblyImpureTipHelper(true), + self::getContainer()->getByType(ConstantConditionInTraitHelper::class), $this->treatPhpDocTypesAsCertain, + $this->reportAlwaysTrueInLastCondition, + true, ), - new PossiblyImpureTipHelper(true), - $this->treatPhpDocTypesAsCertain, - $this->reportAlwaysTrueInLastCondition, - true, - ); + new ConstantConditionInTraitRule(), + ]); } protected function shouldTreatPhpDocTypesAsCertain(): bool @@ -228,12 +234,29 @@ public function testBug5984(): void $this->analyse([__DIR__ . '/data/bug-5984.php'], []); } + public function testBug12267(): void + { + $this->treatPhpDocTypesAsCertain = true; + $this->analyse([__DIR__ . '/data/bug-12267.php'], []); + } + public function testBug6702(): void { $this->treatPhpDocTypesAsCertain = true; $this->analyse([__DIR__ . '/data/bug-6702.php'], []); } + public function testInTrait(): void + { + $this->treatPhpDocTypesAsCertain = true; + $this->analyse([__DIR__ . '/data/boolean-not-in-trait.php'], [ + [ + 'Negated boolean expression is always false.', + 19, + ], + ]); + } + public function testBug12852(): void { $this->treatPhpDocTypesAsCertain = true; diff --git a/tests/PHPStan/Rules/Comparison/BooleanOrConstantConditionRuleTest.php b/tests/PHPStan/Rules/Comparison/BooleanOrConstantConditionRuleTest.php index e2d84d9e7a6..ba4aab80505 100644 --- a/tests/PHPStan/Rules/Comparison/BooleanOrConstantConditionRuleTest.php +++ b/tests/PHPStan/Rules/Comparison/BooleanOrConstantConditionRuleTest.php @@ -3,12 +3,13 @@ namespace PHPStan\Rules\Comparison; use PHPStan\Rules\Rule; +use PHPStan\Testing\CompositeRule; use PHPStan\Testing\RuleTestCase; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\RequiresPhp; /** - * @extends RuleTestCase + * @extends RuleTestCase */ class BooleanOrConstantConditionRuleTest extends RuleTestCase { @@ -19,20 +20,25 @@ class BooleanOrConstantConditionRuleTest extends RuleTestCase protected function getRule(): Rule { - return new BooleanOrConstantConditionRule( - new ConstantConditionRuleHelper( - new ImpossibleCheckTypeHelper( - self::createReflectionProvider(), - $this->getTypeSpecifier(), + // @phpstan-ignore argument.type + return new CompositeRule([ + new BooleanOrConstantConditionRule( + new ConstantConditionRuleHelper( + new ImpossibleCheckTypeHelper( + self::createReflectionProvider(), + $this->getTypeSpecifier(), + $this->treatPhpDocTypesAsCertain, + ), $this->treatPhpDocTypesAsCertain, ), + new PossiblyImpureTipHelper(true), + self::getContainer()->getByType(ConstantConditionInTraitHelper::class), $this->treatPhpDocTypesAsCertain, + $this->reportAlwaysTrueInLastCondition, + true, ), - new PossiblyImpureTipHelper(true), - $this->treatPhpDocTypesAsCertain, - $this->reportAlwaysTrueInLastCondition, - true, - ); + new ConstantConditionInTraitRule(), + ]); } protected function shouldTreatPhpDocTypesAsCertain(): bool @@ -383,6 +389,18 @@ public function testBug10305(): void ]); } + public function testInTrait(): void + { + $this->treatPhpDocTypesAsCertain = true; + $this->reportAlwaysTrueInLastCondition = true; + $this->analyse([__DIR__ . '/data/boolean-or-in-trait.php'], [ + [ + 'Left side of || is always true.', + 19, + ], + ]); + } + public function testBug14473(): void { $this->treatPhpDocTypesAsCertain = true; diff --git a/tests/PHPStan/Rules/Comparison/Bug14534Test.php b/tests/PHPStan/Rules/Comparison/Bug14534Test.php index a759a867887..cc8f02a3973 100644 --- a/tests/PHPStan/Rules/Comparison/Bug14534Test.php +++ b/tests/PHPStan/Rules/Comparison/Bug14534Test.php @@ -18,9 +18,10 @@ protected function getRule(): Rule return new StrictComparisonOfDifferentTypesRule( self::getContainer()->getByType(RicherScopeGetTypeHelper::class), new PossiblyImpureTipHelper(true), - true, - true, - true, + self::getContainer()->getByType(ConstantConditionInTraitHelper::class), + treatPhpDocTypesAsCertain: true, + reportAlwaysTrueInLastCondition: true, + treatPhpDocTypesAsCertainTip: true, ); } diff --git a/tests/PHPStan/Rules/Comparison/ConstantLooseComparisonRuleTest.php b/tests/PHPStan/Rules/Comparison/ConstantLooseComparisonRuleTest.php index cb643ad543f..60c19d9bcc9 100644 --- a/tests/PHPStan/Rules/Comparison/ConstantLooseComparisonRuleTest.php +++ b/tests/PHPStan/Rules/Comparison/ConstantLooseComparisonRuleTest.php @@ -3,6 +3,7 @@ namespace PHPStan\Rules\Comparison; use PHPStan\Rules\Rule; +use PHPStan\Testing\CompositeRule; use PHPStan\Testing\RuleTestCase; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\RequiresPhp; @@ -10,7 +11,7 @@ use const PHP_VERSION_ID; /** - * @extends RuleTestCase + * @extends RuleTestCase */ class ConstantLooseComparisonRuleTest extends RuleTestCase { @@ -21,12 +22,17 @@ class ConstantLooseComparisonRuleTest extends RuleTestCase protected function getRule(): Rule { - return new ConstantLooseComparisonRule( - new PossiblyImpureTipHelper(true), - $this->treatPhpDocTypesAsCertain, - $this->reportAlwaysTrueInLastCondition, - true, - ); + // @phpstan-ignore argument.type + return new CompositeRule([ + new ConstantLooseComparisonRule( + new PossiblyImpureTipHelper(true), + self::getContainer()->getByType(ConstantConditionInTraitHelper::class), + $this->treatPhpDocTypesAsCertain, + $this->reportAlwaysTrueInLastCondition, + true, + ), + new ConstantConditionInTraitRule(), + ]); } public function testRule(): void @@ -248,6 +254,17 @@ public function testBug13098(): void $this->analyse([__DIR__ . '/data/bug-13098.php'], []); } + public function testInTrait(): void + { + $this->analyse([__DIR__ . '/data/loose-comparison-in-trait.php'], [ + [ + 'Loose comparison using == between 1 and null will always evaluate to false.', + 19, + 'Because the type is coming from a PHPDoc, you can turn off this check by setting treatPhpDocTypesAsCertain: false in your %configurationFile%.', + ], + ]); + } + public function testBug14606(): void { $this->analyse([__DIR__ . '/data/bug-14606.php'], [ diff --git a/tests/PHPStan/Rules/Comparison/DoWhileLoopConstantConditionRuleTest.php b/tests/PHPStan/Rules/Comparison/DoWhileLoopConstantConditionRuleTest.php index 3bd0c71136a..6d2b7497d4b 100644 --- a/tests/PHPStan/Rules/Comparison/DoWhileLoopConstantConditionRuleTest.php +++ b/tests/PHPStan/Rules/Comparison/DoWhileLoopConstantConditionRuleTest.php @@ -3,29 +3,36 @@ namespace PHPStan\Rules\Comparison; use PHPStan\Rules\Rule; +use PHPStan\Testing\CompositeRule; use PHPStan\Testing\RuleTestCase; +use PHPUnit\Framework\Attributes\RequiresPhp; /** - * @extends RuleTestCase + * @extends RuleTestCase */ class DoWhileLoopConstantConditionRuleTest extends RuleTestCase { protected function getRule(): Rule { - return new DoWhileLoopConstantConditionRule( - new ConstantConditionRuleHelper( - new ImpossibleCheckTypeHelper( - self::createReflectionProvider(), - $this->getTypeSpecifier(), + // @phpstan-ignore argument.type + return new CompositeRule([ + new DoWhileLoopConstantConditionRule( + new ConstantConditionRuleHelper( + new ImpossibleCheckTypeHelper( + self::createReflectionProvider(), + $this->getTypeSpecifier(), + $this->shouldTreatPhpDocTypesAsCertain(), + ), $this->shouldTreatPhpDocTypesAsCertain(), ), + new PossiblyImpureTipHelper(true), + self::getContainer()->getByType(ConstantConditionInTraitHelper::class), $this->shouldTreatPhpDocTypesAsCertain(), + true, ), - new PossiblyImpureTipHelper(true), - $this->shouldTreatPhpDocTypesAsCertain(), - true, - ); + new ConstantConditionInTraitRule(), + ]); } public function testBug6189(): void @@ -75,4 +82,15 @@ public function testRule(): void ]); } + #[RequiresPhp('>= 8.2')] + public function testInTrait(): void + { + $this->analyse([__DIR__ . '/data/do-while-in-trait.php'], [ + [ + 'Do-while loop condition is always false.', + 19, + ], + ]); + } + } diff --git a/tests/PHPStan/Rules/Comparison/ElseIfConstantConditionRuleTest.php b/tests/PHPStan/Rules/Comparison/ElseIfConstantConditionRuleTest.php index 92f14d6dd33..857f32cbd21 100644 --- a/tests/PHPStan/Rules/Comparison/ElseIfConstantConditionRuleTest.php +++ b/tests/PHPStan/Rules/Comparison/ElseIfConstantConditionRuleTest.php @@ -3,12 +3,13 @@ namespace PHPStan\Rules\Comparison; use PHPStan\Rules\Rule; +use PHPStan\Testing\CompositeRule; use PHPStan\Testing\RuleTestCase; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\RequiresPhp; /** - * @extends RuleTestCase + * @extends RuleTestCase */ class ElseIfConstantConditionRuleTest extends RuleTestCase { @@ -19,20 +20,25 @@ class ElseIfConstantConditionRuleTest extends RuleTestCase protected function getRule(): Rule { - return new ElseIfConstantConditionRule( - new ConstantConditionRuleHelper( - new ImpossibleCheckTypeHelper( - self::createReflectionProvider(), - $this->getTypeSpecifier(), + // @phpstan-ignore argument.type + return new CompositeRule([ + new ElseIfConstantConditionRule( + new ConstantConditionRuleHelper( + new ImpossibleCheckTypeHelper( + self::createReflectionProvider(), + $this->getTypeSpecifier(), + $this->treatPhpDocTypesAsCertain, + ), $this->treatPhpDocTypesAsCertain, ), + new PossiblyImpureTipHelper(true), + self::getContainer()->getByType(ConstantConditionInTraitHelper::class), $this->treatPhpDocTypesAsCertain, + $this->reportAlwaysTrueInLastCondition, + true, ), - new PossiblyImpureTipHelper(true), - $this->treatPhpDocTypesAsCertain, - $this->reportAlwaysTrueInLastCondition, - true, - ); + new ConstantConditionInTraitRule(), + ]); } protected function shouldTreatPhpDocTypesAsCertain(): bool @@ -151,4 +157,16 @@ public function testBug6947(): void ]); } + #[RequiresPhp('>= 8.2')] + public function testInTrait(): void + { + $this->treatPhpDocTypesAsCertain = true; + $this->analyse([__DIR__ . '/data/elseif-condition-in-trait.php'], [ + [ + 'Elseif condition is always false.', + 23, + ], + ]); + } + } diff --git a/tests/PHPStan/Rules/Comparison/IfConstantConditionRuleTest.php b/tests/PHPStan/Rules/Comparison/IfConstantConditionRuleTest.php index 5a847ad0293..ea99bb4a056 100644 --- a/tests/PHPStan/Rules/Comparison/IfConstantConditionRuleTest.php +++ b/tests/PHPStan/Rules/Comparison/IfConstantConditionRuleTest.php @@ -3,11 +3,12 @@ namespace PHPStan\Rules\Comparison; use PHPStan\Rules\Rule; +use PHPStan\Testing\CompositeRule; use PHPStan\Testing\RuleTestCase; use PHPUnit\Framework\Attributes\RequiresPhp; /** - * @extends RuleTestCase + * @extends RuleTestCase */ class IfConstantConditionRuleTest extends RuleTestCase { @@ -16,19 +17,24 @@ class IfConstantConditionRuleTest extends RuleTestCase protected function getRule(): Rule { - return new IfConstantConditionRule( - new ConstantConditionRuleHelper( - new ImpossibleCheckTypeHelper( - self::createReflectionProvider(), - $this->getTypeSpecifier(), + // @phpstan-ignore argument.type + return new CompositeRule([ + new IfConstantConditionRule( + new ConstantConditionRuleHelper( + new ImpossibleCheckTypeHelper( + self::createReflectionProvider(), + $this->getTypeSpecifier(), + $this->treatPhpDocTypesAsCertain, + ), $this->treatPhpDocTypesAsCertain, ), + new PossiblyImpureTipHelper(true), + self::getContainer()->getByType(ConstantConditionInTraitHelper::class), $this->treatPhpDocTypesAsCertain, + true, ), - new PossiblyImpureTipHelper(true), - $this->treatPhpDocTypesAsCertain, - true, - ); + new ConstantConditionInTraitRule(), + ]); } protected function shouldTreatPhpDocTypesAsCertain(): bool @@ -230,6 +236,17 @@ public function testBug4284(): void ]); } + public function testInTrait(): void + { + $this->treatPhpDocTypesAsCertain = true; + $this->analyse([__DIR__ . '/data/if-condition-in-trait.php'], [ + [ + 'If condition is always true.', + 19, + ], + ]); + } + public function testBug6822(): void { $this->treatPhpDocTypesAsCertain = true; diff --git a/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeFunctionCallRuleTest.php b/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeFunctionCallRuleTest.php index ce1094a5b20..ddbaa374acf 100644 --- a/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeFunctionCallRuleTest.php +++ b/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeFunctionCallRuleTest.php @@ -3,6 +3,7 @@ namespace PHPStan\Rules\Comparison; use PHPStan\Rules\Rule; +use PHPStan\Testing\CompositeRule; use PHPStan\Testing\RuleTestCase; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\RequiresPhp; @@ -12,7 +13,7 @@ use function count; /** - * @extends RuleTestCase + * @extends RuleTestCase */ class ImpossibleCheckTypeFunctionCallRuleTest extends RuleTestCase { @@ -23,17 +24,22 @@ class ImpossibleCheckTypeFunctionCallRuleTest extends RuleTestCase protected function getRule(): Rule { - return new ImpossibleCheckTypeFunctionCallRule( - new ImpossibleCheckTypeHelper( - self::createReflectionProvider(), - $this->getTypeSpecifier(), + // @phpstan-ignore argument.type + return new CompositeRule([ + new ImpossibleCheckTypeFunctionCallRule( + new ImpossibleCheckTypeHelper( + self::createReflectionProvider(), + $this->getTypeSpecifier(), + $this->treatPhpDocTypesAsCertain, + ), + new PossiblyImpureTipHelper(true), + self::getContainer()->getByType(ConstantConditionInTraitHelper::class), $this->treatPhpDocTypesAsCertain, + $this->reportAlwaysTrueInLastCondition, + true, ), - new PossiblyImpureTipHelper(true), - $this->treatPhpDocTypesAsCertain, - $this->reportAlwaysTrueInLastCondition, - true, - ); + new ConstantConditionInTraitRule(), + ]); } protected function shouldTreatPhpDocTypesAsCertain(): bool @@ -1139,6 +1145,51 @@ public function testBug13628(): void $this->analyse([__DIR__ . '/data/bug-13628.php'], []); } + public function testBug13023(): void + { + $this->treatPhpDocTypesAsCertain = true; + $this->analyse([__DIR__ . '/data/bug-13023.php'], []); + } + + public function testBug9095(): void + { + $this->treatPhpDocTypesAsCertain = true; + $this->analyse([__DIR__ . '/data/bug-9095.php'], []); + } + + #[RequiresPhp('>= 8.1')] + public function testBug7599(): void + { + $this->treatPhpDocTypesAsCertain = true; + $this->analyse([__DIR__ . '/data/bug-7599.php'], []); + } + + #[RequiresPhp('>= 8.0')] + public function testBug13474(): void + { + $this->treatPhpDocTypesAsCertain = true; + $this->analyse([__DIR__ . '/data/bug-13474.php'], []); + } + + public function testBug13687(): void + { + $this->treatPhpDocTypesAsCertain = true; + $this->analyse([__DIR__ . '/data/bug-13687.php'], []); + } + + #[RequiresPhp('>= 8.1')] + public function testBug12798(): void + { + $this->treatPhpDocTypesAsCertain = true; + $this->analyse([__DIR__ . '/data/bug-12798.php'], []); + } + + public function testBug4570(): void + { + $this->treatPhpDocTypesAsCertain = true; + $this->analyse([__DIR__ . '/data/bug-4570.php'], []); + } + public function testBug9666(): void { $tipText = 'Because the type is coming from a PHPDoc, you can turn off this check by setting treatPhpDocTypesAsCertain: false in your %configurationFile%.'; @@ -1218,6 +1269,17 @@ public function testBug13799(): void ]); } + public function testInTrait(): void + { + $this->treatPhpDocTypesAsCertain = true; + $this->analyse([__DIR__ . '/data/impossible-function-call-in-trait.php'], [ + [ + 'Call to function is_string() with int will always evaluate to false.', + 19, + ], + ]); + } + public function testBug12063(): void { $this->treatPhpDocTypesAsCertain = true; diff --git a/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeGenericOverwriteRuleTest.php b/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeGenericOverwriteRuleTest.php index bb98ecf94bb..fae1bdb6e11 100644 --- a/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeGenericOverwriteRuleTest.php +++ b/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeGenericOverwriteRuleTest.php @@ -20,6 +20,7 @@ public function getRule(): Rule true, ), new PossiblyImpureTipHelper(true), + self::getContainer()->getByType(ConstantConditionInTraitHelper::class), true, false, true, diff --git a/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeMethodCallRuleEqualsTest.php b/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeMethodCallRuleEqualsTest.php index f48b76d5389..6d7e5d9f2ed 100644 --- a/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeMethodCallRuleEqualsTest.php +++ b/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeMethodCallRuleEqualsTest.php @@ -20,6 +20,7 @@ public function getRule(): Rule true, ), new PossiblyImpureTipHelper(true), + self::getContainer()->getByType(ConstantConditionInTraitHelper::class), true, false, true, diff --git a/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeMethodCallRuleTest.php b/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeMethodCallRuleTest.php index 8648a231d1b..9dde818f42c 100644 --- a/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeMethodCallRuleTest.php +++ b/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeMethodCallRuleTest.php @@ -3,12 +3,13 @@ namespace PHPStan\Rules\Comparison; use PHPStan\Rules\Rule; +use PHPStan\Testing\CompositeRule; use PHPStan\Testing\RuleTestCase; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\RequiresPhp; /** - * @extends RuleTestCase + * @extends RuleTestCase */ class ImpossibleCheckTypeMethodCallRuleTest extends RuleTestCase { @@ -19,17 +20,22 @@ class ImpossibleCheckTypeMethodCallRuleTest extends RuleTestCase public function getRule(): Rule { - return new ImpossibleCheckTypeMethodCallRule( - new ImpossibleCheckTypeHelper( - self::createReflectionProvider(), - $this->getTypeSpecifier(), + // @phpstan-ignore argument.type + return new CompositeRule([ + new ImpossibleCheckTypeMethodCallRule( + new ImpossibleCheckTypeHelper( + self::createReflectionProvider(), + $this->getTypeSpecifier(), + $this->treatPhpDocTypesAsCertain, + ), + new PossiblyImpureTipHelper(true), + self::getContainer()->getByType(ConstantConditionInTraitHelper::class), $this->treatPhpDocTypesAsCertain, + $this->reportAlwaysTrueInLastCondition, + true, ), - new PossiblyImpureTipHelper(true), - $this->treatPhpDocTypesAsCertain, - $this->reportAlwaysTrueInLastCondition, - true, - ); + new ConstantConditionInTraitRule(), + ]); } protected function shouldTreatPhpDocTypesAsCertain(): bool @@ -302,6 +308,17 @@ public function testBug10337(): void $this->analyse([__DIR__ . '/data/bug-10337.php'], []); } + public function testInTrait(): void + { + $this->treatPhpDocTypesAsCertain = true; + $this->analyse([__DIR__ . '/data/impossible-method-call-in-trait.php'], [ + [ + 'Call to method ImpossibleMethodCallInTrait\TypeChecker::isString() with int will always evaluate to false.', + 30, + ], + ]); + } + public static function getAdditionalConfigFiles(): array { return [ diff --git a/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeStaticMethodCallRuleTest.php b/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeStaticMethodCallRuleTest.php index 1f18b019e47..1846fd0cac3 100644 --- a/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeStaticMethodCallRuleTest.php +++ b/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeStaticMethodCallRuleTest.php @@ -3,12 +3,13 @@ namespace PHPStan\Rules\Comparison; use PHPStan\Rules\Rule; +use PHPStan\Testing\CompositeRule; use PHPStan\Testing\RuleTestCase; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\RequiresPhp; /** - * @extends RuleTestCase + * @extends RuleTestCase */ class ImpossibleCheckTypeStaticMethodCallRuleTest extends RuleTestCase { @@ -19,17 +20,22 @@ class ImpossibleCheckTypeStaticMethodCallRuleTest extends RuleTestCase public function getRule(): Rule { - return new ImpossibleCheckTypeStaticMethodCallRule( - new ImpossibleCheckTypeHelper( - self::createReflectionProvider(), - $this->getTypeSpecifier(), + // @phpstan-ignore argument.type + return new CompositeRule([ + new ImpossibleCheckTypeStaticMethodCallRule( + new ImpossibleCheckTypeHelper( + self::createReflectionProvider(), + $this->getTypeSpecifier(), + $this->treatPhpDocTypesAsCertain, + ), + new PossiblyImpureTipHelper(true), + self::getContainer()->getByType(ConstantConditionInTraitHelper::class), $this->treatPhpDocTypesAsCertain, + $this->reportAlwaysTrueInLastCondition, + true, ), - new PossiblyImpureTipHelper(true), - $this->treatPhpDocTypesAsCertain, - $this->reportAlwaysTrueInLastCondition, - true, - ); + new ConstantConditionInTraitRule(), + ]); } protected function shouldTreatPhpDocTypesAsCertain(): bool @@ -167,6 +173,17 @@ public function testBug13566(): void $this->analyse([__DIR__ . '/data/bug-13566.php'], []); } + public function testInTrait(): void + { + $this->treatPhpDocTypesAsCertain = true; + $this->analyse([__DIR__ . '/data/impossible-static-method-call-in-trait.php'], [ + [ + 'Call to static method ImpossibleStaticMethodCallInTrait\TypeChecker::isString() with int will always evaluate to false.', + 28, + ], + ]); + } + public static function getAdditionalConfigFiles(): array { return [ diff --git a/tests/PHPStan/Rules/Comparison/LogicalXorConstantConditionRuleTest.php b/tests/PHPStan/Rules/Comparison/LogicalXorConstantConditionRuleTest.php index 5ca7530b60a..0987b033036 100644 --- a/tests/PHPStan/Rules/Comparison/LogicalXorConstantConditionRuleTest.php +++ b/tests/PHPStan/Rules/Comparison/LogicalXorConstantConditionRuleTest.php @@ -3,30 +3,37 @@ namespace PHPStan\Rules\Comparison; use PHPStan\Rules\Rule as TRule; +use PHPStan\Testing\CompositeRule; use PHPStan\Testing\RuleTestCase; +use PHPUnit\Framework\Attributes\RequiresPhp; /** - * @extends RuleTestCase + * @extends RuleTestCase */ class LogicalXorConstantConditionRuleTest extends RuleTestCase { protected function getRule(): TRule { - return new LogicalXorConstantConditionRule( - new ConstantConditionRuleHelper( - new ImpossibleCheckTypeHelper( - self::createReflectionProvider(), - $this->getTypeSpecifier(), + // @phpstan-ignore argument.type + return new CompositeRule([ + new LogicalXorConstantConditionRule( + new ConstantConditionRuleHelper( + new ImpossibleCheckTypeHelper( + self::createReflectionProvider(), + $this->getTypeSpecifier(), + $this->shouldTreatPhpDocTypesAsCertain(), + ), $this->shouldTreatPhpDocTypesAsCertain(), ), + new PossiblyImpureTipHelper(true), + self::getContainer()->getByType(ConstantConditionInTraitHelper::class), $this->shouldTreatPhpDocTypesAsCertain(), + false, + true, ), - new PossiblyImpureTipHelper(true), - $this->shouldTreatPhpDocTypesAsCertain(), - false, - true, - ); + new ConstantConditionInTraitRule(), + ]); } public function testRule(): void @@ -70,4 +77,15 @@ public function testRule(): void ]); } + #[RequiresPhp('>= 8.2')] + public function testInTrait(): void + { + $this->analyse([__DIR__ . '/data/logical-xor-in-trait.php'], [ + [ + 'Left side of xor is always false.', + 19, + ], + ]); + } + } diff --git a/tests/PHPStan/Rules/Comparison/MatchExpressionRuleTest.php b/tests/PHPStan/Rules/Comparison/MatchExpressionRuleTest.php index 7ab3586dfec..b0e0274fdab 100644 --- a/tests/PHPStan/Rules/Comparison/MatchExpressionRuleTest.php +++ b/tests/PHPStan/Rules/Comparison/MatchExpressionRuleTest.php @@ -3,11 +3,12 @@ namespace PHPStan\Rules\Comparison; use PHPStan\Rules\Rule; +use PHPStan\Testing\CompositeRule; use PHPStan\Testing\RuleTestCase; use PHPUnit\Framework\Attributes\RequiresPhp; /** - * @extends RuleTestCase + * @extends RuleTestCase */ class MatchExpressionRuleTest extends RuleTestCase { @@ -16,18 +17,23 @@ class MatchExpressionRuleTest extends RuleTestCase protected function getRule(): Rule { - return new MatchExpressionRule( - new ConstantConditionRuleHelper( - new ImpossibleCheckTypeHelper( - self::createReflectionProvider(), - $this->getTypeSpecifier(), + // @phpstan-ignore argument.type + return new CompositeRule([ + new MatchExpressionRule( + new ConstantConditionRuleHelper( + new ImpossibleCheckTypeHelper( + self::createReflectionProvider(), + $this->getTypeSpecifier(), + $this->treatPhpDocTypesAsCertain, + ), $this->treatPhpDocTypesAsCertain, ), + new PossiblyImpureTipHelper(true), + self::getContainer()->getByType(ConstantConditionInTraitHelper::class), $this->treatPhpDocTypesAsCertain, ), - new PossiblyImpureTipHelper(true), - $this->treatPhpDocTypesAsCertain, - ); + new ConstantConditionInTraitRule(), + ]); } protected function shouldTreatPhpDocTypesAsCertain(): bool @@ -489,4 +495,16 @@ public function testBug11310(): void ]); } + #[RequiresPhp('>= 8.0')] + public function testInTrait(): void + { + $this->treatPhpDocTypesAsCertain = true; + $this->analyse([__DIR__ . '/data/match-in-trait.php'], [ + [ + 'Match arm comparison between true and false is always false.', + 21, + ], + ]); + } + } diff --git a/tests/PHPStan/Rules/Comparison/NumberComparisonOperatorsConstantConditionRuleTest.php b/tests/PHPStan/Rules/Comparison/NumberComparisonOperatorsConstantConditionRuleTest.php index 0d96baee651..6011cadf288 100644 --- a/tests/PHPStan/Rules/Comparison/NumberComparisonOperatorsConstantConditionRuleTest.php +++ b/tests/PHPStan/Rules/Comparison/NumberComparisonOperatorsConstantConditionRuleTest.php @@ -3,12 +3,13 @@ namespace PHPStan\Rules\Comparison; use PHPStan\Rules\Rule; +use PHPStan\Testing\CompositeRule; use PHPStan\Testing\RuleTestCase; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\RequiresPhp; /** - * @extends RuleTestCase + * @extends RuleTestCase */ class NumberComparisonOperatorsConstantConditionRuleTest extends RuleTestCase { @@ -19,11 +20,16 @@ class NumberComparisonOperatorsConstantConditionRuleTest extends RuleTestCase protected function getRule(): Rule { - return new NumberComparisonOperatorsConstantConditionRule( - new PossiblyImpureTipHelper(true), - $this->treatPhpDocTypesAsCertain, - true, - ); + // @phpstan-ignore argument.type + return new CompositeRule([ + new NumberComparisonOperatorsConstantConditionRule( + new PossiblyImpureTipHelper(true), + self::getContainer()->getByType(ConstantConditionInTraitHelper::class), + $this->treatPhpDocTypesAsCertain, + true, + ), + new ConstantConditionInTraitRule(), + ]); } protected function shouldPolluteScopeWithAlwaysIterableForeach(): bool @@ -303,6 +309,17 @@ public function testBug12163(): void ]); } + public function testInTrait(): void + { + $this->analyse([__DIR__ . '/data/number-comparison-in-trait.php'], [ + [ + 'Comparison operation ">" between 1 and 0 is always true.', + 19, + 'Because the type is coming from a PHPDoc, you can turn off this check by setting treatPhpDocTypesAsCertain: false in your %configurationFile%.', + ], + ]); + } + public function testBug11146(): void { $this->analyse([__DIR__ . '/data/bug-11146.php'], []); diff --git a/tests/PHPStan/Rules/Comparison/StrictComparisonOfDifferentTypesRuleTest.php b/tests/PHPStan/Rules/Comparison/StrictComparisonOfDifferentTypesRuleTest.php index 9c8e1f25fc9..edad6beaa66 100644 --- a/tests/PHPStan/Rules/Comparison/StrictComparisonOfDifferentTypesRuleTest.php +++ b/tests/PHPStan/Rules/Comparison/StrictComparisonOfDifferentTypesRuleTest.php @@ -4,6 +4,7 @@ use PHPStan\Analyser\RicherScopeGetTypeHelper; use PHPStan\Rules\Rule; +use PHPStan\Testing\CompositeRule; use PHPStan\Testing\RuleTestCase; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\RequiresPhp; @@ -11,7 +12,7 @@ use const PHP_VERSION_ID; /** - * @extends RuleTestCase + * @extends RuleTestCase */ class StrictComparisonOfDifferentTypesRuleTest extends RuleTestCase { @@ -24,13 +25,18 @@ class StrictComparisonOfDifferentTypesRuleTest extends RuleTestCase protected function getRule(): Rule { - return new StrictComparisonOfDifferentTypesRule( - self::getContainer()->getByType(RicherScopeGetTypeHelper::class), - new PossiblyImpureTipHelper(true), - $this->treatPhpDocTypesAsCertain, - $this->reportAlwaysTrueInLastCondition, - true, - ); + // @phpstan-ignore argument.type + return new CompositeRule([ + new StrictComparisonOfDifferentTypesRule( + self::getContainer()->getByType(RicherScopeGetTypeHelper::class), + new PossiblyImpureTipHelper(true), + self::getContainer()->getByType(ConstantConditionInTraitHelper::class), + $this->treatPhpDocTypesAsCertain, + $this->reportAlwaysTrueInLastCondition, + true, + ), + new ConstantConditionInTraitRule(), + ]); } protected function shouldTreatPhpDocTypesAsCertain(): bool @@ -1047,6 +1053,22 @@ public function testBug3761(): void $this->analyse([__DIR__ . '/data/bug-3761.php'], []); } + public function testBug8060(): void + { + $this->analyse([__DIR__ . '/data/bug-8060.php'], []); + } + + #[RequiresPhp('>= 8.2')] + public function testBug9515(): void + { + $this->analyse([__DIR__ . '/data/bug-9515.php'], []); + } + + public function testBug4121(): void + { + $this->analyse([__DIR__ . '/data/bug-4121.php'], []); + } + public function testBug13208(): void { $this->analyse([__DIR__ . '/data/bug-13208.php'], []); @@ -1168,6 +1190,16 @@ public function testPossiblyImpureTip(): void ]); } + public function testInTrait(): void + { + $this->analyse([__DIR__ . '/data/strict-comparison-in-trait.php'], [ + [ + 'Strict comparison using !== between string and null will always evaluate to true.', + 19, + ], + ]); + } + public function testBug11054(): void { $this->analyse([__DIR__ . '/data/bug-11054.php'], [ diff --git a/tests/PHPStan/Rules/Comparison/TernaryOperatorConstantConditionRuleTest.php b/tests/PHPStan/Rules/Comparison/TernaryOperatorConstantConditionRuleTest.php index 4cf6c399ea7..50ab36fdf92 100644 --- a/tests/PHPStan/Rules/Comparison/TernaryOperatorConstantConditionRuleTest.php +++ b/tests/PHPStan/Rules/Comparison/TernaryOperatorConstantConditionRuleTest.php @@ -3,10 +3,12 @@ namespace PHPStan\Rules\Comparison; use PHPStan\Rules\Rule; +use PHPStan\Testing\CompositeRule; use PHPStan\Testing\RuleTestCase; +use PHPUnit\Framework\Attributes\RequiresPhp; /** - * @extends RuleTestCase + * @extends RuleTestCase */ class TernaryOperatorConstantConditionRuleTest extends RuleTestCase { @@ -15,19 +17,24 @@ class TernaryOperatorConstantConditionRuleTest extends RuleTestCase protected function getRule(): Rule { - return new TernaryOperatorConstantConditionRule( - new ConstantConditionRuleHelper( - new ImpossibleCheckTypeHelper( - self::createReflectionProvider(), - $this->getTypeSpecifier(), + // @phpstan-ignore argument.type + return new CompositeRule([ + new TernaryOperatorConstantConditionRule( + new ConstantConditionRuleHelper( + new ImpossibleCheckTypeHelper( + self::createReflectionProvider(), + $this->getTypeSpecifier(), + $this->treatPhpDocTypesAsCertain, + ), $this->treatPhpDocTypesAsCertain, ), + new PossiblyImpureTipHelper(true), + self::getContainer()->getByType(ConstantConditionInTraitHelper::class), $this->treatPhpDocTypesAsCertain, + true, ), - new PossiblyImpureTipHelper(true), - $this->treatPhpDocTypesAsCertain, - true, - ); + new ConstantConditionInTraitRule(), + ]); } protected function shouldTreatPhpDocTypesAsCertain(): bool @@ -99,10 +106,28 @@ public function testBug7580(): void $this->analyse([__DIR__ . '/data/bug-7580.php'], []); } + #[RequiresPhp('>= 8.1')] + public function testBug11949(): void + { + $this->treatPhpDocTypesAsCertain = true; + $this->analyse([__DIR__ . '/data/bug-11949.php'], []); + } + public function testBug3370(): void { $this->treatPhpDocTypesAsCertain = true; $this->analyse([__DIR__ . '/data/bug-3370.php'], []); } + public function testInTrait(): void + { + $this->treatPhpDocTypesAsCertain = true; + $this->analyse([__DIR__ . '/data/ternary-in-trait.php'], [ + [ + 'Ternary operator condition is always true.', + 17, + ], + ]); + } + } diff --git a/tests/PHPStan/Rules/Comparison/WhileLoopAlwaysFalseConditionRuleTest.php b/tests/PHPStan/Rules/Comparison/WhileLoopAlwaysFalseConditionRuleTest.php index 10c0fef19ad..d400dccf665 100644 --- a/tests/PHPStan/Rules/Comparison/WhileLoopAlwaysFalseConditionRuleTest.php +++ b/tests/PHPStan/Rules/Comparison/WhileLoopAlwaysFalseConditionRuleTest.php @@ -3,29 +3,36 @@ namespace PHPStan\Rules\Comparison; use PHPStan\Rules\Rule; +use PHPStan\Testing\CompositeRule; use PHPStan\Testing\RuleTestCase; +use PHPUnit\Framework\Attributes\RequiresPhp; /** - * @extends RuleTestCase + * @extends RuleTestCase */ class WhileLoopAlwaysFalseConditionRuleTest extends RuleTestCase { protected function getRule(): Rule { - return new WhileLoopAlwaysFalseConditionRule( - new ConstantConditionRuleHelper( - new ImpossibleCheckTypeHelper( - self::createReflectionProvider(), - $this->getTypeSpecifier(), + // @phpstan-ignore argument.type + return new CompositeRule([ + new WhileLoopAlwaysFalseConditionRule( + new ConstantConditionRuleHelper( + new ImpossibleCheckTypeHelper( + self::createReflectionProvider(), + $this->getTypeSpecifier(), + $this->shouldTreatPhpDocTypesAsCertain(), + ), $this->shouldTreatPhpDocTypesAsCertain(), ), + new PossiblyImpureTipHelper(true), + self::getContainer()->getByType(ConstantConditionInTraitHelper::class), $this->shouldTreatPhpDocTypesAsCertain(), + true, ), - new PossiblyImpureTipHelper(true), - $this->shouldTreatPhpDocTypesAsCertain(), - true, - ); + new ConstantConditionInTraitRule(), + ]); } public function testRule(): void @@ -43,4 +50,15 @@ public function testRule(): void ]); } + #[RequiresPhp('>= 8.2')] + public function testInTrait(): void + { + $this->analyse([__DIR__ . '/data/while-false-in-trait.php'], [ + [ + 'While loop condition is always false.', + 19, + ], + ]); + } + } diff --git a/tests/PHPStan/Rules/Comparison/WhileLoopAlwaysTrueConditionRuleTest.php b/tests/PHPStan/Rules/Comparison/WhileLoopAlwaysTrueConditionRuleTest.php index f365fb0a9e5..bb9fd1499ff 100644 --- a/tests/PHPStan/Rules/Comparison/WhileLoopAlwaysTrueConditionRuleTest.php +++ b/tests/PHPStan/Rules/Comparison/WhileLoopAlwaysTrueConditionRuleTest.php @@ -3,30 +3,36 @@ namespace PHPStan\Rules\Comparison; use PHPStan\Rules\Rule; +use PHPStan\Testing\CompositeRule; use PHPStan\Testing\RuleTestCase; use PHPUnit\Framework\Attributes\RequiresPhp; /** - * @extends RuleTestCase + * @extends RuleTestCase */ class WhileLoopAlwaysTrueConditionRuleTest extends RuleTestCase { protected function getRule(): Rule { - return new WhileLoopAlwaysTrueConditionRule( - new ConstantConditionRuleHelper( - new ImpossibleCheckTypeHelper( - self::createReflectionProvider(), - $this->getTypeSpecifier(), + // @phpstan-ignore argument.type + return new CompositeRule([ + new WhileLoopAlwaysTrueConditionRule( + new ConstantConditionRuleHelper( + new ImpossibleCheckTypeHelper( + self::createReflectionProvider(), + $this->getTypeSpecifier(), + $this->shouldTreatPhpDocTypesAsCertain(), + ), $this->shouldTreatPhpDocTypesAsCertain(), ), + new PossiblyImpureTipHelper(true), + self::getContainer()->getByType(ConstantConditionInTraitHelper::class), $this->shouldTreatPhpDocTypesAsCertain(), + true, ), - new PossiblyImpureTipHelper(true), - $this->shouldTreatPhpDocTypesAsCertain(), - true, - ); + new ConstantConditionInTraitRule(), + ]); } public function testRule(): void @@ -73,4 +79,14 @@ public function testBug6189(): void ]); } + public function testInTrait(): void + { + $this->analyse([__DIR__ . '/data/while-true-in-trait.php'], [ + [ + 'While loop condition is always true.', + 19, + ], + ]); + } + } diff --git a/tests/PHPStan/Rules/Comparison/data/boolean-and-in-trait.php b/tests/PHPStan/Rules/Comparison/data/boolean-and-in-trait.php new file mode 100644 index 00000000000..a82755c5b3d --- /dev/null +++ b/tests/PHPStan/Rules/Comparison/data/boolean-and-in-trait.php @@ -0,0 +1,58 @@ +doBar() && rand(0, 1)) { + + } + } + + public function doFoo2() + { + // left side: always constant + if ($this->doBar2() && rand(0, 1)) { + + } + } + +} + +class Foo +{ + + use FooTrait; + + public function doBar(): \stdClass + { + + } + + public function doBar2(): \stdClass + { + + } + +} + +class FooAnother +{ + + use FooTrait; + + public function doBar(): ?\stdClass + { + + } + + public function doBar2(): \stdClass + { + + } + +} diff --git a/tests/PHPStan/Rules/Comparison/data/boolean-not-in-trait.php b/tests/PHPStan/Rules/Comparison/data/boolean-not-in-trait.php new file mode 100644 index 00000000000..a51454f5cc9 --- /dev/null +++ b/tests/PHPStan/Rules/Comparison/data/boolean-not-in-trait.php @@ -0,0 +1,58 @@ +doBar()) { + + } + } + + public function doFoo2() + { + // always constant (negation of always-truthy is always false) + if (!$this->doBar2()) { + + } + } + +} + +class Foo +{ + + use FooTrait; + + public function doBar(): \stdClass + { + + } + + public function doBar2(): \stdClass + { + + } + +} + +class FooAnother +{ + + use FooTrait; + + public function doBar(): ?\stdClass + { + + } + + public function doBar2(): \stdClass + { + + } + +} diff --git a/tests/PHPStan/Rules/Comparison/data/boolean-or-in-trait.php b/tests/PHPStan/Rules/Comparison/data/boolean-or-in-trait.php new file mode 100644 index 00000000000..acb20bfb2c1 --- /dev/null +++ b/tests/PHPStan/Rules/Comparison/data/boolean-or-in-trait.php @@ -0,0 +1,58 @@ +doBar() || rand(0, 1)) { + + } + } + + public function doFoo2() + { + // left side: always constant + if ($this->doBar2() || rand(0, 1)) { + + } + } + +} + +class Foo +{ + + use FooTrait; + + public function doBar(): \stdClass + { + + } + + public function doBar2(): \stdClass + { + + } + +} + +class FooAnother +{ + + use FooTrait; + + public function doBar(): ?\stdClass + { + + } + + public function doBar2(): \stdClass + { + + } + +} diff --git a/tests/PHPStan/Rules/Comparison/data/bug-11949.php b/tests/PHPStan/Rules/Comparison/data/bug-11949.php new file mode 100644 index 00000000000..4de1ea766b3 --- /dev/null +++ b/tests/PHPStan/Rules/Comparison/data/bug-11949.php @@ -0,0 +1,68 @@ += 8.1 + +namespace Bug11949; + +function trans(string $key): string +{ + return $key; +} + +trait EnumString +{ + + /** @var array */ + static protected ?array $_translatedValues; + + static public function getValueIndex(string $value): int + { + return ($i = array_search($value, self::NAMES)) === false ? -1 : $i; + } + + /** @return array */ + static public function getTranslatedValues(): array + { + return self::$_translatedValues ??= array_map(static::getTranslatedValue(...), array_combine(self::NAMES, self::NAMES)); + } + + static public function getTranslatedValue(string $value): string + { + return self::TRANSLATION ? trans(self::TRANSLATION . $value) : $value; + } + +} + +abstract class UserStatus +{ + + use EnumString; + + const ACTIVE = 'active'; + const PENDING = 'pending'; + const BLOCKED = 'blocked'; + + protected const NAMES = [ + self::ACTIVE, + self::PENDING, + self::BLOCKED, + ]; + + protected const TRANSLATION = 'users.statuses.'; + +} + +abstract class SystemCheckStatus +{ + + use EnumString; + + const SUCCESS = 'success'; + const FAILURE = 'failure'; + + protected const NAMES = [ + self::SUCCESS, + self::FAILURE, + ]; + + protected const TRANSLATION = ''; + +} diff --git a/tests/PHPStan/Rules/Comparison/data/bug-12267.php b/tests/PHPStan/Rules/Comparison/data/bug-12267.php new file mode 100644 index 00000000000..4d300a9b4eb --- /dev/null +++ b/tests/PHPStan/Rules/Comparison/data/bug-12267.php @@ -0,0 +1,43 @@ +model) { + return; + } + + echo $this->model; + } +} + +class Class1 +{ + /** @use PrintSomething */ + use PrintSomething; + + public function what(): void + { + $this->printIt(); + } +} + +class Class2 +{ + /** @use PrintSomething<\Exception> */ + use PrintSomething; + + public function what(): void + { + $this->printIt(); + } +} diff --git a/tests/PHPStan/Rules/Comparison/data/bug-12798.php b/tests/PHPStan/Rules/Comparison/data/bug-12798.php new file mode 100644 index 00000000000..d6d29084b56 --- /dev/null +++ b/tests/PHPStan/Rules/Comparison/data/bug-12798.php @@ -0,0 +1,53 @@ += 8.1 + +declare(strict_types = 1); + +namespace Bug12798; + +interface Colorable +{ + public function color(): string; +} + +trait HasColors +{ + /** @return array */ + public static function colors(): array + { + /** @phpstan-ignore return.type */ + return array_reduce(self::cases(), function (array $colors, self $case) { + $key = is_subclass_of($case, \BackedEnum::class) ? $case->value : $case->name; + $color = is_subclass_of($case, Colorable::class) ? $case->color() : 'gray'; + + $colors[$key] = $color; + return $colors; + }, []); + } +} + +enum AlertLevelBacked: int implements Colorable +{ + use HasColors; + + case Low = 1; + case Medium = 2; + case Critical = 3; + + public function color(): string + { + return match ($this) { + self::Low => 'green', + self::Medium => 'yellow', + self::Critical => 'red', + }; + } +} + +enum AlertLevel +{ + use HasColors; + + case Low; + case Medium; + case Critical; +} diff --git a/tests/PHPStan/Rules/Comparison/data/bug-13023.php b/tests/PHPStan/Rules/Comparison/data/bug-13023.php new file mode 100644 index 00000000000..dae307df47d --- /dev/null +++ b/tests/PHPStan/Rules/Comparison/data/bug-13023.php @@ -0,0 +1,25 @@ += 8.0 + +namespace Bug13474; + +/** + * @template TValue of mixed + */ +interface ModelInterface +{ + /** + * @return TValue + */ + public function getValue(): mixed; +} + +/** + * @implements ModelInterface + */ +class ModelA implements ModelInterface +{ + #[\Override] + public function getValue(): int + { + return 0; + } +} + +/** + * @implements ModelInterface + */ +class ModelB implements ModelInterface +{ + #[\Override] + public function getValue(): string + { + return 'foo'; + } +} + +/** + * @template T of ModelInterface + */ +trait ModelTrait +{ + /** + * @return T + */ + abstract function model(): ModelInterface; + + /** + * @return template-type + */ + public function getValue(): mixed + { + return $this->model()->getValue(); + } + + public function test(): void + { + if (is_string($this->getValue())) { + echo 'string'; + return; + } + + echo 'other'; + } +} + +class TestA +{ + /** @use ModelTrait */ + use ModelTrait; + + #[\Override] + function model(): ModelA + { + return new ModelA(); + } +} + +class TestB +{ + /** @use ModelTrait */ + use ModelTrait; + + #[\Override] + function model(): ModelB + { + return new ModelB(); + } +} diff --git a/tests/PHPStan/Rules/Comparison/data/bug-13687.php b/tests/PHPStan/Rules/Comparison/data/bug-13687.php new file mode 100644 index 00000000000..0ccb02a4263 --- /dev/null +++ b/tests/PHPStan/Rules/Comparison/data/bug-13687.php @@ -0,0 +1,34 @@ +bar(); + } + + if (property_exists($this, 'baz')) { + $a = $this->baz; + } + } +} + +class A +{ + use MyTrait; + + public string $baz = 'baz'; +} + +class B +{ + use MyTrait; + + public function bar(): void + { + echo 'bar'; + } +} diff --git a/tests/PHPStan/Rules/Comparison/data/bug-4121.php b/tests/PHPStan/Rules/Comparison/data/bug-4121.php new file mode 100644 index 00000000000..a790dbe7f51 --- /dev/null +++ b/tests/PHPStan/Rules/Comparison/data/bug-4121.php @@ -0,0 +1,23 @@ + 'abc', + 'valueToFetch' => '123', + ]; +} + +final class SecondConsumer +{ + use MyLogic; + + private const MY_CONST_ARRAY = [ + 'someValue' => 'abc', + 'someOtherValue' => '123', + ]; +} diff --git a/tests/PHPStan/Rules/Comparison/data/bug-7599.php b/tests/PHPStan/Rules/Comparison/data/bug-7599.php new file mode 100644 index 00000000000..37210e72415 --- /dev/null +++ b/tests/PHPStan/Rules/Comparison/data/bug-7599.php @@ -0,0 +1,41 @@ += 8.1 + +declare(strict_types = 1); + +namespace Bug7599; + +trait TraitForEnum +{ + /** + * @return array + */ + public static function fooMethod(): array + { + return array_map( + fn(self $enum): string => method_exists($enum, 'barMethod') + ? $enum->barMethod() + : $enum->name, + static::cases() + ); + } +} + +enum TestEnum: string +{ + use TraitForEnum; + + case Foo = 'foo'; + case Bar = 'bar'; +} + +enum SecondEnum: string +{ + use TraitForEnum; + + case Baz = 'baz'; + + public function barMethod(): string + { + return 'blah'; + } +} diff --git a/tests/PHPStan/Rules/Comparison/data/bug-7898.php b/tests/PHPStan/Rules/Comparison/data/bug-7898.php index 16e4b813ce4..6fb89d3bd2b 100644 --- a/tests/PHPStan/Rules/Comparison/data/bug-7898.php +++ b/tests/PHPStan/Rules/Comparison/data/bug-7898.php @@ -175,7 +175,7 @@ public function getCountryCode(): string public function getHasDaycationTaxesAndFees(): bool { assertType("array{US: array{bar: array{sales_tax: array{type: 'rate', unit: 'per-room-per-night'}, city_tax: array{type: 'both', unit: 'per-room-per-night'}, resort_fee: array{type: 'both', unit: 'per-room-per-night'}, additional_tax_or_fee: array{type: 'both', unit: 'per-room-per-night'}}, foo: array{tax: array{type: 'rate', unit: 'per-room-per-night'}}}, CA: array{bar: array{goods_and_services_tax: array{type: 'rate', unit: 'per-room-per-night'}, provincial_sales_tax: array{type: 'rate', unit: 'per-room-per-night'}, harmonized_sales_tax: array{type: 'rate', unit: 'per-room-per-night'}, municipal_and_regional_district_tax: array{type: 'rate', unit: 'per-room-per-night'}, additional_tax_or_fee: array{type: 'both', unit: 'per-room-per-night'}}}, SG: array{bar: array{service_charge: array{type: 'rate', unit: 'per-room-per-night'}, tax: array{type: 'rate', unit: 'per-room-per-night'}}}, TH: array{bar: array{service_charge: array{type: 'rate', unit: 'per-room-per-night'}, tax: array{type: 'rate', unit: 'per-room-per-night'}, city_tax: array{type: 'rate', unit: 'per-room-per-night'}}}, AE: array{bar: array{vat: array{type: 'rate', unit: 'per-room-per-night'}, service_charge: array{type: 'rate', unit: 'per-room-per-night'}, municipality_fee: array{type: 'rate', unit: 'per-room-per-night'}, tourism_fee: array{type: 'both', unit: 'per-room-per-night'}, destination_fee: array{type: 'both', unit: 'per-room-per-night'}}}, BH: array{bar: array{vat: array{type: 'rate', unit: 'per-room-per-night'}, service_charge: array{type: 'rate', unit: 'per-room-per-night'}, city_tax: array{type: 'rate', unit: 'per-room-per-night'}}}, HK: array{bar: array{service_charge: array{type: 'rate', unit: 'per-room-per-night'}, tax: array{type: 'rate', unit: 'per-room-per-night'}}}, ES: array{bar: array{city_tax: array{type: 'both', unit: 'per-room-per-night'}}}}", FooEnum::APPLICABLE_TAX_AND_FEES_BY_TYPE); - assertType("array{bar: array{city_tax: array{type: 'both', unit: 'per-room-per-night'}}|array{sales_tax: array{type: 'rate', unit: 'per-room-per-night'}, city_tax: array{type: 'both', unit: 'per-room-per-night'}, resort_fee: array{type: 'both', unit: 'per-room-per-night'}, additional_tax_or_fee: array{type: 'both', unit: 'per-room-per-night'}}, foo?: array{tax: array{type: 'rate', unit: 'per-room-per-night'}}}|array{bar: array{goods_and_services_tax: array{type: 'rate', unit: 'per-room-per-night'}, provincial_sales_tax: array{type: 'rate', unit: 'per-room-per-night'}, harmonized_sales_tax: array{type: 'rate', unit: 'per-room-per-night'}, municipal_and_regional_district_tax: array{type: 'rate', unit: 'per-room-per-night'}, additional_tax_or_fee: array{type: 'both', unit: 'per-room-per-night'}}}|array{bar: array{service_charge: array{type: 'rate', unit: 'per-room-per-night'}, tax: array{type: 'rate', unit: 'per-room-per-night'}, city_tax?: array{type: 'rate', unit: 'per-room-per-night'}}}|array{bar: array{vat: array{type: 'rate', unit: 'per-room-per-night'}, service_charge: array{type: 'rate', unit: 'per-room-per-night'}, city_tax: array{type: 'rate', unit: 'per-room-per-night'}}}|array{bar: array{vat: array{type: 'rate', unit: 'per-room-per-night'}, service_charge: array{type: 'rate', unit: 'per-room-per-night'}, municipality_fee: array{type: 'rate', unit: 'per-room-per-night'}, tourism_fee: array{type: 'both', unit: 'per-room-per-night'}, destination_fee: array{type: 'both', unit: 'per-room-per-night'}}}", FooEnum::APPLICABLE_TAX_AND_FEES_BY_TYPE[$this->getCountryCode()]); + assertType("array{bar: array{city_tax: array{type: 'both', unit: 'per-room-per-night'}}}|array{bar: array{goods_and_services_tax: array{type: 'rate', unit: 'per-room-per-night'}, provincial_sales_tax: array{type: 'rate', unit: 'per-room-per-night'}, harmonized_sales_tax: array{type: 'rate', unit: 'per-room-per-night'}, municipal_and_regional_district_tax: array{type: 'rate', unit: 'per-room-per-night'}, additional_tax_or_fee: array{type: 'both', unit: 'per-room-per-night'}}}|array{bar: array{sales_tax: array{type: 'rate', unit: 'per-room-per-night'}, city_tax: array{type: 'both', unit: 'per-room-per-night'}, resort_fee: array{type: 'both', unit: 'per-room-per-night'}, additional_tax_or_fee: array{type: 'both', unit: 'per-room-per-night'}}, foo: array{tax: array{type: 'rate', unit: 'per-room-per-night'}}}|array{bar: array{service_charge: array{type: 'rate', unit: 'per-room-per-night'}, tax: array{type: 'rate', unit: 'per-room-per-night'}, city_tax?: array{type: 'rate', unit: 'per-room-per-night'}}}|array{bar: array{vat: array{type: 'rate', unit: 'per-room-per-night'}, service_charge: array{type: 'rate', unit: 'per-room-per-night'}, city_tax: array{type: 'rate', unit: 'per-room-per-night'}}}|array{bar: array{vat: array{type: 'rate', unit: 'per-room-per-night'}, service_charge: array{type: 'rate', unit: 'per-room-per-night'}, municipality_fee: array{type: 'rate', unit: 'per-room-per-night'}, tourism_fee: array{type: 'both', unit: 'per-room-per-night'}, destination_fee: array{type: 'both', unit: 'per-room-per-night'}}}", FooEnum::APPLICABLE_TAX_AND_FEES_BY_TYPE[$this->getCountryCode()]); return array_key_exists(FooEnum::FOO_TYPE, FooEnum::APPLICABLE_TAX_AND_FEES_BY_TYPE[$this->getCountryCode()]); } diff --git a/tests/PHPStan/Rules/Comparison/data/bug-8060.php b/tests/PHPStan/Rules/Comparison/data/bug-8060.php new file mode 100644 index 00000000000..b762a452ba5 --- /dev/null +++ b/tests/PHPStan/Rules/Comparison/data/bug-8060.php @@ -0,0 +1,39 @@ +getAnything(); + + if ($anything !== null) { + return; + } + + echo 'foo'; + } + + abstract protected function getAnything(): ?string; +} + +class Example +{ + use ExampleTrait; + + protected function getAnything(): string + { + return 'foo'; + } +} + +class Example2 +{ + use ExampleTrait; + + protected function getAnything(): ?string + { + return 'foo'; + } +} diff --git a/tests/PHPStan/Rules/Comparison/data/bug-9095.php b/tests/PHPStan/Rules/Comparison/data/bug-9095.php new file mode 100644 index 00000000000..32fa108496f --- /dev/null +++ b/tests/PHPStan/Rules/Comparison/data/bug-9095.php @@ -0,0 +1,34 @@ +bar(); + } +} + +class EmptyClass +{ + use SomeTrait; +} + +trait SomeTrait +{ + public function bar(): void + { + if (property_exists($this, 'message')) { + if (!is_string($this->message)) { + return; + } + + echo $this->message . "\n"; + } + } +} diff --git a/tests/PHPStan/Rules/Comparison/data/bug-9515.php b/tests/PHPStan/Rules/Comparison/data/bug-9515.php new file mode 100644 index 00000000000..07667db002f --- /dev/null +++ b/tests/PHPStan/Rules/Comparison/data/bug-9515.php @@ -0,0 +1,41 @@ += 8.2 + +declare(strict_types = 1); + +namespace Bug9515; + +trait Foo +{ + abstract public function getFoo(): ?string; + + public function getName(): string + { + $str = 'Hello'; + + if ($this->getFoo() !== null) { + $str .= ' World'; + } + + return $str; + } +} + +class Bar +{ + use Foo; + + public function getFoo(): string + { + return "Bar"; + } +} + +class Zar +{ + use Foo; + + public function getFoo(): null + { + return null; + } +} diff --git a/tests/PHPStan/Rules/Comparison/data/do-while-in-trait.php b/tests/PHPStan/Rules/Comparison/data/do-while-in-trait.php new file mode 100644 index 00000000000..05c5fb89007 --- /dev/null +++ b/tests/PHPStan/Rules/Comparison/data/do-while-in-trait.php @@ -0,0 +1,56 @@ += 8.2 + +namespace DoWhileInTrait; + +trait FooTrait +{ + + public function doFoo() + { + // sometimes constant, sometimes not + do { + } while ($this->doBar()); + } + + public function doFoo2() + { + // always falsy + do { + } while ($this->doBar2()); + } + +} + +class Foo +{ + + use FooTrait; + + public function doBar(): null + { + + } + + public function doBar2(): null + { + + } + +} + +class FooAnother +{ + + use FooTrait; + + public function doBar(): ?\stdClass + { + + } + + public function doBar2(): null + { + + } + +} diff --git a/tests/PHPStan/Rules/Comparison/data/elseif-condition-in-trait.php b/tests/PHPStan/Rules/Comparison/data/elseif-condition-in-trait.php new file mode 100644 index 00000000000..0ec93827730 --- /dev/null +++ b/tests/PHPStan/Rules/Comparison/data/elseif-condition-in-trait.php @@ -0,0 +1,62 @@ += 8.2 + +namespace ElseIfConditionInTrait; + +trait FooTrait +{ + + public function doFoo() + { + $x = rand(0, 1); + // sometimes falsy, sometimes not + if ($x) { + } elseif ($this->doBar()) { + + } + } + + public function doFoo2() + { + $x = rand(0, 1); + // always falsy + if ($x) { + } elseif ($this->doBar2()) { + + } + } + +} + +class Foo +{ + + use FooTrait; + + public function doBar(): null + { + + } + + public function doBar2(): null + { + + } + +} + +class FooAnother +{ + + use FooTrait; + + public function doBar(): ?\stdClass + { + + } + + public function doBar2(): null + { + + } + +} diff --git a/tests/PHPStan/Rules/Comparison/data/if-condition-in-trait.php b/tests/PHPStan/Rules/Comparison/data/if-condition-in-trait.php new file mode 100644 index 00000000000..9065569d00e --- /dev/null +++ b/tests/PHPStan/Rules/Comparison/data/if-condition-in-trait.php @@ -0,0 +1,58 @@ +doBar()) { + + } + } + + public function doFoo2() + { + // always truthy + if ($this->doBar2()) { + + } + } + +} + +class Foo +{ + + use FooTrait; + + public function doBar(): \stdClass + { + + } + + public function doBar2(): \stdClass + { + + } + +} + +class FooAnother +{ + + use FooTrait; + + public function doBar(): ?\stdClass + { + + } + + public function doBar2(): \stdClass + { + + } + +} diff --git a/tests/PHPStan/Rules/Comparison/data/impossible-function-call-in-trait.php b/tests/PHPStan/Rules/Comparison/data/impossible-function-call-in-trait.php new file mode 100644 index 00000000000..9b1123c1181 --- /dev/null +++ b/tests/PHPStan/Rules/Comparison/data/impossible-function-call-in-trait.php @@ -0,0 +1,59 @@ +doBar())) { + + } + } + + public function doFoo2() + { + // always false + if (is_string($this->doBar2())) { + + } + } + +} + +class Foo +{ + + use FooTrait; + + public function doBar(): int + { + + } + + public function doBar2(): int + { + + } + +} + +class FooAnother +{ + + use FooTrait; + + /** @return int|string */ + public function doBar() + { + + } + + public function doBar2(): int + { + + } + +} diff --git a/tests/PHPStan/Rules/Comparison/data/impossible-method-call-in-trait.php b/tests/PHPStan/Rules/Comparison/data/impossible-method-call-in-trait.php new file mode 100644 index 00000000000..86c0de8769a --- /dev/null +++ b/tests/PHPStan/Rules/Comparison/data/impossible-method-call-in-trait.php @@ -0,0 +1,70 @@ +isString($this->doBar())) { + + } + } + + public function doFoo2() + { + $checker = new TypeChecker(); + // always false + if ($checker->isString($this->doBar2())) { + + } + } + +} + +class Foo +{ + + use FooTrait; + + public function doBar(): int + { + + } + + public function doBar2(): int + { + + } + +} + +class FooAnother +{ + + use FooTrait; + + /** @return int|string */ + public function doBar() + { + + } + + public function doBar2(): int + { + + } + +} diff --git a/tests/PHPStan/Rules/Comparison/data/impossible-static-method-call-in-trait.php b/tests/PHPStan/Rules/Comparison/data/impossible-static-method-call-in-trait.php new file mode 100644 index 00000000000..b11bf5ba709 --- /dev/null +++ b/tests/PHPStan/Rules/Comparison/data/impossible-static-method-call-in-trait.php @@ -0,0 +1,68 @@ +doBar())) { + + } + } + + public function doFoo2() + { + // always false + if (TypeChecker::isString($this->doBar2())) { + + } + } + +} + +class Foo +{ + + use FooTrait; + + public function doBar(): int + { + + } + + public function doBar2(): int + { + + } + +} + +class FooAnother +{ + + use FooTrait; + + /** @return int|string */ + public function doBar() + { + + } + + public function doBar2(): int + { + + } + +} diff --git a/tests/PHPStan/Rules/Comparison/data/logical-xor-in-trait.php b/tests/PHPStan/Rules/Comparison/data/logical-xor-in-trait.php new file mode 100644 index 00000000000..9a9c140b35b --- /dev/null +++ b/tests/PHPStan/Rules/Comparison/data/logical-xor-in-trait.php @@ -0,0 +1,58 @@ += 8.2 + +namespace LogicalXorInTrait; + +trait FooTrait +{ + + public function doFoo() + { + // left side: sometimes constant, sometimes not + if ($this->doBar() xor rand(0, 1)) { + + } + } + + public function doFoo2() + { + // left side: always constant (always false) + if ($this->doBar2() xor rand(0, 1)) { + + } + } + +} + +class Foo +{ + + use FooTrait; + + public function doBar(): null + { + + } + + public function doBar2(): null + { + + } + +} + +class FooAnother +{ + + use FooTrait; + + public function doBar(): ?\stdClass + { + + } + + public function doBar2(): null + { + + } + +} diff --git a/tests/PHPStan/Rules/Comparison/data/loose-comparison-in-trait.php b/tests/PHPStan/Rules/Comparison/data/loose-comparison-in-trait.php new file mode 100644 index 00000000000..9456460a62f --- /dev/null +++ b/tests/PHPStan/Rules/Comparison/data/loose-comparison-in-trait.php @@ -0,0 +1,61 @@ +doBar() == null) { + + } + } + + public function doFoo2() + { + // always false + if ($this->doBar2() == null) { + + } + } + +} + +class Foo +{ + + use FooTrait; + + /** @return 1 */ + public function doBar(): int + { + + } + + /** @return 1 */ + public function doBar2(): int + { + + } + +} + +class FooAnother +{ + + use FooTrait; + + public function doBar(): ?int + { + + } + + /** @return 1 */ + public function doBar2(): int + { + + } + +} diff --git a/tests/PHPStan/Rules/Comparison/data/match-in-trait.php b/tests/PHPStan/Rules/Comparison/data/match-in-trait.php new file mode 100644 index 00000000000..bf257ff0550 --- /dev/null +++ b/tests/PHPStan/Rules/Comparison/data/match-in-trait.php @@ -0,0 +1,60 @@ += 8.2 + +namespace MatchInTrait; + +trait FooTrait +{ + + public function doFoo() + { + // sometimes constant, sometimes not + match (true) { + $this->doBar() => 'yes', + default => 'no', + }; + } + + public function doFoo2() + { + // always false + match (true) { + $this->doBar2() => 'yes', + default => 'no', + }; + } + +} + +class Foo +{ + + use FooTrait; + + public function doBar(): false + { + + } + + public function doBar2(): false + { + + } + +} + +class FooAnother +{ + + use FooTrait; + + public function doBar(): bool + { + + } + + public function doBar2(): false + { + + } + +} diff --git a/tests/PHPStan/Rules/Comparison/data/number-comparison-in-trait.php b/tests/PHPStan/Rules/Comparison/data/number-comparison-in-trait.php new file mode 100644 index 00000000000..be62971b3e0 --- /dev/null +++ b/tests/PHPStan/Rules/Comparison/data/number-comparison-in-trait.php @@ -0,0 +1,61 @@ +doBar() > 0) { + + } + } + + public function doFoo2() + { + // always constant + if ($this->doBar2() > 0) { + + } + } + +} + +class Foo +{ + + use FooTrait; + + /** @return 1 */ + public function doBar(): int + { + + } + + /** @return 1 */ + public function doBar2(): int + { + + } + +} + +class FooAnother +{ + + use FooTrait; + + public function doBar(): int + { + + } + + /** @return 1 */ + public function doBar2(): int + { + + } + +} diff --git a/tests/PHPStan/Rules/Comparison/data/strict-comparison-in-trait.php b/tests/PHPStan/Rules/Comparison/data/strict-comparison-in-trait.php new file mode 100644 index 00000000000..ee3b08ecd11 --- /dev/null +++ b/tests/PHPStan/Rules/Comparison/data/strict-comparison-in-trait.php @@ -0,0 +1,58 @@ +doBar() !== null) { + + } + } + + public function doFoo2() + { + // always not nullable + if ($this->doBar2() !== null) { + + } + } + +} + +class Foo +{ + + use FooTrait; + + public function doBar(): string + { + + } + + public function doBar2(): string + { + + } + +} + +class FooAnother +{ + + use FooTrait; + + public function doBar(): ?string + { + + } + + public function doBar2(): string + { + + } + +} diff --git a/tests/PHPStan/Rules/Comparison/data/ternary-in-trait.php b/tests/PHPStan/Rules/Comparison/data/ternary-in-trait.php new file mode 100644 index 00000000000..5c0376725cc --- /dev/null +++ b/tests/PHPStan/Rules/Comparison/data/ternary-in-trait.php @@ -0,0 +1,54 @@ +doBar() ? 'yes' : 'no'; + } + + public function doFoo2() + { + // always truthy + $x = $this->doBar2() ? 'yes' : 'no'; + } + +} + +class Foo +{ + + use FooTrait; + + public function doBar(): \stdClass + { + + } + + public function doBar2(): \stdClass + { + + } + +} + +class FooAnother +{ + + use FooTrait; + + public function doBar(): ?\stdClass + { + + } + + public function doBar2(): \stdClass + { + + } + +} diff --git a/tests/PHPStan/Rules/Comparison/data/while-false-in-trait.php b/tests/PHPStan/Rules/Comparison/data/while-false-in-trait.php new file mode 100644 index 00000000000..a5538629436 --- /dev/null +++ b/tests/PHPStan/Rules/Comparison/data/while-false-in-trait.php @@ -0,0 +1,58 @@ += 8.2 + +namespace WhileFalseInTrait; + +trait FooTrait +{ + + public function doFoo() + { + // sometimes falsy, sometimes not + while ($this->doBar()) { + + } + } + + public function doFoo2() + { + // always falsy + while ($this->doBar2()) { + + } + } + +} + +class Foo +{ + + use FooTrait; + + public function doBar(): null + { + + } + + public function doBar2(): null + { + + } + +} + +class FooAnother +{ + + use FooTrait; + + public function doBar(): ?\stdClass + { + + } + + public function doBar2(): null + { + + } + +} diff --git a/tests/PHPStan/Rules/Comparison/data/while-true-in-trait.php b/tests/PHPStan/Rules/Comparison/data/while-true-in-trait.php new file mode 100644 index 00000000000..0ea5164fa8b --- /dev/null +++ b/tests/PHPStan/Rules/Comparison/data/while-true-in-trait.php @@ -0,0 +1,58 @@ +doBar()) { + + } + } + + public function doFoo2(): void + { + // always truthy + while ($this->doBar2()) { + + } + } + +} + +class Foo +{ + + use FooTrait; + + public function doBar(): \stdClass + { + + } + + public function doBar2(): \stdClass + { + + } + +} + +class FooAnother +{ + + use FooTrait; + + public function doBar(): ?\stdClass + { + + } + + public function doBar2(): \stdClass + { + + } + +} diff --git a/tests/PHPStan/Rules/Functions/CallCallablesRuleTest.php b/tests/PHPStan/Rules/Functions/CallCallablesRuleTest.php index 11ec549385f..f226cb36585 100644 --- a/tests/PHPStan/Rules/Functions/CallCallablesRuleTest.php +++ b/tests/PHPStan/Rules/Functions/CallCallablesRuleTest.php @@ -364,6 +364,18 @@ public function testPipeOperator(): void ]); } + #[RequiresPhp('>= 8.0')] + public function testConstantParameterCheckCallables(): void + { + $this->checkExplicitMixed = false; + $this->analyse([__DIR__ . '/data/constant-parameter-check-callables.php'], [ + [ + 'Constant SORT_REGULAR is not allowed for parameter #2 $flags of closure.', + 10, + ], + ]); + } + public function testBug4608(): void { $this->analyse([__DIR__ . '/data/bug-4608-callables.php'], [ diff --git a/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php b/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php index 1860256abb2..c82118cbff5 100644 --- a/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php +++ b/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php @@ -1337,7 +1337,7 @@ public function testBug2911(): void { $this->analyse([__DIR__ . '/data/bug-2911.php'], [ [ - 'Parameter #1 $array of function Bug2911\bar expects array{bar: string}, non-empty-array given.', + 'Parameter #1 $array of function Bug2911\bar expects array{bar: string, ...}, non-empty-array given.', 23, ], ]); @@ -1605,9 +1605,14 @@ public function testBenevolentSuperglobalKeys(): void $this->analyse([__DIR__ . '/data/benevolent-superglobal-keys.php'], []); } + #[RequiresPhp('>= 8.0')] public function testFileParams(): void { $this->analyse([__DIR__ . '/data/file.php'], [ + [ + 'Constant FILE_APPEND is not allowed for parameter #2 $flags of function file.', + 16, + ], [ 'Parameter #2 $flags of function file expects 0|1|2|3|4|5|6|7|16|17|18|19|20|21|22|23, 8 given.', 16, @@ -1615,9 +1620,14 @@ public function testFileParams(): void ]); } + #[RequiresPhp('>= 8.0')] public function testFlockParams(): void { $this->analyse([__DIR__ . '/data/flock.php'], [ + [ + 'Constant FILE_APPEND is not allowed for parameter #2 $operation of function flock.', + 45, + ], [ 'Parameter #2 $operation of function flock expects int<0, 7>, 8 given.', 45, @@ -1633,6 +1643,10 @@ public function testJsonValidate(): void 'Parameter #2 $depth of function json_validate expects int<1, max>, 0 given.', 6, ], + [ + 'Constant JSON_BIGINT_AS_STRING is not allowed for parameter #3 $flags of function json_validate.', + 7, + ], [ 'Parameter #3 $flags of function json_validate expects 0|1048576, 2 given.', 7, @@ -2817,6 +2831,76 @@ public function testBug14312b(): void $this->analyse([__DIR__ . '/data/bug-14312b.php'], []); } + #[RequiresPhp('>= 8.0')] + public function testConstantParameterCheck(): void + { + $this->analyse([__DIR__ . '/data/constant-parameter-check.php'], [ + [ + 'Constant SORT_REGULAR is not allowed for parameter #2 $flags of function json_encode.', + 12, + ], + [ + 'Constant SORT_REGULAR is not allowed for parameter #2 $flags of function json_encode.', + 21, + ], + [ + 'Constants SORT_NUMERIC, SORT_STRING cannot be combined for parameter #2 $flags of function sort.', + 27, + ], + [ + 'Constants SORT_NUMERIC, SORT_STRING cannot be combined for parameter #2 $flags of function sort.', + 30, + ], + [ + 'Constants ENT_QUOTES, ENT_NOQUOTES cannot be combined for parameter #2 $flags of function htmlspecialchars.', + 33, + ], + [ + 'Constants ENT_HTML401, ENT_HTML5 cannot be combined for parameter #2 $flags of function htmlspecialchars.', + 33, + ], + [ + 'Constant SORT_REGULAR is not allowed for parameter #2 $filter of function filter_var.', + 39, + ], + [ + 'Constant JSON_PRETTY_PRINT is not allowed for parameter #4 $flags of function json_decode.', + 51, + ], + [ + 'Constants LOCK_SH, LOCK_EX cannot be combined for parameter #2 $operation of function flock.', + 54, + ], + [ + 'Constant SORT_REGULAR is not allowed for parameter $flags of function json_encode.', + 70, + ], + [ + 'Combining constants with | is not allowed for parameter #2 $flags of function array_unique.', + 76, + ], + [ + 'Combining constants with | is not allowed for parameter #2 $filter of function filter_var.', + 79, + ], + [ + 'Constant JSON_THROW_ON_ERROR is not allowed for parameter #3 $depth of function json_decode.', + 99, + ], + ]); + } + + #[RequiresPhp('>= 8.0')] + public function testBug12850(): void + { + $this->analyse([__DIR__ . '/data/bug-12850.php'], [ + [ + 'Constants LOCK_EX, LOCK_SH cannot be combined for parameter #2 $operation of function flock.', + 9, + ], + ]); + } + public function testBug4608(): void { $paramName = PHP_VERSION_ID >= 80000 ? 'callback' : 'function'; @@ -2824,6 +2908,7 @@ public function testBug4608(): void [ sprintf("Parameter #1 \$%s of function call_user_func expects callable(): mixed, array{class@anonymous/tests/PHPStan/Rules/Functions/data/bug-4608-call-user-func.php:5, 'abc'|'not_abc'} given.", $paramName), 11, + ], ]); } @@ -2876,4 +2961,15 @@ public function testBug11894(): void $this->analyse([__DIR__ . '/data/bug-11894.php'], []); } + public function testBug11494(): void + { + $this->analyse([__DIR__ . '/data/bug-11494.php'], [ + [ + 'Parameter #1 $a of function Bug11494\test expects array{long: string, details: string}|array{short: string}, array{short: \'thing\', extra: \'other\'} given.', + 18, + "• Type #1 from the union: Array does not have offset 'long'.\n• Type #2 from the union: Sealed array shape does not accept array with extra key 'extra'.", + ], + ]); + } + } diff --git a/tests/PHPStan/Rules/Functions/CallUserFuncRuleTest.php b/tests/PHPStan/Rules/Functions/CallUserFuncRuleTest.php index 7dda2aeb20a..a6181d6732c 100644 --- a/tests/PHPStan/Rules/Functions/CallUserFuncRuleTest.php +++ b/tests/PHPStan/Rules/Functions/CallUserFuncRuleTest.php @@ -183,4 +183,15 @@ public function testNoNamedArguments(): void ]); } + #[RequiresPhp('>= 8.0')] + public function testConstantParameterCheckCallUserFunc(): void + { + $this->analyse([__DIR__ . '/data/constant-parameter-check-call-user-func.php'], [ + [ + 'Constant SORT_REGULAR is not allowed for parameter #2 $flags of callable passed to call_user_func().', + 9, + ], + ]); + } + } diff --git a/tests/PHPStan/Rules/Functions/ExistingClassesInTypehintsRuleTest.php b/tests/PHPStan/Rules/Functions/ExistingClassesInTypehintsRuleTest.php index 87554a9f53a..63e7eed92d3 100644 --- a/tests/PHPStan/Rules/Functions/ExistingClassesInTypehintsRuleTest.php +++ b/tests/PHPStan/Rules/Functions/ExistingClassesInTypehintsRuleTest.php @@ -111,6 +111,10 @@ public function testExistingClassInTypehint(): void 'Template type T of function TestFunctionTypehints\templateTypeMissingInParameter() is not referenced in a parameter.', 96, ], + [ + 'Parameter $a of function TestFunctionTypehints\nonexistentClassesInUnsealedExtras() has invalid type TestFunctionTypehints\NonexistentUnsealedValueClass.', + 104, + ], ]); } diff --git a/tests/PHPStan/Rules/Functions/ReturnTypeRuleTest.php b/tests/PHPStan/Rules/Functions/ReturnTypeRuleTest.php index 272fc1a39e9..da3ba5d6d56 100644 --- a/tests/PHPStan/Rules/Functions/ReturnTypeRuleTest.php +++ b/tests/PHPStan/Rules/Functions/ReturnTypeRuleTest.php @@ -438,4 +438,17 @@ public function testBug14428(): void $this->analyse([__DIR__ . '/../../Analyser/nsrt/bug-14428.php'], []); } + public function testBug13565(): void + { + $this->checkNullables = true; + $this->checkExplicitMixed = true; + $this->analyse([__DIR__ . '/data/bug-13565.php'], [ + [ + 'Function Bug13565\x() should return array{name: string} but returns array{name: \'string\', email: Bug13565\NotAString}.', + 11, + 'Sealed array shape does not accept array with extra key \'email\'.', + ], + ]); + } + } diff --git a/tests/PHPStan/Rules/Functions/data/bug-11494.php b/tests/PHPStan/Rules/Functions/data/bug-11494.php new file mode 100644 index 00000000000..61f276b3f95 --- /dev/null +++ b/tests/PHPStan/Rules/Functions/data/bug-11494.php @@ -0,0 +1,18 @@ + 'thing', 'extra' => 'other']); diff --git a/tests/PHPStan/Rules/Functions/data/bug-11518.php b/tests/PHPStan/Rules/Functions/data/bug-11518.php index 0e9ad45d9a1..0c5472039c1 100644 --- a/tests/PHPStan/Rules/Functions/data/bug-11518.php +++ b/tests/PHPStan/Rules/Functions/data/bug-11518.php @@ -4,7 +4,7 @@ /** * @param mixed[] $a - * @return array{thing: mixed} + * @return array{thing: mixed, ...} * */ function blah(array $a): array { diff --git a/tests/PHPStan/Rules/Functions/data/bug-11533.php b/tests/PHPStan/Rules/Functions/data/bug-11533.php index 0b1a98401ba..69e3ee684e2 100644 --- a/tests/PHPStan/Rules/Functions/data/bug-11533.php +++ b/tests/PHPStan/Rules/Functions/data/bug-11533.php @@ -13,7 +13,7 @@ function hello(array $param): void world($param); } -/** @param array{need: string, field: string} $param */ +/** @param array{need: string, field: string, ...} $param */ function world(array $param): void { } diff --git a/tests/PHPStan/Rules/Functions/data/bug-12850.php b/tests/PHPStan/Rules/Functions/data/bug-12850.php new file mode 100644 index 00000000000..fa8a41ab5f0 --- /dev/null +++ b/tests/PHPStan/Rules/Functions/data/bug-12850.php @@ -0,0 +1,18 @@ + 'string', 'email' => new NotAString()]; +} + +/** + * @return array{name: string, email?: string} + */ +function y(): array { return x(); } + +function send_mail(string $val): void { echo "sending mail to $val"; } diff --git a/tests/PHPStan/Rules/Functions/data/bug-2911.php b/tests/PHPStan/Rules/Functions/data/bug-2911.php index 194b8a3c0a3..4eec57aa481 100644 --- a/tests/PHPStan/Rules/Functions/data/bug-2911.php +++ b/tests/PHPStan/Rules/Functions/data/bug-2911.php @@ -25,7 +25,7 @@ function foo2(array $array): void { /** - * @param array{bar: string} $array + * @param array{bar: string, ...} $array */ function bar(array $array): void { } diff --git a/tests/PHPStan/Rules/Functions/data/bug-3931.php b/tests/PHPStan/Rules/Functions/data/bug-3931.php index d5eb4d83a3a..637927871d4 100644 --- a/tests/PHPStan/Rules/Functions/data/bug-3931.php +++ b/tests/PHPStan/Rules/Functions/data/bug-3931.php @@ -7,11 +7,11 @@ /** * @template T of array * @param T $arr - * @return T & array{mykey: int} + * @return T & array{mykey: int, ...} */ function addSomeKey(array $arr, int $value): array { $arr['mykey'] = $value; - assertType("T of array (function Bug3931\\addSomeKey(), argument)&hasOffsetValue('mykey', int)&non-empty-array", $arr); + assertType("T of array (function Bug3931\addSomeKey(), argument)&hasOffsetValue('mykey', int)&non-empty-array", $arr); return $arr; } @@ -22,5 +22,5 @@ function addSomeKey(array $arr, int $value): array { function test(array $arr): void { $r = addSomeKey($arr, 1); - assertType("array{mykey: int}", $r); // could be better, the T part currently disappears + assertType('array{mykey: int, ...}', $r); } diff --git a/tests/PHPStan/Rules/Functions/data/bug-7156.php b/tests/PHPStan/Rules/Functions/data/bug-7156.php index 209a9decf54..3757e952dc1 100644 --- a/tests/PHPStan/Rules/Functions/data/bug-7156.php +++ b/tests/PHPStan/Rules/Functions/data/bug-7156.php @@ -6,7 +6,7 @@ use function PHPStan\Testing\assertType; /** - * @param array{value: string} $foo + * @param array{value: string, ...} $foo */ function foo($foo): void { print_r($foo); diff --git a/tests/PHPStan/Rules/Functions/data/constant-parameter-check-call-user-func.php b/tests/PHPStan/Rules/Functions/data/constant-parameter-check-call-user-func.php new file mode 100644 index 00000000000..50b1ab12822 --- /dev/null +++ b/tests/PHPStan/Rules/Functions/data/constant-parameter-check-call-user-func.php @@ -0,0 +1,9 @@ += 8.0 + +namespace ConstantParameterCheckCallUserFunc; + +// call_user_func with correct constant +call_user_func('json_encode', [], JSON_PRETTY_PRINT); + +// call_user_func with wrong constant +call_user_func('json_encode', [], SORT_REGULAR); diff --git a/tests/PHPStan/Rules/Functions/data/constant-parameter-check-callables.php b/tests/PHPStan/Rules/Functions/data/constant-parameter-check-callables.php new file mode 100644 index 00000000000..6acb0ba1876 --- /dev/null +++ b/tests/PHPStan/Rules/Functions/data/constant-parameter-check-callables.php @@ -0,0 +1,10 @@ +} $a + */ +function nonexistentClassesInUnsealedExtras(array $a) +{ + +} diff --git a/tests/PHPStan/Rules/Generics/ClassAncestorsRuleTest.php b/tests/PHPStan/Rules/Generics/ClassAncestorsRuleTest.php index 85120fe0d2f..10614f59930 100644 --- a/tests/PHPStan/Rules/Generics/ClassAncestorsRuleTest.php +++ b/tests/PHPStan/Rules/Generics/ClassAncestorsRuleTest.php @@ -262,6 +262,10 @@ public function testCrossCheckInterfaces(): void 'Interface IteratorAggregate specifies template type TValue of interface Traversable as string but it\'s already specified as CrossCheckInterfaces\Item.', 19, ], + [ + 'Interface IteratorAggregate specifies template type TValue of interface Traversable as array{a: int, ...} but it\'s already specified as array{a: int, ...}.', + 67, + ], ]); } diff --git a/tests/PHPStan/Rules/Generics/MethodSignatureVarianceRuleTest.php b/tests/PHPStan/Rules/Generics/MethodSignatureVarianceRuleTest.php index 56a7beab2b4..7f19aa0edeb 100644 --- a/tests/PHPStan/Rules/Generics/MethodSignatureVarianceRuleTest.php +++ b/tests/PHPStan/Rules/Generics/MethodSignatureVarianceRuleTest.php @@ -103,6 +103,10 @@ public function testRule(): void 'Template type X is declared as covariant, but occurs in invariant position in return type of method MethodSignatureVariance\Covariant\C::m().', 71, ], + [ + 'Template type X is declared as covariant, but occurs in contravariant position in parameter o of method MethodSignatureVariance\Covariant\C::o().', + 77, + ], ]); $this->analyse([__DIR__ . '/data/method-signature-variance-contravariant.php'], [ diff --git a/tests/PHPStan/Rules/Generics/data/cross-check-interfaces.php b/tests/PHPStan/Rules/Generics/data/cross-check-interfaces.php index 76cbd59de86..a9d4b444d74 100644 --- a/tests/PHPStan/Rules/Generics/data/cross-check-interfaces.php +++ b/tests/PHPStan/Rules/Generics/data/cross-check-interfaces.php @@ -34,3 +34,40 @@ public function getIterator(): \Traversable return new \ArrayIterator([]); } } + +/** + * @extends \Traversable}> + */ +interface ShapedItemListInterface extends \Traversable +{ +} + +/** + * `IteratorAggregate}>` and the inherited + * `Traversable}>` resolve to the same + * unsealed array shape — `equals()` deduplicates them and no + * `interfaceConflict` is reported. + * + * @implements \IteratorAggregate}> + */ +final class ShapedItemList implements \IteratorAggregate, ShapedItemListInterface +{ + public function getIterator(): \Traversable + { + return new \ArrayIterator([]); + } +} + +/** + * Different unsealed value type on the two sides — `equals()` returns + * false on the unsealed extras, so the conflict surfaces. + * + * @implements \IteratorAggregate}> + */ +final class ShapedItemListMismatch implements \IteratorAggregate, ShapedItemListInterface +{ + public function getIterator(): \Traversable + { + return new \ArrayIterator([]); + } +} diff --git a/tests/PHPStan/Rules/Generics/data/method-signature-variance-covariant.php b/tests/PHPStan/Rules/Generics/data/method-signature-variance-covariant.php index 4837dbba5d8..77ed2062ce8 100644 --- a/tests/PHPStan/Rules/Generics/data/method-signature-variance-covariant.php +++ b/tests/PHPStan/Rules/Generics/data/method-signature-variance-covariant.php @@ -72,4 +72,7 @@ function m() {} /** @param X $n */ private function n($n) {} + + /** @param array{a: int, ...} $o */ + function o($o) {} } diff --git a/tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php b/tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php index d7a4d6766d8..84a305a297f 100644 --- a/tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php +++ b/tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php @@ -532,6 +532,16 @@ public function testCallMethods(): void 1589, "Array does not have offset 'id'.", ], + [ + 'Parameter #1 $param of method Test\ConstantArrayAccepts::doBar() expects array{name: string, color?: string}, array{name: string, color: string, year: int} given.', + 1614, + "Sealed array shape does not accept array with extra key 'year'.", + ], + [ + 'Parameter #1 $params of method Test\ConstantArrayAcceptsOptionalKey::doFoo() expects array{wrapperClass?: class-string}, array{wrapperClass: \'stdClass\', undocumented: 42} given.', + 1638, + "Sealed array shape does not accept array with extra key 'undocumented'.", + ], [ 'Parameter #1 $test of method Test\NumericStringParam::sayHello() expects numeric-string, 123 given.', 1657, @@ -859,6 +869,16 @@ public function testCallMethodsOnThisOnly(): void 1589, "Array does not have offset 'id'.", ], + [ + 'Parameter #1 $param of method Test\ConstantArrayAccepts::doBar() expects array{name: string, color?: string}, array{name: string, color: string, year: int} given.', + 1614, + "Sealed array shape does not accept array with extra key 'year'.", + ], + [ + 'Parameter #1 $params of method Test\ConstantArrayAcceptsOptionalKey::doFoo() expects array{wrapperClass?: class-string}, array{wrapperClass: \'stdClass\', undocumented: 42} given.', + 1638, + "Sealed array shape does not accept array with extra key 'undocumented'.", + ], [ 'Parameter #1 $test of method Test\NumericStringParam::sayHello() expects numeric-string, 123 given.', 1657, @@ -2204,7 +2224,18 @@ public function testBug5258(): void $this->checkThisOnly = false; $this->checkNullables = true; $this->checkUnionTypes = true; - $this->analyse([__DIR__ . '/data/bug-5258.php'], []); + $this->analyse([__DIR__ . '/data/bug-5258.php'], [ + [ + 'Parameter #1 $params of method Bug5258\HelloWorld::method2() expects array{other_key: string}, array{some_key: non-falsy-string, other_key: string} given.', + 12, + "Sealed array shape does not accept array with extra key 'some_key'.", + ], + [ + 'Parameter #1 $params of method Bug5258\HelloWorld::method2() expects array{other_key: string}, array{some_key?: string, other_key: non-falsy-string} given.', + 14, + "Sealed array shape does not accept array with extra key 'some_key'.", + ], + ]); } public function testBug5591(): void @@ -3945,12 +3976,36 @@ public function testBug7369(): void ]); } + #[RequiresPhp('>= 8.0.0')] + public function testConstantParameterCheckMethods(): void + { + $this->checkThisOnly = false; + $this->checkNullables = true; + $this->checkUnionTypes = true; + + $this->analyse([__DIR__ . '/data/constant-parameter-check-methods.php'], [ + [ + 'Constant SORT_REGULAR is not allowed for parameter #2 $flags of method finfo::file().', + 10, + ], + [ + 'Constant PDO::ATTR_ERRMODE is not allowed for parameter #1 $mode of method PDOStatement::fetch().', + 17, + ], + [ + 'Constant Collator::FRENCH_COLLATION is not allowed for parameter #2 $flags of method Collator::sort().', + 25, + ], + ]); + } + #[RequiresPhp('>= 8.0.0')] public function testBug11073(): void { $this->checkThisOnly = false; $this->checkNullables = true; $this->checkUnionTypes = true; + $this->analyse([__DIR__ . '/data/bug-11073.php'], []); } diff --git a/tests/PHPStan/Rules/Methods/CallStaticMethodsRuleTest.php b/tests/PHPStan/Rules/Methods/CallStaticMethodsRuleTest.php index 47a70c9d02a..32bc7c9aab7 100644 --- a/tests/PHPStan/Rules/Methods/CallStaticMethodsRuleTest.php +++ b/tests/PHPStan/Rules/Methods/CallStaticMethodsRuleTest.php @@ -563,10 +563,12 @@ public function testDiscussion7004(): void [ 'Parameter #1 $data of static method Discussion7004\Foo::fromArray2() expects array{array{newsletterName: string, subscriberCount: int}}, array given.', 47, + 'Sealed array shape can only accept a constant array. Extra keys are not allowed.', ], [ 'Parameter #1 $data of static method Discussion7004\Foo::fromArray3() expects array{newsletterName: string, subscriberCount: int}, array given.', 48, + 'Sealed array shape can only accept a constant array. Extra keys are not allowed.', ], ]); } @@ -1015,6 +1017,22 @@ public function testPipeOperator(): void ]); } + #[RequiresPhp('>= 8.0')] + public function testConstantParameterCheckStatic(): void + { + $this->checkThisOnly = false; + $this->analyse([__DIR__ . '/data/constant-parameter-check-static.php'], [ + [ + 'Constant IntlDateFormatter::GREGORIAN is not allowed for parameter #2 $dateType of static method IntlDateFormatter::create().', + 9, + ], + [ + 'Constant NumberFormatter::TYPE_INT32 is not allowed for parameter #2 $style of static method NumberFormatter::create().', + 15, + ], + ]); + } + public function testBug11894(): void { $this->checkThisOnly = false; diff --git a/tests/PHPStan/Rules/Methods/MethodCallWithPossiblyRenamedNamedArgumentRuleTest.php b/tests/PHPStan/Rules/Methods/MethodCallWithPossiblyRenamedNamedArgumentRuleTest.php new file mode 100644 index 00000000000..5cad631977b --- /dev/null +++ b/tests/PHPStan/Rules/Methods/MethodCallWithPossiblyRenamedNamedArgumentRuleTest.php @@ -0,0 +1,70 @@ + + */ +#[RequiresPhp('>= 8.0')] +class MethodCallWithPossiblyRenamedNamedArgumentRuleTest extends RuleTestCase +{ + + protected function getRule(): Rule + { + $reflectionProvider = self::createReflectionProvider(); + $ruleLevelHelper = new RuleLevelHelper($reflectionProvider, checkNullables: true, checkThisOnly: false, checkUnionTypes: true, checkExplicitMixed: true, checkImplicitMixed: false, checkBenevolentUnionTypes: false, discoveringSymbolsTip: true); + $phpVersion = self::getContainer()->getByType(PhpVersion::class); + $phpClassReflectionExtension = self::getContainer()->getByType(PhpClassReflectionExtension::class); + + // @phpstan-ignore argument.type + return new CompositeRule([ + new CallMethodsRule( + new MethodCallCheck($reflectionProvider, $ruleLevelHelper, true, true), + new FunctionCallParametersCheck($ruleLevelHelper, new NullsafeCheck(), new UnresolvableTypeHelper(), new PropertyReflectionFinder(), $reflectionProvider, true, true, true, true), + ), + new OverridingMethodRule( + $phpVersion, + new MethodSignatureRule(new ParentMethodHelper($phpClassReflectionExtension), true, true, true), + false, + new MethodParameterComparisonHelper($phpVersion), + new MethodVisibilityComparisonHelper(), + new MethodPrototypeFinder($phpVersion, $phpClassReflectionExtension), + false, + ), + new MethodCallWithPossiblyRenamedNamedArgumentRule(), + ]); + } + + public function testRule(): void + { + $this->analyse([__DIR__ . '/data/named-argument-renamed-parameter.php'], [ + [ + 'Call to NamedArgumentRenamedParameter\Foo::doFoo() uses named argument for parameter $a, but NamedArgumentRenamedParameter\Bar renames it to $b.', + 25, + ], + ]); + } + + public function testBug7434(): void + { + $this->analyse([__DIR__ . '/data/bug-7434.php'], [ + [ + 'Call to Bug7434\Contract::method() uses named argument for parameter $val, but Bug7434\ImplementationWithDifferentName renames it to $wrong.', + 28, + ], + ]); + } + +} diff --git a/tests/PHPStan/Rules/Methods/MethodSignatureRuleTest.php b/tests/PHPStan/Rules/Methods/MethodSignatureRuleTest.php index 51b42d8d00c..d06e1339742 100644 --- a/tests/PHPStan/Rules/Methods/MethodSignatureRuleTest.php +++ b/tests/PHPStan/Rules/Methods/MethodSignatureRuleTest.php @@ -83,6 +83,10 @@ public function testReturnTypeRule(): void 'Parameter #1 $node (PhpParser\Node\Expr\StaticCall) of method MethodSignature\Rule::processNode() should be contravariant with parameter $node (PhpParser\Node) of method MethodSignature\GenericRule::processNode()', 454, ], + [ + 'Return type (array{foo: string, bar: string}) of method MethodSignature\ConstantArrayClass::foobar() should be compatible with return type (array{foo: string}) of method MethodSignature\ConstantArrayInterface::foobar()', + 476, + ], ], ); } @@ -186,6 +190,10 @@ public function testReturnTypeRuleWithoutMaybes(): void 'Return type (MethodSignature\Cat) of method MethodSignature\SubClass::returnTypeTest5() should be compatible with return type (MethodSignature\Dog) of method MethodSignature\BaseInterface::returnTypeTest5()', 358, ], + [ + 'Return type (array{foo: string, bar: string}) of method MethodSignature\ConstantArrayClass::foobar() should be compatible with return type (array{foo: string}) of method MethodSignature\ConstantArrayInterface::foobar()', + 476, + ], ], ); } diff --git a/tests/PHPStan/Rules/Methods/MissingMethodParameterTypehintRuleTest.php b/tests/PHPStan/Rules/Methods/MissingMethodParameterTypehintRuleTest.php index 9f2056500d4..e568f07eca3 100644 --- a/tests/PHPStan/Rules/Methods/MissingMethodParameterTypehintRuleTest.php +++ b/tests/PHPStan/Rules/Methods/MissingMethodParameterTypehintRuleTest.php @@ -86,6 +86,16 @@ public function testRule(): void 'Method MissingMethodParameterTypehint\Baz::acceptsGenericWithSomeDefaults() has parameter $c with generic class MissingMethodParameterTypehint\GenericClassWithSomeDefaults but does not specify its types: T, U (1-2 required)', 270, ], + [ + 'Method MissingMethodParameterTypehint\UnsealedArrayShape::doFoo() has parameter $a with no value type specified in unsealed extra keys (...).', + 284, + MissingTypehintCheck::MISSING_ITERABLE_VALUE_TYPE_TIP, + ], + [ + 'Method MissingMethodParameterTypehint\UnsealedArrayShape::doBar() has parameter $a with no value type specified in unsealed extra keys (...).', + 293, + MissingTypehintCheck::MISSING_ITERABLE_VALUE_TYPE_TIP, + ], ]; $this->analyse([__DIR__ . '/data/missing-method-parameter-typehint.php'], $errors); diff --git a/tests/PHPStan/Rules/Methods/ReturnTypeRuleTest.php b/tests/PHPStan/Rules/Methods/ReturnTypeRuleTest.php index a60a6a704a6..f6196b99cab 100644 --- a/tests/PHPStan/Rules/Methods/ReturnTypeRuleTest.php +++ b/tests/PHPStan/Rules/Methods/ReturnTypeRuleTest.php @@ -876,9 +876,9 @@ public function testBug8146bErrors(): void $this->checkBenevolentUnionTypes = true; $this->analyse([__DIR__ . '/data/bug-8146b-errors.php'], [ [ - "Method Bug8146bError\LocationFixtures::getData() should return array, coordinates: array{lat: float, lng: float}}>> but returns array{Bács-Kiskun: array{Ágasegyháza: array{constituencies: array{'Bács-Kiskun 4.', true, false, Bug8146bError\X, null}, coordinates: array{lat: 46.8386043, lng: 19.4502899}}, Akasztó: array{constituencies: array{'Bács-Kiskun 3.'}, coordinates: array{lat: 46.6898175, lng: 19.205086}}, Apostag: array{constituencies: array{'Bács-Kiskun 3.'}, coordinates: array{lat: 46.8812652, lng: 18.9648478}}, Bácsalmás: array{constituencies: array{'Bács-Kiskun 5.'}, coordinates: array{lat: 46.1250396, lng: 19.3357509}}, Bácsbokod: array{constituencies: array{'Bács-Kiskun 6.'}, coordinates: array{lat: 46.1234737, lng: 19.155708}}, Bácsborsód: array{constituencies: array{'Bács-Kiskun 6.'}, coordinates: array{lat: 46.0989373, lng: 19.1566725}}, Bácsszentgyörgy: array{constituencies: array{'Bács-Kiskun 6.'}, coordinates: array{lat: 45.9746039, lng: 19.0398066}}, Bácsszőlős: array{constituencies: array{'Bács-Kiskun 5.'}, coordinates: array{lat: 46.1352003, lng: 19.4215997}}, ...}, Baranya: non-empty-array|(literal-string&non-falsy-string), float|(literal-string&non-falsy-string)>>>, Békés: array{Almáskamarás: array{constituencies: array{'Békés 4.'}, coordinates: array{lat: 46.4617785, lng: 21.092448}}, Battonya: array{constituencies: array{'Békés 4.'}, coordinates: array{lat: 46.2902462, lng: 21.0199215}}, Békés: array{constituencies: array{'Békés 2.'}, coordinates: array{lat: 46.6704899, lng: 21.0434996}}, Békéscsaba: array{constituencies: array{'Békés 1.'}, coordinates: array{lat: 46.6735939, lng: 21.0877309}}, Békéssámson: array{constituencies: array{'Békés 4.'}, coordinates: array{lat: 46.4208677, lng: 20.6176498}}, Békésszentandrás: array{constituencies: array{'Békés 2.'}, coordinates: array{lat: 46.8715996, lng: 20.48336}}, Bélmegyer: array{constituencies: array{'Békés 3.'}, coordinates: array{lat: 46.8726019, lng: 21.1832832}}, Biharugra: array{constituencies: array{'Békés 3.'}, coordinates: array{lat: 46.9691009, lng: 21.5987651}}, ...}, Borsod-Abaúj-Zemplén: non-empty-array|(literal-string&non-falsy-string), float|(literal-string&non-falsy-string)>>>, Budapest: array{'Budapest I. ker.': array{constituencies: array{'Budapest 01.'}, coordinates: array{lat: 47.4968219, lng: 19.037458}}, 'Budapest II. ker.': array{constituencies: array{'Budapest 03.', 'Budapest 04.'}, coordinates: array{lat: 47.5393329, lng: 18.986934}}, 'Budapest III. ker.': array{constituencies: array{'Budapest 04.', 'Budapest 10.'}, coordinates: array{lat: 47.5671768, lng: 19.0368517}}, 'Budapest IV. ker.': array{constituencies: array{'Budapest 11.', 'Budapest 12.'}, coordinates: array{lat: 47.5648915, lng: 19.0913149}}, 'Budapest V. ker.': array{constituencies: array{'Budapest 01.'}, coordinates: array{lat: 47.5002319, lng: 19.0520181}}, 'Budapest VI. ker.': array{constituencies: array{'Budapest 05.'}, coordinates: array{lat: 47.509863, lng: 19.0625813}}, 'Budapest VII. ker.': array{constituencies: array{'Budapest 05.'}, coordinates: array{lat: 47.5027289, lng: 19.073376}}, 'Budapest VIII. ker.': array{constituencies: array{'Budapest 01.', 'Budapest 06.'}, coordinates: array{lat: 47.4894184, lng: 19.070668}}, ...}, Csongrád-Csanád: array{Algyő: array{constituencies: array{'Csongrád-Csanád 4.'}, coordinates: array{lat: 46.3329625, lng: 20.207889}}, Ambrózfalva: array{constituencies: array{'Csongrád-Csanád 4.'}, coordinates: array{lat: 46.3501417, lng: 20.7313995}}, Apátfalva: array{constituencies: array{'Csongrád-Csanád 4.'}, coordinates: array{lat: 46.173317, lng: 20.5800472}}, Árpádhalom: array{constituencies: array{'Csongrád-Csanád 3.'}, coordinates: array{lat: 46.6158286, lng: 20.547733}}, Ásotthalom: array{constituencies: array{'Csongrád-Csanád 2.'}, coordinates: array{lat: 46.1995983, lng: 19.7833756}}, Baks: array{constituencies: array{'Csongrád-Csanád 3.'}, coordinates: array{lat: 46.5518708, lng: 20.1064166}}, Balástya: array{constituencies: array{'Csongrád-Csanád 3.'}, coordinates: array{lat: 46.4261828, lng: 20.004933}}, Bordány: array{constituencies: array{'Csongrád-Csanád 2.'}, coordinates: array{lat: 46.3194213, lng: 19.9227063}}, ...}, Fejér: array{Aba: array{constituencies: array{'Fejér 5.'}, coordinates: array{lat: 47.0328193, lng: 18.522359}}, Adony: array{constituencies: array{'Fejér 4.'}, coordinates: array{lat: 47.119831, lng: 18.8612469}}, Alap: array{constituencies: array{'Fejér 5.'}, coordinates: array{lat: 46.8075763, lng: 18.684028}}, Alcsútdoboz: array{constituencies: array{'Fejér 3.'}, coordinates: array{lat: 47.4277067, lng: 18.6030325}}, Alsószentiván: array{constituencies: array{'Fejér 5.'}, coordinates: array{lat: 46.7910573, lng: 18.732161}}, Bakonycsernye: array{constituencies: array{'Fejér 2.'}, coordinates: array{lat: 47.321719, lng: 18.0907379}}, Bakonykúti: array{constituencies: array{'Fejér 2.'}, coordinates: array{lat: 47.2458464, lng: 18.195769}}, Balinka: array{constituencies: array{'Fejér 2.'}, coordinates: array{lat: 47.3135736, lng: 18.1907168}}, ...}, Győr-Moson-Sopron: array{Abda: array{constituencies: array{'Győr-Moson-Sopron 5.'}, coordinates: array{lat: 47.6962149, lng: 17.5445786}}, Acsalag: array{constituencies: array{'Győr-Moson-Sopron 3.'}, coordinates: array{lat: 47.676095, lng: 17.1977771}}, Ágfalva: array{constituencies: array{'Győr-Moson-Sopron 4.'}, coordinates: array{lat: 47.688862, lng: 16.5110233}}, Agyagosszergény: array{constituencies: array{'Győr-Moson-Sopron 3.'}, coordinates: array{lat: 47.608545, lng: 16.9409912}}, Árpás: array{constituencies: array{'Győr-Moson-Sopron 3.'}, coordinates: array{lat: 47.5134127, lng: 17.3931579}}, Ásványráró: array{constituencies: array{'Győr-Moson-Sopron 5.'}, coordinates: array{lat: 47.8287695, lng: 17.499195}}, Babót: array{constituencies: array{'Győr-Moson-Sopron 3.'}, coordinates: array{lat: 47.5752269, lng: 17.0758604}}, Bágyogszovát: array{constituencies: array{'Győr-Moson-Sopron 3.'}, coordinates: array{lat: 47.5866036, lng: 17.3617273}}, ...}, ...}.", + "Method Bug8146bError\LocationFixtures::getData() should return array, coordinates: array{lat: float, lng: float, ...}}>> but returns array{Bács-Kiskun: array{Ágasegyháza: array{constituencies: array{'Bács-Kiskun 4.', true, false, Bug8146bError\X, null}, coordinates: array{lat: 46.8386043, lng: 19.4502899}}, Akasztó: array{constituencies: array{'Bács-Kiskun 3.'}, coordinates: array{lat: 46.6898175, lng: 19.205086}}, Apostag: array{constituencies: array{'Bács-Kiskun 3.'}, coordinates: array{lat: 46.8812652, lng: 18.9648478}}, Bácsalmás: array{constituencies: array{'Bács-Kiskun 5.'}, coordinates: array{lat: 46.1250396, lng: 19.3357509}}, Bácsbokod: array{constituencies: array{'Bács-Kiskun 6.'}, coordinates: array{lat: 46.1234737, lng: 19.155708}}, Bácsborsód: array{constituencies: array{'Bács-Kiskun 6.'}, coordinates: array{lat: 46.0989373, lng: 19.1566725}}, Bácsszentgyörgy: array{constituencies: array{'Bács-Kiskun 6.'}, coordinates: array{lat: 45.9746039, lng: 19.0398066}}, Bácsszőlős: array{constituencies: array{'Bács-Kiskun 5.'}, coordinates: array{lat: 46.1352003, lng: 19.4215997}}, ...}, Baranya: non-empty-array|(literal-string&non-falsy-string), float|(literal-string&non-falsy-string)>>>, Békés: array{Almáskamarás: array{constituencies: array{'Békés 4.'}, coordinates: array{lat: 46.4617785, lng: 21.092448}}, Battonya: array{constituencies: array{'Békés 4.'}, coordinates: array{lat: 46.2902462, lng: 21.0199215}}, Békés: array{constituencies: array{'Békés 2.'}, coordinates: array{lat: 46.6704899, lng: 21.0434996}}, Békéscsaba: array{constituencies: array{'Békés 1.'}, coordinates: array{lat: 46.6735939, lng: 21.0877309}}, Békéssámson: array{constituencies: array{'Békés 4.'}, coordinates: array{lat: 46.4208677, lng: 20.6176498}}, Békésszentandrás: array{constituencies: array{'Békés 2.'}, coordinates: array{lat: 46.8715996, lng: 20.48336}}, Bélmegyer: array{constituencies: array{'Békés 3.'}, coordinates: array{lat: 46.8726019, lng: 21.1832832}}, Biharugra: array{constituencies: array{'Békés 3.'}, coordinates: array{lat: 46.9691009, lng: 21.5987651}}, ...}, Borsod-Abaúj-Zemplén: non-empty-array|(literal-string&non-falsy-string), float|(literal-string&non-falsy-string)>>>, Budapest: array{'Budapest I. ker.': array{constituencies: array{'Budapest 01.'}, coordinates: array{lat: 47.4968219, lng: 19.037458}}, 'Budapest II. ker.': array{constituencies: array{'Budapest 03.', 'Budapest 04.'}, coordinates: array{lat: 47.5393329, lng: 18.986934}}, 'Budapest III. ker.': array{constituencies: array{'Budapest 04.', 'Budapest 10.'}, coordinates: array{lat: 47.5671768, lng: 19.0368517}}, 'Budapest IV. ker.': array{constituencies: array{'Budapest 11.', 'Budapest 12.'}, coordinates: array{lat: 47.5648915, lng: 19.0913149}}, 'Budapest V. ker.': array{constituencies: array{'Budapest 01.'}, coordinates: array{lat: 47.5002319, lng: 19.0520181}}, 'Budapest VI. ker.': array{constituencies: array{'Budapest 05.'}, coordinates: array{lat: 47.509863, lng: 19.0625813}}, 'Budapest VII. ker.': array{constituencies: array{'Budapest 05.'}, coordinates: array{lat: 47.5027289, lng: 19.073376}}, 'Budapest VIII. ker.': array{constituencies: array{'Budapest 01.', 'Budapest 06.'}, coordinates: array{lat: 47.4894184, lng: 19.070668}}, ...}, Csongrád-Csanád: array{Algyő: array{constituencies: array{'Csongrád-Csanád 4.'}, coordinates: array{lat: 46.3329625, lng: 20.207889}}, Ambrózfalva: array{constituencies: array{'Csongrád-Csanád 4.'}, coordinates: array{lat: 46.3501417, lng: 20.7313995}}, Apátfalva: array{constituencies: array{'Csongrád-Csanád 4.'}, coordinates: array{lat: 46.173317, lng: 20.5800472}}, Árpádhalom: array{constituencies: array{'Csongrád-Csanád 3.'}, coordinates: array{lat: 46.6158286, lng: 20.547733}}, Ásotthalom: array{constituencies: array{'Csongrád-Csanád 2.'}, coordinates: array{lat: 46.1995983, lng: 19.7833756}}, Baks: array{constituencies: array{'Csongrád-Csanád 3.'}, coordinates: array{lat: 46.5518708, lng: 20.1064166}}, Balástya: array{constituencies: array{'Csongrád-Csanád 3.'}, coordinates: array{lat: 46.4261828, lng: 20.004933}}, Bordány: array{constituencies: array{'Csongrád-Csanád 2.'}, coordinates: array{lat: 46.3194213, lng: 19.9227063}}, ...}, Fejér: array{Aba: array{constituencies: array{'Fejér 5.'}, coordinates: array{lat: 47.0328193, lng: 18.522359}}, Adony: array{constituencies: array{'Fejér 4.'}, coordinates: array{lat: 47.119831, lng: 18.8612469}}, Alap: array{constituencies: array{'Fejér 5.'}, coordinates: array{lat: 46.8075763, lng: 18.684028}}, Alcsútdoboz: array{constituencies: array{'Fejér 3.'}, coordinates: array{lat: 47.4277067, lng: 18.6030325}}, Alsószentiván: array{constituencies: array{'Fejér 5.'}, coordinates: array{lat: 46.7910573, lng: 18.732161}}, Bakonycsernye: array{constituencies: array{'Fejér 2.'}, coordinates: array{lat: 47.321719, lng: 18.0907379}}, Bakonykúti: array{constituencies: array{'Fejér 2.'}, coordinates: array{lat: 47.2458464, lng: 18.195769}}, Balinka: array{constituencies: array{'Fejér 2.'}, coordinates: array{lat: 47.3135736, lng: 18.1907168}}, ...}, Győr-Moson-Sopron: array{Abda: array{constituencies: array{'Győr-Moson-Sopron 5.'}, coordinates: array{lat: 47.6962149, lng: 17.5445786}}, Acsalag: array{constituencies: array{'Győr-Moson-Sopron 3.'}, coordinates: array{lat: 47.676095, lng: 17.1977771}}, Ágfalva: array{constituencies: array{'Győr-Moson-Sopron 4.'}, coordinates: array{lat: 47.688862, lng: 16.5110233}}, Agyagosszergény: array{constituencies: array{'Győr-Moson-Sopron 3.'}, coordinates: array{lat: 47.608545, lng: 16.9409912}}, Árpás: array{constituencies: array{'Győr-Moson-Sopron 3.'}, coordinates: array{lat: 47.5134127, lng: 17.3931579}}, Ásványráró: array{constituencies: array{'Győr-Moson-Sopron 5.'}, coordinates: array{lat: 47.8287695, lng: 17.499195}}, Babót: array{constituencies: array{'Győr-Moson-Sopron 3.'}, coordinates: array{lat: 47.5752269, lng: 17.0758604}}, Bágyogszovát: array{constituencies: array{'Győr-Moson-Sopron 3.'}, coordinates: array{lat: 47.5866036, lng: 17.3617273}}, ...}, ...}.", 12, - "Offset 'constituencies' (non-empty-list) does not accept type array{'Bács-Kiskun 4.', true, false, Bug8146bError\X, null}.", + "• Offset 'constituencies' (non-empty-list) does not accept type array{'Bács-Kiskun 4.', true, false, Bug8146bError\X, null}.\n• Sealed array shape can only accept a constant array. Extra keys are not allowed.", ], ]); } @@ -1347,4 +1347,10 @@ public function testBug14553(): void $this->analyse([__DIR__ . '/../../Analyser/nsrt/bug-14553.php'], []); } + #[RequiresPhp('>= 8.2.0')] + public function testBug12110(): void + { + $this->analyse([__DIR__ . '/data/bug-12110.php'], []); + } + } diff --git a/tests/PHPStan/Rules/Methods/data/bug-12110.php b/tests/PHPStan/Rules/Methods/data/bug-12110.php new file mode 100644 index 00000000000..fbd1e2f8c81 --- /dev/null +++ b/tests/PHPStan/Rules/Methods/data/bug-12110.php @@ -0,0 +1,670 @@ += 8.2 + +namespace Bug12110; + +class OwnerModel {}; +class PermissionsModel {}; + +final readonly class TemplateRepositoryModel implements \JsonSerializable +{ + public function __construct( + /** + * @var null|int + */ + public null|int $id = null, + /** + * @var null|string + */ + public null|string $node_id = null, + /** + * @var null|string + */ + public null|string $name = null, + /** + * @var null|string + */ + public null|string $full_name = null, + /** + * @var null|OwnerModel + */ + public null|OwnerModel $owner = null, + /** + * @var null|bool + */ + public null|bool $private = null, + /** + * @var null|string + */ + public null|string $html_url = null, + /** + * @var null|string + */ + public null|string $description = null, + /** + * @var null|bool + */ + public null|bool $fork = null, + /** + * @var null|string + */ + public null|string $url = null, + /** + * @var null|string + */ + public null|string $archive_url = null, + /** + * @var null|string + */ + public null|string $assignees_url = null, + /** + * @var null|string + */ + public null|string $blobs_url = null, + /** + * @var null|string + */ + public null|string $branches_url = null, + /** + * @var null|string + */ + public null|string $collaborators_url = null, + /** + * @var null|string + */ + public null|string $comments_url = null, + /** + * @var null|string + */ + public null|string $commits_url = null, + /** + * @var null|string + */ + public null|string $compare_url = null, + /** + * @var null|string + */ + public null|string $contents_url = null, + /** + * @var null|string + */ + public null|string $contributors_url = null, + /** + * @var null|string + */ + public null|string $deployments_url = null, + /** + * @var null|string + */ + public null|string $downloads_url = null, + /** + * @var null|string + */ + public null|string $events_url = null, + /** + * @var null|string + */ + public null|string $forks_url = null, + /** + * @var null|string + */ + public null|string $git_commits_url = null, + /** + * @var null|string + */ + public null|string $git_refs_url = null, + /** + * @var null|string + */ + public null|string $git_tags_url = null, + /** + * @var null|string + */ + public null|string $git_url = null, + /** + * @var null|string + */ + public null|string $issue_comment_url = null, + /** + * @var null|string + */ + public null|string $issue_events_url = null, + /** + * @var null|string + */ + public null|string $issues_url = null, + /** + * @var null|string + */ + public null|string $keys_url = null, + /** + * @var null|string + */ + public null|string $labels_url = null, + /** + * @var null|string + */ + public null|string $languages_url = null, + /** + * @var null|string + */ + public null|string $merges_url = null, + /** + * @var null|string + */ + public null|string $milestones_url = null, + /** + * @var null|string + */ + public null|string $notifications_url = null, + /** + * @var null|string + */ + public null|string $pulls_url = null, + /** + * @var null|string + */ + public null|string $releases_url = null, + /** + * @var null|string + */ + public null|string $ssh_url = null, + /** + * @var null|string + */ + public null|string $stargazers_url = null, + /** + * @var null|string + */ + public null|string $statuses_url = null, + /** + * @var null|string + */ + public null|string $subscribers_url = null, + /** + * @var null|string + */ + public null|string $subscription_url = null, + /** + * @var null|string + */ + public null|string $tags_url = null, + /** + * @var null|string + */ + public null|string $teams_url = null, + /** + * @var null|string + */ + public null|string $trees_url = null, + /** + * @var null|string + */ + public null|string $clone_url = null, + /** + * @var null|string + */ + public null|string $mirror_url = null, + /** + * @var null|string + */ + public null|string $hooks_url = null, + /** + * @var null|string + */ + public null|string $svn_url = null, + /** + * @var null|string + */ + public null|string $homepage = null, + /** + * @var null|string + */ + public null|string $language = null, + /** + * @var null|int + */ + public null|int $forks_count = null, + /** + * @var null|int + */ + public null|int $stargazers_count = null, + /** + * @var null|int + */ + public null|int $watchers_count = null, + /** + * @var null|int + */ + public null|int $size = null, + /** + * @var null|string + */ + public null|string $default_branch = null, + /** + * @var null|int + */ + public null|int $open_issues_count = null, + /** + * @var null|bool + */ + public null|bool $is_template = null, + /** + * @var null|list + */ + public null|array $topics = null, + /** + * @var null|bool + */ + public null|bool $has_issues = null, + /** + * @var null|bool + */ + public null|bool $has_projects = null, + /** + * @var null|bool + */ + public null|bool $has_wiki = null, + /** + * @var null|bool + */ + public null|bool $has_pages = null, + /** + * @var null|bool + */ + public null|bool $has_downloads = null, + /** + * @var null|bool + */ + public null|bool $archived = null, + /** + * @var null|bool + */ + public null|bool $disabled = null, + /** + * @var null|string + */ + public null|string $visibility = null, + /** + * @var null|string + */ + public null|string $pushed_at = null, + /** + * @var null|string + */ + public null|string $created_at = null, + /** + * @var null|string + */ + public null|string $updated_at = null, + /** + * @var null|PermissionsModel + */ + public null|PermissionsModel $permissions = null, + /** + * @var null|bool + */ + public null|bool $allow_rebase_merge = null, + /** + * @var null|string + */ + public null|string $template_repository = null, + /** + * @var null|string + */ + public null|string $temp_clone_token = null, + /** + * @var null|bool + */ + public null|bool $allow_squash_merge = null, + /** + * @var null|bool + */ + public null|bool $delete_branch_on_merge = null, + /** + * @var null|bool + */ + public null|bool $allow_merge_commit = null, + /** + * @var null|int + */ + public null|int $subscribers_count = null, + /** + * @var null|int + */ + public null|int $network_count = null, + ) {} + + /** + * @return array{ + * 'id'?: int, + * 'node_id'?: string, + * 'name'?: string, + * 'full_name'?: string, + * 'owner'?: OwnerModel, + * 'private'?: bool, + * 'html_url'?: string, + * 'description'?: string, + * 'fork'?: bool, + * 'url'?: string, + * 'archive_url'?: string, + * 'assignees_url'?: string, + * 'blobs_url'?: string, + * 'branches_url'?: string, + * 'collaborators_url'?: string, + * 'comments_url'?: string, + * 'commits_url'?: string, + * 'compare_url'?: string, + * 'contents_url'?: string, + * 'contributors_url'?: string, + * 'deployments_url'?: string, + * 'downloads_url'?: string, + * 'events_url'?: string, + * 'forks_url'?: string, + * 'git_commits_url'?: string, + * 'git_refs_url'?: string, + * 'git_tags_url'?: string, + * 'git_url'?: string, + * 'issue_comment_url'?: string, + * 'issue_events_url'?: string, + * 'issues_url'?: string, + * 'keys_url'?: string, + * 'labels_url'?: string, + * 'languages_url'?: string, + * 'merges_url'?: string, + * 'milestones_url'?: string, + * 'notifications_url'?: string, + * 'pulls_url'?: string, + * 'releases_url'?: string, + * 'ssh_url'?: string, + * 'stargazers_url'?: string, + * 'statuses_url'?: string, + * 'subscribers_url'?: string, + * 'subscription_url'?: string, + * 'tags_url'?: string, + * 'teams_url'?: string, + * 'trees_url'?: string, + * 'clone_url'?: string, + * 'mirror_url'?: string, + * 'hooks_url'?: string, + * 'svn_url'?: string, + * 'homepage'?: string, + * 'language'?: string, + * 'forks_count'?: int, + * 'stargazers_count'?: int, + * 'watchers_count'?: int, + * 'size'?: int, + * 'default_branch'?: string, + * 'open_issues_count'?: int, + * 'is_template'?: bool, + * 'topics'?: list, + * 'has_issues'?: bool, + * 'has_projects'?: bool, + * 'has_wiki'?: bool, + * 'has_pages'?: bool, + * 'has_downloads'?: bool, + * 'archived'?: bool, + * 'disabled'?: bool, + * 'visibility'?: string, + * 'pushed_at'?: string, + * 'created_at'?: string, + * 'updated_at'?: string, + * 'permissions'?: PermissionsModel, + * 'allow_rebase_merge'?: bool, + * 'template_repository'?: string, + * 'temp_clone_token'?: string, + * 'allow_squash_merge'?: bool, + * 'delete_branch_on_merge'?: bool, + * 'allow_merge_commit'?: bool, + * 'subscribers_count'?: int, + * 'network_count'?: int, + * } + */ + public function jsonSerialize(): array + { + $properties = []; + if ($this->id !== null) { + $properties['id'] = $this->id; + } + if ($this->node_id !== null) { + $properties['node_id'] = $this->node_id; + } + if ($this->name !== null) { + $properties['name'] = $this->name; + } + if ($this->full_name !== null) { + $properties['full_name'] = $this->full_name; + } + if ($this->owner !== null) { + $properties['owner'] = $this->owner; + } + if ($this->private !== null) { + $properties['private'] = $this->private; + } + if ($this->html_url !== null) { + $properties['html_url'] = $this->html_url; + } + if ($this->description !== null) { + $properties['description'] = $this->description; + } + if ($this->fork !== null) { + $properties['fork'] = $this->fork; + } + if ($this->url !== null) { + $properties['url'] = $this->url; + } + if ($this->archive_url !== null) { + $properties['archive_url'] = $this->archive_url; + } + if ($this->assignees_url !== null) { + $properties['assignees_url'] = $this->assignees_url; + } + if ($this->blobs_url !== null) { + $properties['blobs_url'] = $this->blobs_url; + } + if ($this->branches_url !== null) { + $properties['branches_url'] = $this->branches_url; + } + if ($this->collaborators_url !== null) { + $properties['collaborators_url'] = $this->collaborators_url; + } + if ($this->comments_url !== null) { + $properties['comments_url'] = $this->comments_url; + } + if ($this->commits_url !== null) { + $properties['commits_url'] = $this->commits_url; + } + if ($this->compare_url !== null) { + $properties['compare_url'] = $this->compare_url; + } + if ($this->contents_url !== null) { + $properties['contents_url'] = $this->contents_url; + } + if ($this->contributors_url !== null) { + $properties['contributors_url'] = $this->contributors_url; + } + if ($this->deployments_url !== null) { + $properties['deployments_url'] = $this->deployments_url; + } + if ($this->downloads_url !== null) { + $properties['downloads_url'] = $this->downloads_url; + } + if ($this->events_url !== null) { + $properties['events_url'] = $this->events_url; + } + if ($this->forks_url !== null) { + $properties['forks_url'] = $this->forks_url; + } + if ($this->git_commits_url !== null) { + $properties['git_commits_url'] = $this->git_commits_url; + } + if ($this->git_refs_url !== null) { + $properties['git_refs_url'] = $this->git_refs_url; + } + if ($this->git_tags_url !== null) { + $properties['git_tags_url'] = $this->git_tags_url; + } + if ($this->git_url !== null) { + $properties['git_url'] = $this->git_url; + } + if ($this->issue_comment_url !== null) { + $properties['issue_comment_url'] = $this->issue_comment_url; + } + if ($this->issue_events_url !== null) { + $properties['issue_events_url'] = $this->issue_events_url; + } + if ($this->issues_url !== null) { + $properties['issues_url'] = $this->issues_url; + } + if ($this->keys_url !== null) { + $properties['keys_url'] = $this->keys_url; + } + if ($this->labels_url !== null) { + $properties['labels_url'] = $this->labels_url; + } + if ($this->languages_url !== null) { + $properties['languages_url'] = $this->languages_url; + } + if ($this->merges_url !== null) { + $properties['merges_url'] = $this->merges_url; + } + if ($this->milestones_url !== null) { + $properties['milestones_url'] = $this->milestones_url; + } + if ($this->notifications_url !== null) { + $properties['notifications_url'] = $this->notifications_url; + } + if ($this->pulls_url !== null) { + $properties['pulls_url'] = $this->pulls_url; + } + if ($this->releases_url !== null) { + $properties['releases_url'] = $this->releases_url; + } + if ($this->ssh_url !== null) { + $properties['ssh_url'] = $this->ssh_url; + } + if ($this->stargazers_url !== null) { + $properties['stargazers_url'] = $this->stargazers_url; + } + if ($this->statuses_url !== null) { + $properties['statuses_url'] = $this->statuses_url; + } + if ($this->subscribers_url !== null) { + $properties['subscribers_url'] = $this->subscribers_url; + } + if ($this->subscription_url !== null) { + $properties['subscription_url'] = $this->subscription_url; + } + if ($this->tags_url !== null) { + $properties['tags_url'] = $this->tags_url; + } + if ($this->teams_url !== null) { + $properties['teams_url'] = $this->teams_url; + } + if ($this->trees_url !== null) { + $properties['trees_url'] = $this->trees_url; + } + if ($this->clone_url !== null) { + $properties['clone_url'] = $this->clone_url; + } + if ($this->mirror_url !== null) { + $properties['mirror_url'] = $this->mirror_url; + } + if ($this->hooks_url !== null) { + $properties['hooks_url'] = $this->hooks_url; + } + if ($this->svn_url !== null) { + $properties['svn_url'] = $this->svn_url; + } + if ($this->homepage !== null) { + $properties['homepage'] = $this->homepage; + } + if ($this->language !== null) { + $properties['language'] = $this->language; + } + if ($this->forks_count !== null) { + $properties['forks_count'] = $this->forks_count; + } + if ($this->stargazers_count !== null) { + $properties['stargazers_count'] = $this->stargazers_count; + } + if ($this->watchers_count !== null) { + $properties['watchers_count'] = $this->watchers_count; + } + if ($this->size !== null) { + $properties['size'] = $this->size; + } + if ($this->default_branch !== null) { + $properties['default_branch'] = $this->default_branch; + } + if ($this->open_issues_count !== null) { + $properties['open_issues_count'] = $this->open_issues_count; + } + if ($this->is_template !== null) { + $properties['is_template'] = $this->is_template; + } + if ($this->topics !== null) { + $properties['topics'] = $this->topics; + } + if ($this->has_issues !== null) { + $properties['has_issues'] = $this->has_issues; + } + if ($this->has_projects !== null) { + $properties['has_projects'] = $this->has_projects; + } + if ($this->has_wiki !== null) { + $properties['has_wiki'] = $this->has_wiki; + } + if ($this->has_pages !== null) { + $properties['has_pages'] = $this->has_pages; + } + if ($this->has_downloads !== null) { + $properties['has_downloads'] = $this->has_downloads; + } + if ($this->archived !== null) { + $properties['archived'] = $this->archived; + } + if ($this->disabled !== null) { + $properties['disabled'] = $this->disabled; + } + if ($this->visibility !== null) { + $properties['visibility'] = $this->visibility; + } + if ($this->pushed_at !== null) { + $properties['pushed_at'] = $this->pushed_at; + } + if ($this->created_at !== null) { + $properties['created_at'] = $this->created_at; + } + if ($this->updated_at !== null) { + $properties['updated_at'] = $this->updated_at; + } + if ($this->permissions !== null) { + $properties['permissions'] = $this->permissions; + } + if ($this->allow_rebase_merge !== null) { + $properties['allow_rebase_merge'] = $this->allow_rebase_merge; + } + if ($this->template_repository !== null) { + $properties['template_repository'] = $this->template_repository; + } + if ($this->temp_clone_token !== null) { + $properties['temp_clone_token'] = $this->temp_clone_token; + } + if ($this->allow_squash_merge !== null) { + $properties['allow_squash_merge'] = $this->allow_squash_merge; + } + if ($this->delete_branch_on_merge !== null) { + $properties['delete_branch_on_merge'] = $this->delete_branch_on_merge; + } + if ($this->allow_merge_commit !== null) { + $properties['allow_merge_commit'] = $this->allow_merge_commit; + } + if ($this->subscribers_count !== null) { + $properties['subscribers_count'] = $this->subscribers_count; + } + if ($this->network_count !== null) { + $properties['network_count'] = $this->network_count; + } + return $properties; + } +} diff --git a/tests/PHPStan/Rules/Methods/data/bug-5232.php b/tests/PHPStan/Rules/Methods/data/bug-5232.php index 4089988ff72..1d047d30f19 100644 --- a/tests/PHPStan/Rules/Methods/data/bug-5232.php +++ b/tests/PHPStan/Rules/Methods/data/bug-5232.php @@ -5,7 +5,7 @@ abstract class HelloWorld { /** - * @phpstan-return array{workId: string, collectionNumber: string, uuid: string|null} + * @phpstan-return array{workId: string, collectionNumber: string, uuid: string|null, ...} */ public function sayHello(string $content): array { diff --git a/tests/PHPStan/Rules/Methods/data/bug-5258.php b/tests/PHPStan/Rules/Methods/data/bug-5258.php index 27a751f8591..a2df20d2baf 100644 --- a/tests/PHPStan/Rules/Methods/data/bug-5258.php +++ b/tests/PHPStan/Rules/Methods/data/bug-5258.php @@ -21,3 +21,23 @@ public function method2(array$params): void { } } + +class HelloWorld2 +{ + /** + * @param array{some_key?:string, other_key:string} $params + */ + public function method1(array $params): void + { + if (!empty($params['some_key'])) $this->method2($params); + + if (!empty($params['other_key'])) $this->method2($params); + } + + /** + * @param array{other_key:string, ...} $params + **/ + public function method2(array$params): void + { + } +} diff --git a/tests/PHPStan/Rules/Methods/data/bug-6552.php b/tests/PHPStan/Rules/Methods/data/bug-6552.php index 51a4c32e075..e9b464d742b 100644 --- a/tests/PHPStan/Rules/Methods/data/bug-6552.php +++ b/tests/PHPStan/Rules/Methods/data/bug-6552.php @@ -6,7 +6,7 @@ class HelloWorld { /** * @param mixed $a - * @return array{schemaVersion: mixed}|null + * @return array{schemaVersion: mixed, ...}|null */ public function sayHello($a) { diff --git a/tests/PHPStan/Rules/Methods/data/bug-7434.php b/tests/PHPStan/Rules/Methods/data/bug-7434.php new file mode 100644 index 00000000000..be1750fdfbc --- /dev/null +++ b/tests/PHPStan/Rules/Methods/data/bug-7434.php @@ -0,0 +1,29 @@ +method(val: 'string'); +} diff --git a/tests/PHPStan/Rules/Methods/data/bug-8146b-errors.php b/tests/PHPStan/Rules/Methods/data/bug-8146b-errors.php index 27509dcc963..aa7298045c8 100644 --- a/tests/PHPStan/Rules/Methods/data/bug-8146b-errors.php +++ b/tests/PHPStan/Rules/Methods/data/bug-8146b-errors.php @@ -6,7 +6,7 @@ class X{} class LocationFixtures { - /** @return array, coordinates: array{lat: float, lng: float}}>> */ + /** @return array, coordinates: array{lat: float, lng: float, ...}}>> */ public function getData(): array { return [ diff --git a/tests/PHPStan/Rules/Methods/data/constant-parameter-check-methods.php b/tests/PHPStan/Rules/Methods/data/constant-parameter-check-methods.php new file mode 100644 index 00000000000..7ecae9f3f89 --- /dev/null +++ b/tests/PHPStan/Rules/Methods/data/constant-parameter-check-methods.php @@ -0,0 +1,25 @@ +file('test.txt', FILEINFO_MIME_TYPE); + +// finfo::file - wrong constant +$finfo->file('test.txt', SORT_REGULAR); + +// PDOStatement::fetch - correct class constant +/** @var \PDOStatement $stmt */ +$stmt->fetch(\PDO::FETCH_ASSOC); + +// PDOStatement::fetch - wrong class constant +$stmt->fetch(\PDO::ATTR_ERRMODE); + +// Collator::sort - correct class constant +/** @var \Collator $collator */ +$arr = []; +$collator->sort($arr, \Collator::SORT_STRING); + +// Collator::sort - wrong class constant +$collator->sort($arr, \Collator::FRENCH_COLLATION); diff --git a/tests/PHPStan/Rules/Methods/data/constant-parameter-check-static.php b/tests/PHPStan/Rules/Methods/data/constant-parameter-check-static.php new file mode 100644 index 00000000000..16b7298f55c --- /dev/null +++ b/tests/PHPStan/Rules/Methods/data/constant-parameter-check-static.php @@ -0,0 +1,15 @@ + '', + 'bar' => '', + ]; + } +} diff --git a/tests/PHPStan/Rules/Methods/data/missing-method-parameter-typehint.php b/tests/PHPStan/Rules/Methods/data/missing-method-parameter-typehint.php index 27fa039ef4d..a48a3cf8bf2 100644 --- a/tests/PHPStan/Rules/Methods/data/missing-method-parameter-typehint.php +++ b/tests/PHPStan/Rules/Methods/data/missing-method-parameter-typehint.php @@ -273,3 +273,26 @@ public function acceptsGenericWithSomeDefaults(GenericClassWithSomeDefaults $c) } } + +class UnsealedArrayShape +{ + + /** + * @param array{a: int, ...} $a + * @param array{a: int, ...} $b + */ + public function doFoo(array $a, array $b): void + { + + } + + /** + * @param non-empty-array{a?: int, b?: int, ...} $a + * @param non-empty-array{a?: int, b?: int, ...} $b + */ + public function doBar(array $a, array $b): void + { + + } + +} diff --git a/tests/PHPStan/Rules/Methods/data/named-argument-renamed-parameter.php b/tests/PHPStan/Rules/Methods/data/named-argument-renamed-parameter.php new file mode 100644 index 00000000000..72a5b96c2c8 --- /dev/null +++ b/tests/PHPStan/Rules/Methods/data/named-argument-renamed-parameter.php @@ -0,0 +1,26 @@ += 8.0 + +declare(strict_types = 1); + +namespace NamedArgumentRenamedParameter; + +interface Foo +{ + + public function doFoo(string $a): void; + +} + +class Bar implements Foo +{ + + public function doFoo(string $b): void + { + + } + +} + +function (Foo $foo): void { + $foo->doFoo(a: 'a'); +}; diff --git a/tests/PHPStan/Rules/Playground/ArrayDimCastRuleTest.php b/tests/PHPStan/Rules/Playground/ArrayDimCastRuleTest.php new file mode 100644 index 00000000000..f0ca0570744 --- /dev/null +++ b/tests/PHPStan/Rules/Playground/ArrayDimCastRuleTest.php @@ -0,0 +1,61 @@ + + */ +final class ArrayDimCastRuleTest extends RuleTestCase +{ + + protected function getRule(): Rule + { + return new ArrayDimCastRule(); + } + + public function testRule(): void + { + $tip = 'Learn more: https://phpstan.org/blog/why-array-string-keys-are-not-type-safe'; + $this->analyse([__DIR__ . '/data/array-dim-fetch-cast.php'], [ + [ + "Key '1' (string) will be cast to 1 (int) in the array access.", + 13, + $tip, + ], + [ + "Key null (null) will be cast to '' (string) in the array access.", + 14, + $tip, + ], + [ + 'Key 2.5 (float) will be cast to 2 (int) in the array access.', + 15, + $tip, + ], + [ + 'Key true (bool) will be cast to 1 (int) in the array access.', + 17, + $tip, + ], + [ + 'Key false (bool) will be cast to 0 (int) in the array access.', + 18, + $tip, + ], + [ + "Key '10' (string) will be cast to 10 (int) in the array access.", + 20, + $tip, + ], + [ + "Key '1' (string) will be cast to 1 (int) in the array access.", + 26, + $tip, + ], + ]); + } + +} diff --git a/tests/PHPStan/Rules/Playground/LiteralArrayKeyCastRuleTest.php b/tests/PHPStan/Rules/Playground/LiteralArrayKeyCastRuleTest.php new file mode 100644 index 00000000000..898e12a7321 --- /dev/null +++ b/tests/PHPStan/Rules/Playground/LiteralArrayKeyCastRuleTest.php @@ -0,0 +1,56 @@ + + */ +final class LiteralArrayKeyCastRuleTest extends RuleTestCase +{ + + protected function getRule(): Rule + { + return new LiteralArrayKeyCastRule(); + } + + public function testRule(): void + { + $tip = 'Learn more: https://phpstan.org/blog/why-array-string-keys-are-not-type-safe'; + $this->analyse([__DIR__ . '/data/literal-array-key-cast.php'], [ + [ + "Key '1' (string) will be cast to 1 (int) in the array.", + 14, + $tip, + ], + [ + "Key null (null) will be cast to '' (string) in the array.", + 15, + $tip, + ], + [ + 'Key 2.5 (float) will be cast to 2 (int) in the array.', + 16, + $tip, + ], + [ + 'Key true (bool) will be cast to 1 (int) in the array.', + 18, + $tip, + ], + [ + 'Key false (bool) will be cast to 0 (int) in the array.', + 19, + $tip, + ], + [ + "Key '10' (string) will be cast to 10 (int) in the array.", + 21, + $tip, + ], + ]); + } + +} diff --git a/tests/PHPStan/Rules/Playground/data/array-dim-fetch-cast.php b/tests/PHPStan/Rules/Playground/data/array-dim-fetch-cast.php new file mode 100644 index 00000000000..20dfa5a7021 --- /dev/null +++ b/tests/PHPStan/Rules/Playground/data/array-dim-fetch-cast.php @@ -0,0 +1,29 @@ + 1, + '+1' => 2, + '1' => 3, // cast to 1 + null => 4, // cast to '' + 2.5 => 5, // cast to 2 + '1.2' => 6, + true => 7, // cast to 1 + false => 8, // cast to 0 + '08' => 9, + $partiallyCast => 10, // one part of the union is cast to 10 + ]; + } + +} diff --git a/tests/PHPStan/Type/Constant/ConstantArrayTypeBuilderTest.php b/tests/PHPStan/Type/Constant/ConstantArrayTypeBuilderTest.php index 12fb1c2b8f4..bd97c440358 100644 --- a/tests/PHPStan/Type/Constant/ConstantArrayTypeBuilderTest.php +++ b/tests/PHPStan/Type/Constant/ConstantArrayTypeBuilderTest.php @@ -2,7 +2,9 @@ namespace PHPStan\Type\Constant; +use PHPStan\DependencyInjection\BleedingEdgeToggle; use PHPStan\Testing\PHPStanTestCase; +use PHPStan\Type\ArrayType; use PHPStan\Type\BooleanType; use PHPStan\Type\ErrorType; use PHPStan\Type\IntegerType; @@ -313,4 +315,45 @@ public function testOptionalNullOffsetOnEmptyArrayIsPossiblyEmpty(): void $this->assertSame('array{0?: 1}', $array->describe(VerbosityLevel::precise())); } + public function testGetArrayEmptyWithUnknownSealednessStaysConstantArrayType(): void + { + $builder = ConstantArrayTypeBuilder::createEmpty(); + $array = $builder->getArray(); + $this->assertInstanceOf(ConstantArrayType::class, $array); + $this->assertSame('array{}', $array->describe(VerbosityLevel::precise())); + } + + public function testGetArraySealedEmptyStaysConstantArrayType(): void + { + $bleedingEdgeBackup = BleedingEdgeToggle::isBleedingEdge(); + BleedingEdgeToggle::setBleedingEdge(true); + try { + $builder = ConstantArrayTypeBuilder::createEmpty(); + $array = $builder->getArray(); + $this->assertInstanceOf(ConstantArrayType::class, $array); + $this->assertSame('array{}', $array->describe(VerbosityLevel::precise())); + } finally { + BleedingEdgeToggle::setBleedingEdge($bleedingEdgeBackup); + } + } + + public function testGetArrayEmptyWithRealUnsealedCollapsesToArrayType(): void + { + $builder = ConstantArrayTypeBuilder::createEmpty(); + $builder->makeUnsealed(new IntegerType(), new StringType()); + $array = $builder->getArray(); + $this->assertInstanceOf(ArrayType::class, $array); + $this->assertSame('array', $array->describe(VerbosityLevel::precise())); + } + + public function testGetArrayWithKnownKeysAndRealUnsealedStaysConstantArrayType(): void + { + $builder = ConstantArrayTypeBuilder::createEmpty(); + $builder->setOffsetValueType(new ConstantStringType('a'), new IntegerType()); + $builder->makeUnsealed(new StringType(), new StringType()); + $array = $builder->getArray(); + $this->assertInstanceOf(ConstantArrayType::class, $array); + $this->assertSame('array{a: int, ...}', $array->describe(VerbosityLevel::precise())); + } + } diff --git a/tests/PHPStan/Type/Constant/ConstantArrayTypeTest.php b/tests/PHPStan/Type/Constant/ConstantArrayTypeTest.php index 1c2b6fc410a..ced494973f0 100644 --- a/tests/PHPStan/Type/Constant/ConstantArrayTypeTest.php +++ b/tests/PHPStan/Type/Constant/ConstantArrayTypeTest.php @@ -3,16 +3,22 @@ namespace PHPStan\Type\Constant; use Closure; +use PHPStan\DependencyInjection\BleedingEdgeToggle; +use PHPStan\PhpDoc\TypeStringResolver; use PHPStan\Testing\PHPStanTestCase; use PHPStan\TrinaryLogic; use PHPStan\Type\Accessory\HasOffsetType; use PHPStan\Type\Accessory\HasOffsetValueType; use PHPStan\Type\ArrayType; +use PHPStan\Type\BooleanType; use PHPStan\Type\CallableType; +use PHPStan\Type\ClassStringType; +use PHPStan\Type\GeneralizePrecision; use PHPStan\Type\Generic\GenericClassStringType; use PHPStan\Type\Generic\TemplateTypeFactory; use PHPStan\Type\Generic\TemplateTypeScope; use PHPStan\Type\Generic\TemplateTypeVariance; +use PHPStan\Type\IntegerRangeType; use PHPStan\Type\IntegerType; use PHPStan\Type\IntersectionType; use PHPStan\Type\IterableType; @@ -20,13 +26,16 @@ use PHPStan\Type\NeverType; use PHPStan\Type\NullType; use PHPStan\Type\ObjectType; +use PHPStan\Type\ObjectWithoutClassType; use PHPStan\Type\StringType; use PHPStan\Type\Type; use PHPStan\Type\TypeCombinator; use PHPStan\Type\UnionType; use PHPStan\Type\VerbosityLevel; use PHPUnit\Framework\Attributes\DataProvider; +use stdClass; use function array_map; +use function is_string; use function sprintf; class ConstantArrayTypeTest extends PHPStanTestCase @@ -408,17 +417,225 @@ public static function dataAccepts(): iterable ]), TrinaryLogic::createMaybe(), ]; + + $bleedingEdgeBackup = BleedingEdgeToggle::isBleedingEdge(); + BleedingEdgeToggle::setBleedingEdge(false); + + yield [ + new ConstantArrayType([], []), + new ConstantArrayType([], []), + TrinaryLogic::createYes(), + ]; + + // empty array (with unknown sealedness) does not accept extra keys + yield [ + new ConstantArrayType([], []), + new ConstantArrayType([new ConstantStringType('a')], [new StringType()]), + TrinaryLogic::createNo(), + [], + ]; + + // non-empty array (with unknown sealedness) accepts extra keys + yield [ + new ConstantArrayType([new ConstantStringType('a')], [new StringType()]), + new ConstantArrayType([ + new ConstantStringType('a'), + new ConstantStringType('b'), + ], [ + new StringType(), + new IntegerType(), + ]), + TrinaryLogic::createYes(), + [], + ]; + + BleedingEdgeToggle::setBleedingEdge(true); + + // empty array (sealed) does not accept extra keys + yield [ + new ConstantArrayType([], []), + new ConstantArrayType([new ConstantStringType('a')], [new StringType()]), + TrinaryLogic::createNo(), + [], + ]; + + // non-empty array (sealed) does not accept extra keys + yield [ + new ConstantArrayType([new ConstantStringType('a')], [new StringType()]), + new ConstantArrayType([ + new ConstantStringType('a'), + new ConstantStringType('b'), + ], [ + new StringType(), + new IntegerType(), + ]), + TrinaryLogic::createNo(), + ['Sealed array shape does not accept array with extra key \'b\'.'], + ]; + + // sealed array does not accept general array + yield [ + new ConstantArrayType([new ConstantStringType('a')], [new StringType()]), + new ArrayType(new StringType(), new StringType()), + TrinaryLogic::createNo(), + ['Sealed array shape can only accept a constant array. Extra keys are not allowed.'], + ]; + + // sealed array does not accept unsealed array + yield [ + new ConstantArrayType([new ConstantStringType('a')], [new StringType()]), + new ConstantArrayType([new ConstantStringType('a')], [new StringType()], unsealed: [new StringType(), new ObjectType(stdClass::class)]), + TrinaryLogic::createNo(), + ['Sealed array shape does not accept unsealed array shape.'], + ]; + + // unsealed array accepts compatible general array + yield [ + new ConstantArrayType([new ConstantStringType('a')], [new StringType()], unsealed: [new StringType(), new StringType()]), + new IntersectionType([ + new ArrayType(new StringType(), new StringType()), + new HasOffsetValueType(new ConstantStringType('a'), new StringType()), + ]), + TrinaryLogic::createYes(), + [], + ]; + + // unsealed array does not accept incompatible general array (the error is in the keys already) + yield [ + new ConstantArrayType([new ConstantStringType('a')], [new IntegerType()], unsealed: [new StringType(), new StringType()]), + new IntersectionType([ + new ArrayType(new StringType(), new StringType()), + new HasOffsetValueType(new ConstantStringType('a'), new StringType()), + ]), + TrinaryLogic::createNo(), + [], + ]; + + // unsealed array does not accept incompatible general array (integer vs. string unsealed values) + yield [ + new ConstantArrayType([new ConstantStringType('a')], [new StringType()], unsealed: [new StringType(), new IntegerType()]), + new IntersectionType([ + new ArrayType(new StringType(), new StringType()), + new HasOffsetValueType(new ConstantStringType('a'), new StringType()), + ]), + TrinaryLogic::createNo(), + [], + ]; + + // unsealed array must check extra keys against its own unsealed types + yield [ + new ConstantArrayType([new ConstantStringType('a')], [new StringType()], unsealed: [new StringType(), new StringType()]), + new ConstantArrayType([ + new ConstantStringType('a'), + new ConstantStringType('b'), + ], [ + new StringType(), + new StringType(), + ]), + TrinaryLogic::createYes(), + [], + ]; + + yield [ + new ConstantArrayType([new ConstantStringType('a')], [new StringType()], unsealed: [new IntegerType(), new StringType()]), + new ConstantArrayType([ + new ConstantStringType('a'), + new ConstantIntegerType(10), + ], [ + new StringType(), + new StringType(), + ]), + TrinaryLogic::createYes(), + [], + ]; + + yield [ + new ConstantArrayType([new ConstantStringType('a')], [new StringType()], unsealed: [new IntegerType(), new StringType()]), + new ConstantArrayType([ + new ConstantStringType('a'), + new ConstantStringType('b'), + ], [ + new StringType(), + new StringType(), + ]), + TrinaryLogic::createNo(), + [ + 'Unsealed array key type int does not accept extra key type \'b\'.', + ], + ]; + + yield [ + new ConstantArrayType([new ConstantStringType('a')], [new StringType()], unsealed: [new StringType(), new IntegerType()]), + new ConstantArrayType([ + new ConstantStringType('a'), + new ConstantStringType('b'), + ], [ + new StringType(), + new StringType(), + ]), + TrinaryLogic::createNo(), + [ + 'Unsealed array value type int does not accept extra offset \'b\' with value type string.', + ], + ]; + + // unsealed array must check the other array unsealed types + yield [ + new ConstantArrayType([new ConstantStringType('a')], [new StringType()], unsealed: [new StringType(), new StringType()]), + new ConstantArrayType([new ConstantStringType('a')], [new StringType()], unsealed: [new StringType(), new StringType()]), + TrinaryLogic::createYes(), + [], + ]; + + yield [ + new ConstantArrayType([new ConstantStringType('a')], [new StringType()], unsealed: [new StringType(), new StringType()]), + new ConstantArrayType([new ConstantStringType('a')], [new StringType()], unsealed: [new IntegerType(), new StringType()]), + TrinaryLogic::createNo(), + [ + 'Unsealed array key type string does not accept unsealed array key type int.', + ], + ]; + + yield [ + new ConstantArrayType([new ConstantStringType('a')], [new StringType()], unsealed: [new StringType(), new StringType()]), + new ConstantArrayType([new ConstantStringType('a')], [new StringType()], unsealed: [new StringType(), new IntegerType()]), + TrinaryLogic::createNo(), + [ + 'Unsealed array value type string does not accept unsealed array value type int.', + ], + ]; + + yield [ + new ConstantArrayType([new ConstantStringType('a')], [new StringType()]), + new UnionType([ + new ConstantArrayType([new ConstantStringType('a')], [new StringType()]), + new StringType(), + ]), + TrinaryLogic::createMaybe(), + [], + ]; + + BleedingEdgeToggle::setBleedingEdge($bleedingEdgeBackup); } + /** + * @param array|null $reasons + */ #[DataProvider('dataAccepts')] - public function testAccepts(Type $type, Type $otherType, TrinaryLogic $expectedResult): void + public function testAccepts(Type $type, Type $otherType, TrinaryLogic $expectedResult, ?array $reasons = null): void { - $actualResult = $type->accepts($otherType, true)->result; + $actualResult = $type->accepts($otherType, true); + $testDescription = sprintf('%s -> accepts(%s)', $type->describe(VerbosityLevel::precise()), $otherType->describe(VerbosityLevel::precise())); $this->assertSame( $expectedResult->describe(), - $actualResult->describe(), - sprintf('%s -> accepts(%s)', $type->describe(VerbosityLevel::precise()), $otherType->describe(VerbosityLevel::precise())), + $actualResult->result->describe(), + $testDescription, ); + if ($reasons === null) { + return; + } + + $this->assertSame($reasons, $actualResult->reasons, $testDescription); } public static function dataIsSuperTypeOf(): iterable @@ -690,11 +907,105 @@ public static function dataIsSuperTypeOf(): iterable new ArrayType(new StringType(), new MixedType()), TrinaryLogic::createNo(), ]; + + // empty array (with unknown sealedness) does not accept extra keys + yield [ + new ConstantArrayType([], []), + new ConstantArrayType([new ConstantStringType('a')], [new StringType()]), + TrinaryLogic::createNo(), + ]; + + // non-empty array (with unknown sealedness) accepts extra keys + yield [ + new ConstantArrayType([new ConstantStringType('a')], [new StringType()]), + new ConstantArrayType([ + new ConstantStringType('a'), + new ConstantStringType('b'), + ], [ + new StringType(), + new IntegerType(), + ]), + TrinaryLogic::createYes(), + ]; + + // definite sealedness tests (bleeding edge) + + // both sealed, same keys, compatible values + yield ['array{a: int, b: string}', 'array{a: int, b: string}', TrinaryLogic::createYes()]; + + // both sealed, bigger vs smaller (subset) — sealed requires exact keys + yield ['array{a: int, b: string}', 'array{a: int}', TrinaryLogic::createNo()]; + yield ['array{a: int}', 'array{a: int, b: string}', TrinaryLogic::createNo()]; + + // both sealed, narrower value + yield ['array{a: int}', 'array{a: int<0, max>}', TrinaryLogic::createYes()]; + yield ['array{a: int<0, max>}', 'array{a: int}', TrinaryLogic::createMaybe()]; + + // both sealed, optional key in left only + yield ['array{a: int, b?: string}', 'array{a: int}', TrinaryLogic::createYes()]; + yield ['array{a: int, b?: string}', 'array{a: int, b: string}', TrinaryLogic::createYes()]; + + // both unsealed, compatible known keys + compatible unsealed + yield ['array{a: int, ...}', 'array{a: int<0, max>, ...}', TrinaryLogic::createYes()]; + yield ['array{a: int<0, max>, ...}', 'array{a: int, ...}', TrinaryLogic::createMaybe()]; + + // both unsealed, bigger known on right (right's extra fits left's unsealed extras) + yield ['array{a: int, ...}', 'array{a: int, b: string, ...}', TrinaryLogic::createYes()]; + + // both unsealed, right has known key left doesn't require; left's unsealed must cover + yield ['array{a: int, ...}', 'array{a: int, b: int, ...}', TrinaryLogic::createNo()]; + yield ['array{a: int, ...}', 'array{a: int, b: non-empty-string, ...}', TrinaryLogic::createYes()]; + + // both unsealed, narrower unsealed value on right + yield ['array{a: int, ...}', 'array{a: int, ...}', TrinaryLogic::createYes()]; + yield ['array{a: int, ...}', 'array{a: int, ...}', TrinaryLogic::createMaybe()]; + + // both unsealed, narrower unsealed key on right (array-key ⊃ string) + yield ['array{a: int, ...}', 'array{a: int, ...}', TrinaryLogic::createYes()]; + yield ['array{a: int, ...}', 'array{a: int, ...}', TrinaryLogic::createMaybe()]; + + // both unsealed, incompatible unsealed key types + yield ['array{...}', 'array{...}', TrinaryLogic::createNo()]; + + // both unsealed, incompatible unsealed value types + yield ['array{...}', 'array{...}', TrinaryLogic::createNo()]; + + // unsealed vs sealed — sealed's extras must fit unsealed's unsealed + yield ['array{a: int, ...}', 'array{a: int, b: string}', TrinaryLogic::createYes()]; + yield ['array{a: int, ...}', 'array{a: int, b: string}', TrinaryLogic::createNo()]; + + // sealed vs unsealed — unsealed might have extras sealed doesn't allow + yield ['array{a: int}', 'array{a: int, ...}', TrinaryLogic::createMaybe()]; + yield ['array{a: int, b: string}', 'array{a: int<0, max>, ...}', TrinaryLogic::createMaybe()]; + + // sealed vs unsealed where sealed's keys can't be in unsealed's extras + yield ['array{a: int}', 'array{...}', TrinaryLogic::createNo()]; + + // sealed vs unsealed where sealed fits unsealed's extras + yield ['array{a: int}', 'array{...}', TrinaryLogic::createMaybe()]; } + /** + * @param ConstantArrayType|string $type + * @param Type|string $otherType + */ #[DataProvider('dataIsSuperTypeOf')] - public function testIsSuperTypeOf(ConstantArrayType $type, Type $otherType, TrinaryLogic $expectedResult): void + public function testIsSuperTypeOf($type, $otherType, TrinaryLogic $expectedResult): void { + $bleedingEdgeBackup = BleedingEdgeToggle::isBleedingEdge(); + BleedingEdgeToggle::setBleedingEdge(true); + try { + $resolver = self::getContainer()->getByType(TypeStringResolver::class); + if (is_string($type)) { + $type = $resolver->resolve($type, null); + } + if (is_string($otherType)) { + $otherType = $resolver->resolve($otherType, null); + } + } finally { + BleedingEdgeToggle::setBleedingEdge($bleedingEdgeBackup); + } + $actualResult = $type->isSuperTypeOf($otherType); $this->assertSame( $expectedResult->describe(), @@ -902,6 +1213,174 @@ public static function dataIsCallable(): iterable ]), TrinaryLogic::createYes(), ]; + + yield [ + new ConstantArrayType([ + new ConstantIntegerType(0), + new ConstantIntegerType(1), + ], [ + new ClassStringType(), + new StringType(), + ]), + TrinaryLogic::createMaybe(), + ]; + + yield [ + new ConstantArrayType([ + new ConstantIntegerType(0), + new ConstantIntegerType(1), + ], [ + new ObjectWithoutClassType(), + new StringType(), + ]), + TrinaryLogic::createMaybe(), + ]; + + $never = new NeverType(true); + $sealed = [$never, $never]; + + yield [ + new ConstantArrayType([ + new ConstantIntegerType(0), + new ConstantIntegerType(1), + ], [ + new ObjectWithoutClassType(), + new StringType(), + ], unsealed: $sealed), + TrinaryLogic::createMaybe(), + ]; + + yield [ + new ConstantArrayType([ + new ConstantIntegerType(0), + new ConstantIntegerType(1), + ], [ + new ObjectWithoutClassType(), + new StringType(), + ], unsealed: [new IntegerType(), new StringType()]), + TrinaryLogic::createMaybe(), + ]; + + yield [ + new ConstantArrayType([ + new ConstantIntegerType(0), + new ConstantIntegerType(1), + ], [ + new GenericClassStringType(new ObjectType(Closure::class)), + new ConstantStringType('bind'), + ], unsealed: [new IntegerType(), new StringType()]), + TrinaryLogic::createMaybe(), // extra keys would void the callable-ness + ]; + + yield [ + new ConstantArrayType([ + new ConstantIntegerType(0), + ], [ + new ObjectWithoutClassType(), + ], unsealed: [new IntegerType(), new StringType()]), + TrinaryLogic::createMaybe(), + ]; + + yield [ + new ConstantArrayType([ + new ConstantIntegerType(0), + ], [ + new ObjectWithoutClassType(), + ], unsealed: $sealed), + TrinaryLogic::createNo(), + ]; + + yield [ + new ConstantArrayType([ + new ConstantIntegerType(0), + ], [ + new ObjectWithoutClassType(), + ], unsealed: [IntegerRangeType::createAllGreaterThanOrEqualTo(2), new StringType()]), + TrinaryLogic::createNo(), + ]; + + yield [ + new ConstantArrayType([ + new ConstantIntegerType(0), + ], [ + new ObjectWithoutClassType(), + ], unsealed: [new StringType(), new StringType()]), + TrinaryLogic::createNo(), + ]; + + // Only key 0 explicit, value at key 1 from unsealed can never be + // a non-falsy-string (int → not a string at all). + yield [ + new ConstantArrayType([ + new ConstantIntegerType(0), + ], [ + new ObjectWithoutClassType(), + ], unsealed: [new IntegerType(), new IntegerType()]), + TrinaryLogic::createNo(), + ]; + + // Only key 1 explicit, value at key 0 from unsealed must be + // object|class-string; int can never be that. + yield [ + new ConstantArrayType([ + new ConstantIntegerType(1), + ], [ + new ConstantStringType('bind'), + ], unsealed: [new IntegerType(), new IntegerType()]), + TrinaryLogic::createNo(), + ]; + + // Only key 1 explicit, value at key 0 from unsealed is a plain + // string — `string ∩ (object|class-string) = class-string`, so + // it could line up. + yield [ + new ConstantArrayType([ + new ConstantIntegerType(1), + ], [ + new ConstantStringType('bind'), + ], unsealed: [new IntegerType(), new StringType()]), + TrinaryLogic::createMaybe(), + ]; + + // Sealed three-element array is never a callable (callable + // shape has exactly two slots). + yield [ + new ConstantArrayType([ + new ConstantIntegerType(0), + new ConstantIntegerType(1), + new ConstantIntegerType(2), + ], [ + new GenericClassStringType(new ObjectType(Closure::class)), + new ConstantStringType('bind'), + new ConstantStringType('extra'), + ]), + TrinaryLogic::createNo(), + ]; + + // Sealed two-element array with a stray non-callable key + // position is never a callable. + yield [ + new ConstantArrayType([ + new ConstantIntegerType(0), + new ConstantIntegerType(5), + ], [ + new GenericClassStringType(new ObjectType(Closure::class)), + new ConstantStringType('bind'), + ]), + TrinaryLogic::createNo(), + ]; + + // Fully open `array{...}`: callable iff actual + // extras happen to land on `[0 => object|class-string, + // 1 => non-falsy-string]` — uncertain by construction. + yield [ + new ConstantArrayType([], [], unsealed: [new MixedType(), new MixedType()]), + TrinaryLogic::createMaybe(), + ]; + + // Empty value, no explicit keys, sealed → empty array → No. + // (Already covered by the 'zero items' case above; included here + // as a foil for the open-shape variant.) } public static function dataValuesArray(): iterable @@ -1070,4 +1549,530 @@ public function testHasOffsetValueType( ); } + public function testEqualsTreatsLegacyNullAndSealedMarkerAsEqual(): void + { + $bleedingEdgeBackup = BleedingEdgeToggle::isBleedingEdge(); + + try { + // Pre-bleeding-edge construction leaves the unsealed slot null + // (`isUnsealed()` answers `Maybe`). + BleedingEdgeToggle::setBleedingEdge(false); + $legacyNull = new ConstantArrayType([new ConstantStringType('a')], [new IntegerType()]); + + // Bleeding-edge construction seeds the `[NeverType, NeverType]` + // sealed marker (`isUnsealed()` answers `No`). + BleedingEdgeToggle::setBleedingEdge(true); + $sealedMarker = new ConstantArrayType([new ConstantStringType('a')], [new IntegerType()]); + + // Both represent the same sealed shape, so they must compare + // equal in both directions — this mismatch is what made the + // `TypeToPhpDocNode` round-trip fail under old PHPUnit (data + // providers run before the container enables bleeding edge). + $this->assertTrue($legacyNull->equals($sealedMarker), 'legacy-null should equal sealed-marker'); + $this->assertTrue($sealedMarker->equals($legacyNull), 'sealed-marker should equal legacy-null'); + } finally { + BleedingEdgeToggle::setBleedingEdge($bleedingEdgeBackup); + } + } + + public function testSealedness(): void + { + $bleedingEdgeBackup = BleedingEdgeToggle::isBleedingEdge(); + + BleedingEdgeToggle::setBleedingEdge(false); + + try { + $builder = ConstantArrayTypeBuilder::createEmpty(); + $array = $builder->getArray(); + $this->assertInstanceOf(ConstantArrayType::class, $array); + $this->assertSame(TrinaryLogic::createMaybe()->describe(), $array->isSealed()->describe()); + $this->assertSame(TrinaryLogic::createMaybe()->describe(), $array->isUnsealed()->describe()); + + BleedingEdgeToggle::setBleedingEdge(true); + $builder = ConstantArrayTypeBuilder::createEmpty(); + $array = $builder->getArray(); + $this->assertInstanceOf(ConstantArrayType::class, $array); + $this->assertSame(TrinaryLogic::createYes()->describe(), $array->isSealed()->describe()); + $this->assertSame(TrinaryLogic::createNo()->describe(), $array->isUnsealed()->describe()); + + $builder = ConstantArrayTypeBuilder::createEmpty(); + $builder->makeUnsealed(new IntegerType(), new StringType()); + $array = $builder->getArray(); + // No known keys + real unsealed extras now collapses to a general ArrayType + // (see ConstantArrayTypeBuilder::getArray). + $this->assertInstanceOf(ArrayType::class, $array); + $this->assertSame('array', $array->describe(VerbosityLevel::precise())); + } finally { + BleedingEdgeToggle::setBleedingEdge($bleedingEdgeBackup); + } + } + + public static function dataGetArraySize(): iterable + { + $bleedingEdgeBackup = BleedingEdgeToggle::isBleedingEdge(); + + foreach ([false, true] as $bleedingEdge) { + BleedingEdgeToggle::setBleedingEdge($bleedingEdge); + + yield [ + new ConstantArrayType([], []), + new ConstantIntegerType(0), + ]; + + $builder = ConstantArrayTypeBuilder::createEmpty(); + yield [ + $builder->getArray(), + new ConstantIntegerType(0), + ]; + + $builder->makeUnsealed(new IntegerType(), new ObjectType(stdClass::class)); + yield [ + $builder->getArray(), + IntegerRangeType::createAllGreaterThanOrEqualTo(0), + ]; + + $builder->setOffsetValueType(new ConstantIntegerType(0), new ObjectType(stdClass::class)); + yield [ + $builder->getArray(), + IntegerRangeType::createAllGreaterThanOrEqualTo(1), + ]; + + $builder->setOffsetValueType(new ConstantIntegerType(1), new ObjectType(stdClass::class), true); + yield [ + $builder->getArray(), + IntegerRangeType::createAllGreaterThanOrEqualTo(1), + ]; + } + + $builder = ConstantArrayTypeBuilder::createEmpty(); + $builder->makeUnsealed(new IntegerType(), new ObjectType(stdClass::class)); + yield [ + $builder->getArray(), + IntegerRangeType::createAllGreaterThanOrEqualTo(0), + ]; + $builder->setOffsetValueType(new ConstantIntegerType(0), new ObjectType(stdClass::class)); + yield [ + $builder->getArray(), + IntegerRangeType::createAllGreaterThanOrEqualTo(1), + ]; + + $builder->setOffsetValueType(new ConstantIntegerType(1), new ObjectType(stdClass::class), true); + yield [ + $builder->getArray(), + IntegerRangeType::createAllGreaterThanOrEqualTo(1), + ]; + + BleedingEdgeToggle::setBleedingEdge($bleedingEdgeBackup); + } + + #[DataProvider('dataGetArraySize')] + public function testGetArraySize(Type $constantArray, Type $expectedSize): void + { + $this->assertSame($expectedSize->describe(VerbosityLevel::precise()), $constantArray->getArraySize()->describe(VerbosityLevel::precise())); + } + + public static function dataGetFiniteTypes(): iterable + { + yield 'empty array' => [ + new ConstantArrayType([], []), + ['array{}'], + ]; + + yield 'single key with single finite value' => [ + new ConstantArrayType( + [new ConstantStringType('a')], + [new ConstantStringType('foo')], + ), + ["array{a: 'foo'}"], + ]; + + yield 'multiple finite-only values' => [ + new ConstantArrayType( + [ + new ConstantStringType('a'), + new ConstantStringType('b'), + ], + [ + new ConstantIntegerType(1), + new ConstantStringType('foo'), + ], + ), + ["array{a: 1, b: 'foo'}"], + ]; + + yield 'union value expands to cartesian product' => [ + new ConstantArrayType( + [new ConstantStringType('a')], + [new UnionType([ + new ConstantIntegerType(1), + new ConstantIntegerType(2), + ])], + ), + ['array{a: 1}', 'array{a: 2}'], + ]; + + yield 'two union values expand to full cartesian product' => [ + new ConstantArrayType( + [ + new ConstantStringType('a'), + new ConstantStringType('b'), + ], + [ + new UnionType([ + new ConstantIntegerType(1), + new ConstantIntegerType(2), + ]), + new UnionType([ + new ConstantStringType('x'), + new ConstantStringType('y'), + ]), + ], + ), + [ + "array{a: 1, b: 'x'}", + "array{a: 1, b: 'y'}", + "array{a: 2, b: 'x'}", + "array{a: 2, b: 'y'}", + ], + ]; + + yield 'bool value expands to true/false' => [ + new ConstantArrayType( + [new ConstantStringType('flag')], + [new BooleanType()], + ), + ['array{flag: true}', 'array{flag: false}'], + ]; + + yield 'non-finite value yields no finite types' => [ + new ConstantArrayType( + [new ConstantStringType('a')], + [new IntegerType()], + ), + [], + ]; + + yield 'mixed finite and non-finite values yield no finite types' => [ + new ConstantArrayType( + [ + new ConstantStringType('a'), + new ConstantStringType('b'), + ], + [ + new ConstantIntegerType(1), + new IntegerType(), + ], + ), + [], + ]; + + yield 'optional key forks with-without' => [ + new ConstantArrayType( + [ + new ConstantStringType('a'), + new ConstantStringType('b'), + ], + [ + new ConstantIntegerType(1), + new ConstantStringType('foo'), + ], + [0], + [0], + ), + [ + "array{b: 'foo'}", + "array{a: 1, b: 'foo'}", + ], + ]; + + yield 'all optional keys' => [ + new ConstantArrayType( + [ + new ConstantStringType('a'), + new ConstantStringType('b'), + ], + [ + new ConstantIntegerType(1), + new ConstantStringType('foo'), + ], + [0], + [0, 1], + ), + [ + 'array{}', + "array{b: 'foo'}", + 'array{a: 1}', + "array{a: 1, b: 'foo'}", + ], + ]; + + yield 'optional key combined with union value' => [ + new ConstantArrayType( + [ + new ConstantStringType('a'), + new ConstantStringType('b'), + ], + [ + new UnionType([ + new ConstantIntegerType(1), + new ConstantIntegerType(2), + ]), + new ConstantStringType('foo'), + ], + [2], + [0], + ), + [ + "array{b: 'foo'}", + "array{a: 1, b: 'foo'}", + "array{a: 2, b: 'foo'}", + ], + ]; + + yield 'exceeding CALCULATE_SCALARS_LIMIT bails out' => [ + (static function (): ConstantArrayType { + $keyTypes = []; + $valueTypes = []; + // 8 keys × 2 = 256 combinations, well above the 128 limit. + for ($i = 0; $i < 8; $i++) { + $keyTypes[] = new ConstantIntegerType($i); + $valueTypes[] = new UnionType([ + new ConstantStringType('a'), + new ConstantStringType('b'), + ]); + } + return new ConstantArrayType($keyTypes, $valueTypes); + })(), + [], + ]; + + $never = new NeverType(true); + $sealed = [$never, $never]; + + yield 'sealed is finite' => [ + new ConstantArrayType( + [new ConstantStringType('a')], + [new ConstantStringType('foo')], + unsealed: $sealed, + ), + ["array{a: 'foo'}"], + ]; + + yield 'unsealed is finite' => [ + new ConstantArrayType( + [new ConstantStringType('a')], + [new ConstantStringType('foo')], + unsealed: [new IntegerType(), new StringType()], + ), + [], + ]; + } + + /** + * @param list $expectedDescriptions + */ + #[DataProvider('dataGetFiniteTypes')] + public function testGetFiniteTypes(ConstantArrayType $type, array $expectedDescriptions): void + { + $actual = array_map( + static fn (Type $finite): string => $finite->describe(VerbosityLevel::precise()), + $type->getFiniteTypes(), + ); + + $this->assertSame( + $expectedDescriptions, + $actual, + sprintf('%s -> getFiniteTypes()', $type->describe(VerbosityLevel::precise())), + ); + } + + public static function dataGeneralize(): iterable + { + $never = new NeverType(true); + $sealedMarker = [$never, $never]; + + yield 'sealed empty (legacy null unsealed)' => [ + new ConstantArrayType([], []), + GeneralizePrecision::lessSpecific(), + 'array{}', + ]; + + yield 'sealed empty (bleeding-edge NeverType marker)' => [ + new ConstantArrayType([], [], unsealed: $sealedMarker), + GeneralizePrecision::lessSpecific(), + 'array{}', + ]; + + yield 'sealed single explicit key' => [ + new ConstantArrayType( + [new ConstantStringType('a')], + [new ConstantIntegerType(1)], + unsealed: $sealedMarker, + ), + GeneralizePrecision::lessSpecific(), + 'non-empty-array', + ]; + + yield 'sealed two explicit keys, lessSpecific' => [ + new ConstantArrayType( + [new ConstantStringType('a'), new ConstantStringType('b')], + [new ConstantIntegerType(1), new ConstantStringType('x')], + unsealed: $sealedMarker, + ), + GeneralizePrecision::lessSpecific(), + 'non-empty-array', + ]; + + yield 'sealed two explicit keys, moreSpecific' => [ + new ConstantArrayType( + [new ConstantStringType('a'), new ConstantStringType('b')], + [new ConstantIntegerType(1), new ConstantStringType('x')], + unsealed: $sealedMarker, + ), + GeneralizePrecision::moreSpecific(), + "non-empty-array&hasOffsetValue('a', int)&hasOffsetValue('b', literal-string&lowercase-string&non-falsy-string)", + ]; + + yield 'sealed list, lessSpecific' => [ + new ConstantArrayType( + [new ConstantIntegerType(0), new ConstantIntegerType(1)], + [new ConstantIntegerType(1), new ConstantIntegerType(2)], + unsealed: $sealedMarker, + isList: TrinaryLogic::createYes(), + ), + GeneralizePrecision::lessSpecific(), + 'non-empty-list', + ]; + + yield 'sealed only-optional keys' => [ + new ConstantArrayType( + [new ConstantStringType('a')], + [new ConstantIntegerType(1)], + optionalKeys: [0], + unsealed: $sealedMarker, + ), + GeneralizePrecision::lessSpecific(), + 'array', + ]; + + yield 'unsealed only, lessSpecific' => [ + new ConstantArrayType([], [], unsealed: [new IntegerType(), new ConstantStringType('foo')]), + GeneralizePrecision::lessSpecific(), + // No explicit keys but real unsealed extras — generalize + // has to broaden the unsealed value (`'foo'` → `string`) + // and degrade to a plain `ArrayType`. The size is uncertain + // (zero-or-more extras), so no `NonEmptyArrayType`. + 'array', + ]; + + yield 'unsealed only with non-falsy-string key, moreSpecific' => [ + new ConstantArrayType([], [], unsealed: [new IntegerType(), new ConstantStringType('foo')]), + GeneralizePrecision::moreSpecific(), + 'array', + ]; + + yield 'unsealed with explicit key, lessSpecific' => [ + new ConstantArrayType( + [new ConstantStringType('a')], + [new ConstantIntegerType(1)], + unsealed: [new IntegerType(), new StringType()], + ), + GeneralizePrecision::lessSpecific(), + 'non-empty-array', + ]; + + yield 'unsealed with explicit key, moreSpecific' => [ + new ConstantArrayType( + [new ConstantStringType('a')], + [new ConstantIntegerType(1)], + unsealed: [new IntegerType(), new StringType()], + ), + GeneralizePrecision::moreSpecific(), + "non-empty-array&hasOffsetValue('a', int)", + ]; + + yield 'unsealed with optional explicit key' => [ + new ConstantArrayType( + [new ConstantStringType('a')], + [new ConstantIntegerType(1)], + optionalKeys: [0], + unsealed: [new IntegerType(), new StringType()], + ), + GeneralizePrecision::lessSpecific(), + 'array', + ]; + + yield 'templateArgument routes through traverse' => [ + new ConstantArrayType( + [new ConstantStringType('a')], + [new ConstantIntegerType(1)], + unsealed: [new IntegerType(), new ConstantStringType('foo')], + ), + GeneralizePrecision::templateArgument(), + // `traverse` recurses into both explicit and unsealed values + // (see commit history c. unsealed-aware traverse): `1` → + // `int`, `'foo'` → `string`. + 'array{a: int, ...}', + ]; + } + + #[DataProvider('dataGeneralize')] + public function testGeneralize(ConstantArrayType $type, GeneralizePrecision $precision, string $expectedDescription): void + { + $this->assertSame( + $expectedDescription, + $type->generalize($precision)->describe(VerbosityLevel::precise()), + ); + } + + public function testGeneralizeValuesAlsoBroadensUnsealedValue(): void + { + $type = new ConstantArrayType( + [new ConstantStringType('a')], + [new ConstantIntegerType(1)], + unsealed: [new IntegerType(), new ConstantStringType('foo')], + ); + + $this->assertSame( + 'array{a: int, ...}', + $type->generalizeValues()->describe(VerbosityLevel::precise()), + ); + } + + public function testTraverseSimultaneouslyVisitsUnsealedValue(): void + { + $left = new ConstantArrayType( + [new ConstantStringType('a')], + [new IntegerType()], + unsealed: [new IntegerType(), new IntegerType()], + ); + $right = new ConstantArrayType( + [new ConstantStringType('a')], + [new StringType()], + unsealed: [new IntegerType(), new StringType()], + ); + + $visited = []; + $result = $left->traverseSimultaneously($right, static function (Type $l, Type $r) use (&$visited): Type { + $visited[] = [ + $l->describe(VerbosityLevel::precise()), + $r->describe(VerbosityLevel::precise()), + ]; + return new MixedType(); + }); + + $this->assertSame( + [ + ['int', 'string'], + ['int', 'string'], + ], + $visited, + ); + + $this->assertSame( + 'array{a: mixed, ...}', + $result->describe(VerbosityLevel::precise()), + ); + } + } diff --git a/tests/PHPStan/Type/Constant/ConstantStringTypeTest.php b/tests/PHPStan/Type/Constant/ConstantStringTypeTest.php index 5b11f9913e9..72da17e3c61 100644 --- a/tests/PHPStan/Type/Constant/ConstantStringTypeTest.php +++ b/tests/PHPStan/Type/Constant/ConstantStringTypeTest.php @@ -185,6 +185,65 @@ public function testSetInvalidValue(): void $this->assertInstanceOf(ErrorType::class, $result); } + public static function dataIsDecimalIntegerString(): iterable + { + yield [ + '0', + TrinaryLogic::createYes(), + ]; + yield [ + '1', + TrinaryLogic::createYes(), + ]; + yield [ + '1234', + TrinaryLogic::createYes(), + ]; + yield [ + '-1', + TrinaryLogic::createYes(), + ]; + yield [ + '+1', + TrinaryLogic::createNo(), + ]; + yield [ + '00', + TrinaryLogic::createNo(), + ]; + yield [ + '01', + TrinaryLogic::createNo(), + ]; + yield [ + '18E+3', + TrinaryLogic::createNo(), + ]; + yield [ + '1.2', + TrinaryLogic::createNo(), + ]; + yield [ + '1,3', + TrinaryLogic::createNo(), + ]; + yield [ + 'foo', + TrinaryLogic::createNo(), + ]; + yield [ + '1foo', + TrinaryLogic::createNo(), + ]; + } + + #[DataProvider('dataIsDecimalIntegerString')] + public function testIsDecimalIntegerString(string $value, TrinaryLogic $expected): void + { + $type = new ConstantStringType($value); + $this->assertSame($expected->describe(), $type->isDecimalIntegerString()->describe()); + } + #[DataProvider('dataIsCallable')] public function testIsCallable(TrinaryLogic $trinaryLogic, string $constantValue): void { diff --git a/tests/PHPStan/Type/IntersectionTypeTest.php b/tests/PHPStan/Type/IntersectionTypeTest.php index c9d6a69f7e9..1d980af5918 100644 --- a/tests/PHPStan/Type/IntersectionTypeTest.php +++ b/tests/PHPStan/Type/IntersectionTypeTest.php @@ -8,6 +8,7 @@ use PHPStan\Testing\PHPStanTestCase; use PHPStan\TrinaryLogic; use PHPStan\Type\Accessory\AccessoryArrayListType; +use PHPStan\Type\Accessory\AccessoryDecimalIntegerStringType; use PHPStan\Type\Accessory\AccessoryLowercaseStringType; use PHPStan\Type\Accessory\AccessoryUppercaseStringType; use PHPStan\Type\Accessory\HasOffsetType; @@ -920,6 +921,30 @@ public static function dataDescribe(): iterable VerbosityLevel::precise(), 'uppercase-string', ]; + + yield [ + new IntersectionType([new StringType(), new AccessoryDecimalIntegerStringType()]), + VerbosityLevel::typeOnly(), + 'string', + ]; + + yield [ + new IntersectionType([new StringType(), new AccessoryDecimalIntegerStringType(inverse: true)]), + VerbosityLevel::typeOnly(), + 'string', + ]; + + yield [ + new IntersectionType([new StringType(), new AccessoryDecimalIntegerStringType()]), + VerbosityLevel::value(), + 'decimal-int-string', + ]; + + yield [ + new IntersectionType([new StringType(), new AccessoryDecimalIntegerStringType(inverse: true)]), + VerbosityLevel::value(), + 'non-decimal-int-string', + ]; } #[DataProvider('dataDescribe')] diff --git a/tests/PHPStan/Type/StringTypeTest.php b/tests/PHPStan/Type/StringTypeTest.php index 3be9e03240a..205f6a81c0b 100644 --- a/tests/PHPStan/Type/StringTypeTest.php +++ b/tests/PHPStan/Type/StringTypeTest.php @@ -5,6 +5,7 @@ use Exception; use PHPStan\Testing\PHPStanTestCase; use PHPStan\TrinaryLogic; +use PHPStan\Type\Accessory\AccessoryDecimalIntegerStringType; use PHPStan\Type\Accessory\HasPropertyType; use PHPStan\Type\Constant\ConstantStringType; use PHPStan\Type\Generic\GenericClassStringType; @@ -173,12 +174,87 @@ public static function dataAccepts(): iterable )->toArgument(), TrinaryLogic::createYes(), ]; + + $decimalIntString = new IntersectionType([ + new StringType(), + new AccessoryDecimalIntegerStringType(), + ]); + $nonDecimalIntString = new IntersectionType([ + new StringType(), + new AccessoryDecimalIntegerStringType(inverse: true), + ]); + + yield [ + $decimalIntString, + new ConstantStringType('1'), + TrinaryLogic::createYes(), + ]; + yield [ + $decimalIntString, + $decimalIntString, + TrinaryLogic::createYes(), + ]; + yield [ + $decimalIntString, + $nonDecimalIntString, + TrinaryLogic::createNo(), + ]; + yield [ + $nonDecimalIntString, + $decimalIntString, + TrinaryLogic::createNo(), + ]; + yield [ + $decimalIntString, + new StringType(), + TrinaryLogic::createMaybe(), + ]; + yield [ + $decimalIntString, + new ConstantStringType('10'), + TrinaryLogic::createYes(), + ]; + yield [ + $nonDecimalIntString, + new ConstantStringType('10'), + TrinaryLogic::createNo(), + ]; + yield [ + $decimalIntString, + new ConstantStringType('foo'), + TrinaryLogic::createNo(), + ]; + yield [ + $nonDecimalIntString, + new ConstantStringType('foo'), + TrinaryLogic::createYes(), + ]; + yield [ + $nonDecimalIntString, + new StringType(), + TrinaryLogic::createMaybe(), + ]; + yield [ + $nonDecimalIntString, + new ConstantStringType('1'), + TrinaryLogic::createNo(), + ]; + yield [ + $decimalIntString, + new ConstantStringType('+1'), + TrinaryLogic::createNo(), + ]; + yield [ + $nonDecimalIntString, + new ConstantStringType('+1'), + TrinaryLogic::createYes(), + ]; } #[DataProvider('dataAccepts')] public function testAccepts(Type $stringType, Type $otherType, TrinaryLogic $expectedResult): void { - $this->assertInstanceOf(StringType::class, $stringType); + $this->assertSame('Yes', $stringType->isString()->describe()); $actualResult = $stringType->accepts($otherType, true)->result; $this->assertSame( diff --git a/tests/PHPStan/Type/TypeCombinatorTest.php b/tests/PHPStan/Type/TypeCombinatorTest.php index f325c638da4..de64a500225 100644 --- a/tests/PHPStan/Type/TypeCombinatorTest.php +++ b/tests/PHPStan/Type/TypeCombinatorTest.php @@ -14,12 +14,15 @@ use InvalidArgumentException; use Iterator; use ObjectShapesAcceptance\ClassWithFooIntProperty; +use PHPStan\DependencyInjection\BleedingEdgeToggle; use PHPStan\Fixture\FinalClass; use PHPStan\Generics\FunctionsAssertType\C; +use PHPStan\PhpDoc\TypeStringResolver; use PHPStan\Reflection\Callables\SimpleImpurePoint; use PHPStan\Testing\PHPStanTestCase; use PHPStan\TrinaryLogic; use PHPStan\Type\Accessory\AccessoryArrayListType; +use PHPStan\Type\Accessory\AccessoryDecimalIntegerStringType; use PHPStan\Type\Accessory\AccessoryLiteralStringType; use PHPStan\Type\Accessory\AccessoryLowercaseStringType; use PHPStan\Type\Accessory\AccessoryNonEmptyStringType; @@ -63,6 +66,7 @@ use function array_reverse; use function get_class; use function implode; +use function is_string; use function sprintf; use const PHP_VERSION_ID; @@ -730,7 +734,7 @@ public static function dataUnion(): iterable ]), ], UnionType::class, - 'array{foo: DateTimeImmutable, bar: int}|array{foo: null, bar: string}', + 'array{bar: int, foo: DateTimeImmutable}|array{bar: string, foo: null}', ], [ [ @@ -748,7 +752,7 @@ public static function dataUnion(): iterable ]), ], UnionType::class, - 'array{foo: DateTimeImmutable, bar: int}|array{foo: null}', + 'array{bar: int, foo: DateTimeImmutable}|array{foo: null}', ], [ [ @@ -770,7 +774,7 @@ public static function dataUnion(): iterable ]), ], UnionType::class, - 'array{foo: DateTimeImmutable, bar: int}|array{foo: null, bar: string, baz: int}', + 'array{bar: int, foo: DateTimeImmutable}|array{bar: string, baz: int, foo: null}', ], [ [ @@ -2653,7 +2657,7 @@ public static function dataUnion(): iterable new NonAcceptingNeverType(), ], NeverType::class, - 'never', + 'never=explicit', ]; yield [ [ @@ -2857,10 +2861,330 @@ public static function dataUnion(): iterable ObjectType::class, $nonFinalClass->getDisplayName(), ]; + + $decimalIntString = new IntersectionType([ + new StringType(), + new AccessoryDecimalIntegerStringType(), + ]); + $nonDecimalIntString = new IntersectionType([ + new StringType(), + new AccessoryDecimalIntegerStringType(inverse: true), + ]); + + yield [ + [ + $decimalIntString, + new StringType(), + ], + StringType::class, + 'string', + ]; + yield [ + [ + $nonDecimalIntString, + new StringType(), + ], + StringType::class, + 'string', + ]; + yield [ + [ + $decimalIntString, + $nonDecimalIntString, + ], + StringType::class, + 'string', + ]; + + yield [ + [ + $decimalIntString, + new IntersectionType([new StringType(), new AccessoryNumericStringType()]), + ], + IntersectionType::class, + 'numeric-string', + ]; + + yield [ + [ + $nonDecimalIntString, + new IntersectionType([new StringType(), new AccessoryNumericStringType()]), + ], + StringType::class, + 'string', + ]; + + yield [ + [ + $decimalIntString, + new IntersectionType([new StringType(), new AccessoryLowercaseStringType()]), + ], + IntersectionType::class, + 'lowercase-string', + ]; + + yield [ + [ + $nonDecimalIntString, + new IntersectionType([new StringType(), new AccessoryLowercaseStringType()]), + ], + StringType::class, + 'string', + ]; + + yield [ + [ + new ConstantArrayType( + [new ConstantIntegerType(0), new ConstantIntegerType(1)], + [new IntegerType(), new UnionType([ + new ConstantStringType('0'), + new ConstantStringType('foo'), + ])], + ), + new ConstantArrayType( + [new ConstantIntegerType(0), new ConstantIntegerType(1)], + [IntegerRangeType::createAllGreaterThanOrEqualTo(0), new IntersectionType([ + new StringType(), + new AccessoryNonFalsyStringType(), + ])], + ), + ], + ConstantArrayType::class, + 'array{int, non-empty-string}', + ]; + + // current behaviour (unknown sealedness) + yield [ + [ + new ConstantArrayType( + [ + new ConstantStringType('a'), + new ConstantStringType('b'), + ], + [ + new IntegerType(), + new StringType(), + ], + ), + new ConstantArrayType( + [ + new ConstantStringType('a'), + ], + [ + new IntegerType(), + ], + ), + ], + ConstantArrayType::class, + 'array{a: int, b?: string}', + ]; + + // new behaviour with definitely sealed arrays + yield [ + [ + 'array{a: int, b: string}', + 'array{a: int}', + ], + ConstantArrayType::class, + 'array{a: int, b?: string}', + ]; + + yield [ + [ + 'array{a: true, b: string}', + 'array{a: false}', + ], + UnionType::class, + 'array{a: false}|array{a: true, b: string}', + ]; + + yield [ + [ + 'array{int, 0|\'foo\'}', + 'array{int<0, max>, non-falsy-string}', + ], + ConstantArrayType::class, + 'array{int, 0|non-falsy-string}', + ]; + + yield [ + [ + 'array{a: int, b: string}', + 'array{a: int<0, max>, ...}', + ], + ConstantArrayType::class, + 'array{a: int, ...}', + ]; + + yield [ + [ + 'array{a: int, b: string}', + 'array{a: int<0, max>, ...}', + ], + ConstantArrayType::class, + 'array{a: int, b?: string, ...}', + ]; + + yield [ + [ + 'array{a: int, b: string, ...}', + 'array{a: int<0, max>, ...}', + ], + ConstantArrayType::class, + 'array{a: int, ...}', + ]; + + yield [ + [ + 'array{a: int, ...}', + 'array{b: string, ...}', + ], + IntersectionType::class, + 'non-empty-array', + ]; + + yield [ + [ + 'array{a: int, ...}', + 'array{b: string, ...}', + ], + IntersectionType::class, + 'non-empty-array{a?: int, b?: string, ...}', + ]; + + yield [ + [ + 'array{a: int, ...}', + 'array{b: string, ...}', + ], + IntersectionType::class, + 'non-empty-array{a?: int, ...}', + ]; + + yield [ + [ + 'array{a: string, ...}', + 'array{b: string, ...}', + ], + IntersectionType::class, + 'non-empty-array', + ]; + + yield [ + [ + 'array{a: int, ...}', + 'array{a: int, ...}', + ], + ConstantArrayType::class, + 'array{a: int, ...}', + ]; + + yield [ + [ + 'array{a: int, ...}', + 'array{a: int, ...}', + ], + ConstantArrayType::class, + 'array{a: int, ...}', + ]; + + yield [ + [ + 'array{...>}', + 'array{...>}', + ], + ArrayType::class, + 'array', + ]; + + yield [ + [ + 'array{...}', + 'array{...}', + ], + ArrayType::class, + 'array', + ]; + + yield [ + [ + 'array{...}', + 'array{...}', + ], + ArrayType::class, + 'array', + ]; + + yield [ + [ + 'array{a: int, ...}', + 'array{...}', + ], + ArrayType::class, + 'array', + ]; + + yield [ + [ + 'array{a: non-empty-string, ...}', + 'array{...}', + ], + ArrayType::class, + 'array', + ]; + + yield [ + [ + 'array{a: int, ...}', + 'array{a: string, ...}', + ], + ConstantArrayType::class, + 'array{a: int|string, ...}', + ]; + + yield [ + [ + 'array{a: int}', + 'array{...}', + ], + ArrayType::class, + 'array<\'a\'|int, int>', + ]; + + yield [ + [ + 'array{a: int}', + 'array{...}', + ], + ArrayType::class, + 'array', + ]; + + // Both unsealed with a shared known key → result preserves the shape as ConstantArrayType + // (only the "empty known keys + real unsealed extras" combination collapses to ArrayType). + yield [ + [ + 'array{a: int, ...}', + 'array{a: int, ...}', + ], + ConstantArrayType::class, + 'array{a: int, ...}', + ]; + + // Sealed empty arrays stay as ConstantArrayType — explicit-Never unsealed + // is NOT "real" extras, so it doesn't trigger the ArrayType collapse. + yield [ + [ + 'array{}', + 'array{}', + ], + ConstantArrayType::class, + 'array{}', + ]; } /** - * @param list $types + * @param list|list $types * @param class-string $expectedTypeClass */ #[DataProvider('dataUnion')] @@ -2870,29 +3194,22 @@ public function testUnion( string $expectedTypeDescription, ): void { - $actualType = TypeCombinator::union(...$types); - $actualTypeDescription = $actualType->describe(VerbosityLevel::precise()); - if ($actualType instanceof MixedType) { - if ($actualType->isExplicitMixed()) { - $actualTypeDescription .= '=explicit'; - } else { - $actualTypeDescription .= '=implicit'; - } - } - if (get_class($actualType) === ObjectType::class) { - $actualClassReflection = $actualType->getClassReflection(); - if ( - $actualClassReflection !== null - && $actualClassReflection->hasFinalByKeywordOverride() - && $actualClassReflection->isFinal() - ) { - $actualTypeDescription .= '=final'; + $bleedingEdgeBackup = BleedingEdgeToggle::isBleedingEdge(); + $typeStringResolver = self::getContainer()->getByType(TypeStringResolver::class); + foreach ($types as $i => $type) { + BleedingEdgeToggle::setBleedingEdge(true); + if (!is_string($type)) { + continue; } + + $types[$i] = $typeStringResolver->resolve($type, null); } + BleedingEdgeToggle::setBleedingEdge($bleedingEdgeBackup); + $actualType = TypeCombinator::union(...$types); $this->assertSame( $expectedTypeDescription, - $actualTypeDescription, + self::describeForIntersectTest($actualType), sprintf('union(%s)', implode(', ', array_map( static fn (Type $type): string => $type->describe(VerbosityLevel::precise()), $types, @@ -2916,7 +3233,7 @@ public function testUnion( } /** - * @param list $types + * @param list|list $types * @param class-string $expectedTypeClass */ #[DataProvider('dataUnion')] @@ -2927,28 +3244,23 @@ public function testUnionInversed( ): void { $types = array_reverse($types); - $actualType = TypeCombinator::union(...$types); - $actualTypeDescription = $actualType->describe(VerbosityLevel::precise()); - if ($actualType instanceof MixedType) { - if ($actualType->isExplicitMixed()) { - $actualTypeDescription .= '=explicit'; - } else { - $actualTypeDescription .= '=implicit'; - } - } - if (get_class($actualType) === ObjectType::class) { - $actualClassReflection = $actualType->getClassReflection(); - if ( - $actualClassReflection !== null - && $actualClassReflection->hasFinalByKeywordOverride() - && $actualClassReflection->isFinal() - ) { - $actualTypeDescription .= '=final'; + $bleedingEdgeBackup = BleedingEdgeToggle::isBleedingEdge(); + $typeStringResolver = self::getContainer()->getByType(TypeStringResolver::class); + foreach ($types as $i => $type) { + BleedingEdgeToggle::setBleedingEdge(true); + if (!is_string($type)) { + continue; } + + $types[$i] = $typeStringResolver->resolve($type, null); } + + BleedingEdgeToggle::setBleedingEdge($bleedingEdgeBackup); + + $actualType = TypeCombinator::union(...$types); $this->assertSame( $expectedTypeDescription, - $actualTypeDescription, + self::describeForIntersectTest($actualType), sprintf('union(%s)', implode(', ', array_map( static fn (Type $type): string => $type->describe(VerbosityLevel::precise()), $types, @@ -4970,6 +5282,67 @@ public static function dataIntersect(): iterable 'T of Countable&Iterator (function a(), parameter)', ]; + yield [ + [ + new StringType(), + new AccessoryNumericStringType(), + new AccessoryDecimalIntegerStringType(), + ], + IntersectionType::class, + 'decimal-int-string', + ]; + + yield [ + [ + new StringType(), + new AccessoryNumericStringType(), + new AccessoryDecimalIntegerStringType(inverse: true), + ], + IntersectionType::class, + 'non-decimal-int-string&numeric-string', + ]; + + $decimalIntString = new IntersectionType([ + new StringType(), + new AccessoryDecimalIntegerStringType(), + ]); + $nonDecimalIntString = new IntersectionType([ + new StringType(), + new AccessoryDecimalIntegerStringType(inverse: true), + ]); + + yield [ + [ + $decimalIntString, + $nonDecimalIntString, + ], + NeverType::class, + '*NEVER*=implicit', + ]; + + yield [ + [ + $decimalIntString, + new IntersectionType([ + new StringType(), + new AccessoryLowercaseStringType(), + ]), + ], + IntersectionType::class, + 'decimal-int-string', + ]; + yield [ + [ + $nonDecimalIntString, + new IntersectionType([ + new StringType(), + new AccessoryLowercaseStringType(), + ]), + ], + IntersectionType::class, + 'lowercase-string&non-decimal-int-string', + ]; + yield [ [ new ConstantArrayType( @@ -4987,10 +5360,213 @@ public static function dataIntersect(): iterable ConstantArrayType::class, 'array{0|1|2|3, stdClass}', ]; + + yield [ + [ + new ConstantArrayType( + [new ConstantIntegerType(0), new ConstantIntegerType(1)], + [new IntegerType(), new UnionType([ + new ConstantStringType('0'), + new ConstantStringType('foo'), + ])], + ), + new ConstantArrayType( + [new ConstantIntegerType(0), new ConstantIntegerType(1)], + [IntegerRangeType::createAllGreaterThanOrEqualTo(0), new IntersectionType([ + new StringType(), + new AccessoryNonFalsyStringType(), + ])], + ), + ], + ConstantArrayType::class, + "array{int<0, max>, 'foo'}", + ]; + + // current flawed behaviour (unknown sealedness) + yield [ + [ + new ConstantArrayType( + [ + new ConstantStringType('a'), + new ConstantStringType('b'), + ], + [ + new IntegerType(), + new StringType(), + ], + ), + new ConstantArrayType( + [ + new ConstantStringType('a'), + ], + [ + new IntegerType(), + ], + ), + ], + ConstantArrayType::class, + 'array{a: int, b: string}', + ]; + + // new behaviour with definitely sealed arrays + yield [ + [ + 'array{a: int, b: string}', + 'array{a: int}', + ], + NeverType::class, + '*NEVER*=implicit', + ]; + + yield [ + [ + 'array{int, 0|\'foo\'}', + 'array{int<0, max>, non-falsy-string}', + ], + ConstantArrayType::class, + "array{int<0, max>, 'foo'}", + ]; + + yield [ + [ + 'array{a: int, b: string}', + 'array{a: int<0, max>, ...}', + ], + ConstantArrayType::class, + 'array{a: int<0, max>, b: string}', + ]; + + yield [ + [ + 'array{a: int, b: string, ...}', + 'array{a: int<0, max>, ...}', + ], + ConstantArrayType::class, + 'array{a: int<0, max>, b: string, ...}', + ]; + + // both unsealed, disjoint known keys, default extras — union of known keys + yield [ + [ + 'array{a: int, ...}', + 'array{b: string, ...}', + ], + ConstantArrayType::class, + 'array{a: int, b: string, ...}', + ]; + + // both unsealed, narrower unsealed value on right + yield [ + [ + 'array{a: int, ...}', + 'array{a: int, ...}', + ], + ConstantArrayType::class, + 'array{a: int, ...}', + ]; + + // both unsealed, narrower unsealed key on right (array-key ∩ string = string) + yield [ + [ + 'array{a: int, ...}', + 'array{a: int, ...}', + ], + ConstantArrayType::class, + 'array{a: int, ...}', + ]; + + // both unsealed, unsealed value types intersect to a narrower common type + yield [ + [ + 'array{...>}', + 'array{...>}', + ], + ArrayType::class, + 'array>', + ]; + + yield [ + [ + 'array{a: int, ...>}', + 'array{a: int, ...>}', + ], + ConstantArrayType::class, + 'array{a: int, ...>}', + ]; + + // both unsealed, unsealed key types incompatible — no valid key overlap + yield [ + [ + 'array{...}', + 'array{...}', + ], + NeverType::class, + '*NEVER*=implicit', + ]; + + // both unsealed, unsealed value types incompatible + yield [ + [ + 'array{...}', + 'array{...}', + ], + NeverType::class, + '*NEVER*=implicit', + ]; + + // both unsealed: one side's known key conflicts with the other side's unsealed value type + yield [ + [ + 'array{a: int, ...}', + 'array{...}', + ], + ConstantArrayType::class, + 'array{a: *NEVER*, ...}', + ]; + + // both unsealed: known key value is compatible with other side's unsealed value + yield [ + [ + 'array{a: non-empty-string, ...}', + 'array{...}', + ], + ConstantArrayType::class, + 'array{a: non-empty-string, ...}', + ]; + + // both unsealed with same known key, value types incompatible at that key + yield [ + [ + 'array{a: int, ...}', + 'array{a: string, ...}', + ], + NeverType::class, + '*NEVER*=implicit', + ]; + + // sealed + unsealed where sealed's known key value doesn't fit unsealed's key type — incompatible + yield [ + [ + 'array{a: int}', + 'array{...}', + ], + NeverType::class, + '*NEVER*=implicit', + ]; + + // sealed + unsealed where sealed is compatible with unsealed's unsealed types + yield [ + [ + 'array{a: int}', + 'array{...}', + ], + ConstantArrayType::class, + 'array{a: int}', + ]; } /** - * @param list $types + * @param list|list $types * @param class-string $expectedTypeClass */ #[DataProvider('dataIntersect')] @@ -5000,40 +5576,33 @@ public function testIntersect( string $expectedTypeDescription, ): void { - $actualType = TypeCombinator::intersect(...$types); - $actualTypeDescription = $actualType->describe(VerbosityLevel::precise()); - if ($actualType instanceof MixedType) { - if ($actualType->isExplicitMixed()) { - $actualTypeDescription .= '=explicit'; - } else { - $actualTypeDescription .= '=implicit'; - } - } - if ($actualType instanceof NeverType) { - if ($actualType->isExplicit()) { - $actualTypeDescription .= '=explicit'; - } else { - $actualTypeDescription .= '=implicit'; + $bleedingEdgeBackup = BleedingEdgeToggle::isBleedingEdge(); + $typeStringResolver = self::getContainer()->getByType(TypeStringResolver::class); + foreach ($types as $i => $type) { + BleedingEdgeToggle::setBleedingEdge(true); + if (!is_string($type)) { + continue; } - } - if (get_class($actualType) === ObjectType::class && $actualType->isEnum()->no()) { - $actualClassReflection = $actualType->getClassReflection(); - if ( - $actualClassReflection !== null - && $actualClassReflection->hasFinalByKeywordOverride() - && $actualClassReflection->isFinal() - ) { - $actualTypeDescription .= '=final'; - } + $types[$i] = $typeStringResolver->resolve($type, null); } - $this->assertSame($expectedTypeDescription, $actualTypeDescription); + BleedingEdgeToggle::setBleedingEdge($bleedingEdgeBackup); + + $actualType = TypeCombinator::intersect(...$types); + $this->assertSame( + $expectedTypeDescription, + self::describeForIntersectTest($actualType), + sprintf('intersect(%s)', implode(', ', array_map( + static fn (Type $type): string => $type->describe(VerbosityLevel::precise()), + $types, + ))), + ); $this->assertInstanceOf($expectedTypeClass, $actualType); } /** - * @param list $types + * @param list|list $types * @param class-string $expectedTypeClass */ #[DataProvider('dataIntersect')] @@ -5043,35 +5612,58 @@ public function testIntersectInversed( string $expectedTypeDescription, ): void { - $actualType = TypeCombinator::intersect(...array_reverse($types)); - $actualTypeDescription = $actualType->describe(VerbosityLevel::precise()); - if ($actualType instanceof MixedType) { - if ($actualType->isExplicitMixed()) { - $actualTypeDescription .= '=explicit'; - } else { - $actualTypeDescription .= '=implicit'; + $bleedingEdgeBackup = BleedingEdgeToggle::isBleedingEdge(); + $typeStringResolver = self::getContainer()->getByType(TypeStringResolver::class); + foreach ($types as $i => $type) { + BleedingEdgeToggle::setBleedingEdge(true); + if (!is_string($type)) { + continue; } + + $types[$i] = $typeStringResolver->resolve($type, null); } - if ($actualType instanceof NeverType) { - if ($actualType->isExplicit()) { - $actualTypeDescription .= '=explicit'; - } else { - $actualTypeDescription .= '=implicit'; + + BleedingEdgeToggle::setBleedingEdge($bleedingEdgeBackup); + + $actualType = TypeCombinator::intersect(...array_reverse($types)); + $this->assertSame( + $expectedTypeDescription, + self::describeForIntersectTest($actualType), + sprintf('union(%s)', implode(', ', array_map( + static fn (Type $type): string => $type->describe(VerbosityLevel::precise()), + $types, + ))), + ); + $this->assertInstanceOf($expectedTypeClass, $actualType); + } + + private static function describeForIntersectTest(Type $type): string + { + $type = TypeTraverser::map($type, static function (Type $type, callable $traverse): Type { + if ($type instanceof ConstantArrayType) { + return $traverse($type->sortKeys()); } - } - if (get_class($actualType) === ObjectType::class && $actualType->isEnum()->no()) { - $actualClassReflection = $actualType->getClassReflection(); + return $traverse($type); + }); + $description = $type->describe(VerbosityLevel::precise()); + if ($type instanceof MixedType) { + $description .= $type->isExplicitMixed() ? '=explicit' : '=implicit'; + } + if ($type instanceof NeverType) { + $description .= $type->isExplicit() ? '=explicit' : '=implicit'; + } + if (get_class($type) === ObjectType::class && $type->isEnum()->no()) { + $classReflection = $type->getClassReflection(); if ( - $actualClassReflection !== null - && $actualClassReflection->hasFinalByKeywordOverride() - && $actualClassReflection->isFinal() + $classReflection !== null + && $classReflection->hasFinalByKeywordOverride() + && $classReflection->isFinal() ) { - $actualTypeDescription .= '=final'; + $description .= '=final'; } } - $this->assertSame($expectedTypeDescription, $actualTypeDescription); - $this->assertInstanceOf($expectedTypeClass, $actualType); + return $description; } public static function dataRemove(): array diff --git a/tests/PHPStan/Type/TypeToPhpDocNodeTest.php b/tests/PHPStan/Type/TypeToPhpDocNodeTest.php index 3cf9e5f3fa6..41ce52e2e4d 100644 --- a/tests/PHPStan/Type/TypeToPhpDocNodeTest.php +++ b/tests/PHPStan/Type/TypeToPhpDocNodeTest.php @@ -6,6 +6,7 @@ use PHPStan\Testing\PHPStanTestCase; use PHPStan\TrinaryLogic; use PHPStan\Type\Accessory\AccessoryArrayListType; +use PHPStan\Type\Accessory\AccessoryDecimalIntegerStringType; use PHPStan\Type\Accessory\AccessoryLiteralStringType; use PHPStan\Type\Accessory\AccessoryLowercaseStringType; use PHPStan\Type\Accessory\AccessoryNonEmptyStringType; @@ -522,6 +523,16 @@ public static function dataToPhpDocNodeWithoutCheckingEquals(): iterable new ConstantFloatType(-0.0), '-0.0', ]; + + yield [ + new IntersectionType([new StringType(), new AccessoryDecimalIntegerStringType()]), + 'decimal-int-string', + ]; + + yield [ + new IntersectionType([new StringType(), new AccessoryDecimalIntegerStringType(inverse: true)]), + 'non-decimal-int-string', + ]; } #[DataProvider('dataToPhpDocNodeWithoutCheckingEquals')] @@ -551,6 +562,17 @@ public static function dataFromTypeStringToPhpDocNode(): iterable yield ['callable(Foo $foo=, Bar $bar=): Bar']; yield ['Closure(Foo $foo=, Bar $bar=): Bar']; yield ['Closure(Foo $foo=, Bar $bar=): (Closure(Foo): Bar)']; + + yield ['array{a: int}']; + yield ['array{a: int, ...}']; + yield ['array{a: int, ...}']; + yield ['array{a: int, ...}']; + yield ['array{int, int, int, ...}']; + yield ['array{int, int, int, ...}']; + yield ['array{int, int, int, ...}']; + + yield ['list{0?: int, 1?: int, 2?: int, ...}']; + yield ['list{0?: int, 1?: int, 2?: int, ...}']; } #[DataProvider('dataFromTypeStringToPhpDocNode')]