From 30c231a9eaab0c6b291b45d885b8897f978925dc Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Fri, 13 Feb 2026 10:49:03 +0100 Subject: [PATCH 001/158] Open 2.2.x --- .github/workflows/backward-compatibility.yml | 2 +- .github/workflows/build-issue-bot.yml | 2 +- .github/workflows/changelog-generator.yml | 2 +- .github/workflows/e2e-tests.yml | 2 +- .github/workflows/lint.yml | 2 +- .github/workflows/phar.yml | 24 ++++++++++---------- .github/workflows/reflection-golden-test.yml | 2 +- .github/workflows/spelling.yml | 2 +- .github/workflows/static-analysis.yml | 2 +- .github/workflows/tests.yml | 2 +- 10 files changed, 21 insertions(+), 21 deletions(-) diff --git a/.github/workflows/backward-compatibility.yml b/.github/workflows/backward-compatibility.yml index 53f74a49962..3e1c4662279 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/build-issue-bot.yml b/.github/workflows/build-issue-bot.yml index 6bb4e62e30d..6debccc9350 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 1dfc0d775cb..cd76fb3188d 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/e2e-tests.yml b/.github/workflows/e2e-tests.yml index dbd205476cb..e57002cd237 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/**' diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 1cd3a2c44a7..43c28049931 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 79ba4438682..be5ef62818b 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 @@ -90,14 +90,14 @@ jobs: - uses: "ramsey/composer-install@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" @@ -129,25 +129,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: @@ -278,7 +278,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 @@ -300,7 +300,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 7f962ce2c0f..f57fa94f9b6 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 b2f810732c2..24f48d2bb76 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" jobs: typos: diff --git a/.github/workflows/static-analysis.yml b/.github/workflows/static-analysis.yml index f61dcac3aa1..681d948eb38 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 0fdceac7231..cfd78291360 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/**' From 25b268bd168d028c8a88ba540facdb99b75c4e63 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Sat, 14 Feb 2026 10:44:49 +0100 Subject: [PATCH 002/158] Add agentic workflow to document new config parameters Automatically detects undocumented parameters in conf/parametersSchema.neon and creates a draft PR on phpstan/phpstan with documentation updates. Triggers on push to 2.2.x when the schema changes, or manually. Co-Authored-By: Claude Opus 4.6 --- .gitattributes | 2 + .../workflows/document-config-params.lock.yml | 1152 +++++++++++++++++ .github/workflows/document-config-params.md | 132 ++ 3 files changed, 1286 insertions(+) create mode 100644 .github/workflows/document-config-params.lock.yml create mode 100644 .github/workflows/document-config-params.md 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/document-config-params.lock.yml b/.github/workflows/document-config-params.lock.yml new file mode 100644 index 00000000000..bf8a0262011 --- /dev/null +++ b/.github/workflows/document-config-params.lock.yml @@ -0,0 +1,1152 @@ +# +# ___ _ _ +# / _ \ | | (_) +# | |_| | __ _ ___ _ __ | |_ _ ___ +# | _ |/ _` |/ _ \ '_ \| __| |/ __| +# | | | | (_| | __/ | | | |_| | (__ +# \_| |_/\__, |\___|_| |_|\__|_|\___| +# __/ | +# _ _ |___/ +# | | | | / _| | +# | | | | ___ _ __ _ __| |_| | _____ ____ +# | |/\| |/ _ \ '__| |/ /| _| |/ _ \ \ /\ / / ___| +# \ /\ / (_) | | | | ( | | | | (_) \ V V /\__ \ +# \/ \/ \___/|_| |_|\_\|_| |_|\___/ \_/\_/ |___/ +# +# This file was automatically generated by gh-aw (v0.43.23). DO NOT EDIT. +# +# To update this file, edit the corresponding .md file and run: +# gh aw compile +# Not all edits will cause changes to this file. +# +# For more information: https://github.github.com/gh-aw/introduction/overview/ +# +# Finds undocumented PHPStan config parameters and creates documentation PRs on phpstan/phpstan +# +# frontmatter-hash: 69e695fc26ed030a830663b4989c2181e28836aebc20d9269f866543d9f36bab + +name: "Document Config Parameters" +"on": + push: + branches: + - 2.2.x + paths: + - conf/parametersSchema.neon + workflow_dispatch: + +permissions: {} + +concurrency: + group: "gh-aw-${{ github.workflow }}-${{ github.ref }}" + +run-name: "Document Config Parameters" + +jobs: + activation: + needs: pre_activation + if: needs.pre_activation.outputs.activated == 'true' + runs-on: ubuntu-slim + permissions: + contents: read + outputs: + comment_id: "" + comment_repo: "" + steps: + - name: Setup Scripts + uses: github/gh-aw/actions/setup@9382be3ca9ac18917e111a99d4e6bbff58d0dccc # v0.43.23 + with: + destination: /opt/gh-aw/actions + - name: Check workflow file timestamps + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + GH_AW_WORKFLOW_FILE: "document-config-params.lock.yml" + with: + script: | + const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('/opt/gh-aw/actions/check_workflow_timestamp_api.cjs'); + await main(); + + agent: + needs: activation + runs-on: ubuntu-latest + permissions: + contents: read + issues: read + pull-requests: read + env: + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + GH_AW_ASSETS_ALLOWED_EXTS: "" + GH_AW_ASSETS_BRANCH: "" + GH_AW_ASSETS_MAX_SIZE_KB: 0 + GH_AW_MCP_LOG_DIR: /tmp/gh-aw/mcp-logs/safeoutputs + GH_AW_SAFE_OUTPUTS: /opt/gh-aw/safeoutputs/outputs.jsonl + GH_AW_SAFE_OUTPUTS_CONFIG_PATH: /opt/gh-aw/safeoutputs/config.json + GH_AW_SAFE_OUTPUTS_TOOLS_PATH: /opt/gh-aw/safeoutputs/tools.json + GH_AW_WORKFLOW_ID_SANITIZED: documentconfigparams + outputs: + checkout_pr_success: ${{ steps.checkout-pr.outputs.checkout_pr_success || 'true' }} + has_patch: ${{ steps.collect_output.outputs.has_patch }} + model: ${{ steps.generate_aw_info.outputs.model }} + output: ${{ steps.collect_output.outputs.output }} + output_types: ${{ steps.collect_output.outputs.output_types }} + secret_verification_result: ${{ steps.validate-secret.outputs.verification_result }} + steps: + - name: Setup Scripts + uses: github/gh-aw/actions/setup@9382be3ca9ac18917e111a99d4e6bbff58d0dccc # v0.43.23 + with: + destination: /opt/gh-aw/actions + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + - name: Create gh-aw temp directory + run: bash /opt/gh-aw/actions/create_gh_aw_tmp_dir.sh + - name: Configure Git credentials + env: + REPO_NAME: ${{ github.repository }} + SERVER_URL: ${{ github.server_url }} + run: | + git config --global user.email "github-actions[bot]@users.noreply.github.com" + git config --global user.name "github-actions[bot]" + # Re-authenticate git with GitHub token + SERVER_URL_STRIPPED="${SERVER_URL#https://}" + git remote set-url origin "https://x-access-token:${{ github.token }}@${SERVER_URL_STRIPPED}/${REPO_NAME}.git" + echo "Git configured with standard GitHub Actions identity" + - name: Checkout PR branch + id: checkout-pr + if: | + github.event.pull_request + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + GH_TOKEN: ${{ secrets.PHPSTAN_BOT_TOKEN }} + with: + github-token: ${{ secrets.PHPSTAN_BOT_TOKEN }} + script: | + const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('/opt/gh-aw/actions/checkout_pr_branch.cjs'); + await main(); + - name: Generate agentic run info + id: generate_aw_info + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + with: + script: | + const fs = require('fs'); + + const awInfo = { + engine_id: "claude", + engine_name: "Claude Code", + model: "claude-opus-4-6", + version: "", + agent_version: "2.1.39", + cli_version: "v0.43.23", + workflow_name: "Document Config Parameters", + experimental: false, + supports_tools_allowlist: true, + supports_http_transport: true, + run_id: context.runId, + run_number: context.runNumber, + run_attempt: process.env.GITHUB_RUN_ATTEMPT, + repository: context.repo.owner + '/' + context.repo.repo, + ref: context.ref, + sha: context.sha, + actor: context.actor, + event_name: context.eventName, + staged: false, + allowed_domains: ["defaults"], + firewall_enabled: true, + awf_version: "v0.17.0", + awmg_version: "", + steps: { + firewall: "squid" + }, + created_at: new Date().toISOString() + }; + + // Write to /tmp/gh-aw directory to avoid inclusion in PR + const tmpPath = '/tmp/gh-aw/aw_info.json'; + fs.writeFileSync(tmpPath, JSON.stringify(awInfo, null, 2)); + console.log('Generated aw_info.json at:', tmpPath); + console.log(JSON.stringify(awInfo, null, 2)); + + // Set model as output for reuse in other steps/jobs + core.setOutput('model', awInfo.model); + - name: Validate CLAUDE_CODE_OAUTH_TOKEN or ANTHROPIC_API_KEY secret + id: validate-secret + run: /opt/gh-aw/actions/validate_multi_secret.sh CLAUDE_CODE_OAUTH_TOKEN ANTHROPIC_API_KEY 'Claude Code' https://github.github.com/gh-aw/reference/engines/#anthropic-claude-code + env: + CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + - name: Setup Node.js + uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 + with: + node-version: '24' + package-manager-cache: false + - name: Install awf binary + run: bash /opt/gh-aw/actions/install_awf_binary.sh v0.17.0 + - name: Install Claude Code CLI + run: npm install -g --silent @anthropic-ai/claude-code@2.1.39 + - name: Determine automatic lockdown mode for GitHub MCP server + id: determine-automatic-lockdown + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + GH_AW_GITHUB_TOKEN: ${{ secrets.GH_AW_GITHUB_TOKEN }} + GH_AW_GITHUB_MCP_SERVER_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN }} + with: + script: | + const determineAutomaticLockdown = require('/opt/gh-aw/actions/determine_automatic_lockdown.cjs'); + await determineAutomaticLockdown(github, context, core); + - name: Download container images + run: bash /opt/gh-aw/actions/download_docker_images.sh ghcr.io/github/gh-aw-firewall/agent:0.17.0 ghcr.io/github/gh-aw-firewall/api-proxy:0.17.0 ghcr.io/github/gh-aw-firewall/squid:0.17.0 ghcr.io/github/gh-aw-mcpg:v0.1.4 ghcr.io/github/github-mcp-server:v0.30.3 node:lts-alpine + - name: Write Safe Outputs Config + run: | + mkdir -p /opt/gh-aw/safeoutputs + mkdir -p /tmp/gh-aw/safeoutputs + mkdir -p /tmp/gh-aw/mcp-logs/safeoutputs + cat > /opt/gh-aw/safeoutputs/config.json << 'GH_AW_SAFE_OUTPUTS_CONFIG_EOF' + {"create_pull_request":{},"missing_data":{},"missing_tool":{},"noop":{"max":1}} + GH_AW_SAFE_OUTPUTS_CONFIG_EOF + cat > /opt/gh-aw/safeoutputs/tools.json << 'GH_AW_SAFE_OUTPUTS_TOOLS_EOF' + [ + { + "description": "Create a new GitHub pull request to propose code changes. Use this after making file edits to submit them for review and merging. The PR will be created from the current branch with your committed changes. For code review comments on an existing PR, use create_pull_request_review_comment instead. CONSTRAINTS: Maximum 1 pull request(s) can be created. Title will be prefixed with \"[Docs] \". PRs will be created as drafts.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "body": { + "description": "Detailed PR description in Markdown. Include what changes were made, why, testing notes, and any breaking changes. Do NOT repeat the title as a heading.", + "type": "string" + }, + "branch": { + "description": "Source branch name containing the changes. If omitted, uses the current working branch.", + "type": "string" + }, + "labels": { + "description": "Labels to categorize the PR (e.g., 'enhancement', 'bugfix'). Labels must exist in the repository.", + "items": { + "type": "string" + }, + "type": "array" + }, + "title": { + "description": "Concise PR title describing the changes. Follow repository conventions (e.g., conventional commits). The title appears as the main heading.", + "type": "string" + } + }, + "required": [ + "title", + "body" + ], + "type": "object" + }, + "name": "create_pull_request" + }, + { + "description": "Report that a tool or capability needed to complete the task is not available, or share any information you deem important about missing functionality or limitations. Use this when you cannot accomplish what was requested because the required functionality is missing or access is restricted.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "alternatives": { + "description": "Any workarounds, manual steps, or alternative approaches the user could take (max 256 characters).", + "type": "string" + }, + "reason": { + "description": "Explanation of why this tool is needed or what information you want to share about the limitation (max 256 characters).", + "type": "string" + }, + "tool": { + "description": "Optional: Name or description of the missing tool or capability (max 128 characters). Be specific about what functionality is needed.", + "type": "string" + } + }, + "required": [ + "reason" + ], + "type": "object" + }, + "name": "missing_tool" + }, + { + "description": "Log a transparency message when no significant actions are needed. Use this to confirm workflow completion and provide visibility when analysis is complete but no changes or outputs are required (e.g., 'No issues found', 'All checks passed'). This ensures the workflow produces human-visible output even when no other actions are taken.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "message": { + "description": "Status or completion message to log. Should explain what was analyzed and the outcome (e.g., 'Code review complete - no issues found', 'Analysis complete - all tests passing').", + "type": "string" + } + }, + "required": [ + "message" + ], + "type": "object" + }, + "name": "noop" + }, + { + "description": "Report that data or information needed to complete the task is not available. Use this when you cannot accomplish what was requested because required data, context, or information is missing.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "alternatives": { + "description": "Any workarounds, manual steps, or alternative approaches the user could take (max 256 characters).", + "type": "string" + }, + "context": { + "description": "Additional context about the missing data or where it should come from (max 256 characters).", + "type": "string" + }, + "data_type": { + "description": "Type or description of the missing data or information (max 128 characters). Be specific about what data is needed.", + "type": "string" + }, + "reason": { + "description": "Explanation of why this data is needed to complete the task (max 256 characters).", + "type": "string" + } + }, + "required": [], + "type": "object" + }, + "name": "missing_data" + } + ] + GH_AW_SAFE_OUTPUTS_TOOLS_EOF + cat > /opt/gh-aw/safeoutputs/validation.json << 'GH_AW_SAFE_OUTPUTS_VALIDATION_EOF' + { + "create_pull_request": { + "defaultMax": 1, + "fields": { + "body": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + }, + "branch": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 256 + }, + "labels": { + "type": "array", + "itemType": "string", + "itemSanitize": true, + "itemMaxLength": 128 + }, + "title": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 128 + } + } + }, + "missing_tool": { + "defaultMax": 20, + "fields": { + "alternatives": { + "type": "string", + "sanitize": true, + "maxLength": 512 + }, + "reason": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 256 + }, + "tool": { + "type": "string", + "sanitize": true, + "maxLength": 128 + } + } + }, + "noop": { + "defaultMax": 1, + "fields": { + "message": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + } + } + } + } + GH_AW_SAFE_OUTPUTS_VALIDATION_EOF + - name: Generate Safe Outputs MCP Server Config + id: safe-outputs-config + run: | + # Generate a secure random API key (360 bits of entropy, 40+ chars) + # Mask immediately to prevent timing vulnerabilities + API_KEY=$(openssl rand -base64 45 | tr -d '/+=') + echo "::add-mask::${API_KEY}" + + PORT=3001 + + # Set outputs for next steps + { + echo "safe_outputs_api_key=${API_KEY}" + echo "safe_outputs_port=${PORT}" + } >> "$GITHUB_OUTPUT" + + echo "Safe Outputs MCP server will run on port ${PORT}" + + - name: Start Safe Outputs MCP HTTP Server + id: safe-outputs-start + env: + DEBUG: '*' + GH_AW_SAFE_OUTPUTS_PORT: ${{ steps.safe-outputs-config.outputs.safe_outputs_port }} + GH_AW_SAFE_OUTPUTS_API_KEY: ${{ steps.safe-outputs-config.outputs.safe_outputs_api_key }} + GH_AW_SAFE_OUTPUTS_TOOLS_PATH: /opt/gh-aw/safeoutputs/tools.json + GH_AW_SAFE_OUTPUTS_CONFIG_PATH: /opt/gh-aw/safeoutputs/config.json + GH_AW_MCP_LOG_DIR: /tmp/gh-aw/mcp-logs/safeoutputs + run: | + # Environment variables are set above to prevent template injection + export DEBUG + export GH_AW_SAFE_OUTPUTS_PORT + export GH_AW_SAFE_OUTPUTS_API_KEY + export GH_AW_SAFE_OUTPUTS_TOOLS_PATH + export GH_AW_SAFE_OUTPUTS_CONFIG_PATH + export GH_AW_MCP_LOG_DIR + + bash /opt/gh-aw/actions/start_safe_outputs_server.sh + + - name: Start MCP gateway + id: start-mcp-gateway + env: + GH_AW_SAFE_OUTPUTS: ${{ env.GH_AW_SAFE_OUTPUTS }} + GH_AW_SAFE_OUTPUTS_API_KEY: ${{ steps.safe-outputs-start.outputs.api_key }} + GH_AW_SAFE_OUTPUTS_PORT: ${{ steps.safe-outputs-start.outputs.port }} + GITHUB_MCP_LOCKDOWN: ${{ steps.determine-automatic-lockdown.outputs.lockdown == 'true' && '1' || '0' }} + GITHUB_MCP_SERVER_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + run: | + set -eo pipefail + mkdir -p /tmp/gh-aw/mcp-config + + # Export gateway environment variables for MCP config and gateway script + export MCP_GATEWAY_PORT="80" + export MCP_GATEWAY_DOMAIN="host.docker.internal" + MCP_GATEWAY_API_KEY=$(openssl rand -base64 45 | tr -d '/+=') + echo "::add-mask::${MCP_GATEWAY_API_KEY}" + export MCP_GATEWAY_API_KEY + export MCP_GATEWAY_PAYLOAD_DIR="/tmp/gh-aw/mcp-payloads" + mkdir -p "${MCP_GATEWAY_PAYLOAD_DIR}" + export DEBUG="*" + + export GH_AW_ENGINE="claude" + export MCP_GATEWAY_DOCKER_COMMAND='docker run -i --rm --network host -v /var/run/docker.sock:/var/run/docker.sock -e MCP_GATEWAY_PORT -e MCP_GATEWAY_DOMAIN -e MCP_GATEWAY_API_KEY -e MCP_GATEWAY_PAYLOAD_DIR -e DEBUG -e MCP_GATEWAY_LOG_DIR -e GH_AW_MCP_LOG_DIR -e GH_AW_SAFE_OUTPUTS -e GH_AW_SAFE_OUTPUTS_CONFIG_PATH -e GH_AW_SAFE_OUTPUTS_TOOLS_PATH -e GH_AW_ASSETS_BRANCH -e GH_AW_ASSETS_MAX_SIZE_KB -e GH_AW_ASSETS_ALLOWED_EXTS -e DEFAULT_BRANCH -e GITHUB_MCP_SERVER_TOKEN -e GITHUB_MCP_LOCKDOWN -e GITHUB_REPOSITORY -e GITHUB_SERVER_URL -e GITHUB_SHA -e GITHUB_WORKSPACE -e GITHUB_TOKEN -e GITHUB_RUN_ID -e GITHUB_RUN_NUMBER -e GITHUB_RUN_ATTEMPT -e GITHUB_JOB -e GITHUB_ACTION -e GITHUB_EVENT_NAME -e GITHUB_EVENT_PATH -e GITHUB_ACTOR -e GITHUB_ACTOR_ID -e GITHUB_TRIGGERING_ACTOR -e GITHUB_WORKFLOW -e GITHUB_WORKFLOW_REF -e GITHUB_WORKFLOW_SHA -e GITHUB_REF -e GITHUB_REF_NAME -e GITHUB_REF_TYPE -e GITHUB_HEAD_REF -e GITHUB_BASE_REF -e GH_AW_SAFE_OUTPUTS_PORT -e GH_AW_SAFE_OUTPUTS_API_KEY -v /tmp/gh-aw/mcp-payloads:/tmp/gh-aw/mcp-payloads:rw -v /opt:/opt:ro -v /tmp:/tmp:rw -v '"${GITHUB_WORKSPACE}"':'"${GITHUB_WORKSPACE}"':rw ghcr.io/github/gh-aw-mcpg:v0.1.4' + + cat << GH_AW_MCP_CONFIG_EOF | bash /opt/gh-aw/actions/start_mcp_gateway.sh + { + "mcpServers": { + "github": { + "container": "ghcr.io/github/github-mcp-server:v0.30.3", + "env": { + "GITHUB_LOCKDOWN_MODE": "$GITHUB_MCP_LOCKDOWN", + "GITHUB_PERSONAL_ACCESS_TOKEN": "$GITHUB_MCP_SERVER_TOKEN", + "GITHUB_READ_ONLY": "1", + "GITHUB_TOOLSETS": "context,repos,issues,pull_requests" + } + }, + "safeoutputs": { + "type": "http", + "url": "http://host.docker.internal:$GH_AW_SAFE_OUTPUTS_PORT", + "headers": { + "Authorization": "$GH_AW_SAFE_OUTPUTS_API_KEY" + } + } + }, + "gateway": { + "port": $MCP_GATEWAY_PORT, + "domain": "${MCP_GATEWAY_DOMAIN}", + "apiKey": "${MCP_GATEWAY_API_KEY}", + "payloadDir": "${MCP_GATEWAY_PAYLOAD_DIR}" + } + } + GH_AW_MCP_CONFIG_EOF + - name: Generate workflow overview + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + with: + script: | + const { generateWorkflowOverview } = require('/opt/gh-aw/actions/generate_workflow_overview.cjs'); + await generateWorkflowOverview(core); + - name: Create prompt with built-in context + env: + GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt + GH_AW_SAFE_OUTPUTS: ${{ env.GH_AW_SAFE_OUTPUTS }} + GH_AW_GITHUB_ACTOR: ${{ github.actor }} + GH_AW_GITHUB_EVENT_COMMENT_ID: ${{ github.event.comment.id }} + GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER: ${{ github.event.discussion.number }} + GH_AW_GITHUB_EVENT_ISSUE_NUMBER: ${{ github.event.issue.number }} + GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER: ${{ github.event.pull_request.number }} + GH_AW_GITHUB_REPOSITORY: ${{ github.repository }} + GH_AW_GITHUB_RUN_ID: ${{ github.run_id }} + GH_AW_GITHUB_WORKSPACE: ${{ github.workspace }} + run: | + bash /opt/gh-aw/actions/create_prompt_first.sh + cat << 'GH_AW_PROMPT_EOF' > "$GH_AW_PROMPT" + + GH_AW_PROMPT_EOF + cat "/opt/gh-aw/prompts/xpia.md" >> "$GH_AW_PROMPT" + cat "/opt/gh-aw/prompts/temp_folder_prompt.md" >> "$GH_AW_PROMPT" + cat "/opt/gh-aw/prompts/markdown.md" >> "$GH_AW_PROMPT" + cat << 'GH_AW_PROMPT_EOF' >> "$GH_AW_PROMPT" + + GitHub API Access Instructions + + The gh CLI is NOT authenticated. Do NOT use gh commands for GitHub operations. + + + To create or modify GitHub resources (issues, discussions, pull requests, etc.), you MUST call the appropriate safe output tool. Simply writing content will NOT work - the workflow requires actual tool calls. + + Temporary IDs: Some safe output tools support a temporary ID field (usually named temporary_id) so you can reference newly-created items elsewhere in the SAME agent output (for example, using #aw_abc1 in a later body). + + **IMPORTANT - temporary_id format rules:** + - If you DON'T need to reference the item later, OMIT the temporary_id field entirely (it will be auto-generated if needed) + - If you DO need cross-references/chaining, you MUST match this EXACT validation regex: /^aw_[A-Za-z0-9]{3,8}$/i + - Format: aw_ prefix followed by 3 to 8 alphanumeric characters (A-Z, a-z, 0-9, case-insensitive) + - Valid alphanumeric characters: ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789 + - INVALID examples: aw_ab (too short), aw_123456789 (too long), aw_test-id (contains hyphen), aw_id_123 (contains underscore) + - VALID examples: aw_abc, aw_abc1, aw_Test123, aw_A1B2C3D4, aw_12345678 + - To generate valid IDs: use 3-8 random alphanumeric characters or omit the field to let the system auto-generate + + Do NOT invent other aw_* formats — downstream steps will reject them with validation errors matching against /^aw_[A-Za-z0-9]{3,8}$/i. + + Discover available tools from the safeoutputs MCP server. + + **Critical**: Tool calls write structured data that downstream jobs process. Without tool calls, follow-up actions will be skipped. + + **Note**: If you made no other safe output tool calls during this workflow execution, call the "noop" tool to provide a status message indicating completion or that no actions were needed. + + + + The following GitHub context information is available for this workflow: + {{#if __GH_AW_GITHUB_ACTOR__ }} + - **actor**: __GH_AW_GITHUB_ACTOR__ + {{/if}} + {{#if __GH_AW_GITHUB_REPOSITORY__ }} + - **repository**: __GH_AW_GITHUB_REPOSITORY__ + {{/if}} + {{#if __GH_AW_GITHUB_WORKSPACE__ }} + - **workspace**: __GH_AW_GITHUB_WORKSPACE__ + {{/if}} + {{#if __GH_AW_GITHUB_EVENT_ISSUE_NUMBER__ }} + - **issue-number**: #__GH_AW_GITHUB_EVENT_ISSUE_NUMBER__ + {{/if}} + {{#if __GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER__ }} + - **discussion-number**: #__GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER__ + {{/if}} + {{#if __GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER__ }} + - **pull-request-number**: #__GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER__ + {{/if}} + {{#if __GH_AW_GITHUB_EVENT_COMMENT_ID__ }} + - **comment-id**: __GH_AW_GITHUB_EVENT_COMMENT_ID__ + {{/if}} + {{#if __GH_AW_GITHUB_RUN_ID__ }} + - **workflow-run-id**: __GH_AW_GITHUB_RUN_ID__ + {{/if}} + + + GH_AW_PROMPT_EOF + cat << 'GH_AW_PROMPT_EOF' >> "$GH_AW_PROMPT" + + GH_AW_PROMPT_EOF + cat << 'GH_AW_PROMPT_EOF' >> "$GH_AW_PROMPT" + {{#runtime-import .github/workflows/document-config-params.md}} + GH_AW_PROMPT_EOF + - name: Substitute placeholders + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt + GH_AW_GITHUB_ACTOR: ${{ github.actor }} + GH_AW_GITHUB_EVENT_COMMENT_ID: ${{ github.event.comment.id }} + GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER: ${{ github.event.discussion.number }} + GH_AW_GITHUB_EVENT_ISSUE_NUMBER: ${{ github.event.issue.number }} + GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER: ${{ github.event.pull_request.number }} + GH_AW_GITHUB_REPOSITORY: ${{ github.repository }} + GH_AW_GITHUB_RUN_ID: ${{ github.run_id }} + GH_AW_GITHUB_WORKSPACE: ${{ github.workspace }} + with: + script: | + const substitutePlaceholders = require('/opt/gh-aw/actions/substitute_placeholders.cjs'); + + // Call the substitution function + return await substitutePlaceholders({ + file: process.env.GH_AW_PROMPT, + substitutions: { + GH_AW_GITHUB_ACTOR: process.env.GH_AW_GITHUB_ACTOR, + GH_AW_GITHUB_EVENT_COMMENT_ID: process.env.GH_AW_GITHUB_EVENT_COMMENT_ID, + GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER: process.env.GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER, + GH_AW_GITHUB_EVENT_ISSUE_NUMBER: process.env.GH_AW_GITHUB_EVENT_ISSUE_NUMBER, + GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER: process.env.GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER, + GH_AW_GITHUB_REPOSITORY: process.env.GH_AW_GITHUB_REPOSITORY, + GH_AW_GITHUB_RUN_ID: process.env.GH_AW_GITHUB_RUN_ID, + GH_AW_GITHUB_WORKSPACE: process.env.GH_AW_GITHUB_WORKSPACE + } + }); + - name: Interpolate variables and render templates + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt + with: + script: | + const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('/opt/gh-aw/actions/interpolate_prompt.cjs'); + await main(); + - name: Validate prompt placeholders + env: + GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt + run: bash /opt/gh-aw/actions/validate_prompt_placeholders.sh + - name: Print prompt + env: + GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt + run: bash /opt/gh-aw/actions/print_prompt_summary.sh + - name: Clean git credentials + run: bash /opt/gh-aw/actions/clean_git_credentials.sh + - name: Execute Claude Code CLI + id: agentic_execution + # Allowed tools (sorted): + # - Bash + # - BashOutput + # - Edit + # - ExitPlanMode + # - Glob + # - Grep + # - KillBash + # - LS + # - MultiEdit + # - NotebookEdit + # - NotebookRead + # - Read + # - Task + # - TodoWrite + # - Write + # - mcp__github__download_workflow_run_artifact + # - mcp__github__get_code_scanning_alert + # - mcp__github__get_commit + # - mcp__github__get_dependabot_alert + # - mcp__github__get_discussion + # - mcp__github__get_discussion_comments + # - mcp__github__get_file_contents + # - mcp__github__get_job_logs + # - mcp__github__get_label + # - mcp__github__get_latest_release + # - mcp__github__get_me + # - mcp__github__get_notification_details + # - mcp__github__get_pull_request + # - mcp__github__get_pull_request_comments + # - mcp__github__get_pull_request_diff + # - mcp__github__get_pull_request_files + # - mcp__github__get_pull_request_review_comments + # - mcp__github__get_pull_request_reviews + # - mcp__github__get_pull_request_status + # - mcp__github__get_release_by_tag + # - mcp__github__get_secret_scanning_alert + # - mcp__github__get_tag + # - mcp__github__get_workflow_run + # - mcp__github__get_workflow_run_logs + # - mcp__github__get_workflow_run_usage + # - mcp__github__issue_read + # - mcp__github__list_branches + # - mcp__github__list_code_scanning_alerts + # - mcp__github__list_commits + # - mcp__github__list_dependabot_alerts + # - mcp__github__list_discussion_categories + # - mcp__github__list_discussions + # - mcp__github__list_issue_types + # - mcp__github__list_issues + # - mcp__github__list_label + # - mcp__github__list_notifications + # - mcp__github__list_pull_requests + # - mcp__github__list_releases + # - mcp__github__list_secret_scanning_alerts + # - mcp__github__list_starred_repositories + # - mcp__github__list_tags + # - mcp__github__list_workflow_jobs + # - mcp__github__list_workflow_run_artifacts + # - mcp__github__list_workflow_runs + # - mcp__github__list_workflows + # - mcp__github__pull_request_read + # - mcp__github__search_code + # - mcp__github__search_issues + # - mcp__github__search_orgs + # - mcp__github__search_pull_requests + # - mcp__github__search_repositories + # - mcp__github__search_users + timeout-minutes: 30 + run: | + set -o pipefail + sudo -E awf --tty --env-all --container-workdir "${GITHUB_WORKSPACE}" --allow-domains '*.githubusercontent.com,anthropic.com,api.anthropic.com,api.github.com,api.snapcraft.io,archive.ubuntu.com,azure.archive.ubuntu.com,cdn.playwright.dev,codeload.github.com,crl.geotrust.com,crl.globalsign.com,crl.identrust.com,crl.sectigo.com,crl.thawte.com,crl.usertrust.com,crl.verisign.com,crl3.digicert.com,crl4.digicert.com,crls.ssl.com,files.pythonhosted.org,ghcr.io,github-cloud.githubusercontent.com,github-cloud.s3.amazonaws.com,github.com,host.docker.internal,json-schema.org,json.schemastore.org,keyserver.ubuntu.com,lfs.github.com,objects.githubusercontent.com,ocsp.digicert.com,ocsp.geotrust.com,ocsp.globalsign.com,ocsp.identrust.com,ocsp.sectigo.com,ocsp.ssl.com,ocsp.thawte.com,ocsp.usertrust.com,ocsp.verisign.com,packagecloud.io,packages.cloud.google.com,packages.microsoft.com,playwright.download.prss.microsoft.com,ppa.launchpad.net,pypi.org,raw.githubusercontent.com,registry.npmjs.org,s.symcb.com,s.symcd.com,security.ubuntu.com,sentry.io,statsig.anthropic.com,ts-crl.ws.symantec.com,ts-ocsp.ws.symantec.com' --log-level info --proxy-logs-dir /tmp/gh-aw/sandbox/firewall/logs --enable-host-access --image-tag 0.17.0 --skip-pull --enable-api-proxy \ + -- /bin/bash -c 'export PATH="$(find /opt/hostedtoolcache -maxdepth 4 -type d -name bin 2>/dev/null | tr '\''\n'\'' '\'':'\'')$PATH"; [ -n "$GOROOT" ] && export PATH="$GOROOT/bin:$PATH" || true && claude --print --disable-slash-commands --no-chrome --model claude-opus-4-6 --mcp-config /tmp/gh-aw/mcp-config/mcp-servers.json --allowed-tools Bash,BashOutput,Edit,ExitPlanMode,Glob,Grep,KillBash,LS,MultiEdit,NotebookEdit,NotebookRead,Read,Task,TodoWrite,Write,mcp__github__download_workflow_run_artifact,mcp__github__get_code_scanning_alert,mcp__github__get_commit,mcp__github__get_dependabot_alert,mcp__github__get_discussion,mcp__github__get_discussion_comments,mcp__github__get_file_contents,mcp__github__get_job_logs,mcp__github__get_label,mcp__github__get_latest_release,mcp__github__get_me,mcp__github__get_notification_details,mcp__github__get_pull_request,mcp__github__get_pull_request_comments,mcp__github__get_pull_request_diff,mcp__github__get_pull_request_files,mcp__github__get_pull_request_review_comments,mcp__github__get_pull_request_reviews,mcp__github__get_pull_request_status,mcp__github__get_release_by_tag,mcp__github__get_secret_scanning_alert,mcp__github__get_tag,mcp__github__get_workflow_run,mcp__github__get_workflow_run_logs,mcp__github__get_workflow_run_usage,mcp__github__issue_read,mcp__github__list_branches,mcp__github__list_code_scanning_alerts,mcp__github__list_commits,mcp__github__list_dependabot_alerts,mcp__github__list_discussion_categories,mcp__github__list_discussions,mcp__github__list_issue_types,mcp__github__list_issues,mcp__github__list_label,mcp__github__list_notifications,mcp__github__list_pull_requests,mcp__github__list_releases,mcp__github__list_secret_scanning_alerts,mcp__github__list_starred_repositories,mcp__github__list_tags,mcp__github__list_workflow_jobs,mcp__github__list_workflow_run_artifacts,mcp__github__list_workflow_runs,mcp__github__list_workflows,mcp__github__pull_request_read,mcp__github__search_code,mcp__github__search_issues,mcp__github__search_orgs,mcp__github__search_pull_requests,mcp__github__search_repositories,mcp__github__search_users --debug-file /tmp/gh-aw/agent-stdio.log --verbose --permission-mode bypassPermissions --output-format stream-json "$(cat /tmp/gh-aw/aw-prompts/prompt.txt)"' 2>&1 | tee -a /tmp/gh-aw/agent-stdio.log + env: + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + BASH_DEFAULT_TIMEOUT_MS: 60000 + BASH_MAX_TIMEOUT_MS: 60000 + CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + DISABLE_BUG_COMMAND: 1 + DISABLE_ERROR_REPORTING: 1 + DISABLE_TELEMETRY: 1 + GH_AW_MCP_CONFIG: /tmp/gh-aw/mcp-config/mcp-servers.json + GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt + GH_AW_SAFE_OUTPUTS: ${{ env.GH_AW_SAFE_OUTPUTS }} + GITHUB_WORKSPACE: ${{ github.workspace }} + MCP_TIMEOUT: 120000 + MCP_TOOL_TIMEOUT: 60000 + - name: Configure Git credentials + env: + REPO_NAME: ${{ github.repository }} + SERVER_URL: ${{ github.server_url }} + run: | + git config --global user.email "github-actions[bot]@users.noreply.github.com" + git config --global user.name "github-actions[bot]" + # Re-authenticate git with GitHub token + SERVER_URL_STRIPPED="${SERVER_URL#https://}" + git remote set-url origin "https://x-access-token:${{ github.token }}@${SERVER_URL_STRIPPED}/${REPO_NAME}.git" + echo "Git configured with standard GitHub Actions identity" + - name: Stop MCP gateway + if: always() + continue-on-error: true + env: + MCP_GATEWAY_PORT: ${{ steps.start-mcp-gateway.outputs.gateway-port }} + MCP_GATEWAY_API_KEY: ${{ steps.start-mcp-gateway.outputs.gateway-api-key }} + GATEWAY_PID: ${{ steps.start-mcp-gateway.outputs.gateway-pid }} + run: | + bash /opt/gh-aw/actions/stop_mcp_gateway.sh "$GATEWAY_PID" + - name: Redact secrets in logs + if: always() + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + with: + script: | + const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('/opt/gh-aw/actions/redact_secrets.cjs'); + await main(); + env: + GH_AW_SECRET_NAMES: 'ANTHROPIC_API_KEY,CLAUDE_CODE_OAUTH_TOKEN,GH_AW_GITHUB_MCP_SERVER_TOKEN,GH_AW_GITHUB_TOKEN,GITHUB_TOKEN,PHPSTAN_BOT_TOKEN' + SECRET_ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + SECRET_CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + SECRET_GH_AW_GITHUB_MCP_SERVER_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN }} + SECRET_GH_AW_GITHUB_TOKEN: ${{ secrets.GH_AW_GITHUB_TOKEN }} + SECRET_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + SECRET_PHPSTAN_BOT_TOKEN: ${{ secrets.PHPSTAN_BOT_TOKEN }} + - name: Upload Safe Outputs + if: always() + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + with: + name: safe-output + path: ${{ env.GH_AW_SAFE_OUTPUTS }} + if-no-files-found: warn + - name: Ingest agent output + id: collect_output + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + GH_AW_SAFE_OUTPUTS: ${{ env.GH_AW_SAFE_OUTPUTS }} + GH_AW_ALLOWED_DOMAINS: "*.githubusercontent.com,anthropic.com,api.anthropic.com,api.github.com,api.snapcraft.io,archive.ubuntu.com,azure.archive.ubuntu.com,cdn.playwright.dev,codeload.github.com,crl.geotrust.com,crl.globalsign.com,crl.identrust.com,crl.sectigo.com,crl.thawte.com,crl.usertrust.com,crl.verisign.com,crl3.digicert.com,crl4.digicert.com,crls.ssl.com,files.pythonhosted.org,ghcr.io,github-cloud.githubusercontent.com,github-cloud.s3.amazonaws.com,github.com,host.docker.internal,json-schema.org,json.schemastore.org,keyserver.ubuntu.com,lfs.github.com,objects.githubusercontent.com,ocsp.digicert.com,ocsp.geotrust.com,ocsp.globalsign.com,ocsp.identrust.com,ocsp.sectigo.com,ocsp.ssl.com,ocsp.thawte.com,ocsp.usertrust.com,ocsp.verisign.com,packagecloud.io,packages.cloud.google.com,packages.microsoft.com,playwright.download.prss.microsoft.com,ppa.launchpad.net,pypi.org,raw.githubusercontent.com,registry.npmjs.org,s.symcb.com,s.symcd.com,security.ubuntu.com,sentry.io,statsig.anthropic.com,ts-crl.ws.symantec.com,ts-ocsp.ws.symantec.com" + GITHUB_SERVER_URL: ${{ github.server_url }} + GITHUB_API_URL: ${{ github.api_url }} + with: + script: | + const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('/opt/gh-aw/actions/collect_ndjson_output.cjs'); + await main(); + - name: Upload sanitized agent output + if: always() && env.GH_AW_AGENT_OUTPUT + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + with: + name: agent-output + path: ${{ env.GH_AW_AGENT_OUTPUT }} + if-no-files-found: warn + - name: Parse agent logs for step summary + if: always() + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + GH_AW_AGENT_OUTPUT: /tmp/gh-aw/agent-stdio.log + with: + script: | + const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('/opt/gh-aw/actions/parse_claude_log.cjs'); + await main(); + - name: Parse MCP gateway logs for step summary + if: always() + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + with: + script: | + const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('/opt/gh-aw/actions/parse_mcp_gateway_log.cjs'); + await main(); + - name: Print firewall logs + if: always() + continue-on-error: true + env: + AWF_LOGS_DIR: /tmp/gh-aw/sandbox/firewall/logs + run: | + # Fix permissions on firewall logs so they can be uploaded as artifacts + # AWF runs with sudo, creating files owned by root + sudo chmod -R a+r /tmp/gh-aw/sandbox/firewall/logs 2>/dev/null || true + awf logs summary | tee -a "$GITHUB_STEP_SUMMARY" + - name: Upload agent artifacts + if: always() + continue-on-error: true + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + with: + name: agent-artifacts + path: | + /tmp/gh-aw/aw-prompts/prompt.txt + /tmp/gh-aw/aw_info.json + /tmp/gh-aw/mcp-logs/ + /tmp/gh-aw/sandbox/firewall/logs/ + /tmp/gh-aw/agent-stdio.log + /tmp/gh-aw/agent/ + /tmp/gh-aw/aw.patch + if-no-files-found: ignore + + conclusion: + needs: + - activation + - agent + - detection + - safe_outputs + if: (always()) && (needs.agent.result != 'skipped') + runs-on: ubuntu-slim + permissions: + contents: write + issues: write + pull-requests: write + outputs: + noop_message: ${{ steps.noop.outputs.noop_message }} + tools_reported: ${{ steps.missing_tool.outputs.tools_reported }} + total_count: ${{ steps.missing_tool.outputs.total_count }} + steps: + - name: Setup Scripts + uses: github/gh-aw/actions/setup@9382be3ca9ac18917e111a99d4e6bbff58d0dccc # v0.43.23 + with: + destination: /opt/gh-aw/actions + - name: Download agent output artifact + continue-on-error: true + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 + with: + name: agent-output + path: /tmp/gh-aw/safeoutputs/ + - name: Setup agent output environment variable + run: | + mkdir -p /tmp/gh-aw/safeoutputs/ + find "/tmp/gh-aw/safeoutputs/" -type f -print + echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" + - name: Process No-Op Messages + id: noop + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} + GH_AW_NOOP_MAX: 1 + GH_AW_WORKFLOW_NAME: "Document Config Parameters" + with: + github-token: ${{ secrets.PHPSTAN_BOT_TOKEN }} + script: | + const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('/opt/gh-aw/actions/noop.cjs'); + await main(); + - name: Record Missing Tool + id: missing_tool + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} + GH_AW_WORKFLOW_NAME: "Document Config Parameters" + with: + github-token: ${{ secrets.PHPSTAN_BOT_TOKEN }} + script: | + const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('/opt/gh-aw/actions/missing_tool.cjs'); + await main(); + - name: Handle Agent Failure + id: handle_agent_failure + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} + GH_AW_WORKFLOW_NAME: "Document Config Parameters" + GH_AW_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + GH_AW_AGENT_CONCLUSION: ${{ needs.agent.result }} + GH_AW_WORKFLOW_ID: "document-config-params" + GH_AW_SECRET_VERIFICATION_RESULT: ${{ needs.agent.outputs.secret_verification_result }} + GH_AW_CHECKOUT_PR_SUCCESS: ${{ needs.agent.outputs.checkout_pr_success }} + with: + github-token: ${{ secrets.PHPSTAN_BOT_TOKEN }} + script: | + const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('/opt/gh-aw/actions/handle_agent_failure.cjs'); + await main(); + - name: Handle No-Op Message + id: handle_noop_message + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} + GH_AW_WORKFLOW_NAME: "Document Config Parameters" + GH_AW_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + GH_AW_AGENT_CONCLUSION: ${{ needs.agent.result }} + GH_AW_NOOP_MESSAGE: ${{ steps.noop.outputs.noop_message }} + GH_AW_NOOP_REPORT_AS_ISSUE: "true" + with: + github-token: ${{ secrets.PHPSTAN_BOT_TOKEN }} + script: | + const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('/opt/gh-aw/actions/handle_noop_message.cjs'); + await main(); + - name: Handle Create Pull Request Error + id: handle_create_pr_error + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} + GH_AW_WORKFLOW_NAME: "Document Config Parameters" + GH_AW_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + with: + github-token: ${{ secrets.PHPSTAN_BOT_TOKEN }} + script: | + const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('/opt/gh-aw/actions/handle_create_pr_error.cjs'); + await main(); + - name: Update reaction comment with completion status + id: conclusion + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} + GH_AW_COMMENT_ID: ${{ needs.activation.outputs.comment_id }} + GH_AW_COMMENT_REPO: ${{ needs.activation.outputs.comment_repo }} + GH_AW_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + GH_AW_WORKFLOW_NAME: "Document Config Parameters" + GH_AW_AGENT_CONCLUSION: ${{ needs.agent.result }} + GH_AW_DETECTION_CONCLUSION: ${{ needs.detection.result }} + with: + github-token: ${{ secrets.PHPSTAN_BOT_TOKEN }} + script: | + const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('/opt/gh-aw/actions/notify_comment_error.cjs'); + await main(); + + detection: + needs: agent + if: needs.agent.outputs.output_types != '' || needs.agent.outputs.has_patch == 'true' + runs-on: ubuntu-latest + permissions: {} + timeout-minutes: 10 + outputs: + success: ${{ steps.parse_results.outputs.success }} + steps: + - name: Setup Scripts + uses: github/gh-aw/actions/setup@9382be3ca9ac18917e111a99d4e6bbff58d0dccc # v0.43.23 + with: + destination: /opt/gh-aw/actions + - name: Download agent artifacts + continue-on-error: true + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 + with: + name: agent-artifacts + path: /tmp/gh-aw/threat-detection/ + - name: Download agent output artifact + continue-on-error: true + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 + with: + name: agent-output + path: /tmp/gh-aw/threat-detection/ + - name: Echo agent output types + env: + AGENT_OUTPUT_TYPES: ${{ needs.agent.outputs.output_types }} + run: | + echo "Agent output-types: $AGENT_OUTPUT_TYPES" + - name: Setup threat detection + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + WORKFLOW_NAME: "Document Config Parameters" + WORKFLOW_DESCRIPTION: "Finds undocumented PHPStan config parameters and creates documentation PRs on phpstan/phpstan" + HAS_PATCH: ${{ needs.agent.outputs.has_patch }} + with: + script: | + const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('/opt/gh-aw/actions/setup_threat_detection.cjs'); + await main(); + - name: Ensure threat-detection directory and log + run: | + mkdir -p /tmp/gh-aw/threat-detection + touch /tmp/gh-aw/threat-detection/detection.log + - name: Validate CLAUDE_CODE_OAUTH_TOKEN or ANTHROPIC_API_KEY secret + id: validate-secret + run: /opt/gh-aw/actions/validate_multi_secret.sh CLAUDE_CODE_OAUTH_TOKEN ANTHROPIC_API_KEY 'Claude Code' https://github.github.com/gh-aw/reference/engines/#anthropic-claude-code + env: + CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + - name: Setup Node.js + uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 + with: + node-version: '24' + package-manager-cache: false + - name: Install Claude Code CLI + run: npm install -g --silent @anthropic-ai/claude-code@2.1.39 + - name: Execute Claude Code CLI + id: agentic_execution + # Allowed tools (sorted): + # - Bash(cat) + # - Bash(grep) + # - Bash(head) + # - Bash(jq) + # - Bash(ls) + # - Bash(tail) + # - Bash(wc) + # - BashOutput + # - ExitPlanMode + # - Glob + # - Grep + # - KillBash + # - LS + # - NotebookRead + # - Read + # - Task + # - TodoWrite + timeout-minutes: 20 + run: | + set -o pipefail + # Execute Claude Code CLI with prompt from file + claude --print --disable-slash-commands --no-chrome --model claude-opus-4-6 --allowed-tools 'Bash(cat),Bash(grep),Bash(head),Bash(jq),Bash(ls),Bash(tail),Bash(wc),BashOutput,ExitPlanMode,Glob,Grep,KillBash,LS,NotebookRead,Read,Task,TodoWrite' --debug-file /tmp/gh-aw/threat-detection/detection.log --verbose --permission-mode bypassPermissions --output-format stream-json "$(cat /tmp/gh-aw/aw-prompts/prompt.txt)" 2>&1 | tee -a /tmp/gh-aw/threat-detection/detection.log + env: + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + BASH_DEFAULT_TIMEOUT_MS: 60000 + BASH_MAX_TIMEOUT_MS: 60000 + CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + DISABLE_BUG_COMMAND: 1 + DISABLE_ERROR_REPORTING: 1 + DISABLE_TELEMETRY: 1 + GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt + GITHUB_WORKSPACE: ${{ github.workspace }} + MCP_TIMEOUT: 120000 + MCP_TOOL_TIMEOUT: 60000 + - name: Parse threat detection results + id: parse_results + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + with: + script: | + const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('/opt/gh-aw/actions/parse_threat_detection_results.cjs'); + await main(); + - name: Upload threat detection log + if: always() + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + with: + name: threat-detection.log + path: /tmp/gh-aw/threat-detection/detection.log + if-no-files-found: ignore + + pre_activation: + runs-on: ubuntu-slim + outputs: + activated: ${{ steps.check_membership.outputs.is_team_member == 'true' }} + steps: + - name: Setup Scripts + uses: github/gh-aw/actions/setup@9382be3ca9ac18917e111a99d4e6bbff58d0dccc # v0.43.23 + with: + destination: /opt/gh-aw/actions + - name: Check team membership for workflow + id: check_membership + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + GH_AW_REQUIRED_ROLES: admin,maintainer,write + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('/opt/gh-aw/actions/check_membership.cjs'); + await main(); + + safe_outputs: + needs: + - activation + - agent + - detection + if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (needs.detection.outputs.success == 'true') + runs-on: ubuntu-slim + permissions: + contents: write + issues: write + pull-requests: write + timeout-minutes: 15 + env: + GH_AW_ENGINE_ID: "claude" + GH_AW_ENGINE_MODEL: "claude-opus-4-6" + GH_AW_WORKFLOW_ID: "document-config-params" + GH_AW_WORKFLOW_NAME: "Document Config Parameters" + outputs: + create_discussion_error_count: ${{ steps.process_safe_outputs.outputs.create_discussion_error_count }} + create_discussion_errors: ${{ steps.process_safe_outputs.outputs.create_discussion_errors }} + process_safe_outputs_processed_count: ${{ steps.process_safe_outputs.outputs.processed_count }} + process_safe_outputs_temporary_id_map: ${{ steps.process_safe_outputs.outputs.temporary_id_map }} + steps: + - name: Setup Scripts + uses: github/gh-aw/actions/setup@9382be3ca9ac18917e111a99d4e6bbff58d0dccc # v0.43.23 + with: + destination: /opt/gh-aw/actions + - name: Download agent output artifact + continue-on-error: true + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 + with: + name: agent-output + path: /tmp/gh-aw/safeoutputs/ + - name: Setup agent output environment variable + run: | + mkdir -p /tmp/gh-aw/safeoutputs/ + find "/tmp/gh-aw/safeoutputs/" -type f -print + echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" + - name: Download patch artifact + continue-on-error: true + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 + with: + name: agent-artifacts + path: /tmp/gh-aw/ + - name: Checkout repository + if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'create_pull_request')) + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + repository: phpstan/phpstan + token: ${{ github.token }} + persist-credentials: false + fetch-depth: 1 + - name: Configure Git credentials + if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'create_pull_request')) + env: + REPO_NAME: "phpstan/phpstan" + SERVER_URL: ${{ github.server_url }} + GIT_TOKEN: ${{ github.token }} + run: | + git config --global user.email "github-actions[bot]@users.noreply.github.com" + git config --global user.name "github-actions[bot]" + # Re-authenticate git with GitHub token + SERVER_URL_STRIPPED="${SERVER_URL#https://}" + git remote set-url origin "https://x-access-token:${GIT_TOKEN}@${SERVER_URL_STRIPPED}/${REPO_NAME}.git" + echo "Git configured with standard GitHub Actions identity" + - name: Process Safe Outputs + id: process_safe_outputs + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} + GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG: "{\"create_pull_request\":{\"base_branch\":\"${{ github.ref_name }}\",\"draft\":true,\"fallback_as_issue\":true,\"max\":1,\"max_patch_size\":1024,\"target-repo\":\"phpstan/phpstan\",\"title_prefix\":\"[Docs] \"},\"missing_data\":{},\"missing_tool\":{}}" + with: + github-token: ${{ secrets.PHPSTAN_BOT_TOKEN }} + script: | + const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('/opt/gh-aw/actions/safe_output_handler_manager.cjs'); + await main(); + diff --git a/.github/workflows/document-config-params.md b/.github/workflows/document-config-params.md new file mode 100644 index 00000000000..a232aed9b53 --- /dev/null +++ b/.github/workflows/document-config-params.md @@ -0,0 +1,132 @@ +--- +name: Document Config Parameters +description: Finds undocumented PHPStan config parameters and creates documentation PRs on phpstan/phpstan +on: + push: + branches: [2.2.x] + paths: [conf/parametersSchema.neon] + workflow_dispatch: +engine: + id: claude + model: claude-opus-4-6 + env: + CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} +permissions: + contents: read + issues: read + pull-requests: read +tools: + bash: ["*"] + github: + toolsets: [default, repos] +safe-outputs: + github-token: ${{ secrets.PHPSTAN_BOT_TOKEN }} + create-pull-request: + target-repo: phpstan/phpstan + title-prefix: "[Docs] " + draft: true + fallback-as-issue: true +timeout-minutes: 30 +--- + +# Document Undocumented Config Parameters + +You are a documentation agent for PHPStan. Your job is to find configuration parameters that exist in the schema but lack user-facing documentation, and to write documentation for them. + +## Source files + +- **Parameter schema**: `conf/parametersSchema.neon` in this workspace (phpstan-src repo) +- **Config reference docs**: `website/src/config-reference.md` in the `phpstan/phpstan` repository — fetch this file using the GitHub `get_file_contents` tool from the `phpstan/phpstan` repo + +## Task + +### Step 1: Read both files + +1. Read `conf/parametersSchema.neon` from the workspace +2. Fetch `website/src/config-reference.md` from the `phpstan/phpstan` repo using the GitHub tools + +### Step 2: Identify user-facing parameters from the schema + +Extract all parameter names from `parametersSchema.neon`. **Skip these entirely:** + +- The entire `featureToggles` section and all its sub-parameters +- Everything after the `# playground mode` comment — these are internal/irrelevant: + - `sourceLocatorPlaygroundMode` + - Nette parameters: `debugMode`, `productionMode`, `tempDir`, `__validate` + - DerivativeContainerFactory internals: `additionalConfigFiles`, `generateBaselineFile`, `analysedPaths`, `allConfigFiles`, `composerAutoloaderProjectPaths`, `analysedPathsFromConfig`, `usedLevel`, `cliAutoloadFile` + - Editor mode internals: `singleReflectionFile`, `singleReflectionInsteadOfFile` + +Also skip these internal parameters that users should not configure directly: +- `strictRulesInstalled`, `deprecationRulesInstalled` (set by installing packages, not by users) +- `cliArgumentsVariablesRegistered` (internal CLI flag) +- `rootDir`, `currentWorkingDirectory` (auto-detected, not user-configurable) +- `sysGetTempDir` (internal) +- `parametersNotInvalidatingCache` (internal) +- `env` (internal environment variable mapping) + +### Step 3: Determine which parameters are undocumented + +Check which parameter names from the schema do NOT appear as documented parameters in `config-reference.md`. A parameter counts as "documented" if it appears as a heading (`###`), in a config key listing, or is explained in a section body. + +{{#if github.event_name == 'push'}} +Focus on parameters that were added or changed in the push. Run `git diff HEAD~1 -- conf/parametersSchema.neon` to see what changed. Only document newly added parameters. +{{/if}} + +If there are no undocumented parameters, stop and report that all parameters are documented. Do not create a PR. + +### Step 4: Research each undocumented parameter + +For each undocumented parameter, investigate what it does: + +1. **Search the source code** in `src/` for where the parameter is used. Look for the parameter name in PHP files — it will typically appear in a service constructor or be read from the DI container. +2. **Check level configs** in `conf/config.level*.neon` to see which level enables the parameter and what its default value is. +3. **Check `conf/config.neon`** for the parameter's default value. +4. **Look at related rules and tests** to understand the behavior. Check `tests/` for test data files that exercise the parameter. +5. **Check if phpstan-strict-rules sets it** by searching for the parameter name in the codebase and noting if strict-rules is mentioned. + +### Step 5: Write documentation + +Write the file `website/src/config-reference.md` to the workspace with the complete updated content. The file path must exactly match the target repo's structure. + +First, write the original content fetched from phpstan/phpstan to `website/src/config-reference.md` in the workspace. Then edit it to add the new documentation. + +**Place each parameter in the correct existing section:** +- Boolean flags that enable stricter checks → "Stricter analysis" section (as `###` sub-headings) +- Parameters related to parallel processing → "Parallel processing" section +- Parameters related to caching → "Caching" section +- Other general settings → "Miscellaneous parameters" section +- Parameters related to exceptions → "Exceptions" section + +**Follow the existing documentation conventions exactly:** + +For parameters in "Stricter analysis", use this format: + +``` +### `parameterName` + +**default**: `value` ([strict-rules](https://github.com/phpstan/phpstan-strict-rules) sets it to `otherValue`) + +When set to `true/false`, it [concise description of what changes]. +``` + +Include a short PHP code example only if it helps illustrate the behavior clearly. Keep descriptions concise — one or two sentences is ideal. + +If the parameter was introduced in a specific PHPStan version (not 1.0), add a version badge: + +```html +
Available in PHPStan X.Y
+``` + +For parameters in "Miscellaneous parameters", use: + +``` +### `parameterName` + +**default**: `value` + +Description of what the parameter does. +``` + +### Step 6: Create a pull request + +After editing the documentation file, create a pull request. The PR description should list which parameters were newly documented with a one-line summary of each. From 547f1281d85464bc01e69620bfd7414854abdf3a Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Sat, 14 Feb 2026 10:46:15 +0100 Subject: [PATCH 003/158] Add gh-aw infrastructure files Co-Authored-By: Claude Opus 4.6 --- .github/agents/agentic-workflows.agent.md | 167 ++++++++++++++++++++++ .github/aw/actions-lock.json | 34 +++++ 2 files changed, 201 insertions(+) create mode 100644 .github/agents/agentic-workflows.agent.md create mode 100644 .github/aw/actions-lock.json diff --git a/.github/agents/agentic-workflows.agent.md b/.github/agents/agentic-workflows.agent.md new file mode 100644 index 00000000000..dea035c3519 --- /dev/null +++ b/.github/agents/agentic-workflows.agent.md @@ -0,0 +1,167 @@ +--- +description: GitHub Agentic Workflows (gh-aw) - Create, debug, and upgrade AI-powered workflows with intelligent prompt routing +infer: false +--- + +# GitHub Agentic Workflows Agent + +This agent helps you work with **GitHub Agentic Workflows (gh-aw)**, a CLI extension for creating AI-powered workflows in natural language using markdown files. + +## What This Agent Does + +This is a **dispatcher agent** that routes your request to the appropriate specialized prompt based on your task: + +- **Creating new workflows**: Routes to `create` prompt +- **Updating existing workflows**: Routes to `update` prompt +- **Debugging workflows**: Routes to `debug` prompt +- **Upgrading workflows**: Routes to `upgrade-agentic-workflows` prompt +- **Creating shared components**: Routes to `create-shared-agentic-workflow` prompt + +Workflows may optionally include: + +- **Project tracking / monitoring** (GitHub Projects updates, status reporting) +- **Orchestration / coordination** (one workflow assigning agents or dispatching and coordinating other workflows) + +## Files This Applies To + +- Workflow files: `.github/workflows/*.md` and `.github/workflows/**/*.md` +- Workflow lock files: `.github/workflows/*.lock.yml` +- Shared components: `.github/workflows/shared/*.md` +- Configuration: https://github.com/github/gh-aw/blob/v0.43.23/.github/aw/github-agentic-workflows.md + +## Problems This Solves + +- **Workflow Creation**: Design secure, validated agentic workflows with proper triggers, tools, and permissions +- **Workflow Debugging**: Analyze logs, identify missing tools, investigate failures, and fix configuration issues +- **Version Upgrades**: Migrate workflows to new gh-aw versions, apply codemods, fix breaking changes +- **Component Design**: Create reusable shared workflow components that wrap MCP servers + +## How to Use + +When you interact with this agent, it will: + +1. **Understand your intent** - Determine what kind of task you're trying to accomplish +2. **Route to the right prompt** - Load the specialized prompt file for your task +3. **Execute the task** - Follow the detailed instructions in the loaded prompt + +## Available Prompts + +### Create New Workflow +**Load when**: User wants to create a new workflow from scratch, add automation, or design a workflow that doesn't exist yet + +**Prompt file**: https://github.com/github/gh-aw/blob/v0.43.23/.github/aw/create-agentic-workflow.md + +**Use cases**: +- "Create a workflow that triages issues" +- "I need a workflow to label pull requests" +- "Design a weekly research automation" + +### Update Existing Workflow +**Load when**: User wants to modify, improve, or refactor an existing workflow + +**Prompt file**: https://github.com/github/gh-aw/blob/v0.43.23/.github/aw/update-agentic-workflow.md + +**Use cases**: +- "Add web-fetch tool to the issue-classifier workflow" +- "Update the PR reviewer to use discussions instead of issues" +- "Improve the prompt for the weekly-research workflow" + +### Debug Workflow +**Load when**: User needs to investigate, audit, debug, or understand a workflow, troubleshoot issues, analyze logs, or fix errors + +**Prompt file**: https://github.com/github/gh-aw/blob/v0.43.23/.github/aw/debug-agentic-workflow.md + +**Use cases**: +- "Why is this workflow failing?" +- "Analyze the logs for workflow X" +- "Investigate missing tool calls in run #12345" + +### Upgrade Agentic Workflows +**Load when**: User wants to upgrade workflows to a new gh-aw version or fix deprecations + +**Prompt file**: https://github.com/github/gh-aw/blob/v0.43.23/.github/aw/upgrade-agentic-workflows.md + +**Use cases**: +- "Upgrade all workflows to the latest version" +- "Fix deprecated fields in workflows" +- "Apply breaking changes from the new release" + +### Create Shared Agentic Workflow +**Load when**: User wants to create a reusable workflow component or wrap an MCP server + +**Prompt file**: https://github.com/github/gh-aw/blob/v0.43.23/.github/aw/create-shared-agentic-workflow.md + +**Use cases**: +- "Create a shared component for Notion integration" +- "Wrap the Slack MCP server as a reusable component" +- "Design a shared workflow for database queries" + +### Orchestration and Delegation + +**Load when**: Creating or updating workflows that coordinate multiple agents or dispatch work to other workflows + +**Prompt file**: https://github.com/github/gh-aw/blob/v0.43.23/.github/aw/orchestration.md + +**Use cases**: +- Assigning work to AI coding agents +- Dispatching specialized worker workflows +- Using correlation IDs for tracking +- Orchestration design patterns + +### GitHub Projects Integration + +**Load when**: Creating or updating workflows that manage GitHub Projects v2 + +**Prompt file**: https://github.com/github/gh-aw/blob/v0.43.23/.github/aw/projects.md + +**Use cases**: +- Tracking items and fields with update-project +- Posting periodic run summaries +- Creating new projects +- Projects v2 authentication and configuration + +## Instructions + +When a user interacts with you: + +1. **Identify the task type** from the user's request +2. **Load the appropriate prompt** from the GitHub repository URLs listed above +3. **Follow the loaded prompt's instructions** exactly +4. **If uncertain**, ask clarifying questions to determine the right prompt + +## Quick Reference + +```bash +# Initialize repository for agentic workflows +gh aw init + +# Generate the lock file for a workflow +gh aw compile [workflow-name] + +# Debug workflow runs +gh aw logs [workflow-name] +gh aw audit + +# Upgrade workflows +gh aw fix --write +gh aw compile --validate +``` + +## Key Features of gh-aw + +- **Natural Language Workflows**: Write workflows in markdown with YAML frontmatter +- **AI Engine Support**: Copilot, Claude, Codex, or custom engines +- **MCP Server Integration**: Connect to Model Context Protocol servers for tools +- **Safe Outputs**: Structured communication between AI and GitHub API +- **Strict Mode**: Security-first validation and sandboxing +- **Shared Components**: Reusable workflow building blocks +- **Repo Memory**: Persistent git-backed storage for agents +- **Sandboxed Execution**: All workflows run in the Agent Workflow Firewall (AWF) sandbox, enabling full `bash` and `edit` tools by default + +## Important Notes + +- Always reference the instructions file at https://github.com/github/gh-aw/blob/v0.43.23/.github/aw/github-agentic-workflows.md for complete documentation +- Use the MCP tool `agentic-workflows` when running in GitHub Copilot Cloud +- Workflows must be compiled to `.lock.yml` files before running in GitHub Actions +- **Bash tools are enabled by default** - Don't restrict bash commands unnecessarily since workflows are sandboxed by the AWF +- Follow security best practices: minimal permissions, explicit network access, no template injection diff --git a/.github/aw/actions-lock.json b/.github/aw/actions-lock.json new file mode 100644 index 00000000000..c0fa40a836b --- /dev/null +++ b/.github/aw/actions-lock.json @@ -0,0 +1,34 @@ +{ + "entries": { + "actions/checkout@v6.0.2": { + "repo": "actions/checkout", + "version": "v6.0.2", + "sha": "de0fac2e4500dabe0009e67214ff5f5447ce83dd" + }, + "actions/download-artifact@v6.0.0": { + "repo": "actions/download-artifact", + "version": "v6.0.0", + "sha": "018cc2cf5baa6db3ef3c5f8a56943fffe632ef53" + }, + "actions/github-script@v8": { + "repo": "actions/github-script", + "version": "v8", + "sha": "ed597411d8f924073f98dfc5c65a23a2325f34cd" + }, + "actions/setup-node@v6.2.0": { + "repo": "actions/setup-node", + "version": "v6.2.0", + "sha": "6044e13b5dc448c55e2357c09f80417699197238" + }, + "actions/upload-artifact@v6.0.0": { + "repo": "actions/upload-artifact", + "version": "v6.0.0", + "sha": "b7c566a772e6b6bfb58ed0dc250532a479d7789f" + }, + "github/gh-aw/actions/setup@v0.43.23": { + "repo": "github/gh-aw/actions/setup", + "version": "v0.43.23", + "sha": "9382be3ca9ac18917e111a99d4e6bbff58d0dccc" + } + } +} From 080bafad9c050f0aadca5143df68f55974ba8167 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Sat, 14 Feb 2026 10:57:17 +0100 Subject: [PATCH 004/158] Fix manual dispatch to check all parameters, not just diff Use github.event.before for push diffs to handle multi-commit pushes. Add explicit else branch for manual dispatch to check entire schema. Co-Authored-By: Claude Opus 4.6 --- .github/workflows/document-config-params.lock.yml | 4 ++++ .github/workflows/document-config-params.md | 4 +++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/.github/workflows/document-config-params.lock.yml b/.github/workflows/document-config-params.lock.yml index bf8a0262011..52c49749d47 100644 --- a/.github/workflows/document-config-params.lock.yml +++ b/.github/workflows/document-config-params.lock.yml @@ -480,6 +480,7 @@ jobs: GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt GH_AW_SAFE_OUTPUTS: ${{ env.GH_AW_SAFE_OUTPUTS }} GH_AW_GITHUB_ACTOR: ${{ github.actor }} + GH_AW_GITHUB_EVENT_BEFORE: ${{ github.event.before }} GH_AW_GITHUB_EVENT_COMMENT_ID: ${{ github.event.comment.id }} GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER: ${{ github.event.discussion.number }} GH_AW_GITHUB_EVENT_ISSUE_NUMBER: ${{ github.event.issue.number }} @@ -564,6 +565,7 @@ jobs: env: GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt GH_AW_GITHUB_ACTOR: ${{ github.actor }} + GH_AW_GITHUB_EVENT_BEFORE: ${{ github.event.before }} GH_AW_GITHUB_EVENT_COMMENT_ID: ${{ github.event.comment.id }} GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER: ${{ github.event.discussion.number }} GH_AW_GITHUB_EVENT_ISSUE_NUMBER: ${{ github.event.issue.number }} @@ -580,6 +582,7 @@ jobs: file: process.env.GH_AW_PROMPT, substitutions: { GH_AW_GITHUB_ACTOR: process.env.GH_AW_GITHUB_ACTOR, + GH_AW_GITHUB_EVENT_BEFORE: process.env.GH_AW_GITHUB_EVENT_BEFORE, GH_AW_GITHUB_EVENT_COMMENT_ID: process.env.GH_AW_GITHUB_EVENT_COMMENT_ID, GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER: process.env.GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER, GH_AW_GITHUB_EVENT_ISSUE_NUMBER: process.env.GH_AW_GITHUB_EVENT_ISSUE_NUMBER, @@ -593,6 +596,7 @@ jobs: uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 env: GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt + GH_AW_GITHUB_EVENT_BEFORE: ${{ github.event.before }} with: script: | const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); diff --git a/.github/workflows/document-config-params.md b/.github/workflows/document-config-params.md index a232aed9b53..2315a09f579 100644 --- a/.github/workflows/document-config-params.md +++ b/.github/workflows/document-config-params.md @@ -69,7 +69,9 @@ Also skip these internal parameters that users should not configure directly: Check which parameter names from the schema do NOT appear as documented parameters in `config-reference.md`. A parameter counts as "documented" if it appears as a heading (`###`), in a config key listing, or is explained in a section body. {{#if github.event_name == 'push'}} -Focus on parameters that were added or changed in the push. Run `git diff HEAD~1 -- conf/parametersSchema.neon` to see what changed. Only document newly added parameters. +Focus only on parameters that were added or changed in this push. Run `git diff ${{ github.event.before }} -- conf/parametersSchema.neon` to see what changed across all commits in the push. Only document newly added parameters. +{{#else}} +Check ALL non-skipped parameters from the schema against the documentation. Do not look at git history or diffs — compare the entire `parametersSchema.neon` against `config-reference.md` and document every undocumented parameter you find. {{/if}} If there are no undocumented parameters, stop and report that all parameters are documented. Do not create a PR. From 2a5eaba0eb9a004433742d4f613ef7b8941ab6c3 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Sat, 14 Feb 2026 11:16:38 +0100 Subject: [PATCH 005/158] Skip level-only parameters in config docs workflow These parameters exist purely to be toggled by rule levels and are not configured by users directly. Co-Authored-By: Claude Opus 4.6 --- .github/workflows/document-config-params.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/.github/workflows/document-config-params.md b/.github/workflows/document-config-params.md index 2315a09f579..8aed94f6071 100644 --- a/.github/workflows/document-config-params.md +++ b/.github/workflows/document-config-params.md @@ -64,6 +64,26 @@ Also skip these internal parameters that users should not configure directly: - `parametersNotInvalidatingCache` (internal) - `env` (internal environment variable mapping) +Also skip these level-only parameters — they exist purely to be toggled by rule levels in `conf/config.level*.neon` and are not configured by users directly: +- `checkThisOnly` (level 2) +- `checkMaybeUndefinedVariables` (level 1) +- `checkExtraArguments` (level 1) +- `reportMagicMethods` (level 1) +- `reportMagicProperties` (level 1) +- `checkClassCaseSensitivity` (level 2) +- `checkPhpDocMissingReturn` (level 2) +- `checkPhpDocMethodSignatures` (level 3) +- `checkAdvancedIsset` (level 4) +- `checkFunctionArgumentTypes` (level 5) +- `checkArgumentsPassedByReference` (level 5) +- `checkMissingVarTagTypehint` (level 6) +- `checkMissingTypehints` (level 6) +- `checkUnionTypes` (level 7) +- `reportMaybes` (level 7) +- `checkNullables` (level 8) +- `checkExplicitMixed` (level 9) +- `checkImplicitMixed` (level 10) + ### Step 3: Determine which parameters are undocumented Check which parameter names from the schema do NOT appear as documented parameters in `config-reference.md`. A parameter counts as "documented" if it appears as a heading (`###`), in a config key listing, or is explained in a section body. From 3bb4431d691fd74aa23a4b91d201aab5ad20658c Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Sat, 14 Feb 2026 11:43:33 +0100 Subject: [PATCH 006/158] Fix cross-repo patch by pre-fetching config-reference.md The safe-output patch was trying to create the file as new, but it already exists in phpstan/phpstan. Add a pre-step that fetches and commits the file so the agent's edits produce a modification patch. Co-Authored-By: Claude Opus 4.6 --- .../workflows/document-config-params.lock.yml | 7 ++++++- .github/workflows/document-config-params.md | 17 ++++++++++++----- 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/.github/workflows/document-config-params.lock.yml b/.github/workflows/document-config-params.lock.yml index 52c49749d47..14d3a14fcfc 100644 --- a/.github/workflows/document-config-params.lock.yml +++ b/.github/workflows/document-config-params.lock.yml @@ -23,7 +23,7 @@ # # Finds undocumented PHPStan config parameters and creates documentation PRs on phpstan/phpstan # -# frontmatter-hash: 69e695fc26ed030a830663b4989c2181e28836aebc20d9269f866543d9f36bab +# frontmatter-hash: 1a9f53816b136dca6a59bce24fbe857946c572cd0ca2df9f462956e9caf1a30d name: "Document Config Parameters" "on": @@ -102,6 +102,11 @@ jobs: persist-credentials: false - name: Create gh-aw temp directory run: bash /opt/gh-aw/actions/create_gh_aw_tmp_dir.sh + - env: + GH_TOKEN: ${{ secrets.PHPSTAN_BOT_TOKEN }} + name: Fetch config-reference.md from phpstan/phpstan + run: "mkdir -p website/src\ngh api repos/phpstan/phpstan/contents/website/src/config-reference.md -H \"Accept: application/vnd.github.raw+json\" > website/src/config-reference.md\ngit add website/src/config-reference.md\ngit commit -m \"Seed config-reference.md from phpstan/phpstan\"" + - name: Configure Git credentials env: REPO_NAME: ${{ github.repository }} diff --git a/.github/workflows/document-config-params.md b/.github/workflows/document-config-params.md index 8aed94f6071..5cec3711e63 100644 --- a/.github/workflows/document-config-params.md +++ b/.github/workflows/document-config-params.md @@ -27,6 +27,15 @@ safe-outputs: draft: true fallback-as-issue: true timeout-minutes: 30 +steps: + - name: Fetch config-reference.md from phpstan/phpstan + env: + GH_TOKEN: ${{ secrets.PHPSTAN_BOT_TOKEN }} + run: | + mkdir -p website/src + gh api repos/phpstan/phpstan/contents/website/src/config-reference.md -H "Accept: application/vnd.github.raw+json" > website/src/config-reference.md + git add website/src/config-reference.md + git commit -m "Seed config-reference.md from phpstan/phpstan" --- # Document Undocumented Config Parameters @@ -36,14 +45,14 @@ You are a documentation agent for PHPStan. Your job is to find configuration par ## Source files - **Parameter schema**: `conf/parametersSchema.neon` in this workspace (phpstan-src repo) -- **Config reference docs**: `website/src/config-reference.md` in the `phpstan/phpstan` repository — fetch this file using the GitHub `get_file_contents` tool from the `phpstan/phpstan` repo +- **Config reference docs**: `website/src/config-reference.md` — already fetched from `phpstan/phpstan` into the workspace by a pre-step ## Task ### Step 1: Read both files 1. Read `conf/parametersSchema.neon` from the workspace -2. Fetch `website/src/config-reference.md` from the `phpstan/phpstan` repo using the GitHub tools +2. Read `website/src/config-reference.md` from the workspace (it was pre-fetched from the `phpstan/phpstan` repo) ### Step 2: Identify user-facing parameters from the schema @@ -108,9 +117,7 @@ For each undocumented parameter, investigate what it does: ### Step 5: Write documentation -Write the file `website/src/config-reference.md` to the workspace with the complete updated content. The file path must exactly match the target repo's structure. - -First, write the original content fetched from phpstan/phpstan to `website/src/config-reference.md` in the workspace. Then edit it to add the new documentation. +Edit the existing `website/src/config-reference.md` file in the workspace to add the new documentation. Do NOT overwrite the file — use targeted edits to insert new parameter sections in the correct locations. **Place each parameter in the correct existing section:** - Boolean flags that enable stricter checks → "Stricter analysis" section (as `###` sub-headings) From afa59666adb209fbf1184bbcf94d925473fb1d0a Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Sat, 14 Feb 2026 11:59:52 +0100 Subject: [PATCH 007/158] Fix git identity for pre-step commit on CI runner Co-Authored-By: Claude Opus 4.6 --- .github/workflows/document-config-params.lock.yml | 4 ++-- .github/workflows/document-config-params.md | 2 ++ 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/document-config-params.lock.yml b/.github/workflows/document-config-params.lock.yml index 14d3a14fcfc..e89f06232fc 100644 --- a/.github/workflows/document-config-params.lock.yml +++ b/.github/workflows/document-config-params.lock.yml @@ -23,7 +23,7 @@ # # Finds undocumented PHPStan config parameters and creates documentation PRs on phpstan/phpstan # -# frontmatter-hash: 1a9f53816b136dca6a59bce24fbe857946c572cd0ca2df9f462956e9caf1a30d +# frontmatter-hash: d760e657da671c4981c44c5653b1d742bd302e5bb6d2d59c20b32d62d08ad4df name: "Document Config Parameters" "on": @@ -105,7 +105,7 @@ jobs: - env: GH_TOKEN: ${{ secrets.PHPSTAN_BOT_TOKEN }} name: Fetch config-reference.md from phpstan/phpstan - run: "mkdir -p website/src\ngh api repos/phpstan/phpstan/contents/website/src/config-reference.md -H \"Accept: application/vnd.github.raw+json\" > website/src/config-reference.md\ngit add website/src/config-reference.md\ngit commit -m \"Seed config-reference.md from phpstan/phpstan\"" + run: "mkdir -p website/src\ngh api repos/phpstan/phpstan/contents/website/src/config-reference.md -H \"Accept: application/vnd.github.raw+json\" > website/src/config-reference.md\ngit config user.name \"github-actions[bot]\"\ngit config user.email \"github-actions[bot]@users.noreply.github.com\"\ngit add website/src/config-reference.md\ngit commit -m \"Seed config-reference.md from phpstan/phpstan\"" - name: Configure Git credentials env: diff --git a/.github/workflows/document-config-params.md b/.github/workflows/document-config-params.md index 5cec3711e63..69812f25ad9 100644 --- a/.github/workflows/document-config-params.md +++ b/.github/workflows/document-config-params.md @@ -34,6 +34,8 @@ steps: run: | mkdir -p website/src gh api repos/phpstan/phpstan/contents/website/src/config-reference.md -H "Accept: application/vnd.github.raw+json" > website/src/config-reference.md + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" git add website/src/config-reference.md git commit -m "Seed config-reference.md from phpstan/phpstan" --- From 97f58a0b7bfa9805ee6299bd9a20f55afd5f4f22 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Sat, 14 Feb 2026 12:35:22 +0100 Subject: [PATCH 008/158] Use direct checkout and bash PR creation instead of safe-outputs Instead of using safe-outputs create-pull-request (which generates patches that fail on cross-repo applies), checkout phpstan/phpstan to __phpstan-website/ subdirectory, edit config-reference.md in place, and push branch + create PR via bash/gh CLI. Co-Authored-By: Claude Opus 4.6 --- .github/aw/actions-lock.json | 5 + .../workflows/document-config-params.lock.yml | 640 +----------------- .github/workflows/document-config-params.md | 47 +- 3 files changed, 41 insertions(+), 651 deletions(-) diff --git a/.github/aw/actions-lock.json b/.github/aw/actions-lock.json index c0fa40a836b..fd519aa91be 100644 --- a/.github/aw/actions-lock.json +++ b/.github/aw/actions-lock.json @@ -1,5 +1,10 @@ { "entries": { + "actions/checkout@v4": { + "repo": "actions/checkout", + "version": "v4", + "sha": "34e114876b0b11c390a56381ad16ebd13914f8d5" + }, "actions/checkout@v6.0.2": { "repo": "actions/checkout", "version": "v6.0.2", diff --git a/.github/workflows/document-config-params.lock.yml b/.github/workflows/document-config-params.lock.yml index e89f06232fc..f6959636751 100644 --- a/.github/workflows/document-config-params.lock.yml +++ b/.github/workflows/document-config-params.lock.yml @@ -23,7 +23,7 @@ # # Finds undocumented PHPStan config parameters and creates documentation PRs on phpstan/phpstan # -# frontmatter-hash: d760e657da671c4981c44c5653b1d742bd302e5bb6d2d59c20b32d62d08ad4df +# frontmatter-hash: 8fc29ae470188f0f52dc79af2c2bba18a872a57ddfa4479eb52b07a7b642c978 name: "Document Config Parameters" "on": @@ -75,37 +75,24 @@ jobs: issues: read pull-requests: read env: - DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} - GH_AW_ASSETS_ALLOWED_EXTS: "" - GH_AW_ASSETS_BRANCH: "" - GH_AW_ASSETS_MAX_SIZE_KB: 0 - GH_AW_MCP_LOG_DIR: /tmp/gh-aw/mcp-logs/safeoutputs - GH_AW_SAFE_OUTPUTS: /opt/gh-aw/safeoutputs/outputs.jsonl - GH_AW_SAFE_OUTPUTS_CONFIG_PATH: /opt/gh-aw/safeoutputs/config.json - GH_AW_SAFE_OUTPUTS_TOOLS_PATH: /opt/gh-aw/safeoutputs/tools.json GH_AW_WORKFLOW_ID_SANITIZED: documentconfigparams outputs: checkout_pr_success: ${{ steps.checkout-pr.outputs.checkout_pr_success || 'true' }} - has_patch: ${{ steps.collect_output.outputs.has_patch }} model: ${{ steps.generate_aw_info.outputs.model }} - output: ${{ steps.collect_output.outputs.output }} - output_types: ${{ steps.collect_output.outputs.output_types }} secret_verification_result: ${{ steps.validate-secret.outputs.verification_result }} steps: - name: Setup Scripts uses: github/gh-aw/actions/setup@9382be3ca9ac18917e111a99d4e6bbff58d0dccc # v0.43.23 with: destination: /opt/gh-aw/actions - - name: Checkout repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - persist-credentials: false - name: Create gh-aw temp directory run: bash /opt/gh-aw/actions/create_gh_aw_tmp_dir.sh - - env: - GH_TOKEN: ${{ secrets.PHPSTAN_BOT_TOKEN }} - name: Fetch config-reference.md from phpstan/phpstan - run: "mkdir -p website/src\ngh api repos/phpstan/phpstan/contents/website/src/config-reference.md -H \"Accept: application/vnd.github.raw+json\" > website/src/config-reference.md\ngit config user.name \"github-actions[bot]\"\ngit config user.email \"github-actions[bot]@users.noreply.github.com\"\ngit add website/src/config-reference.md\ngit commit -m \"Seed config-reference.md from phpstan/phpstan\"" + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + path: __phpstan-website + ref: 2.2.x + repository: phpstan/phpstan + token: ${{ secrets.PHPSTAN_BOT_TOKEN }} - name: Configure Git credentials env: @@ -124,9 +111,9 @@ jobs: github.event.pull_request uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 env: - GH_TOKEN: ${{ secrets.PHPSTAN_BOT_TOKEN }} + GH_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} with: - github-token: ${{ secrets.PHPSTAN_BOT_TOKEN }} + github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); setupGlobals(core, github, context, exec, io); @@ -203,230 +190,10 @@ jobs: const determineAutomaticLockdown = require('/opt/gh-aw/actions/determine_automatic_lockdown.cjs'); await determineAutomaticLockdown(github, context, core); - name: Download container images - run: bash /opt/gh-aw/actions/download_docker_images.sh ghcr.io/github/gh-aw-firewall/agent:0.17.0 ghcr.io/github/gh-aw-firewall/api-proxy:0.17.0 ghcr.io/github/gh-aw-firewall/squid:0.17.0 ghcr.io/github/gh-aw-mcpg:v0.1.4 ghcr.io/github/github-mcp-server:v0.30.3 node:lts-alpine - - name: Write Safe Outputs Config - run: | - mkdir -p /opt/gh-aw/safeoutputs - mkdir -p /tmp/gh-aw/safeoutputs - mkdir -p /tmp/gh-aw/mcp-logs/safeoutputs - cat > /opt/gh-aw/safeoutputs/config.json << 'GH_AW_SAFE_OUTPUTS_CONFIG_EOF' - {"create_pull_request":{},"missing_data":{},"missing_tool":{},"noop":{"max":1}} - GH_AW_SAFE_OUTPUTS_CONFIG_EOF - cat > /opt/gh-aw/safeoutputs/tools.json << 'GH_AW_SAFE_OUTPUTS_TOOLS_EOF' - [ - { - "description": "Create a new GitHub pull request to propose code changes. Use this after making file edits to submit them for review and merging. The PR will be created from the current branch with your committed changes. For code review comments on an existing PR, use create_pull_request_review_comment instead. CONSTRAINTS: Maximum 1 pull request(s) can be created. Title will be prefixed with \"[Docs] \". PRs will be created as drafts.", - "inputSchema": { - "additionalProperties": false, - "properties": { - "body": { - "description": "Detailed PR description in Markdown. Include what changes were made, why, testing notes, and any breaking changes. Do NOT repeat the title as a heading.", - "type": "string" - }, - "branch": { - "description": "Source branch name containing the changes. If omitted, uses the current working branch.", - "type": "string" - }, - "labels": { - "description": "Labels to categorize the PR (e.g., 'enhancement', 'bugfix'). Labels must exist in the repository.", - "items": { - "type": "string" - }, - "type": "array" - }, - "title": { - "description": "Concise PR title describing the changes. Follow repository conventions (e.g., conventional commits). The title appears as the main heading.", - "type": "string" - } - }, - "required": [ - "title", - "body" - ], - "type": "object" - }, - "name": "create_pull_request" - }, - { - "description": "Report that a tool or capability needed to complete the task is not available, or share any information you deem important about missing functionality or limitations. Use this when you cannot accomplish what was requested because the required functionality is missing or access is restricted.", - "inputSchema": { - "additionalProperties": false, - "properties": { - "alternatives": { - "description": "Any workarounds, manual steps, or alternative approaches the user could take (max 256 characters).", - "type": "string" - }, - "reason": { - "description": "Explanation of why this tool is needed or what information you want to share about the limitation (max 256 characters).", - "type": "string" - }, - "tool": { - "description": "Optional: Name or description of the missing tool or capability (max 128 characters). Be specific about what functionality is needed.", - "type": "string" - } - }, - "required": [ - "reason" - ], - "type": "object" - }, - "name": "missing_tool" - }, - { - "description": "Log a transparency message when no significant actions are needed. Use this to confirm workflow completion and provide visibility when analysis is complete but no changes or outputs are required (e.g., 'No issues found', 'All checks passed'). This ensures the workflow produces human-visible output even when no other actions are taken.", - "inputSchema": { - "additionalProperties": false, - "properties": { - "message": { - "description": "Status or completion message to log. Should explain what was analyzed and the outcome (e.g., 'Code review complete - no issues found', 'Analysis complete - all tests passing').", - "type": "string" - } - }, - "required": [ - "message" - ], - "type": "object" - }, - "name": "noop" - }, - { - "description": "Report that data or information needed to complete the task is not available. Use this when you cannot accomplish what was requested because required data, context, or information is missing.", - "inputSchema": { - "additionalProperties": false, - "properties": { - "alternatives": { - "description": "Any workarounds, manual steps, or alternative approaches the user could take (max 256 characters).", - "type": "string" - }, - "context": { - "description": "Additional context about the missing data or where it should come from (max 256 characters).", - "type": "string" - }, - "data_type": { - "description": "Type or description of the missing data or information (max 128 characters). Be specific about what data is needed.", - "type": "string" - }, - "reason": { - "description": "Explanation of why this data is needed to complete the task (max 256 characters).", - "type": "string" - } - }, - "required": [], - "type": "object" - }, - "name": "missing_data" - } - ] - GH_AW_SAFE_OUTPUTS_TOOLS_EOF - cat > /opt/gh-aw/safeoutputs/validation.json << 'GH_AW_SAFE_OUTPUTS_VALIDATION_EOF' - { - "create_pull_request": { - "defaultMax": 1, - "fields": { - "body": { - "required": true, - "type": "string", - "sanitize": true, - "maxLength": 65000 - }, - "branch": { - "required": true, - "type": "string", - "sanitize": true, - "maxLength": 256 - }, - "labels": { - "type": "array", - "itemType": "string", - "itemSanitize": true, - "itemMaxLength": 128 - }, - "title": { - "required": true, - "type": "string", - "sanitize": true, - "maxLength": 128 - } - } - }, - "missing_tool": { - "defaultMax": 20, - "fields": { - "alternatives": { - "type": "string", - "sanitize": true, - "maxLength": 512 - }, - "reason": { - "required": true, - "type": "string", - "sanitize": true, - "maxLength": 256 - }, - "tool": { - "type": "string", - "sanitize": true, - "maxLength": 128 - } - } - }, - "noop": { - "defaultMax": 1, - "fields": { - "message": { - "required": true, - "type": "string", - "sanitize": true, - "maxLength": 65000 - } - } - } - } - GH_AW_SAFE_OUTPUTS_VALIDATION_EOF - - name: Generate Safe Outputs MCP Server Config - id: safe-outputs-config - run: | - # Generate a secure random API key (360 bits of entropy, 40+ chars) - # Mask immediately to prevent timing vulnerabilities - API_KEY=$(openssl rand -base64 45 | tr -d '/+=') - echo "::add-mask::${API_KEY}" - - PORT=3001 - - # Set outputs for next steps - { - echo "safe_outputs_api_key=${API_KEY}" - echo "safe_outputs_port=${PORT}" - } >> "$GITHUB_OUTPUT" - - echo "Safe Outputs MCP server will run on port ${PORT}" - - - name: Start Safe Outputs MCP HTTP Server - id: safe-outputs-start - env: - DEBUG: '*' - GH_AW_SAFE_OUTPUTS_PORT: ${{ steps.safe-outputs-config.outputs.safe_outputs_port }} - GH_AW_SAFE_OUTPUTS_API_KEY: ${{ steps.safe-outputs-config.outputs.safe_outputs_api_key }} - GH_AW_SAFE_OUTPUTS_TOOLS_PATH: /opt/gh-aw/safeoutputs/tools.json - GH_AW_SAFE_OUTPUTS_CONFIG_PATH: /opt/gh-aw/safeoutputs/config.json - GH_AW_MCP_LOG_DIR: /tmp/gh-aw/mcp-logs/safeoutputs - run: | - # Environment variables are set above to prevent template injection - export DEBUG - export GH_AW_SAFE_OUTPUTS_PORT - export GH_AW_SAFE_OUTPUTS_API_KEY - export GH_AW_SAFE_OUTPUTS_TOOLS_PATH - export GH_AW_SAFE_OUTPUTS_CONFIG_PATH - export GH_AW_MCP_LOG_DIR - - bash /opt/gh-aw/actions/start_safe_outputs_server.sh - + run: bash /opt/gh-aw/actions/download_docker_images.sh ghcr.io/github/gh-aw-firewall/agent:0.17.0 ghcr.io/github/gh-aw-firewall/api-proxy:0.17.0 ghcr.io/github/gh-aw-firewall/squid:0.17.0 ghcr.io/github/gh-aw-mcpg:v0.1.4 ghcr.io/github/github-mcp-server:v0.30.3 - name: Start MCP gateway id: start-mcp-gateway env: - GH_AW_SAFE_OUTPUTS: ${{ env.GH_AW_SAFE_OUTPUTS }} - GH_AW_SAFE_OUTPUTS_API_KEY: ${{ steps.safe-outputs-start.outputs.api_key }} - GH_AW_SAFE_OUTPUTS_PORT: ${{ steps.safe-outputs-start.outputs.port }} GITHUB_MCP_LOCKDOWN: ${{ steps.determine-automatic-lockdown.outputs.lockdown == 'true' && '1' || '0' }} GITHUB_MCP_SERVER_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} run: | @@ -444,7 +211,7 @@ jobs: export DEBUG="*" export GH_AW_ENGINE="claude" - export MCP_GATEWAY_DOCKER_COMMAND='docker run -i --rm --network host -v /var/run/docker.sock:/var/run/docker.sock -e MCP_GATEWAY_PORT -e MCP_GATEWAY_DOMAIN -e MCP_GATEWAY_API_KEY -e MCP_GATEWAY_PAYLOAD_DIR -e DEBUG -e MCP_GATEWAY_LOG_DIR -e GH_AW_MCP_LOG_DIR -e GH_AW_SAFE_OUTPUTS -e GH_AW_SAFE_OUTPUTS_CONFIG_PATH -e GH_AW_SAFE_OUTPUTS_TOOLS_PATH -e GH_AW_ASSETS_BRANCH -e GH_AW_ASSETS_MAX_SIZE_KB -e GH_AW_ASSETS_ALLOWED_EXTS -e DEFAULT_BRANCH -e GITHUB_MCP_SERVER_TOKEN -e GITHUB_MCP_LOCKDOWN -e GITHUB_REPOSITORY -e GITHUB_SERVER_URL -e GITHUB_SHA -e GITHUB_WORKSPACE -e GITHUB_TOKEN -e GITHUB_RUN_ID -e GITHUB_RUN_NUMBER -e GITHUB_RUN_ATTEMPT -e GITHUB_JOB -e GITHUB_ACTION -e GITHUB_EVENT_NAME -e GITHUB_EVENT_PATH -e GITHUB_ACTOR -e GITHUB_ACTOR_ID -e GITHUB_TRIGGERING_ACTOR -e GITHUB_WORKFLOW -e GITHUB_WORKFLOW_REF -e GITHUB_WORKFLOW_SHA -e GITHUB_REF -e GITHUB_REF_NAME -e GITHUB_REF_TYPE -e GITHUB_HEAD_REF -e GITHUB_BASE_REF -e GH_AW_SAFE_OUTPUTS_PORT -e GH_AW_SAFE_OUTPUTS_API_KEY -v /tmp/gh-aw/mcp-payloads:/tmp/gh-aw/mcp-payloads:rw -v /opt:/opt:ro -v /tmp:/tmp:rw -v '"${GITHUB_WORKSPACE}"':'"${GITHUB_WORKSPACE}"':rw ghcr.io/github/gh-aw-mcpg:v0.1.4' + export MCP_GATEWAY_DOCKER_COMMAND='docker run -i --rm --network host -v /var/run/docker.sock:/var/run/docker.sock -e MCP_GATEWAY_PORT -e MCP_GATEWAY_DOMAIN -e MCP_GATEWAY_API_KEY -e MCP_GATEWAY_PAYLOAD_DIR -e DEBUG -e MCP_GATEWAY_LOG_DIR -e GH_AW_MCP_LOG_DIR -e GH_AW_SAFE_OUTPUTS -e GH_AW_SAFE_OUTPUTS_CONFIG_PATH -e GH_AW_SAFE_OUTPUTS_TOOLS_PATH -e GH_AW_ASSETS_BRANCH -e GH_AW_ASSETS_MAX_SIZE_KB -e GH_AW_ASSETS_ALLOWED_EXTS -e DEFAULT_BRANCH -e GITHUB_MCP_SERVER_TOKEN -e GITHUB_MCP_LOCKDOWN -e GITHUB_REPOSITORY -e GITHUB_SERVER_URL -e GITHUB_SHA -e GITHUB_WORKSPACE -e GITHUB_TOKEN -e GITHUB_RUN_ID -e GITHUB_RUN_NUMBER -e GITHUB_RUN_ATTEMPT -e GITHUB_JOB -e GITHUB_ACTION -e GITHUB_EVENT_NAME -e GITHUB_EVENT_PATH -e GITHUB_ACTOR -e GITHUB_ACTOR_ID -e GITHUB_TRIGGERING_ACTOR -e GITHUB_WORKFLOW -e GITHUB_WORKFLOW_REF -e GITHUB_WORKFLOW_SHA -e GITHUB_REF -e GITHUB_REF_NAME -e GITHUB_REF_TYPE -e GITHUB_HEAD_REF -e GITHUB_BASE_REF -v /tmp/gh-aw/mcp-payloads:/tmp/gh-aw/mcp-payloads:rw -v /opt:/opt:ro -v /tmp:/tmp:rw -v '"${GITHUB_WORKSPACE}"':'"${GITHUB_WORKSPACE}"':rw ghcr.io/github/gh-aw-mcpg:v0.1.4' cat << GH_AW_MCP_CONFIG_EOF | bash /opt/gh-aw/actions/start_mcp_gateway.sh { @@ -457,13 +224,6 @@ jobs: "GITHUB_READ_ONLY": "1", "GITHUB_TOOLSETS": "context,repos,issues,pull_requests" } - }, - "safeoutputs": { - "type": "http", - "url": "http://host.docker.internal:$GH_AW_SAFE_OUTPUTS_PORT", - "headers": { - "Authorization": "$GH_AW_SAFE_OUTPUTS_API_KEY" - } } }, "gateway": { @@ -483,7 +243,6 @@ jobs: - name: Create prompt with built-in context env: GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt - GH_AW_SAFE_OUTPUTS: ${{ env.GH_AW_SAFE_OUTPUTS }} GH_AW_GITHUB_ACTOR: ${{ github.actor }} GH_AW_GITHUB_EVENT_BEFORE: ${{ github.event.before }} GH_AW_GITHUB_EVENT_COMMENT_ID: ${{ github.event.comment.id }} @@ -502,34 +261,6 @@ jobs: cat "/opt/gh-aw/prompts/temp_folder_prompt.md" >> "$GH_AW_PROMPT" cat "/opt/gh-aw/prompts/markdown.md" >> "$GH_AW_PROMPT" cat << 'GH_AW_PROMPT_EOF' >> "$GH_AW_PROMPT" - - GitHub API Access Instructions - - The gh CLI is NOT authenticated. Do NOT use gh commands for GitHub operations. - - - To create or modify GitHub resources (issues, discussions, pull requests, etc.), you MUST call the appropriate safe output tool. Simply writing content will NOT work - the workflow requires actual tool calls. - - Temporary IDs: Some safe output tools support a temporary ID field (usually named temporary_id) so you can reference newly-created items elsewhere in the SAME agent output (for example, using #aw_abc1 in a later body). - - **IMPORTANT - temporary_id format rules:** - - If you DON'T need to reference the item later, OMIT the temporary_id field entirely (it will be auto-generated if needed) - - If you DO need cross-references/chaining, you MUST match this EXACT validation regex: /^aw_[A-Za-z0-9]{3,8}$/i - - Format: aw_ prefix followed by 3 to 8 alphanumeric characters (A-Z, a-z, 0-9, case-insensitive) - - Valid alphanumeric characters: ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789 - - INVALID examples: aw_ab (too short), aw_123456789 (too long), aw_test-id (contains hyphen), aw_id_123 (contains underscore) - - VALID examples: aw_abc, aw_abc1, aw_Test123, aw_A1B2C3D4, aw_12345678 - - To generate valid IDs: use 3-8 random alphanumeric characters or omit the field to let the system auto-generate - - Do NOT invent other aw_* formats — downstream steps will reject them with validation errors matching against /^aw_[A-Za-z0-9]{3,8}$/i. - - Discover available tools from the safeoutputs MCP server. - - **Critical**: Tool calls write structured data that downstream jobs process. Without tool calls, follow-up actions will be skipped. - - **Note**: If you made no other safe output tool calls during this workflow execution, call the "noop" tool to provide a status message indicating completion or that no actions were needed. - - The following GitHub context information is available for this workflow: {{#if __GH_AW_GITHUB_ACTOR__ }} @@ -703,7 +434,6 @@ jobs: DISABLE_TELEMETRY: 1 GH_AW_MCP_CONFIG: /tmp/gh-aw/mcp-config/mcp-servers.json GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt - GH_AW_SAFE_OUTPUTS: ${{ env.GH_AW_SAFE_OUTPUTS }} GITHUB_WORKSPACE: ${{ github.workspace }} MCP_TIMEOUT: 120000 MCP_TOOL_TIMEOUT: 60000 @@ -744,34 +474,6 @@ jobs: SECRET_GH_AW_GITHUB_TOKEN: ${{ secrets.GH_AW_GITHUB_TOKEN }} SECRET_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} SECRET_PHPSTAN_BOT_TOKEN: ${{ secrets.PHPSTAN_BOT_TOKEN }} - - name: Upload Safe Outputs - if: always() - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 - with: - name: safe-output - path: ${{ env.GH_AW_SAFE_OUTPUTS }} - if-no-files-found: warn - - name: Ingest agent output - id: collect_output - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - GH_AW_SAFE_OUTPUTS: ${{ env.GH_AW_SAFE_OUTPUTS }} - GH_AW_ALLOWED_DOMAINS: "*.githubusercontent.com,anthropic.com,api.anthropic.com,api.github.com,api.snapcraft.io,archive.ubuntu.com,azure.archive.ubuntu.com,cdn.playwright.dev,codeload.github.com,crl.geotrust.com,crl.globalsign.com,crl.identrust.com,crl.sectigo.com,crl.thawte.com,crl.usertrust.com,crl.verisign.com,crl3.digicert.com,crl4.digicert.com,crls.ssl.com,files.pythonhosted.org,ghcr.io,github-cloud.githubusercontent.com,github-cloud.s3.amazonaws.com,github.com,host.docker.internal,json-schema.org,json.schemastore.org,keyserver.ubuntu.com,lfs.github.com,objects.githubusercontent.com,ocsp.digicert.com,ocsp.geotrust.com,ocsp.globalsign.com,ocsp.identrust.com,ocsp.sectigo.com,ocsp.ssl.com,ocsp.thawte.com,ocsp.usertrust.com,ocsp.verisign.com,packagecloud.io,packages.cloud.google.com,packages.microsoft.com,playwright.download.prss.microsoft.com,ppa.launchpad.net,pypi.org,raw.githubusercontent.com,registry.npmjs.org,s.symcb.com,s.symcd.com,security.ubuntu.com,sentry.io,statsig.anthropic.com,ts-crl.ws.symantec.com,ts-ocsp.ws.symantec.com" - GITHUB_SERVER_URL: ${{ github.server_url }} - GITHUB_API_URL: ${{ github.api_url }} - with: - script: | - const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io); - const { main } = require('/opt/gh-aw/actions/collect_ndjson_output.cjs'); - await main(); - - name: Upload sanitized agent output - if: always() && env.GH_AW_AGENT_OUTPUT - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 - with: - name: agent-output - path: ${{ env.GH_AW_AGENT_OUTPUT }} - if-no-files-found: warn - name: Parse agent logs for step summary if: always() uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 @@ -815,247 +517,6 @@ jobs: /tmp/gh-aw/sandbox/firewall/logs/ /tmp/gh-aw/agent-stdio.log /tmp/gh-aw/agent/ - /tmp/gh-aw/aw.patch - if-no-files-found: ignore - - conclusion: - needs: - - activation - - agent - - detection - - safe_outputs - if: (always()) && (needs.agent.result != 'skipped') - runs-on: ubuntu-slim - permissions: - contents: write - issues: write - pull-requests: write - outputs: - noop_message: ${{ steps.noop.outputs.noop_message }} - tools_reported: ${{ steps.missing_tool.outputs.tools_reported }} - total_count: ${{ steps.missing_tool.outputs.total_count }} - steps: - - name: Setup Scripts - uses: github/gh-aw/actions/setup@9382be3ca9ac18917e111a99d4e6bbff58d0dccc # v0.43.23 - with: - destination: /opt/gh-aw/actions - - name: Download agent output artifact - continue-on-error: true - uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 - with: - name: agent-output - path: /tmp/gh-aw/safeoutputs/ - - name: Setup agent output environment variable - run: | - mkdir -p /tmp/gh-aw/safeoutputs/ - find "/tmp/gh-aw/safeoutputs/" -type f -print - echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" - - name: Process No-Op Messages - id: noop - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_NOOP_MAX: 1 - GH_AW_WORKFLOW_NAME: "Document Config Parameters" - with: - github-token: ${{ secrets.PHPSTAN_BOT_TOKEN }} - script: | - const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io); - const { main } = require('/opt/gh-aw/actions/noop.cjs'); - await main(); - - name: Record Missing Tool - id: missing_tool - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_WORKFLOW_NAME: "Document Config Parameters" - with: - github-token: ${{ secrets.PHPSTAN_BOT_TOKEN }} - script: | - const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io); - const { main } = require('/opt/gh-aw/actions/missing_tool.cjs'); - await main(); - - name: Handle Agent Failure - id: handle_agent_failure - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_WORKFLOW_NAME: "Document Config Parameters" - GH_AW_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} - GH_AW_AGENT_CONCLUSION: ${{ needs.agent.result }} - GH_AW_WORKFLOW_ID: "document-config-params" - GH_AW_SECRET_VERIFICATION_RESULT: ${{ needs.agent.outputs.secret_verification_result }} - GH_AW_CHECKOUT_PR_SUCCESS: ${{ needs.agent.outputs.checkout_pr_success }} - with: - github-token: ${{ secrets.PHPSTAN_BOT_TOKEN }} - script: | - const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io); - const { main } = require('/opt/gh-aw/actions/handle_agent_failure.cjs'); - await main(); - - name: Handle No-Op Message - id: handle_noop_message - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_WORKFLOW_NAME: "Document Config Parameters" - GH_AW_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} - GH_AW_AGENT_CONCLUSION: ${{ needs.agent.result }} - GH_AW_NOOP_MESSAGE: ${{ steps.noop.outputs.noop_message }} - GH_AW_NOOP_REPORT_AS_ISSUE: "true" - with: - github-token: ${{ secrets.PHPSTAN_BOT_TOKEN }} - script: | - const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io); - const { main } = require('/opt/gh-aw/actions/handle_noop_message.cjs'); - await main(); - - name: Handle Create Pull Request Error - id: handle_create_pr_error - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_WORKFLOW_NAME: "Document Config Parameters" - GH_AW_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} - with: - github-token: ${{ secrets.PHPSTAN_BOT_TOKEN }} - script: | - const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io); - const { main } = require('/opt/gh-aw/actions/handle_create_pr_error.cjs'); - await main(); - - name: Update reaction comment with completion status - id: conclusion - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_COMMENT_ID: ${{ needs.activation.outputs.comment_id }} - GH_AW_COMMENT_REPO: ${{ needs.activation.outputs.comment_repo }} - GH_AW_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} - GH_AW_WORKFLOW_NAME: "Document Config Parameters" - GH_AW_AGENT_CONCLUSION: ${{ needs.agent.result }} - GH_AW_DETECTION_CONCLUSION: ${{ needs.detection.result }} - with: - github-token: ${{ secrets.PHPSTAN_BOT_TOKEN }} - script: | - const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io); - const { main } = require('/opt/gh-aw/actions/notify_comment_error.cjs'); - await main(); - - detection: - needs: agent - if: needs.agent.outputs.output_types != '' || needs.agent.outputs.has_patch == 'true' - runs-on: ubuntu-latest - permissions: {} - timeout-minutes: 10 - outputs: - success: ${{ steps.parse_results.outputs.success }} - steps: - - name: Setup Scripts - uses: github/gh-aw/actions/setup@9382be3ca9ac18917e111a99d4e6bbff58d0dccc # v0.43.23 - with: - destination: /opt/gh-aw/actions - - name: Download agent artifacts - continue-on-error: true - uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 - with: - name: agent-artifacts - path: /tmp/gh-aw/threat-detection/ - - name: Download agent output artifact - continue-on-error: true - uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 - with: - name: agent-output - path: /tmp/gh-aw/threat-detection/ - - name: Echo agent output types - env: - AGENT_OUTPUT_TYPES: ${{ needs.agent.outputs.output_types }} - run: | - echo "Agent output-types: $AGENT_OUTPUT_TYPES" - - name: Setup threat detection - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - WORKFLOW_NAME: "Document Config Parameters" - WORKFLOW_DESCRIPTION: "Finds undocumented PHPStan config parameters and creates documentation PRs on phpstan/phpstan" - HAS_PATCH: ${{ needs.agent.outputs.has_patch }} - with: - script: | - const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io); - const { main } = require('/opt/gh-aw/actions/setup_threat_detection.cjs'); - await main(); - - name: Ensure threat-detection directory and log - run: | - mkdir -p /tmp/gh-aw/threat-detection - touch /tmp/gh-aw/threat-detection/detection.log - - name: Validate CLAUDE_CODE_OAUTH_TOKEN or ANTHROPIC_API_KEY secret - id: validate-secret - run: /opt/gh-aw/actions/validate_multi_secret.sh CLAUDE_CODE_OAUTH_TOKEN ANTHROPIC_API_KEY 'Claude Code' https://github.github.com/gh-aw/reference/engines/#anthropic-claude-code - env: - CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} - ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} - - name: Setup Node.js - uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 - with: - node-version: '24' - package-manager-cache: false - - name: Install Claude Code CLI - run: npm install -g --silent @anthropic-ai/claude-code@2.1.39 - - name: Execute Claude Code CLI - id: agentic_execution - # Allowed tools (sorted): - # - Bash(cat) - # - Bash(grep) - # - Bash(head) - # - Bash(jq) - # - Bash(ls) - # - Bash(tail) - # - Bash(wc) - # - BashOutput - # - ExitPlanMode - # - Glob - # - Grep - # - KillBash - # - LS - # - NotebookRead - # - Read - # - Task - # - TodoWrite - timeout-minutes: 20 - run: | - set -o pipefail - # Execute Claude Code CLI with prompt from file - claude --print --disable-slash-commands --no-chrome --model claude-opus-4-6 --allowed-tools 'Bash(cat),Bash(grep),Bash(head),Bash(jq),Bash(ls),Bash(tail),Bash(wc),BashOutput,ExitPlanMode,Glob,Grep,KillBash,LS,NotebookRead,Read,Task,TodoWrite' --debug-file /tmp/gh-aw/threat-detection/detection.log --verbose --permission-mode bypassPermissions --output-format stream-json "$(cat /tmp/gh-aw/aw-prompts/prompt.txt)" 2>&1 | tee -a /tmp/gh-aw/threat-detection/detection.log - env: - ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} - BASH_DEFAULT_TIMEOUT_MS: 60000 - BASH_MAX_TIMEOUT_MS: 60000 - CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} - DISABLE_BUG_COMMAND: 1 - DISABLE_ERROR_REPORTING: 1 - DISABLE_TELEMETRY: 1 - GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt - GITHUB_WORKSPACE: ${{ github.workspace }} - MCP_TIMEOUT: 120000 - MCP_TOOL_TIMEOUT: 60000 - - name: Parse threat detection results - id: parse_results - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - with: - script: | - const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io); - const { main } = require('/opt/gh-aw/actions/parse_threat_detection_results.cjs'); - await main(); - - name: Upload threat detection log - if: always() - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 - with: - name: threat-detection.log - path: /tmp/gh-aw/threat-detection/detection.log if-no-files-found: ignore pre_activation: @@ -1080,82 +541,3 @@ jobs: const { main } = require('/opt/gh-aw/actions/check_membership.cjs'); await main(); - safe_outputs: - needs: - - activation - - agent - - detection - if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (needs.detection.outputs.success == 'true') - runs-on: ubuntu-slim - permissions: - contents: write - issues: write - pull-requests: write - timeout-minutes: 15 - env: - GH_AW_ENGINE_ID: "claude" - GH_AW_ENGINE_MODEL: "claude-opus-4-6" - GH_AW_WORKFLOW_ID: "document-config-params" - GH_AW_WORKFLOW_NAME: "Document Config Parameters" - outputs: - create_discussion_error_count: ${{ steps.process_safe_outputs.outputs.create_discussion_error_count }} - create_discussion_errors: ${{ steps.process_safe_outputs.outputs.create_discussion_errors }} - process_safe_outputs_processed_count: ${{ steps.process_safe_outputs.outputs.processed_count }} - process_safe_outputs_temporary_id_map: ${{ steps.process_safe_outputs.outputs.temporary_id_map }} - steps: - - name: Setup Scripts - uses: github/gh-aw/actions/setup@9382be3ca9ac18917e111a99d4e6bbff58d0dccc # v0.43.23 - with: - destination: /opt/gh-aw/actions - - name: Download agent output artifact - continue-on-error: true - uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 - with: - name: agent-output - path: /tmp/gh-aw/safeoutputs/ - - name: Setup agent output environment variable - run: | - mkdir -p /tmp/gh-aw/safeoutputs/ - find "/tmp/gh-aw/safeoutputs/" -type f -print - echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" - - name: Download patch artifact - continue-on-error: true - uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 - with: - name: agent-artifacts - path: /tmp/gh-aw/ - - name: Checkout repository - if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'create_pull_request')) - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - repository: phpstan/phpstan - token: ${{ github.token }} - persist-credentials: false - fetch-depth: 1 - - name: Configure Git credentials - if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'create_pull_request')) - env: - REPO_NAME: "phpstan/phpstan" - SERVER_URL: ${{ github.server_url }} - GIT_TOKEN: ${{ github.token }} - run: | - git config --global user.email "github-actions[bot]@users.noreply.github.com" - git config --global user.name "github-actions[bot]" - # Re-authenticate git with GitHub token - SERVER_URL_STRIPPED="${SERVER_URL#https://}" - git remote set-url origin "https://x-access-token:${GIT_TOKEN}@${SERVER_URL_STRIPPED}/${REPO_NAME}.git" - echo "Git configured with standard GitHub Actions identity" - - name: Process Safe Outputs - id: process_safe_outputs - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG: "{\"create_pull_request\":{\"base_branch\":\"${{ github.ref_name }}\",\"draft\":true,\"fallback_as_issue\":true,\"max\":1,\"max_patch_size\":1024,\"target-repo\":\"phpstan/phpstan\",\"title_prefix\":\"[Docs] \"},\"missing_data\":{},\"missing_tool\":{}}" - with: - github-token: ${{ secrets.PHPSTAN_BOT_TOKEN }} - script: | - const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io); - const { main } = require('/opt/gh-aw/actions/safe_output_handler_manager.cjs'); - await main(); - diff --git a/.github/workflows/document-config-params.md b/.github/workflows/document-config-params.md index 69812f25ad9..56ba12ba6f4 100644 --- a/.github/workflows/document-config-params.md +++ b/.github/workflows/document-config-params.md @@ -19,25 +19,14 @@ tools: bash: ["*"] github: toolsets: [default, repos] -safe-outputs: - github-token: ${{ secrets.PHPSTAN_BOT_TOKEN }} - create-pull-request: - target-repo: phpstan/phpstan - title-prefix: "[Docs] " - draft: true - fallback-as-issue: true timeout-minutes: 30 steps: - - name: Fetch config-reference.md from phpstan/phpstan - env: - GH_TOKEN: ${{ secrets.PHPSTAN_BOT_TOKEN }} - run: | - mkdir -p website/src - gh api repos/phpstan/phpstan/contents/website/src/config-reference.md -H "Accept: application/vnd.github.raw+json" > website/src/config-reference.md - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - git add website/src/config-reference.md - git commit -m "Seed config-reference.md from phpstan/phpstan" + - uses: actions/checkout@v4 + with: + repository: phpstan/phpstan + ref: 2.2.x + path: __phpstan-website + token: ${{ secrets.PHPSTAN_BOT_TOKEN }} --- # Document Undocumented Config Parameters @@ -47,14 +36,15 @@ You are a documentation agent for PHPStan. Your job is to find configuration par ## Source files - **Parameter schema**: `conf/parametersSchema.neon` in this workspace (phpstan-src repo) -- **Config reference docs**: `website/src/config-reference.md` — already fetched from `phpstan/phpstan` into the workspace by a pre-step +- **Config reference docs**: `__phpstan-website/website/src/config-reference.md` (checked out from `phpstan/phpstan`) +- **Source code for research**: `src/`, `conf/`, and `tests/` directories in this workspace (phpstan-src repo) ## Task ### Step 1: Read both files 1. Read `conf/parametersSchema.neon` from the workspace -2. Read `website/src/config-reference.md` from the workspace (it was pre-fetched from the `phpstan/phpstan` repo) +2. Read `__phpstan-website/website/src/config-reference.md` from the workspace ### Step 2: Identify user-facing parameters from the schema @@ -109,7 +99,7 @@ If there are no undocumented parameters, stop and report that all parameters are ### Step 4: Research each undocumented parameter -For each undocumented parameter, investigate what it does: +For each undocumented parameter, investigate what it does by reading files from the workspace (phpstan-src): 1. **Search the source code** in `src/` for where the parameter is used. Look for the parameter name in PHP files — it will typically appear in a service constructor or be read from the DI container. 2. **Check level configs** in `conf/config.level*.neon` to see which level enables the parameter and what its default value is. @@ -119,7 +109,7 @@ For each undocumented parameter, investigate what it does: ### Step 5: Write documentation -Edit the existing `website/src/config-reference.md` file in the workspace to add the new documentation. Do NOT overwrite the file — use targeted edits to insert new parameter sections in the correct locations. +Edit the existing `__phpstan-website/website/src/config-reference.md` file to add the new documentation. Do NOT overwrite the file — use targeted edits to insert new parameter sections in the correct locations. **Place each parameter in the correct existing section:** - Boolean flags that enable stricter checks → "Stricter analysis" section (as `###` sub-headings) @@ -160,4 +150,17 @@ Description of what the parameter does. ### Step 6: Create a pull request -After editing the documentation file, create a pull request. The PR description should list which parameters were newly documented with a one-line summary of each. +After editing the documentation file, push the changes and create a PR on `phpstan/phpstan`: + +```bash +cd __phpstan-website +git config user.name "github-actions[bot]" +git config user.email "github-actions[bot]@users.noreply.github.com" +git checkout -b docs/undocumented-config-params +git add website/src/config-reference.md +git commit -m "Document undocumented configuration parameters" +git push origin docs/undocumented-config-params +gh pr create --repo phpstan/phpstan --base 2.2.x --draft --title "[Docs] Document undocumented config parameters" --body "PR DESCRIPTION HERE" +``` + +Replace `PR DESCRIPTION HERE` with a description listing which parameters were newly documented with a one-line summary of each. From da9ed281ec1dcd99a7ea95ff4a4a9944fd9f33e8 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Sat, 14 Feb 2026 12:46:46 +0100 Subject: [PATCH 009/158] Add main repo checkout so gh-aw git config step works The gh-aw framework runs git commands in the workspace root expecting a git repository. Without checking out phpstan-src first, the "Configure Git credentials" step fails with "not a git repository". Co-Authored-By: Claude Opus 4.6 --- .github/workflows/document-config-params.lock.yml | 3 ++- .github/workflows/document-config-params.md | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/document-config-params.lock.yml b/.github/workflows/document-config-params.lock.yml index f6959636751..0a6a7edbf21 100644 --- a/.github/workflows/document-config-params.lock.yml +++ b/.github/workflows/document-config-params.lock.yml @@ -23,7 +23,7 @@ # # Finds undocumented PHPStan config parameters and creates documentation PRs on phpstan/phpstan # -# frontmatter-hash: 8fc29ae470188f0f52dc79af2c2bba18a872a57ddfa4479eb52b07a7b642c978 +# frontmatter-hash: 31bb738b106c65eb8c5258fe6d0d61365db8ea6b0f389fc75193988252680777 name: "Document Config Parameters" "on": @@ -87,6 +87,7 @@ jobs: destination: /opt/gh-aw/actions - name: Create gh-aw temp directory run: bash /opt/gh-aw/actions/create_gh_aw_tmp_dir.sh + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: path: __phpstan-website diff --git a/.github/workflows/document-config-params.md b/.github/workflows/document-config-params.md index 56ba12ba6f4..4d300da6246 100644 --- a/.github/workflows/document-config-params.md +++ b/.github/workflows/document-config-params.md @@ -21,6 +21,7 @@ tools: toolsets: [default, repos] timeout-minutes: 30 steps: + - uses: actions/checkout@v4 - uses: actions/checkout@v4 with: repository: phpstan/phpstan From 5c6e070cc640224d43571bcc60da30f77e6bd805 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Sat, 14 Feb 2026 12:49:22 +0100 Subject: [PATCH 010/158] Correct username --- .github/workflows/document-config-params.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/document-config-params.md b/.github/workflows/document-config-params.md index 4d300da6246..36c626b1371 100644 --- a/.github/workflows/document-config-params.md +++ b/.github/workflows/document-config-params.md @@ -155,8 +155,8 @@ After editing the documentation file, push the changes and create a PR on `phpst ```bash cd __phpstan-website -git config user.name "github-actions[bot]" -git config user.email "github-actions[bot]@users.noreply.github.com" +git config user.name "phpstan-bot" +git config user.email "ondrej+phpstanbot@mirtes.cz" git checkout -b docs/undocumented-config-params git add website/src/config-reference.md git commit -m "Document undocumented configuration parameters" From 5abeb3cceedf31dc51dc49a3fd7851dbf36de957 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Sat, 14 Feb 2026 13:43:37 +0100 Subject: [PATCH 011/158] Instruct agent to extract nested parameters from schema The parametersSchema.neon has nested structure() blocks like exceptions.check.* and cache.*. The agent was only looking at top-level parameters and missing nested ones like throwTypeCovariance and tooWideImplicitThrowType. Co-Authored-By: Claude Opus 4.6 --- .github/workflows/document-config-params.md | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/.github/workflows/document-config-params.md b/.github/workflows/document-config-params.md index 36c626b1371..d1ad6b64f88 100644 --- a/.github/workflows/document-config-params.md +++ b/.github/workflows/document-config-params.md @@ -49,7 +49,23 @@ You are a documentation agent for PHPStan. Your job is to find configuration par ### Step 2: Identify user-facing parameters from the schema -Extract all parameter names from `parametersSchema.neon`. **Skip these entirely:** +Extract all parameter names from `parametersSchema.neon`. Note that some parameters are nested inside `structure()` blocks — these use dotted paths in the user's `phpstan.neon`. For example, the schema has: + +```neon +exceptions: structure([ + implicitThrows: bool(), + check: structure([ + missingCheckedExceptionInThrows: bool(), + tooWideThrowType: bool(), + throwTypeCovariance: bool(), + tooWideImplicitThrowType: bool() + ]) +]) +``` + +This means the user-facing parameters are `exceptions.implicitThrows`, `exceptions.check.missingCheckedExceptionInThrows`, `exceptions.check.tooWideThrowType`, etc. Similarly, `cache` has sub-keys like `cache.nodesByStringCountMax`. Make sure to extract ALL nested parameters, not just top-level ones. + +**Skip these entirely:** - The entire `featureToggles` section and all its sub-parameters - Everything after the `# playground mode` comment — these are internal/irrelevant: From b43a04be4732e2d63e2603ca3dbb1df98f1b8a7b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Mirtes?= Date: Mon, 16 Feb 2026 08:13:17 +0100 Subject: [PATCH 012/158] Fix Claude PR reactions workflow: don't cancel in-progress runs Co-authored-by: Claude --- .github/workflows/claude-pr-reactions.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/claude-pr-reactions.yml b/.github/workflows/claude-pr-reactions.yml index 5e687aefeb8..3550dbb5a87 100644 --- a/.github/workflows/claude-pr-reactions.yml +++ b/.github/workflows/claude-pr-reactions.yml @@ -16,7 +16,7 @@ permissions: concurrency: group: claude-pr-reactions-${{ github.event.pull_request.number || github.event.issue.number }} - cancel-in-progress: true + cancel-in-progress: false jobs: react: @@ -48,3 +48,5 @@ jobs: github_token: ${{ secrets.PHPSTAN_BOT_TOKEN }} trigger_phrase: "@phpstan-bot" claude_args: "--model claude-opus-4-6 --max-turns 50" + additional_permissions: | + actions: read From d145799599842924b57468660e9225b27db3ce70 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Mon, 16 Feb 2026 09:29:24 +0100 Subject: [PATCH 013/158] Improve Claude react workflow --- .../{claude-pr-reactions.yml => claude-react-on-comment.yml} | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) rename .github/workflows/{claude-pr-reactions.yml => claude-react-on-comment.yml} (95%) diff --git a/.github/workflows/claude-pr-reactions.yml b/.github/workflows/claude-react-on-comment.yml similarity index 95% rename from .github/workflows/claude-pr-reactions.yml rename to .github/workflows/claude-react-on-comment.yml index f0b86326377..d25beb45486 100644 --- a/.github/workflows/claude-pr-reactions.yml +++ b/.github/workflows/claude-react-on-comment.yml @@ -1,4 +1,4 @@ -name: "Claude PR Reactions" +name: "Claude React on comment" on: issue_comment: @@ -20,7 +20,7 @@ concurrency: jobs: react: - name: "React to PR feedback" + name: "React on comment" runs-on: blacksmith-4vcpu-ubuntu-2404 timeout-minutes: 60 From 7c6309cc113339081e3694f7c5a37e99ec0e8781 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Mon, 16 Feb 2026 09:35:17 +0100 Subject: [PATCH 014/158] Configure phpstan-bot git identity for Claude PR reactions workflow Co-Authored-By: Claude Opus 4.6 --- .github/workflows/claude-react-on-comment.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/claude-react-on-comment.yml b/.github/workflows/claude-react-on-comment.yml index d25beb45486..bf5a66674cc 100644 --- a/.github/workflows/claude-react-on-comment.yml +++ b/.github/workflows/claude-react-on-comment.yml @@ -41,6 +41,11 @@ jobs: - uses: "ramsey/composer-install@v3" + - name: "Configure git identity" + run: | + git config user.name "phpstan-bot" + git config user.email "ondrej+phpstanbot@mirtes.cz" + - name: "React to feedback" uses: anthropics/claude-code-action@v1 with: From c476b6006b1bc171be2ec396d1c659a04b208408 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 16 Feb 2026 09:06:18 +0000 Subject: [PATCH 015/158] Split claude react-on-comment into two jobs to avoid expensive setup Add a lightweight check-trigger job on ubuntu-latest that checks if the comment contains the @phpstan-bot trigger phrase. The expensive react job (setup-php, composer install on blacksmith runner) only runs when the trigger phrase is actually present. https://claude.ai/code/session_01T86RqyQoZyKWyG4MUnf7FC --- .github/workflows/claude-react-on-comment.yml | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/.github/workflows/claude-react-on-comment.yml b/.github/workflows/claude-react-on-comment.yml index bf5a66674cc..4de52b149e9 100644 --- a/.github/workflows/claude-react-on-comment.yml +++ b/.github/workflows/claude-react-on-comment.yml @@ -19,8 +19,28 @@ concurrency: cancel-in-progress: false jobs: + check-trigger: + name: "Check trigger phrase" + runs-on: ubuntu-latest + timeout-minutes: 1 + outputs: + triggered: ${{ steps.check.outputs.triggered }} + steps: + - name: "Check for trigger phrase" + id: check + env: + COMMENT_BODY: ${{ github.event.comment.body || github.event.review.body || '' }} + run: | + if echo "$COMMENT_BODY" | grep -qF "@phpstan-bot"; then + echo "triggered=true" >> "$GITHUB_OUTPUT" + else + echo "triggered=false" >> "$GITHUB_OUTPUT" + fi + react: name: "React on comment" + needs: check-trigger + if: needs.check-trigger.outputs.triggered == 'true' runs-on: blacksmith-4vcpu-ubuntu-2404 timeout-minutes: 60 From 394461fd540c6542574116fc8e0734af71c001af Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Mon, 16 Feb 2026 10:55:01 +0100 Subject: [PATCH 016/158] Fix Claude credentials to phpstan-bot --- .github/workflows/claude-react-on-comment.yml | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/.github/workflows/claude-react-on-comment.yml b/.github/workflows/claude-react-on-comment.yml index 4de52b149e9..09be31cd16f 100644 --- a/.github/workflows/claude-react-on-comment.yml +++ b/.github/workflows/claude-react-on-comment.yml @@ -61,11 +61,6 @@ jobs: - uses: "ramsey/composer-install@v3" - - name: "Configure git identity" - run: | - git config user.name "phpstan-bot" - git config user.email "ondrej+phpstanbot@mirtes.cz" - - name: "React to feedback" uses: anthropics/claude-code-action@v1 with: @@ -73,5 +68,7 @@ jobs: github_token: ${{ secrets.PHPSTAN_BOT_TOKEN }} trigger_phrase: "@phpstan-bot" claude_args: "--model claude-opus-4-6" + bot_name: "phpstan-bot" + bot_id: "79867460" additional_permissions: | actions: read From 72f877f55fd94907da984d4cd3605abbfc7b2b0d Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Mon, 16 Feb 2026 13:07:46 +0100 Subject: [PATCH 017/158] Add scheduled workflow to trigger Claude easy fixes nightly Runs claude-random-easy-fixes.yml with issue_count=5 every hour at :15 from 10pm to 7am CEST (20:15-05:15 UTC). Co-Authored-By: Claude Opus 4.6 --- .../claude-random-easy-fixes-scheduled.yml | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 .github/workflows/claude-random-easy-fixes-scheduled.yml diff --git a/.github/workflows/claude-random-easy-fixes-scheduled.yml b/.github/workflows/claude-random-easy-fixes-scheduled.yml new file mode 100644 index 00000000000..22650fb78d8 --- /dev/null +++ b/.github/workflows/claude-random-easy-fixes-scheduled.yml @@ -0,0 +1,15 @@ +name: "Claude Random Easy Fixes (Scheduled)" + +on: + schedule: + # Run 10 times, once an hour at :15, from 10pm CEST (20:00 UTC) to 7am CEST (05:00 UTC) + - cron: '15 20-23,0-5 * * *' + +jobs: + trigger: + runs-on: ubuntu-latest + steps: + - name: Trigger Claude Random Easy Fixes + env: + GH_TOKEN: ${{ secrets.PHPSTAN_BOT_TOKEN }} + run: gh workflow run claude-random-easy-fixes.yml -f issue_count=5 --repo ${{ github.repository }} From b4735ef309a1b29d80596e30f8443cc1fa1125ef Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Sat, 21 Feb 2026 06:20:39 +0000 Subject: [PATCH 018/158] Update aw --- .github/aw/actions-lock.json | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/aw/actions-lock.json b/.github/aw/actions-lock.json index fd519aa91be..3fb57cfc84b 100644 --- a/.github/aw/actions-lock.json +++ b/.github/aw/actions-lock.json @@ -1,8 +1,8 @@ { "entries": { - "actions/checkout@v4": { + "actions/checkout@v4.3.1": { "repo": "actions/checkout", - "version": "v4", + "version": "v4.3.1", "sha": "34e114876b0b11c390a56381ad16ebd13914f8d5" }, "actions/checkout@v6.0.2": { @@ -30,10 +30,10 @@ "version": "v6.0.0", "sha": "b7c566a772e6b6bfb58ed0dc250532a479d7789f" }, - "github/gh-aw/actions/setup@v0.43.23": { + "github/gh-aw/actions/setup@v0.48.1": { "repo": "github/gh-aw/actions/setup", - "version": "v0.43.23", - "sha": "9382be3ca9ac18917e111a99d4e6bbff58d0dccc" + "version": "v0.48.1", + "sha": "26b6572ae210580303087bc3142fe58d140bf65c" } } } From b15ff63d28b6e5803de83b21ba2bda54ad88061d Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Sat, 21 Feb 2026 06:23:43 +0000 Subject: [PATCH 019/158] Add document-phpdoc-tags workflow Adds a gh-aw workflow that compares PhpDocNodeResolver with phpdocs-basics.md to find undocumented PHPDoc tags and creates PRs on phpstan/phpstan. Co-Authored-By: Claude Opus 4.6 --- .github/aw/actions-lock.json | 5 + .../workflows/document-config-params.lock.yml | 281 +++++---- .../workflows/document-phpdoc-tags.lock.yml | 575 ++++++++++++++++++ .github/workflows/document-phpdoc-tags.md | 125 ++++ 4 files changed, 861 insertions(+), 125 deletions(-) create mode 100644 .github/workflows/document-phpdoc-tags.lock.yml create mode 100644 .github/workflows/document-phpdoc-tags.md diff --git a/.github/aw/actions-lock.json b/.github/aw/actions-lock.json index 3fb57cfc84b..5e9220a26b1 100644 --- a/.github/aw/actions-lock.json +++ b/.github/aw/actions-lock.json @@ -30,6 +30,11 @@ "version": "v6.0.0", "sha": "b7c566a772e6b6bfb58ed0dc250532a479d7789f" }, + "github/gh-aw/actions/setup@v0.45.4": { + "repo": "github/gh-aw/actions/setup", + "version": "v0.45.4", + "sha": "ac090214a48a1938f7abafe132460b66752261af" + }, "github/gh-aw/actions/setup@v0.48.1": { "repo": "github/gh-aw/actions/setup", "version": "v0.48.1", diff --git a/.github/workflows/document-config-params.lock.yml b/.github/workflows/document-config-params.lock.yml index 0a6a7edbf21..b5be8ab9735 100644 --- a/.github/workflows/document-config-params.lock.yml +++ b/.github/workflows/document-config-params.lock.yml @@ -13,7 +13,7 @@ # \ /\ / (_) | | | | ( | | | | (_) \ V V /\__ \ # \/ \/ \___/|_| |_|\_\|_| |_|\___/ \_/\_/ |___/ # -# This file was automatically generated by gh-aw (v0.43.23). DO NOT EDIT. +# This file was automatically generated by gh-aw (v0.45.4). DO NOT EDIT. # # To update this file, edit the corresponding .md file and run: # gh aw compile @@ -53,9 +53,17 @@ jobs: comment_repo: "" steps: - name: Setup Scripts - uses: github/gh-aw/actions/setup@9382be3ca9ac18917e111a99d4e6bbff58d0dccc # v0.43.23 + uses: github/gh-aw/actions/setup@ac090214a48a1938f7abafe132460b66752261af # v0.45.4 with: destination: /opt/gh-aw/actions + - name: Checkout .github and .agents folders + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + sparse-checkout: | + .github + .agents + fetch-depth: 1 + persist-credentials: false - name: Check workflow file timestamps uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 env: @@ -66,6 +74,127 @@ jobs: setupGlobals(core, github, context, exec, io); const { main } = require('/opt/gh-aw/actions/check_workflow_timestamp_api.cjs'); await main(); + - name: Create prompt with built-in context + env: + GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt + GH_AW_GITHUB_ACTOR: ${{ github.actor }} + GH_AW_GITHUB_EVENT_BEFORE: ${{ github.event.before }} + GH_AW_GITHUB_EVENT_COMMENT_ID: ${{ github.event.comment.id }} + GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER: ${{ github.event.discussion.number }} + GH_AW_GITHUB_EVENT_ISSUE_NUMBER: ${{ github.event.issue.number }} + GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER: ${{ github.event.pull_request.number }} + GH_AW_GITHUB_REPOSITORY: ${{ github.repository }} + GH_AW_GITHUB_RUN_ID: ${{ github.run_id }} + GH_AW_GITHUB_WORKSPACE: ${{ github.workspace }} + run: | + bash /opt/gh-aw/actions/create_prompt_first.sh + cat << 'GH_AW_PROMPT_EOF' > "$GH_AW_PROMPT" + + GH_AW_PROMPT_EOF + cat "/opt/gh-aw/prompts/xpia.md" >> "$GH_AW_PROMPT" + cat "/opt/gh-aw/prompts/temp_folder_prompt.md" >> "$GH_AW_PROMPT" + cat "/opt/gh-aw/prompts/markdown.md" >> "$GH_AW_PROMPT" + cat << 'GH_AW_PROMPT_EOF' >> "$GH_AW_PROMPT" + + The following GitHub context information is available for this workflow: + {{#if __GH_AW_GITHUB_ACTOR__ }} + - **actor**: __GH_AW_GITHUB_ACTOR__ + {{/if}} + {{#if __GH_AW_GITHUB_REPOSITORY__ }} + - **repository**: __GH_AW_GITHUB_REPOSITORY__ + {{/if}} + {{#if __GH_AW_GITHUB_WORKSPACE__ }} + - **workspace**: __GH_AW_GITHUB_WORKSPACE__ + {{/if}} + {{#if __GH_AW_GITHUB_EVENT_ISSUE_NUMBER__ }} + - **issue-number**: #__GH_AW_GITHUB_EVENT_ISSUE_NUMBER__ + {{/if}} + {{#if __GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER__ }} + - **discussion-number**: #__GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER__ + {{/if}} + {{#if __GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER__ }} + - **pull-request-number**: #__GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER__ + {{/if}} + {{#if __GH_AW_GITHUB_EVENT_COMMENT_ID__ }} + - **comment-id**: __GH_AW_GITHUB_EVENT_COMMENT_ID__ + {{/if}} + {{#if __GH_AW_GITHUB_RUN_ID__ }} + - **workflow-run-id**: __GH_AW_GITHUB_RUN_ID__ + {{/if}} + + + GH_AW_PROMPT_EOF + cat << 'GH_AW_PROMPT_EOF' >> "$GH_AW_PROMPT" + + GH_AW_PROMPT_EOF + cat << 'GH_AW_PROMPT_EOF' >> "$GH_AW_PROMPT" + {{#runtime-import .github/workflows/document-config-params.md}} + GH_AW_PROMPT_EOF + - name: Interpolate variables and render templates + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt + GH_AW_GITHUB_EVENT_BEFORE: ${{ github.event.before }} + with: + script: | + const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('/opt/gh-aw/actions/interpolate_prompt.cjs'); + await main(); + - name: Substitute placeholders + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt + GH_AW_GITHUB_ACTOR: ${{ github.actor }} + GH_AW_GITHUB_EVENT_BEFORE: ${{ github.event.before }} + GH_AW_GITHUB_EVENT_COMMENT_ID: ${{ github.event.comment.id }} + GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER: ${{ github.event.discussion.number }} + GH_AW_GITHUB_EVENT_ISSUE_NUMBER: ${{ github.event.issue.number }} + GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER: ${{ github.event.pull_request.number }} + GH_AW_GITHUB_REPOSITORY: ${{ github.repository }} + GH_AW_GITHUB_RUN_ID: ${{ github.run_id }} + GH_AW_GITHUB_WORKSPACE: ${{ github.workspace }} + GH_AW_NEEDS_PRE_ACTIVATION_OUTPUTS_ACTIVATED: ${{ needs.pre_activation.outputs.activated }} + GH_AW_NEEDS_PRE_ACTIVATION_OUTPUTS_MATCHED_COMMAND: ${{ needs.pre_activation.outputs.matched_command }} + with: + script: | + const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + + const substitutePlaceholders = require('/opt/gh-aw/actions/substitute_placeholders.cjs'); + + // Call the substitution function + return await substitutePlaceholders({ + file: process.env.GH_AW_PROMPT, + substitutions: { + GH_AW_GITHUB_ACTOR: process.env.GH_AW_GITHUB_ACTOR, + GH_AW_GITHUB_EVENT_BEFORE: process.env.GH_AW_GITHUB_EVENT_BEFORE, + GH_AW_GITHUB_EVENT_COMMENT_ID: process.env.GH_AW_GITHUB_EVENT_COMMENT_ID, + GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER: process.env.GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER, + GH_AW_GITHUB_EVENT_ISSUE_NUMBER: process.env.GH_AW_GITHUB_EVENT_ISSUE_NUMBER, + GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER: process.env.GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER, + GH_AW_GITHUB_REPOSITORY: process.env.GH_AW_GITHUB_REPOSITORY, + GH_AW_GITHUB_RUN_ID: process.env.GH_AW_GITHUB_RUN_ID, + GH_AW_GITHUB_WORKSPACE: process.env.GH_AW_GITHUB_WORKSPACE, + GH_AW_NEEDS_PRE_ACTIVATION_OUTPUTS_ACTIVATED: process.env.GH_AW_NEEDS_PRE_ACTIVATION_OUTPUTS_ACTIVATED, + GH_AW_NEEDS_PRE_ACTIVATION_OUTPUTS_MATCHED_COMMAND: process.env.GH_AW_NEEDS_PRE_ACTIVATION_OUTPUTS_MATCHED_COMMAND + } + }); + - name: Validate prompt placeholders + env: + GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt + run: bash /opt/gh-aw/actions/validate_prompt_placeholders.sh + - name: Print prompt + env: + GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt + run: bash /opt/gh-aw/actions/print_prompt_summary.sh + - name: Upload prompt artifact + if: success() + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 + with: + name: prompt + path: /tmp/gh-aw/aw-prompts/prompt.txt + retention-days: 1 agent: needs: activation @@ -82,7 +211,7 @@ jobs: secret_verification_result: ${{ steps.validate-secret.outputs.verification_result }} steps: - name: Setup Scripts - uses: github/gh-aw/actions/setup@9382be3ca9ac18917e111a99d4e6bbff58d0dccc # v0.43.23 + uses: github/gh-aw/actions/setup@ac090214a48a1938f7abafe132460b66752261af # v0.45.4 with: destination: /opt/gh-aw/actions - name: Create gh-aw temp directory @@ -132,12 +261,11 @@ jobs: engine_name: "Claude Code", model: "claude-opus-4-6", version: "", - agent_version: "2.1.39", - cli_version: "v0.43.23", + agent_version: "2.1.42", + cli_version: "v0.45.4", workflow_name: "Document Config Parameters", experimental: false, supports_tools_allowlist: true, - supports_http_transport: true, run_id: context.runId, run_number: context.runNumber, run_attempt: process.env.GITHUB_RUN_ATTEMPT, @@ -149,8 +277,8 @@ jobs: staged: false, allowed_domains: ["defaults"], firewall_enabled: true, - awf_version: "v0.17.0", - awmg_version: "", + awf_version: "v0.19.1", + awmg_version: "v0.1.4", steps: { firewall: "squid" }, @@ -177,10 +305,10 @@ jobs: node-version: '24' package-manager-cache: false - name: Install awf binary - run: bash /opt/gh-aw/actions/install_awf_binary.sh v0.17.0 + run: bash /opt/gh-aw/actions/install_awf_binary.sh v0.19.1 - name: Install Claude Code CLI - run: npm install -g --silent @anthropic-ai/claude-code@2.1.39 - - name: Determine automatic lockdown mode for GitHub MCP server + run: npm install -g --silent @anthropic-ai/claude-code@2.1.42 + - name: Determine automatic lockdown mode for GitHub MCP Server id: determine-automatic-lockdown uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 env: @@ -191,8 +319,8 @@ jobs: const determineAutomaticLockdown = require('/opt/gh-aw/actions/determine_automatic_lockdown.cjs'); await determineAutomaticLockdown(github, context, core); - name: Download container images - run: bash /opt/gh-aw/actions/download_docker_images.sh ghcr.io/github/gh-aw-firewall/agent:0.17.0 ghcr.io/github/gh-aw-firewall/api-proxy:0.17.0 ghcr.io/github/gh-aw-firewall/squid:0.17.0 ghcr.io/github/gh-aw-mcpg:v0.1.4 ghcr.io/github/github-mcp-server:v0.30.3 - - name: Start MCP gateway + run: bash /opt/gh-aw/actions/download_docker_images.sh ghcr.io/github/gh-aw-firewall/agent:0.19.1 ghcr.io/github/gh-aw-firewall/api-proxy:0.19.1 ghcr.io/github/gh-aw-firewall/squid:0.19.1 ghcr.io/github/gh-aw-mcpg:v0.1.4 ghcr.io/github/github-mcp-server:v0.30.3 + - name: Start MCP Gateway id: start-mcp-gateway env: GITHUB_MCP_LOCKDOWN: ${{ steps.determine-automatic-lockdown.outputs.lockdown == 'true' && '1' || '0' }} @@ -241,113 +369,11 @@ jobs: script: | const { generateWorkflowOverview } = require('/opt/gh-aw/actions/generate_workflow_overview.cjs'); await generateWorkflowOverview(core); - - name: Create prompt with built-in context - env: - GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt - GH_AW_GITHUB_ACTOR: ${{ github.actor }} - GH_AW_GITHUB_EVENT_BEFORE: ${{ github.event.before }} - GH_AW_GITHUB_EVENT_COMMENT_ID: ${{ github.event.comment.id }} - GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER: ${{ github.event.discussion.number }} - GH_AW_GITHUB_EVENT_ISSUE_NUMBER: ${{ github.event.issue.number }} - GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER: ${{ github.event.pull_request.number }} - GH_AW_GITHUB_REPOSITORY: ${{ github.repository }} - GH_AW_GITHUB_RUN_ID: ${{ github.run_id }} - GH_AW_GITHUB_WORKSPACE: ${{ github.workspace }} - run: | - bash /opt/gh-aw/actions/create_prompt_first.sh - cat << 'GH_AW_PROMPT_EOF' > "$GH_AW_PROMPT" - - GH_AW_PROMPT_EOF - cat "/opt/gh-aw/prompts/xpia.md" >> "$GH_AW_PROMPT" - cat "/opt/gh-aw/prompts/temp_folder_prompt.md" >> "$GH_AW_PROMPT" - cat "/opt/gh-aw/prompts/markdown.md" >> "$GH_AW_PROMPT" - cat << 'GH_AW_PROMPT_EOF' >> "$GH_AW_PROMPT" - - The following GitHub context information is available for this workflow: - {{#if __GH_AW_GITHUB_ACTOR__ }} - - **actor**: __GH_AW_GITHUB_ACTOR__ - {{/if}} - {{#if __GH_AW_GITHUB_REPOSITORY__ }} - - **repository**: __GH_AW_GITHUB_REPOSITORY__ - {{/if}} - {{#if __GH_AW_GITHUB_WORKSPACE__ }} - - **workspace**: __GH_AW_GITHUB_WORKSPACE__ - {{/if}} - {{#if __GH_AW_GITHUB_EVENT_ISSUE_NUMBER__ }} - - **issue-number**: #__GH_AW_GITHUB_EVENT_ISSUE_NUMBER__ - {{/if}} - {{#if __GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER__ }} - - **discussion-number**: #__GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER__ - {{/if}} - {{#if __GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER__ }} - - **pull-request-number**: #__GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER__ - {{/if}} - {{#if __GH_AW_GITHUB_EVENT_COMMENT_ID__ }} - - **comment-id**: __GH_AW_GITHUB_EVENT_COMMENT_ID__ - {{/if}} - {{#if __GH_AW_GITHUB_RUN_ID__ }} - - **workflow-run-id**: __GH_AW_GITHUB_RUN_ID__ - {{/if}} - - - GH_AW_PROMPT_EOF - cat << 'GH_AW_PROMPT_EOF' >> "$GH_AW_PROMPT" - - GH_AW_PROMPT_EOF - cat << 'GH_AW_PROMPT_EOF' >> "$GH_AW_PROMPT" - {{#runtime-import .github/workflows/document-config-params.md}} - GH_AW_PROMPT_EOF - - name: Substitute placeholders - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt - GH_AW_GITHUB_ACTOR: ${{ github.actor }} - GH_AW_GITHUB_EVENT_BEFORE: ${{ github.event.before }} - GH_AW_GITHUB_EVENT_COMMENT_ID: ${{ github.event.comment.id }} - GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER: ${{ github.event.discussion.number }} - GH_AW_GITHUB_EVENT_ISSUE_NUMBER: ${{ github.event.issue.number }} - GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER: ${{ github.event.pull_request.number }} - GH_AW_GITHUB_REPOSITORY: ${{ github.repository }} - GH_AW_GITHUB_RUN_ID: ${{ github.run_id }} - GH_AW_GITHUB_WORKSPACE: ${{ github.workspace }} - with: - script: | - const substitutePlaceholders = require('/opt/gh-aw/actions/substitute_placeholders.cjs'); - - // Call the substitution function - return await substitutePlaceholders({ - file: process.env.GH_AW_PROMPT, - substitutions: { - GH_AW_GITHUB_ACTOR: process.env.GH_AW_GITHUB_ACTOR, - GH_AW_GITHUB_EVENT_BEFORE: process.env.GH_AW_GITHUB_EVENT_BEFORE, - GH_AW_GITHUB_EVENT_COMMENT_ID: process.env.GH_AW_GITHUB_EVENT_COMMENT_ID, - GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER: process.env.GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER, - GH_AW_GITHUB_EVENT_ISSUE_NUMBER: process.env.GH_AW_GITHUB_EVENT_ISSUE_NUMBER, - GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER: process.env.GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER, - GH_AW_GITHUB_REPOSITORY: process.env.GH_AW_GITHUB_REPOSITORY, - GH_AW_GITHUB_RUN_ID: process.env.GH_AW_GITHUB_RUN_ID, - GH_AW_GITHUB_WORKSPACE: process.env.GH_AW_GITHUB_WORKSPACE - } - }); - - name: Interpolate variables and render templates - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt - GH_AW_GITHUB_EVENT_BEFORE: ${{ github.event.before }} + - name: Download prompt artifact + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 with: - script: | - const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io); - const { main } = require('/opt/gh-aw/actions/interpolate_prompt.cjs'); - await main(); - - name: Validate prompt placeholders - env: - GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt - run: bash /opt/gh-aw/actions/validate_prompt_placeholders.sh - - name: Print prompt - env: - GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt - run: bash /opt/gh-aw/actions/print_prompt_summary.sh + name: prompt + path: /tmp/gh-aw/aw-prompts - name: Clean git credentials run: bash /opt/gh-aw/actions/clean_git_credentials.sh - name: Execute Claude Code CLI @@ -423,7 +449,7 @@ jobs: timeout-minutes: 30 run: | set -o pipefail - sudo -E awf --tty --env-all --container-workdir "${GITHUB_WORKSPACE}" --allow-domains '*.githubusercontent.com,anthropic.com,api.anthropic.com,api.github.com,api.snapcraft.io,archive.ubuntu.com,azure.archive.ubuntu.com,cdn.playwright.dev,codeload.github.com,crl.geotrust.com,crl.globalsign.com,crl.identrust.com,crl.sectigo.com,crl.thawte.com,crl.usertrust.com,crl.verisign.com,crl3.digicert.com,crl4.digicert.com,crls.ssl.com,files.pythonhosted.org,ghcr.io,github-cloud.githubusercontent.com,github-cloud.s3.amazonaws.com,github.com,host.docker.internal,json-schema.org,json.schemastore.org,keyserver.ubuntu.com,lfs.github.com,objects.githubusercontent.com,ocsp.digicert.com,ocsp.geotrust.com,ocsp.globalsign.com,ocsp.identrust.com,ocsp.sectigo.com,ocsp.ssl.com,ocsp.thawte.com,ocsp.usertrust.com,ocsp.verisign.com,packagecloud.io,packages.cloud.google.com,packages.microsoft.com,playwright.download.prss.microsoft.com,ppa.launchpad.net,pypi.org,raw.githubusercontent.com,registry.npmjs.org,s.symcb.com,s.symcd.com,security.ubuntu.com,sentry.io,statsig.anthropic.com,ts-crl.ws.symantec.com,ts-ocsp.ws.symantec.com' --log-level info --proxy-logs-dir /tmp/gh-aw/sandbox/firewall/logs --enable-host-access --image-tag 0.17.0 --skip-pull --enable-api-proxy \ + sudo -E awf --tty --env-all --container-workdir "${GITHUB_WORKSPACE}" --allow-domains '*.githubusercontent.com,anthropic.com,api.anthropic.com,api.github.com,api.snapcraft.io,archive.ubuntu.com,azure.archive.ubuntu.com,cdn.playwright.dev,codeload.github.com,crl.geotrust.com,crl.globalsign.com,crl.identrust.com,crl.sectigo.com,crl.thawte.com,crl.usertrust.com,crl.verisign.com,crl3.digicert.com,crl4.digicert.com,crls.ssl.com,files.pythonhosted.org,ghcr.io,github-cloud.githubusercontent.com,github-cloud.s3.amazonaws.com,github.com,host.docker.internal,json-schema.org,json.schemastore.org,keyserver.ubuntu.com,lfs.github.com,objects.githubusercontent.com,ocsp.digicert.com,ocsp.geotrust.com,ocsp.globalsign.com,ocsp.identrust.com,ocsp.sectigo.com,ocsp.ssl.com,ocsp.thawte.com,ocsp.usertrust.com,ocsp.verisign.com,packagecloud.io,packages.cloud.google.com,packages.microsoft.com,playwright.download.prss.microsoft.com,ppa.launchpad.net,pypi.org,raw.githubusercontent.com,registry.npmjs.org,s.symcb.com,s.symcd.com,security.ubuntu.com,sentry.io,statsig.anthropic.com,ts-crl.ws.symantec.com,ts-ocsp.ws.symantec.com' --log-level info --proxy-logs-dir /tmp/gh-aw/sandbox/firewall/logs --enable-host-access --image-tag 0.19.1 --skip-pull --enable-api-proxy \ -- /bin/bash -c 'export PATH="$(find /opt/hostedtoolcache -maxdepth 4 -type d -name bin 2>/dev/null | tr '\''\n'\'' '\'':'\'')$PATH"; [ -n "$GOROOT" ] && export PATH="$GOROOT/bin:$PATH" || true && claude --print --disable-slash-commands --no-chrome --model claude-opus-4-6 --mcp-config /tmp/gh-aw/mcp-config/mcp-servers.json --allowed-tools Bash,BashOutput,Edit,ExitPlanMode,Glob,Grep,KillBash,LS,MultiEdit,NotebookEdit,NotebookRead,Read,Task,TodoWrite,Write,mcp__github__download_workflow_run_artifact,mcp__github__get_code_scanning_alert,mcp__github__get_commit,mcp__github__get_dependabot_alert,mcp__github__get_discussion,mcp__github__get_discussion_comments,mcp__github__get_file_contents,mcp__github__get_job_logs,mcp__github__get_label,mcp__github__get_latest_release,mcp__github__get_me,mcp__github__get_notification_details,mcp__github__get_pull_request,mcp__github__get_pull_request_comments,mcp__github__get_pull_request_diff,mcp__github__get_pull_request_files,mcp__github__get_pull_request_review_comments,mcp__github__get_pull_request_reviews,mcp__github__get_pull_request_status,mcp__github__get_release_by_tag,mcp__github__get_secret_scanning_alert,mcp__github__get_tag,mcp__github__get_workflow_run,mcp__github__get_workflow_run_logs,mcp__github__get_workflow_run_usage,mcp__github__issue_read,mcp__github__list_branches,mcp__github__list_code_scanning_alerts,mcp__github__list_commits,mcp__github__list_dependabot_alerts,mcp__github__list_discussion_categories,mcp__github__list_discussions,mcp__github__list_issue_types,mcp__github__list_issues,mcp__github__list_label,mcp__github__list_notifications,mcp__github__list_pull_requests,mcp__github__list_releases,mcp__github__list_secret_scanning_alerts,mcp__github__list_starred_repositories,mcp__github__list_tags,mcp__github__list_workflow_jobs,mcp__github__list_workflow_run_artifacts,mcp__github__list_workflow_runs,mcp__github__list_workflows,mcp__github__pull_request_read,mcp__github__search_code,mcp__github__search_issues,mcp__github__search_orgs,mcp__github__search_pull_requests,mcp__github__search_repositories,mcp__github__search_users --debug-file /tmp/gh-aw/agent-stdio.log --verbose --permission-mode bypassPermissions --output-format stream-json "$(cat /tmp/gh-aw/aw-prompts/prompt.txt)"' 2>&1 | tee -a /tmp/gh-aw/agent-stdio.log env: ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} @@ -449,7 +475,7 @@ jobs: SERVER_URL_STRIPPED="${SERVER_URL#https://}" git remote set-url origin "https://x-access-token:${{ github.token }}@${SERVER_URL_STRIPPED}/${REPO_NAME}.git" echo "Git configured with standard GitHub Actions identity" - - name: Stop MCP gateway + - name: Stop MCP Gateway if: always() continue-on-error: true env: @@ -486,7 +512,7 @@ jobs: setupGlobals(core, github, context, exec, io); const { main } = require('/opt/gh-aw/actions/parse_claude_log.cjs'); await main(); - - name: Parse MCP gateway logs for step summary + - name: Parse MCP Gateway logs for step summary if: always() uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 with: @@ -504,11 +530,16 @@ jobs: # Fix permissions on firewall logs so they can be uploaded as artifacts # AWF runs with sudo, creating files owned by root sudo chmod -R a+r /tmp/gh-aw/sandbox/firewall/logs 2>/dev/null || true - awf logs summary | tee -a "$GITHUB_STEP_SUMMARY" + # Only run awf logs summary if awf command exists (it may not be installed if workflow failed before install step) + if command -v awf &> /dev/null; then + awf logs summary | tee -a "$GITHUB_STEP_SUMMARY" + else + echo 'AWF binary not installed, skipping firewall log summary' + fi - name: Upload agent artifacts if: always() continue-on-error: true - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 with: name: agent-artifacts path: | @@ -526,7 +557,7 @@ jobs: activated: ${{ steps.check_membership.outputs.is_team_member == 'true' }} steps: - name: Setup Scripts - uses: github/gh-aw/actions/setup@9382be3ca9ac18917e111a99d4e6bbff58d0dccc # v0.43.23 + uses: github/gh-aw/actions/setup@ac090214a48a1938f7abafe132460b66752261af # v0.45.4 with: destination: /opt/gh-aw/actions - name: Check team membership for workflow diff --git a/.github/workflows/document-phpdoc-tags.lock.yml b/.github/workflows/document-phpdoc-tags.lock.yml new file mode 100644 index 00000000000..f3a6263460e --- /dev/null +++ b/.github/workflows/document-phpdoc-tags.lock.yml @@ -0,0 +1,575 @@ +# +# ___ _ _ +# / _ \ | | (_) +# | |_| | __ _ ___ _ __ | |_ _ ___ +# | _ |/ _` |/ _ \ '_ \| __| |/ __| +# | | | | (_| | __/ | | | |_| | (__ +# \_| |_/\__, |\___|_| |_|\__|_|\___| +# __/ | +# _ _ |___/ +# | | | | / _| | +# | | | | ___ _ __ _ __| |_| | _____ ____ +# | |/\| |/ _ \ '__| |/ /| _| |/ _ \ \ /\ / / ___| +# \ /\ / (_) | | | | ( | | | | (_) \ V V /\__ \ +# \/ \/ \___/|_| |_|\_\|_| |_|\___/ \_/\_/ |___/ +# +# This file was automatically generated by gh-aw (v0.45.4). DO NOT EDIT. +# +# To update this file, edit the corresponding .md file and run: +# gh aw compile +# Not all edits will cause changes to this file. +# +# For more information: https://github.github.com/gh-aw/introduction/overview/ +# +# Finds undocumented PHPDoc tags supported by PHPStan and creates documentation PRs on phpstan/phpstan +# +# frontmatter-hash: 4b00de08f40349ad6705433b25b1eda1b6286fca149203f6d4ddf87f4279b376 + +name: "Document PHPDoc Tags" +"on": + push: + branches: + - 2.2.x + paths: + - src/PhpDoc/PhpDocNodeResolver.php + workflow_dispatch: + +permissions: {} + +concurrency: + group: "gh-aw-${{ github.workflow }}-${{ github.ref }}" + +run-name: "Document PHPDoc Tags" + +jobs: + activation: + needs: pre_activation + if: needs.pre_activation.outputs.activated == 'true' + runs-on: ubuntu-slim + permissions: + contents: read + outputs: + comment_id: "" + comment_repo: "" + steps: + - name: Setup Scripts + uses: github/gh-aw/actions/setup@ac090214a48a1938f7abafe132460b66752261af # v0.45.4 + with: + destination: /opt/gh-aw/actions + - name: Checkout .github and .agents folders + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + sparse-checkout: | + .github + .agents + fetch-depth: 1 + persist-credentials: false + - name: Check workflow file timestamps + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + GH_AW_WORKFLOW_FILE: "document-phpdoc-tags.lock.yml" + with: + script: | + const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('/opt/gh-aw/actions/check_workflow_timestamp_api.cjs'); + await main(); + - name: Create prompt with built-in context + env: + GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt + GH_AW_GITHUB_ACTOR: ${{ github.actor }} + GH_AW_GITHUB_EVENT_BEFORE: ${{ github.event.before }} + GH_AW_GITHUB_EVENT_COMMENT_ID: ${{ github.event.comment.id }} + GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER: ${{ github.event.discussion.number }} + GH_AW_GITHUB_EVENT_ISSUE_NUMBER: ${{ github.event.issue.number }} + GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER: ${{ github.event.pull_request.number }} + GH_AW_GITHUB_REPOSITORY: ${{ github.repository }} + GH_AW_GITHUB_RUN_ID: ${{ github.run_id }} + GH_AW_GITHUB_WORKSPACE: ${{ github.workspace }} + run: | + bash /opt/gh-aw/actions/create_prompt_first.sh + cat << 'GH_AW_PROMPT_EOF' > "$GH_AW_PROMPT" + + GH_AW_PROMPT_EOF + cat "/opt/gh-aw/prompts/xpia.md" >> "$GH_AW_PROMPT" + cat "/opt/gh-aw/prompts/temp_folder_prompt.md" >> "$GH_AW_PROMPT" + cat "/opt/gh-aw/prompts/markdown.md" >> "$GH_AW_PROMPT" + cat << 'GH_AW_PROMPT_EOF' >> "$GH_AW_PROMPT" + + The following GitHub context information is available for this workflow: + {{#if __GH_AW_GITHUB_ACTOR__ }} + - **actor**: __GH_AW_GITHUB_ACTOR__ + {{/if}} + {{#if __GH_AW_GITHUB_REPOSITORY__ }} + - **repository**: __GH_AW_GITHUB_REPOSITORY__ + {{/if}} + {{#if __GH_AW_GITHUB_WORKSPACE__ }} + - **workspace**: __GH_AW_GITHUB_WORKSPACE__ + {{/if}} + {{#if __GH_AW_GITHUB_EVENT_ISSUE_NUMBER__ }} + - **issue-number**: #__GH_AW_GITHUB_EVENT_ISSUE_NUMBER__ + {{/if}} + {{#if __GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER__ }} + - **discussion-number**: #__GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER__ + {{/if}} + {{#if __GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER__ }} + - **pull-request-number**: #__GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER__ + {{/if}} + {{#if __GH_AW_GITHUB_EVENT_COMMENT_ID__ }} + - **comment-id**: __GH_AW_GITHUB_EVENT_COMMENT_ID__ + {{/if}} + {{#if __GH_AW_GITHUB_RUN_ID__ }} + - **workflow-run-id**: __GH_AW_GITHUB_RUN_ID__ + {{/if}} + + + GH_AW_PROMPT_EOF + cat << 'GH_AW_PROMPT_EOF' >> "$GH_AW_PROMPT" + + GH_AW_PROMPT_EOF + cat << 'GH_AW_PROMPT_EOF' >> "$GH_AW_PROMPT" + {{#runtime-import .github/workflows/document-phpdoc-tags.md}} + GH_AW_PROMPT_EOF + - name: Interpolate variables and render templates + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt + GH_AW_GITHUB_EVENT_BEFORE: ${{ github.event.before }} + with: + script: | + const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('/opt/gh-aw/actions/interpolate_prompt.cjs'); + await main(); + - name: Substitute placeholders + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt + GH_AW_GITHUB_ACTOR: ${{ github.actor }} + GH_AW_GITHUB_EVENT_BEFORE: ${{ github.event.before }} + GH_AW_GITHUB_EVENT_COMMENT_ID: ${{ github.event.comment.id }} + GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER: ${{ github.event.discussion.number }} + GH_AW_GITHUB_EVENT_ISSUE_NUMBER: ${{ github.event.issue.number }} + GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER: ${{ github.event.pull_request.number }} + GH_AW_GITHUB_REPOSITORY: ${{ github.repository }} + GH_AW_GITHUB_RUN_ID: ${{ github.run_id }} + GH_AW_GITHUB_WORKSPACE: ${{ github.workspace }} + GH_AW_NEEDS_PRE_ACTIVATION_OUTPUTS_ACTIVATED: ${{ needs.pre_activation.outputs.activated }} + GH_AW_NEEDS_PRE_ACTIVATION_OUTPUTS_MATCHED_COMMAND: ${{ needs.pre_activation.outputs.matched_command }} + with: + script: | + const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + + const substitutePlaceholders = require('/opt/gh-aw/actions/substitute_placeholders.cjs'); + + // Call the substitution function + return await substitutePlaceholders({ + file: process.env.GH_AW_PROMPT, + substitutions: { + GH_AW_GITHUB_ACTOR: process.env.GH_AW_GITHUB_ACTOR, + GH_AW_GITHUB_EVENT_BEFORE: process.env.GH_AW_GITHUB_EVENT_BEFORE, + GH_AW_GITHUB_EVENT_COMMENT_ID: process.env.GH_AW_GITHUB_EVENT_COMMENT_ID, + GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER: process.env.GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER, + GH_AW_GITHUB_EVENT_ISSUE_NUMBER: process.env.GH_AW_GITHUB_EVENT_ISSUE_NUMBER, + GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER: process.env.GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER, + GH_AW_GITHUB_REPOSITORY: process.env.GH_AW_GITHUB_REPOSITORY, + GH_AW_GITHUB_RUN_ID: process.env.GH_AW_GITHUB_RUN_ID, + GH_AW_GITHUB_WORKSPACE: process.env.GH_AW_GITHUB_WORKSPACE, + GH_AW_NEEDS_PRE_ACTIVATION_OUTPUTS_ACTIVATED: process.env.GH_AW_NEEDS_PRE_ACTIVATION_OUTPUTS_ACTIVATED, + GH_AW_NEEDS_PRE_ACTIVATION_OUTPUTS_MATCHED_COMMAND: process.env.GH_AW_NEEDS_PRE_ACTIVATION_OUTPUTS_MATCHED_COMMAND + } + }); + - name: Validate prompt placeholders + env: + GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt + run: bash /opt/gh-aw/actions/validate_prompt_placeholders.sh + - name: Print prompt + env: + GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt + run: bash /opt/gh-aw/actions/print_prompt_summary.sh + - name: Upload prompt artifact + if: success() + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 + with: + name: prompt + path: /tmp/gh-aw/aw-prompts/prompt.txt + retention-days: 1 + + agent: + needs: activation + runs-on: ubuntu-latest + permissions: + contents: read + issues: read + pull-requests: read + env: + GH_AW_WORKFLOW_ID_SANITIZED: documentphpdoctags + outputs: + checkout_pr_success: ${{ steps.checkout-pr.outputs.checkout_pr_success || 'true' }} + model: ${{ steps.generate_aw_info.outputs.model }} + secret_verification_result: ${{ steps.validate-secret.outputs.verification_result }} + steps: + - name: Setup Scripts + uses: github/gh-aw/actions/setup@ac090214a48a1938f7abafe132460b66752261af # v0.45.4 + with: + destination: /opt/gh-aw/actions + - name: Create gh-aw temp directory + run: bash /opt/gh-aw/actions/create_gh_aw_tmp_dir.sh + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + path: __phpstan-website + ref: 2.2.x + repository: phpstan/phpstan + token: ${{ secrets.PHPSTAN_BOT_TOKEN }} + + - name: Configure Git credentials + env: + REPO_NAME: ${{ github.repository }} + SERVER_URL: ${{ github.server_url }} + run: | + git config --global user.email "github-actions[bot]@users.noreply.github.com" + git config --global user.name "github-actions[bot]" + # Re-authenticate git with GitHub token + SERVER_URL_STRIPPED="${SERVER_URL#https://}" + git remote set-url origin "https://x-access-token:${{ github.token }}@${SERVER_URL_STRIPPED}/${REPO_NAME}.git" + echo "Git configured with standard GitHub Actions identity" + - name: Checkout PR branch + id: checkout-pr + if: | + github.event.pull_request + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + GH_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + with: + github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + script: | + const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('/opt/gh-aw/actions/checkout_pr_branch.cjs'); + await main(); + - name: Generate agentic run info + id: generate_aw_info + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + with: + script: | + const fs = require('fs'); + + const awInfo = { + engine_id: "claude", + engine_name: "Claude Code", + model: "claude-opus-4-6", + version: "", + agent_version: "2.1.42", + cli_version: "v0.45.4", + workflow_name: "Document PHPDoc Tags", + experimental: false, + supports_tools_allowlist: true, + run_id: context.runId, + run_number: context.runNumber, + run_attempt: process.env.GITHUB_RUN_ATTEMPT, + repository: context.repo.owner + '/' + context.repo.repo, + ref: context.ref, + sha: context.sha, + actor: context.actor, + event_name: context.eventName, + staged: false, + allowed_domains: ["defaults"], + firewall_enabled: true, + awf_version: "v0.19.1", + awmg_version: "v0.1.4", + steps: { + firewall: "squid" + }, + created_at: new Date().toISOString() + }; + + // Write to /tmp/gh-aw directory to avoid inclusion in PR + const tmpPath = '/tmp/gh-aw/aw_info.json'; + fs.writeFileSync(tmpPath, JSON.stringify(awInfo, null, 2)); + console.log('Generated aw_info.json at:', tmpPath); + console.log(JSON.stringify(awInfo, null, 2)); + + // Set model as output for reuse in other steps/jobs + core.setOutput('model', awInfo.model); + - name: Validate CLAUDE_CODE_OAUTH_TOKEN or ANTHROPIC_API_KEY secret + id: validate-secret + run: /opt/gh-aw/actions/validate_multi_secret.sh CLAUDE_CODE_OAUTH_TOKEN ANTHROPIC_API_KEY 'Claude Code' https://github.github.com/gh-aw/reference/engines/#anthropic-claude-code + env: + CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + - name: Setup Node.js + uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 + with: + node-version: '24' + package-manager-cache: false + - name: Install awf binary + run: bash /opt/gh-aw/actions/install_awf_binary.sh v0.19.1 + - name: Install Claude Code CLI + run: npm install -g --silent @anthropic-ai/claude-code@2.1.42 + - name: Determine automatic lockdown mode for GitHub MCP Server + id: determine-automatic-lockdown + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + GH_AW_GITHUB_TOKEN: ${{ secrets.GH_AW_GITHUB_TOKEN }} + GH_AW_GITHUB_MCP_SERVER_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN }} + with: + script: | + const determineAutomaticLockdown = require('/opt/gh-aw/actions/determine_automatic_lockdown.cjs'); + await determineAutomaticLockdown(github, context, core); + - name: Download container images + run: bash /opt/gh-aw/actions/download_docker_images.sh ghcr.io/github/gh-aw-firewall/agent:0.19.1 ghcr.io/github/gh-aw-firewall/api-proxy:0.19.1 ghcr.io/github/gh-aw-firewall/squid:0.19.1 ghcr.io/github/gh-aw-mcpg:v0.1.4 ghcr.io/github/github-mcp-server:v0.30.3 + - name: Start MCP Gateway + id: start-mcp-gateway + env: + GITHUB_MCP_LOCKDOWN: ${{ steps.determine-automatic-lockdown.outputs.lockdown == 'true' && '1' || '0' }} + GITHUB_MCP_SERVER_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + run: | + set -eo pipefail + mkdir -p /tmp/gh-aw/mcp-config + + # Export gateway environment variables for MCP config and gateway script + export MCP_GATEWAY_PORT="80" + export MCP_GATEWAY_DOMAIN="host.docker.internal" + MCP_GATEWAY_API_KEY=$(openssl rand -base64 45 | tr -d '/+=') + echo "::add-mask::${MCP_GATEWAY_API_KEY}" + export MCP_GATEWAY_API_KEY + export MCP_GATEWAY_PAYLOAD_DIR="/tmp/gh-aw/mcp-payloads" + mkdir -p "${MCP_GATEWAY_PAYLOAD_DIR}" + export DEBUG="*" + + export GH_AW_ENGINE="claude" + export MCP_GATEWAY_DOCKER_COMMAND='docker run -i --rm --network host -v /var/run/docker.sock:/var/run/docker.sock -e MCP_GATEWAY_PORT -e MCP_GATEWAY_DOMAIN -e MCP_GATEWAY_API_KEY -e MCP_GATEWAY_PAYLOAD_DIR -e DEBUG -e MCP_GATEWAY_LOG_DIR -e GH_AW_MCP_LOG_DIR -e GH_AW_SAFE_OUTPUTS -e GH_AW_SAFE_OUTPUTS_CONFIG_PATH -e GH_AW_SAFE_OUTPUTS_TOOLS_PATH -e GH_AW_ASSETS_BRANCH -e GH_AW_ASSETS_MAX_SIZE_KB -e GH_AW_ASSETS_ALLOWED_EXTS -e DEFAULT_BRANCH -e GITHUB_MCP_SERVER_TOKEN -e GITHUB_MCP_LOCKDOWN -e GITHUB_REPOSITORY -e GITHUB_SERVER_URL -e GITHUB_SHA -e GITHUB_WORKSPACE -e GITHUB_TOKEN -e GITHUB_RUN_ID -e GITHUB_RUN_NUMBER -e GITHUB_RUN_ATTEMPT -e GITHUB_JOB -e GITHUB_ACTION -e GITHUB_EVENT_NAME -e GITHUB_EVENT_PATH -e GITHUB_ACTOR -e GITHUB_ACTOR_ID -e GITHUB_TRIGGERING_ACTOR -e GITHUB_WORKFLOW -e GITHUB_WORKFLOW_REF -e GITHUB_WORKFLOW_SHA -e GITHUB_REF -e GITHUB_REF_NAME -e GITHUB_REF_TYPE -e GITHUB_HEAD_REF -e GITHUB_BASE_REF -v /tmp/gh-aw/mcp-payloads:/tmp/gh-aw/mcp-payloads:rw -v /opt:/opt:ro -v /tmp:/tmp:rw -v '"${GITHUB_WORKSPACE}"':'"${GITHUB_WORKSPACE}"':rw ghcr.io/github/gh-aw-mcpg:v0.1.4' + + cat << GH_AW_MCP_CONFIG_EOF | bash /opt/gh-aw/actions/start_mcp_gateway.sh + { + "mcpServers": { + "github": { + "container": "ghcr.io/github/github-mcp-server:v0.30.3", + "env": { + "GITHUB_LOCKDOWN_MODE": "$GITHUB_MCP_LOCKDOWN", + "GITHUB_PERSONAL_ACCESS_TOKEN": "$GITHUB_MCP_SERVER_TOKEN", + "GITHUB_READ_ONLY": "1", + "GITHUB_TOOLSETS": "context,repos,issues,pull_requests" + } + } + }, + "gateway": { + "port": $MCP_GATEWAY_PORT, + "domain": "${MCP_GATEWAY_DOMAIN}", + "apiKey": "${MCP_GATEWAY_API_KEY}", + "payloadDir": "${MCP_GATEWAY_PAYLOAD_DIR}" + } + } + GH_AW_MCP_CONFIG_EOF + - name: Generate workflow overview + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + with: + script: | + const { generateWorkflowOverview } = require('/opt/gh-aw/actions/generate_workflow_overview.cjs'); + await generateWorkflowOverview(core); + - name: Download prompt artifact + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 + with: + name: prompt + path: /tmp/gh-aw/aw-prompts + - name: Clean git credentials + run: bash /opt/gh-aw/actions/clean_git_credentials.sh + - name: Execute Claude Code CLI + id: agentic_execution + # Allowed tools (sorted): + # - Bash + # - BashOutput + # - Edit + # - ExitPlanMode + # - Glob + # - Grep + # - KillBash + # - LS + # - MultiEdit + # - NotebookEdit + # - NotebookRead + # - Read + # - Task + # - TodoWrite + # - Write + # - mcp__github__download_workflow_run_artifact + # - mcp__github__get_code_scanning_alert + # - mcp__github__get_commit + # - mcp__github__get_dependabot_alert + # - mcp__github__get_discussion + # - mcp__github__get_discussion_comments + # - mcp__github__get_file_contents + # - mcp__github__get_job_logs + # - mcp__github__get_label + # - mcp__github__get_latest_release + # - mcp__github__get_me + # - mcp__github__get_notification_details + # - mcp__github__get_pull_request + # - mcp__github__get_pull_request_comments + # - mcp__github__get_pull_request_diff + # - mcp__github__get_pull_request_files + # - mcp__github__get_pull_request_review_comments + # - mcp__github__get_pull_request_reviews + # - mcp__github__get_pull_request_status + # - mcp__github__get_release_by_tag + # - mcp__github__get_secret_scanning_alert + # - mcp__github__get_tag + # - mcp__github__get_workflow_run + # - mcp__github__get_workflow_run_logs + # - mcp__github__get_workflow_run_usage + # - mcp__github__issue_read + # - mcp__github__list_branches + # - mcp__github__list_code_scanning_alerts + # - mcp__github__list_commits + # - mcp__github__list_dependabot_alerts + # - mcp__github__list_discussion_categories + # - mcp__github__list_discussions + # - mcp__github__list_issue_types + # - mcp__github__list_issues + # - mcp__github__list_label + # - mcp__github__list_notifications + # - mcp__github__list_pull_requests + # - mcp__github__list_releases + # - mcp__github__list_secret_scanning_alerts + # - mcp__github__list_starred_repositories + # - mcp__github__list_tags + # - mcp__github__list_workflow_jobs + # - mcp__github__list_workflow_run_artifacts + # - mcp__github__list_workflow_runs + # - mcp__github__list_workflows + # - mcp__github__pull_request_read + # - mcp__github__search_code + # - mcp__github__search_issues + # - mcp__github__search_orgs + # - mcp__github__search_pull_requests + # - mcp__github__search_repositories + # - mcp__github__search_users + timeout-minutes: 30 + run: | + set -o pipefail + sudo -E awf --tty --env-all --container-workdir "${GITHUB_WORKSPACE}" --allow-domains '*.githubusercontent.com,anthropic.com,api.anthropic.com,api.github.com,api.snapcraft.io,archive.ubuntu.com,azure.archive.ubuntu.com,cdn.playwright.dev,codeload.github.com,crl.geotrust.com,crl.globalsign.com,crl.identrust.com,crl.sectigo.com,crl.thawte.com,crl.usertrust.com,crl.verisign.com,crl3.digicert.com,crl4.digicert.com,crls.ssl.com,files.pythonhosted.org,ghcr.io,github-cloud.githubusercontent.com,github-cloud.s3.amazonaws.com,github.com,host.docker.internal,json-schema.org,json.schemastore.org,keyserver.ubuntu.com,lfs.github.com,objects.githubusercontent.com,ocsp.digicert.com,ocsp.geotrust.com,ocsp.globalsign.com,ocsp.identrust.com,ocsp.sectigo.com,ocsp.ssl.com,ocsp.thawte.com,ocsp.usertrust.com,ocsp.verisign.com,packagecloud.io,packages.cloud.google.com,packages.microsoft.com,playwright.download.prss.microsoft.com,ppa.launchpad.net,pypi.org,raw.githubusercontent.com,registry.npmjs.org,s.symcb.com,s.symcd.com,security.ubuntu.com,sentry.io,statsig.anthropic.com,ts-crl.ws.symantec.com,ts-ocsp.ws.symantec.com' --log-level info --proxy-logs-dir /tmp/gh-aw/sandbox/firewall/logs --enable-host-access --image-tag 0.19.1 --skip-pull --enable-api-proxy \ + -- /bin/bash -c 'export PATH="$(find /opt/hostedtoolcache -maxdepth 4 -type d -name bin 2>/dev/null | tr '\''\n'\'' '\'':'\'')$PATH"; [ -n "$GOROOT" ] && export PATH="$GOROOT/bin:$PATH" || true && claude --print --disable-slash-commands --no-chrome --model claude-opus-4-6 --mcp-config /tmp/gh-aw/mcp-config/mcp-servers.json --allowed-tools Bash,BashOutput,Edit,ExitPlanMode,Glob,Grep,KillBash,LS,MultiEdit,NotebookEdit,NotebookRead,Read,Task,TodoWrite,Write,mcp__github__download_workflow_run_artifact,mcp__github__get_code_scanning_alert,mcp__github__get_commit,mcp__github__get_dependabot_alert,mcp__github__get_discussion,mcp__github__get_discussion_comments,mcp__github__get_file_contents,mcp__github__get_job_logs,mcp__github__get_label,mcp__github__get_latest_release,mcp__github__get_me,mcp__github__get_notification_details,mcp__github__get_pull_request,mcp__github__get_pull_request_comments,mcp__github__get_pull_request_diff,mcp__github__get_pull_request_files,mcp__github__get_pull_request_review_comments,mcp__github__get_pull_request_reviews,mcp__github__get_pull_request_status,mcp__github__get_release_by_tag,mcp__github__get_secret_scanning_alert,mcp__github__get_tag,mcp__github__get_workflow_run,mcp__github__get_workflow_run_logs,mcp__github__get_workflow_run_usage,mcp__github__issue_read,mcp__github__list_branches,mcp__github__list_code_scanning_alerts,mcp__github__list_commits,mcp__github__list_dependabot_alerts,mcp__github__list_discussion_categories,mcp__github__list_discussions,mcp__github__list_issue_types,mcp__github__list_issues,mcp__github__list_label,mcp__github__list_notifications,mcp__github__list_pull_requests,mcp__github__list_releases,mcp__github__list_secret_scanning_alerts,mcp__github__list_starred_repositories,mcp__github__list_tags,mcp__github__list_workflow_jobs,mcp__github__list_workflow_run_artifacts,mcp__github__list_workflow_runs,mcp__github__list_workflows,mcp__github__pull_request_read,mcp__github__search_code,mcp__github__search_issues,mcp__github__search_orgs,mcp__github__search_pull_requests,mcp__github__search_repositories,mcp__github__search_users --debug-file /tmp/gh-aw/agent-stdio.log --verbose --permission-mode bypassPermissions --output-format stream-json "$(cat /tmp/gh-aw/aw-prompts/prompt.txt)"' 2>&1 | tee -a /tmp/gh-aw/agent-stdio.log + env: + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + BASH_DEFAULT_TIMEOUT_MS: 60000 + BASH_MAX_TIMEOUT_MS: 60000 + CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + DISABLE_BUG_COMMAND: 1 + DISABLE_ERROR_REPORTING: 1 + DISABLE_TELEMETRY: 1 + GH_AW_MCP_CONFIG: /tmp/gh-aw/mcp-config/mcp-servers.json + GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt + GITHUB_WORKSPACE: ${{ github.workspace }} + MCP_TIMEOUT: 120000 + MCP_TOOL_TIMEOUT: 60000 + - name: Configure Git credentials + env: + REPO_NAME: ${{ github.repository }} + SERVER_URL: ${{ github.server_url }} + run: | + git config --global user.email "github-actions[bot]@users.noreply.github.com" + git config --global user.name "github-actions[bot]" + # Re-authenticate git with GitHub token + SERVER_URL_STRIPPED="${SERVER_URL#https://}" + git remote set-url origin "https://x-access-token:${{ github.token }}@${SERVER_URL_STRIPPED}/${REPO_NAME}.git" + echo "Git configured with standard GitHub Actions identity" + - name: Stop MCP Gateway + if: always() + continue-on-error: true + env: + MCP_GATEWAY_PORT: ${{ steps.start-mcp-gateway.outputs.gateway-port }} + MCP_GATEWAY_API_KEY: ${{ steps.start-mcp-gateway.outputs.gateway-api-key }} + GATEWAY_PID: ${{ steps.start-mcp-gateway.outputs.gateway-pid }} + run: | + bash /opt/gh-aw/actions/stop_mcp_gateway.sh "$GATEWAY_PID" + - name: Redact secrets in logs + if: always() + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + with: + script: | + const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('/opt/gh-aw/actions/redact_secrets.cjs'); + await main(); + env: + GH_AW_SECRET_NAMES: 'ANTHROPIC_API_KEY,CLAUDE_CODE_OAUTH_TOKEN,GH_AW_GITHUB_MCP_SERVER_TOKEN,GH_AW_GITHUB_TOKEN,GITHUB_TOKEN,PHPSTAN_BOT_TOKEN' + SECRET_ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + SECRET_CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + SECRET_GH_AW_GITHUB_MCP_SERVER_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN }} + SECRET_GH_AW_GITHUB_TOKEN: ${{ secrets.GH_AW_GITHUB_TOKEN }} + SECRET_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + SECRET_PHPSTAN_BOT_TOKEN: ${{ secrets.PHPSTAN_BOT_TOKEN }} + - name: Parse agent logs for step summary + if: always() + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + GH_AW_AGENT_OUTPUT: /tmp/gh-aw/agent-stdio.log + with: + script: | + const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('/opt/gh-aw/actions/parse_claude_log.cjs'); + await main(); + - name: Parse MCP Gateway logs for step summary + if: always() + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + with: + script: | + const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('/opt/gh-aw/actions/parse_mcp_gateway_log.cjs'); + await main(); + - name: Print firewall logs + if: always() + continue-on-error: true + env: + AWF_LOGS_DIR: /tmp/gh-aw/sandbox/firewall/logs + run: | + # Fix permissions on firewall logs so they can be uploaded as artifacts + # AWF runs with sudo, creating files owned by root + sudo chmod -R a+r /tmp/gh-aw/sandbox/firewall/logs 2>/dev/null || true + # Only run awf logs summary if awf command exists (it may not be installed if workflow failed before install step) + if command -v awf &> /dev/null; then + awf logs summary | tee -a "$GITHUB_STEP_SUMMARY" + else + echo 'AWF binary not installed, skipping firewall log summary' + fi + - name: Upload agent artifacts + if: always() + continue-on-error: true + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 + with: + name: agent-artifacts + path: | + /tmp/gh-aw/aw-prompts/prompt.txt + /tmp/gh-aw/aw_info.json + /tmp/gh-aw/mcp-logs/ + /tmp/gh-aw/sandbox/firewall/logs/ + /tmp/gh-aw/agent-stdio.log + /tmp/gh-aw/agent/ + if-no-files-found: ignore + + pre_activation: + runs-on: ubuntu-slim + outputs: + activated: ${{ steps.check_membership.outputs.is_team_member == 'true' }} + steps: + - name: Setup Scripts + uses: github/gh-aw/actions/setup@ac090214a48a1938f7abafe132460b66752261af # v0.45.4 + with: + destination: /opt/gh-aw/actions + - name: Check team membership for workflow + id: check_membership + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + GH_AW_REQUIRED_ROLES: admin,maintainer,write + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('/opt/gh-aw/actions/check_membership.cjs'); + await main(); + diff --git a/.github/workflows/document-phpdoc-tags.md b/.github/workflows/document-phpdoc-tags.md new file mode 100644 index 00000000000..cab8a61f3f2 --- /dev/null +++ b/.github/workflows/document-phpdoc-tags.md @@ -0,0 +1,125 @@ +--- +name: Document PHPDoc Tags +description: Finds undocumented PHPDoc tags supported by PHPStan and creates documentation PRs on phpstan/phpstan +on: + push: + branches: [2.2.x] + paths: [src/PhpDoc/PhpDocNodeResolver.php] + workflow_dispatch: +engine: + id: claude + model: claude-opus-4-6 + env: + CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} +permissions: + contents: read + issues: read + pull-requests: read +tools: + bash: ["*"] + github: + toolsets: [default, repos] +timeout-minutes: 30 +steps: + - uses: actions/checkout@v4 + - uses: actions/checkout@v4 + with: + repository: phpstan/phpstan + ref: 2.2.x + path: __phpstan-website + token: ${{ secrets.PHPSTAN_BOT_TOKEN }} +--- + +# Document Undocumented PHPDoc Tags + +You are a documentation agent for PHPStan. Your job is to find PHPDoc tags that PHPStan supports but are not documented on the website, and to write documentation for them. + +## Source files + +- **Tag handling code**: `src/PhpDoc/PhpDocNodeResolver.php` in this workspace (phpstan-src repo) +- **Valid tag list**: `src/Rules/PhpDoc/InvalidPHPStanDocTagRule.php` in this workspace — contains `POSSIBLE_PHPSTAN_TAGS` listing all recognized `@phpstan-*` tags +- **PHPDocs basics page**: `__phpstan-website/website/src/writing-php-code/phpdocs-basics.md` (checked out from `phpstan/phpstan`) +- **PHPDoc types page**: `__phpstan-website/website/src/writing-php-code/phpdoc-types.md` (checked out from `phpstan/phpstan`) +- **All website docs**: `__phpstan-website/website/src/` directory — search here for tags that may be documented on other pages +- **Source code for research**: `src/`, `conf/`, and `tests/` directories in this workspace (phpstan-src repo) + +## Task + +### Step 1: Extract all supported tags from source code + +1. Read `src/PhpDoc/PhpDocNodeResolver.php` and extract every PHPDoc tag name it processes. Tags appear as string literals in arrays like `['@var', '@phan-var', '@psalm-var', '@phpstan-var']` and in `getTagsByName()` calls. +2. Read `src/Rules/PhpDoc/InvalidPHPStanDocTagRule.php` and extract the list of recognized `@phpstan-*` tags. +3. Build a complete list of **base tags** that PHPStan supports. For tags that have `@phpstan-`/`@psalm-`/`@phan-` prefix variants, the base tag is the unprefixed form (e.g., `@param` is the base for `@phpstan-param`). For tags that only exist with a `@phpstan-` prefix (e.g., `@phpstan-type`, `@phpstan-assert`), keep the prefixed form. + +### Step 2: Check which tags are documented on the website + +1. Read `__phpstan-website/website/src/writing-php-code/phpdocs-basics.md` +2. Read `__phpstan-website/website/src/writing-php-code/phpdoc-types.md` +3. Search the entire `__phpstan-website/website/src/` directory for each tag name to check if it's documented on any page + +A tag counts as "documented" if it appears on any website page with an explanation of what it does. A tag does NOT count as documented if it only appears in passing examples without explanation, or only in the "Prefixed tags" section. + +### Step 3: Determine which tags need documentation + +**Important — prefix variants are already handled:** + +The "Prefixed tags" section of `phpdocs-basics.md` already explains that tags like `@param`, `@return`, `@var`, and generics-related tags can be prefixed with `@phpstan-` (and `@psalm-`, `@phan-`). Do NOT create separate documentation for prefix variants. Only document the base tag (e.g., `@param`, not `@phpstan-param`). Exception: tags that ONLY exist with a prefix (like `@phpstan-type`, `@phpstan-assert`) need to be documented with their prefix. + +**Important — verify tag name accuracy:** + +When checking whether a tag is documented, verify the exact tag name matches between the source code and the documentation. Flag and fix any mismatches (e.g., if docs use a slightly different tag name than the code). + +{{#if github.event_name == 'push'}} +Focus primarily on tags that were added or changed in this push. Run `git diff ${{ github.event.before }} -- src/PhpDoc/PhpDocNodeResolver.php` to see what changed. Document newly added or changed tags, but also briefly check if any other tags remain undocumented and include those too. +{{#else}} +Check ALL tags from the source code against the documentation. Do not look at git history or diffs — compare the full tag list against all website documentation and document every undocumented tag you find. +{{/if}} + +If there are no undocumented tags (and no mismatched tag names), stop and report that all tags are documented. Do not create a PR. + +### Step 4: Research each undocumented tag + +For each undocumented tag, investigate what it does: + +1. **Read the resolver method** in `PhpDocNodeResolver.php` to understand how the tag is parsed. +2. **Search the source code** in `src/` for where the resolved tag data is used. For example, search for related method names in `ResolvedPhpDocBlock.php` and in rules under `src/Rules/`. +3. **Look at related rules** in `src/Rules/` that enforce or check the tag's semantics. +4. **Check tests** in `tests/` for test data files that exercise the tag — these show exactly what behavior the tag enables. + +### Step 5: Write documentation + +Edit `__phpstan-website/website/src/writing-php-code/phpdocs-basics.md` to add documentation for missing tags. Do NOT overwrite the file — use targeted edits to insert new sections. + +**Follow the existing writing style exactly:** + +- Use section headings at the same level as similar existing sections +- Provide a concise description (one or two sentences) +- Include a short PHP code example showing the tag in use +- If the tag interacts with specific rules or features, mention that briefly +- Use fenced code blocks with `php` language annotation +- If the tag was introduced in a specific PHPStan version, add a version badge: + +```html +
Available in PHPStan X.Y
+``` + +**Placement:** Insert new sections near related existing content. For example, property-related tags go near `@readonly`, class-level tags go near other class-level tags, etc. + +**Also fix any tag name mismatches** between documentation and source code to ensure the documented tag names match what the code actually accepts. + +### Step 6: Create a pull request + +After editing the documentation file, push the changes and create a PR on `phpstan/phpstan`: + +```bash +cd __phpstan-website +git config user.name "phpstan-bot" +git config user.email "ondrej+phpstanbot@mirtes.cz" +git checkout -b docs/undocumented-phpdoc-tags +git add website/src/writing-php-code/phpdocs-basics.md +git commit -m "Document undocumented PHPDoc tags" +git push origin docs/undocumented-phpdoc-tags +gh pr create --repo phpstan/phpstan --base 2.2.x --draft --title "[Docs] Document undocumented PHPDoc tags" --body "PR DESCRIPTION HERE" +``` + +Replace `PR DESCRIPTION HERE` with a description listing which tags were newly documented with a one-line summary of each, any tag name mismatches that were fixed, and a note that prefix variants are already covered by the "Prefixed tags" section. From 45dea8d751b679ffe3e5d82c3fd86cbe8e1f1ab1 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Sat, 21 Feb 2026 06:28:24 +0000 Subject: [PATCH 020/158] Add document-phpdoc-types workflow Co-Authored-By: Claude Opus 4.6 --- .../workflows/document-phpdoc-types.lock.yml | 575 ++++++++++++++++++ .github/workflows/document-phpdoc-types.md | 113 ++++ 2 files changed, 688 insertions(+) create mode 100644 .github/workflows/document-phpdoc-types.lock.yml create mode 100644 .github/workflows/document-phpdoc-types.md diff --git a/.github/workflows/document-phpdoc-types.lock.yml b/.github/workflows/document-phpdoc-types.lock.yml new file mode 100644 index 00000000000..a2441229f19 --- /dev/null +++ b/.github/workflows/document-phpdoc-types.lock.yml @@ -0,0 +1,575 @@ +# +# ___ _ _ +# / _ \ | | (_) +# | |_| | __ _ ___ _ __ | |_ _ ___ +# | _ |/ _` |/ _ \ '_ \| __| |/ __| +# | | | | (_| | __/ | | | |_| | (__ +# \_| |_/\__, |\___|_| |_|\__|_|\___| +# __/ | +# _ _ |___/ +# | | | | / _| | +# | | | | ___ _ __ _ __| |_| | _____ ____ +# | |/\| |/ _ \ '__| |/ /| _| |/ _ \ \ /\ / / ___| +# \ /\ / (_) | | | | ( | | | | (_) \ V V /\__ \ +# \/ \/ \___/|_| |_|\_\|_| |_|\___/ \_/\_/ |___/ +# +# This file was automatically generated by gh-aw (v0.45.4). DO NOT EDIT. +# +# To update this file, edit the corresponding .md file and run: +# gh aw compile +# Not all edits will cause changes to this file. +# +# For more information: https://github.github.com/gh-aw/introduction/overview/ +# +# Finds undocumented PHPDoc types in TypeNodeResolver and creates documentation PRs on phpstan/phpstan +# +# frontmatter-hash: f03c46de2b23185fffeb696c2bd043a4f156e078ffffc538664f89847bc94706 + +name: "Document PHPDoc Types" +"on": + push: + branches: + - 2.2.x + paths: + - src/PhpDoc/TypeNodeResolver.php + workflow_dispatch: + +permissions: {} + +concurrency: + group: "gh-aw-${{ github.workflow }}-${{ github.ref }}" + +run-name: "Document PHPDoc Types" + +jobs: + activation: + needs: pre_activation + if: needs.pre_activation.outputs.activated == 'true' + runs-on: ubuntu-slim + permissions: + contents: read + outputs: + comment_id: "" + comment_repo: "" + steps: + - name: Setup Scripts + uses: github/gh-aw/actions/setup@ac090214a48a1938f7abafe132460b66752261af # v0.45.4 + with: + destination: /opt/gh-aw/actions + - name: Checkout .github and .agents folders + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + sparse-checkout: | + .github + .agents + fetch-depth: 1 + persist-credentials: false + - name: Check workflow file timestamps + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + GH_AW_WORKFLOW_FILE: "document-phpdoc-types.lock.yml" + with: + script: | + const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('/opt/gh-aw/actions/check_workflow_timestamp_api.cjs'); + await main(); + - name: Create prompt with built-in context + env: + GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt + GH_AW_GITHUB_ACTOR: ${{ github.actor }} + GH_AW_GITHUB_EVENT_BEFORE: ${{ github.event.before }} + GH_AW_GITHUB_EVENT_COMMENT_ID: ${{ github.event.comment.id }} + GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER: ${{ github.event.discussion.number }} + GH_AW_GITHUB_EVENT_ISSUE_NUMBER: ${{ github.event.issue.number }} + GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER: ${{ github.event.pull_request.number }} + GH_AW_GITHUB_REPOSITORY: ${{ github.repository }} + GH_AW_GITHUB_RUN_ID: ${{ github.run_id }} + GH_AW_GITHUB_WORKSPACE: ${{ github.workspace }} + run: | + bash /opt/gh-aw/actions/create_prompt_first.sh + cat << 'GH_AW_PROMPT_EOF' > "$GH_AW_PROMPT" + + GH_AW_PROMPT_EOF + cat "/opt/gh-aw/prompts/xpia.md" >> "$GH_AW_PROMPT" + cat "/opt/gh-aw/prompts/temp_folder_prompt.md" >> "$GH_AW_PROMPT" + cat "/opt/gh-aw/prompts/markdown.md" >> "$GH_AW_PROMPT" + cat << 'GH_AW_PROMPT_EOF' >> "$GH_AW_PROMPT" + + The following GitHub context information is available for this workflow: + {{#if __GH_AW_GITHUB_ACTOR__ }} + - **actor**: __GH_AW_GITHUB_ACTOR__ + {{/if}} + {{#if __GH_AW_GITHUB_REPOSITORY__ }} + - **repository**: __GH_AW_GITHUB_REPOSITORY__ + {{/if}} + {{#if __GH_AW_GITHUB_WORKSPACE__ }} + - **workspace**: __GH_AW_GITHUB_WORKSPACE__ + {{/if}} + {{#if __GH_AW_GITHUB_EVENT_ISSUE_NUMBER__ }} + - **issue-number**: #__GH_AW_GITHUB_EVENT_ISSUE_NUMBER__ + {{/if}} + {{#if __GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER__ }} + - **discussion-number**: #__GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER__ + {{/if}} + {{#if __GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER__ }} + - **pull-request-number**: #__GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER__ + {{/if}} + {{#if __GH_AW_GITHUB_EVENT_COMMENT_ID__ }} + - **comment-id**: __GH_AW_GITHUB_EVENT_COMMENT_ID__ + {{/if}} + {{#if __GH_AW_GITHUB_RUN_ID__ }} + - **workflow-run-id**: __GH_AW_GITHUB_RUN_ID__ + {{/if}} + + + GH_AW_PROMPT_EOF + cat << 'GH_AW_PROMPT_EOF' >> "$GH_AW_PROMPT" + + GH_AW_PROMPT_EOF + cat << 'GH_AW_PROMPT_EOF' >> "$GH_AW_PROMPT" + {{#runtime-import .github/workflows/document-phpdoc-types.md}} + GH_AW_PROMPT_EOF + - name: Interpolate variables and render templates + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt + GH_AW_GITHUB_EVENT_BEFORE: ${{ github.event.before }} + with: + script: | + const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('/opt/gh-aw/actions/interpolate_prompt.cjs'); + await main(); + - name: Substitute placeholders + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt + GH_AW_GITHUB_ACTOR: ${{ github.actor }} + GH_AW_GITHUB_EVENT_BEFORE: ${{ github.event.before }} + GH_AW_GITHUB_EVENT_COMMENT_ID: ${{ github.event.comment.id }} + GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER: ${{ github.event.discussion.number }} + GH_AW_GITHUB_EVENT_ISSUE_NUMBER: ${{ github.event.issue.number }} + GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER: ${{ github.event.pull_request.number }} + GH_AW_GITHUB_REPOSITORY: ${{ github.repository }} + GH_AW_GITHUB_RUN_ID: ${{ github.run_id }} + GH_AW_GITHUB_WORKSPACE: ${{ github.workspace }} + GH_AW_NEEDS_PRE_ACTIVATION_OUTPUTS_ACTIVATED: ${{ needs.pre_activation.outputs.activated }} + GH_AW_NEEDS_PRE_ACTIVATION_OUTPUTS_MATCHED_COMMAND: ${{ needs.pre_activation.outputs.matched_command }} + with: + script: | + const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + + const substitutePlaceholders = require('/opt/gh-aw/actions/substitute_placeholders.cjs'); + + // Call the substitution function + return await substitutePlaceholders({ + file: process.env.GH_AW_PROMPT, + substitutions: { + GH_AW_GITHUB_ACTOR: process.env.GH_AW_GITHUB_ACTOR, + GH_AW_GITHUB_EVENT_BEFORE: process.env.GH_AW_GITHUB_EVENT_BEFORE, + GH_AW_GITHUB_EVENT_COMMENT_ID: process.env.GH_AW_GITHUB_EVENT_COMMENT_ID, + GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER: process.env.GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER, + GH_AW_GITHUB_EVENT_ISSUE_NUMBER: process.env.GH_AW_GITHUB_EVENT_ISSUE_NUMBER, + GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER: process.env.GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER, + GH_AW_GITHUB_REPOSITORY: process.env.GH_AW_GITHUB_REPOSITORY, + GH_AW_GITHUB_RUN_ID: process.env.GH_AW_GITHUB_RUN_ID, + GH_AW_GITHUB_WORKSPACE: process.env.GH_AW_GITHUB_WORKSPACE, + GH_AW_NEEDS_PRE_ACTIVATION_OUTPUTS_ACTIVATED: process.env.GH_AW_NEEDS_PRE_ACTIVATION_OUTPUTS_ACTIVATED, + GH_AW_NEEDS_PRE_ACTIVATION_OUTPUTS_MATCHED_COMMAND: process.env.GH_AW_NEEDS_PRE_ACTIVATION_OUTPUTS_MATCHED_COMMAND + } + }); + - name: Validate prompt placeholders + env: + GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt + run: bash /opt/gh-aw/actions/validate_prompt_placeholders.sh + - name: Print prompt + env: + GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt + run: bash /opt/gh-aw/actions/print_prompt_summary.sh + - name: Upload prompt artifact + if: success() + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 + with: + name: prompt + path: /tmp/gh-aw/aw-prompts/prompt.txt + retention-days: 1 + + agent: + needs: activation + runs-on: ubuntu-latest + permissions: + contents: read + issues: read + pull-requests: read + env: + GH_AW_WORKFLOW_ID_SANITIZED: documentphpdoctypes + outputs: + checkout_pr_success: ${{ steps.checkout-pr.outputs.checkout_pr_success || 'true' }} + model: ${{ steps.generate_aw_info.outputs.model }} + secret_verification_result: ${{ steps.validate-secret.outputs.verification_result }} + steps: + - name: Setup Scripts + uses: github/gh-aw/actions/setup@ac090214a48a1938f7abafe132460b66752261af # v0.45.4 + with: + destination: /opt/gh-aw/actions + - name: Create gh-aw temp directory + run: bash /opt/gh-aw/actions/create_gh_aw_tmp_dir.sh + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + path: __phpstan-website + ref: 2.2.x + repository: phpstan/phpstan + token: ${{ secrets.PHPSTAN_BOT_TOKEN }} + + - name: Configure Git credentials + env: + REPO_NAME: ${{ github.repository }} + SERVER_URL: ${{ github.server_url }} + run: | + git config --global user.email "github-actions[bot]@users.noreply.github.com" + git config --global user.name "github-actions[bot]" + # Re-authenticate git with GitHub token + SERVER_URL_STRIPPED="${SERVER_URL#https://}" + git remote set-url origin "https://x-access-token:${{ github.token }}@${SERVER_URL_STRIPPED}/${REPO_NAME}.git" + echo "Git configured with standard GitHub Actions identity" + - name: Checkout PR branch + id: checkout-pr + if: | + github.event.pull_request + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + GH_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + with: + github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + script: | + const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('/opt/gh-aw/actions/checkout_pr_branch.cjs'); + await main(); + - name: Generate agentic run info + id: generate_aw_info + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + with: + script: | + const fs = require('fs'); + + const awInfo = { + engine_id: "claude", + engine_name: "Claude Code", + model: "claude-opus-4-6", + version: "", + agent_version: "2.1.42", + cli_version: "v0.45.4", + workflow_name: "Document PHPDoc Types", + experimental: false, + supports_tools_allowlist: true, + run_id: context.runId, + run_number: context.runNumber, + run_attempt: process.env.GITHUB_RUN_ATTEMPT, + repository: context.repo.owner + '/' + context.repo.repo, + ref: context.ref, + sha: context.sha, + actor: context.actor, + event_name: context.eventName, + staged: false, + allowed_domains: ["defaults"], + firewall_enabled: true, + awf_version: "v0.19.1", + awmg_version: "v0.1.4", + steps: { + firewall: "squid" + }, + created_at: new Date().toISOString() + }; + + // Write to /tmp/gh-aw directory to avoid inclusion in PR + const tmpPath = '/tmp/gh-aw/aw_info.json'; + fs.writeFileSync(tmpPath, JSON.stringify(awInfo, null, 2)); + console.log('Generated aw_info.json at:', tmpPath); + console.log(JSON.stringify(awInfo, null, 2)); + + // Set model as output for reuse in other steps/jobs + core.setOutput('model', awInfo.model); + - name: Validate CLAUDE_CODE_OAUTH_TOKEN or ANTHROPIC_API_KEY secret + id: validate-secret + run: /opt/gh-aw/actions/validate_multi_secret.sh CLAUDE_CODE_OAUTH_TOKEN ANTHROPIC_API_KEY 'Claude Code' https://github.github.com/gh-aw/reference/engines/#anthropic-claude-code + env: + CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + - name: Setup Node.js + uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 + with: + node-version: '24' + package-manager-cache: false + - name: Install awf binary + run: bash /opt/gh-aw/actions/install_awf_binary.sh v0.19.1 + - name: Install Claude Code CLI + run: npm install -g --silent @anthropic-ai/claude-code@2.1.42 + - name: Determine automatic lockdown mode for GitHub MCP Server + id: determine-automatic-lockdown + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + GH_AW_GITHUB_TOKEN: ${{ secrets.GH_AW_GITHUB_TOKEN }} + GH_AW_GITHUB_MCP_SERVER_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN }} + with: + script: | + const determineAutomaticLockdown = require('/opt/gh-aw/actions/determine_automatic_lockdown.cjs'); + await determineAutomaticLockdown(github, context, core); + - name: Download container images + run: bash /opt/gh-aw/actions/download_docker_images.sh ghcr.io/github/gh-aw-firewall/agent:0.19.1 ghcr.io/github/gh-aw-firewall/api-proxy:0.19.1 ghcr.io/github/gh-aw-firewall/squid:0.19.1 ghcr.io/github/gh-aw-mcpg:v0.1.4 ghcr.io/github/github-mcp-server:v0.30.3 + - name: Start MCP Gateway + id: start-mcp-gateway + env: + GITHUB_MCP_LOCKDOWN: ${{ steps.determine-automatic-lockdown.outputs.lockdown == 'true' && '1' || '0' }} + GITHUB_MCP_SERVER_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + run: | + set -eo pipefail + mkdir -p /tmp/gh-aw/mcp-config + + # Export gateway environment variables for MCP config and gateway script + export MCP_GATEWAY_PORT="80" + export MCP_GATEWAY_DOMAIN="host.docker.internal" + MCP_GATEWAY_API_KEY=$(openssl rand -base64 45 | tr -d '/+=') + echo "::add-mask::${MCP_GATEWAY_API_KEY}" + export MCP_GATEWAY_API_KEY + export MCP_GATEWAY_PAYLOAD_DIR="/tmp/gh-aw/mcp-payloads" + mkdir -p "${MCP_GATEWAY_PAYLOAD_DIR}" + export DEBUG="*" + + export GH_AW_ENGINE="claude" + export MCP_GATEWAY_DOCKER_COMMAND='docker run -i --rm --network host -v /var/run/docker.sock:/var/run/docker.sock -e MCP_GATEWAY_PORT -e MCP_GATEWAY_DOMAIN -e MCP_GATEWAY_API_KEY -e MCP_GATEWAY_PAYLOAD_DIR -e DEBUG -e MCP_GATEWAY_LOG_DIR -e GH_AW_MCP_LOG_DIR -e GH_AW_SAFE_OUTPUTS -e GH_AW_SAFE_OUTPUTS_CONFIG_PATH -e GH_AW_SAFE_OUTPUTS_TOOLS_PATH -e GH_AW_ASSETS_BRANCH -e GH_AW_ASSETS_MAX_SIZE_KB -e GH_AW_ASSETS_ALLOWED_EXTS -e DEFAULT_BRANCH -e GITHUB_MCP_SERVER_TOKEN -e GITHUB_MCP_LOCKDOWN -e GITHUB_REPOSITORY -e GITHUB_SERVER_URL -e GITHUB_SHA -e GITHUB_WORKSPACE -e GITHUB_TOKEN -e GITHUB_RUN_ID -e GITHUB_RUN_NUMBER -e GITHUB_RUN_ATTEMPT -e GITHUB_JOB -e GITHUB_ACTION -e GITHUB_EVENT_NAME -e GITHUB_EVENT_PATH -e GITHUB_ACTOR -e GITHUB_ACTOR_ID -e GITHUB_TRIGGERING_ACTOR -e GITHUB_WORKFLOW -e GITHUB_WORKFLOW_REF -e GITHUB_WORKFLOW_SHA -e GITHUB_REF -e GITHUB_REF_NAME -e GITHUB_REF_TYPE -e GITHUB_HEAD_REF -e GITHUB_BASE_REF -v /tmp/gh-aw/mcp-payloads:/tmp/gh-aw/mcp-payloads:rw -v /opt:/opt:ro -v /tmp:/tmp:rw -v '"${GITHUB_WORKSPACE}"':'"${GITHUB_WORKSPACE}"':rw ghcr.io/github/gh-aw-mcpg:v0.1.4' + + cat << GH_AW_MCP_CONFIG_EOF | bash /opt/gh-aw/actions/start_mcp_gateway.sh + { + "mcpServers": { + "github": { + "container": "ghcr.io/github/github-mcp-server:v0.30.3", + "env": { + "GITHUB_LOCKDOWN_MODE": "$GITHUB_MCP_LOCKDOWN", + "GITHUB_PERSONAL_ACCESS_TOKEN": "$GITHUB_MCP_SERVER_TOKEN", + "GITHUB_READ_ONLY": "1", + "GITHUB_TOOLSETS": "context,repos,issues,pull_requests" + } + } + }, + "gateway": { + "port": $MCP_GATEWAY_PORT, + "domain": "${MCP_GATEWAY_DOMAIN}", + "apiKey": "${MCP_GATEWAY_API_KEY}", + "payloadDir": "${MCP_GATEWAY_PAYLOAD_DIR}" + } + } + GH_AW_MCP_CONFIG_EOF + - name: Generate workflow overview + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + with: + script: | + const { generateWorkflowOverview } = require('/opt/gh-aw/actions/generate_workflow_overview.cjs'); + await generateWorkflowOverview(core); + - name: Download prompt artifact + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 + with: + name: prompt + path: /tmp/gh-aw/aw-prompts + - name: Clean git credentials + run: bash /opt/gh-aw/actions/clean_git_credentials.sh + - name: Execute Claude Code CLI + id: agentic_execution + # Allowed tools (sorted): + # - Bash + # - BashOutput + # - Edit + # - ExitPlanMode + # - Glob + # - Grep + # - KillBash + # - LS + # - MultiEdit + # - NotebookEdit + # - NotebookRead + # - Read + # - Task + # - TodoWrite + # - Write + # - mcp__github__download_workflow_run_artifact + # - mcp__github__get_code_scanning_alert + # - mcp__github__get_commit + # - mcp__github__get_dependabot_alert + # - mcp__github__get_discussion + # - mcp__github__get_discussion_comments + # - mcp__github__get_file_contents + # - mcp__github__get_job_logs + # - mcp__github__get_label + # - mcp__github__get_latest_release + # - mcp__github__get_me + # - mcp__github__get_notification_details + # - mcp__github__get_pull_request + # - mcp__github__get_pull_request_comments + # - mcp__github__get_pull_request_diff + # - mcp__github__get_pull_request_files + # - mcp__github__get_pull_request_review_comments + # - mcp__github__get_pull_request_reviews + # - mcp__github__get_pull_request_status + # - mcp__github__get_release_by_tag + # - mcp__github__get_secret_scanning_alert + # - mcp__github__get_tag + # - mcp__github__get_workflow_run + # - mcp__github__get_workflow_run_logs + # - mcp__github__get_workflow_run_usage + # - mcp__github__issue_read + # - mcp__github__list_branches + # - mcp__github__list_code_scanning_alerts + # - mcp__github__list_commits + # - mcp__github__list_dependabot_alerts + # - mcp__github__list_discussion_categories + # - mcp__github__list_discussions + # - mcp__github__list_issue_types + # - mcp__github__list_issues + # - mcp__github__list_label + # - mcp__github__list_notifications + # - mcp__github__list_pull_requests + # - mcp__github__list_releases + # - mcp__github__list_secret_scanning_alerts + # - mcp__github__list_starred_repositories + # - mcp__github__list_tags + # - mcp__github__list_workflow_jobs + # - mcp__github__list_workflow_run_artifacts + # - mcp__github__list_workflow_runs + # - mcp__github__list_workflows + # - mcp__github__pull_request_read + # - mcp__github__search_code + # - mcp__github__search_issues + # - mcp__github__search_orgs + # - mcp__github__search_pull_requests + # - mcp__github__search_repositories + # - mcp__github__search_users + timeout-minutes: 30 + run: | + set -o pipefail + sudo -E awf --tty --env-all --container-workdir "${GITHUB_WORKSPACE}" --allow-domains '*.githubusercontent.com,anthropic.com,api.anthropic.com,api.github.com,api.snapcraft.io,archive.ubuntu.com,azure.archive.ubuntu.com,cdn.playwright.dev,codeload.github.com,crl.geotrust.com,crl.globalsign.com,crl.identrust.com,crl.sectigo.com,crl.thawte.com,crl.usertrust.com,crl.verisign.com,crl3.digicert.com,crl4.digicert.com,crls.ssl.com,files.pythonhosted.org,ghcr.io,github-cloud.githubusercontent.com,github-cloud.s3.amazonaws.com,github.com,host.docker.internal,json-schema.org,json.schemastore.org,keyserver.ubuntu.com,lfs.github.com,objects.githubusercontent.com,ocsp.digicert.com,ocsp.geotrust.com,ocsp.globalsign.com,ocsp.identrust.com,ocsp.sectigo.com,ocsp.ssl.com,ocsp.thawte.com,ocsp.usertrust.com,ocsp.verisign.com,packagecloud.io,packages.cloud.google.com,packages.microsoft.com,playwright.download.prss.microsoft.com,ppa.launchpad.net,pypi.org,raw.githubusercontent.com,registry.npmjs.org,s.symcb.com,s.symcd.com,security.ubuntu.com,sentry.io,statsig.anthropic.com,ts-crl.ws.symantec.com,ts-ocsp.ws.symantec.com' --log-level info --proxy-logs-dir /tmp/gh-aw/sandbox/firewall/logs --enable-host-access --image-tag 0.19.1 --skip-pull --enable-api-proxy \ + -- /bin/bash -c 'export PATH="$(find /opt/hostedtoolcache -maxdepth 4 -type d -name bin 2>/dev/null | tr '\''\n'\'' '\'':'\'')$PATH"; [ -n "$GOROOT" ] && export PATH="$GOROOT/bin:$PATH" || true && claude --print --disable-slash-commands --no-chrome --model claude-opus-4-6 --mcp-config /tmp/gh-aw/mcp-config/mcp-servers.json --allowed-tools Bash,BashOutput,Edit,ExitPlanMode,Glob,Grep,KillBash,LS,MultiEdit,NotebookEdit,NotebookRead,Read,Task,TodoWrite,Write,mcp__github__download_workflow_run_artifact,mcp__github__get_code_scanning_alert,mcp__github__get_commit,mcp__github__get_dependabot_alert,mcp__github__get_discussion,mcp__github__get_discussion_comments,mcp__github__get_file_contents,mcp__github__get_job_logs,mcp__github__get_label,mcp__github__get_latest_release,mcp__github__get_me,mcp__github__get_notification_details,mcp__github__get_pull_request,mcp__github__get_pull_request_comments,mcp__github__get_pull_request_diff,mcp__github__get_pull_request_files,mcp__github__get_pull_request_review_comments,mcp__github__get_pull_request_reviews,mcp__github__get_pull_request_status,mcp__github__get_release_by_tag,mcp__github__get_secret_scanning_alert,mcp__github__get_tag,mcp__github__get_workflow_run,mcp__github__get_workflow_run_logs,mcp__github__get_workflow_run_usage,mcp__github__issue_read,mcp__github__list_branches,mcp__github__list_code_scanning_alerts,mcp__github__list_commits,mcp__github__list_dependabot_alerts,mcp__github__list_discussion_categories,mcp__github__list_discussions,mcp__github__list_issue_types,mcp__github__list_issues,mcp__github__list_label,mcp__github__list_notifications,mcp__github__list_pull_requests,mcp__github__list_releases,mcp__github__list_secret_scanning_alerts,mcp__github__list_starred_repositories,mcp__github__list_tags,mcp__github__list_workflow_jobs,mcp__github__list_workflow_run_artifacts,mcp__github__list_workflow_runs,mcp__github__list_workflows,mcp__github__pull_request_read,mcp__github__search_code,mcp__github__search_issues,mcp__github__search_orgs,mcp__github__search_pull_requests,mcp__github__search_repositories,mcp__github__search_users --debug-file /tmp/gh-aw/agent-stdio.log --verbose --permission-mode bypassPermissions --output-format stream-json "$(cat /tmp/gh-aw/aw-prompts/prompt.txt)"' 2>&1 | tee -a /tmp/gh-aw/agent-stdio.log + env: + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + BASH_DEFAULT_TIMEOUT_MS: 60000 + BASH_MAX_TIMEOUT_MS: 60000 + CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + DISABLE_BUG_COMMAND: 1 + DISABLE_ERROR_REPORTING: 1 + DISABLE_TELEMETRY: 1 + GH_AW_MCP_CONFIG: /tmp/gh-aw/mcp-config/mcp-servers.json + GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt + GITHUB_WORKSPACE: ${{ github.workspace }} + MCP_TIMEOUT: 120000 + MCP_TOOL_TIMEOUT: 60000 + - name: Configure Git credentials + env: + REPO_NAME: ${{ github.repository }} + SERVER_URL: ${{ github.server_url }} + run: | + git config --global user.email "github-actions[bot]@users.noreply.github.com" + git config --global user.name "github-actions[bot]" + # Re-authenticate git with GitHub token + SERVER_URL_STRIPPED="${SERVER_URL#https://}" + git remote set-url origin "https://x-access-token:${{ github.token }}@${SERVER_URL_STRIPPED}/${REPO_NAME}.git" + echo "Git configured with standard GitHub Actions identity" + - name: Stop MCP Gateway + if: always() + continue-on-error: true + env: + MCP_GATEWAY_PORT: ${{ steps.start-mcp-gateway.outputs.gateway-port }} + MCP_GATEWAY_API_KEY: ${{ steps.start-mcp-gateway.outputs.gateway-api-key }} + GATEWAY_PID: ${{ steps.start-mcp-gateway.outputs.gateway-pid }} + run: | + bash /opt/gh-aw/actions/stop_mcp_gateway.sh "$GATEWAY_PID" + - name: Redact secrets in logs + if: always() + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + with: + script: | + const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('/opt/gh-aw/actions/redact_secrets.cjs'); + await main(); + env: + GH_AW_SECRET_NAMES: 'ANTHROPIC_API_KEY,CLAUDE_CODE_OAUTH_TOKEN,GH_AW_GITHUB_MCP_SERVER_TOKEN,GH_AW_GITHUB_TOKEN,GITHUB_TOKEN,PHPSTAN_BOT_TOKEN' + SECRET_ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + SECRET_CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + SECRET_GH_AW_GITHUB_MCP_SERVER_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN }} + SECRET_GH_AW_GITHUB_TOKEN: ${{ secrets.GH_AW_GITHUB_TOKEN }} + SECRET_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + SECRET_PHPSTAN_BOT_TOKEN: ${{ secrets.PHPSTAN_BOT_TOKEN }} + - name: Parse agent logs for step summary + if: always() + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + GH_AW_AGENT_OUTPUT: /tmp/gh-aw/agent-stdio.log + with: + script: | + const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('/opt/gh-aw/actions/parse_claude_log.cjs'); + await main(); + - name: Parse MCP Gateway logs for step summary + if: always() + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + with: + script: | + const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('/opt/gh-aw/actions/parse_mcp_gateway_log.cjs'); + await main(); + - name: Print firewall logs + if: always() + continue-on-error: true + env: + AWF_LOGS_DIR: /tmp/gh-aw/sandbox/firewall/logs + run: | + # Fix permissions on firewall logs so they can be uploaded as artifacts + # AWF runs with sudo, creating files owned by root + sudo chmod -R a+r /tmp/gh-aw/sandbox/firewall/logs 2>/dev/null || true + # Only run awf logs summary if awf command exists (it may not be installed if workflow failed before install step) + if command -v awf &> /dev/null; then + awf logs summary | tee -a "$GITHUB_STEP_SUMMARY" + else + echo 'AWF binary not installed, skipping firewall log summary' + fi + - name: Upload agent artifacts + if: always() + continue-on-error: true + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 + with: + name: agent-artifacts + path: | + /tmp/gh-aw/aw-prompts/prompt.txt + /tmp/gh-aw/aw_info.json + /tmp/gh-aw/mcp-logs/ + /tmp/gh-aw/sandbox/firewall/logs/ + /tmp/gh-aw/agent-stdio.log + /tmp/gh-aw/agent/ + if-no-files-found: ignore + + pre_activation: + runs-on: ubuntu-slim + outputs: + activated: ${{ steps.check_membership.outputs.is_team_member == 'true' }} + steps: + - name: Setup Scripts + uses: github/gh-aw/actions/setup@ac090214a48a1938f7abafe132460b66752261af # v0.45.4 + with: + destination: /opt/gh-aw/actions + - name: Check team membership for workflow + id: check_membership + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + GH_AW_REQUIRED_ROLES: admin,maintainer,write + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('/opt/gh-aw/actions/check_membership.cjs'); + await main(); + diff --git a/.github/workflows/document-phpdoc-types.md b/.github/workflows/document-phpdoc-types.md new file mode 100644 index 00000000000..61258a431e5 --- /dev/null +++ b/.github/workflows/document-phpdoc-types.md @@ -0,0 +1,113 @@ +--- +name: Document PHPDoc Types +description: Finds undocumented PHPDoc types in TypeNodeResolver and creates documentation PRs on phpstan/phpstan +on: + push: + branches: [2.2.x] + paths: [src/PhpDoc/TypeNodeResolver.php] + workflow_dispatch: +engine: + id: claude + model: claude-opus-4-6 + env: + CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} +permissions: + contents: read + issues: read + pull-requests: read +tools: + bash: ["*"] + github: + toolsets: [default, repos] +timeout-minutes: 30 +steps: + - uses: actions/checkout@v4 + - uses: actions/checkout@v4 + with: + repository: phpstan/phpstan + ref: 2.2.x + path: __phpstan-website + token: ${{ secrets.PHPSTAN_BOT_TOKEN }} +--- + +# Document Undocumented PHPDoc Types + +You are a documentation agent for PHPStan. Your job is to find PHPDoc types supported by `TypeNodeResolver` that are not yet documented in the user-facing PHPDoc types reference, and to add documentation for them. + +## Source files + +- **Type resolver**: `src/PhpDoc/TypeNodeResolver.php` in this workspace (phpstan-src repo) +- **PHPDoc types docs**: `__phpstan-website/website/src/writing-php-code/phpdoc-types.md` (checked out from `phpstan/phpstan`) + +## Task + +### Step 1: Extract supported types from TypeNodeResolver + +Read `src/PhpDoc/TypeNodeResolver.php` and extract every type name that it resolves. The types come from two places: + +1. **`resolveIdentifierTypeNode()`** — contains a `switch (strtolower($typeNode->name))` with `case` entries for each identifier type (e.g. `int`, `non-empty-string`, `callable-object`, etc.). + +2. **`resolveGenericTypeNode()`** — contains `if`/`elseif` checks on `$mainTypeName` for generic type forms (e.g. `array`, `class-string`, `key-of`, `int-mask`, etc.). + +**Skip** any type names that begin with `__` (double underscore) — these are internal. + +### Step 2: Extract documented types from phpdoc-types.md + +Read `__phpstan-website/website/src/writing-php-code/phpdoc-types.md` and extract all type names that are already documented. Types appear as: +- Bullet list items with inline code (e.g. `* \`int\`, \`integer\``) +- In code block examples +- In prose descriptions (e.g. "`non-falsy-string` (also known as `truthy-string`)") + +Be thorough — a type counts as "documented" even if it only appears as a secondary mention, alias, or in a code example. + +### Step 3: Compare and identify undocumented types + +{{#if github.event_name == 'push'}} +Focus only on types that were added or changed in this push. Run `git diff ${{ github.event.before }} -- src/PhpDoc/TypeNodeResolver.php` to see what changed. Only document newly added types. +{{#else}} +Compare ALL non-skipped types from TypeNodeResolver against the documentation. Document every supported type that is not yet mentioned anywhere in phpdoc-types.md. +{{/if}} + +If there are no undocumented types, stop and report that all types are documented. Do not create a PR. + +### Step 4: Add documentation for undocumented types + +Edit `__phpstan-website/website/src/writing-php-code/phpdoc-types.md` to add the missing types. Use **targeted edits** — do not overwrite the file. + +**Placement rules** — add each type to the correct existing section: + +- Integer types/ranges → "Integer ranges" section +- String types → "Other advanced string types" section +- Array types → "General arrays" section +- Class/interface/trait/enum string types → "class-string" section +- Callable types → "Callables" or "Basic types" section as appropriate +- Bottom type synonyms → "Bottom type" section +- Mixed variants → "Mixed" section +- Scalar variants → "Basic types" section +- Object variants → "Basic types" section + +**Follow the existing writing style exactly.** The documentation is concise: + +- For types added to a bullet list, just add a new `* \`type-name\`` entry or append to an existing line (e.g. adding `noreturn` to the bottom type synonyms list). +- For types that need a brief explanation, write one or two sentences in the same style as existing entries. For example, the string types section uses patterns like: + - `` `non-empty-string` is any string except `''`. `` + - `` `lowercase-string` accepts strings where `strtolower($string) === $string` is true. `` +- Only add code examples if the type's behavior is non-obvious. +- If the new type is an alias or synonym of an already-documented type, mention it alongside the existing type (e.g. add `noreturn` to the bottom type list, add `interface-string` next to `class-string`). + +### Step 5: Create a pull request + +After editing the documentation file, push the changes and create a PR on `phpstan/phpstan`: + +```bash +cd __phpstan-website +git config user.name "phpstan-bot" +git config user.email "ondrej+phpstanbot@mirtes.cz" +git checkout -b docs/undocumented-phpdoc-types +git add website/src/writing-php-code/phpdoc-types.md +git commit -m "Document undocumented PHPDoc types" +git push origin docs/undocumented-phpdoc-types +gh pr create --repo phpstan/phpstan --base 2.2.x --draft --title "[Docs] Document undocumented PHPDoc types" --body "PR DESCRIPTION HERE" +``` + +Replace `PR DESCRIPTION HERE` with a description listing which types were newly documented, grouped by section. From 2246d72d9429d7687bd29984563ee316fcc0fee8 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Sun, 22 Feb 2026 20:12:19 +0100 Subject: [PATCH 021/158] claude-random-easy-fixes-scheduled.yml - generate fixes for 20 issues only --- .github/workflows/claude-random-easy-fixes-scheduled.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/claude-random-easy-fixes-scheduled.yml b/.github/workflows/claude-random-easy-fixes-scheduled.yml index 22650fb78d8..1ddb9c24f53 100644 --- a/.github/workflows/claude-random-easy-fixes-scheduled.yml +++ b/.github/workflows/claude-random-easy-fixes-scheduled.yml @@ -2,8 +2,8 @@ name: "Claude Random Easy Fixes (Scheduled)" on: schedule: - # Run 10 times, once an hour at :15, from 10pm CEST (20:00 UTC) to 7am CEST (05:00 UTC) - - cron: '15 20-23,0-5 * * *' + # Run 4 times, once an hour at :15, from 9pm CET (20:00 UTC) to 12am CET (23:00 UTC) + - cron: '15 20-23 * * *' jobs: trigger: From d74052a2457e9235e1d0245835ad816d57ad8392 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Tue, 24 Feb 2026 09:47:51 +0100 Subject: [PATCH 022/158] Remove agentic workflows --- .github/agents/agentic-workflows.agent.md | 167 ----- .github/aw/actions-lock.json | 44 -- .../workflows/document-config-params.lock.yml | 575 ------------------ .github/workflows/document-config-params.md | 183 ------ .../workflows/document-phpdoc-tags.lock.yml | 575 ------------------ .github/workflows/document-phpdoc-tags.md | 125 ---- .../workflows/document-phpdoc-types.lock.yml | 575 ------------------ .github/workflows/document-phpdoc-types.md | 113 ---- 8 files changed, 2357 deletions(-) delete mode 100644 .github/agents/agentic-workflows.agent.md delete mode 100644 .github/aw/actions-lock.json delete mode 100644 .github/workflows/document-config-params.lock.yml delete mode 100644 .github/workflows/document-config-params.md delete mode 100644 .github/workflows/document-phpdoc-tags.lock.yml delete mode 100644 .github/workflows/document-phpdoc-tags.md delete mode 100644 .github/workflows/document-phpdoc-types.lock.yml delete mode 100644 .github/workflows/document-phpdoc-types.md diff --git a/.github/agents/agentic-workflows.agent.md b/.github/agents/agentic-workflows.agent.md deleted file mode 100644 index dea035c3519..00000000000 --- a/.github/agents/agentic-workflows.agent.md +++ /dev/null @@ -1,167 +0,0 @@ ---- -description: GitHub Agentic Workflows (gh-aw) - Create, debug, and upgrade AI-powered workflows with intelligent prompt routing -infer: false ---- - -# GitHub Agentic Workflows Agent - -This agent helps you work with **GitHub Agentic Workflows (gh-aw)**, a CLI extension for creating AI-powered workflows in natural language using markdown files. - -## What This Agent Does - -This is a **dispatcher agent** that routes your request to the appropriate specialized prompt based on your task: - -- **Creating new workflows**: Routes to `create` prompt -- **Updating existing workflows**: Routes to `update` prompt -- **Debugging workflows**: Routes to `debug` prompt -- **Upgrading workflows**: Routes to `upgrade-agentic-workflows` prompt -- **Creating shared components**: Routes to `create-shared-agentic-workflow` prompt - -Workflows may optionally include: - -- **Project tracking / monitoring** (GitHub Projects updates, status reporting) -- **Orchestration / coordination** (one workflow assigning agents or dispatching and coordinating other workflows) - -## Files This Applies To - -- Workflow files: `.github/workflows/*.md` and `.github/workflows/**/*.md` -- Workflow lock files: `.github/workflows/*.lock.yml` -- Shared components: `.github/workflows/shared/*.md` -- Configuration: https://github.com/github/gh-aw/blob/v0.43.23/.github/aw/github-agentic-workflows.md - -## Problems This Solves - -- **Workflow Creation**: Design secure, validated agentic workflows with proper triggers, tools, and permissions -- **Workflow Debugging**: Analyze logs, identify missing tools, investigate failures, and fix configuration issues -- **Version Upgrades**: Migrate workflows to new gh-aw versions, apply codemods, fix breaking changes -- **Component Design**: Create reusable shared workflow components that wrap MCP servers - -## How to Use - -When you interact with this agent, it will: - -1. **Understand your intent** - Determine what kind of task you're trying to accomplish -2. **Route to the right prompt** - Load the specialized prompt file for your task -3. **Execute the task** - Follow the detailed instructions in the loaded prompt - -## Available Prompts - -### Create New Workflow -**Load when**: User wants to create a new workflow from scratch, add automation, or design a workflow that doesn't exist yet - -**Prompt file**: https://github.com/github/gh-aw/blob/v0.43.23/.github/aw/create-agentic-workflow.md - -**Use cases**: -- "Create a workflow that triages issues" -- "I need a workflow to label pull requests" -- "Design a weekly research automation" - -### Update Existing Workflow -**Load when**: User wants to modify, improve, or refactor an existing workflow - -**Prompt file**: https://github.com/github/gh-aw/blob/v0.43.23/.github/aw/update-agentic-workflow.md - -**Use cases**: -- "Add web-fetch tool to the issue-classifier workflow" -- "Update the PR reviewer to use discussions instead of issues" -- "Improve the prompt for the weekly-research workflow" - -### Debug Workflow -**Load when**: User needs to investigate, audit, debug, or understand a workflow, troubleshoot issues, analyze logs, or fix errors - -**Prompt file**: https://github.com/github/gh-aw/blob/v0.43.23/.github/aw/debug-agentic-workflow.md - -**Use cases**: -- "Why is this workflow failing?" -- "Analyze the logs for workflow X" -- "Investigate missing tool calls in run #12345" - -### Upgrade Agentic Workflows -**Load when**: User wants to upgrade workflows to a new gh-aw version or fix deprecations - -**Prompt file**: https://github.com/github/gh-aw/blob/v0.43.23/.github/aw/upgrade-agentic-workflows.md - -**Use cases**: -- "Upgrade all workflows to the latest version" -- "Fix deprecated fields in workflows" -- "Apply breaking changes from the new release" - -### Create Shared Agentic Workflow -**Load when**: User wants to create a reusable workflow component or wrap an MCP server - -**Prompt file**: https://github.com/github/gh-aw/blob/v0.43.23/.github/aw/create-shared-agentic-workflow.md - -**Use cases**: -- "Create a shared component for Notion integration" -- "Wrap the Slack MCP server as a reusable component" -- "Design a shared workflow for database queries" - -### Orchestration and Delegation - -**Load when**: Creating or updating workflows that coordinate multiple agents or dispatch work to other workflows - -**Prompt file**: https://github.com/github/gh-aw/blob/v0.43.23/.github/aw/orchestration.md - -**Use cases**: -- Assigning work to AI coding agents -- Dispatching specialized worker workflows -- Using correlation IDs for tracking -- Orchestration design patterns - -### GitHub Projects Integration - -**Load when**: Creating or updating workflows that manage GitHub Projects v2 - -**Prompt file**: https://github.com/github/gh-aw/blob/v0.43.23/.github/aw/projects.md - -**Use cases**: -- Tracking items and fields with update-project -- Posting periodic run summaries -- Creating new projects -- Projects v2 authentication and configuration - -## Instructions - -When a user interacts with you: - -1. **Identify the task type** from the user's request -2. **Load the appropriate prompt** from the GitHub repository URLs listed above -3. **Follow the loaded prompt's instructions** exactly -4. **If uncertain**, ask clarifying questions to determine the right prompt - -## Quick Reference - -```bash -# Initialize repository for agentic workflows -gh aw init - -# Generate the lock file for a workflow -gh aw compile [workflow-name] - -# Debug workflow runs -gh aw logs [workflow-name] -gh aw audit - -# Upgrade workflows -gh aw fix --write -gh aw compile --validate -``` - -## Key Features of gh-aw - -- **Natural Language Workflows**: Write workflows in markdown with YAML frontmatter -- **AI Engine Support**: Copilot, Claude, Codex, or custom engines -- **MCP Server Integration**: Connect to Model Context Protocol servers for tools -- **Safe Outputs**: Structured communication between AI and GitHub API -- **Strict Mode**: Security-first validation and sandboxing -- **Shared Components**: Reusable workflow building blocks -- **Repo Memory**: Persistent git-backed storage for agents -- **Sandboxed Execution**: All workflows run in the Agent Workflow Firewall (AWF) sandbox, enabling full `bash` and `edit` tools by default - -## Important Notes - -- Always reference the instructions file at https://github.com/github/gh-aw/blob/v0.43.23/.github/aw/github-agentic-workflows.md for complete documentation -- Use the MCP tool `agentic-workflows` when running in GitHub Copilot Cloud -- Workflows must be compiled to `.lock.yml` files before running in GitHub Actions -- **Bash tools are enabled by default** - Don't restrict bash commands unnecessarily since workflows are sandboxed by the AWF -- Follow security best practices: minimal permissions, explicit network access, no template injection diff --git a/.github/aw/actions-lock.json b/.github/aw/actions-lock.json deleted file mode 100644 index 5e9220a26b1..00000000000 --- a/.github/aw/actions-lock.json +++ /dev/null @@ -1,44 +0,0 @@ -{ - "entries": { - "actions/checkout@v4.3.1": { - "repo": "actions/checkout", - "version": "v4.3.1", - "sha": "34e114876b0b11c390a56381ad16ebd13914f8d5" - }, - "actions/checkout@v6.0.2": { - "repo": "actions/checkout", - "version": "v6.0.2", - "sha": "de0fac2e4500dabe0009e67214ff5f5447ce83dd" - }, - "actions/download-artifact@v6.0.0": { - "repo": "actions/download-artifact", - "version": "v6.0.0", - "sha": "018cc2cf5baa6db3ef3c5f8a56943fffe632ef53" - }, - "actions/github-script@v8": { - "repo": "actions/github-script", - "version": "v8", - "sha": "ed597411d8f924073f98dfc5c65a23a2325f34cd" - }, - "actions/setup-node@v6.2.0": { - "repo": "actions/setup-node", - "version": "v6.2.0", - "sha": "6044e13b5dc448c55e2357c09f80417699197238" - }, - "actions/upload-artifact@v6.0.0": { - "repo": "actions/upload-artifact", - "version": "v6.0.0", - "sha": "b7c566a772e6b6bfb58ed0dc250532a479d7789f" - }, - "github/gh-aw/actions/setup@v0.45.4": { - "repo": "github/gh-aw/actions/setup", - "version": "v0.45.4", - "sha": "ac090214a48a1938f7abafe132460b66752261af" - }, - "github/gh-aw/actions/setup@v0.48.1": { - "repo": "github/gh-aw/actions/setup", - "version": "v0.48.1", - "sha": "26b6572ae210580303087bc3142fe58d140bf65c" - } - } -} diff --git a/.github/workflows/document-config-params.lock.yml b/.github/workflows/document-config-params.lock.yml deleted file mode 100644 index b5be8ab9735..00000000000 --- a/.github/workflows/document-config-params.lock.yml +++ /dev/null @@ -1,575 +0,0 @@ -# -# ___ _ _ -# / _ \ | | (_) -# | |_| | __ _ ___ _ __ | |_ _ ___ -# | _ |/ _` |/ _ \ '_ \| __| |/ __| -# | | | | (_| | __/ | | | |_| | (__ -# \_| |_/\__, |\___|_| |_|\__|_|\___| -# __/ | -# _ _ |___/ -# | | | | / _| | -# | | | | ___ _ __ _ __| |_| | _____ ____ -# | |/\| |/ _ \ '__| |/ /| _| |/ _ \ \ /\ / / ___| -# \ /\ / (_) | | | | ( | | | | (_) \ V V /\__ \ -# \/ \/ \___/|_| |_|\_\|_| |_|\___/ \_/\_/ |___/ -# -# This file was automatically generated by gh-aw (v0.45.4). DO NOT EDIT. -# -# To update this file, edit the corresponding .md file and run: -# gh aw compile -# Not all edits will cause changes to this file. -# -# For more information: https://github.github.com/gh-aw/introduction/overview/ -# -# Finds undocumented PHPStan config parameters and creates documentation PRs on phpstan/phpstan -# -# frontmatter-hash: 31bb738b106c65eb8c5258fe6d0d61365db8ea6b0f389fc75193988252680777 - -name: "Document Config Parameters" -"on": - push: - branches: - - 2.2.x - paths: - - conf/parametersSchema.neon - workflow_dispatch: - -permissions: {} - -concurrency: - group: "gh-aw-${{ github.workflow }}-${{ github.ref }}" - -run-name: "Document Config Parameters" - -jobs: - activation: - needs: pre_activation - if: needs.pre_activation.outputs.activated == 'true' - runs-on: ubuntu-slim - permissions: - contents: read - outputs: - comment_id: "" - comment_repo: "" - steps: - - name: Setup Scripts - uses: github/gh-aw/actions/setup@ac090214a48a1938f7abafe132460b66752261af # v0.45.4 - with: - destination: /opt/gh-aw/actions - - name: Checkout .github and .agents folders - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - sparse-checkout: | - .github - .agents - fetch-depth: 1 - persist-credentials: false - - name: Check workflow file timestamps - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - GH_AW_WORKFLOW_FILE: "document-config-params.lock.yml" - with: - script: | - const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io); - const { main } = require('/opt/gh-aw/actions/check_workflow_timestamp_api.cjs'); - await main(); - - name: Create prompt with built-in context - env: - GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt - GH_AW_GITHUB_ACTOR: ${{ github.actor }} - GH_AW_GITHUB_EVENT_BEFORE: ${{ github.event.before }} - GH_AW_GITHUB_EVENT_COMMENT_ID: ${{ github.event.comment.id }} - GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER: ${{ github.event.discussion.number }} - GH_AW_GITHUB_EVENT_ISSUE_NUMBER: ${{ github.event.issue.number }} - GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER: ${{ github.event.pull_request.number }} - GH_AW_GITHUB_REPOSITORY: ${{ github.repository }} - GH_AW_GITHUB_RUN_ID: ${{ github.run_id }} - GH_AW_GITHUB_WORKSPACE: ${{ github.workspace }} - run: | - bash /opt/gh-aw/actions/create_prompt_first.sh - cat << 'GH_AW_PROMPT_EOF' > "$GH_AW_PROMPT" - - GH_AW_PROMPT_EOF - cat "/opt/gh-aw/prompts/xpia.md" >> "$GH_AW_PROMPT" - cat "/opt/gh-aw/prompts/temp_folder_prompt.md" >> "$GH_AW_PROMPT" - cat "/opt/gh-aw/prompts/markdown.md" >> "$GH_AW_PROMPT" - cat << 'GH_AW_PROMPT_EOF' >> "$GH_AW_PROMPT" - - The following GitHub context information is available for this workflow: - {{#if __GH_AW_GITHUB_ACTOR__ }} - - **actor**: __GH_AW_GITHUB_ACTOR__ - {{/if}} - {{#if __GH_AW_GITHUB_REPOSITORY__ }} - - **repository**: __GH_AW_GITHUB_REPOSITORY__ - {{/if}} - {{#if __GH_AW_GITHUB_WORKSPACE__ }} - - **workspace**: __GH_AW_GITHUB_WORKSPACE__ - {{/if}} - {{#if __GH_AW_GITHUB_EVENT_ISSUE_NUMBER__ }} - - **issue-number**: #__GH_AW_GITHUB_EVENT_ISSUE_NUMBER__ - {{/if}} - {{#if __GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER__ }} - - **discussion-number**: #__GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER__ - {{/if}} - {{#if __GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER__ }} - - **pull-request-number**: #__GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER__ - {{/if}} - {{#if __GH_AW_GITHUB_EVENT_COMMENT_ID__ }} - - **comment-id**: __GH_AW_GITHUB_EVENT_COMMENT_ID__ - {{/if}} - {{#if __GH_AW_GITHUB_RUN_ID__ }} - - **workflow-run-id**: __GH_AW_GITHUB_RUN_ID__ - {{/if}} - - - GH_AW_PROMPT_EOF - cat << 'GH_AW_PROMPT_EOF' >> "$GH_AW_PROMPT" - - GH_AW_PROMPT_EOF - cat << 'GH_AW_PROMPT_EOF' >> "$GH_AW_PROMPT" - {{#runtime-import .github/workflows/document-config-params.md}} - GH_AW_PROMPT_EOF - - name: Interpolate variables and render templates - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt - GH_AW_GITHUB_EVENT_BEFORE: ${{ github.event.before }} - with: - script: | - const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io); - const { main } = require('/opt/gh-aw/actions/interpolate_prompt.cjs'); - await main(); - - name: Substitute placeholders - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt - GH_AW_GITHUB_ACTOR: ${{ github.actor }} - GH_AW_GITHUB_EVENT_BEFORE: ${{ github.event.before }} - GH_AW_GITHUB_EVENT_COMMENT_ID: ${{ github.event.comment.id }} - GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER: ${{ github.event.discussion.number }} - GH_AW_GITHUB_EVENT_ISSUE_NUMBER: ${{ github.event.issue.number }} - GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER: ${{ github.event.pull_request.number }} - GH_AW_GITHUB_REPOSITORY: ${{ github.repository }} - GH_AW_GITHUB_RUN_ID: ${{ github.run_id }} - GH_AW_GITHUB_WORKSPACE: ${{ github.workspace }} - GH_AW_NEEDS_PRE_ACTIVATION_OUTPUTS_ACTIVATED: ${{ needs.pre_activation.outputs.activated }} - GH_AW_NEEDS_PRE_ACTIVATION_OUTPUTS_MATCHED_COMMAND: ${{ needs.pre_activation.outputs.matched_command }} - with: - script: | - const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io); - - const substitutePlaceholders = require('/opt/gh-aw/actions/substitute_placeholders.cjs'); - - // Call the substitution function - return await substitutePlaceholders({ - file: process.env.GH_AW_PROMPT, - substitutions: { - GH_AW_GITHUB_ACTOR: process.env.GH_AW_GITHUB_ACTOR, - GH_AW_GITHUB_EVENT_BEFORE: process.env.GH_AW_GITHUB_EVENT_BEFORE, - GH_AW_GITHUB_EVENT_COMMENT_ID: process.env.GH_AW_GITHUB_EVENT_COMMENT_ID, - GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER: process.env.GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER, - GH_AW_GITHUB_EVENT_ISSUE_NUMBER: process.env.GH_AW_GITHUB_EVENT_ISSUE_NUMBER, - GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER: process.env.GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER, - GH_AW_GITHUB_REPOSITORY: process.env.GH_AW_GITHUB_REPOSITORY, - GH_AW_GITHUB_RUN_ID: process.env.GH_AW_GITHUB_RUN_ID, - GH_AW_GITHUB_WORKSPACE: process.env.GH_AW_GITHUB_WORKSPACE, - GH_AW_NEEDS_PRE_ACTIVATION_OUTPUTS_ACTIVATED: process.env.GH_AW_NEEDS_PRE_ACTIVATION_OUTPUTS_ACTIVATED, - GH_AW_NEEDS_PRE_ACTIVATION_OUTPUTS_MATCHED_COMMAND: process.env.GH_AW_NEEDS_PRE_ACTIVATION_OUTPUTS_MATCHED_COMMAND - } - }); - - name: Validate prompt placeholders - env: - GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt - run: bash /opt/gh-aw/actions/validate_prompt_placeholders.sh - - name: Print prompt - env: - GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt - run: bash /opt/gh-aw/actions/print_prompt_summary.sh - - name: Upload prompt artifact - if: success() - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 - with: - name: prompt - path: /tmp/gh-aw/aw-prompts/prompt.txt - retention-days: 1 - - agent: - needs: activation - runs-on: ubuntu-latest - permissions: - contents: read - issues: read - pull-requests: read - env: - GH_AW_WORKFLOW_ID_SANITIZED: documentconfigparams - outputs: - checkout_pr_success: ${{ steps.checkout-pr.outputs.checkout_pr_success || 'true' }} - model: ${{ steps.generate_aw_info.outputs.model }} - secret_verification_result: ${{ steps.validate-secret.outputs.verification_result }} - steps: - - name: Setup Scripts - uses: github/gh-aw/actions/setup@ac090214a48a1938f7abafe132460b66752261af # v0.45.4 - with: - destination: /opt/gh-aw/actions - - name: Create gh-aw temp directory - run: bash /opt/gh-aw/actions/create_gh_aw_tmp_dir.sh - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - with: - path: __phpstan-website - ref: 2.2.x - repository: phpstan/phpstan - token: ${{ secrets.PHPSTAN_BOT_TOKEN }} - - - name: Configure Git credentials - env: - REPO_NAME: ${{ github.repository }} - SERVER_URL: ${{ github.server_url }} - run: | - git config --global user.email "github-actions[bot]@users.noreply.github.com" - git config --global user.name "github-actions[bot]" - # Re-authenticate git with GitHub token - SERVER_URL_STRIPPED="${SERVER_URL#https://}" - git remote set-url origin "https://x-access-token:${{ github.token }}@${SERVER_URL_STRIPPED}/${REPO_NAME}.git" - echo "Git configured with standard GitHub Actions identity" - - name: Checkout PR branch - id: checkout-pr - if: | - github.event.pull_request - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - GH_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} - with: - github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} - script: | - const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io); - const { main } = require('/opt/gh-aw/actions/checkout_pr_branch.cjs'); - await main(); - - name: Generate agentic run info - id: generate_aw_info - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - with: - script: | - const fs = require('fs'); - - const awInfo = { - engine_id: "claude", - engine_name: "Claude Code", - model: "claude-opus-4-6", - version: "", - agent_version: "2.1.42", - cli_version: "v0.45.4", - workflow_name: "Document Config Parameters", - experimental: false, - supports_tools_allowlist: true, - run_id: context.runId, - run_number: context.runNumber, - run_attempt: process.env.GITHUB_RUN_ATTEMPT, - repository: context.repo.owner + '/' + context.repo.repo, - ref: context.ref, - sha: context.sha, - actor: context.actor, - event_name: context.eventName, - staged: false, - allowed_domains: ["defaults"], - firewall_enabled: true, - awf_version: "v0.19.1", - awmg_version: "v0.1.4", - steps: { - firewall: "squid" - }, - created_at: new Date().toISOString() - }; - - // Write to /tmp/gh-aw directory to avoid inclusion in PR - const tmpPath = '/tmp/gh-aw/aw_info.json'; - fs.writeFileSync(tmpPath, JSON.stringify(awInfo, null, 2)); - console.log('Generated aw_info.json at:', tmpPath); - console.log(JSON.stringify(awInfo, null, 2)); - - // Set model as output for reuse in other steps/jobs - core.setOutput('model', awInfo.model); - - name: Validate CLAUDE_CODE_OAUTH_TOKEN or ANTHROPIC_API_KEY secret - id: validate-secret - run: /opt/gh-aw/actions/validate_multi_secret.sh CLAUDE_CODE_OAUTH_TOKEN ANTHROPIC_API_KEY 'Claude Code' https://github.github.com/gh-aw/reference/engines/#anthropic-claude-code - env: - CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} - ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} - - name: Setup Node.js - uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 - with: - node-version: '24' - package-manager-cache: false - - name: Install awf binary - run: bash /opt/gh-aw/actions/install_awf_binary.sh v0.19.1 - - name: Install Claude Code CLI - run: npm install -g --silent @anthropic-ai/claude-code@2.1.42 - - name: Determine automatic lockdown mode for GitHub MCP Server - id: determine-automatic-lockdown - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - GH_AW_GITHUB_TOKEN: ${{ secrets.GH_AW_GITHUB_TOKEN }} - GH_AW_GITHUB_MCP_SERVER_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN }} - with: - script: | - const determineAutomaticLockdown = require('/opt/gh-aw/actions/determine_automatic_lockdown.cjs'); - await determineAutomaticLockdown(github, context, core); - - name: Download container images - run: bash /opt/gh-aw/actions/download_docker_images.sh ghcr.io/github/gh-aw-firewall/agent:0.19.1 ghcr.io/github/gh-aw-firewall/api-proxy:0.19.1 ghcr.io/github/gh-aw-firewall/squid:0.19.1 ghcr.io/github/gh-aw-mcpg:v0.1.4 ghcr.io/github/github-mcp-server:v0.30.3 - - name: Start MCP Gateway - id: start-mcp-gateway - env: - GITHUB_MCP_LOCKDOWN: ${{ steps.determine-automatic-lockdown.outputs.lockdown == 'true' && '1' || '0' }} - GITHUB_MCP_SERVER_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} - run: | - set -eo pipefail - mkdir -p /tmp/gh-aw/mcp-config - - # Export gateway environment variables for MCP config and gateway script - export MCP_GATEWAY_PORT="80" - export MCP_GATEWAY_DOMAIN="host.docker.internal" - MCP_GATEWAY_API_KEY=$(openssl rand -base64 45 | tr -d '/+=') - echo "::add-mask::${MCP_GATEWAY_API_KEY}" - export MCP_GATEWAY_API_KEY - export MCP_GATEWAY_PAYLOAD_DIR="/tmp/gh-aw/mcp-payloads" - mkdir -p "${MCP_GATEWAY_PAYLOAD_DIR}" - export DEBUG="*" - - export GH_AW_ENGINE="claude" - export MCP_GATEWAY_DOCKER_COMMAND='docker run -i --rm --network host -v /var/run/docker.sock:/var/run/docker.sock -e MCP_GATEWAY_PORT -e MCP_GATEWAY_DOMAIN -e MCP_GATEWAY_API_KEY -e MCP_GATEWAY_PAYLOAD_DIR -e DEBUG -e MCP_GATEWAY_LOG_DIR -e GH_AW_MCP_LOG_DIR -e GH_AW_SAFE_OUTPUTS -e GH_AW_SAFE_OUTPUTS_CONFIG_PATH -e GH_AW_SAFE_OUTPUTS_TOOLS_PATH -e GH_AW_ASSETS_BRANCH -e GH_AW_ASSETS_MAX_SIZE_KB -e GH_AW_ASSETS_ALLOWED_EXTS -e DEFAULT_BRANCH -e GITHUB_MCP_SERVER_TOKEN -e GITHUB_MCP_LOCKDOWN -e GITHUB_REPOSITORY -e GITHUB_SERVER_URL -e GITHUB_SHA -e GITHUB_WORKSPACE -e GITHUB_TOKEN -e GITHUB_RUN_ID -e GITHUB_RUN_NUMBER -e GITHUB_RUN_ATTEMPT -e GITHUB_JOB -e GITHUB_ACTION -e GITHUB_EVENT_NAME -e GITHUB_EVENT_PATH -e GITHUB_ACTOR -e GITHUB_ACTOR_ID -e GITHUB_TRIGGERING_ACTOR -e GITHUB_WORKFLOW -e GITHUB_WORKFLOW_REF -e GITHUB_WORKFLOW_SHA -e GITHUB_REF -e GITHUB_REF_NAME -e GITHUB_REF_TYPE -e GITHUB_HEAD_REF -e GITHUB_BASE_REF -v /tmp/gh-aw/mcp-payloads:/tmp/gh-aw/mcp-payloads:rw -v /opt:/opt:ro -v /tmp:/tmp:rw -v '"${GITHUB_WORKSPACE}"':'"${GITHUB_WORKSPACE}"':rw ghcr.io/github/gh-aw-mcpg:v0.1.4' - - cat << GH_AW_MCP_CONFIG_EOF | bash /opt/gh-aw/actions/start_mcp_gateway.sh - { - "mcpServers": { - "github": { - "container": "ghcr.io/github/github-mcp-server:v0.30.3", - "env": { - "GITHUB_LOCKDOWN_MODE": "$GITHUB_MCP_LOCKDOWN", - "GITHUB_PERSONAL_ACCESS_TOKEN": "$GITHUB_MCP_SERVER_TOKEN", - "GITHUB_READ_ONLY": "1", - "GITHUB_TOOLSETS": "context,repos,issues,pull_requests" - } - } - }, - "gateway": { - "port": $MCP_GATEWAY_PORT, - "domain": "${MCP_GATEWAY_DOMAIN}", - "apiKey": "${MCP_GATEWAY_API_KEY}", - "payloadDir": "${MCP_GATEWAY_PAYLOAD_DIR}" - } - } - GH_AW_MCP_CONFIG_EOF - - name: Generate workflow overview - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - with: - script: | - const { generateWorkflowOverview } = require('/opt/gh-aw/actions/generate_workflow_overview.cjs'); - await generateWorkflowOverview(core); - - name: Download prompt artifact - uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 - with: - name: prompt - path: /tmp/gh-aw/aw-prompts - - name: Clean git credentials - run: bash /opt/gh-aw/actions/clean_git_credentials.sh - - name: Execute Claude Code CLI - id: agentic_execution - # Allowed tools (sorted): - # - Bash - # - BashOutput - # - Edit - # - ExitPlanMode - # - Glob - # - Grep - # - KillBash - # - LS - # - MultiEdit - # - NotebookEdit - # - NotebookRead - # - Read - # - Task - # - TodoWrite - # - Write - # - mcp__github__download_workflow_run_artifact - # - mcp__github__get_code_scanning_alert - # - mcp__github__get_commit - # - mcp__github__get_dependabot_alert - # - mcp__github__get_discussion - # - mcp__github__get_discussion_comments - # - mcp__github__get_file_contents - # - mcp__github__get_job_logs - # - mcp__github__get_label - # - mcp__github__get_latest_release - # - mcp__github__get_me - # - mcp__github__get_notification_details - # - mcp__github__get_pull_request - # - mcp__github__get_pull_request_comments - # - mcp__github__get_pull_request_diff - # - mcp__github__get_pull_request_files - # - mcp__github__get_pull_request_review_comments - # - mcp__github__get_pull_request_reviews - # - mcp__github__get_pull_request_status - # - mcp__github__get_release_by_tag - # - mcp__github__get_secret_scanning_alert - # - mcp__github__get_tag - # - mcp__github__get_workflow_run - # - mcp__github__get_workflow_run_logs - # - mcp__github__get_workflow_run_usage - # - mcp__github__issue_read - # - mcp__github__list_branches - # - mcp__github__list_code_scanning_alerts - # - mcp__github__list_commits - # - mcp__github__list_dependabot_alerts - # - mcp__github__list_discussion_categories - # - mcp__github__list_discussions - # - mcp__github__list_issue_types - # - mcp__github__list_issues - # - mcp__github__list_label - # - mcp__github__list_notifications - # - mcp__github__list_pull_requests - # - mcp__github__list_releases - # - mcp__github__list_secret_scanning_alerts - # - mcp__github__list_starred_repositories - # - mcp__github__list_tags - # - mcp__github__list_workflow_jobs - # - mcp__github__list_workflow_run_artifacts - # - mcp__github__list_workflow_runs - # - mcp__github__list_workflows - # - mcp__github__pull_request_read - # - mcp__github__search_code - # - mcp__github__search_issues - # - mcp__github__search_orgs - # - mcp__github__search_pull_requests - # - mcp__github__search_repositories - # - mcp__github__search_users - timeout-minutes: 30 - run: | - set -o pipefail - sudo -E awf --tty --env-all --container-workdir "${GITHUB_WORKSPACE}" --allow-domains '*.githubusercontent.com,anthropic.com,api.anthropic.com,api.github.com,api.snapcraft.io,archive.ubuntu.com,azure.archive.ubuntu.com,cdn.playwright.dev,codeload.github.com,crl.geotrust.com,crl.globalsign.com,crl.identrust.com,crl.sectigo.com,crl.thawte.com,crl.usertrust.com,crl.verisign.com,crl3.digicert.com,crl4.digicert.com,crls.ssl.com,files.pythonhosted.org,ghcr.io,github-cloud.githubusercontent.com,github-cloud.s3.amazonaws.com,github.com,host.docker.internal,json-schema.org,json.schemastore.org,keyserver.ubuntu.com,lfs.github.com,objects.githubusercontent.com,ocsp.digicert.com,ocsp.geotrust.com,ocsp.globalsign.com,ocsp.identrust.com,ocsp.sectigo.com,ocsp.ssl.com,ocsp.thawte.com,ocsp.usertrust.com,ocsp.verisign.com,packagecloud.io,packages.cloud.google.com,packages.microsoft.com,playwright.download.prss.microsoft.com,ppa.launchpad.net,pypi.org,raw.githubusercontent.com,registry.npmjs.org,s.symcb.com,s.symcd.com,security.ubuntu.com,sentry.io,statsig.anthropic.com,ts-crl.ws.symantec.com,ts-ocsp.ws.symantec.com' --log-level info --proxy-logs-dir /tmp/gh-aw/sandbox/firewall/logs --enable-host-access --image-tag 0.19.1 --skip-pull --enable-api-proxy \ - -- /bin/bash -c 'export PATH="$(find /opt/hostedtoolcache -maxdepth 4 -type d -name bin 2>/dev/null | tr '\''\n'\'' '\'':'\'')$PATH"; [ -n "$GOROOT" ] && export PATH="$GOROOT/bin:$PATH" || true && claude --print --disable-slash-commands --no-chrome --model claude-opus-4-6 --mcp-config /tmp/gh-aw/mcp-config/mcp-servers.json --allowed-tools Bash,BashOutput,Edit,ExitPlanMode,Glob,Grep,KillBash,LS,MultiEdit,NotebookEdit,NotebookRead,Read,Task,TodoWrite,Write,mcp__github__download_workflow_run_artifact,mcp__github__get_code_scanning_alert,mcp__github__get_commit,mcp__github__get_dependabot_alert,mcp__github__get_discussion,mcp__github__get_discussion_comments,mcp__github__get_file_contents,mcp__github__get_job_logs,mcp__github__get_label,mcp__github__get_latest_release,mcp__github__get_me,mcp__github__get_notification_details,mcp__github__get_pull_request,mcp__github__get_pull_request_comments,mcp__github__get_pull_request_diff,mcp__github__get_pull_request_files,mcp__github__get_pull_request_review_comments,mcp__github__get_pull_request_reviews,mcp__github__get_pull_request_status,mcp__github__get_release_by_tag,mcp__github__get_secret_scanning_alert,mcp__github__get_tag,mcp__github__get_workflow_run,mcp__github__get_workflow_run_logs,mcp__github__get_workflow_run_usage,mcp__github__issue_read,mcp__github__list_branches,mcp__github__list_code_scanning_alerts,mcp__github__list_commits,mcp__github__list_dependabot_alerts,mcp__github__list_discussion_categories,mcp__github__list_discussions,mcp__github__list_issue_types,mcp__github__list_issues,mcp__github__list_label,mcp__github__list_notifications,mcp__github__list_pull_requests,mcp__github__list_releases,mcp__github__list_secret_scanning_alerts,mcp__github__list_starred_repositories,mcp__github__list_tags,mcp__github__list_workflow_jobs,mcp__github__list_workflow_run_artifacts,mcp__github__list_workflow_runs,mcp__github__list_workflows,mcp__github__pull_request_read,mcp__github__search_code,mcp__github__search_issues,mcp__github__search_orgs,mcp__github__search_pull_requests,mcp__github__search_repositories,mcp__github__search_users --debug-file /tmp/gh-aw/agent-stdio.log --verbose --permission-mode bypassPermissions --output-format stream-json "$(cat /tmp/gh-aw/aw-prompts/prompt.txt)"' 2>&1 | tee -a /tmp/gh-aw/agent-stdio.log - env: - ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} - BASH_DEFAULT_TIMEOUT_MS: 60000 - BASH_MAX_TIMEOUT_MS: 60000 - CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} - DISABLE_BUG_COMMAND: 1 - DISABLE_ERROR_REPORTING: 1 - DISABLE_TELEMETRY: 1 - GH_AW_MCP_CONFIG: /tmp/gh-aw/mcp-config/mcp-servers.json - GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt - GITHUB_WORKSPACE: ${{ github.workspace }} - MCP_TIMEOUT: 120000 - MCP_TOOL_TIMEOUT: 60000 - - name: Configure Git credentials - env: - REPO_NAME: ${{ github.repository }} - SERVER_URL: ${{ github.server_url }} - run: | - git config --global user.email "github-actions[bot]@users.noreply.github.com" - git config --global user.name "github-actions[bot]" - # Re-authenticate git with GitHub token - SERVER_URL_STRIPPED="${SERVER_URL#https://}" - git remote set-url origin "https://x-access-token:${{ github.token }}@${SERVER_URL_STRIPPED}/${REPO_NAME}.git" - echo "Git configured with standard GitHub Actions identity" - - name: Stop MCP Gateway - if: always() - continue-on-error: true - env: - MCP_GATEWAY_PORT: ${{ steps.start-mcp-gateway.outputs.gateway-port }} - MCP_GATEWAY_API_KEY: ${{ steps.start-mcp-gateway.outputs.gateway-api-key }} - GATEWAY_PID: ${{ steps.start-mcp-gateway.outputs.gateway-pid }} - run: | - bash /opt/gh-aw/actions/stop_mcp_gateway.sh "$GATEWAY_PID" - - name: Redact secrets in logs - if: always() - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - with: - script: | - const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io); - const { main } = require('/opt/gh-aw/actions/redact_secrets.cjs'); - await main(); - env: - GH_AW_SECRET_NAMES: 'ANTHROPIC_API_KEY,CLAUDE_CODE_OAUTH_TOKEN,GH_AW_GITHUB_MCP_SERVER_TOKEN,GH_AW_GITHUB_TOKEN,GITHUB_TOKEN,PHPSTAN_BOT_TOKEN' - SECRET_ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} - SECRET_CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} - SECRET_GH_AW_GITHUB_MCP_SERVER_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN }} - SECRET_GH_AW_GITHUB_TOKEN: ${{ secrets.GH_AW_GITHUB_TOKEN }} - SECRET_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - SECRET_PHPSTAN_BOT_TOKEN: ${{ secrets.PHPSTAN_BOT_TOKEN }} - - name: Parse agent logs for step summary - if: always() - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - GH_AW_AGENT_OUTPUT: /tmp/gh-aw/agent-stdio.log - with: - script: | - const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io); - const { main } = require('/opt/gh-aw/actions/parse_claude_log.cjs'); - await main(); - - name: Parse MCP Gateway logs for step summary - if: always() - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - with: - script: | - const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io); - const { main } = require('/opt/gh-aw/actions/parse_mcp_gateway_log.cjs'); - await main(); - - name: Print firewall logs - if: always() - continue-on-error: true - env: - AWF_LOGS_DIR: /tmp/gh-aw/sandbox/firewall/logs - run: | - # Fix permissions on firewall logs so they can be uploaded as artifacts - # AWF runs with sudo, creating files owned by root - sudo chmod -R a+r /tmp/gh-aw/sandbox/firewall/logs 2>/dev/null || true - # Only run awf logs summary if awf command exists (it may not be installed if workflow failed before install step) - if command -v awf &> /dev/null; then - awf logs summary | tee -a "$GITHUB_STEP_SUMMARY" - else - echo 'AWF binary not installed, skipping firewall log summary' - fi - - name: Upload agent artifacts - if: always() - continue-on-error: true - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 - with: - name: agent-artifacts - path: | - /tmp/gh-aw/aw-prompts/prompt.txt - /tmp/gh-aw/aw_info.json - /tmp/gh-aw/mcp-logs/ - /tmp/gh-aw/sandbox/firewall/logs/ - /tmp/gh-aw/agent-stdio.log - /tmp/gh-aw/agent/ - if-no-files-found: ignore - - pre_activation: - runs-on: ubuntu-slim - outputs: - activated: ${{ steps.check_membership.outputs.is_team_member == 'true' }} - steps: - - name: Setup Scripts - uses: github/gh-aw/actions/setup@ac090214a48a1938f7abafe132460b66752261af # v0.45.4 - with: - destination: /opt/gh-aw/actions - - name: Check team membership for workflow - id: check_membership - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - GH_AW_REQUIRED_ROLES: admin,maintainer,write - with: - github-token: ${{ secrets.GITHUB_TOKEN }} - script: | - const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io); - const { main } = require('/opt/gh-aw/actions/check_membership.cjs'); - await main(); - diff --git a/.github/workflows/document-config-params.md b/.github/workflows/document-config-params.md deleted file mode 100644 index d1ad6b64f88..00000000000 --- a/.github/workflows/document-config-params.md +++ /dev/null @@ -1,183 +0,0 @@ ---- -name: Document Config Parameters -description: Finds undocumented PHPStan config parameters and creates documentation PRs on phpstan/phpstan -on: - push: - branches: [2.2.x] - paths: [conf/parametersSchema.neon] - workflow_dispatch: -engine: - id: claude - model: claude-opus-4-6 - env: - CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} -permissions: - contents: read - issues: read - pull-requests: read -tools: - bash: ["*"] - github: - toolsets: [default, repos] -timeout-minutes: 30 -steps: - - uses: actions/checkout@v4 - - uses: actions/checkout@v4 - with: - repository: phpstan/phpstan - ref: 2.2.x - path: __phpstan-website - token: ${{ secrets.PHPSTAN_BOT_TOKEN }} ---- - -# Document Undocumented Config Parameters - -You are a documentation agent for PHPStan. Your job is to find configuration parameters that exist in the schema but lack user-facing documentation, and to write documentation for them. - -## Source files - -- **Parameter schema**: `conf/parametersSchema.neon` in this workspace (phpstan-src repo) -- **Config reference docs**: `__phpstan-website/website/src/config-reference.md` (checked out from `phpstan/phpstan`) -- **Source code for research**: `src/`, `conf/`, and `tests/` directories in this workspace (phpstan-src repo) - -## Task - -### Step 1: Read both files - -1. Read `conf/parametersSchema.neon` from the workspace -2. Read `__phpstan-website/website/src/config-reference.md` from the workspace - -### Step 2: Identify user-facing parameters from the schema - -Extract all parameter names from `parametersSchema.neon`. Note that some parameters are nested inside `structure()` blocks — these use dotted paths in the user's `phpstan.neon`. For example, the schema has: - -```neon -exceptions: structure([ - implicitThrows: bool(), - check: structure([ - missingCheckedExceptionInThrows: bool(), - tooWideThrowType: bool(), - throwTypeCovariance: bool(), - tooWideImplicitThrowType: bool() - ]) -]) -``` - -This means the user-facing parameters are `exceptions.implicitThrows`, `exceptions.check.missingCheckedExceptionInThrows`, `exceptions.check.tooWideThrowType`, etc. Similarly, `cache` has sub-keys like `cache.nodesByStringCountMax`. Make sure to extract ALL nested parameters, not just top-level ones. - -**Skip these entirely:** - -- The entire `featureToggles` section and all its sub-parameters -- Everything after the `# playground mode` comment — these are internal/irrelevant: - - `sourceLocatorPlaygroundMode` - - Nette parameters: `debugMode`, `productionMode`, `tempDir`, `__validate` - - DerivativeContainerFactory internals: `additionalConfigFiles`, `generateBaselineFile`, `analysedPaths`, `allConfigFiles`, `composerAutoloaderProjectPaths`, `analysedPathsFromConfig`, `usedLevel`, `cliAutoloadFile` - - Editor mode internals: `singleReflectionFile`, `singleReflectionInsteadOfFile` - -Also skip these internal parameters that users should not configure directly: -- `strictRulesInstalled`, `deprecationRulesInstalled` (set by installing packages, not by users) -- `cliArgumentsVariablesRegistered` (internal CLI flag) -- `rootDir`, `currentWorkingDirectory` (auto-detected, not user-configurable) -- `sysGetTempDir` (internal) -- `parametersNotInvalidatingCache` (internal) -- `env` (internal environment variable mapping) - -Also skip these level-only parameters — they exist purely to be toggled by rule levels in `conf/config.level*.neon` and are not configured by users directly: -- `checkThisOnly` (level 2) -- `checkMaybeUndefinedVariables` (level 1) -- `checkExtraArguments` (level 1) -- `reportMagicMethods` (level 1) -- `reportMagicProperties` (level 1) -- `checkClassCaseSensitivity` (level 2) -- `checkPhpDocMissingReturn` (level 2) -- `checkPhpDocMethodSignatures` (level 3) -- `checkAdvancedIsset` (level 4) -- `checkFunctionArgumentTypes` (level 5) -- `checkArgumentsPassedByReference` (level 5) -- `checkMissingVarTagTypehint` (level 6) -- `checkMissingTypehints` (level 6) -- `checkUnionTypes` (level 7) -- `reportMaybes` (level 7) -- `checkNullables` (level 8) -- `checkExplicitMixed` (level 9) -- `checkImplicitMixed` (level 10) - -### Step 3: Determine which parameters are undocumented - -Check which parameter names from the schema do NOT appear as documented parameters in `config-reference.md`. A parameter counts as "documented" if it appears as a heading (`###`), in a config key listing, or is explained in a section body. - -{{#if github.event_name == 'push'}} -Focus only on parameters that were added or changed in this push. Run `git diff ${{ github.event.before }} -- conf/parametersSchema.neon` to see what changed across all commits in the push. Only document newly added parameters. -{{#else}} -Check ALL non-skipped parameters from the schema against the documentation. Do not look at git history or diffs — compare the entire `parametersSchema.neon` against `config-reference.md` and document every undocumented parameter you find. -{{/if}} - -If there are no undocumented parameters, stop and report that all parameters are documented. Do not create a PR. - -### Step 4: Research each undocumented parameter - -For each undocumented parameter, investigate what it does by reading files from the workspace (phpstan-src): - -1. **Search the source code** in `src/` for where the parameter is used. Look for the parameter name in PHP files — it will typically appear in a service constructor or be read from the DI container. -2. **Check level configs** in `conf/config.level*.neon` to see which level enables the parameter and what its default value is. -3. **Check `conf/config.neon`** for the parameter's default value. -4. **Look at related rules and tests** to understand the behavior. Check `tests/` for test data files that exercise the parameter. -5. **Check if phpstan-strict-rules sets it** by searching for the parameter name in the codebase and noting if strict-rules is mentioned. - -### Step 5: Write documentation - -Edit the existing `__phpstan-website/website/src/config-reference.md` file to add the new documentation. Do NOT overwrite the file — use targeted edits to insert new parameter sections in the correct locations. - -**Place each parameter in the correct existing section:** -- Boolean flags that enable stricter checks → "Stricter analysis" section (as `###` sub-headings) -- Parameters related to parallel processing → "Parallel processing" section -- Parameters related to caching → "Caching" section -- Other general settings → "Miscellaneous parameters" section -- Parameters related to exceptions → "Exceptions" section - -**Follow the existing documentation conventions exactly:** - -For parameters in "Stricter analysis", use this format: - -``` -### `parameterName` - -**default**: `value` ([strict-rules](https://github.com/phpstan/phpstan-strict-rules) sets it to `otherValue`) - -When set to `true/false`, it [concise description of what changes]. -``` - -Include a short PHP code example only if it helps illustrate the behavior clearly. Keep descriptions concise — one or two sentences is ideal. - -If the parameter was introduced in a specific PHPStan version (not 1.0), add a version badge: - -```html -
Available in PHPStan X.Y
-``` - -For parameters in "Miscellaneous parameters", use: - -``` -### `parameterName` - -**default**: `value` - -Description of what the parameter does. -``` - -### Step 6: Create a pull request - -After editing the documentation file, push the changes and create a PR on `phpstan/phpstan`: - -```bash -cd __phpstan-website -git config user.name "phpstan-bot" -git config user.email "ondrej+phpstanbot@mirtes.cz" -git checkout -b docs/undocumented-config-params -git add website/src/config-reference.md -git commit -m "Document undocumented configuration parameters" -git push origin docs/undocumented-config-params -gh pr create --repo phpstan/phpstan --base 2.2.x --draft --title "[Docs] Document undocumented config parameters" --body "PR DESCRIPTION HERE" -``` - -Replace `PR DESCRIPTION HERE` with a description listing which parameters were newly documented with a one-line summary of each. diff --git a/.github/workflows/document-phpdoc-tags.lock.yml b/.github/workflows/document-phpdoc-tags.lock.yml deleted file mode 100644 index f3a6263460e..00000000000 --- a/.github/workflows/document-phpdoc-tags.lock.yml +++ /dev/null @@ -1,575 +0,0 @@ -# -# ___ _ _ -# / _ \ | | (_) -# | |_| | __ _ ___ _ __ | |_ _ ___ -# | _ |/ _` |/ _ \ '_ \| __| |/ __| -# | | | | (_| | __/ | | | |_| | (__ -# \_| |_/\__, |\___|_| |_|\__|_|\___| -# __/ | -# _ _ |___/ -# | | | | / _| | -# | | | | ___ _ __ _ __| |_| | _____ ____ -# | |/\| |/ _ \ '__| |/ /| _| |/ _ \ \ /\ / / ___| -# \ /\ / (_) | | | | ( | | | | (_) \ V V /\__ \ -# \/ \/ \___/|_| |_|\_\|_| |_|\___/ \_/\_/ |___/ -# -# This file was automatically generated by gh-aw (v0.45.4). DO NOT EDIT. -# -# To update this file, edit the corresponding .md file and run: -# gh aw compile -# Not all edits will cause changes to this file. -# -# For more information: https://github.github.com/gh-aw/introduction/overview/ -# -# Finds undocumented PHPDoc tags supported by PHPStan and creates documentation PRs on phpstan/phpstan -# -# frontmatter-hash: 4b00de08f40349ad6705433b25b1eda1b6286fca149203f6d4ddf87f4279b376 - -name: "Document PHPDoc Tags" -"on": - push: - branches: - - 2.2.x - paths: - - src/PhpDoc/PhpDocNodeResolver.php - workflow_dispatch: - -permissions: {} - -concurrency: - group: "gh-aw-${{ github.workflow }}-${{ github.ref }}" - -run-name: "Document PHPDoc Tags" - -jobs: - activation: - needs: pre_activation - if: needs.pre_activation.outputs.activated == 'true' - runs-on: ubuntu-slim - permissions: - contents: read - outputs: - comment_id: "" - comment_repo: "" - steps: - - name: Setup Scripts - uses: github/gh-aw/actions/setup@ac090214a48a1938f7abafe132460b66752261af # v0.45.4 - with: - destination: /opt/gh-aw/actions - - name: Checkout .github and .agents folders - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - sparse-checkout: | - .github - .agents - fetch-depth: 1 - persist-credentials: false - - name: Check workflow file timestamps - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - GH_AW_WORKFLOW_FILE: "document-phpdoc-tags.lock.yml" - with: - script: | - const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io); - const { main } = require('/opt/gh-aw/actions/check_workflow_timestamp_api.cjs'); - await main(); - - name: Create prompt with built-in context - env: - GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt - GH_AW_GITHUB_ACTOR: ${{ github.actor }} - GH_AW_GITHUB_EVENT_BEFORE: ${{ github.event.before }} - GH_AW_GITHUB_EVENT_COMMENT_ID: ${{ github.event.comment.id }} - GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER: ${{ github.event.discussion.number }} - GH_AW_GITHUB_EVENT_ISSUE_NUMBER: ${{ github.event.issue.number }} - GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER: ${{ github.event.pull_request.number }} - GH_AW_GITHUB_REPOSITORY: ${{ github.repository }} - GH_AW_GITHUB_RUN_ID: ${{ github.run_id }} - GH_AW_GITHUB_WORKSPACE: ${{ github.workspace }} - run: | - bash /opt/gh-aw/actions/create_prompt_first.sh - cat << 'GH_AW_PROMPT_EOF' > "$GH_AW_PROMPT" - - GH_AW_PROMPT_EOF - cat "/opt/gh-aw/prompts/xpia.md" >> "$GH_AW_PROMPT" - cat "/opt/gh-aw/prompts/temp_folder_prompt.md" >> "$GH_AW_PROMPT" - cat "/opt/gh-aw/prompts/markdown.md" >> "$GH_AW_PROMPT" - cat << 'GH_AW_PROMPT_EOF' >> "$GH_AW_PROMPT" - - The following GitHub context information is available for this workflow: - {{#if __GH_AW_GITHUB_ACTOR__ }} - - **actor**: __GH_AW_GITHUB_ACTOR__ - {{/if}} - {{#if __GH_AW_GITHUB_REPOSITORY__ }} - - **repository**: __GH_AW_GITHUB_REPOSITORY__ - {{/if}} - {{#if __GH_AW_GITHUB_WORKSPACE__ }} - - **workspace**: __GH_AW_GITHUB_WORKSPACE__ - {{/if}} - {{#if __GH_AW_GITHUB_EVENT_ISSUE_NUMBER__ }} - - **issue-number**: #__GH_AW_GITHUB_EVENT_ISSUE_NUMBER__ - {{/if}} - {{#if __GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER__ }} - - **discussion-number**: #__GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER__ - {{/if}} - {{#if __GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER__ }} - - **pull-request-number**: #__GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER__ - {{/if}} - {{#if __GH_AW_GITHUB_EVENT_COMMENT_ID__ }} - - **comment-id**: __GH_AW_GITHUB_EVENT_COMMENT_ID__ - {{/if}} - {{#if __GH_AW_GITHUB_RUN_ID__ }} - - **workflow-run-id**: __GH_AW_GITHUB_RUN_ID__ - {{/if}} - - - GH_AW_PROMPT_EOF - cat << 'GH_AW_PROMPT_EOF' >> "$GH_AW_PROMPT" - - GH_AW_PROMPT_EOF - cat << 'GH_AW_PROMPT_EOF' >> "$GH_AW_PROMPT" - {{#runtime-import .github/workflows/document-phpdoc-tags.md}} - GH_AW_PROMPT_EOF - - name: Interpolate variables and render templates - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt - GH_AW_GITHUB_EVENT_BEFORE: ${{ github.event.before }} - with: - script: | - const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io); - const { main } = require('/opt/gh-aw/actions/interpolate_prompt.cjs'); - await main(); - - name: Substitute placeholders - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt - GH_AW_GITHUB_ACTOR: ${{ github.actor }} - GH_AW_GITHUB_EVENT_BEFORE: ${{ github.event.before }} - GH_AW_GITHUB_EVENT_COMMENT_ID: ${{ github.event.comment.id }} - GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER: ${{ github.event.discussion.number }} - GH_AW_GITHUB_EVENT_ISSUE_NUMBER: ${{ github.event.issue.number }} - GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER: ${{ github.event.pull_request.number }} - GH_AW_GITHUB_REPOSITORY: ${{ github.repository }} - GH_AW_GITHUB_RUN_ID: ${{ github.run_id }} - GH_AW_GITHUB_WORKSPACE: ${{ github.workspace }} - GH_AW_NEEDS_PRE_ACTIVATION_OUTPUTS_ACTIVATED: ${{ needs.pre_activation.outputs.activated }} - GH_AW_NEEDS_PRE_ACTIVATION_OUTPUTS_MATCHED_COMMAND: ${{ needs.pre_activation.outputs.matched_command }} - with: - script: | - const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io); - - const substitutePlaceholders = require('/opt/gh-aw/actions/substitute_placeholders.cjs'); - - // Call the substitution function - return await substitutePlaceholders({ - file: process.env.GH_AW_PROMPT, - substitutions: { - GH_AW_GITHUB_ACTOR: process.env.GH_AW_GITHUB_ACTOR, - GH_AW_GITHUB_EVENT_BEFORE: process.env.GH_AW_GITHUB_EVENT_BEFORE, - GH_AW_GITHUB_EVENT_COMMENT_ID: process.env.GH_AW_GITHUB_EVENT_COMMENT_ID, - GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER: process.env.GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER, - GH_AW_GITHUB_EVENT_ISSUE_NUMBER: process.env.GH_AW_GITHUB_EVENT_ISSUE_NUMBER, - GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER: process.env.GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER, - GH_AW_GITHUB_REPOSITORY: process.env.GH_AW_GITHUB_REPOSITORY, - GH_AW_GITHUB_RUN_ID: process.env.GH_AW_GITHUB_RUN_ID, - GH_AW_GITHUB_WORKSPACE: process.env.GH_AW_GITHUB_WORKSPACE, - GH_AW_NEEDS_PRE_ACTIVATION_OUTPUTS_ACTIVATED: process.env.GH_AW_NEEDS_PRE_ACTIVATION_OUTPUTS_ACTIVATED, - GH_AW_NEEDS_PRE_ACTIVATION_OUTPUTS_MATCHED_COMMAND: process.env.GH_AW_NEEDS_PRE_ACTIVATION_OUTPUTS_MATCHED_COMMAND - } - }); - - name: Validate prompt placeholders - env: - GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt - run: bash /opt/gh-aw/actions/validate_prompt_placeholders.sh - - name: Print prompt - env: - GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt - run: bash /opt/gh-aw/actions/print_prompt_summary.sh - - name: Upload prompt artifact - if: success() - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 - with: - name: prompt - path: /tmp/gh-aw/aw-prompts/prompt.txt - retention-days: 1 - - agent: - needs: activation - runs-on: ubuntu-latest - permissions: - contents: read - issues: read - pull-requests: read - env: - GH_AW_WORKFLOW_ID_SANITIZED: documentphpdoctags - outputs: - checkout_pr_success: ${{ steps.checkout-pr.outputs.checkout_pr_success || 'true' }} - model: ${{ steps.generate_aw_info.outputs.model }} - secret_verification_result: ${{ steps.validate-secret.outputs.verification_result }} - steps: - - name: Setup Scripts - uses: github/gh-aw/actions/setup@ac090214a48a1938f7abafe132460b66752261af # v0.45.4 - with: - destination: /opt/gh-aw/actions - - name: Create gh-aw temp directory - run: bash /opt/gh-aw/actions/create_gh_aw_tmp_dir.sh - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - with: - path: __phpstan-website - ref: 2.2.x - repository: phpstan/phpstan - token: ${{ secrets.PHPSTAN_BOT_TOKEN }} - - - name: Configure Git credentials - env: - REPO_NAME: ${{ github.repository }} - SERVER_URL: ${{ github.server_url }} - run: | - git config --global user.email "github-actions[bot]@users.noreply.github.com" - git config --global user.name "github-actions[bot]" - # Re-authenticate git with GitHub token - SERVER_URL_STRIPPED="${SERVER_URL#https://}" - git remote set-url origin "https://x-access-token:${{ github.token }}@${SERVER_URL_STRIPPED}/${REPO_NAME}.git" - echo "Git configured with standard GitHub Actions identity" - - name: Checkout PR branch - id: checkout-pr - if: | - github.event.pull_request - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - GH_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} - with: - github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} - script: | - const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io); - const { main } = require('/opt/gh-aw/actions/checkout_pr_branch.cjs'); - await main(); - - name: Generate agentic run info - id: generate_aw_info - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - with: - script: | - const fs = require('fs'); - - const awInfo = { - engine_id: "claude", - engine_name: "Claude Code", - model: "claude-opus-4-6", - version: "", - agent_version: "2.1.42", - cli_version: "v0.45.4", - workflow_name: "Document PHPDoc Tags", - experimental: false, - supports_tools_allowlist: true, - run_id: context.runId, - run_number: context.runNumber, - run_attempt: process.env.GITHUB_RUN_ATTEMPT, - repository: context.repo.owner + '/' + context.repo.repo, - ref: context.ref, - sha: context.sha, - actor: context.actor, - event_name: context.eventName, - staged: false, - allowed_domains: ["defaults"], - firewall_enabled: true, - awf_version: "v0.19.1", - awmg_version: "v0.1.4", - steps: { - firewall: "squid" - }, - created_at: new Date().toISOString() - }; - - // Write to /tmp/gh-aw directory to avoid inclusion in PR - const tmpPath = '/tmp/gh-aw/aw_info.json'; - fs.writeFileSync(tmpPath, JSON.stringify(awInfo, null, 2)); - console.log('Generated aw_info.json at:', tmpPath); - console.log(JSON.stringify(awInfo, null, 2)); - - // Set model as output for reuse in other steps/jobs - core.setOutput('model', awInfo.model); - - name: Validate CLAUDE_CODE_OAUTH_TOKEN or ANTHROPIC_API_KEY secret - id: validate-secret - run: /opt/gh-aw/actions/validate_multi_secret.sh CLAUDE_CODE_OAUTH_TOKEN ANTHROPIC_API_KEY 'Claude Code' https://github.github.com/gh-aw/reference/engines/#anthropic-claude-code - env: - CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} - ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} - - name: Setup Node.js - uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 - with: - node-version: '24' - package-manager-cache: false - - name: Install awf binary - run: bash /opt/gh-aw/actions/install_awf_binary.sh v0.19.1 - - name: Install Claude Code CLI - run: npm install -g --silent @anthropic-ai/claude-code@2.1.42 - - name: Determine automatic lockdown mode for GitHub MCP Server - id: determine-automatic-lockdown - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - GH_AW_GITHUB_TOKEN: ${{ secrets.GH_AW_GITHUB_TOKEN }} - GH_AW_GITHUB_MCP_SERVER_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN }} - with: - script: | - const determineAutomaticLockdown = require('/opt/gh-aw/actions/determine_automatic_lockdown.cjs'); - await determineAutomaticLockdown(github, context, core); - - name: Download container images - run: bash /opt/gh-aw/actions/download_docker_images.sh ghcr.io/github/gh-aw-firewall/agent:0.19.1 ghcr.io/github/gh-aw-firewall/api-proxy:0.19.1 ghcr.io/github/gh-aw-firewall/squid:0.19.1 ghcr.io/github/gh-aw-mcpg:v0.1.4 ghcr.io/github/github-mcp-server:v0.30.3 - - name: Start MCP Gateway - id: start-mcp-gateway - env: - GITHUB_MCP_LOCKDOWN: ${{ steps.determine-automatic-lockdown.outputs.lockdown == 'true' && '1' || '0' }} - GITHUB_MCP_SERVER_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} - run: | - set -eo pipefail - mkdir -p /tmp/gh-aw/mcp-config - - # Export gateway environment variables for MCP config and gateway script - export MCP_GATEWAY_PORT="80" - export MCP_GATEWAY_DOMAIN="host.docker.internal" - MCP_GATEWAY_API_KEY=$(openssl rand -base64 45 | tr -d '/+=') - echo "::add-mask::${MCP_GATEWAY_API_KEY}" - export MCP_GATEWAY_API_KEY - export MCP_GATEWAY_PAYLOAD_DIR="/tmp/gh-aw/mcp-payloads" - mkdir -p "${MCP_GATEWAY_PAYLOAD_DIR}" - export DEBUG="*" - - export GH_AW_ENGINE="claude" - export MCP_GATEWAY_DOCKER_COMMAND='docker run -i --rm --network host -v /var/run/docker.sock:/var/run/docker.sock -e MCP_GATEWAY_PORT -e MCP_GATEWAY_DOMAIN -e MCP_GATEWAY_API_KEY -e MCP_GATEWAY_PAYLOAD_DIR -e DEBUG -e MCP_GATEWAY_LOG_DIR -e GH_AW_MCP_LOG_DIR -e GH_AW_SAFE_OUTPUTS -e GH_AW_SAFE_OUTPUTS_CONFIG_PATH -e GH_AW_SAFE_OUTPUTS_TOOLS_PATH -e GH_AW_ASSETS_BRANCH -e GH_AW_ASSETS_MAX_SIZE_KB -e GH_AW_ASSETS_ALLOWED_EXTS -e DEFAULT_BRANCH -e GITHUB_MCP_SERVER_TOKEN -e GITHUB_MCP_LOCKDOWN -e GITHUB_REPOSITORY -e GITHUB_SERVER_URL -e GITHUB_SHA -e GITHUB_WORKSPACE -e GITHUB_TOKEN -e GITHUB_RUN_ID -e GITHUB_RUN_NUMBER -e GITHUB_RUN_ATTEMPT -e GITHUB_JOB -e GITHUB_ACTION -e GITHUB_EVENT_NAME -e GITHUB_EVENT_PATH -e GITHUB_ACTOR -e GITHUB_ACTOR_ID -e GITHUB_TRIGGERING_ACTOR -e GITHUB_WORKFLOW -e GITHUB_WORKFLOW_REF -e GITHUB_WORKFLOW_SHA -e GITHUB_REF -e GITHUB_REF_NAME -e GITHUB_REF_TYPE -e GITHUB_HEAD_REF -e GITHUB_BASE_REF -v /tmp/gh-aw/mcp-payloads:/tmp/gh-aw/mcp-payloads:rw -v /opt:/opt:ro -v /tmp:/tmp:rw -v '"${GITHUB_WORKSPACE}"':'"${GITHUB_WORKSPACE}"':rw ghcr.io/github/gh-aw-mcpg:v0.1.4' - - cat << GH_AW_MCP_CONFIG_EOF | bash /opt/gh-aw/actions/start_mcp_gateway.sh - { - "mcpServers": { - "github": { - "container": "ghcr.io/github/github-mcp-server:v0.30.3", - "env": { - "GITHUB_LOCKDOWN_MODE": "$GITHUB_MCP_LOCKDOWN", - "GITHUB_PERSONAL_ACCESS_TOKEN": "$GITHUB_MCP_SERVER_TOKEN", - "GITHUB_READ_ONLY": "1", - "GITHUB_TOOLSETS": "context,repos,issues,pull_requests" - } - } - }, - "gateway": { - "port": $MCP_GATEWAY_PORT, - "domain": "${MCP_GATEWAY_DOMAIN}", - "apiKey": "${MCP_GATEWAY_API_KEY}", - "payloadDir": "${MCP_GATEWAY_PAYLOAD_DIR}" - } - } - GH_AW_MCP_CONFIG_EOF - - name: Generate workflow overview - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - with: - script: | - const { generateWorkflowOverview } = require('/opt/gh-aw/actions/generate_workflow_overview.cjs'); - await generateWorkflowOverview(core); - - name: Download prompt artifact - uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 - with: - name: prompt - path: /tmp/gh-aw/aw-prompts - - name: Clean git credentials - run: bash /opt/gh-aw/actions/clean_git_credentials.sh - - name: Execute Claude Code CLI - id: agentic_execution - # Allowed tools (sorted): - # - Bash - # - BashOutput - # - Edit - # - ExitPlanMode - # - Glob - # - Grep - # - KillBash - # - LS - # - MultiEdit - # - NotebookEdit - # - NotebookRead - # - Read - # - Task - # - TodoWrite - # - Write - # - mcp__github__download_workflow_run_artifact - # - mcp__github__get_code_scanning_alert - # - mcp__github__get_commit - # - mcp__github__get_dependabot_alert - # - mcp__github__get_discussion - # - mcp__github__get_discussion_comments - # - mcp__github__get_file_contents - # - mcp__github__get_job_logs - # - mcp__github__get_label - # - mcp__github__get_latest_release - # - mcp__github__get_me - # - mcp__github__get_notification_details - # - mcp__github__get_pull_request - # - mcp__github__get_pull_request_comments - # - mcp__github__get_pull_request_diff - # - mcp__github__get_pull_request_files - # - mcp__github__get_pull_request_review_comments - # - mcp__github__get_pull_request_reviews - # - mcp__github__get_pull_request_status - # - mcp__github__get_release_by_tag - # - mcp__github__get_secret_scanning_alert - # - mcp__github__get_tag - # - mcp__github__get_workflow_run - # - mcp__github__get_workflow_run_logs - # - mcp__github__get_workflow_run_usage - # - mcp__github__issue_read - # - mcp__github__list_branches - # - mcp__github__list_code_scanning_alerts - # - mcp__github__list_commits - # - mcp__github__list_dependabot_alerts - # - mcp__github__list_discussion_categories - # - mcp__github__list_discussions - # - mcp__github__list_issue_types - # - mcp__github__list_issues - # - mcp__github__list_label - # - mcp__github__list_notifications - # - mcp__github__list_pull_requests - # - mcp__github__list_releases - # - mcp__github__list_secret_scanning_alerts - # - mcp__github__list_starred_repositories - # - mcp__github__list_tags - # - mcp__github__list_workflow_jobs - # - mcp__github__list_workflow_run_artifacts - # - mcp__github__list_workflow_runs - # - mcp__github__list_workflows - # - mcp__github__pull_request_read - # - mcp__github__search_code - # - mcp__github__search_issues - # - mcp__github__search_orgs - # - mcp__github__search_pull_requests - # - mcp__github__search_repositories - # - mcp__github__search_users - timeout-minutes: 30 - run: | - set -o pipefail - sudo -E awf --tty --env-all --container-workdir "${GITHUB_WORKSPACE}" --allow-domains '*.githubusercontent.com,anthropic.com,api.anthropic.com,api.github.com,api.snapcraft.io,archive.ubuntu.com,azure.archive.ubuntu.com,cdn.playwright.dev,codeload.github.com,crl.geotrust.com,crl.globalsign.com,crl.identrust.com,crl.sectigo.com,crl.thawte.com,crl.usertrust.com,crl.verisign.com,crl3.digicert.com,crl4.digicert.com,crls.ssl.com,files.pythonhosted.org,ghcr.io,github-cloud.githubusercontent.com,github-cloud.s3.amazonaws.com,github.com,host.docker.internal,json-schema.org,json.schemastore.org,keyserver.ubuntu.com,lfs.github.com,objects.githubusercontent.com,ocsp.digicert.com,ocsp.geotrust.com,ocsp.globalsign.com,ocsp.identrust.com,ocsp.sectigo.com,ocsp.ssl.com,ocsp.thawte.com,ocsp.usertrust.com,ocsp.verisign.com,packagecloud.io,packages.cloud.google.com,packages.microsoft.com,playwright.download.prss.microsoft.com,ppa.launchpad.net,pypi.org,raw.githubusercontent.com,registry.npmjs.org,s.symcb.com,s.symcd.com,security.ubuntu.com,sentry.io,statsig.anthropic.com,ts-crl.ws.symantec.com,ts-ocsp.ws.symantec.com' --log-level info --proxy-logs-dir /tmp/gh-aw/sandbox/firewall/logs --enable-host-access --image-tag 0.19.1 --skip-pull --enable-api-proxy \ - -- /bin/bash -c 'export PATH="$(find /opt/hostedtoolcache -maxdepth 4 -type d -name bin 2>/dev/null | tr '\''\n'\'' '\'':'\'')$PATH"; [ -n "$GOROOT" ] && export PATH="$GOROOT/bin:$PATH" || true && claude --print --disable-slash-commands --no-chrome --model claude-opus-4-6 --mcp-config /tmp/gh-aw/mcp-config/mcp-servers.json --allowed-tools Bash,BashOutput,Edit,ExitPlanMode,Glob,Grep,KillBash,LS,MultiEdit,NotebookEdit,NotebookRead,Read,Task,TodoWrite,Write,mcp__github__download_workflow_run_artifact,mcp__github__get_code_scanning_alert,mcp__github__get_commit,mcp__github__get_dependabot_alert,mcp__github__get_discussion,mcp__github__get_discussion_comments,mcp__github__get_file_contents,mcp__github__get_job_logs,mcp__github__get_label,mcp__github__get_latest_release,mcp__github__get_me,mcp__github__get_notification_details,mcp__github__get_pull_request,mcp__github__get_pull_request_comments,mcp__github__get_pull_request_diff,mcp__github__get_pull_request_files,mcp__github__get_pull_request_review_comments,mcp__github__get_pull_request_reviews,mcp__github__get_pull_request_status,mcp__github__get_release_by_tag,mcp__github__get_secret_scanning_alert,mcp__github__get_tag,mcp__github__get_workflow_run,mcp__github__get_workflow_run_logs,mcp__github__get_workflow_run_usage,mcp__github__issue_read,mcp__github__list_branches,mcp__github__list_code_scanning_alerts,mcp__github__list_commits,mcp__github__list_dependabot_alerts,mcp__github__list_discussion_categories,mcp__github__list_discussions,mcp__github__list_issue_types,mcp__github__list_issues,mcp__github__list_label,mcp__github__list_notifications,mcp__github__list_pull_requests,mcp__github__list_releases,mcp__github__list_secret_scanning_alerts,mcp__github__list_starred_repositories,mcp__github__list_tags,mcp__github__list_workflow_jobs,mcp__github__list_workflow_run_artifacts,mcp__github__list_workflow_runs,mcp__github__list_workflows,mcp__github__pull_request_read,mcp__github__search_code,mcp__github__search_issues,mcp__github__search_orgs,mcp__github__search_pull_requests,mcp__github__search_repositories,mcp__github__search_users --debug-file /tmp/gh-aw/agent-stdio.log --verbose --permission-mode bypassPermissions --output-format stream-json "$(cat /tmp/gh-aw/aw-prompts/prompt.txt)"' 2>&1 | tee -a /tmp/gh-aw/agent-stdio.log - env: - ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} - BASH_DEFAULT_TIMEOUT_MS: 60000 - BASH_MAX_TIMEOUT_MS: 60000 - CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} - DISABLE_BUG_COMMAND: 1 - DISABLE_ERROR_REPORTING: 1 - DISABLE_TELEMETRY: 1 - GH_AW_MCP_CONFIG: /tmp/gh-aw/mcp-config/mcp-servers.json - GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt - GITHUB_WORKSPACE: ${{ github.workspace }} - MCP_TIMEOUT: 120000 - MCP_TOOL_TIMEOUT: 60000 - - name: Configure Git credentials - env: - REPO_NAME: ${{ github.repository }} - SERVER_URL: ${{ github.server_url }} - run: | - git config --global user.email "github-actions[bot]@users.noreply.github.com" - git config --global user.name "github-actions[bot]" - # Re-authenticate git with GitHub token - SERVER_URL_STRIPPED="${SERVER_URL#https://}" - git remote set-url origin "https://x-access-token:${{ github.token }}@${SERVER_URL_STRIPPED}/${REPO_NAME}.git" - echo "Git configured with standard GitHub Actions identity" - - name: Stop MCP Gateway - if: always() - continue-on-error: true - env: - MCP_GATEWAY_PORT: ${{ steps.start-mcp-gateway.outputs.gateway-port }} - MCP_GATEWAY_API_KEY: ${{ steps.start-mcp-gateway.outputs.gateway-api-key }} - GATEWAY_PID: ${{ steps.start-mcp-gateway.outputs.gateway-pid }} - run: | - bash /opt/gh-aw/actions/stop_mcp_gateway.sh "$GATEWAY_PID" - - name: Redact secrets in logs - if: always() - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - with: - script: | - const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io); - const { main } = require('/opt/gh-aw/actions/redact_secrets.cjs'); - await main(); - env: - GH_AW_SECRET_NAMES: 'ANTHROPIC_API_KEY,CLAUDE_CODE_OAUTH_TOKEN,GH_AW_GITHUB_MCP_SERVER_TOKEN,GH_AW_GITHUB_TOKEN,GITHUB_TOKEN,PHPSTAN_BOT_TOKEN' - SECRET_ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} - SECRET_CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} - SECRET_GH_AW_GITHUB_MCP_SERVER_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN }} - SECRET_GH_AW_GITHUB_TOKEN: ${{ secrets.GH_AW_GITHUB_TOKEN }} - SECRET_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - SECRET_PHPSTAN_BOT_TOKEN: ${{ secrets.PHPSTAN_BOT_TOKEN }} - - name: Parse agent logs for step summary - if: always() - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - GH_AW_AGENT_OUTPUT: /tmp/gh-aw/agent-stdio.log - with: - script: | - const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io); - const { main } = require('/opt/gh-aw/actions/parse_claude_log.cjs'); - await main(); - - name: Parse MCP Gateway logs for step summary - if: always() - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - with: - script: | - const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io); - const { main } = require('/opt/gh-aw/actions/parse_mcp_gateway_log.cjs'); - await main(); - - name: Print firewall logs - if: always() - continue-on-error: true - env: - AWF_LOGS_DIR: /tmp/gh-aw/sandbox/firewall/logs - run: | - # Fix permissions on firewall logs so they can be uploaded as artifacts - # AWF runs with sudo, creating files owned by root - sudo chmod -R a+r /tmp/gh-aw/sandbox/firewall/logs 2>/dev/null || true - # Only run awf logs summary if awf command exists (it may not be installed if workflow failed before install step) - if command -v awf &> /dev/null; then - awf logs summary | tee -a "$GITHUB_STEP_SUMMARY" - else - echo 'AWF binary not installed, skipping firewall log summary' - fi - - name: Upload agent artifacts - if: always() - continue-on-error: true - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 - with: - name: agent-artifacts - path: | - /tmp/gh-aw/aw-prompts/prompt.txt - /tmp/gh-aw/aw_info.json - /tmp/gh-aw/mcp-logs/ - /tmp/gh-aw/sandbox/firewall/logs/ - /tmp/gh-aw/agent-stdio.log - /tmp/gh-aw/agent/ - if-no-files-found: ignore - - pre_activation: - runs-on: ubuntu-slim - outputs: - activated: ${{ steps.check_membership.outputs.is_team_member == 'true' }} - steps: - - name: Setup Scripts - uses: github/gh-aw/actions/setup@ac090214a48a1938f7abafe132460b66752261af # v0.45.4 - with: - destination: /opt/gh-aw/actions - - name: Check team membership for workflow - id: check_membership - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - GH_AW_REQUIRED_ROLES: admin,maintainer,write - with: - github-token: ${{ secrets.GITHUB_TOKEN }} - script: | - const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io); - const { main } = require('/opt/gh-aw/actions/check_membership.cjs'); - await main(); - diff --git a/.github/workflows/document-phpdoc-tags.md b/.github/workflows/document-phpdoc-tags.md deleted file mode 100644 index cab8a61f3f2..00000000000 --- a/.github/workflows/document-phpdoc-tags.md +++ /dev/null @@ -1,125 +0,0 @@ ---- -name: Document PHPDoc Tags -description: Finds undocumented PHPDoc tags supported by PHPStan and creates documentation PRs on phpstan/phpstan -on: - push: - branches: [2.2.x] - paths: [src/PhpDoc/PhpDocNodeResolver.php] - workflow_dispatch: -engine: - id: claude - model: claude-opus-4-6 - env: - CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} -permissions: - contents: read - issues: read - pull-requests: read -tools: - bash: ["*"] - github: - toolsets: [default, repos] -timeout-minutes: 30 -steps: - - uses: actions/checkout@v4 - - uses: actions/checkout@v4 - with: - repository: phpstan/phpstan - ref: 2.2.x - path: __phpstan-website - token: ${{ secrets.PHPSTAN_BOT_TOKEN }} ---- - -# Document Undocumented PHPDoc Tags - -You are a documentation agent for PHPStan. Your job is to find PHPDoc tags that PHPStan supports but are not documented on the website, and to write documentation for them. - -## Source files - -- **Tag handling code**: `src/PhpDoc/PhpDocNodeResolver.php` in this workspace (phpstan-src repo) -- **Valid tag list**: `src/Rules/PhpDoc/InvalidPHPStanDocTagRule.php` in this workspace — contains `POSSIBLE_PHPSTAN_TAGS` listing all recognized `@phpstan-*` tags -- **PHPDocs basics page**: `__phpstan-website/website/src/writing-php-code/phpdocs-basics.md` (checked out from `phpstan/phpstan`) -- **PHPDoc types page**: `__phpstan-website/website/src/writing-php-code/phpdoc-types.md` (checked out from `phpstan/phpstan`) -- **All website docs**: `__phpstan-website/website/src/` directory — search here for tags that may be documented on other pages -- **Source code for research**: `src/`, `conf/`, and `tests/` directories in this workspace (phpstan-src repo) - -## Task - -### Step 1: Extract all supported tags from source code - -1. Read `src/PhpDoc/PhpDocNodeResolver.php` and extract every PHPDoc tag name it processes. Tags appear as string literals in arrays like `['@var', '@phan-var', '@psalm-var', '@phpstan-var']` and in `getTagsByName()` calls. -2. Read `src/Rules/PhpDoc/InvalidPHPStanDocTagRule.php` and extract the list of recognized `@phpstan-*` tags. -3. Build a complete list of **base tags** that PHPStan supports. For tags that have `@phpstan-`/`@psalm-`/`@phan-` prefix variants, the base tag is the unprefixed form (e.g., `@param` is the base for `@phpstan-param`). For tags that only exist with a `@phpstan-` prefix (e.g., `@phpstan-type`, `@phpstan-assert`), keep the prefixed form. - -### Step 2: Check which tags are documented on the website - -1. Read `__phpstan-website/website/src/writing-php-code/phpdocs-basics.md` -2. Read `__phpstan-website/website/src/writing-php-code/phpdoc-types.md` -3. Search the entire `__phpstan-website/website/src/` directory for each tag name to check if it's documented on any page - -A tag counts as "documented" if it appears on any website page with an explanation of what it does. A tag does NOT count as documented if it only appears in passing examples without explanation, or only in the "Prefixed tags" section. - -### Step 3: Determine which tags need documentation - -**Important — prefix variants are already handled:** - -The "Prefixed tags" section of `phpdocs-basics.md` already explains that tags like `@param`, `@return`, `@var`, and generics-related tags can be prefixed with `@phpstan-` (and `@psalm-`, `@phan-`). Do NOT create separate documentation for prefix variants. Only document the base tag (e.g., `@param`, not `@phpstan-param`). Exception: tags that ONLY exist with a prefix (like `@phpstan-type`, `@phpstan-assert`) need to be documented with their prefix. - -**Important — verify tag name accuracy:** - -When checking whether a tag is documented, verify the exact tag name matches between the source code and the documentation. Flag and fix any mismatches (e.g., if docs use a slightly different tag name than the code). - -{{#if github.event_name == 'push'}} -Focus primarily on tags that were added or changed in this push. Run `git diff ${{ github.event.before }} -- src/PhpDoc/PhpDocNodeResolver.php` to see what changed. Document newly added or changed tags, but also briefly check if any other tags remain undocumented and include those too. -{{#else}} -Check ALL tags from the source code against the documentation. Do not look at git history or diffs — compare the full tag list against all website documentation and document every undocumented tag you find. -{{/if}} - -If there are no undocumented tags (and no mismatched tag names), stop and report that all tags are documented. Do not create a PR. - -### Step 4: Research each undocumented tag - -For each undocumented tag, investigate what it does: - -1. **Read the resolver method** in `PhpDocNodeResolver.php` to understand how the tag is parsed. -2. **Search the source code** in `src/` for where the resolved tag data is used. For example, search for related method names in `ResolvedPhpDocBlock.php` and in rules under `src/Rules/`. -3. **Look at related rules** in `src/Rules/` that enforce or check the tag's semantics. -4. **Check tests** in `tests/` for test data files that exercise the tag — these show exactly what behavior the tag enables. - -### Step 5: Write documentation - -Edit `__phpstan-website/website/src/writing-php-code/phpdocs-basics.md` to add documentation for missing tags. Do NOT overwrite the file — use targeted edits to insert new sections. - -**Follow the existing writing style exactly:** - -- Use section headings at the same level as similar existing sections -- Provide a concise description (one or two sentences) -- Include a short PHP code example showing the tag in use -- If the tag interacts with specific rules or features, mention that briefly -- Use fenced code blocks with `php` language annotation -- If the tag was introduced in a specific PHPStan version, add a version badge: - -```html -
Available in PHPStan X.Y
-``` - -**Placement:** Insert new sections near related existing content. For example, property-related tags go near `@readonly`, class-level tags go near other class-level tags, etc. - -**Also fix any tag name mismatches** between documentation and source code to ensure the documented tag names match what the code actually accepts. - -### Step 6: Create a pull request - -After editing the documentation file, push the changes and create a PR on `phpstan/phpstan`: - -```bash -cd __phpstan-website -git config user.name "phpstan-bot" -git config user.email "ondrej+phpstanbot@mirtes.cz" -git checkout -b docs/undocumented-phpdoc-tags -git add website/src/writing-php-code/phpdocs-basics.md -git commit -m "Document undocumented PHPDoc tags" -git push origin docs/undocumented-phpdoc-tags -gh pr create --repo phpstan/phpstan --base 2.2.x --draft --title "[Docs] Document undocumented PHPDoc tags" --body "PR DESCRIPTION HERE" -``` - -Replace `PR DESCRIPTION HERE` with a description listing which tags were newly documented with a one-line summary of each, any tag name mismatches that were fixed, and a note that prefix variants are already covered by the "Prefixed tags" section. diff --git a/.github/workflows/document-phpdoc-types.lock.yml b/.github/workflows/document-phpdoc-types.lock.yml deleted file mode 100644 index a2441229f19..00000000000 --- a/.github/workflows/document-phpdoc-types.lock.yml +++ /dev/null @@ -1,575 +0,0 @@ -# -# ___ _ _ -# / _ \ | | (_) -# | |_| | __ _ ___ _ __ | |_ _ ___ -# | _ |/ _` |/ _ \ '_ \| __| |/ __| -# | | | | (_| | __/ | | | |_| | (__ -# \_| |_/\__, |\___|_| |_|\__|_|\___| -# __/ | -# _ _ |___/ -# | | | | / _| | -# | | | | ___ _ __ _ __| |_| | _____ ____ -# | |/\| |/ _ \ '__| |/ /| _| |/ _ \ \ /\ / / ___| -# \ /\ / (_) | | | | ( | | | | (_) \ V V /\__ \ -# \/ \/ \___/|_| |_|\_\|_| |_|\___/ \_/\_/ |___/ -# -# This file was automatically generated by gh-aw (v0.45.4). DO NOT EDIT. -# -# To update this file, edit the corresponding .md file and run: -# gh aw compile -# Not all edits will cause changes to this file. -# -# For more information: https://github.github.com/gh-aw/introduction/overview/ -# -# Finds undocumented PHPDoc types in TypeNodeResolver and creates documentation PRs on phpstan/phpstan -# -# frontmatter-hash: f03c46de2b23185fffeb696c2bd043a4f156e078ffffc538664f89847bc94706 - -name: "Document PHPDoc Types" -"on": - push: - branches: - - 2.2.x - paths: - - src/PhpDoc/TypeNodeResolver.php - workflow_dispatch: - -permissions: {} - -concurrency: - group: "gh-aw-${{ github.workflow }}-${{ github.ref }}" - -run-name: "Document PHPDoc Types" - -jobs: - activation: - needs: pre_activation - if: needs.pre_activation.outputs.activated == 'true' - runs-on: ubuntu-slim - permissions: - contents: read - outputs: - comment_id: "" - comment_repo: "" - steps: - - name: Setup Scripts - uses: github/gh-aw/actions/setup@ac090214a48a1938f7abafe132460b66752261af # v0.45.4 - with: - destination: /opt/gh-aw/actions - - name: Checkout .github and .agents folders - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - sparse-checkout: | - .github - .agents - fetch-depth: 1 - persist-credentials: false - - name: Check workflow file timestamps - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - GH_AW_WORKFLOW_FILE: "document-phpdoc-types.lock.yml" - with: - script: | - const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io); - const { main } = require('/opt/gh-aw/actions/check_workflow_timestamp_api.cjs'); - await main(); - - name: Create prompt with built-in context - env: - GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt - GH_AW_GITHUB_ACTOR: ${{ github.actor }} - GH_AW_GITHUB_EVENT_BEFORE: ${{ github.event.before }} - GH_AW_GITHUB_EVENT_COMMENT_ID: ${{ github.event.comment.id }} - GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER: ${{ github.event.discussion.number }} - GH_AW_GITHUB_EVENT_ISSUE_NUMBER: ${{ github.event.issue.number }} - GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER: ${{ github.event.pull_request.number }} - GH_AW_GITHUB_REPOSITORY: ${{ github.repository }} - GH_AW_GITHUB_RUN_ID: ${{ github.run_id }} - GH_AW_GITHUB_WORKSPACE: ${{ github.workspace }} - run: | - bash /opt/gh-aw/actions/create_prompt_first.sh - cat << 'GH_AW_PROMPT_EOF' > "$GH_AW_PROMPT" - - GH_AW_PROMPT_EOF - cat "/opt/gh-aw/prompts/xpia.md" >> "$GH_AW_PROMPT" - cat "/opt/gh-aw/prompts/temp_folder_prompt.md" >> "$GH_AW_PROMPT" - cat "/opt/gh-aw/prompts/markdown.md" >> "$GH_AW_PROMPT" - cat << 'GH_AW_PROMPT_EOF' >> "$GH_AW_PROMPT" - - The following GitHub context information is available for this workflow: - {{#if __GH_AW_GITHUB_ACTOR__ }} - - **actor**: __GH_AW_GITHUB_ACTOR__ - {{/if}} - {{#if __GH_AW_GITHUB_REPOSITORY__ }} - - **repository**: __GH_AW_GITHUB_REPOSITORY__ - {{/if}} - {{#if __GH_AW_GITHUB_WORKSPACE__ }} - - **workspace**: __GH_AW_GITHUB_WORKSPACE__ - {{/if}} - {{#if __GH_AW_GITHUB_EVENT_ISSUE_NUMBER__ }} - - **issue-number**: #__GH_AW_GITHUB_EVENT_ISSUE_NUMBER__ - {{/if}} - {{#if __GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER__ }} - - **discussion-number**: #__GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER__ - {{/if}} - {{#if __GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER__ }} - - **pull-request-number**: #__GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER__ - {{/if}} - {{#if __GH_AW_GITHUB_EVENT_COMMENT_ID__ }} - - **comment-id**: __GH_AW_GITHUB_EVENT_COMMENT_ID__ - {{/if}} - {{#if __GH_AW_GITHUB_RUN_ID__ }} - - **workflow-run-id**: __GH_AW_GITHUB_RUN_ID__ - {{/if}} - - - GH_AW_PROMPT_EOF - cat << 'GH_AW_PROMPT_EOF' >> "$GH_AW_PROMPT" - - GH_AW_PROMPT_EOF - cat << 'GH_AW_PROMPT_EOF' >> "$GH_AW_PROMPT" - {{#runtime-import .github/workflows/document-phpdoc-types.md}} - GH_AW_PROMPT_EOF - - name: Interpolate variables and render templates - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt - GH_AW_GITHUB_EVENT_BEFORE: ${{ github.event.before }} - with: - script: | - const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io); - const { main } = require('/opt/gh-aw/actions/interpolate_prompt.cjs'); - await main(); - - name: Substitute placeholders - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt - GH_AW_GITHUB_ACTOR: ${{ github.actor }} - GH_AW_GITHUB_EVENT_BEFORE: ${{ github.event.before }} - GH_AW_GITHUB_EVENT_COMMENT_ID: ${{ github.event.comment.id }} - GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER: ${{ github.event.discussion.number }} - GH_AW_GITHUB_EVENT_ISSUE_NUMBER: ${{ github.event.issue.number }} - GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER: ${{ github.event.pull_request.number }} - GH_AW_GITHUB_REPOSITORY: ${{ github.repository }} - GH_AW_GITHUB_RUN_ID: ${{ github.run_id }} - GH_AW_GITHUB_WORKSPACE: ${{ github.workspace }} - GH_AW_NEEDS_PRE_ACTIVATION_OUTPUTS_ACTIVATED: ${{ needs.pre_activation.outputs.activated }} - GH_AW_NEEDS_PRE_ACTIVATION_OUTPUTS_MATCHED_COMMAND: ${{ needs.pre_activation.outputs.matched_command }} - with: - script: | - const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io); - - const substitutePlaceholders = require('/opt/gh-aw/actions/substitute_placeholders.cjs'); - - // Call the substitution function - return await substitutePlaceholders({ - file: process.env.GH_AW_PROMPT, - substitutions: { - GH_AW_GITHUB_ACTOR: process.env.GH_AW_GITHUB_ACTOR, - GH_AW_GITHUB_EVENT_BEFORE: process.env.GH_AW_GITHUB_EVENT_BEFORE, - GH_AW_GITHUB_EVENT_COMMENT_ID: process.env.GH_AW_GITHUB_EVENT_COMMENT_ID, - GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER: process.env.GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER, - GH_AW_GITHUB_EVENT_ISSUE_NUMBER: process.env.GH_AW_GITHUB_EVENT_ISSUE_NUMBER, - GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER: process.env.GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER, - GH_AW_GITHUB_REPOSITORY: process.env.GH_AW_GITHUB_REPOSITORY, - GH_AW_GITHUB_RUN_ID: process.env.GH_AW_GITHUB_RUN_ID, - GH_AW_GITHUB_WORKSPACE: process.env.GH_AW_GITHUB_WORKSPACE, - GH_AW_NEEDS_PRE_ACTIVATION_OUTPUTS_ACTIVATED: process.env.GH_AW_NEEDS_PRE_ACTIVATION_OUTPUTS_ACTIVATED, - GH_AW_NEEDS_PRE_ACTIVATION_OUTPUTS_MATCHED_COMMAND: process.env.GH_AW_NEEDS_PRE_ACTIVATION_OUTPUTS_MATCHED_COMMAND - } - }); - - name: Validate prompt placeholders - env: - GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt - run: bash /opt/gh-aw/actions/validate_prompt_placeholders.sh - - name: Print prompt - env: - GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt - run: bash /opt/gh-aw/actions/print_prompt_summary.sh - - name: Upload prompt artifact - if: success() - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 - with: - name: prompt - path: /tmp/gh-aw/aw-prompts/prompt.txt - retention-days: 1 - - agent: - needs: activation - runs-on: ubuntu-latest - permissions: - contents: read - issues: read - pull-requests: read - env: - GH_AW_WORKFLOW_ID_SANITIZED: documentphpdoctypes - outputs: - checkout_pr_success: ${{ steps.checkout-pr.outputs.checkout_pr_success || 'true' }} - model: ${{ steps.generate_aw_info.outputs.model }} - secret_verification_result: ${{ steps.validate-secret.outputs.verification_result }} - steps: - - name: Setup Scripts - uses: github/gh-aw/actions/setup@ac090214a48a1938f7abafe132460b66752261af # v0.45.4 - with: - destination: /opt/gh-aw/actions - - name: Create gh-aw temp directory - run: bash /opt/gh-aw/actions/create_gh_aw_tmp_dir.sh - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - with: - path: __phpstan-website - ref: 2.2.x - repository: phpstan/phpstan - token: ${{ secrets.PHPSTAN_BOT_TOKEN }} - - - name: Configure Git credentials - env: - REPO_NAME: ${{ github.repository }} - SERVER_URL: ${{ github.server_url }} - run: | - git config --global user.email "github-actions[bot]@users.noreply.github.com" - git config --global user.name "github-actions[bot]" - # Re-authenticate git with GitHub token - SERVER_URL_STRIPPED="${SERVER_URL#https://}" - git remote set-url origin "https://x-access-token:${{ github.token }}@${SERVER_URL_STRIPPED}/${REPO_NAME}.git" - echo "Git configured with standard GitHub Actions identity" - - name: Checkout PR branch - id: checkout-pr - if: | - github.event.pull_request - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - GH_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} - with: - github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} - script: | - const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io); - const { main } = require('/opt/gh-aw/actions/checkout_pr_branch.cjs'); - await main(); - - name: Generate agentic run info - id: generate_aw_info - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - with: - script: | - const fs = require('fs'); - - const awInfo = { - engine_id: "claude", - engine_name: "Claude Code", - model: "claude-opus-4-6", - version: "", - agent_version: "2.1.42", - cli_version: "v0.45.4", - workflow_name: "Document PHPDoc Types", - experimental: false, - supports_tools_allowlist: true, - run_id: context.runId, - run_number: context.runNumber, - run_attempt: process.env.GITHUB_RUN_ATTEMPT, - repository: context.repo.owner + '/' + context.repo.repo, - ref: context.ref, - sha: context.sha, - actor: context.actor, - event_name: context.eventName, - staged: false, - allowed_domains: ["defaults"], - firewall_enabled: true, - awf_version: "v0.19.1", - awmg_version: "v0.1.4", - steps: { - firewall: "squid" - }, - created_at: new Date().toISOString() - }; - - // Write to /tmp/gh-aw directory to avoid inclusion in PR - const tmpPath = '/tmp/gh-aw/aw_info.json'; - fs.writeFileSync(tmpPath, JSON.stringify(awInfo, null, 2)); - console.log('Generated aw_info.json at:', tmpPath); - console.log(JSON.stringify(awInfo, null, 2)); - - // Set model as output for reuse in other steps/jobs - core.setOutput('model', awInfo.model); - - name: Validate CLAUDE_CODE_OAUTH_TOKEN or ANTHROPIC_API_KEY secret - id: validate-secret - run: /opt/gh-aw/actions/validate_multi_secret.sh CLAUDE_CODE_OAUTH_TOKEN ANTHROPIC_API_KEY 'Claude Code' https://github.github.com/gh-aw/reference/engines/#anthropic-claude-code - env: - CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} - ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} - - name: Setup Node.js - uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 - with: - node-version: '24' - package-manager-cache: false - - name: Install awf binary - run: bash /opt/gh-aw/actions/install_awf_binary.sh v0.19.1 - - name: Install Claude Code CLI - run: npm install -g --silent @anthropic-ai/claude-code@2.1.42 - - name: Determine automatic lockdown mode for GitHub MCP Server - id: determine-automatic-lockdown - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - GH_AW_GITHUB_TOKEN: ${{ secrets.GH_AW_GITHUB_TOKEN }} - GH_AW_GITHUB_MCP_SERVER_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN }} - with: - script: | - const determineAutomaticLockdown = require('/opt/gh-aw/actions/determine_automatic_lockdown.cjs'); - await determineAutomaticLockdown(github, context, core); - - name: Download container images - run: bash /opt/gh-aw/actions/download_docker_images.sh ghcr.io/github/gh-aw-firewall/agent:0.19.1 ghcr.io/github/gh-aw-firewall/api-proxy:0.19.1 ghcr.io/github/gh-aw-firewall/squid:0.19.1 ghcr.io/github/gh-aw-mcpg:v0.1.4 ghcr.io/github/github-mcp-server:v0.30.3 - - name: Start MCP Gateway - id: start-mcp-gateway - env: - GITHUB_MCP_LOCKDOWN: ${{ steps.determine-automatic-lockdown.outputs.lockdown == 'true' && '1' || '0' }} - GITHUB_MCP_SERVER_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} - run: | - set -eo pipefail - mkdir -p /tmp/gh-aw/mcp-config - - # Export gateway environment variables for MCP config and gateway script - export MCP_GATEWAY_PORT="80" - export MCP_GATEWAY_DOMAIN="host.docker.internal" - MCP_GATEWAY_API_KEY=$(openssl rand -base64 45 | tr -d '/+=') - echo "::add-mask::${MCP_GATEWAY_API_KEY}" - export MCP_GATEWAY_API_KEY - export MCP_GATEWAY_PAYLOAD_DIR="/tmp/gh-aw/mcp-payloads" - mkdir -p "${MCP_GATEWAY_PAYLOAD_DIR}" - export DEBUG="*" - - export GH_AW_ENGINE="claude" - export MCP_GATEWAY_DOCKER_COMMAND='docker run -i --rm --network host -v /var/run/docker.sock:/var/run/docker.sock -e MCP_GATEWAY_PORT -e MCP_GATEWAY_DOMAIN -e MCP_GATEWAY_API_KEY -e MCP_GATEWAY_PAYLOAD_DIR -e DEBUG -e MCP_GATEWAY_LOG_DIR -e GH_AW_MCP_LOG_DIR -e GH_AW_SAFE_OUTPUTS -e GH_AW_SAFE_OUTPUTS_CONFIG_PATH -e GH_AW_SAFE_OUTPUTS_TOOLS_PATH -e GH_AW_ASSETS_BRANCH -e GH_AW_ASSETS_MAX_SIZE_KB -e GH_AW_ASSETS_ALLOWED_EXTS -e DEFAULT_BRANCH -e GITHUB_MCP_SERVER_TOKEN -e GITHUB_MCP_LOCKDOWN -e GITHUB_REPOSITORY -e GITHUB_SERVER_URL -e GITHUB_SHA -e GITHUB_WORKSPACE -e GITHUB_TOKEN -e GITHUB_RUN_ID -e GITHUB_RUN_NUMBER -e GITHUB_RUN_ATTEMPT -e GITHUB_JOB -e GITHUB_ACTION -e GITHUB_EVENT_NAME -e GITHUB_EVENT_PATH -e GITHUB_ACTOR -e GITHUB_ACTOR_ID -e GITHUB_TRIGGERING_ACTOR -e GITHUB_WORKFLOW -e GITHUB_WORKFLOW_REF -e GITHUB_WORKFLOW_SHA -e GITHUB_REF -e GITHUB_REF_NAME -e GITHUB_REF_TYPE -e GITHUB_HEAD_REF -e GITHUB_BASE_REF -v /tmp/gh-aw/mcp-payloads:/tmp/gh-aw/mcp-payloads:rw -v /opt:/opt:ro -v /tmp:/tmp:rw -v '"${GITHUB_WORKSPACE}"':'"${GITHUB_WORKSPACE}"':rw ghcr.io/github/gh-aw-mcpg:v0.1.4' - - cat << GH_AW_MCP_CONFIG_EOF | bash /opt/gh-aw/actions/start_mcp_gateway.sh - { - "mcpServers": { - "github": { - "container": "ghcr.io/github/github-mcp-server:v0.30.3", - "env": { - "GITHUB_LOCKDOWN_MODE": "$GITHUB_MCP_LOCKDOWN", - "GITHUB_PERSONAL_ACCESS_TOKEN": "$GITHUB_MCP_SERVER_TOKEN", - "GITHUB_READ_ONLY": "1", - "GITHUB_TOOLSETS": "context,repos,issues,pull_requests" - } - } - }, - "gateway": { - "port": $MCP_GATEWAY_PORT, - "domain": "${MCP_GATEWAY_DOMAIN}", - "apiKey": "${MCP_GATEWAY_API_KEY}", - "payloadDir": "${MCP_GATEWAY_PAYLOAD_DIR}" - } - } - GH_AW_MCP_CONFIG_EOF - - name: Generate workflow overview - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - with: - script: | - const { generateWorkflowOverview } = require('/opt/gh-aw/actions/generate_workflow_overview.cjs'); - await generateWorkflowOverview(core); - - name: Download prompt artifact - uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 - with: - name: prompt - path: /tmp/gh-aw/aw-prompts - - name: Clean git credentials - run: bash /opt/gh-aw/actions/clean_git_credentials.sh - - name: Execute Claude Code CLI - id: agentic_execution - # Allowed tools (sorted): - # - Bash - # - BashOutput - # - Edit - # - ExitPlanMode - # - Glob - # - Grep - # - KillBash - # - LS - # - MultiEdit - # - NotebookEdit - # - NotebookRead - # - Read - # - Task - # - TodoWrite - # - Write - # - mcp__github__download_workflow_run_artifact - # - mcp__github__get_code_scanning_alert - # - mcp__github__get_commit - # - mcp__github__get_dependabot_alert - # - mcp__github__get_discussion - # - mcp__github__get_discussion_comments - # - mcp__github__get_file_contents - # - mcp__github__get_job_logs - # - mcp__github__get_label - # - mcp__github__get_latest_release - # - mcp__github__get_me - # - mcp__github__get_notification_details - # - mcp__github__get_pull_request - # - mcp__github__get_pull_request_comments - # - mcp__github__get_pull_request_diff - # - mcp__github__get_pull_request_files - # - mcp__github__get_pull_request_review_comments - # - mcp__github__get_pull_request_reviews - # - mcp__github__get_pull_request_status - # - mcp__github__get_release_by_tag - # - mcp__github__get_secret_scanning_alert - # - mcp__github__get_tag - # - mcp__github__get_workflow_run - # - mcp__github__get_workflow_run_logs - # - mcp__github__get_workflow_run_usage - # - mcp__github__issue_read - # - mcp__github__list_branches - # - mcp__github__list_code_scanning_alerts - # - mcp__github__list_commits - # - mcp__github__list_dependabot_alerts - # - mcp__github__list_discussion_categories - # - mcp__github__list_discussions - # - mcp__github__list_issue_types - # - mcp__github__list_issues - # - mcp__github__list_label - # - mcp__github__list_notifications - # - mcp__github__list_pull_requests - # - mcp__github__list_releases - # - mcp__github__list_secret_scanning_alerts - # - mcp__github__list_starred_repositories - # - mcp__github__list_tags - # - mcp__github__list_workflow_jobs - # - mcp__github__list_workflow_run_artifacts - # - mcp__github__list_workflow_runs - # - mcp__github__list_workflows - # - mcp__github__pull_request_read - # - mcp__github__search_code - # - mcp__github__search_issues - # - mcp__github__search_orgs - # - mcp__github__search_pull_requests - # - mcp__github__search_repositories - # - mcp__github__search_users - timeout-minutes: 30 - run: | - set -o pipefail - sudo -E awf --tty --env-all --container-workdir "${GITHUB_WORKSPACE}" --allow-domains '*.githubusercontent.com,anthropic.com,api.anthropic.com,api.github.com,api.snapcraft.io,archive.ubuntu.com,azure.archive.ubuntu.com,cdn.playwright.dev,codeload.github.com,crl.geotrust.com,crl.globalsign.com,crl.identrust.com,crl.sectigo.com,crl.thawte.com,crl.usertrust.com,crl.verisign.com,crl3.digicert.com,crl4.digicert.com,crls.ssl.com,files.pythonhosted.org,ghcr.io,github-cloud.githubusercontent.com,github-cloud.s3.amazonaws.com,github.com,host.docker.internal,json-schema.org,json.schemastore.org,keyserver.ubuntu.com,lfs.github.com,objects.githubusercontent.com,ocsp.digicert.com,ocsp.geotrust.com,ocsp.globalsign.com,ocsp.identrust.com,ocsp.sectigo.com,ocsp.ssl.com,ocsp.thawte.com,ocsp.usertrust.com,ocsp.verisign.com,packagecloud.io,packages.cloud.google.com,packages.microsoft.com,playwright.download.prss.microsoft.com,ppa.launchpad.net,pypi.org,raw.githubusercontent.com,registry.npmjs.org,s.symcb.com,s.symcd.com,security.ubuntu.com,sentry.io,statsig.anthropic.com,ts-crl.ws.symantec.com,ts-ocsp.ws.symantec.com' --log-level info --proxy-logs-dir /tmp/gh-aw/sandbox/firewall/logs --enable-host-access --image-tag 0.19.1 --skip-pull --enable-api-proxy \ - -- /bin/bash -c 'export PATH="$(find /opt/hostedtoolcache -maxdepth 4 -type d -name bin 2>/dev/null | tr '\''\n'\'' '\'':'\'')$PATH"; [ -n "$GOROOT" ] && export PATH="$GOROOT/bin:$PATH" || true && claude --print --disable-slash-commands --no-chrome --model claude-opus-4-6 --mcp-config /tmp/gh-aw/mcp-config/mcp-servers.json --allowed-tools Bash,BashOutput,Edit,ExitPlanMode,Glob,Grep,KillBash,LS,MultiEdit,NotebookEdit,NotebookRead,Read,Task,TodoWrite,Write,mcp__github__download_workflow_run_artifact,mcp__github__get_code_scanning_alert,mcp__github__get_commit,mcp__github__get_dependabot_alert,mcp__github__get_discussion,mcp__github__get_discussion_comments,mcp__github__get_file_contents,mcp__github__get_job_logs,mcp__github__get_label,mcp__github__get_latest_release,mcp__github__get_me,mcp__github__get_notification_details,mcp__github__get_pull_request,mcp__github__get_pull_request_comments,mcp__github__get_pull_request_diff,mcp__github__get_pull_request_files,mcp__github__get_pull_request_review_comments,mcp__github__get_pull_request_reviews,mcp__github__get_pull_request_status,mcp__github__get_release_by_tag,mcp__github__get_secret_scanning_alert,mcp__github__get_tag,mcp__github__get_workflow_run,mcp__github__get_workflow_run_logs,mcp__github__get_workflow_run_usage,mcp__github__issue_read,mcp__github__list_branches,mcp__github__list_code_scanning_alerts,mcp__github__list_commits,mcp__github__list_dependabot_alerts,mcp__github__list_discussion_categories,mcp__github__list_discussions,mcp__github__list_issue_types,mcp__github__list_issues,mcp__github__list_label,mcp__github__list_notifications,mcp__github__list_pull_requests,mcp__github__list_releases,mcp__github__list_secret_scanning_alerts,mcp__github__list_starred_repositories,mcp__github__list_tags,mcp__github__list_workflow_jobs,mcp__github__list_workflow_run_artifacts,mcp__github__list_workflow_runs,mcp__github__list_workflows,mcp__github__pull_request_read,mcp__github__search_code,mcp__github__search_issues,mcp__github__search_orgs,mcp__github__search_pull_requests,mcp__github__search_repositories,mcp__github__search_users --debug-file /tmp/gh-aw/agent-stdio.log --verbose --permission-mode bypassPermissions --output-format stream-json "$(cat /tmp/gh-aw/aw-prompts/prompt.txt)"' 2>&1 | tee -a /tmp/gh-aw/agent-stdio.log - env: - ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} - BASH_DEFAULT_TIMEOUT_MS: 60000 - BASH_MAX_TIMEOUT_MS: 60000 - CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} - DISABLE_BUG_COMMAND: 1 - DISABLE_ERROR_REPORTING: 1 - DISABLE_TELEMETRY: 1 - GH_AW_MCP_CONFIG: /tmp/gh-aw/mcp-config/mcp-servers.json - GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt - GITHUB_WORKSPACE: ${{ github.workspace }} - MCP_TIMEOUT: 120000 - MCP_TOOL_TIMEOUT: 60000 - - name: Configure Git credentials - env: - REPO_NAME: ${{ github.repository }} - SERVER_URL: ${{ github.server_url }} - run: | - git config --global user.email "github-actions[bot]@users.noreply.github.com" - git config --global user.name "github-actions[bot]" - # Re-authenticate git with GitHub token - SERVER_URL_STRIPPED="${SERVER_URL#https://}" - git remote set-url origin "https://x-access-token:${{ github.token }}@${SERVER_URL_STRIPPED}/${REPO_NAME}.git" - echo "Git configured with standard GitHub Actions identity" - - name: Stop MCP Gateway - if: always() - continue-on-error: true - env: - MCP_GATEWAY_PORT: ${{ steps.start-mcp-gateway.outputs.gateway-port }} - MCP_GATEWAY_API_KEY: ${{ steps.start-mcp-gateway.outputs.gateway-api-key }} - GATEWAY_PID: ${{ steps.start-mcp-gateway.outputs.gateway-pid }} - run: | - bash /opt/gh-aw/actions/stop_mcp_gateway.sh "$GATEWAY_PID" - - name: Redact secrets in logs - if: always() - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - with: - script: | - const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io); - const { main } = require('/opt/gh-aw/actions/redact_secrets.cjs'); - await main(); - env: - GH_AW_SECRET_NAMES: 'ANTHROPIC_API_KEY,CLAUDE_CODE_OAUTH_TOKEN,GH_AW_GITHUB_MCP_SERVER_TOKEN,GH_AW_GITHUB_TOKEN,GITHUB_TOKEN,PHPSTAN_BOT_TOKEN' - SECRET_ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} - SECRET_CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} - SECRET_GH_AW_GITHUB_MCP_SERVER_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN }} - SECRET_GH_AW_GITHUB_TOKEN: ${{ secrets.GH_AW_GITHUB_TOKEN }} - SECRET_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - SECRET_PHPSTAN_BOT_TOKEN: ${{ secrets.PHPSTAN_BOT_TOKEN }} - - name: Parse agent logs for step summary - if: always() - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - GH_AW_AGENT_OUTPUT: /tmp/gh-aw/agent-stdio.log - with: - script: | - const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io); - const { main } = require('/opt/gh-aw/actions/parse_claude_log.cjs'); - await main(); - - name: Parse MCP Gateway logs for step summary - if: always() - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - with: - script: | - const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io); - const { main } = require('/opt/gh-aw/actions/parse_mcp_gateway_log.cjs'); - await main(); - - name: Print firewall logs - if: always() - continue-on-error: true - env: - AWF_LOGS_DIR: /tmp/gh-aw/sandbox/firewall/logs - run: | - # Fix permissions on firewall logs so they can be uploaded as artifacts - # AWF runs with sudo, creating files owned by root - sudo chmod -R a+r /tmp/gh-aw/sandbox/firewall/logs 2>/dev/null || true - # Only run awf logs summary if awf command exists (it may not be installed if workflow failed before install step) - if command -v awf &> /dev/null; then - awf logs summary | tee -a "$GITHUB_STEP_SUMMARY" - else - echo 'AWF binary not installed, skipping firewall log summary' - fi - - name: Upload agent artifacts - if: always() - continue-on-error: true - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 - with: - name: agent-artifacts - path: | - /tmp/gh-aw/aw-prompts/prompt.txt - /tmp/gh-aw/aw_info.json - /tmp/gh-aw/mcp-logs/ - /tmp/gh-aw/sandbox/firewall/logs/ - /tmp/gh-aw/agent-stdio.log - /tmp/gh-aw/agent/ - if-no-files-found: ignore - - pre_activation: - runs-on: ubuntu-slim - outputs: - activated: ${{ steps.check_membership.outputs.is_team_member == 'true' }} - steps: - - name: Setup Scripts - uses: github/gh-aw/actions/setup@ac090214a48a1938f7abafe132460b66752261af # v0.45.4 - with: - destination: /opt/gh-aw/actions - - name: Check team membership for workflow - id: check_membership - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - GH_AW_REQUIRED_ROLES: admin,maintainer,write - with: - github-token: ${{ secrets.GITHUB_TOKEN }} - script: | - const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io); - const { main } = require('/opt/gh-aw/actions/check_membership.cjs'); - await main(); - diff --git a/.github/workflows/document-phpdoc-types.md b/.github/workflows/document-phpdoc-types.md deleted file mode 100644 index 61258a431e5..00000000000 --- a/.github/workflows/document-phpdoc-types.md +++ /dev/null @@ -1,113 +0,0 @@ ---- -name: Document PHPDoc Types -description: Finds undocumented PHPDoc types in TypeNodeResolver and creates documentation PRs on phpstan/phpstan -on: - push: - branches: [2.2.x] - paths: [src/PhpDoc/TypeNodeResolver.php] - workflow_dispatch: -engine: - id: claude - model: claude-opus-4-6 - env: - CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} -permissions: - contents: read - issues: read - pull-requests: read -tools: - bash: ["*"] - github: - toolsets: [default, repos] -timeout-minutes: 30 -steps: - - uses: actions/checkout@v4 - - uses: actions/checkout@v4 - with: - repository: phpstan/phpstan - ref: 2.2.x - path: __phpstan-website - token: ${{ secrets.PHPSTAN_BOT_TOKEN }} ---- - -# Document Undocumented PHPDoc Types - -You are a documentation agent for PHPStan. Your job is to find PHPDoc types supported by `TypeNodeResolver` that are not yet documented in the user-facing PHPDoc types reference, and to add documentation for them. - -## Source files - -- **Type resolver**: `src/PhpDoc/TypeNodeResolver.php` in this workspace (phpstan-src repo) -- **PHPDoc types docs**: `__phpstan-website/website/src/writing-php-code/phpdoc-types.md` (checked out from `phpstan/phpstan`) - -## Task - -### Step 1: Extract supported types from TypeNodeResolver - -Read `src/PhpDoc/TypeNodeResolver.php` and extract every type name that it resolves. The types come from two places: - -1. **`resolveIdentifierTypeNode()`** — contains a `switch (strtolower($typeNode->name))` with `case` entries for each identifier type (e.g. `int`, `non-empty-string`, `callable-object`, etc.). - -2. **`resolveGenericTypeNode()`** — contains `if`/`elseif` checks on `$mainTypeName` for generic type forms (e.g. `array`, `class-string`, `key-of`, `int-mask`, etc.). - -**Skip** any type names that begin with `__` (double underscore) — these are internal. - -### Step 2: Extract documented types from phpdoc-types.md - -Read `__phpstan-website/website/src/writing-php-code/phpdoc-types.md` and extract all type names that are already documented. Types appear as: -- Bullet list items with inline code (e.g. `* \`int\`, \`integer\``) -- In code block examples -- In prose descriptions (e.g. "`non-falsy-string` (also known as `truthy-string`)") - -Be thorough — a type counts as "documented" even if it only appears as a secondary mention, alias, or in a code example. - -### Step 3: Compare and identify undocumented types - -{{#if github.event_name == 'push'}} -Focus only on types that were added or changed in this push. Run `git diff ${{ github.event.before }} -- src/PhpDoc/TypeNodeResolver.php` to see what changed. Only document newly added types. -{{#else}} -Compare ALL non-skipped types from TypeNodeResolver against the documentation. Document every supported type that is not yet mentioned anywhere in phpdoc-types.md. -{{/if}} - -If there are no undocumented types, stop and report that all types are documented. Do not create a PR. - -### Step 4: Add documentation for undocumented types - -Edit `__phpstan-website/website/src/writing-php-code/phpdoc-types.md` to add the missing types. Use **targeted edits** — do not overwrite the file. - -**Placement rules** — add each type to the correct existing section: - -- Integer types/ranges → "Integer ranges" section -- String types → "Other advanced string types" section -- Array types → "General arrays" section -- Class/interface/trait/enum string types → "class-string" section -- Callable types → "Callables" or "Basic types" section as appropriate -- Bottom type synonyms → "Bottom type" section -- Mixed variants → "Mixed" section -- Scalar variants → "Basic types" section -- Object variants → "Basic types" section - -**Follow the existing writing style exactly.** The documentation is concise: - -- For types added to a bullet list, just add a new `* \`type-name\`` entry or append to an existing line (e.g. adding `noreturn` to the bottom type synonyms list). -- For types that need a brief explanation, write one or two sentences in the same style as existing entries. For example, the string types section uses patterns like: - - `` `non-empty-string` is any string except `''`. `` - - `` `lowercase-string` accepts strings where `strtolower($string) === $string` is true. `` -- Only add code examples if the type's behavior is non-obvious. -- If the new type is an alias or synonym of an already-documented type, mention it alongside the existing type (e.g. add `noreturn` to the bottom type list, add `interface-string` next to `class-string`). - -### Step 5: Create a pull request - -After editing the documentation file, push the changes and create a PR on `phpstan/phpstan`: - -```bash -cd __phpstan-website -git config user.name "phpstan-bot" -git config user.email "ondrej+phpstanbot@mirtes.cz" -git checkout -b docs/undocumented-phpdoc-types -git add website/src/writing-php-code/phpdoc-types.md -git commit -m "Document undocumented PHPDoc types" -git push origin docs/undocumented-phpdoc-types -gh pr create --repo phpstan/phpstan --base 2.2.x --draft --title "[Docs] Document undocumented PHPDoc types" --body "PR DESCRIPTION HERE" -``` - -Replace `PR DESCRIPTION HERE` with a description listing which types were newly documented, grouped by section. From 87b901089f1fe6910fa93681f0118e79f6818454 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Tue, 24 Feb 2026 09:51:49 +0100 Subject: [PATCH 023/158] One less need for a token --- .github/workflows/claude-random-easy-fixes-scheduled.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/claude-random-easy-fixes-scheduled.yml b/.github/workflows/claude-random-easy-fixes-scheduled.yml index 1ddb9c24f53..fabbfb41536 100644 --- a/.github/workflows/claude-random-easy-fixes-scheduled.yml +++ b/.github/workflows/claude-random-easy-fixes-scheduled.yml @@ -8,8 +8,11 @@ on: jobs: trigger: runs-on: ubuntu-latest + permissions: + contents: read + actions: write steps: - name: Trigger Claude Random Easy Fixes env: - GH_TOKEN: ${{ secrets.PHPSTAN_BOT_TOKEN }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: gh workflow run claude-random-easy-fixes.yml -f issue_count=5 --repo ${{ github.repository }} From 5719ba9b15f83c325ef6ed294722ac426bf3d97b Mon Sep 17 00:00:00 2001 From: StepSecurity Bot Date: Tue, 24 Feb 2026 17:02:45 +0000 Subject: [PATCH 024/158] [StepSecurity] ci: Harden GitHub Actions Signed-off-by: StepSecurity Bot --- .github/actions/downgrade-code/action.yml | 4 +- .github/workflows/apiref.yml | 32 ++++--- .github/workflows/backward-compatibility.yml | 14 +++- .github/workflows/block-merge-commits.yml | 7 +- .github/workflows/build-issue-bot.yml | 16 +++- .github/workflows/changelog-generator.yml | 16 +++- .github/workflows/claude-fix-issue.yml | 18 ++-- .github/workflows/claude-fix-pr-ci.yml | 20 +++-- .../claude-random-easy-fixes-scheduled.yml | 8 ++ .../workflows/claude-random-easy-fixes.yml | 5 ++ .github/workflows/claude-react-on-comment.yml | 21 ++++- .github/workflows/close-issues-on-merge.yml | 8 ++ .github/workflows/create-tag.yml | 15 ++-- .github/workflows/e2e-tests.yml | 25 ++++-- .github/workflows/issue-bot.yml | 61 +++++++++----- .github/workflows/lint.yml | 48 +++++++---- .github/workflows/merge-bot-pr.yml | 9 +- .github/workflows/merge-maintained-branch.yml | 9 +- .github/workflows/phar.yml | 79 ++++++++++++------ .../workflows/pr-base-on-previous-branch.yml | 10 ++- .github/workflows/pr-marked-as-ready.yml | 10 ++- .github/workflows/reflection-golden-test.yml | 32 ++++--- .github/workflows/spelling.yml | 12 ++- .github/workflows/static-analysis.yml | 48 +++++++---- .github/workflows/tests.yml | 83 +++++++++++++------ .github/workflows/update-phpstorm-stubs.yml | 18 ++-- 26 files changed, 452 insertions(+), 176 deletions(-) diff --git a/.github/actions/downgrade-code/action.yml b/.github/actions/downgrade-code/action.yml index e09c61b2320..cf9440632bf 100644 --- a/.github/actions/downgrade-code/action.yml +++ b/.github/actions/downgrade-code/action.yml @@ -10,7 +10,7 @@ runs: - name: "Change to simple-downgrade PHP version" if: inputs.php-version == '7.4' || inputs.php-version == '8.0' || inputs.php-version == '8.1' - uses: "shivammathur/setup-php@v2" + uses: "shivammathur/setup-php@44454db4f0199b8b9685a5d763dc37cbf79108e1" # v2 with: coverage: "none" php-version: "8.4" @@ -25,7 +25,7 @@ runs: - name: "Re-store PHP version" if: inputs.php-version == '7.4' || inputs.php-version == '8.0' || inputs.php-version == '8.1' - uses: "shivammathur/setup-php@v2" + uses: "shivammathur/setup-php@44454db4f0199b8b9685a5d763dc37cbf79108e1" # v2 with: coverage: "none" php-version: "${{ inputs.php-version }}" diff --git a/.github/workflows/apiref.yml b/.github/workflows/apiref.yml index d22eecb2abd..889ccbf5443 100644 --- a/.github/workflows/apiref.yml +++ b/.github/workflows/apiref.yml @@ -28,19 +28,24 @@ jobs: timeout-minutes: 60 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@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - name: "Install PHP" - uses: "shivammathur/setup-php@v2" + uses: "shivammathur/setup-php@44454db4f0199b8b9685a5d763dc37cbf79108e1" # v2 with: coverage: "none" php-version: "8.2" - - uses: "ramsey/composer-install@v3" + - uses: "ramsey/composer-install@3cf229dc2919194e9e36783941438d17239e8520" # v3 - name: "Install ApiGen dependencies" - uses: "ramsey/composer-install@v3" + uses: "ramsey/composer-install@3cf229dc2919194e9e36783941438d17239e8520" # v3 with: working-directory: "apigen" @@ -48,7 +53,7 @@ jobs: run: "apigen/vendor/bin/apigen -c apigen/apigen.neon --output docs -- src vendor/nikic/php-parser vendor/ondrejmirtes/better-reflection vendor/phpstan/phpdoc-parser" - name: "Upload docs" - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: docs path: docs @@ -60,19 +65,24 @@ jobs: if: github.repository_owner == 'phpstan' runs-on: "ubuntu-latest" steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2 + with: + egress-policy: audit + - name: "Install Node" - uses: actions/setup-node@v4 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version: "16" - name: "Download docs" - uses: actions/download-artifact@v4 + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: name: docs path: docs - name: "Sync with S3" - uses: jakejarvis/s3-sync-action@v0.5.1 + uses: jakejarvis/s3-sync-action@be0c4ab89158cac4278689ebedd8407dd5f35a83 # v0.5.1 with: args: --exclude '.git*/*' --follow-symlinks env: @@ -84,7 +94,7 @@ jobs: AWS_SECRET_ACCESS_KEY: ${{ secrets.APIREF_AWS_SECRET_ACCESS_KEY }} - name: "Invalidate CloudFront" - uses: chetan/invalidate-cloudfront-action@v2 + uses: chetan/invalidate-cloudfront-action@12d242edc7752fca9140c2034be28792ad22c5a8 # v2.4.1 env: DISTRIBUTION: "E37G1C2KWNAPBD" PATHS: '/${{ github.ref_name }}/*' @@ -92,14 +102,14 @@ jobs: AWS_ACCESS_KEY_ID: ${{ secrets.APIREF_AWS_ACCESS_KEY_ID }} AWS_SECRET_ACCESS_KEY: ${{ secrets.APIREF_AWS_SECRET_ACCESS_KEY }} - - uses: peter-evans/repository-dispatch@v3 + - uses: peter-evans/repository-dispatch@ff45666b9427631e3450c54a1bcbee4d9ff4d7c0 # v3.0.0 with: token: ${{ secrets.PHPSTAN_BOT_TOKEN }} repository: "phpstan/phpstan" event-type: check_website_links - name: "Check for broken links" - uses: ScholliYT/Broken-Links-Crawler-Action@v3 + uses: ScholliYT/Broken-Links-Crawler-Action@21eab52f98097989d343116dbbd46dc4541b849b # v3.3.2 with: website_url: 'https://apiref.phpstan.org/${{ github.ref_name }}/index.html' resolve_before_filtering: 'true' diff --git a/.github/workflows/backward-compatibility.yml b/.github/workflows/backward-compatibility.yml index 3e1c4662279..57efc92341a 100644 --- a/.github/workflows/backward-compatibility.yml +++ b/.github/workflows/backward-compatibility.yml @@ -15,6 +15,9 @@ concurrency: group: bc-${{ github.head_ref || github.run_id }} # will be canceled on subsequent pushes in pull requests but not branches cancel-in-progress: true +permissions: + contents: read + jobs: backward-compatibility: name: "Backward Compatibility" @@ -23,18 +26,23 @@ jobs: timeout-minutes: 60 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@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 with: fetch-depth: 0 - name: "Install PHP" - uses: "shivammathur/setup-php@v2" + uses: "shivammathur/setup-php@44454db4f0199b8b9685a5d763dc37cbf79108e1" # v2 with: coverage: "none" php-version: "8.2" - - uses: "ramsey/composer-install@v3" + - uses: "ramsey/composer-install@3cf229dc2919194e9e36783941438d17239e8520" # v3 - name: "Install BackwardCompatibilityCheck" run: | diff --git a/.github/workflows/block-merge-commits.yml b/.github/workflows/block-merge-commits.yml index 2399d07570f..f94d10b54e9 100644 --- a/.github/workflows/block-merge-commits.yml +++ b/.github/workflows/block-merge-commits.yml @@ -9,7 +9,12 @@ jobs: runs-on: ubuntu-latest steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2 + with: + egress-policy: audit + - name: Block Merge Commits - uses: Morishiri/block-merge-commits-action@v1.0.1 + uses: Morishiri/block-merge-commits-action@a4554c78def8d874966a8d1e20e2971121443755 # v1.0.1 with: repo-token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/build-issue-bot.yml b/.github/workflows/build-issue-bot.yml index 6debccc9350..f634d4e9752 100644 --- a/.github/workflows/build-issue-bot.yml +++ b/.github/workflows/build-issue-bot.yml @@ -18,6 +18,9 @@ concurrency: group: build-issue-bot-${{ github.head_ref || github.run_id }} # will be canceled on subsequent pushes in pull requests but not branches cancel-in-progress: true +permissions: + contents: read + jobs: build-issue-bot: name: "Build Issue Bot" @@ -33,19 +36,24 @@ jobs: - "vendor/bin/phpunit" 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@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - name: "Install PHP" - uses: "shivammathur/setup-php@v2" + uses: "shivammathur/setup-php@44454db4f0199b8b9685a5d763dc37cbf79108e1" # v2 with: coverage: "none" php-version: "8.5" - - uses: "ramsey/composer-install@v3" + - uses: "ramsey/composer-install@3cf229dc2919194e9e36783941438d17239e8520" # v3 - name: "Install issue-bot dependencies" - uses: "ramsey/composer-install@v3" + uses: "ramsey/composer-install@3cf229dc2919194e9e36783941438d17239e8520" # v3 with: working-directory: "issue-bot" diff --git a/.github/workflows/changelog-generator.yml b/.github/workflows/changelog-generator.yml index cd76fb3188d..2dd24c145b5 100644 --- a/.github/workflows/changelog-generator.yml +++ b/.github/workflows/changelog-generator.yml @@ -18,6 +18,9 @@ concurrency: group: changelog-${{ github.head_ref || github.run_id }} # will be canceled on subsequent pushes in pull requests but not branches cancel-in-progress: true +permissions: + contents: read + jobs: changelog-generator: name: "Build Changelog Generator" @@ -26,19 +29,24 @@ jobs: timeout-minutes: 60 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@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - name: "Install PHP" - uses: "shivammathur/setup-php@v2" + uses: "shivammathur/setup-php@44454db4f0199b8b9685a5d763dc37cbf79108e1" # v2 with: coverage: "none" php-version: "8.2" - - uses: "ramsey/composer-install@v3" + - uses: "ramsey/composer-install@3cf229dc2919194e9e36783941438d17239e8520" # v3 - name: "Install Changelog Generator dependencies" - uses: "ramsey/composer-install@v3" + uses: "ramsey/composer-install@3cf229dc2919194e9e36783941438d17239e8520" # v3 with: working-directory: "changelog-generator" diff --git a/.github/workflows/claude-fix-issue.yml b/.github/workflows/claude-fix-issue.yml index 84e7338bc6c..198b6012b1b 100644 --- a/.github/workflows/claude-fix-issue.yml +++ b/.github/workflows/claude-fix-issue.yml @@ -14,6 +14,9 @@ on: required: true type: string +permissions: + contents: read + jobs: fix: name: "Fix #${{ inputs.issue-number }}" @@ -25,22 +28,27 @@ jobs: pull-requests: write 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@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 with: repository: phpstan/phpstan-src ref: "2.1.x" fetch-depth: 0 - name: "Install PHP" - uses: "shivammathur/setup-php@v2" + uses: "shivammathur/setup-php@44454db4f0199b8b9685a5d763dc37cbf79108e1" # v2 with: coverage: "none" php-version: "8.4" ini-file: development extensions: mbstring - - uses: "ramsey/composer-install@v3" + - uses: "ramsey/composer-install@3cf229dc2919194e9e36783941438d17239e8520" # v3 - name: "Fetch issue details" id: issue @@ -59,7 +67,7 @@ jobs: echo "$ISSUE_JSON" | jq -r '.body' > /tmp/issue-body.txt - name: "Run Claude Code" - uses: anthropics/claude-code-action@v1 + uses: anthropics/claude-code-action@35a9e0292d36f1186f5d842b14eb575074e8b450 # v1.0.57 with: claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} claude_args: "--model claude-opus-4-6" @@ -161,7 +169,7 @@ jobs: - name: "Create Pull Request" id: create-pr - uses: peter-evans/create-pull-request@v6 + uses: peter-evans/create-pull-request@c5a7806660adbe173f04e3e038b0ccdcd758773c # v6.1.0 with: branch-suffix: random delete-branch: true diff --git a/.github/workflows/claude-fix-pr-ci.yml b/.github/workflows/claude-fix-pr-ci.yml index 912681e59d0..154ff465764 100644 --- a/.github/workflows/claude-fix-pr-ci.yml +++ b/.github/workflows/claude-fix-pr-ci.yml @@ -19,9 +19,14 @@ jobs: outputs: status: ${{ steps.waitforstatuschecks.outputs.status }} steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2 + with: + egress-policy: audit + - name: "Wait for status checks" id: waitforstatuschecks - uses: "WyriHaximus/github-action-wait-for-status@v1" + uses: "WyriHaximus/github-action-wait-for-status@b809158b20d3e32350fe2d868a124f7f2e0e4253" # v1 with: ignoreActions: "Wait for CI checks,Fix CI failure,Automerge PRs" checkInterval: 13 @@ -40,6 +45,11 @@ jobs: pull-requests: write steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2 + with: + egress-policy: audit + - name: "Check fix attempt count" id: check-attempts env: @@ -114,14 +124,14 @@ jobs: - name: "Checkout PR branch" if: steps.check-attempts.outputs.skip != 'true' && steps.failures.outputs.skip != 'true' - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 with: ref: ${{ github.head_ref }} fetch-depth: 0 - name: "Install PHP" if: steps.check-attempts.outputs.skip != 'true' && steps.failures.outputs.skip != 'true' - uses: "shivammathur/setup-php@v2" + uses: "shivammathur/setup-php@44454db4f0199b8b9685a5d763dc37cbf79108e1" # v2 with: coverage: "none" php-version: "8.4" @@ -130,11 +140,11 @@ jobs: - name: "Install dependencies" if: steps.check-attempts.outputs.skip != 'true' && steps.failures.outputs.skip != 'true' - uses: "ramsey/composer-install@v3" + uses: "ramsey/composer-install@3cf229dc2919194e9e36783941438d17239e8520" # v3 - name: "Run Claude Code" if: steps.check-attempts.outputs.skip != 'true' && steps.failures.outputs.skip != 'true' - uses: anthropics/claude-code-action@v1 + uses: anthropics/claude-code-action@35a9e0292d36f1186f5d842b14eb575074e8b450 # v1.0.57 with: claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} claude_args: "--model claude-opus-4-6" diff --git a/.github/workflows/claude-random-easy-fixes-scheduled.yml b/.github/workflows/claude-random-easy-fixes-scheduled.yml index fabbfb41536..f9a9e032eb7 100644 --- a/.github/workflows/claude-random-easy-fixes-scheduled.yml +++ b/.github/workflows/claude-random-easy-fixes-scheduled.yml @@ -5,6 +5,9 @@ on: # Run 4 times, once an hour at :15, from 9pm CET (20:00 UTC) to 12am CET (23:00 UTC) - cron: '15 20-23 * * *' +permissions: + contents: read + jobs: trigger: runs-on: ubuntu-latest @@ -12,6 +15,11 @@ jobs: 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 Random Easy Fixes env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/claude-random-easy-fixes.yml b/.github/workflows/claude-random-easy-fixes.yml index 3e84d8e2428..7ab81867350 100644 --- a/.github/workflows/claude-random-easy-fixes.yml +++ b/.github/workflows/claude-random-easy-fixes.yml @@ -23,6 +23,11 @@ jobs: issues: read steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2 + with: + egress-policy: audit + - name: "Pick random Easy fix issues" id: pick-issues env: diff --git a/.github/workflows/claude-react-on-comment.yml b/.github/workflows/claude-react-on-comment.yml index 1e4dd5d17b1..ec3276d5034 100644 --- a/.github/workflows/claude-react-on-comment.yml +++ b/.github/workflows/claude-react-on-comment.yml @@ -12,6 +12,9 @@ concurrency: group: claude-pr-reactions-${{ github.event.pull_request.number || github.event.issue.number }} cancel-in-progress: false +permissions: + contents: read + jobs: check-trigger: name: "Check trigger phrase" @@ -20,6 +23,11 @@ jobs: outputs: triggered: ${{ steps.check.outputs.triggered }} steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2 + with: + egress-policy: audit + - name: "Check for trigger phrase" id: check env: @@ -47,23 +55,28 @@ jobs: id-token: write 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@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 with: fetch-depth: 0 - name: "Install PHP" - uses: "shivammathur/setup-php@v2" + uses: "shivammathur/setup-php@44454db4f0199b8b9685a5d763dc37cbf79108e1" # v2 with: coverage: "none" php-version: "8.4" ini-file: development extensions: mbstring - - uses: "ramsey/composer-install@v3" + - uses: "ramsey/composer-install@3cf229dc2919194e9e36783941438d17239e8520" # v3 - name: "React to feedback" - uses: anthropics/claude-code-action@v1 + uses: anthropics/claude-code-action@35a9e0292d36f1186f5d842b14eb575074e8b450 # v1.0.57 with: claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} trigger_phrase: "@phpstan-bot" diff --git a/.github/workflows/close-issues-on-merge.yml b/.github/workflows/close-issues-on-merge.yml index e157df37c5e..4c411c33656 100644 --- a/.github/workflows/close-issues-on-merge.yml +++ b/.github/workflows/close-issues-on-merge.yml @@ -9,12 +9,20 @@ on: types: - closed +permissions: + contents: read + jobs: close-issues: name: Close linked issues if: github.repository_owner == 'phpstan' && github.event.pull_request.merged == true runs-on: ubuntu-latest steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2 + with: + egress-policy: audit + - name: "Find and close linked issues" env: GH_TOKEN: ${{ secrets.PHPSTAN_BOT_TOKEN }} diff --git a/.github/workflows/create-tag.yml b/.github/workflows/create-tag.yml index a853501487e..493a742a6a7 100644 --- a/.github/workflows/create-tag.yml +++ b/.github/workflows/create-tag.yml @@ -20,33 +20,38 @@ jobs: name: "Create tag" runs-on: "ubuntu-latest" steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2 + with: + egress-policy: audit + - name: "Checkout" - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 with: fetch-depth: 0 token: ${{ secrets.PHPSTAN_BOT_TOKEN }} - name: 'Get Previous tag' id: previoustag - uses: "WyriHaximus/github-action-get-previous-tag@v1" + uses: "WyriHaximus/github-action-get-previous-tag@04e8485ecb6487243907e330d522ff60f02283ce" # v1.4.0 env: GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}" - name: 'Get next versions' id: semvers - uses: "WyriHaximus/github-action-next-semvers@v1" + uses: "WyriHaximus/github-action-next-semvers@d079934efaf011a4cf8912d4637097fe35d32b93" # v1 with: version: ${{ steps.previoustag.outputs.tag }} - name: "Create new minor tag" - uses: rickstaa/action-create-tag@v1 + uses: rickstaa/action-create-tag@a1c7777fcb2fee4f19b0f283ba888afa11678b72 # v1.7.2 if: inputs.version == 'minor' with: tag: ${{ steps.semvers.outputs.minor }} message: ${{ steps.semvers.outputs.minor }} - name: "Create new patch tag" - uses: rickstaa/action-create-tag@v1 + uses: rickstaa/action-create-tag@a1c7777fcb2fee4f19b0f283ba888afa11678b72 # v1.7.2 if: inputs.version == 'patch' with: tag: ${{ steps.semvers.outputs.patch }} diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index de913177244..7a378cbb307 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -22,6 +22,9 @@ concurrency: group: e2e-${{ github.head_ref || github.run_id }} # will be canceled on subsequent pushes in pull requests but not branches cancel-in-progress: true +permissions: + contents: read + jobs: result-cache-e2e-tests: name: "Result cache E2E tests" @@ -304,18 +307,23 @@ jobs: ../bashunit -a not_contains 'test.php:7' "$OUTPUT" 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@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - name: "Install PHP" - uses: "shivammathur/setup-php@v2" + uses: "shivammathur/setup-php@44454db4f0199b8b9685a5d763dc37cbf79108e1" # v2 with: coverage: "none" php-version: "8.2" extensions: mbstring ini-values: memory_limit=256M - - uses: "ramsey/composer-install@v3" + - uses: "ramsey/composer-install@3cf229dc2919194e9e36783941438d17239e8520" # v3 - name: "Patch PHPStan" run: "patch src/Analyser/Error.php < e2e/PHPStanErrorPatch.patch" @@ -422,18 +430,23 @@ jobs: ../../bin/phpstan analyze 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@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - name: "Install PHP" - uses: "shivammathur/setup-php@v2" + uses: "shivammathur/setup-php@44454db4f0199b8b9685a5d763dc37cbf79108e1" # v2 with: coverage: "none" php-version: "8.2" tools: ${{ matrix.tools }} extensions: ${{ matrix.extensions }} - - uses: "ramsey/composer-install@v3" + - uses: "ramsey/composer-install@3cf229dc2919194e9e36783941438d17239e8520" # v3 - name: "Install bashunit" run: "curl -s https://bashunit.typeddevs.com/install.sh | bash -s e2e/ 0.22.0" diff --git a/.github/workflows/issue-bot.yml b/.github/workflows/issue-bot.yml index bb3d3cda927..ea4e4fd42a6 100644 --- a/.github/workflows/issue-bot.yml +++ b/.github/workflows/issue-bot.yml @@ -31,22 +31,27 @@ jobs: matrix: ${{ steps.shards.outputs.shards }} 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@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - name: "Install PHP" - uses: "shivammathur/setup-php@v2" + uses: "shivammathur/setup-php@44454db4f0199b8b9685a5d763dc37cbf79108e1" # v2 with: coverage: "none" php-version: "8.5" - name: "Install issue-bot dependencies" - uses: "ramsey/composer-install@v3" + uses: "ramsey/composer-install@3cf229dc2919194e9e36783941438d17239e8520" # v3 with: working-directory: "issue-bot" - name: "Cache downloads" - uses: actions/cache@v4 + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: ./issue-bot/tmp key: "issue-bot-download-v7-${{ github.run_id }}" @@ -65,17 +70,17 @@ jobs: run: | echo "shards=$(jq -c '{include: [range(length) | {shard: .}]}' matrix.json)" >> $GITHUB_OUTPUT - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: playground-cache path: issue-bot/tmp/playgroundCache.tmp - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: issue-cache path: issue-bot/tmp/issueCache.tmp - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: matrix path: issue-bot/matrix.json @@ -91,36 +96,41 @@ jobs: matrix: ${{ fromJSON(needs.download.outputs.matrix) }} 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@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - name: "Install PHP" - uses: "shivammathur/setup-php@v2" + uses: "shivammathur/setup-php@44454db4f0199b8b9685a5d763dc37cbf79108e1" # v2 with: coverage: "none" php-version: "8.5" - - uses: "ramsey/composer-install@v3" + - uses: "ramsey/composer-install@3cf229dc2919194e9e36783941438d17239e8520" # v3 with: composer-options: "--no-dev" - name: "Install issue-bot dependencies" - uses: "ramsey/composer-install@v3" + uses: "ramsey/composer-install@3cf229dc2919194e9e36783941438d17239e8520" # v3 with: working-directory: "issue-bot" - - uses: Wandalen/wretry.action@v3.8.0 + - uses: Wandalen/wretry.action@e68c23e6309f2871ca8ae4763e7629b9c258e1ea # v3.8.0 with: - action: actions/download-artifact@v4 + action: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: | name: playground-cache path: issue-bot/tmp attempt_limit: 5 attempt_delay: 1000 - - uses: Wandalen/wretry.action@v3.8.0 + - uses: Wandalen/wretry.action@e68c23e6309f2871ca8ae4763e7629b9c258e1ea # v3.8.0 with: - action: actions/download-artifact@v4 + action: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: | name: matrix path: issue-bot @@ -140,7 +150,7 @@ jobs: timeout-minutes: 5 run: ./console.php run ${{ steps.chunk.outputs.phpVersion }} ${{ steps.chunk.outputs.playgroundExamples }} - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: results-${{ steps.chunk.outputs.phpVersion }}-${{ steps.chunk.outputs.chunkNumber }} path: issue-bot/tmp/results-${{ steps.chunk.outputs.phpVersion }}-*.tmp @@ -152,31 +162,36 @@ jobs: runs-on: "ubuntu-latest" steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2 + with: + egress-policy: audit + - name: "Checkout" - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - name: "Install PHP" - uses: "shivammathur/setup-php@v2" + uses: "shivammathur/setup-php@44454db4f0199b8b9685a5d763dc37cbf79108e1" # v2 with: coverage: "none" php-version: "8.5" - name: "Install issue-bot dependencies" - uses: "ramsey/composer-install@v3" + uses: "ramsey/composer-install@3cf229dc2919194e9e36783941438d17239e8520" # v3 with: working-directory: "issue-bot" - - uses: actions/download-artifact@v4 + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: name: playground-cache path: issue-bot/tmp - - uses: actions/download-artifact@v4 + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: name: issue-cache path: issue-bot/tmp - - uses: actions/download-artifact@v4 + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: pattern: results-* merge-multiple: true @@ -206,7 +221,7 @@ jobs: - name: "Upload step summary" if: github.event_name == 'pull_request' - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: step-summary path: issue-bot/tmp/step-summary.md diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 43c28049931..a48cc8087d4 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -31,11 +31,16 @@ jobs: - "8.5" steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2 + with: + egress-policy: audit + - name: "Checkout" - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - name: "Install PHP" - uses: "shivammathur/setup-php@v2" + uses: "shivammathur/setup-php@44454db4f0199b8b9685a5d763dc37cbf79108e1" # v2 with: coverage: "none" php-version: "${{ matrix.php-version }}" @@ -44,7 +49,7 @@ jobs: if: matrix.php-version == '7.4' || matrix.php-version == '8.0' || matrix.php-version == '8.1' run: "composer require --dev phpunit/phpunit:^9.6 sebastian/diff:^4.0 doctrine/instantiator:^1.0 --update-with-dependencies --ignore-platform-reqs" - - uses: "ramsey/composer-install@v3" + - uses: "ramsey/composer-install@3cf229dc2919194e9e36783941438d17239e8520" # v3 - uses: ./.github/actions/downgrade-code with: @@ -63,18 +68,23 @@ jobs: timeout-minutes: 60 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@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - name: "Checkout build-cs" - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 with: repository: "phpstan/build-cs" path: "build-cs" ref: "2.x" - name: "Install PHP" - uses: "shivammathur/setup-php@v2" + uses: "shivammathur/setup-php@44454db4f0199b8b9685a5d763dc37cbf79108e1" # v2 with: coverage: "none" php-version: "8.5" @@ -82,10 +92,10 @@ jobs: - name: "Validate Composer" run: "composer validate" - - uses: "ramsey/composer-install@v3" + - uses: "ramsey/composer-install@3cf229dc2919194e9e36783941438d17239e8520" # v3 - name: "Install build-cs dependencies" - uses: "ramsey/composer-install@v3" + uses: "ramsey/composer-install@3cf229dc2919194e9e36783941438d17239e8520" # v3 with: working-directory: "build-cs" @@ -102,16 +112,21 @@ jobs: timeout-minutes: 60 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@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - name: "Install PHP" - uses: "shivammathur/setup-php@v2" + uses: "shivammathur/setup-php@44454db4f0199b8b9685a5d763dc37cbf79108e1" # v2 with: coverage: "none" php-version: "8.5" - - uses: "ramsey/composer-install@v3" + - uses: "ramsey/composer-install@3cf229dc2919194e9e36783941438d17239e8520" # v3 - name: "Composer Dependency Analyser" run: "make composer-dependency-analyser" @@ -123,16 +138,21 @@ jobs: timeout-minutes: 60 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@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - name: "Install PHP" - uses: "shivammathur/setup-php@v2" + uses: "shivammathur/setup-php@44454db4f0199b8b9685a5d763dc37cbf79108e1" # v2 with: coverage: "none" php-version: "8.5" - - uses: "ramsey/composer-install@v3" + - uses: "ramsey/composer-install@3cf229dc2919194e9e36783941438d17239e8520" # v3 - name: "Name Collision Detector" run: "make name-collision" diff --git a/.github/workflows/merge-bot-pr.yml b/.github/workflows/merge-bot-pr.yml index 6d34bb3d80d..58a9ddb5570 100644 --- a/.github/workflows/merge-bot-pr.yml +++ b/.github/workflows/merge-bot-pr.yml @@ -11,17 +11,22 @@ jobs: name: Automerge PRs runs-on: ubuntu-latest steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2 + with: + egress-policy: audit + - name: 'Wait for status checks' if: github.event.pull_request.user.login == 'phpstan-bot' id: waitforstatuschecks - uses: "WyriHaximus/github-action-wait-for-status@v1" + uses: "WyriHaximus/github-action-wait-for-status@b809158b20d3e32350fe2d868a124f7f2e0e4253" # v1 with: ignoreActions: "automerge,Automerge PRs" checkInterval: 13 env: GITHUB_TOKEN: "${{ secrets.PHPSTAN_BOT_TOKEN }}" - name: Merge Pull Request - uses: juliangruber/merge-pull-request-action@v1 + uses: juliangruber/merge-pull-request-action@d4773803fdc1d1fd46801ab0c56c135df9075de8 # v1.1.1 if: steps.waitforstatuschecks.outputs.status == 'success' with: github-token: "${{ secrets.PHPSTAN_BOT_TOKEN }}" diff --git a/.github/workflows/merge-maintained-branch.yml b/.github/workflows/merge-maintained-branch.yml index 0910e6db4a5..8b5aa99578a 100644 --- a/.github/workflows/merge-maintained-branch.yml +++ b/.github/workflows/merge-maintained-branch.yml @@ -13,10 +13,15 @@ jobs: if: github.repository_owner == 'phpstan' runs-on: ubuntu-latest steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2 + with: + egress-policy: audit + - name: "Checkout" - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - name: "Merge branch" - uses: everlytic/branch-merge@1.1.5 + uses: everlytic/branch-merge@c4a244dc23143f824ae6c022a10732566cb8e973 # 1.1.5 with: github_token: "${{ secrets.PHPSTAN_BOT_TOKEN }}" source_ref: ${{ github.ref }} diff --git a/.github/workflows/phar.yml b/.github/workflows/phar.yml index be5ef62818b..ed356e6197e 100644 --- a/.github/workflows/phar.yml +++ b/.github/workflows/phar.yml @@ -26,26 +26,31 @@ jobs: compiler_changed: ${{ steps.changes.outputs.compiler }} 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@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 with: fetch-depth: 0 - name: "Install PHP" - uses: "shivammathur/setup-php@v2" + uses: "shivammathur/setup-php@44454db4f0199b8b9685a5d763dc37cbf79108e1" # v2 with: coverage: "none" php-version: "8.2" extensions: mbstring, intl - - uses: "ramsey/composer-install@v3" + - uses: "ramsey/composer-install@3cf229dc2919194e9e36783941438d17239e8520" # v3 # only sebastian/diff ^4 supports PHP 7.4 so we need that in the PHAR - name: "Downgrade PHPUnit" run: "composer require --dev phpunit/phpunit:^9.6 sebastian/diff:^4.0 doctrine/instantiator:^1.0 --update-with-dependencies --ignore-platform-reqs" - name: "Install compiler dependencies" - uses: "ramsey/composer-install@v3" + uses: "ramsey/composer-install@3cf229dc2919194e9e36783941438d17239e8520" # v3 with: working-directory: "compiler" @@ -65,7 +70,7 @@ jobs: run: "composer dump" - name: "Install Box dependencies" - uses: "ramsey/composer-install@v3" + uses: "ramsey/composer-install@3cf229dc2919194e9e36783941438d17239e8520" # v3 with: working-directory: "compiler/box" @@ -73,7 +78,7 @@ jobs: working-directory: "compiler/build" run: "php ../box/vendor/bin/box compile --no-parallel --sort-compiled-files" - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: phar-file path: tmp/phpstan.phar @@ -88,7 +93,7 @@ jobs: - name: "Set autoloader suffix" run: "composer config autoloader-suffix PHPStanChecksum" - - uses: "ramsey/composer-install@v3" + - uses: "ramsey/composer-install@3cf229dc2919194e9e36783941438d17239e8520" # v3 env: COMPOSER_ROOT_VERSION: "2.2.x-dev" @@ -109,7 +114,7 @@ jobs: id: "checksum" run: echo "md5=$(md5sum tmp/phpstan.phar | cut -d' ' -f1)" >> $GITHUB_OUTPUT - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: phar-file-checksum path: tmp/phpstan.phar @@ -117,7 +122,7 @@ jobs: - name: "Delete checksum PHAR" run: "rm tmp/phpstan.phar" - - uses: dorny/paths-filter@v3 + - uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2 id: changes with: filters: | @@ -156,14 +161,19 @@ jobs: if: github.event_name == 'pull_request' && needs.compiler-tests.outputs.compiler_changed == 'true' runs-on: "ubuntu-latest" steps: - - uses: actions/checkout@v4 + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2 + with: + egress-policy: audit + + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - name: Get base commit SHA id: base run: echo "base_sha=${{ github.event.pull_request.base.sha }}" >> "$GITHUB_OUTPUT" - name: Set up Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version: 20 @@ -177,7 +187,7 @@ jobs: - name: Find phar-file-checksum from base commit id: find-artifact - uses: actions/github-script@v7 + uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0 env: BASE_SHA: ${{ steps.base.outputs.base_sha }} ARTIFACT_NAME: phar-file-checksum @@ -189,14 +199,14 @@ jobs: # saved to phar-file-checksum/phpstan.phar - name: Download old artifact by ID - uses: actions/download-artifact@v4 + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: artifact-ids: ${{ steps.find-artifact.outputs.artifact_id }} run-id: ${{ steps.find-artifact.outputs.run_id }} github-token: ${{ secrets.GITHUB_TOKEN }} - name: "Upload old artifact" - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: phar-file-checksum-base path: phar-file-checksum/phpstan.phar @@ -209,8 +219,13 @@ jobs: runs-on: "ubuntu-latest" steps: # saved to phpstan.phar + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2 + with: + egress-policy: audit + - name: "Download base phpstan.phar" - uses: actions/download-artifact@v4 + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: name: phar-file-checksum-base @@ -229,32 +244,37 @@ jobs: needs: download-base-sha-phar runs-on: "ubuntu-latest" steps: - - uses: actions/checkout@v4 + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2 + with: + egress-policy: audit + + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 # saved to phar-file-checksum/phpstan.phar - name: "Download phpstan.phar" - uses: actions/download-artifact@v4 + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: name: phar-file-checksum path: phar-file-checksum # saved to phar-file-checksum-base/phpstan.phar - name: "Download base phpstan.phar" - uses: actions/download-artifact@v4 + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: name: phar-file-checksum-base path: phar-file-checksum-base - name: "Install PHP" - uses: "shivammathur/setup-php@v2" + uses: "shivammathur/setup-php@44454db4f0199b8b9685a5d763dc37cbf79108e1" # v2 with: coverage: "none" php-version: "8.2" - - uses: "ramsey/composer-install@v3" + - uses: "ramsey/composer-install@3cf229dc2919194e9e36783941438d17239e8520" # v3 - name: "Install Box dependencies" - uses: "ramsey/composer-install@v3" + uses: "ramsey/composer-install@3cf229dc2919194e9e36783941438d17239e8520" # v3 with: working-directory: "compiler/box" @@ -283,10 +303,15 @@ jobs: runs-on: "ubuntu-latest" timeout-minutes: 60 steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2 + with: + egress-policy: audit + - name: Import GPG key id: import-gpg - uses: crazy-max/ghaction-import-gpg@v6 + uses: crazy-max/ghaction-import-gpg@e89d40939c28e39f97cf32126055eeae86ba74ec # v6.3.0 with: gpg_private_key: ${{ secrets.GPG_PHPSTANBOT_PRIVATE_KEY }} passphrase: ${{ secrets.GPG_PHPSTANBOT_KEY_PASSPHRASE }} @@ -295,7 +320,7 @@ jobs: git_commit_gpgsign: true - name: "Checkout phpstan-dist" - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 with: repository: phpstan/phpstan path: phpstan-dist @@ -308,7 +333,7 @@ jobs: run: echo "sha=$(sed -n '2p' .phar-checksum)" >> $GITHUB_OUTPUT - name: "Checkout phpstan-src" - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 with: fetch-depth: 0 path: phpstan-src @@ -338,7 +363,7 @@ jobs: fi - name: "Download phpstan.phar" - uses: actions/download-artifact@v4 + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: name: phar-file @@ -364,7 +389,7 @@ jobs: run: "gpg --verify phpstan.phar.asc" - name: "Install lucky_commit" - uses: baptiste0928/cargo-install@v3 + uses: baptiste0928/cargo-install@f204293d9709061b7bc1756fec3ec4e2cd57dec0 # v3.4.0 with: crate: lucky_commit args: --no-default-features @@ -384,7 +409,7 @@ jobs: - name: "Commit PHAR - tag" if: "startsWith(github.ref, 'refs/tags/')" - uses: stefanzweifel/git-auto-commit-action@v5 + uses: stefanzweifel/git-auto-commit-action@b863ae1933cb653a53c021fe36dbb774e1fb9403 # v5.2.0 with: commit_user_name: "phpstan-bot" commit_user_email: "ondrej+phpstanbot@mirtes.cz" diff --git a/.github/workflows/pr-base-on-previous-branch.yml b/.github/workflows/pr-base-on-previous-branch.yml index 34ef71bb837..27762473433 100644 --- a/.github/workflows/pr-base-on-previous-branch.yml +++ b/.github/workflows/pr-base-on-previous-branch.yml @@ -10,14 +10,22 @@ on: - '2.2.x' +permissions: + contents: read + jobs: comment: name: "Comment on pull request" runs-on: 'ubuntu-latest' steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2 + with: + egress-policy: audit + - name: Comment PR - uses: peter-evans/create-or-update-comment@v4 + uses: peter-evans/create-or-update-comment@71345be0265236311c031f5c7866368bd1eff043 # v4.0.0 with: body: "You've opened the pull request against the latest branch 2.2.x. PHPStan 2.2 is not going to be released for months. If your code is relevant on 2.1.x and you want it to be released sooner, please rebase your pull request and change its target to 2.1.x." token: ${{ secrets.PHPSTAN_BOT_TOKEN }} diff --git a/.github/workflows/pr-marked-as-ready.yml b/.github/workflows/pr-marked-as-ready.yml index b9785a2a3c5..03182b1e413 100644 --- a/.github/workflows/pr-marked-as-ready.yml +++ b/.github/workflows/pr-marked-as-ready.yml @@ -7,14 +7,22 @@ on: types: - ready_for_review +permissions: + contents: read + jobs: comment: name: "Comment on pull request" runs-on: 'ubuntu-latest' steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2 + with: + egress-policy: audit + - name: Comment PR - uses: peter-evans/create-or-update-comment@v4 + uses: peter-evans/create-or-update-comment@71345be0265236311c031f5c7866368bd1eff043 # v4.0.0 with: body: "This pull request has been marked as ready for review." token: ${{ secrets.PHPSTAN_BOT_TOKEN }} diff --git a/.github/workflows/reflection-golden-test.yml b/.github/workflows/reflection-golden-test.yml index f57fa94f9b6..4ca96ccde2f 100644 --- a/.github/workflows/reflection-golden-test.yml +++ b/.github/workflows/reflection-golden-test.yml @@ -32,23 +32,28 @@ jobs: runs-on: "ubuntu-latest" steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2 + with: + egress-policy: audit + - name: "Checkout" - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - name: "Install PHP" - uses: "shivammathur/setup-php@v2" + uses: "shivammathur/setup-php@44454db4f0199b8b9685a5d763dc37cbf79108e1" # v2 with: coverage: "none" php-version: "8.3" # Include exotic extensions to discover more symbols extensions: ds,mbstring,runkit7,scoutapm,seaslog,simdjson,var_representation,yac - - uses: "ramsey/composer-install@v3" + - uses: "ramsey/composer-install@3cf229dc2919194e9e36783941438d17239e8520" # v3 - name: "Dump phpSymbols.txt" run: "php tests/dump-reflection-test-symbols.php" - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: phpSymbols path: ${{ env.REFLECTION_GOLDEN_SYMBOLS_FILE }} @@ -69,7 +74,12 @@ jobs: - "8.5" steps: - - uses: Wandalen/wretry.action@v3.8.0 + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2 + with: + egress-policy: audit + + - uses: Wandalen/wretry.action@e68c23e6309f2871ca8ae4763e7629b9c258e1ea # v3.8.0 with: action: actions/download-artifact@v4 with: | @@ -79,12 +89,12 @@ jobs: attempt_delay: 1000 - name: "Checkout base commit" - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 with: ref: ${{ github.event.pull_request.base.sha || github.event.before }} - name: "Install PHP" - uses: "shivammathur/setup-php@v2" + uses: "shivammathur/setup-php@44454db4f0199b8b9685a5d763dc37cbf79108e1" # v2 with: coverage: "none" php-version: "${{ matrix.php-version }}" @@ -93,20 +103,20 @@ jobs: ini-file: development ini-values: memory_limit=2G - - uses: "ramsey/composer-install@v3" + - uses: "ramsey/composer-install@3cf229dc2919194e9e36783941438d17239e8520" # v3 - name: "Dump previous reflection data" run: "php tests/generate-reflection-test.php" - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: reflection-${{ matrix.php-version }}.test path: ${{ env.REFLECTION_GOLDEN_TEST_FILE }} - name: "Checkout" - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - - uses: "ramsey/composer-install@v3" + - uses: "ramsey/composer-install@3cf229dc2919194e9e36783941438d17239e8520" # v3 - name: "Reflection golden test" run: "make tests-golden-reflection || true" diff --git a/.github/workflows/spelling.yml b/.github/workflows/spelling.yml index 24f48d2bb76..442cd7347e5 100644 --- a/.github/workflows/spelling.yml +++ b/.github/workflows/spelling.yml @@ -8,16 +8,24 @@ on: branches: - "2.2.x" +permissions: + contents: read + jobs: typos: name: "Check for typos" runs-on: "ubuntu-latest" steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2 + with: + egress-policy: audit + - name: "Checkout" - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - name: "Check for typos" - uses: "crate-ci/typos@v1" + uses: "crate-ci/typos@57b11c6b7e54c402ccd9cda953f1072ec4f78e33" # v1.43.5 with: files: "README.md src/" diff --git a/.github/workflows/static-analysis.yml b/.github/workflows/static-analysis.yml index 681d948eb38..c16667fdd4a 100644 --- a/.github/workflows/static-analysis.yml +++ b/.github/workflows/static-analysis.yml @@ -38,11 +38,16 @@ jobs: operating-system: [ubuntu-latest, windows-latest] steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2 + with: + egress-policy: audit + - name: "Checkout" - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - name: "Install PHP" - uses: "shivammathur/setup-php@v2" + uses: "shivammathur/setup-php@44454db4f0199b8b9685a5d763dc37cbf79108e1" # v2 with: coverage: "none" php-version: "${{ matrix.php-version }}" @@ -54,7 +59,7 @@ jobs: shell: bash run: "composer require --dev phpunit/phpunit:^9.6 sebastian/diff:^4.0 doctrine/instantiator:^1.0 --update-with-dependencies --ignore-platform-reqs" - - uses: "ramsey/composer-install@v3" + - uses: "ramsey/composer-install@3cf229dc2919194e9e36783941438d17239e8520" # v3 - uses: ./.github/actions/downgrade-code with: @@ -67,7 +72,7 @@ jobs: if: failure() && (matrix.php-version == '7.4' || matrix.php-version == '8.0' || matrix.php-version == '8.1') run: "php -d memory_limit=599M bin/phpstan analyse --generate-baseline baseline-php-${{ matrix.php-version }}.neon" - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 if: ${{ failure() }} with: name: baseline-${{ matrix.php-version }} @@ -89,21 +94,26 @@ jobs: - "8.5" steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2 + with: + egress-policy: audit + - name: "Checkout" - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - name: "Install PHP" - uses: "shivammathur/setup-php@v2" + uses: "shivammathur/setup-php@44454db4f0199b8b9685a5d763dc37cbf79108e1" # v2 with: coverage: "none" php-version: "${{ matrix.php-version }}" ini-file: development extensions: mbstring - - uses: "ramsey/composer-install@v3" + - uses: "ramsey/composer-install@3cf229dc2919194e9e36783941438d17239e8520" # v3 - name: "Cache Result cache" - uses: actions/cache@v4 + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: ./tmp key: "result-cache-v15-${{ matrix.php-version }}-${{ github.run_id }}" @@ -126,17 +136,22 @@ jobs: timeout-minutes: 60 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@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - name: "Install PHP" - uses: "shivammathur/setup-php@v2" + uses: "shivammathur/setup-php@44454db4f0199b8b9685a5d763dc37cbf79108e1" # v2 with: coverage: "none" php-version: "8.2" ini-file: development - - uses: "ramsey/composer-install@v3" + - uses: "ramsey/composer-install@3cf229dc2919194e9e36783941438d17239e8520" # v3 - name: "Generate baseline" run: | @@ -151,17 +166,22 @@ jobs: timeout-minutes: 60 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@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - name: "Install PHP" - uses: "shivammathur/setup-php@v2" + uses: "shivammathur/setup-php@44454db4f0199b8b9685a5d763dc37cbf79108e1" # v2 with: coverage: "none" php-version: "8.2" ini-file: development - - uses: "ramsey/composer-install@v3" + - uses: "ramsey/composer-install@3cf229dc2919194e9e36783941438d17239e8520" # v3 - name: "Generate baseline" run: | diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index cfd78291360..56461bf177f 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -38,11 +38,16 @@ jobs: operating-system: [ ubuntu-latest, windows-latest ] steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2 + with: + egress-policy: audit + - name: "Checkout" - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - name: "Install PHP" - uses: "shivammathur/setup-php@v2" + uses: "shivammathur/setup-php@44454db4f0199b8b9685a5d763dc37cbf79108e1" # v2 with: coverage: "none" php-version: "${{ matrix.php-version }}" @@ -76,11 +81,16 @@ jobs: operating-system: [ ubuntu-latest, windows-latest ] steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2 + with: + egress-policy: audit + - name: "Checkout" - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - name: "Install PHP" - uses: "shivammathur/setup-php@v2" + uses: "shivammathur/setup-php@44454db4f0199b8b9685a5d763dc37cbf79108e1" # v2 with: coverage: "none" php-version: "${{ matrix.php-version }}" @@ -89,7 +99,7 @@ jobs: ini-file: development ini-values: memory_limit=-1 - - uses: "ramsey/composer-install@v3" + - uses: "ramsey/composer-install@3cf229dc2919194e9e36783941438d17239e8520" # v3 - name: "Check PHP configuration" run: "vendor/bin/phpunit --check-php-configuration" @@ -108,11 +118,16 @@ jobs: operating-system: [ ubuntu-latest, windows-latest ] steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2 + with: + egress-policy: audit + - name: "Checkout" - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - name: "Install PHP" - uses: "shivammathur/setup-php@v2" + uses: "shivammathur/setup-php@44454db4f0199b8b9685a5d763dc37cbf79108e1" # v2 with: coverage: "none" php-version: "8.2" @@ -121,7 +136,7 @@ jobs: ini-file: development ini-values: memory_limit=-1 - - uses: "ramsey/composer-install@v3" + - uses: "ramsey/composer-install@3cf229dc2919194e9e36783941438d17239e8520" # v3 - name: "Check PHP configuration" run: "vendor/bin/phpunit --check-php-configuration" @@ -135,11 +150,16 @@ jobs: timeout-minutes: 60 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@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - name: "Install PHP" - uses: "shivammathur/setup-php@v2" + uses: "shivammathur/setup-php@44454db4f0199b8b9685a5d763dc37cbf79108e1" # v2 with: coverage: "none" php-version: "8.3" @@ -148,7 +168,7 @@ jobs: ini-file: development ini-values: memory_limit=1G - - uses: "ramsey/composer-install@v3" + - uses: "ramsey/composer-install@3cf229dc2919194e9e36783941438d17239e8520" # v3 - id: set-matrix run: echo "matrix=$(php .github/workflows/tests-levels-matrix.php)" >> $GITHUB_OUTPUT @@ -169,11 +189,16 @@ jobs: script: "${{fromJson(needs.tests-levels-matrix.outputs.matrix)}}" 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@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - name: "Install PHP" - uses: "shivammathur/setup-php@v2" + uses: "shivammathur/setup-php@44454db4f0199b8b9685a5d763dc37cbf79108e1" # v2 with: coverage: "none" php-version: "8.3" @@ -182,7 +207,7 @@ jobs: ini-file: development ini-values: memory_limit=-1 - - uses: "ramsey/composer-install@v3" + - uses: "ramsey/composer-install@3cf229dc2919194e9e36783941438d17239e8520" # v3 - name: "Check PHP configuration" run: "vendor/bin/phpunit --check-php-configuration" @@ -208,11 +233,16 @@ jobs: operating-system: ubuntu-latest steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2 + with: + egress-policy: audit + - name: "Checkout" - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - name: "Install PHP" - uses: "shivammathur/setup-php@v2" + uses: "shivammathur/setup-php@44454db4f0199b8b9685a5d763dc37cbf79108e1" # v2 with: coverage: "none" php-version: "${{ matrix.php-version }}" @@ -225,7 +255,7 @@ jobs: shell: bash run: "composer require --dev phpunit/phpunit:^9.6 sebastian/diff:^4.0 doctrine/instantiator:^1.0 --update-with-dependencies --ignore-platform-reqs" - - uses: "ramsey/composer-install@v3" + - uses: "ramsey/composer-install@3cf229dc2919194e9e36783941438d17239e8520" # v3 - name: "Downgrade PHPUnit with Paratest" shell: bash @@ -253,11 +283,16 @@ jobs: operating-system: [ubuntu-latest] steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2 + with: + egress-policy: audit + - name: "Checkout" - uses: actions/checkout@v5 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 - name: "Checkout build-infection" - uses: actions/checkout@v5 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 with: repository: "phpstan/build-infection" path: "build-infection" @@ -268,9 +303,9 @@ jobs: php-version: "${{ matrix.php-version }}" php-extensions: ds,mbstring - - uses: "ramsey/composer-install@v3" + - uses: "ramsey/composer-install@3cf229dc2919194e9e36783941438d17239e8520" # v3 - - uses: "ramsey/composer-install@v3" + - uses: "ramsey/composer-install@3cf229dc2919194e9e36783941438d17239e8520" # v3 with: working-directory: "tests/" @@ -280,7 +315,7 @@ jobs: composer require --dev phpunit/phpunit:^12 brianium/paratest:^7.16 symfony/process:^7 symfony/string:^7 symfony/console:^7 --update-with-dependencies --ignore-platform-reqs --working-dir=tests composer require --dev phpunit/phpunit:^12 sebastian/diff --update-with-dependencies --ignore-platform-reqs - - uses: "ramsey/composer-install@v3" + - uses: "ramsey/composer-install@3cf229dc2919194e9e36783941438d17239e8520" # v3 with: working-directory: "build-infection/" @@ -298,7 +333,7 @@ jobs: echo "name=$(git remote show origin | sed -n '/HEAD branch/s/.*: //p')" >> $GITHUB_OUTPUT - name: "Restore result cache" - uses: actions/cache/restore@v4 + uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: ./tmp key: "result-cache-v1-${{ matrix.php-version }}-${{ github.run_id }}" @@ -329,7 +364,7 @@ jobs: --logger-text=php://stdout - name: "Save result cache" - uses: actions/cache/save@v4 + uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 if: ${{ !cancelled() }} with: path: ./tmp diff --git a/.github/workflows/update-phpstorm-stubs.yml b/.github/workflows/update-phpstorm-stubs.yml index ad4ae46d607..bbc3f68ee55 100644 --- a/.github/workflows/update-phpstorm-stubs.yml +++ b/.github/workflows/update-phpstorm-stubs.yml @@ -7,26 +7,34 @@ on: # * is a special character in YAML so you have to quote this string - cron: '0 0 * * 2' +permissions: + contents: read + jobs: update-phpstorm-stubs: name: "Update PhpStorm stubs" if: ${{ github.repository == 'phpstan/phpstan-src' }} runs-on: "ubuntu-latest" steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2 + with: + egress-policy: audit + - name: "Checkout" - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 with: ref: 2.1.x fetch-depth: '0' token: ${{ secrets.PHPSTAN_BOT_TOKEN }} - name: "Install PHP" - uses: "shivammathur/setup-php@v2" + uses: "shivammathur/setup-php@44454db4f0199b8b9685a5d763dc37cbf79108e1" # v2 with: coverage: "none" php-version: "8.2" - - uses: "ramsey/composer-install@v3" + - uses: "ramsey/composer-install@3cf229dc2919194e9e36783941438d17239e8520" # v3 - name: "Checkout stubs" - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 with: path: "phpstorm-stubs" repository: "jetbrains/phpstorm-stubs" @@ -38,7 +46,7 @@ jobs: run: "./bin/generate-function-metadata.php" - name: "Create Pull Request" id: create-pr - uses: peter-evans/create-pull-request@v6 + uses: peter-evans/create-pull-request@c5a7806660adbe173f04e3e038b0ccdcd758773c # v6.1.0 with: token: ${{ secrets.PHPSTAN_BOT_TOKEN }} branch-suffix: random From e7d321d2e6d8a397a4e6fbbfcfe66a9c0a46362a Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Tue, 24 Feb 2026 22:05:49 +0100 Subject: [PATCH 025/158] Update branch --- .github/workflows/lint-workflows.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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: {} From 0324196af94edb714b3cf4763828172d21fba41c Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Tue, 24 Feb 2026 22:10:07 +0100 Subject: [PATCH 026/158] Fix --- .github/workflows/claude-react-on-comment.yml | 3 --- 1 file changed, 3 deletions(-) diff --git a/.github/workflows/claude-react-on-comment.yml b/.github/workflows/claude-react-on-comment.yml index 7e9f11cd865..ec3276d5034 100644 --- a/.github/workflows/claude-react-on-comment.yml +++ b/.github/workflows/claude-react-on-comment.yml @@ -15,9 +15,6 @@ concurrency: permissions: contents: read -permissions: - contents: read - jobs: check-trigger: name: "Check trigger phrase" From df51ee055b9f05e31bd5b5b41cd4cfa4af6a8942 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Tue, 24 Feb 2026 22:12:31 +0100 Subject: [PATCH 027/158] Fix --- .github/workflows/spelling.yml | 3 --- 1 file changed, 3 deletions(-) diff --git a/.github/workflows/spelling.yml b/.github/workflows/spelling.yml index 33ed1d13006..442cd7347e5 100644 --- a/.github/workflows/spelling.yml +++ b/.github/workflows/spelling.yml @@ -11,9 +11,6 @@ on: permissions: contents: read -permissions: - contents: read - jobs: typos: name: "Check for typos" From b054374e70fce2f9895b1fbcfec69964c3402134 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Thu, 26 Feb 2026 13:20:28 +0100 Subject: [PATCH 028/158] Remove Claude workflow --- .../claude-random-easy-fixes-scheduled.yml | 26 ------------------- 1 file changed, 26 deletions(-) delete mode 100644 .github/workflows/claude-random-easy-fixes-scheduled.yml diff --git a/.github/workflows/claude-random-easy-fixes-scheduled.yml b/.github/workflows/claude-random-easy-fixes-scheduled.yml deleted file mode 100644 index f9a9e032eb7..00000000000 --- a/.github/workflows/claude-random-easy-fixes-scheduled.yml +++ /dev/null @@ -1,26 +0,0 @@ -name: "Claude Random Easy Fixes (Scheduled)" - -on: - schedule: - # Run 4 times, once an hour at :15, from 9pm CET (20:00 UTC) to 12am CET (23:00 UTC) - - cron: '15 20-23 * * *' - -permissions: - contents: read - -jobs: - trigger: - 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 Random Easy Fixes - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: gh workflow run claude-random-easy-fixes.yml -f issue_count=5 --repo ${{ github.repository }} From 688fbc0c64ec243a8b8c8d3903ea0834375b9c3d Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Wed, 11 Mar 2026 09:51:11 +0100 Subject: [PATCH 029/158] Update workflow --- .github/workflows/bench.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/bench.yml b/.github/workflows/bench.yml index 8f359728b50..50c25ac6e53 100644 --- a/.github/workflows/bench.yml +++ b/.github/workflows/bench.yml @@ -9,7 +9,7 @@ on: - 'issue-bot/**' push: branches: - - "2.1.x" + - "2.2.x" paths-ignore: - 'compiler/**' - 'apigen/**' From de7c6040f7da36fb35c58b927ae482f01a92455e Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Wed, 11 Mar 2026 12:40:40 +0100 Subject: [PATCH 030/158] Trigger Claude workflows in the fork --- ...pdate-config-parameters-docs-on-change.yml | 26 +++++++++++++++++++ ...aude-update-phpdoc-tags-docs-on-change.yml | 26 +++++++++++++++++++ ...ude-update-phpdoc-types-docs-on-change.yml | 26 +++++++++++++++++++ 3 files changed, 78 insertions(+) create mode 100644 .github/workflows/claude-update-config-parameters-docs-on-change.yml create mode 100644 .github/workflows/claude-update-phpdoc-tags-docs-on-change.yml create mode 100644 .github/workflows/claude-update-phpdoc-types-docs-on-change.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..3ecf3081468 --- /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.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 From 53c9da669b9cb966af1ad626c307f562af588465 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Wed, 11 Mar 2026 12:41:51 +0100 Subject: [PATCH 031/158] Fix --- .../claude-update-config-parameters-docs-on-change.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/claude-update-config-parameters-docs-on-change.yml b/.github/workflows/claude-update-config-parameters-docs-on-change.yml index 3ecf3081468..1d1ec046e39 100644 --- a/.github/workflows/claude-update-config-parameters-docs-on-change.yml +++ b/.github/workflows/claude-update-config-parameters-docs-on-change.yml @@ -5,7 +5,7 @@ on: branches: - "2.2.x" paths: - - '.github/workflows/claude-update-config-parameters-docs.yml' + - '.github/workflows/claude-update-config-parameters-docs-on-change.yml' - 'conf/parametersSchema.neon' jobs: From e2cfb7c18018d3e337f1666f40f07b8966a53b1c Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Wed, 11 Mar 2026 13:27:13 +0100 Subject: [PATCH 032/158] Claude - react on review workflow --- .github/workflows/claude-react-on-review.yml | 23 ++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 .github/workflows/claude-react-on-review.yml diff --git a/.github/workflows/claude-react-on-review.yml b/.github/workflows/claude-react-on-review.yml new file mode 100644 index 00000000000..199ac6e7e1e --- /dev/null +++ b/.github/workflows/claude-react-on-review.yml @@ -0,0 +1,23 @@ +name: "Claude React On Review" + +on: + pull_request_review: + types: [submitted] + +jobs: + claude: + runs-on: ubuntu-latest + permissions: + contents: read + actions: write + if: github.event.pull_request.user.login == 'phpstan-bot' + 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 Review PR + env: + GH_TOKEN: ${{ secrets.PHPSTAN_BOT_TOKEN }} + run: gh workflow run claude-pr-review.yml -f pr_number=${{ github.event.pull_request.number }} -f review_id=${{ github.event.review.id }} --repo phpstan-bot/phpstan-src From cf0db03aa1e9a483fe1a0c682cbcac1a0441f3bf Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Thu, 19 Mar 2026 11:24:32 +0100 Subject: [PATCH 033/158] Playground - LiteralArrayKeyCastRule --- issue-bot/playground.neon | 1 + .../Playground/LiteralArrayKeyCastRule.php | 60 +++++++++++++++++++ .../LiteralArrayKeyCastRuleTest.php | 49 +++++++++++++++ .../data/literal-array-key-cast.php | 25 ++++++++ 4 files changed, 135 insertions(+) create mode 100644 src/Rules/Playground/LiteralArrayKeyCastRule.php create mode 100644 tests/PHPStan/Rules/Playground/LiteralArrayKeyCastRuleTest.php create mode 100644 tests/PHPStan/Rules/Playground/data/literal-array-key-cast.php diff --git a/issue-bot/playground.neon b/issue-bot/playground.neon index a252e3bac87..70e71310d67 100644 --- a/issue-bot/playground.neon +++ b/issue-bot/playground.neon @@ -1,5 +1,6 @@ rules: - PHPStan\Rules\Playground\FunctionNeverRule + - PHPStan\Rules\Playground\LiteralArrayKeyCastRule - PHPStan\Rules\Playground\MethodNeverRule - PHPStan\Rules\Playground\NotAnalysedTraitRule - PHPStan\Rules\Playground\NoPhpCodeRule diff --git a/src/Rules/Playground/LiteralArrayKeyCastRule.php b/src/Rules/Playground/LiteralArrayKeyCastRule.php new file mode 100644 index 00000000000..47f036081bc --- /dev/null +++ b/src/Rules/Playground/LiteralArrayKeyCastRule.php @@ -0,0 +1,60 @@ + + */ +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') + ->line($item->getStartLine()) + ->build(); + } + } + + return $errors; + } + +} diff --git a/tests/PHPStan/Rules/Playground/LiteralArrayKeyCastRuleTest.php b/tests/PHPStan/Rules/Playground/LiteralArrayKeyCastRuleTest.php new file mode 100644 index 00000000000..d1562d5bdbb --- /dev/null +++ b/tests/PHPStan/Rules/Playground/LiteralArrayKeyCastRuleTest.php @@ -0,0 +1,49 @@ + + */ +final class LiteralArrayKeyCastRuleTest extends RuleTestCase +{ + + protected function getRule(): Rule + { + return new LiteralArrayKeyCastRule(); + } + + public function testRule(): void + { + $this->analyse([__DIR__ . '/data/literal-array-key-cast.php'], [ + [ + "Key '1' (string) will be cast to 1 (int) in the array.", + 14, + ], + [ + "Key null (null) will be cast to '' (string) in the array.", + 15, + ], + [ + 'Key 2.5 (float) will be cast to 2 (int) in the array.', + 16, + ], + [ + 'Key true (bool) will be cast to 1 (int) in the array.', + 18, + ], + [ + 'Key false (bool) will be cast to 0 (int) in the array.', + 19, + ], + [ + "Key '10' (string) will be cast to 10 (int) in the array.", + 21, + ], + ]); + } + +} diff --git a/tests/PHPStan/Rules/Playground/data/literal-array-key-cast.php b/tests/PHPStan/Rules/Playground/data/literal-array-key-cast.php new file mode 100644 index 00000000000..6ebcd206252 --- /dev/null +++ b/tests/PHPStan/Rules/Playground/data/literal-array-key-cast.php @@ -0,0 +1,25 @@ + 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 + ]; + } + +} From cc855b252189ba1dee6ddb2b487cf762fcc80e56 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Thu, 19 Mar 2026 12:01:09 +0100 Subject: [PATCH 034/158] Playground - ArrayDimCastRule --- issue-bot/playground.neon | 1 + src/Rules/Playground/ArrayDimCastRule.php | 62 +++++++++++++++++++ .../Rules/Playground/ArrayDimCastRuleTest.php | 53 ++++++++++++++++ .../Playground/data/array-dim-fetch-cast.php | 29 +++++++++ 4 files changed, 145 insertions(+) create mode 100644 src/Rules/Playground/ArrayDimCastRule.php create mode 100644 tests/PHPStan/Rules/Playground/ArrayDimCastRuleTest.php create mode 100644 tests/PHPStan/Rules/Playground/data/array-dim-fetch-cast.php diff --git a/issue-bot/playground.neon b/issue-bot/playground.neon index 70e71310d67..9fc2864ff3e 100644 --- a/issue-bot/playground.neon +++ b/issue-bot/playground.neon @@ -1,4 +1,5 @@ rules: + - PHPStan\Rules\Playground\ArrayDimCastRule - PHPStan\Rules\Playground\FunctionNeverRule - PHPStan\Rules\Playground\LiteralArrayKeyCastRule - PHPStan\Rules\Playground\MethodNeverRule diff --git a/src/Rules/Playground/ArrayDimCastRule.php b/src/Rules/Playground/ArrayDimCastRule.php new file mode 100644 index 00000000000..de4a6bf11cc --- /dev/null +++ b/src/Rules/Playground/ArrayDimCastRule.php @@ -0,0 +1,62 @@ + + */ +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') + ->build(); + } + + return $errors; + } + +} diff --git a/tests/PHPStan/Rules/Playground/ArrayDimCastRuleTest.php b/tests/PHPStan/Rules/Playground/ArrayDimCastRuleTest.php new file mode 100644 index 00000000000..789ae0f6a40 --- /dev/null +++ b/tests/PHPStan/Rules/Playground/ArrayDimCastRuleTest.php @@ -0,0 +1,53 @@ + + */ +final class ArrayDimCastRuleTest extends RuleTestCase +{ + + protected function getRule(): Rule + { + return new ArrayDimCastRule(); + } + + public function testRule(): void + { + $this->analyse([__DIR__ . '/data/array-dim-fetch-cast.php'], [ + [ + "Key '1' (string) will be cast to 1 (int) in the array access.", + 13, + ], + [ + "Key null (null) will be cast to '' (string) in the array access.", + 14, + ], + [ + 'Key 2.5 (float) will be cast to 2 (int) in the array access.', + 15, + ], + [ + 'Key true (bool) will be cast to 1 (int) in the array access.', + 17, + ], + [ + 'Key false (bool) will be cast to 0 (int) in the array access.', + 18, + ], + [ + "Key '10' (string) will be cast to 10 (int) in the array access.", + 20, + ], + [ + "Key '1' (string) will be cast to 1 (int) in the array access.", + 26, + ], + ]); + } + +} 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 @@ + Date: Thu, 19 Mar 2026 13:38:14 +0100 Subject: [PATCH 035/158] Tips linking to https://phpstan.org/blog/why-array-string-keys-are-not-type-safe --- src/Rules/Playground/ArrayDimCastRule.php | 1 + src/Rules/Playground/LiteralArrayKeyCastRule.php | 1 + tests/PHPStan/Rules/Playground/ArrayDimCastRuleTest.php | 8 ++++++++ .../Rules/Playground/LiteralArrayKeyCastRuleTest.php | 7 +++++++ 4 files changed, 17 insertions(+) diff --git a/src/Rules/Playground/ArrayDimCastRule.php b/src/Rules/Playground/ArrayDimCastRule.php index de4a6bf11cc..0f16a2d4616 100644 --- a/src/Rules/Playground/ArrayDimCastRule.php +++ b/src/Rules/Playground/ArrayDimCastRule.php @@ -53,6 +53,7 @@ public function processNode(Node $node, Scope $scope): array $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(); } diff --git a/src/Rules/Playground/LiteralArrayKeyCastRule.php b/src/Rules/Playground/LiteralArrayKeyCastRule.php index 47f036081bc..807754d35cd 100644 --- a/src/Rules/Playground/LiteralArrayKeyCastRule.php +++ b/src/Rules/Playground/LiteralArrayKeyCastRule.php @@ -49,6 +49,7 @@ public function processNode(Node $node, Scope $scope): array $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(); } diff --git a/tests/PHPStan/Rules/Playground/ArrayDimCastRuleTest.php b/tests/PHPStan/Rules/Playground/ArrayDimCastRuleTest.php index 789ae0f6a40..f0ca0570744 100644 --- a/tests/PHPStan/Rules/Playground/ArrayDimCastRuleTest.php +++ b/tests/PHPStan/Rules/Playground/ArrayDimCastRuleTest.php @@ -18,34 +18,42 @@ protected function getRule(): Rule 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 index d1562d5bdbb..898e12a7321 100644 --- a/tests/PHPStan/Rules/Playground/LiteralArrayKeyCastRuleTest.php +++ b/tests/PHPStan/Rules/Playground/LiteralArrayKeyCastRuleTest.php @@ -18,30 +18,37 @@ protected function getRule(): Rule 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, ], ]); } From c1a266ece0e9bc859e6a0b5a0c93b25b3338e22e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Mirtes?= Date: Fri, 20 Mar 2026 14:55:34 +0100 Subject: [PATCH 036/158] Check constants in parameters --- resources/constantToFunctionParameterMap.php | 2451 +++++++++++++++++ src/Reflection/AllowedConstantsResult.php | 55 + .../AnnotationsMethodParameterReflection.php | 12 + .../ExtendedParameterReflection.php | 7 + .../GenericParametersAcceptorResolver.php | 1 + .../ExtendedNativeParameterReflection.php | 17 + src/Reflection/ParameterAllowedConstants.php | 103 + .../ParameterAllowedConstantsMapProvider.php | 50 + src/Reflection/ParametersAcceptorSelector.php | 3 + .../Php/ClosureCallMethodReflection.php | 1 + src/Reflection/Php/ExitFunctionReflection.php | 1 + src/Reflection/Php/ExtendedDummyParameter.php | 17 + .../Php/PhpClassReflectionExtension.php | 7 +- src/Reflection/Php/PhpFunctionReflection.php | 3 + src/Reflection/Php/PhpMethodReflection.php | 5 + .../PhpParameterFromParserNodeReflection.php | 12 + src/Reflection/Php/PhpParameterReflection.php | 17 + .../ResolvedFunctionVariantWithOriginal.php | 1 + .../NativeFunctionReflectionProvider.php | 6 +- ...ackUnresolvedMethodPrototypeReflection.php | 1 + ...ypeUnresolvedMethodPrototypeReflection.php | 1 + .../WrappedExtendedMethodReflection.php | 1 + src/Rules/AttributesCheck.php | 3 + src/Rules/Classes/InstantiationRule.php | 3 + src/Rules/FunctionCallParametersCheck.php | 92 + src/Rules/Functions/CallCallablesRule.php | 3 + .../CallToFunctionParametersRule.php | 3 + src/Rules/Functions/CallUserFuncRule.php | 3 + src/Rules/Methods/CallMethodsRule.php | 3 + src/Rules/Methods/CallStaticMethodsRule.php | 3 + .../ConstantToFunctionParameterMapTest.php | 162 ++ .../ParameterAllowedConstantsTest.php | 285 ++ .../ParametersAcceptorSelectorTest.php | 2 + .../constantToFunctionParameterMap.neon | 2 + .../Rules/Classes/InstantiationRuleTest.php | 15 + ...constant-parameter-check-instantiation.php | 18 + .../Rules/Functions/CallCallablesRuleTest.php | 12 + .../CallToFunctionParametersRuleTest.php | 80 + .../Rules/Functions/CallUserFuncRuleTest.php | 11 + .../Rules/Functions/data/bug-12850.php | 18 + ...onstant-parameter-check-call-user-func.php | 9 + .../constant-parameter-check-callables.php | 10 + .../data/constant-parameter-check.php | 82 + .../Rules/Methods/CallMethodsRuleTest.php | 22 + .../Methods/CallStaticMethodsRuleTest.php | 16 + .../data/constant-parameter-check-methods.php | 25 + .../data/constant-parameter-check-static.php | 15 + 47 files changed, 3667 insertions(+), 2 deletions(-) create mode 100644 resources/constantToFunctionParameterMap.php create mode 100644 src/Reflection/AllowedConstantsResult.php create mode 100644 src/Reflection/ParameterAllowedConstants.php create mode 100644 src/Reflection/ParameterAllowedConstantsMapProvider.php create mode 100644 tests/PHPStan/Reflection/ConstantToFunctionParameterMapTest.php create mode 100644 tests/PHPStan/Reflection/ParameterAllowedConstantsTest.php create mode 100644 tests/PHPStan/Reflection/constantToFunctionParameterMap.neon create mode 100644 tests/PHPStan/Rules/Classes/data/constant-parameter-check-instantiation.php create mode 100644 tests/PHPStan/Rules/Functions/data/bug-12850.php create mode 100644 tests/PHPStan/Rules/Functions/data/constant-parameter-check-call-user-func.php create mode 100644 tests/PHPStan/Rules/Functions/data/constant-parameter-check-callables.php create mode 100644 tests/PHPStan/Rules/Functions/data/constant-parameter-check.php create mode 100644 tests/PHPStan/Rules/Methods/data/constant-parameter-check-methods.php create mode 100644 tests/PHPStan/Rules/Methods/data/constant-parameter-check-static.php diff --git a/resources/constantToFunctionParameterMap.php b/resources/constantToFunctionParameterMap.php new file mode 100644 index 00000000000..db150a3ad73 --- /dev/null +++ b/resources/constantToFunctionParameterMap.php @@ -0,0 +1,2451 @@ + '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', + ], + ], + ], + + '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', + ], + ], + ], + + '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'], + ], + ], + ], +]; 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/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 d9b75bf3e0e..9698b42ea8f 100644 --- a/src/Reflection/GenericParametersAcceptorResolver.php +++ b/src/Reflection/GenericParametersAcceptorResolver.php @@ -105,6 +105,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..2f844e28a82 --- /dev/null +++ b/src/Reflection/ParameterAllowedConstants.php @@ -0,0 +1,103 @@ + $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; + } + + private function resolveConstantName(ConstantReflection $constant): string + { + if ($constant instanceof ClassConstantReflection) { + return $constant->getDeclaringClass()->getName() . '::' . $constant->getName(); + } + + return $constant->getName(); + } + + /** + * @param list $constants + */ + public function check(array $constants): AllowedConstantsResult + { + $bitmaskNotAllowed = !$this->isBitmask() && count($constants) > 1; + + $disallowed = []; + $names = []; + + foreach ($constants as $constant) { + $name = $this->resolveConstantName($constant); + $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 b4c9b3a3821..608f3fb93d1 100644 --- a/src/Reflection/ParametersAcceptorSelector.php +++ b/src/Reflection/ParametersAcceptorSelector.php @@ -771,6 +771,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; } @@ -830,6 +831,7 @@ public static function combineAcceptors(array $acceptors): ExtendedParametersAcc $immediatelyInvokedCallable, $closureThisType, $attributes, + null, ); if ($isVariadic) { @@ -928,6 +930,7 @@ private static function wrapParameter(ParameterReflection $parameter): ExtendedP TrinaryLogic::createMaybe(), null, [], + null, ); } 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 c4ab5219df2..79c3f6034c8 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 e471943e87c..7dd05c682c6 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, ) { @@ -723,7 +725,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'); } } @@ -971,6 +973,8 @@ public function createUserlandMethodReflection(ClassReflection $fileDeclaringCla * @param array $closureThisParameters */ private function createNativeMethodVariant( + string $declaringClassName, + string $methodName, FunctionSignature $methodSignature, array $phpDocParameterTypes, ?Type $phpDocReturnType, @@ -1025,6 +1029,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/ResolvedFunctionVariantWithOriginal.php b/src/Reflection/ResolvedFunctionVariantWithOriginal.php index 21108d658ef..cd57c8db9d0 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/AttributesCheck.php b/src/Rules/AttributesCheck.php index 36b3c6faa61..3d8d8edf038 100644 --- a/src/Rules/AttributesCheck.php +++ b/src/Rules/AttributesCheck.php @@ -157,6 +157,9 @@ 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.', ); foreach ($parameterErrors as $error) { diff --git a/src/Rules/Classes/InstantiationRule.php b/src/Rules/Classes/InstantiationRule.php index e92e18a03d4..fc4d2e13fc5 100644 --- a/src/Rules/Classes/InstantiationRule.php +++ b/src/Rules/Classes/InstantiationRule.php @@ -269,6 +269,9 @@ 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.', )); } diff --git a/src/Rules/FunctionCallParametersCheck.php b/src/Rules/FunctionCallParametersCheck.php index 16c0938b069..a0dea2a1e0e 100644 --- a/src/Rules/FunctionCallParametersCheck.php +++ b/src/Rules/FunctionCallParametersCheck.php @@ -8,6 +8,7 @@ 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; @@ -36,6 +37,7 @@ use function in_array; use function is_int; use function is_string; +use function lcfirst; use function max; use function sprintf; @@ -87,6 +89,9 @@ public function check( string $unresolvableReturnTypeMessage, string $unresolvableParameterTypeMessage, string $namedArgumentMessage, + string $invalidConstantMessage, + string $exclusiveConstantsMessage, + string $bitmaskNotAllowedMessage, ): array { if ($funcCall instanceof Node\Expr\MethodCall || $funcCall instanceof Node\Expr\StaticCall || $funcCall instanceof Node\Expr\FuncCall) { @@ -407,6 +412,46 @@ public function check( ->line($argumentLine) ->build(); } + + if ( + $parameter instanceof ExtendedParameterReflection + && $parameter->getAllowedConstants() !== null + && $scope->getPhpVersion()->supportsNamedArguments()->yes() + ) { + $constantReflections = $this->resolveConstantReflections($argumentValue, $scope); + if ($constantReflections !== null) { + $result = $parameter->checkAllowedConstants($constantReflections); + foreach ($result->getDisallowedConstants() as $disallowedConstant) { + $errors[] = RuleErrorBuilder::message(sprintf( + $invalidConstantMessage, + $disallowedConstant->getName(), + 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(); + } + } + } } if ( @@ -702,6 +747,53 @@ 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) { + 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/CallCallablesRule.php b/src/Rules/Functions/CallCallablesRule.php index 4ab0af4b014..342498bacd6 100644 --- a/src/Rules/Functions/CallCallablesRule.php +++ b/src/Rules/Functions/CallCallablesRule.php @@ -139,6 +139,9 @@ 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 . '.', ), ); } diff --git a/src/Rules/Functions/CallToFunctionParametersRule.php b/src/Rules/Functions/CallToFunctionParametersRule.php index 39f6f7cfeac..515ea46fed8 100644 --- a/src/Rules/Functions/CallToFunctionParametersRule.php +++ b/src/Rules/Functions/CallToFunctionParametersRule.php @@ -68,6 +68,9 @@ 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 . '.', ); } diff --git a/src/Rules/Functions/CallUserFuncRule.php b/src/Rules/Functions/CallUserFuncRule.php index 3dae092b529..da834fa226e 100644 --- a/src/Rules/Functions/CallUserFuncRule.php +++ b/src/Rules/Functions/CallUserFuncRule.php @@ -84,6 +84,9 @@ 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 . '.', ); } diff --git a/src/Rules/Methods/CallMethodsRule.php b/src/Rules/Methods/CallMethodsRule.php index 1f042288f0e..e2d8f9e4a3d 100644 --- a/src/Rules/Methods/CallMethodsRule.php +++ b/src/Rules/Methods/CallMethodsRule.php @@ -99,6 +99,9 @@ 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 . '.', )); } diff --git a/src/Rules/Methods/CallStaticMethodsRule.php b/src/Rules/Methods/CallStaticMethodsRule.php index 166ad74aced..c19aaff2b72 100644 --- a/src/Rules/Methods/CallStaticMethodsRule.php +++ b/src/Rules/Methods/CallStaticMethodsRule.php @@ -108,6 +108,9 @@ 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 . '.', )); return $errors; 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/Classes/InstantiationRuleTest.php b/tests/PHPStan/Rules/Classes/InstantiationRuleTest.php index 8558296e149..413aba97670 100644 --- a/tests/PHPStan/Rules/Classes/InstantiationRuleTest.php +++ b/tests/PHPStan/Rules/Classes/InstantiationRuleTest.php @@ -615,4 +615,19 @@ public function testBug11006(): void $this->analyse([__DIR__ . '/data/bug-11006.php'], []); } + #[RequiresPhp('>= 8.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 GREGORIAN is not allowed for parameter #2 $dateType of class IntlDateFormatter constructor.', + 18, + ], + ]); + } + } 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 @@ += 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 testMaybeNotCallable(): void { $errors = []; diff --git a/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php b/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php index f78f5918f77..9a539b30a89 100644 --- a/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php +++ b/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php @@ -1586,9 +1586,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, @@ -1596,9 +1601,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, @@ -1614,6 +1624,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, @@ -2758,4 +2772,70 @@ 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, + ], + ]); + } + + #[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, + ], + ]); + } + } diff --git a/tests/PHPStan/Rules/Functions/CallUserFuncRuleTest.php b/tests/PHPStan/Rules/Functions/CallUserFuncRuleTest.php index dd4b3259a83..d695ffa2ad7 100644 --- a/tests/PHPStan/Rules/Functions/CallUserFuncRuleTest.php +++ b/tests/PHPStan/Rules/Functions/CallUserFuncRuleTest.php @@ -102,4 +102,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/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 @@ += 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 @@ += 8.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 ATTR_ERRMODE is not allowed for parameter #1 $mode of method PDOStatement::fetch().', + 17, + ], + [ + 'Constant FRENCH_COLLATION is not allowed for parameter #2 $flags of method Collator::sort().', + 25, + ], + ]); + } + public function testBug11463(): void { $this->checkThisOnly = false; diff --git a/tests/PHPStan/Rules/Methods/CallStaticMethodsRuleTest.php b/tests/PHPStan/Rules/Methods/CallStaticMethodsRuleTest.php index 82ee7e15669..9345fce6418 100644 --- a/tests/PHPStan/Rules/Methods/CallStaticMethodsRuleTest.php +++ b/tests/PHPStan/Rules/Methods/CallStaticMethodsRuleTest.php @@ -997,4 +997,20 @@ public function testPipeOperator(): void ]); } + #[RequiresPhp('>= 8.0')] + public function testConstantParameterCheckStatic(): void + { + $this->checkThisOnly = false; + $this->analyse([__DIR__ . '/data/constant-parameter-check-static.php'], [ + [ + 'Constant GREGORIAN is not allowed for parameter #2 $dateType of static method IntlDateFormatter::create().', + 9, + ], + [ + 'Constant TYPE_INT32 is not allowed for parameter #2 $style of static method NumberFormatter::create().', + 15, + ], + ]); + } + } 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 @@ + Date: Fri, 20 Mar 2026 16:49:19 +0100 Subject: [PATCH 037/158] Fix false positive abount allowed constants in parameters --- .../BetterReflection/BetterReflectionProvider.php | 1 + .../Constant/RuntimeConstantReflection.php | 11 +++++++++++ src/Reflection/ConstantReflection.php | 4 ++++ .../Dummy/DummyClassConstantReflection.php | 11 +++++++++++ src/Reflection/ParameterAllowedConstants.php | 15 +++++---------- .../RealClassClassConstantReflection.php | 11 +++++++++++ src/Rules/FunctionCallParametersCheck.php | 2 +- ...ittenDeclaringClassClassConstantReflection.php | 11 +++++++++++ .../Rules/Classes/InstantiationRuleTest.php | 2 +- .../Functions/data/constant-parameter-check.php | 15 +++++++++++++++ .../PHPStan/Rules/Methods/CallMethodsRuleTest.php | 4 ++-- .../Rules/Methods/CallStaticMethodsRuleTest.php | 4 ++-- 12 files changed, 75 insertions(+), 16 deletions(-) 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/ParameterAllowedConstants.php b/src/Reflection/ParameterAllowedConstants.php index 2f844e28a82..c784e3c29af 100644 --- a/src/Reflection/ParameterAllowedConstants.php +++ b/src/Reflection/ParameterAllowedConstants.php @@ -47,15 +47,6 @@ public function getExclusiveGroups(): array return $this->exclusiveGroups; } - private function resolveConstantName(ConstantReflection $constant): string - { - if ($constant instanceof ClassConstantReflection) { - return $constant->getDeclaringClass()->getName() . '::' . $constant->getName(); - } - - return $constant->getName(); - } - /** * @param list $constants */ @@ -67,7 +58,11 @@ public function check(array $constants): AllowedConstantsResult $names = []; foreach ($constants as $constant) { - $name = $this->resolveConstantName($constant); + if ($constant->isBuiltin()->no()) { + continue; + } + + $name = $constant->describe(); $names[] = $name; if (in_array($name, $this->constants, true)) { 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/Rules/FunctionCallParametersCheck.php b/src/Rules/FunctionCallParametersCheck.php index a0dea2a1e0e..10a5c6ab226 100644 --- a/src/Rules/FunctionCallParametersCheck.php +++ b/src/Rules/FunctionCallParametersCheck.php @@ -424,7 +424,7 @@ public function check( foreach ($result->getDisallowedConstants() as $disallowedConstant) { $errors[] = RuleErrorBuilder::message(sprintf( $invalidConstantMessage, - $disallowedConstant->getName(), + $disallowedConstant->describe(), lcfirst($this->describeParameter($parameter, $argumentName ?? $i + 1)), )) ->identifier('argument.invalidConstant') 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/tests/PHPStan/Rules/Classes/InstantiationRuleTest.php b/tests/PHPStan/Rules/Classes/InstantiationRuleTest.php index 413aba97670..fdeb2a27900 100644 --- a/tests/PHPStan/Rules/Classes/InstantiationRuleTest.php +++ b/tests/PHPStan/Rules/Classes/InstantiationRuleTest.php @@ -624,7 +624,7 @@ public function testConstantParameterCheckInstantiation(): void 12, ], [ - 'Constant GREGORIAN is not allowed for parameter #2 $dateType of class IntlDateFormatter constructor.', + 'Constant IntlDateFormatter::GREGORIAN is not allowed for parameter #2 $dateType of class IntlDateFormatter constructor.', 18, ], ]); diff --git a/tests/PHPStan/Rules/Functions/data/constant-parameter-check.php b/tests/PHPStan/Rules/Functions/data/constant-parameter-check.php index 066ddca480d..43e4b0b3dca 100644 --- a/tests/PHPStan/Rules/Functions/data/constant-parameter-check.php +++ b/tests/PHPStan/Rules/Functions/data/constant-parameter-check.php @@ -80,3 +80,18 @@ // round: single-value parameter - correct round(1.5, 0, PHP_ROUND_HALF_UP); + +class Foo +{ + private const PASSWORD_ALGORITHM = PASSWORD_ARGON2ID; + + // user-defined class constant wrapping a valid constant - should not report + public function hashPassword(string $password): string + { + return password_hash($password, self::PASSWORD_ALGORITHM); + } +} + +// user-defined global constant wrapping a valid constant - should not report +define('MY_JSON_FLAGS', JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); +json_encode([], MY_JSON_FLAGS); diff --git a/tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php b/tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php index 4e385c4612c..4ce54156970 100644 --- a/tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php +++ b/tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php @@ -3933,11 +3933,11 @@ public function testConstantParameterCheckMethods(): void 10, ], [ - 'Constant ATTR_ERRMODE is not allowed for parameter #1 $mode of method PDOStatement::fetch().', + 'Constant PDO::ATTR_ERRMODE is not allowed for parameter #1 $mode of method PDOStatement::fetch().', 17, ], [ - 'Constant FRENCH_COLLATION is not allowed for parameter #2 $flags of method Collator::sort().', + 'Constant Collator::FRENCH_COLLATION is not allowed for parameter #2 $flags of method Collator::sort().', 25, ], ]); diff --git a/tests/PHPStan/Rules/Methods/CallStaticMethodsRuleTest.php b/tests/PHPStan/Rules/Methods/CallStaticMethodsRuleTest.php index 9345fce6418..9f368c7507a 100644 --- a/tests/PHPStan/Rules/Methods/CallStaticMethodsRuleTest.php +++ b/tests/PHPStan/Rules/Methods/CallStaticMethodsRuleTest.php @@ -1003,11 +1003,11 @@ public function testConstantParameterCheckStatic(): void $this->checkThisOnly = false; $this->analyse([__DIR__ . '/data/constant-parameter-check-static.php'], [ [ - 'Constant GREGORIAN is not allowed for parameter #2 $dateType of static method IntlDateFormatter::create().', + 'Constant IntlDateFormatter::GREGORIAN is not allowed for parameter #2 $dateType of static method IntlDateFormatter::create().', 9, ], [ - 'Constant TYPE_INT32 is not allowed for parameter #2 $style of static method NumberFormatter::create().', + 'Constant NumberFormatter::TYPE_INT32 is not allowed for parameter #2 $style of static method NumberFormatter::create().', 15, ], ]); From 261cf495ff479369cb97db0dc519fd69256d42ee Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Fri, 20 Mar 2026 17:18:10 +0100 Subject: [PATCH 038/158] Allow custom rules to emit collector data for CollectedDataNode --- src/Analyser/CollectedDataEmitter.php | 42 +++++++++++++ src/Analyser/FileAnalyserCallback.php | 8 ++- src/Analyser/MutatingScope.php | 20 ++++++- src/Node/EmitCollectedDataNode.php | 60 +++++++++++++++++++ src/Rules/Methods/OverridingMethodRule.php | 5 +- src/Rules/Playground/PromoteParameterRule.php | 3 +- src/Rules/Rule.php | 3 +- src/Testing/CompositeRule.php | 3 +- src/Testing/DelayedRule.php | 3 +- .../Rules/CollectedDataEmitterRule.php | 33 ++++++++++ .../Rules/CollectedDataEmitterTest.php | 33 ++++++++++ 11 files changed, 205 insertions(+), 8 deletions(-) create mode 100644 src/Analyser/CollectedDataEmitter.php create mode 100644 src/Node/EmitCollectedDataNode.php create mode 100644 tests/PHPStan/Rules/CollectedDataEmitterRule.php create mode 100644 tests/PHPStan/Rules/CollectedDataEmitterTest.php 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/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/MutatingScope.php b/src/Analyser/MutatingScope.php index 42fe96957d5..8e598d6d7bc 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; @@ -133,7 +135,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'; @@ -4624,4 +4626,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/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/Rules/Methods/OverridingMethodRule.php b/src/Rules/Methods/OverridingMethodRule.php index f9023059fdc..40f458953b0 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; @@ -49,7 +50,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()); @@ -329,7 +330,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/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/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/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/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, + ], + ]); + } + +} From 4c1a2b9883287bfca99ebc32a8b3eefccae03b50 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Sun, 22 Mar 2026 09:32:21 +0100 Subject: [PATCH 039/158] Detect named arguments whose parameters are renamed in subtypes --- src/Analyser/MutatingScope.php | 8 +- src/Analyser/Scope.php | 4 + src/Rules/AttributesCheck.php | 5 +- src/Rules/Classes/ClassAttributesRule.php | 4 +- .../Classes/ClassConstantAttributesRule.php | 4 +- src/Rules/Classes/InstantiationRule.php | 7 +- .../Constants/ConstantAttributesRule.php | 4 +- .../EnumCases/EnumCaseAttributesRule.php | 4 +- src/Rules/FunctionCallParametersCheck.php | 13 +++- .../Functions/ArrowFunctionAttributesRule.php | 4 +- src/Rules/Functions/CallCallablesRule.php | 5 +- .../CallToFunctionParametersRule.php | 5 +- src/Rules/Functions/CallUserFuncRule.php | 5 +- src/Rules/Functions/ClosureAttributesRule.php | 4 +- .../Functions/FunctionAttributesRule.php | 4 +- src/Rules/Functions/ParamAttributesRule.php | 4 +- src/Rules/Methods/CallMethodsRule.php | 10 ++- src/Rules/Methods/CallStaticMethodsRule.php | 7 +- .../Methods/ConsistentConstructorRule.php | 6 +- src/Rules/Methods/MethodAttributesRule.php | 4 +- .../MethodParameterComparisonHelper.php | 22 +++++- ...dArgumentParameterMethodCallsCollector.php | 26 +++++++ ...rridingMethodRenamesParameterCollector.php | 26 +++++++ src/Rules/Methods/OverridingMethodRule.php | 2 +- .../Properties/PropertyAttributesRule.php | 4 +- .../Properties/PropertyHookAttributesRule.php | 4 +- src/Rules/Traits/TraitAttributesRule.php | 4 +- ...sRuleNamedArgumentRenamedParameterTest.php | 60 ++++++++++++++ ...llWithPossiblyRenamedNamedArgumentRule.php | 78 +++++++++++++++++++ .../data/named-argument-renamed-parameter.php | 26 +++++++ 30 files changed, 334 insertions(+), 29 deletions(-) create mode 100644 src/Rules/Methods/NamedArgumentParameterMethodCallsCollector.php create mode 100644 src/Rules/Methods/OverridingMethodRenamesParameterCollector.php create mode 100644 tests/PHPStan/Rules/Methods/CallMethodsRuleNamedArgumentRenamedParameterTest.php create mode 100644 tests/PHPStan/Rules/Methods/MethodCallWithPossiblyRenamedNamedArgumentRule.php create mode 100644 tests/PHPStan/Rules/Methods/data/named-argument-renamed-parameter.php diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index 8e598d6d7bc..5fe47361448 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -146,10 +146,10 @@ class MutatingScope implements Scope, NodeCallbackInvoker, CollectedDataEmitter /** @var Type[] */ private array $resolvedTypes = []; - /** @var array */ + /** @var array */ private array $truthyScopes = []; - /** @var array */ + /** @var array */ private array $falseyScopes = []; private ?self $fiberScope = null; @@ -3115,6 +3115,9 @@ public function filterByFalseyValue(Expr $expr): self return $scope; } + /** + * @return static + */ public function filterBySpecifiedTypes(SpecifiedTypes $specifiedTypes): self { $typeSpecifications = []; @@ -3224,6 +3227,7 @@ public function filterBySpecifiedTypes(SpecifiedTypes $specifiedTypes): self } } + /** @var static */ return $scope->scopeFactory->create( $scope->context, $scope->isDeclareStrictTypes(), 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/Rules/AttributesCheck.php b/src/Rules/AttributesCheck.php index 3d8d8edf038..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, @@ -160,6 +162,7 @@ public function check( '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/InstantiationRule.php b/src/Rules/Classes/InstantiationRule.php index fc4d2e13fc5..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 = []; @@ -272,6 +274,7 @@ private function checkClassName(string $class, bool $isName, Node $node, Scope $ '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/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/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 10a5c6ab226..66f7390a8d7 100644 --- a/src/Rules/FunctionCallParametersCheck.php +++ b/src/Rules/FunctionCallParametersCheck.php @@ -4,7 +4,9 @@ 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; @@ -14,6 +16,7 @@ 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; @@ -32,6 +35,7 @@ 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; @@ -65,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, @@ -92,6 +97,7 @@ public function check( 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) { @@ -359,6 +365,11 @@ public function check( ->build(); } } + } elseif ($argumentName !== null && $renamedNamedArgumentParameterData !== null) { + $scope->emitCollectedData(NamedArgumentParameterMethodCallsCollector::class, array_merge( + $renamedNamedArgumentParameterData, + [$parameter->getName(), $argumentLine], + )); } if ($this->checkArgumentTypes) { 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 342498bacd6..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) { @@ -142,6 +144,7 @@ public function processNode( '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 515ea46fed8..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 []; @@ -71,6 +73,7 @@ public function processNode(Node $node, Scope $scope): array '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 da834fa226e..0cb9d144b7d 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; @@ -32,7 +34,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 []; @@ -87,6 +89,7 @@ public function processNode(Node $node, Scope $scope): array '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/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 e2d8f9e4a3d..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) { @@ -102,6 +104,10 @@ private function processSingleMethodCall(Scope $scope, MethodCall $node, string '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 c19aaff2b72..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) { @@ -111,6 +113,7 @@ private function processSingleMethodCall(Scope $scope, StaticCall $node, string '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/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/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 40f458953b0..e1916a59f46 100644 --- a/src/Rules/Methods/OverridingMethodRule.php +++ b/src/Rules/Methods/OverridingMethodRule.php @@ -226,7 +226,7 @@ public function processNode(Node $node, Scope&NodeCallbackInvoker&CollectedDataE } } - $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); 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/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/tests/PHPStan/Rules/Methods/CallMethodsRuleNamedArgumentRenamedParameterTest.php b/tests/PHPStan/Rules/Methods/CallMethodsRuleNamedArgumentRenamedParameterTest.php new file mode 100644 index 00000000000..3a7773abbc9 --- /dev/null +++ b/tests/PHPStan/Rules/Methods/CallMethodsRuleNamedArgumentRenamedParameterTest.php @@ -0,0 +1,60 @@ + + */ +#[RequiresPhp('>= 8.0')] +class CallMethodsRuleNamedArgumentRenamedParameterTest 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), + 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, + ], + ]); + } + +} diff --git a/tests/PHPStan/Rules/Methods/MethodCallWithPossiblyRenamedNamedArgumentRule.php b/tests/PHPStan/Rules/Methods/MethodCallWithPossiblyRenamedNamedArgumentRule.php new file mode 100644 index 00000000000..55d7e9d8ad1 --- /dev/null +++ b/tests/PHPStan/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/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'); +}; From b16af79131c02329e75e15f23975ca0705243b5b Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Sun, 22 Mar 2026 12:35:48 +0100 Subject: [PATCH 040/158] Regression test Closes https://github.com/phpstan/phpstan/issues/7434 --- ...sRuleNamedArgumentRenamedParameterTest.php | 10 +++++++ tests/PHPStan/Rules/Methods/data/bug-7434.php | 29 +++++++++++++++++++ 2 files changed, 39 insertions(+) create mode 100644 tests/PHPStan/Rules/Methods/data/bug-7434.php diff --git a/tests/PHPStan/Rules/Methods/CallMethodsRuleNamedArgumentRenamedParameterTest.php b/tests/PHPStan/Rules/Methods/CallMethodsRuleNamedArgumentRenamedParameterTest.php index 3a7773abbc9..e2a51f4e2fd 100644 --- a/tests/PHPStan/Rules/Methods/CallMethodsRuleNamedArgumentRenamedParameterTest.php +++ b/tests/PHPStan/Rules/Methods/CallMethodsRuleNamedArgumentRenamedParameterTest.php @@ -57,4 +57,14 @@ public function testRule(): void ]); } + 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/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'); +} From 0fadf7539f875aceec934c1c948bc48aed0a2813 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Sun, 22 Mar 2026 12:46:14 +0100 Subject: [PATCH 041/158] Fix --- .../Methods/MethodCallWithPossiblyRenamedNamedArgumentRule.php | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename {tests/PHPStan => src}/Rules/Methods/MethodCallWithPossiblyRenamedNamedArgumentRule.php (100%) diff --git a/tests/PHPStan/Rules/Methods/MethodCallWithPossiblyRenamedNamedArgumentRule.php b/src/Rules/Methods/MethodCallWithPossiblyRenamedNamedArgumentRule.php similarity index 100% rename from tests/PHPStan/Rules/Methods/MethodCallWithPossiblyRenamedNamedArgumentRule.php rename to src/Rules/Methods/MethodCallWithPossiblyRenamedNamedArgumentRule.php From 4f8c37f9a6f516c74eb307d0776c64bb2f98a505 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Sun, 22 Mar 2026 14:15:17 +0100 Subject: [PATCH 042/158] Fix false negative - json_decode with JSON_THROW_ON_ERROR being passed to depth should report argument.invalidConstant --- src/Rules/FunctionCallParametersCheck.php | 88 ++++++++++++------- .../CallToFunctionParametersRuleTest.php | 4 + .../data/constant-parameter-check.php | 7 ++ 3 files changed, 69 insertions(+), 30 deletions(-) diff --git a/src/Rules/FunctionCallParametersCheck.php b/src/Rules/FunctionCallParametersCheck.php index 66f7390a8d7..b4c866e419c 100644 --- a/src/Rules/FunctionCallParametersCheck.php +++ b/src/Rules/FunctionCallParametersCheck.php @@ -108,7 +108,15 @@ public function check( $functionParametersMinCount = 0; $functionParametersMaxCount = 0; + $hasAllowedConstants = false; foreach ($parametersAcceptor->getParameters() as $parameter) { + if ( + $parameter instanceof ExtendedParameterReflection + && !$hasAllowedConstants + && $parameter->getAllowedConstants() !== null + ) { + $hasAllowedConstants = true; + } if (!$parameter->isOptional()) { $functionParametersMinCount++; } @@ -426,40 +434,55 @@ public function check( if ( $parameter instanceof ExtendedParameterReflection - && $parameter->getAllowedConstants() !== null && $scope->getPhpVersion()->supportsNamedArguments()->yes() ) { $constantReflections = $this->resolveConstantReflections($argumentValue, $scope); if ($constantReflections !== 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(); + 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 && $hasAllowedConstants) { + 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(); + } } } } @@ -764,6 +787,11 @@ private function describeParameter(ParameterReflection $parameter, int|string|nu 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; } diff --git a/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php b/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php index 9a539b30a89..20c6a565c72 100644 --- a/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php +++ b/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php @@ -2824,6 +2824,10 @@ public function testConstantParameterCheck(): void '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, + ], ]); } diff --git a/tests/PHPStan/Rules/Functions/data/constant-parameter-check.php b/tests/PHPStan/Rules/Functions/data/constant-parameter-check.php index 43e4b0b3dca..c7609983552 100644 --- a/tests/PHPStan/Rules/Functions/data/constant-parameter-check.php +++ b/tests/PHPStan/Rules/Functions/data/constant-parameter-check.php @@ -95,3 +95,10 @@ public function hashPassword(string $password): string // user-defined global constant wrapping a valid constant - should not report define('MY_JSON_FLAGS', JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); json_encode([], MY_JSON_FLAGS); + +json_decode('{}', null, JSON_THROW_ON_ERROR); + +// passing true/false/null should not report +json_decode($json, true); +json_decode($json, null); +json_decode($json, false); From 74913dadbf5f2cbef1033a0a168146f2f458d41e Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Sun, 22 Mar 2026 17:35:09 +0100 Subject: [PATCH 043/158] Update map --- resources/constantToFunctionParameterMap.php | 64 ++++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/resources/constantToFunctionParameterMap.php b/resources/constantToFunctionParameterMap.php index db150a3ad73..2712df60579 100644 --- a/resources/constantToFunctionParameterMap.php +++ b/resources/constantToFunctionParameterMap.php @@ -569,6 +569,38 @@ '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' => [ @@ -609,6 +641,38 @@ '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' => [ From 69e056c13fcd25f116d41f8f7cf84aa02f0ef3e3 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Sun, 22 Mar 2026 17:47:36 +0100 Subject: [PATCH 044/158] Improve map --- resources/constantToFunctionParameterMap.php | 31 +++++++++++++++++++ .../Rules/Classes/InstantiationRuleTest.php | 4 +++ 2 files changed, 35 insertions(+) diff --git a/resources/constantToFunctionParameterMap.php b/resources/constantToFunctionParameterMap.php index 2712df60579..6766e6be3f7 100644 --- a/resources/constantToFunctionParameterMap.php +++ b/resources/constantToFunctionParameterMap.php @@ -2512,4 +2512,35 @@ ], ], ], + + // 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/tests/PHPStan/Rules/Classes/InstantiationRuleTest.php b/tests/PHPStan/Rules/Classes/InstantiationRuleTest.php index fdeb2a27900..7517916e09d 100644 --- a/tests/PHPStan/Rules/Classes/InstantiationRuleTest.php +++ b/tests/PHPStan/Rules/Classes/InstantiationRuleTest.php @@ -456,6 +456,10 @@ public function testBug9946(): void public function testBug10324(): 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, From ea62732f7a885af6f4673b2cf1135defa22f6ad5 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Sun, 22 Mar 2026 17:52:11 +0100 Subject: [PATCH 045/158] Parameter type has to have the same type as parameters with described allowed constants to report passed constant as an error --- src/Rules/FunctionCallParametersCheck.php | 12 ++++++++---- .../Functions/data/constant-parameter-check.php | 3 +++ 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/src/Rules/FunctionCallParametersCheck.php b/src/Rules/FunctionCallParametersCheck.php index b4c866e419c..80c3f72c454 100644 --- a/src/Rules/FunctionCallParametersCheck.php +++ b/src/Rules/FunctionCallParametersCheck.php @@ -108,14 +108,13 @@ public function check( $functionParametersMinCount = 0; $functionParametersMaxCount = 0; - $hasAllowedConstants = false; + $allowedConstantsTypes = []; foreach ($parametersAcceptor->getParameters() as $parameter) { if ( $parameter instanceof ExtendedParameterReflection - && !$hasAllowedConstants && $parameter->getAllowedConstants() !== null ) { - $hasAllowedConstants = true; + $allowedConstantsTypes[] = $parameter->getType(); } if (!$parameter->isOptional()) { $functionParametersMinCount++; @@ -124,6 +123,11 @@ public function check( $functionParametersMaxCount++; } + $allowedConstantsType = null; + if (count($allowedConstantsTypes) > 0) { + $allowedConstantsType = TypeCombinator::union(...$allowedConstantsTypes); + } + if ($parametersAcceptor->isVariadic()) { $functionParametersMaxCount = -1; } @@ -469,7 +473,7 @@ public function check( ->line($argumentLine) ->build(); } - } elseif ($isBuiltin && $hasAllowedConstants) { + } elseif ($isBuiltin && $allowedConstantsType !== null && $allowedConstantsType->isSuperTypeOf($parameterType)->yes()) { foreach ($constantReflections as $constantReflection) { if ($constantReflection->isBuiltin()->no()) { continue; diff --git a/tests/PHPStan/Rules/Functions/data/constant-parameter-check.php b/tests/PHPStan/Rules/Functions/data/constant-parameter-check.php index c7609983552..70878d4eee7 100644 --- a/tests/PHPStan/Rules/Functions/data/constant-parameter-check.php +++ b/tests/PHPStan/Rules/Functions/data/constant-parameter-check.php @@ -102,3 +102,6 @@ public function hashPassword(string $password): string json_decode($json, true); json_decode($json, null); json_decode($json, false); + +// PHP_OS passed to $subject of preg_match - should not report +preg_match('/foo/', PHP_OS); From 9531b297801d2100af701991a6414c7313c7d5bc Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Sun, 22 Mar 2026 18:05:35 +0100 Subject: [PATCH 046/158] Fix test --- .../PHPStan/Rules/Classes/InstantiationRuleTest.php | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/tests/PHPStan/Rules/Classes/InstantiationRuleTest.php b/tests/PHPStan/Rules/Classes/InstantiationRuleTest.php index 7517916e09d..43e79cf13f6 100644 --- a/tests/PHPStan/Rules/Classes/InstantiationRuleTest.php +++ b/tests/PHPStan/Rules/Classes/InstantiationRuleTest.php @@ -453,7 +453,19 @@ 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'], [ + [ + 'Parameter #3 $flags of class RecursiveIteratorIterator constructor expects 0|16, 2 given.', + 23, + ], + ]); + } + + #[RequiresPhp('>= 8.0')] + public function testBug10324On80(): void { $this->analyse([__DIR__ . '/data/bug-10324.php'], [ [ From c8a72b2f62947b9df82ef34d2c3905ba35363d31 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Mon, 23 Mar 2026 09:32:15 +0100 Subject: [PATCH 047/158] Rename test --- ...p => MethodCallWithPossiblyRenamedNamedArgumentRuleTest.php} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename tests/PHPStan/Rules/Methods/{CallMethodsRuleNamedArgumentRenamedParameterTest.php => MethodCallWithPossiblyRenamedNamedArgumentRuleTest.php} (96%) diff --git a/tests/PHPStan/Rules/Methods/CallMethodsRuleNamedArgumentRenamedParameterTest.php b/tests/PHPStan/Rules/Methods/MethodCallWithPossiblyRenamedNamedArgumentRuleTest.php similarity index 96% rename from tests/PHPStan/Rules/Methods/CallMethodsRuleNamedArgumentRenamedParameterTest.php rename to tests/PHPStan/Rules/Methods/MethodCallWithPossiblyRenamedNamedArgumentRuleTest.php index e2a51f4e2fd..b5ecc3f37c4 100644 --- a/tests/PHPStan/Rules/Methods/CallMethodsRuleNamedArgumentRenamedParameterTest.php +++ b/tests/PHPStan/Rules/Methods/MethodCallWithPossiblyRenamedNamedArgumentRuleTest.php @@ -18,7 +18,7 @@ * @extends RuleTestCase */ #[RequiresPhp('>= 8.0')] -class CallMethodsRuleNamedArgumentRenamedParameterTest extends RuleTestCase +class MethodCallWithPossiblyRenamedNamedArgumentRuleTest extends RuleTestCase { protected function getRule(): Rule From 9c3b6d53e640363c198761cdfbf271c2d2c022f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Mirtes?= Date: Tue, 24 Mar 2026 11:24:26 +0100 Subject: [PATCH 048/158] New types: decimal-int-string and non-decimal-int-string --- phpstan-baseline.neon | 8 +- src/PhpDoc/TypeNodeResolver.php | 10 + src/Type/Accessory/AccessoryArrayListType.php | 5 + .../AccessoryDecimalIntegerStringType.php | 450 ++++++++++++++++++ .../Accessory/AccessoryLiteralStringType.php | 5 + .../AccessoryLowercaseStringType.php | 5 + .../Accessory/AccessoryNonEmptyStringType.php | 5 + .../Accessory/AccessoryNonFalsyStringType.php | 5 + .../Accessory/AccessoryNumericStringType.php | 5 + .../AccessoryUppercaseStringType.php | 5 + src/Type/Accessory/HasOffsetType.php | 5 + src/Type/Accessory/HasOffsetValueType.php | 5 + src/Type/Accessory/NonEmptyArrayType.php | 5 + src/Type/Accessory/OversizedArrayType.php | 5 + src/Type/CallableType.php | 5 + src/Type/ClassStringType.php | 5 + src/Type/ClosureType.php | 5 + src/Type/Constant/ConstantStringType.php | 5 + src/Type/FloatType.php | 5 + src/Type/IntersectionType.php | 12 + src/Type/IterableType.php | 5 + src/Type/JustNullableTypeTrait.php | 5 + src/Type/MixedType.php | 17 + src/Type/NeverType.php | 5 + src/Type/NullType.php | 5 + src/Type/ObjectType.php | 5 + src/Type/StaticType.php | 5 + src/Type/StrictMixedType.php | 5 + src/Type/StringType.php | 5 + src/Type/Traits/ArrayTypeTrait.php | 5 + src/Type/Traits/LateResolvableTypeTrait.php | 5 + src/Type/Traits/ObjectTypeTrait.php | 5 + src/Type/Type.php | 11 + src/Type/TypeCombinator.php | 32 ++ src/Type/UnionType.php | 5 + src/Type/VerbosityLevel.php | 2 + src/Type/VoidType.php | 5 + .../Analyser/nsrt/decimal-int-string.php | 48 ++ .../Type/Constant/ConstantStringTypeTest.php | 59 +++ tests/PHPStan/Type/IntersectionTypeTest.php | 25 + tests/PHPStan/Type/StringTypeTest.php | 78 ++- tests/PHPStan/Type/TypeCombinatorTest.php | 91 ++++ tests/PHPStan/Type/TypeToPhpDocNodeTest.php | 11 + 43 files changed, 997 insertions(+), 2 deletions(-) create mode 100644 src/Type/Accessory/AccessoryDecimalIntegerStringType.php create mode 100644 tests/PHPStan/Analyser/nsrt/decimal-int-string.php diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 6906e22da9d..1847333fb30 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -777,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 @@ -1716,7 +1722,7 @@ parameters: - rawMessage: Doing instanceof PHPStan\Type\IntersectionType is error-prone and deprecated. identifier: phpstanApi.instanceofType - count: 3 + count: 4 path: src/Type/TypeCombinator.php - diff --git a/src/PhpDoc/TypeNodeResolver.php b/src/PhpDoc/TypeNodeResolver.php index 8af74bf9264..815789377a7 100644 --- a/src/PhpDoc/TypeNodeResolver.php +++ b/src/PhpDoc/TypeNodeResolver.php @@ -47,6 +47,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; @@ -235,6 +236,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()]); diff --git a/src/Type/Accessory/AccessoryArrayListType.php b/src/Type/Accessory/AccessoryArrayListType.php index ff3536f4735..9e2bd72230b 100644 --- a/src/Type/Accessory/AccessoryArrayListType.php +++ b/src/Type/Accessory/AccessoryArrayListType.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/Accessory/AccessoryDecimalIntegerStringType.php b/src/Type/Accessory/AccessoryDecimalIntegerStringType.php new file mode 100644 index 00000000000..b04cbc13efe --- /dev/null +++ b/src/Type/Accessory/AccessoryDecimalIntegerStringType.php @@ -0,0 +1,450 @@ +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 && !$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 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 new StringType(); + } + + 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 TrinaryLogic::createMaybe(); + } + + public function isUppercaseString(): TrinaryLogic + { + return TrinaryLogic::createMaybe(); + } + + 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 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 da1abf5e370..f0e9b7c033b 100644 --- a/src/Type/Accessory/AccessoryLiteralStringType.php +++ b/src/Type/Accessory/AccessoryLiteralStringType.php @@ -290,6 +290,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 2e5ca831461..826b6f706b5 100644 --- a/src/Type/Accessory/AccessoryLowercaseStringType.php +++ b/src/Type/Accessory/AccessoryLowercaseStringType.php @@ -287,6 +287,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 2499084ba4c..1238abc7dcb 100644 --- a/src/Type/Accessory/AccessoryNonEmptyStringType.php +++ b/src/Type/Accessory/AccessoryNonEmptyStringType.php @@ -287,6 +287,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 9f2eebdbd2d..ffc8f2b9b4b 100644 --- a/src/Type/Accessory/AccessoryNonFalsyStringType.php +++ b/src/Type/Accessory/AccessoryNonFalsyStringType.php @@ -290,6 +290,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 88a811bc1be..62e320e5de5 100644 --- a/src/Type/Accessory/AccessoryNumericStringType.php +++ b/src/Type/Accessory/AccessoryNumericStringType.php @@ -287,6 +287,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 a85c74745be..683b6dec981 100644 --- a/src/Type/Accessory/AccessoryUppercaseStringType.php +++ b/src/Type/Accessory/AccessoryUppercaseStringType.php @@ -287,6 +287,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/HasOffsetType.php b/src/Type/Accessory/HasOffsetType.php index b6757fefb62..20256f2731f 100644 --- a/src/Type/Accessory/HasOffsetType.php +++ b/src/Type/Accessory/HasOffsetType.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/HasOffsetValueType.php b/src/Type/Accessory/HasOffsetValueType.php index 66872dc2f3e..0a16fb5801f 100644 --- a/src/Type/Accessory/HasOffsetValueType.php +++ b/src/Type/Accessory/HasOffsetValueType.php @@ -383,6 +383,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 688da67695a..fa3980c769b 100644 --- a/src/Type/Accessory/NonEmptyArrayType.php +++ b/src/Type/Accessory/NonEmptyArrayType.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/Accessory/OversizedArrayType.php b/src/Type/Accessory/OversizedArrayType.php index 4956e879926..847bddeccfc 100644 --- a/src/Type/Accessory/OversizedArrayType.php +++ b/src/Type/Accessory/OversizedArrayType.php @@ -349,6 +349,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/CallableType.php b/src/Type/CallableType.php index 380c87982a6..9c654578884 100644 --- a/src/Type/CallableType.php +++ b/src/Type/CallableType.php @@ -688,6 +688,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..2c22f319db0 100644 --- a/src/Type/ClassStringType.php +++ b/src/Type/ClassStringType.php @@ -49,6 +49,11 @@ public function isNumericString(): TrinaryLogic return TrinaryLogic::createMaybe(); } + public function isDecimalIntegerString(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + public function isNonEmptyString(): TrinaryLogic { return TrinaryLogic::createYes(); diff --git a/src/Type/ClosureType.php b/src/Type/ClosureType.php index 0a380dff93e..884256d1d29 100644 --- a/src/Type/ClosureType.php +++ b/src/Type/ClosureType.php @@ -790,6 +790,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/ConstantStringType.php b/src/Type/Constant/ConstantStringType.php index 40d0773c48d..a3b06036c7f 100644 --- a/src/Type/Constant/ConstantStringType.php +++ b/src/Type/Constant/ConstantStringType.php @@ -329,6 +329,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/FloatType.php b/src/Type/FloatType.php index 5df57fe7955..b792753083e 100644 --- a/src/Type/FloatType.php +++ b/src/Type/FloatType.php @@ -216,6 +216,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/IntersectionType.php b/src/Type/IntersectionType.php index 05b6a14753f..c11768bf4c7 100644 --- a/src/Type/IntersectionType.php +++ b/src/Type/IntersectionType.php @@ -24,6 +24,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; @@ -403,6 +404,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) @@ -802,6 +804,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()) { @@ -1295,6 +1302,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(), @@ -1478,6 +1489,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 2cf46b754e9..8911cbdf49f 100644 --- a/src/Type/IterableType.php +++ b/src/Type/IterableType.php @@ -367,6 +367,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 0c1892e01eb..ce5634548af 100644 --- a/src/Type/MixedType.php +++ b/src/Type/MixedType.php @@ -20,6 +20,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; @@ -938,6 +939,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 2da6f5e9fa2..40676536a7b 100644 --- a/src/Type/NeverType.php +++ b/src/Type/NeverType.php @@ -504,6 +504,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 5c7730ee9f7..42915cb2025 100644 --- a/src/Type/NullType.php +++ b/src/Type/NullType.php @@ -285,6 +285,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 f90a09d6908..9097a8943e5 100644 --- a/src/Type/ObjectType.php +++ b/src/Type/ObjectType.php @@ -1303,6 +1303,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/StaticType.php b/src/Type/StaticType.php index 682bc77d300..c9b2fb225b3 100644 --- a/src/Type/StaticType.php +++ b/src/Type/StaticType.php @@ -653,6 +653,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 af20367941f..f85dcba9c0e 100644 --- a/src/Type/StrictMixedType.php +++ b/src/Type/StrictMixedType.php @@ -286,6 +286,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 730869022fc..03361300331 100644 --- a/src/Type/StringType.php +++ b/src/Type/StringType.php @@ -232,6 +232,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 a019125c3f0..0a73a101626 100644 --- a/src/Type/Traits/ArrayTypeTrait.php +++ b/src/Type/Traits/ArrayTypeTrait.php @@ -140,6 +140,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 4b0dacddd72..5d171b751b4 100644 --- a/src/Type/Traits/LateResolvableTypeTrait.php +++ b/src/Type/Traits/LateResolvableTypeTrait.php @@ -494,6 +494,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/ObjectTypeTrait.php b/src/Type/Traits/ObjectTypeTrait.php index 51a4922f43f..3dc499e2f31 100644 --- a/src/Type/Traits/ObjectTypeTrait.php +++ b/src/Type/Traits/ObjectTypeTrait.php @@ -225,6 +225,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/Type.php b/src/Type/Type.php index 9af6fcf203c..0f759655af6 100644 --- a/src/Type/Type.php +++ b/src/Type/Type.php @@ -392,6 +392,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 e7a0d6bdf52..443e8f203b2 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; @@ -26,6 +27,7 @@ use PHPStan\Type\Generic\TemplateType; use PHPStan\Type\Generic\TemplateTypeFactory; use PHPStan\Type\Generic\TemplateUnionType; +use function array_filter; use function array_key_exists; use function array_key_first; use function array_merge; @@ -562,6 +564,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; } @@ -581,6 +601,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, diff --git a/src/Type/UnionType.php b/src/Type/UnionType.php index 1348e27dc5f..9a210eab14d 100644 --- a/src/Type/UnionType.php +++ b/src/Type/UnionType.php @@ -701,6 +701,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()); diff --git a/src/Type/VerbosityLevel.php b/src/Type/VerbosityLevel.php index 32be9683a81..73513641eef 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; @@ -156,6 +157,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 ca864245e67..f84c733abc5 100644 --- a/src/Type/VoidType.php +++ b/src/Type/VoidType.php @@ -189,6 +189,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/nsrt/decimal-int-string.php b/tests/PHPStan/Analyser/nsrt/decimal-int-string.php new file mode 100644 index 00000000000..63747525028 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/decimal-int-string.php @@ -0,0 +1,48 @@ + 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); + } + + /** + * @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/Type/Constant/ConstantStringTypeTest.php b/tests/PHPStan/Type/Constant/ConstantStringTypeTest.php index d07d72d48af..801b2d1772e 100644 --- a/tests/PHPStan/Type/Constant/ConstantStringTypeTest.php +++ b/tests/PHPStan/Type/Constant/ConstantStringTypeTest.php @@ -185,4 +185,63 @@ 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()); + } + } diff --git a/tests/PHPStan/Type/IntersectionTypeTest.php b/tests/PHPStan/Type/IntersectionTypeTest.php index e3ed23eb46b..a10aa80ee49 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; @@ -747,6 +748,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 6c51dbcd9b1..9ea18aac9b5 100644 --- a/tests/PHPStan/Type/TypeCombinatorTest.php +++ b/tests/PHPStan/Type/TypeCombinatorTest.php @@ -20,6 +20,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; @@ -2821,6 +2822,58 @@ 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', + ]; } /** @@ -4933,6 +4986,44 @@ public static function dataIntersect(): iterable TemplateIntersectionType::class, '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', + ]; } /** diff --git a/tests/PHPStan/Type/TypeToPhpDocNodeTest.php b/tests/PHPStan/Type/TypeToPhpDocNodeTest.php index 3cf9e5f3fa6..fcc6c6d0cf9 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')] From c4d39c9d36e29ad518ab45550b99ba13a4b17562 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Wed, 25 Mar 2026 10:44:24 +0100 Subject: [PATCH 049/158] issue-bot - support more options --- issue-bot/src/Console/RunCommand.php | 34 +++++++++++++++---- issue-bot/src/Playground/PlaygroundClient.php | 7 ++++ issue-bot/src/Playground/PlaygroundResult.php | 10 ++++++ 3 files changed, 44 insertions(+), 7 deletions(-) diff --git a/issue-bot/src/Console/RunCommand.php b/issue-bot/src/Console/RunCommand.php index fb8ad112c02..2fe5a216d74 100644 --- a/issue-bot/src/Console/RunCommand.php +++ b/issue-bot/src/Console/RunCommand.php @@ -181,15 +181,35 @@ private function analyseHash(LoopInterface $loop, OutputInterface $output, int $ } $tmpDir = sys_get_temp_dir() . '/phpstan-issue-bot-' . $result->getHash(); @mkdir($tmpDir, 0777, true); + + $options = $result->getOptions(); + $parameters = [ + 'level' => $result->getLevel(), + 'inferPrivatePropertyTypeFromConstructor' => $options['inferPrivatePropertyTypeFromConstructor'] ?? true, + 'treatPhpDocTypesAsCertain' => $result->isTreatPhpDocTypesAsCertain(), + 'phpVersion' => $phpVersion, + 'tmpDir' => $tmpDir, + 'rememberPossiblyImpureFunctionValues' => $options['rememberPossiblyImpureFunctionValues'] ?? true, + 'checkBenevolentUnionTypes' => $options['checkBenevolentUnionTypes'] ?? false, + 'checkTooWideReturnTypesInProtectedAndPublicMethods' => $options['checkTooWideTypesInProtectedAndPublicMethods'] ?? false, + 'checkTooWideParameterOutInProtectedAndPublicMethods' => $options['checkTooWideTypesInProtectedAndPublicMethods'] ?? false, + 'checkTooWideThrowTypesInProtectedAndPublicMethods' => $options['checkTooWideTypesInProtectedAndPublicMethods'] ?? false, + ]; + $parameters['exceptions'] = [ + 'implicitThrows' => $options['implicitThrows'] ?? true, + 'reportUncheckedExceptionDeadCatch' => $options['reportUncheckedExceptionDeadCatch'] ?? false, + 'uncheckedExceptionClasses' => $options['uncheckedExceptionClasses'] ?? [], + 'checkedExceptionClasses' => $options['checkedExceptionClasses'] ?? [], + 'check' => [ + 'missingCheckedExceptionInThrows' => $options['missingCheckedExceptionInThrows'] ?? false, + 'tooWideThrowType' => $options['tooWideThrowType'] ?? false, + 'tooWideImplicitThrowType' => $options['tooWideImplicitThrowType'] ?? false, + ], + ]; + $neon = Neon::encode([ 'includes' => $configFiles, - 'parameters' => [ - 'level' => $result->getLevel(), - 'inferPrivatePropertyTypeFromConstructor' => true, - 'treatPhpDocTypesAsCertain' => $result->isTreatPhpDocTypesAsCertain(), - 'phpVersion' => $phpVersion, - 'tmpDir' => $tmpDir, - ], + 'parameters' => $parameters, ]); $hash = $result->getHash(); diff --git a/issue-bot/src/Playground/PlaygroundClient.php b/issue-bot/src/Playground/PlaygroundClient.php index 43cd6ea9d37..6c09c8b1561 100644 --- a/issue-bot/src/Playground/PlaygroundClient.php +++ b/issue-bot/src/Playground/PlaygroundClient.php @@ -6,6 +6,7 @@ use Nette\Utils\Json; use function array_map; use function array_values; +use function is_array; use function sprintf; class PlaygroundClient @@ -27,6 +28,11 @@ public function getResult(string $hash): PlaygroundResult $versionedErrors[(int) $phpVersion] = array_map(static fn (array $error) => new PlaygroundError($error['line'] ?? -1, $error['message'], $error['identifier'] ?? null), array_values($errors)); } + $options = []; + if (isset($json['config']['options']) && is_array($json['config']['options'])) { + $options = $json['config']['options']; + } + return new PlaygroundResult( sprintf('https://phpstan.org/r/%s', $hash), $hash, @@ -35,6 +41,7 @@ public function getResult(string $hash): PlaygroundResult $json['config']['strictRules'], $json['config']['bleedingEdge'], $json['config']['treatPhpDocTypesAsCertain'], + $options, $versionedErrors, ); } diff --git a/issue-bot/src/Playground/PlaygroundResult.php b/issue-bot/src/Playground/PlaygroundResult.php index faddc6c078a..6a3b8cef092 100644 --- a/issue-bot/src/Playground/PlaygroundResult.php +++ b/issue-bot/src/Playground/PlaygroundResult.php @@ -6,6 +6,7 @@ class PlaygroundResult { /** + * @param array $options * @param array> $versionedErrors */ public function __construct( @@ -16,6 +17,7 @@ public function __construct( private bool $strictRules, private bool $bleedingEdge, private bool $treatPhpDocTypesAsCertain, + private array $options, private array $versionedErrors, ) { @@ -56,6 +58,14 @@ public function isTreatPhpDocTypesAsCertain(): bool return $this->treatPhpDocTypesAsCertain; } + /** + * @return array + */ + public function getOptions(): array + { + return $this->options; + } + /** * @return array> */ From c3d31d9dca6c042b2e8432caa5d8fc030a369f53 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Wed, 25 Mar 2026 10:56:31 +0100 Subject: [PATCH 050/158] Fix issue-bot --- .github/workflows/issue-bot.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/issue-bot.yml b/.github/workflows/issue-bot.yml index b0886ca8076..6c5a0518028 100644 --- a/.github/workflows/issue-bot.yml +++ b/.github/workflows/issue-bot.yml @@ -54,9 +54,9 @@ jobs: uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: ./issue-bot/tmp - key: "issue-bot-download-v7-${{ github.run_id }}" + key: "issue-bot-download-v8-${{ github.run_id }}" restore-keys: | - issue-bot-download-v7- + issue-bot-download-v8- - name: "Download data" working-directory: "issue-bot" From 20830ac5a254601c1492f649bbf1c1cee9e91155 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Wed, 25 Mar 2026 18:10:03 +0100 Subject: [PATCH 051/158] tooWideThrowType is true by default --- issue-bot/src/Console/RunCommand.php | 1 - 1 file changed, 1 deletion(-) diff --git a/issue-bot/src/Console/RunCommand.php b/issue-bot/src/Console/RunCommand.php index 2fe5a216d74..8c7f0a4300e 100644 --- a/issue-bot/src/Console/RunCommand.php +++ b/issue-bot/src/Console/RunCommand.php @@ -202,7 +202,6 @@ private function analyseHash(LoopInterface $loop, OutputInterface $output, int $ 'checkedExceptionClasses' => $options['checkedExceptionClasses'] ?? [], 'check' => [ 'missingCheckedExceptionInThrows' => $options['missingCheckedExceptionInThrows'] ?? false, - 'tooWideThrowType' => $options['tooWideThrowType'] ?? false, 'tooWideImplicitThrowType' => $options['tooWideImplicitThrowType'] ?? false, ], ]; From d4015ebbd114afefed634b7bfd017a525157cfdd Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Fri, 27 Mar 2026 12:27:41 +0100 Subject: [PATCH 052/158] Plumbing for reducing false positives about constant conditions in traits --- src/Analyser/RuleErrorTransformer.php | 5 + .../ConstantConditionInTraitCollector.php | 28 ++++++ .../ConstantConditionInTraitHelper.php | 81 ++++++++++++++++ .../ConstantConditionInTraitRule.php | 95 +++++++++++++++++++ src/Rules/RuleErrors/TransformedRuleError.php | 39 ++++++++ 5 files changed, 248 insertions(+) create mode 100644 src/Rules/Comparison/ConstantConditionInTraitCollector.php create mode 100644 src/Rules/Comparison/ConstantConditionInTraitHelper.php create mode 100644 src/Rules/Comparison/ConstantConditionInTraitRule.php create mode 100644 src/Rules/RuleErrors/TransformedRuleError.php 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/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..83ca84f220e --- /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]); + continue; + } + + // report each error in its context + foreach ($uniquedErrors as $uniquedError) { + $transformedErrors[] = new TransformedRuleError($uniquedError); + } + } + } + } + + return $transformedErrors; + } + +} 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(); + } + +} From 7e4ade3e76a48ae6060b33f2b788432e225f8982 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Fri, 27 Mar 2026 12:28:11 +0100 Subject: [PATCH 053/158] Use ConstantConditionInTraitHelper in StrictComparisonOfDifferentTypesRule --- build/baseline-8.0.neon | 12 ---- .../StrictComparisonOfDifferentTypesRule.php | 39 +++++++++---- ...rictComparisonOfDifferentTypesRuleTest.php | 32 +++++++--- .../data/strict-comparison-in-trait.php | 58 +++++++++++++++++++ 4 files changed, 109 insertions(+), 32 deletions(-) create mode 100644 tests/PHPStan/Rules/Comparison/data/strict-comparison-in-trait.php 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/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/tests/PHPStan/Rules/Comparison/StrictComparisonOfDifferentTypesRuleTest.php b/tests/PHPStan/Rules/Comparison/StrictComparisonOfDifferentTypesRuleTest.php index d5e7f59fd81..a62b33caa59 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 { @@ -22,13 +23,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 @@ -1156,4 +1162,14 @@ 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, + ], + ]); + } + } 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 + { + + } + +} From 6b3f2d1097c3be2e39804ae8dcf17631f50aa49b Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Fri, 27 Mar 2026 13:46:13 +0100 Subject: [PATCH 054/158] Added ConstantConditionInTraitHelper to remaining rules --- .../BooleanAndConstantConditionRule.php | 44 ++++++++++-- .../BooleanNotConstantConditionRule.php | 16 +++-- .../BooleanOrConstantConditionRule.php | 44 ++++++++++-- .../ConstantLooseComparisonRule.php | 35 +++++++--- .../DoWhileLoopConstantConditionRule.php | 32 ++++++--- .../ElseIfConstantConditionRule.php | 14 +++- .../Comparison/IfConstantConditionRule.php | 26 ++++--- .../ImpossibleCheckTypeFunctionCallRule.php | 33 ++++++--- .../ImpossibleCheckTypeMethodCallRule.php | 35 +++++++--- ...mpossibleCheckTypeStaticMethodCallRule.php | 35 +++++++--- .../LogicalXorConstantConditionRule.php | 28 +++++++- src/Rules/Comparison/MatchExpressionRule.php | 23 +++++- ...mparisonOperatorsConstantConditionRule.php | 28 +++++--- .../TernaryOperatorConstantConditionRule.php | 22 ++++-- .../WhileLoopAlwaysFalseConditionRule.php | 20 ++++-- .../WhileLoopAlwaysTrueConditionRule.php | 22 ++++-- .../BooleanAndConstantConditionRuleTest.php | 42 +++++++---- .../BooleanNotConstantConditionRuleTest.php | 41 +++++++---- .../BooleanOrConstantConditionRuleTest.php | 42 +++++++---- .../ConstantLooseComparisonRuleTest.php | 31 ++++++-- .../DoWhileLoopConstantConditionRuleTest.php | 38 +++++++--- .../ElseIfConstantConditionRuleTest.php | 41 +++++++---- .../IfConstantConditionRuleTest.php | 39 ++++++++--- ...mpossibleCheckTypeFunctionCallRuleTest.php | 39 ++++++++--- ...sibleCheckTypeGenericOverwriteRuleTest.php | 1 + ...sibleCheckTypeMethodCallRuleEqualsTest.php | 1 + .../ImpossibleCheckTypeMethodCallRuleTest.php | 39 ++++++++--- ...sibleCheckTypeStaticMethodCallRuleTest.php | 39 ++++++++--- .../LogicalXorConstantConditionRuleTest.php | 40 +++++++---- .../Comparison/MatchExpressionRuleTest.php | 38 +++++++--- ...isonOperatorsConstantConditionRuleTest.php | 29 ++++++-- ...rnaryOperatorConstantConditionRuleTest.php | 39 ++++++++--- .../WhileLoopAlwaysFalseConditionRuleTest.php | 38 +++++++--- .../WhileLoopAlwaysTrueConditionRuleTest.php | 38 +++++++--- .../Comparison/data/boolean-and-in-trait.php | 58 +++++++++++++++ .../Comparison/data/boolean-not-in-trait.php | 58 +++++++++++++++ .../Comparison/data/boolean-or-in-trait.php | 58 +++++++++++++++ .../Comparison/data/do-while-in-trait.php | 56 +++++++++++++++ .../data/elseif-condition-in-trait.php | 62 ++++++++++++++++ .../Comparison/data/if-condition-in-trait.php | 58 +++++++++++++++ .../impossible-function-call-in-trait.php | 59 ++++++++++++++++ .../data/impossible-method-call-in-trait.php | 70 +++++++++++++++++++ ...impossible-static-method-call-in-trait.php | 68 ++++++++++++++++++ .../Comparison/data/logical-xor-in-trait.php | 58 +++++++++++++++ .../data/loose-comparison-in-trait.php | 61 ++++++++++++++++ .../Rules/Comparison/data/match-in-trait.php | 60 ++++++++++++++++ .../data/number-comparison-in-trait.php | 61 ++++++++++++++++ .../Comparison/data/ternary-in-trait.php | 54 ++++++++++++++ .../Comparison/data/while-false-in-trait.php | 58 +++++++++++++++ .../Comparison/data/while-true-in-trait.php | 58 +++++++++++++++ 50 files changed, 1749 insertions(+), 280 deletions(-) create mode 100644 tests/PHPStan/Rules/Comparison/data/boolean-and-in-trait.php create mode 100644 tests/PHPStan/Rules/Comparison/data/boolean-not-in-trait.php create mode 100644 tests/PHPStan/Rules/Comparison/data/boolean-or-in-trait.php create mode 100644 tests/PHPStan/Rules/Comparison/data/do-while-in-trait.php create mode 100644 tests/PHPStan/Rules/Comparison/data/elseif-condition-in-trait.php create mode 100644 tests/PHPStan/Rules/Comparison/data/if-condition-in-trait.php create mode 100644 tests/PHPStan/Rules/Comparison/data/impossible-function-call-in-trait.php create mode 100644 tests/PHPStan/Rules/Comparison/data/impossible-method-call-in-trait.php create mode 100644 tests/PHPStan/Rules/Comparison/data/impossible-static-method-call-in-trait.php create mode 100644 tests/PHPStan/Rules/Comparison/data/logical-xor-in-trait.php create mode 100644 tests/PHPStan/Rules/Comparison/data/loose-comparison-in-trait.php create mode 100644 tests/PHPStan/Rules/Comparison/data/match-in-trait.php create mode 100644 tests/PHPStan/Rules/Comparison/data/number-comparison-in-trait.php create mode 100644 tests/PHPStan/Rules/Comparison/data/ternary-in-trait.php create mode 100644 tests/PHPStan/Rules/Comparison/data/while-false-in-trait.php create mode 100644 tests/PHPStan/Rules/Comparison/data/while-true-in-trait.php 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/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/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/tests/PHPStan/Rules/Comparison/BooleanAndConstantConditionRuleTest.php b/tests/PHPStan/Rules/Comparison/BooleanAndConstantConditionRuleTest.php index b332d01adb8..f235ec0c7e4 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,21 +19,26 @@ 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 @@ -446,4 +452,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 3cf3f2a8bbc..404b6ebe989 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,21 +19,26 @@ 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 @@ -236,4 +242,15 @@ public function testBug6702(): void $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, + ], + ]); + } + } diff --git a/tests/PHPStan/Rules/Comparison/BooleanOrConstantConditionRuleTest.php b/tests/PHPStan/Rules/Comparison/BooleanOrConstantConditionRuleTest.php index cc628575f79..241e9be4540 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,21 +20,26 @@ 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 @@ -384,4 +390,16 @@ 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, + ], + ]); + } + } diff --git a/tests/PHPStan/Rules/Comparison/ConstantLooseComparisonRuleTest.php b/tests/PHPStan/Rules/Comparison/ConstantLooseComparisonRuleTest.php index bb6aa370ac4..9d69da03b4c 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,4 +254,15 @@ 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%.', + ], + ]); + } + } diff --git a/tests/PHPStan/Rules/Comparison/DoWhileLoopConstantConditionRuleTest.php b/tests/PHPStan/Rules/Comparison/DoWhileLoopConstantConditionRuleTest.php index 97b3d705baf..f0045408012 100644 --- a/tests/PHPStan/Rules/Comparison/DoWhileLoopConstantConditionRuleTest.php +++ b/tests/PHPStan/Rules/Comparison/DoWhileLoopConstantConditionRuleTest.php @@ -3,30 +3,36 @@ namespace PHPStan\Rules\Comparison; use PHPStan\Rules\Rule; +use PHPStan\Testing\CompositeRule; use PHPStan\Testing\RuleTestCase; /** - * @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 @@ -76,4 +82,14 @@ public function testRule(): void ]); } + 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 594de979a59..342be2c84b1 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,21 +20,26 @@ 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 @@ -152,4 +158,15 @@ public function testBug6947(): void ]); } + 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 79b7b484e13..dd7ddf9df6a 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,20 +17,25 @@ 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 @@ -213,4 +219,15 @@ 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, + ], + ]); + } + } diff --git a/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeFunctionCallRuleTest.php b/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeFunctionCallRuleTest.php index 69e992139d8..80a3c72c215 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; @@ -13,7 +14,7 @@ use function count; /** - * @extends RuleTestCase + * @extends RuleTestCase */ class ImpossibleCheckTypeFunctionCallRuleTest extends RuleTestCase { @@ -24,18 +25,23 @@ class ImpossibleCheckTypeFunctionCallRuleTest extends RuleTestCase protected function getRule(): Rule { - return new ImpossibleCheckTypeFunctionCallRule( - new ImpossibleCheckTypeHelper( - self::createReflectionProvider(), - $this->getTypeSpecifier(), - [stdClass::class], + // @phpstan-ignore argument.type + return new CompositeRule([ + new ImpossibleCheckTypeFunctionCallRule( + new ImpossibleCheckTypeHelper( + self::createReflectionProvider(), + $this->getTypeSpecifier(), + [stdClass::class], + $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 @@ -1207,4 +1213,15 @@ 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, + ], + ]); + } + } diff --git a/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeGenericOverwriteRuleTest.php b/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeGenericOverwriteRuleTest.php index 0190415a989..3871e50298b 100644 --- a/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeGenericOverwriteRuleTest.php +++ b/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeGenericOverwriteRuleTest.php @@ -21,6 +21,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 178b4148958..8066975bf92 100644 --- a/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeMethodCallRuleEqualsTest.php +++ b/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeMethodCallRuleEqualsTest.php @@ -21,6 +21,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 382dbc2f28a..4e9a371d730 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,18 +20,23 @@ 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 @@ -303,6 +309,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 f53b87d0077..075b14d4ae6 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,18 +20,23 @@ 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 @@ -168,6 +174,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 14b97daacaa..bea49f94bc1 100644 --- a/tests/PHPStan/Rules/Comparison/LogicalXorConstantConditionRuleTest.php +++ b/tests/PHPStan/Rules/Comparison/LogicalXorConstantConditionRuleTest.php @@ -3,31 +3,37 @@ namespace PHPStan\Rules\Comparison; use PHPStan\Rules\Rule as TRule; +use PHPStan\Testing\CompositeRule; use PHPStan\Testing\RuleTestCase; /** - * @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 @@ -71,4 +77,14 @@ public function testRule(): void ]); } + 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 09330a874be..99f7e166414 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,19 +17,24 @@ 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 @@ -478,4 +484,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 5400e3edee6..3822a2df11c 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 @@ -298,4 +304,15 @@ 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%.', + ], + ]); + } + } diff --git a/tests/PHPStan/Rules/Comparison/TernaryOperatorConstantConditionRuleTest.php b/tests/PHPStan/Rules/Comparison/TernaryOperatorConstantConditionRuleTest.php index 300484a89b7..f6df113491b 100644 --- a/tests/PHPStan/Rules/Comparison/TernaryOperatorConstantConditionRuleTest.php +++ b/tests/PHPStan/Rules/Comparison/TernaryOperatorConstantConditionRuleTest.php @@ -3,10 +3,11 @@ namespace PHPStan\Rules\Comparison; use PHPStan\Rules\Rule; +use PHPStan\Testing\CompositeRule; use PHPStan\Testing\RuleTestCase; /** - * @extends RuleTestCase + * @extends RuleTestCase */ class TernaryOperatorConstantConditionRuleTest extends RuleTestCase { @@ -15,20 +16,25 @@ 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 @@ -106,4 +112,15 @@ public function testBug3370(): void $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 e84fdb4d566..caf89ddfe30 100644 --- a/tests/PHPStan/Rules/Comparison/WhileLoopAlwaysFalseConditionRuleTest.php +++ b/tests/PHPStan/Rules/Comparison/WhileLoopAlwaysFalseConditionRuleTest.php @@ -3,30 +3,36 @@ namespace PHPStan\Rules\Comparison; use PHPStan\Rules\Rule; +use PHPStan\Testing\CompositeRule; use PHPStan\Testing\RuleTestCase; /** - * @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 @@ -44,4 +50,14 @@ public function testRule(): void ]); } + 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 31f3abbf5e5..ce2f3549cc3 100644 --- a/tests/PHPStan/Rules/Comparison/WhileLoopAlwaysTrueConditionRuleTest.php +++ b/tests/PHPStan/Rules/Comparison/WhileLoopAlwaysTrueConditionRuleTest.php @@ -3,31 +3,37 @@ 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 @@ -74,4 +80,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/do-while-in-trait.php b/tests/PHPStan/Rules/Comparison/data/do-while-in-trait.php new file mode 100644 index 00000000000..1e5e9c279f6 --- /dev/null +++ b/tests/PHPStan/Rules/Comparison/data/do-while-in-trait.php @@ -0,0 +1,56 @@ +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..9815ff99550 --- /dev/null +++ b/tests/PHPStan/Rules/Comparison/data/elseif-condition-in-trait.php @@ -0,0 +1,62 @@ +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..969f7807fd2 --- /dev/null +++ b/tests/PHPStan/Rules/Comparison/data/logical-xor-in-trait.php @@ -0,0 +1,58 @@ +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..45e069dad71 --- /dev/null +++ b/tests/PHPStan/Rules/Comparison/data/match-in-trait.php @@ -0,0 +1,60 @@ += 8.0 + +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/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..c2f34f1b4da --- /dev/null +++ b/tests/PHPStan/Rules/Comparison/data/while-false-in-trait.php @@ -0,0 +1,58 @@ +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 + { + + } + +} From 4a9202b962863b78ac3735298a2a7da6bb38d7dd Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Fri, 27 Mar 2026 15:12:00 +0100 Subject: [PATCH 055/158] Regression tests Fixes phpstan/phpstan#13023 Closes https://github.com/phpstan/phpstan/issues/7599 Closes https://github.com/phpstan/phpstan/issues/13023 Closes https://github.com/phpstan/phpstan/issues/13474 Closes https://github.com/phpstan/phpstan/issues/13687 Closes https://github.com/phpstan/phpstan/issues/12798 Closes https://github.com/phpstan/phpstan/issues/11949 Closes https://github.com/phpstan/phpstan/issues/12267 Closes https://github.com/phpstan/phpstan/issues/9515 Closes https://github.com/phpstan/phpstan/issues/4570 Closes https://github.com/phpstan/phpstan/issues/4121 Closes https://github.com/phpstan/phpstan/issues/8060 --- .../Classes/ImpossibleInstanceOfRuleTest.php | 6 ++ .../PHPStan/Rules/Classes/data/bug-12267.php | 53 +++++++++++ .../BooleanNotConstantConditionRuleTest.php | 6 ++ ...mpossibleCheckTypeFunctionCallRuleTest.php | 44 +++++++++ ...rictComparisonOfDifferentTypesRuleTest.php | 15 +++ ...rnaryOperatorConstantConditionRuleTest.php | 6 ++ .../Rules/Comparison/data/bug-11949.php | 68 ++++++++++++++ .../Rules/Comparison/data/bug-12267.php | 43 +++++++++ .../Rules/Comparison/data/bug-12798.php | 53 +++++++++++ .../Rules/Comparison/data/bug-13023.php | 25 +++++ .../Rules/Comparison/data/bug-13474.php | 91 +++++++++++++++++++ .../Rules/Comparison/data/bug-13687.php | 34 +++++++ .../Rules/Comparison/data/bug-4121.php | 23 +++++ .../Rules/Comparison/data/bug-4570.php | 35 +++++++ .../Rules/Comparison/data/bug-7599.php | 41 +++++++++ .../Rules/Comparison/data/bug-8060.php | 39 ++++++++ .../Rules/Comparison/data/bug-9095.php | 34 +++++++ .../Rules/Comparison/data/bug-9515.php | 39 ++++++++ 18 files changed, 655 insertions(+) create mode 100644 tests/PHPStan/Rules/Classes/data/bug-12267.php create mode 100644 tests/PHPStan/Rules/Comparison/data/bug-11949.php create mode 100644 tests/PHPStan/Rules/Comparison/data/bug-12267.php create mode 100644 tests/PHPStan/Rules/Comparison/data/bug-12798.php create mode 100644 tests/PHPStan/Rules/Comparison/data/bug-13023.php create mode 100644 tests/PHPStan/Rules/Comparison/data/bug-13474.php create mode 100644 tests/PHPStan/Rules/Comparison/data/bug-13687.php create mode 100644 tests/PHPStan/Rules/Comparison/data/bug-4121.php create mode 100644 tests/PHPStan/Rules/Comparison/data/bug-4570.php create mode 100644 tests/PHPStan/Rules/Comparison/data/bug-7599.php create mode 100644 tests/PHPStan/Rules/Comparison/data/bug-8060.php create mode 100644 tests/PHPStan/Rules/Comparison/data/bug-9095.php create mode 100644 tests/PHPStan/Rules/Comparison/data/bug-9515.php diff --git a/tests/PHPStan/Rules/Classes/ImpossibleInstanceOfRuleTest.php b/tests/PHPStan/Rules/Classes/ImpossibleInstanceOfRuleTest.php index fcc567c51d9..f7752941d98 100644 --- a/tests/PHPStan/Rules/Classes/ImpossibleInstanceOfRuleTest.php +++ b/tests/PHPStan/Rules/Classes/ImpossibleInstanceOfRuleTest.php @@ -536,6 +536,12 @@ public function testBug10036(): void ]); } + public function testBug12267(): void + { + $this->treatPhpDocTypesAsCertain = true; + $this->analyse([__DIR__ . '/data/bug-12267.php'], []); + } + #[RequiresPhp('>= 8.0')] public function testNewIsAlwaysFinalClass(): void { 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/Comparison/BooleanNotConstantConditionRuleTest.php b/tests/PHPStan/Rules/Comparison/BooleanNotConstantConditionRuleTest.php index 404b6ebe989..fae6233ed77 100644 --- a/tests/PHPStan/Rules/Comparison/BooleanNotConstantConditionRuleTest.php +++ b/tests/PHPStan/Rules/Comparison/BooleanNotConstantConditionRuleTest.php @@ -236,6 +236,12 @@ 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; diff --git a/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeFunctionCallRuleTest.php b/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeFunctionCallRuleTest.php index 80a3c72c215..8a32b0ddf66 100644 --- a/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeFunctionCallRuleTest.php +++ b/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeFunctionCallRuleTest.php @@ -1147,6 +1147,50 @@ 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'], []); + } + + 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%.'; diff --git a/tests/PHPStan/Rules/Comparison/StrictComparisonOfDifferentTypesRuleTest.php b/tests/PHPStan/Rules/Comparison/StrictComparisonOfDifferentTypesRuleTest.php index a62b33caa59..f457963e726 100644 --- a/tests/PHPStan/Rules/Comparison/StrictComparisonOfDifferentTypesRuleTest.php +++ b/tests/PHPStan/Rules/Comparison/StrictComparisonOfDifferentTypesRuleTest.php @@ -1041,6 +1041,21 @@ public function testBug3761(): void $this->analyse([__DIR__ . '/data/bug-3761.php'], []); } + public function testBug8060(): void + { + $this->analyse([__DIR__ . '/data/bug-8060.php'], []); + } + + 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'], []); diff --git a/tests/PHPStan/Rules/Comparison/TernaryOperatorConstantConditionRuleTest.php b/tests/PHPStan/Rules/Comparison/TernaryOperatorConstantConditionRuleTest.php index f6df113491b..90e904c1ace 100644 --- a/tests/PHPStan/Rules/Comparison/TernaryOperatorConstantConditionRuleTest.php +++ b/tests/PHPStan/Rules/Comparison/TernaryOperatorConstantConditionRuleTest.php @@ -106,6 +106,12 @@ public function testBug7580(): void $this->analyse([__DIR__ . '/data/bug-7580.php'], []); } + public function testBug11949(): void + { + $this->treatPhpDocTypesAsCertain = true; + $this->analyse([__DIR__ . '/data/bug-11949.php'], []); + } + public function testBug3370(): void { $this->treatPhpDocTypesAsCertain = true; 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..b1373e56237 --- /dev/null +++ b/tests/PHPStan/Rules/Comparison/data/bug-11949.php @@ -0,0 +1,68 @@ + */ + 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-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..cd2bb14525b --- /dev/null +++ b/tests/PHPStan/Rules/Comparison/data/bug-9515.php @@ -0,0 +1,39 @@ +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; + } +} From 43c2d272a0e19bf403c367fd111f339adc55792d Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Fri, 27 Mar 2026 15:49:55 +0100 Subject: [PATCH 056/158] ImpossibleInstanceOfRule - add PossiblyImpureTipHelper and ConstantConditionInTraitHelper --- .../Classes/ImpossibleInstanceOfRule.php | 46 +++++++++---- .../Classes/ImpossibleInstanceOfRuleTest.php | 62 +++++++++++++++-- .../data/impossible-instanceof-in-trait.php | 52 ++++++++++++++ .../data/possibly-impure-instanceof-tip.php | 69 +++++++++++++++++++ 4 files changed, 209 insertions(+), 20 deletions(-) create mode 100644 tests/PHPStan/Rules/Classes/data/impossible-instanceof-in-trait.php create mode 100644 tests/PHPStan/Rules/Classes/data/possibly-impure-instanceof-tip.php 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/tests/PHPStan/Rules/Classes/ImpossibleInstanceOfRuleTest.php b/tests/PHPStan/Rules/Classes/ImpossibleInstanceOfRuleTest.php index f7752941d98..cbe569ed60a 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 @@ -602,4 +612,42 @@ 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, + ], + ]); + } + } diff --git a/tests/PHPStan/Rules/Classes/data/impossible-instanceof-in-trait.php b/tests/PHPStan/Rules/Classes/data/impossible-instanceof-in-trait.php new file mode 100644 index 00000000000..1419e085e22 --- /dev/null +++ b/tests/PHPStan/Rules/Classes/data/impossible-instanceof-in-trait.php @@ -0,0 +1,52 @@ +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; + } + } +} From 8175b6e54ab506b5f2126127faa534bbed5a425e Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Fri, 27 Mar 2026 16:04:33 +0100 Subject: [PATCH 057/158] Regression test Closes https://github.com/phpstan/phpstan/issues/10353 --- .../Classes/ImpossibleInstanceOfRuleTest.php | 6 +++ .../PHPStan/Rules/Classes/data/bug-10353.php | 37 +++++++++++++++++++ 2 files changed, 43 insertions(+) create mode 100644 tests/PHPStan/Rules/Classes/data/bug-10353.php diff --git a/tests/PHPStan/Rules/Classes/ImpossibleInstanceOfRuleTest.php b/tests/PHPStan/Rules/Classes/ImpossibleInstanceOfRuleTest.php index cbe569ed60a..6dc06a51159 100644 --- a/tests/PHPStan/Rules/Classes/ImpossibleInstanceOfRuleTest.php +++ b/tests/PHPStan/Rules/Classes/ImpossibleInstanceOfRuleTest.php @@ -546,6 +546,12 @@ 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; 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(); + } +} From 686ab8ab6bebcf4c1ad403488b8264bcecc46e27 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Fri, 27 Mar 2026 16:15:09 +0100 Subject: [PATCH 058/158] Report errors in traits with the same message just once --- .github/workflows/e2e-tests.yml | 6 +++++ e2e/in-trait/phpstan.neon | 4 ++++ e2e/in-trait/src/Bar.php | 20 ++++++++++++++++ e2e/in-trait/src/Foo.php | 20 ++++++++++++++++ e2e/in-trait/src/FooTrait.php | 23 +++++++++++++++++++ src/Analyser/Error.php | 22 ++++++++++++++++++ .../ConstantConditionInTraitRule.php | 2 +- 7 files changed, 96 insertions(+), 1 deletion(-) create mode 100644 e2e/in-trait/phpstan.neon create mode 100644 e2e/in-trait/src/Bar.php create mode 100644 e2e/in-trait/src/Foo.php create mode 100644 e2e/in-trait/src/FooTrait.php diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index 29a350009cd..7cc8f5dd43c 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -267,6 +267,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/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/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/Rules/Comparison/ConstantConditionInTraitRule.php b/src/Rules/Comparison/ConstantConditionInTraitRule.php index 83ca84f220e..fd595520e12 100644 --- a/src/Rules/Comparison/ConstantConditionInTraitRule.php +++ b/src/Rules/Comparison/ConstantConditionInTraitRule.php @@ -77,7 +77,7 @@ public function processNode(Node $node, Scope $scope): array if (count($uniquedErrors) === 1) { // report directly in trait, no "in context of" - $transformedErrors[] = new TransformedRuleError($uniquedErrors[0]); + $transformedErrors[] = new TransformedRuleError($uniquedErrors[0]->removeTraitContext()); continue; } From 7b668ec776381dcbee70d3a83fabe61a90844038 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Fri, 27 Mar 2026 16:22:06 +0100 Subject: [PATCH 059/158] Fix lint --- tests/PHPStan/Rules/Comparison/data/bug-9515.php | 4 +++- tests/PHPStan/Rules/Comparison/data/do-while-in-trait.php | 2 +- .../Rules/Comparison/data/elseif-condition-in-trait.php | 2 +- tests/PHPStan/Rules/Comparison/data/logical-xor-in-trait.php | 2 +- tests/PHPStan/Rules/Comparison/data/match-in-trait.php | 2 +- tests/PHPStan/Rules/Comparison/data/while-false-in-trait.php | 2 +- 6 files changed, 8 insertions(+), 6 deletions(-) diff --git a/tests/PHPStan/Rules/Comparison/data/bug-9515.php b/tests/PHPStan/Rules/Comparison/data/bug-9515.php index cd2bb14525b..07667db002f 100644 --- a/tests/PHPStan/Rules/Comparison/data/bug-9515.php +++ b/tests/PHPStan/Rules/Comparison/data/bug-9515.php @@ -1,4 +1,6 @@ -= 8.2 + +declare(strict_types = 1); namespace Bug9515; diff --git a/tests/PHPStan/Rules/Comparison/data/do-while-in-trait.php b/tests/PHPStan/Rules/Comparison/data/do-while-in-trait.php index 1e5e9c279f6..05c5fb89007 100644 --- a/tests/PHPStan/Rules/Comparison/data/do-while-in-trait.php +++ b/tests/PHPStan/Rules/Comparison/data/do-while-in-trait.php @@ -1,4 +1,4 @@ -= 8.2 namespace DoWhileInTrait; diff --git a/tests/PHPStan/Rules/Comparison/data/elseif-condition-in-trait.php b/tests/PHPStan/Rules/Comparison/data/elseif-condition-in-trait.php index 9815ff99550..0ec93827730 100644 --- a/tests/PHPStan/Rules/Comparison/data/elseif-condition-in-trait.php +++ b/tests/PHPStan/Rules/Comparison/data/elseif-condition-in-trait.php @@ -1,4 +1,4 @@ -= 8.2 namespace ElseIfConditionInTrait; diff --git a/tests/PHPStan/Rules/Comparison/data/logical-xor-in-trait.php b/tests/PHPStan/Rules/Comparison/data/logical-xor-in-trait.php index 969f7807fd2..9a9c140b35b 100644 --- a/tests/PHPStan/Rules/Comparison/data/logical-xor-in-trait.php +++ b/tests/PHPStan/Rules/Comparison/data/logical-xor-in-trait.php @@ -1,4 +1,4 @@ -= 8.2 namespace LogicalXorInTrait; diff --git a/tests/PHPStan/Rules/Comparison/data/match-in-trait.php b/tests/PHPStan/Rules/Comparison/data/match-in-trait.php index 45e069dad71..bf257ff0550 100644 --- a/tests/PHPStan/Rules/Comparison/data/match-in-trait.php +++ b/tests/PHPStan/Rules/Comparison/data/match-in-trait.php @@ -1,4 +1,4 @@ -= 8.0 += 8.2 namespace MatchInTrait; diff --git a/tests/PHPStan/Rules/Comparison/data/while-false-in-trait.php b/tests/PHPStan/Rules/Comparison/data/while-false-in-trait.php index c2f34f1b4da..a5538629436 100644 --- a/tests/PHPStan/Rules/Comparison/data/while-false-in-trait.php +++ b/tests/PHPStan/Rules/Comparison/data/while-false-in-trait.php @@ -1,4 +1,4 @@ -= 8.2 namespace WhileFalseInTrait; From 43aed2c2c625b86678c0ac6be3c4edf3ca2b5798 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Fri, 27 Mar 2026 16:24:06 +0100 Subject: [PATCH 060/158] Fix tests --- .../Rules/Comparison/DoWhileLoopConstantConditionRuleTest.php | 2 ++ .../Rules/Comparison/ElseIfConstantConditionRuleTest.php | 1 + .../Comparison/ImpossibleCheckTypeFunctionCallRuleTest.php | 1 + .../Rules/Comparison/LogicalXorConstantConditionRuleTest.php | 2 ++ .../Comparison/StrictComparisonOfDifferentTypesRuleTest.php | 1 + .../Comparison/TernaryOperatorConstantConditionRuleTest.php | 2 ++ .../Rules/Comparison/WhileLoopAlwaysFalseConditionRuleTest.php | 2 ++ tests/PHPStan/Rules/Comparison/data/bug-11949.php | 2 +- 8 files changed, 12 insertions(+), 1 deletion(-) diff --git a/tests/PHPStan/Rules/Comparison/DoWhileLoopConstantConditionRuleTest.php b/tests/PHPStan/Rules/Comparison/DoWhileLoopConstantConditionRuleTest.php index f0045408012..8c8e736a579 100644 --- a/tests/PHPStan/Rules/Comparison/DoWhileLoopConstantConditionRuleTest.php +++ b/tests/PHPStan/Rules/Comparison/DoWhileLoopConstantConditionRuleTest.php @@ -5,6 +5,7 @@ use PHPStan\Rules\Rule; use PHPStan\Testing\CompositeRule; use PHPStan\Testing\RuleTestCase; +use PHPUnit\Framework\Attributes\RequiresPhp; /** * @extends RuleTestCase @@ -82,6 +83,7 @@ public function testRule(): void ]); } + #[RequiresPhp('>= 8.2')] public function testInTrait(): void { $this->analyse([__DIR__ . '/data/do-while-in-trait.php'], [ diff --git a/tests/PHPStan/Rules/Comparison/ElseIfConstantConditionRuleTest.php b/tests/PHPStan/Rules/Comparison/ElseIfConstantConditionRuleTest.php index 342be2c84b1..5575663828d 100644 --- a/tests/PHPStan/Rules/Comparison/ElseIfConstantConditionRuleTest.php +++ b/tests/PHPStan/Rules/Comparison/ElseIfConstantConditionRuleTest.php @@ -158,6 +158,7 @@ public function testBug6947(): void ]); } + #[RequiresPhp('>= 8.2')] public function testInTrait(): void { $this->treatPhpDocTypesAsCertain = true; diff --git a/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeFunctionCallRuleTest.php b/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeFunctionCallRuleTest.php index 8a32b0ddf66..bea6b5a12cd 100644 --- a/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeFunctionCallRuleTest.php +++ b/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeFunctionCallRuleTest.php @@ -1166,6 +1166,7 @@ public function testBug7599(): void $this->analyse([__DIR__ . '/data/bug-7599.php'], []); } + #[RequiresPhp('>= 8.0')] public function testBug13474(): void { $this->treatPhpDocTypesAsCertain = true; diff --git a/tests/PHPStan/Rules/Comparison/LogicalXorConstantConditionRuleTest.php b/tests/PHPStan/Rules/Comparison/LogicalXorConstantConditionRuleTest.php index bea49f94bc1..c68f739131a 100644 --- a/tests/PHPStan/Rules/Comparison/LogicalXorConstantConditionRuleTest.php +++ b/tests/PHPStan/Rules/Comparison/LogicalXorConstantConditionRuleTest.php @@ -5,6 +5,7 @@ use PHPStan\Rules\Rule as TRule; use PHPStan\Testing\CompositeRule; use PHPStan\Testing\RuleTestCase; +use PHPUnit\Framework\Attributes\RequiresPhp; /** * @extends RuleTestCase @@ -77,6 +78,7 @@ public function testRule(): void ]); } + #[RequiresPhp('>= 8.2')] public function testInTrait(): void { $this->analyse([__DIR__ . '/data/logical-xor-in-trait.php'], [ diff --git a/tests/PHPStan/Rules/Comparison/StrictComparisonOfDifferentTypesRuleTest.php b/tests/PHPStan/Rules/Comparison/StrictComparisonOfDifferentTypesRuleTest.php index f457963e726..df6773794f1 100644 --- a/tests/PHPStan/Rules/Comparison/StrictComparisonOfDifferentTypesRuleTest.php +++ b/tests/PHPStan/Rules/Comparison/StrictComparisonOfDifferentTypesRuleTest.php @@ -1046,6 +1046,7 @@ 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'], []); diff --git a/tests/PHPStan/Rules/Comparison/TernaryOperatorConstantConditionRuleTest.php b/tests/PHPStan/Rules/Comparison/TernaryOperatorConstantConditionRuleTest.php index 90e904c1ace..3bd4459d290 100644 --- a/tests/PHPStan/Rules/Comparison/TernaryOperatorConstantConditionRuleTest.php +++ b/tests/PHPStan/Rules/Comparison/TernaryOperatorConstantConditionRuleTest.php @@ -5,6 +5,7 @@ use PHPStan\Rules\Rule; use PHPStan\Testing\CompositeRule; use PHPStan\Testing\RuleTestCase; +use PHPUnit\Framework\Attributes\RequiresPhp; /** * @extends RuleTestCase @@ -106,6 +107,7 @@ public function testBug7580(): void $this->analyse([__DIR__ . '/data/bug-7580.php'], []); } + #[RequiresPhp('>= 8.1')] public function testBug11949(): void { $this->treatPhpDocTypesAsCertain = true; diff --git a/tests/PHPStan/Rules/Comparison/WhileLoopAlwaysFalseConditionRuleTest.php b/tests/PHPStan/Rules/Comparison/WhileLoopAlwaysFalseConditionRuleTest.php index caf89ddfe30..e4356732de2 100644 --- a/tests/PHPStan/Rules/Comparison/WhileLoopAlwaysFalseConditionRuleTest.php +++ b/tests/PHPStan/Rules/Comparison/WhileLoopAlwaysFalseConditionRuleTest.php @@ -5,6 +5,7 @@ use PHPStan\Rules\Rule; use PHPStan\Testing\CompositeRule; use PHPStan\Testing\RuleTestCase; +use PHPUnit\Framework\Attributes\RequiresPhp; /** * @extends RuleTestCase @@ -50,6 +51,7 @@ public function testRule(): void ]); } + #[RequiresPhp('>= 8.2')] public function testInTrait(): void { $this->analyse([__DIR__ . '/data/while-false-in-trait.php'], [ diff --git a/tests/PHPStan/Rules/Comparison/data/bug-11949.php b/tests/PHPStan/Rules/Comparison/data/bug-11949.php index b1373e56237..4de1ea766b3 100644 --- a/tests/PHPStan/Rules/Comparison/data/bug-11949.php +++ b/tests/PHPStan/Rules/Comparison/data/bug-11949.php @@ -1,4 +1,4 @@ -= 8.1 namespace Bug11949; From b47a204e3add46a834f8b1a303b06548d33fed5e Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Fri, 27 Mar 2026 16:29:59 +0100 Subject: [PATCH 061/158] Remove dead `@phpstan-ignore` in traits --- src/Type/Generic/TemplateTypeTrait.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Type/Generic/TemplateTypeTrait.php b/src/Type/Generic/TemplateTypeTrait.php index d823f9b20d5..b3020a07470 100644 --- a/src/Type/Generic/TemplateTypeTrait.php +++ b/src/Type/Generic/TemplateTypeTrait.php @@ -132,7 +132,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; } @@ -149,7 +149,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; } @@ -166,7 +166,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; } From bbf5afba969cc478cca0ed94d524734032ddd0e6 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Thu, 26 Mar 2026 11:03:53 +0100 Subject: [PATCH 062/158] reportUnsafeArrayStringKeyCasting - detect implementation --- conf/config.neon | 1 + conf/parametersSchema.neon | 1 + src/DependencyInjection/ContainerFactory.php | 1 + ...eportUnsafeArrayStringKeyCastingToggle.php | 34 +++++++ .../AccessoryDecimalIntegerStringType.php | 2 +- src/Type/ArrayType.php | 39 +++++++- ...ringKeyCastingDetectTypeAcceptanceTest.php | 45 +++++++++ ...tringKeyCastingDetectTypeInferenceTest.php | 40 ++++++++ ...ringKeyCastingUnsafeTypeAcceptanceTest.php | 34 +++++++ ...nsafe-array-string-key-casting-accepts.php | 42 +++++++++ ...unsafe-array-string-key-casting-detect.php | 91 +++++++++++++++++++ .../Analyser/nsrt/decimal-int-string.php | 10 ++ ...portUnsafeArrayStringKeyCastingDetect.neon | 2 + 13 files changed, 338 insertions(+), 4 deletions(-) create mode 100644 src/DependencyInjection/ReportUnsafeArrayStringKeyCastingToggle.php create mode 100644 tests/PHPStan/Analyser/ReportUnsafeArrayStringKeyCastingDetectTypeAcceptanceTest.php create mode 100644 tests/PHPStan/Analyser/ReportUnsafeArrayStringKeyCastingDetectTypeInferenceTest.php create mode 100644 tests/PHPStan/Analyser/ReportUnsafeArrayStringKeyCastingUnsafeTypeAcceptanceTest.php create mode 100644 tests/PHPStan/Analyser/data/report-unsafe-array-string-key-casting-accepts.php create mode 100644 tests/PHPStan/Analyser/data/report-unsafe-array-string-key-casting-detect.php create mode 100644 tests/PHPStan/Analyser/reportUnsafeArrayStringKeyCastingDetect.neon diff --git a/conf/config.neon b/conf/config.neon index cb8c20ad481..67a5301b571 100644 --- a/conf/config.neon +++ b/conf/config.neon @@ -82,6 +82,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 bc79fe7c401..300ea5ac343 100644 --- a/conf/parametersSchema.neon +++ b/conf/parametersSchema.neon @@ -91,6 +91,7 @@ parametersSchema: reportWrongPhpDocTypeInVarTag: bool() reportAnyTypeWideningInVarTag: bool() reportNonIntStringArrayKey: bool() + reportUnsafeArrayStringKeyCasting: schema(string(), pattern('detect|prevent'), nullable()) reportPossiblyNonexistentGeneralArrayOffset: bool() reportPossiblyNonexistentConstantArrayOffset: bool() checkMissingOverrideMethodAttribute: bool() 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 @@ +inverse) { - return new StringType(); + return $this; } return new IntegerType(); diff --git a/src/Type/ArrayType.php b/src/Type/ArrayType.php index 288caefdc63..3a326a13d2b 100644 --- a/src/Type/ArrayType.php +++ b/src/Type/ArrayType.php @@ -2,6 +2,7 @@ namespace PHPStan\Type; +use PHPStan\DependencyInjection\ReportUnsafeArrayStringKeyCastingToggle; use PHPStan\Php\PhpVersion; use PHPStan\PhpDocParser\Ast\Type\GenericTypeNode; use PHPStan\PhpDocParser\Ast\Type\IdentifierTypeNode; @@ -12,6 +13,7 @@ use PHPStan\ShouldNotHappenException; use PHPStan\TrinaryLogic; use PHPStan\Type\Accessory\AccessoryArrayListType; +use PHPStan\Type\Accessory\AccessoryDecimalIntegerStringType; use PHPStan\Type\Accessory\HasOffsetValueType; use PHPStan\Type\Accessory\NonEmptyArrayType; use PHPStan\Type\Constant\ConstantArrayType; @@ -47,6 +49,8 @@ class ArrayType implements Type private Type $keyType; + private ?Type $cachedIterableKeyType = null; + /** @api */ public function __construct(Type $keyType, private Type $itemType) { @@ -198,15 +202,44 @@ 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()]); } if ($keyType instanceof StrictMixedType) { - return new BenevolentUnionType([new IntegerType(), new StringType()]); + $keyType = new BenevolentUnionType([new IntegerType(), new StringType()]); + } + + $level = ReportUnsafeArrayStringKeyCastingToggle::getLevel(); + if ($level === null) { + return $this->cachedIterableKeyType = $keyType; + } + + if ($level === ReportUnsafeArrayStringKeyCastingToggle::PREVENT) { + return $this->cachedIterableKeyType = $keyType; + } + + if ($level !== ReportUnsafeArrayStringKeyCastingToggle::DETECT) { // @phpstan-ignore notIdentical.alwaysFalse + throw new ShouldNotHappenException(); } - return $keyType; + return $this->cachedIterableKeyType = TypeTraverser::map($keyType, static function (Type $type, callable $traverse): Type { + if ($type instanceof UnionType) { + return $traverse($type); + } + + if ($type->isString()->yes() && !$type->isDecimalIntegerString()->no()) { + return TypeCombinator::union( + new IntegerType(), + TypeCombinator::intersect($type, new AccessoryDecimalIntegerStringType(inverse: true)), + ); + } + + return $type; + }); } public function getFirstIterableKeyType(): Type diff --git a/tests/PHPStan/Analyser/ReportUnsafeArrayStringKeyCastingDetectTypeAcceptanceTest.php b/tests/PHPStan/Analyser/ReportUnsafeArrayStringKeyCastingDetectTypeAcceptanceTest.php new file mode 100644 index 00000000000..02412d59406 --- /dev/null +++ b/tests/PHPStan/Analyser/ReportUnsafeArrayStringKeyCastingDetectTypeAcceptanceTest.php @@ -0,0 +1,45 @@ + + */ +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, + ], + ]); + } + + 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/ReportUnsafeArrayStringKeyCastingUnsafeTypeAcceptanceTest.php b/tests/PHPStan/Analyser/ReportUnsafeArrayStringKeyCastingUnsafeTypeAcceptanceTest.php new file mode 100644 index 00000000000..429a6a438ae --- /dev/null +++ b/tests/PHPStan/Analyser/ReportUnsafeArrayStringKeyCastingUnsafeTypeAcceptanceTest.php @@ -0,0 +1,34 @@ + + */ +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, + ], + ]); + } + +} 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..d04e42a4990 --- /dev/null +++ b/tests/PHPStan/Analyser/data/report-unsafe-array-string-key-casting-accepts.php @@ -0,0 +1,42 @@ + $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); + } + +} 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..df88c4437db --- /dev/null +++ b/tests/PHPStan/Analyser/data/report-unsafe-array-string-key-casting-detect.php @@ -0,0 +1,91 @@ + $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); + } + +} diff --git a/tests/PHPStan/Analyser/nsrt/decimal-int-string.php b/tests/PHPStan/Analyser/nsrt/decimal-int-string.php index 63747525028..fe5c5fdd529 100644 --- a/tests/PHPStan/Analyser/nsrt/decimal-int-string.php +++ b/tests/PHPStan/Analyser/nsrt/decimal-int-string.php @@ -35,6 +35,16 @@ public function doBar(string $s): void 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 */ 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 From 3012e242770bbefea84b18029c42ba3f3a1b0da9 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Sun, 29 Mar 2026 15:07:50 +0200 Subject: [PATCH 063/158] reportUnsafeArrayStringKeyCasting - prevent implementation --- .../ValidateIgnoredErrorsExtension.php | 1 + src/PhpDoc/TypeNodeResolver.php | 31 ++++++- .../Accessory/AccessoryNumericStringType.php | 17 +++- src/Type/ArrayType.php | 9 +- src/Type/StringType.php | 12 ++- ...ingKeyCastingPreventTypeAcceptanceTest.php | 53 +++++++++++ ...ringKeyCastingPreventTypeInferenceTest.php | 40 ++++++++ ...nsafe-array-string-key-casting-prevent.php | 91 +++++++++++++++++++ ...ortUnsafeArrayStringKeyCastingPrevent.neon | 2 + 9 files changed, 244 insertions(+), 12 deletions(-) create mode 100644 tests/PHPStan/Analyser/ReportUnsafeArrayStringKeyCastingPreventTypeAcceptanceTest.php create mode 100644 tests/PHPStan/Analyser/ReportUnsafeArrayStringKeyCastingPreventTypeInferenceTest.php create mode 100644 tests/PHPStan/Analyser/data/report-unsafe-array-string-key-casting-prevent.php create mode 100644 tests/PHPStan/Analyser/reportUnsafeArrayStringKeyCastingPrevent.neon diff --git a/src/DependencyInjection/ValidateIgnoredErrorsExtension.php b/src/DependencyInjection/ValidateIgnoredErrorsExtension.php index 6bcd2eb995d..3ee37fc86b6 100644 --- a/src/DependencyInjection/ValidateIgnoredErrorsExtension.php +++ b/src/DependencyInjection/ValidateIgnoredErrorsExtension.php @@ -139,6 +139,7 @@ public function getRegistry(): UnaryOperatorTypeSpecifyingExtensionRegistry } }, new OversizedArrayBuilder(), true), + reportUnsafeArrayStringKeyCasting: null, ), ), ); diff --git a/src/PhpDoc/TypeNodeResolver.php b/src/PhpDoc/TypeNodeResolver.php index 815789377a7..27e7ae27ac9 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; @@ -106,6 +108,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 +131,9 @@ use function strtolower; use function substr; +/** + * @phpstan-import-type Level from ReportUnsafeArrayStringKeyCastingToggle as ReportUnsafeArrayStringKeyCastingLevel + */ #[AutowiredService] final class TypeNodeResolver { @@ -135,12 +141,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, ) { } @@ -661,7 +672,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 @@ -686,9 +697,23 @@ 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([ + $originalKey = $genericTypes[0]; + if ($this->reportUnsafeArrayStringKeyCasting === ReportUnsafeArrayStringKeyCastingToggle::PREVENT) { + $originalKey = TypeTraverser::map($originalKey, 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; + }); + } + $keyType = TypeCombinator::intersect($originalKey->toArrayKey(), new UnionType([ new IntegerType(), new StringType(), ]))->toArrayKey(); diff --git a/src/Type/Accessory/AccessoryNumericStringType.php b/src/Type/Accessory/AccessoryNumericStringType.php index 62e320e5de5..d0a7d65ac0d 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; @@ -209,12 +210,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)]), ]); } diff --git a/src/Type/ArrayType.php b/src/Type/ArrayType.php index 3a326a13d2b..8a82601946f 100644 --- a/src/Type/ArrayType.php +++ b/src/Type/ArrayType.php @@ -34,6 +34,7 @@ use PHPStan\Type\Traits\UndecidedComparisonTypeTrait; use function array_merge; use function count; +use function in_array; use function sprintf; /** @api */ @@ -54,11 +55,11 @@ class ArrayType implements Type /** @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; @@ -207,10 +208,10 @@ public function getIterableKeyType(): Type } $keyType = $this->keyType; if ($keyType instanceof MixedType && !$keyType instanceof TemplateMixedType) { - $keyType = new BenevolentUnionType([new IntegerType(), new StringType()]); + $keyType = (new BenevolentUnionType([new IntegerType(), new StringType()]))->toArrayKey(); } if ($keyType instanceof StrictMixedType) { - $keyType = new BenevolentUnionType([new IntegerType(), new StringType()]); + $keyType = (new BenevolentUnionType([new IntegerType(), new StringType()]))->toArrayKey(); } $level = ReportUnsafeArrayStringKeyCastingToggle::getLevel(); diff --git a/src/Type/StringType.php b/src/Type/StringType.php index 03361300331..afbdcf92a43 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; @@ -177,7 +179,15 @@ public function toArray(): Type public function toArrayKey(): Type { - return $this; + $level = ReportUnsafeArrayStringKeyCastingToggle::getLevel(); + if ($level !== ReportUnsafeArrayStringKeyCastingToggle::PREVENT) { + return $this; + } + + return new UnionType([ + new IntegerType(), + TypeCombinator::intersect($this, new AccessoryDecimalIntegerStringType(inverse: true)), + ]); } public function toCoercedArgumentType(bool $strictTypes): Type diff --git a/tests/PHPStan/Analyser/ReportUnsafeArrayStringKeyCastingPreventTypeAcceptanceTest.php b/tests/PHPStan/Analyser/ReportUnsafeArrayStringKeyCastingPreventTypeAcceptanceTest.php new file mode 100644 index 00000000000..846ab7512a5 --- /dev/null +++ b/tests/PHPStan/Analyser/ReportUnsafeArrayStringKeyCastingPreventTypeAcceptanceTest.php @@ -0,0 +1,53 @@ + + */ +class ReportUnsafeArrayStringKeyCastingPreventTypeAcceptanceTest 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::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, + ], + ]); + } + + 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/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..163a996bd25 --- /dev/null +++ b/tests/PHPStan/Analyser/data/report-unsafe-array-string-key-casting-prevent.php @@ -0,0 +1,91 @@ + $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|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); + } + +} 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 From 3da8941511ce503c3f2acbefb993fbefc301ea5e Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Sun, 29 Mar 2026 15:54:46 +0200 Subject: [PATCH 064/158] Fix --- src/Type/ClassStringType.php | 2 +- src/Type/StringType.php | 7 +++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/src/Type/ClassStringType.php b/src/Type/ClassStringType.php index 2c22f319db0..55f24671a20 100644 --- a/src/Type/ClassStringType.php +++ b/src/Type/ClassStringType.php @@ -46,7 +46,7 @@ public function isString(): TrinaryLogic public function isNumericString(): TrinaryLogic { - return TrinaryLogic::createMaybe(); + return TrinaryLogic::createNo(); } public function isDecimalIntegerString(): TrinaryLogic diff --git a/src/Type/StringType.php b/src/Type/StringType.php index afbdcf92a43..e549f4ba826 100644 --- a/src/Type/StringType.php +++ b/src/Type/StringType.php @@ -184,6 +184,13 @@ public function toArrayKey(): Type 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)), From 6f6897de3a44dec9c80d56dd175a807a70db3261 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Sun, 29 Mar 2026 16:07:27 +0200 Subject: [PATCH 065/158] Issue bot - support reportUnsafeArrayStringKeyCasting --- issue-bot/src/Console/RunCommand.php | 1 + 1 file changed, 1 insertion(+) 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, From 0d7fe195a10fa7f54ff249a077d4c5e148f911f9 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Tue, 31 Mar 2026 12:14:49 +0200 Subject: [PATCH 066/158] Normalize decimal-int-string intersection with lowercase-string/uppercase-string --- .../AccessoryDecimalIntegerStringType.php | 13 ++++-- tests/PHPStan/Type/TypeCombinatorTest.php | 41 +++++++++++++++++++ 2 files changed, 51 insertions(+), 3 deletions(-) diff --git a/src/Type/Accessory/AccessoryDecimalIntegerStringType.php b/src/Type/Accessory/AccessoryDecimalIntegerStringType.php index 93f2eed9f47..c83657bbd56 100644 --- a/src/Type/Accessory/AccessoryDecimalIntegerStringType.php +++ b/src/Type/Accessory/AccessoryDecimalIntegerStringType.php @@ -124,7 +124,14 @@ public function isSubTypeOf(Type $otherType): IsSuperTypeOfResult return $otherType->isSuperTypeOf($this); } - if ($otherType instanceof AccessoryNumericStringType && !$this->inverse) { + if ( + ( + $otherType instanceof AccessoryNumericStringType + || $otherType instanceof AccessoryLowercaseStringType + || $otherType instanceof AccessoryUppercaseStringType + ) + && !$this->inverse + ) { return IsSuperTypeOfResult::createYes(); } @@ -357,12 +364,12 @@ public function isLiteralString(): TrinaryLogic public function isLowercaseString(): TrinaryLogic { - return TrinaryLogic::createMaybe(); + return $this->inverse ? TrinaryLogic::createMaybe() : TrinaryLogic::createYes(); } public function isUppercaseString(): TrinaryLogic { - return TrinaryLogic::createMaybe(); + return $this->inverse ? TrinaryLogic::createMaybe() : TrinaryLogic::createYes(); } public function isClassString(): TrinaryLogic diff --git a/tests/PHPStan/Type/TypeCombinatorTest.php b/tests/PHPStan/Type/TypeCombinatorTest.php index 9ea18aac9b5..6a04347b13d 100644 --- a/tests/PHPStan/Type/TypeCombinatorTest.php +++ b/tests/PHPStan/Type/TypeCombinatorTest.php @@ -2874,6 +2874,24 @@ public static function dataUnion(): iterable 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', + ]; } /** @@ -5024,6 +5042,29 @@ public static function dataIntersect(): iterable 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', + ]; } /** From 399ce26031981c26a170b414d9905d63874ec2bf Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Wed, 1 Apr 2026 08:58:57 +0200 Subject: [PATCH 067/158] Fix CS --- src/Type/TypeCombinator.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Type/TypeCombinator.php b/src/Type/TypeCombinator.php index 202ecb5fc7b..2051b65ea73 100644 --- a/src/Type/TypeCombinator.php +++ b/src/Type/TypeCombinator.php @@ -27,8 +27,8 @@ use PHPStan\Type\Generic\TemplateType; use PHPStan\Type\Generic\TemplateTypeFactory; use PHPStan\Type\Generic\TemplateUnionType; -use function array_filter; use function array_fill; +use function array_filter; use function array_key_exists; use function array_key_first; use function array_merge; From 5df1fd4330b4c5356dca4b96aff52ff9d6d86a56 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Wed, 1 Apr 2026 11:12:47 +0200 Subject: [PATCH 068/158] Update baseline --- phpstan-baseline.neon | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index e99255404ef..1ceaa3e986f 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -1722,7 +1722,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 - From 301cf483ecef1f0ae39386211bddbdcb0eea083e Mon Sep 17 00:00:00 2001 From: Theodore Brown Date: Wed, 8 Apr 2026 01:49:33 -0500 Subject: [PATCH 069/158] Respect sys_temp_dir from the parent process (#5390) --- .github/workflows/e2e-tests.yml | 11 +++++++++++ e2e/bug-14093/phpstan.neon | 4 ++++ e2e/bug-14093/test.php | 6 ++++++ src/Process/ProcessHelper.php | 4 ++++ 4 files changed, 25 insertions(+) create mode 100644 e2e/bug-14093/phpstan.neon create mode 100644 e2e/bug-14093/test.php diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index 7cc8f5dd43c..c174980ac98 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -457,6 +457,17 @@ jobs: OUTPUT=$(../../bin/phpstan analyze -l 0 -vvv test.php 2>&1) echo "$OUTPUT" ../bashunit -a contains 'Parallel processing scheduler' "$OUTPUT" + - script: | + cd e2e/bug-14093 + # Use a relative sys_temp_dir so the value starts with a letter (not /), just like Windows + # paths starting with a drive letter. If the sys_temp_dir value isn't correctly quoted, + # PHP evaluates ~ as a bitwise NOT rather than a literal character, truncating the path. + mkdir tmp~1 + php -d "sys_temp_dir='tmp~1'" ../../bin/phpstan analyze + if [ -d tmp ]; then + echo "FAIL: tmp was created by a worker, meaning sys_temp_dir='tmp~1' was incorrectly evaluated to 'tmp'" + exit 1 + fi steps: - name: Harden the runner (Audit all outbound calls) diff --git a/e2e/bug-14093/phpstan.neon b/e2e/bug-14093/phpstan.neon new file mode 100644 index 00000000000..14f9de4dceb --- /dev/null +++ b/e2e/bug-14093/phpstan.neon @@ -0,0 +1,4 @@ +parameters: + level: 8 + paths: + - test.php diff --git a/e2e/bug-14093/test.php b/e2e/bug-14093/test.php new file mode 100644 index 00000000000..29c13da5315 --- /dev/null +++ b/e2e/bug-14093/test.php @@ -0,0 +1,6 @@ +getOption('memory-limit') === null) { From bce809352367b5c34d42d92e69436c3583084e76 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Fri, 17 Apr 2026 18:04:15 +0200 Subject: [PATCH 070/158] Capture current ConstantArrayType inconsistencies related to sealedness --- .../Type/Constant/ConstantArrayTypeTest.php | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/tests/PHPStan/Type/Constant/ConstantArrayTypeTest.php b/tests/PHPStan/Type/Constant/ConstantArrayTypeTest.php index 1c2b6fc410a..6d6c41af1cc 100644 --- a/tests/PHPStan/Type/Constant/ConstantArrayTypeTest.php +++ b/tests/PHPStan/Type/Constant/ConstantArrayTypeTest.php @@ -408,6 +408,32 @@ public static function dataAccepts(): iterable ]), TrinaryLogic::createMaybe(), ]; + + 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(), + ]; } #[DataProvider('dataAccepts')] @@ -690,6 +716,26 @@ 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(), + ]; } #[DataProvider('dataIsSuperTypeOf')] From 9fdaaddea4327a234153f10167b7bf8e3a5ea631 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Wed, 29 Apr 2026 13:02:30 +0200 Subject: [PATCH 071/158] Fix build --- tests/PHPStan/Rules/Comparison/Bug14534Test.php | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) 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, ); } From 1c4a8b35b405e65384a7105a80cca6afc67c2bef Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Thu, 7 May 2026 21:38:08 +0200 Subject: [PATCH 072/158] Add Type::toBitwiseNotType() to AccessoryDecimalIntegerStringType MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This class was added on the `unsealed` branch and didn't exist when the `Type::toBitwiseNotType()` interface method was introduced in the merged Group 3 refactor — sync up the missing override so the class is instantiable again. Body matches the other accessory string types: when not inverted the type is guaranteed non-empty, so `~$s` keeps the non-empty accessory. The decimal-integer property itself doesn't survive bitwise-not, so the accessory is dropped. --- .../Accessory/AccessoryDecimalIntegerStringType.php | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/Type/Accessory/AccessoryDecimalIntegerStringType.php b/src/Type/Accessory/AccessoryDecimalIntegerStringType.php index c83657bbd56..7732aceffc6 100644 --- a/src/Type/Accessory/AccessoryDecimalIntegerStringType.php +++ b/src/Type/Accessory/AccessoryDecimalIntegerStringType.php @@ -220,6 +220,18 @@ 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(); From 4e51c5ab8ade943e750df8797005ef5e299a6b3e Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Sat, 9 May 2026 10:17:43 +0200 Subject: [PATCH 073/158] Test result-cache restore does not trigger reflection (#5617) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Petr Morávek --- .github/workflows/e2e-tests.yml | 5 ++++ .../.gitignore | 2 ++ .../composer.json | 17 +++++++++++++ .../lib/ThrowingSourceLocator.php | 25 +++++++++++++++++++ .../phpstan.neon | 10 ++++++++ .../src/foo.php | 4 +++ 6 files changed, 63 insertions(+) create mode 100644 e2e/result-cache-restore-without-reflection/.gitignore create mode 100644 e2e/result-cache-restore-without-reflection/composer.json create mode 100644 e2e/result-cache-restore-without-reflection/lib/ThrowingSourceLocator.php create mode 100644 e2e/result-cache-restore-without-reflection/phpstan.neon create mode 100644 e2e/result-cache-restore-without-reflection/src/foo.php diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index 60d5a23a406..f5989b740b8 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -307,6 +307,11 @@ jobs: echo "$OUTPUT" ../bashunit -a matches "Note: Using configuration file .+phpstan.neon." "$OUTPUT" ../bashunit -a contains 'Result cache not used because the metadata do not match: metaExtensions' "$OUTPUT" + - script: | + cd e2e/result-cache-restore-without-reflection + composer install + ../../bin/phpstan -vvv + ../../bin/phpstan -vvv - script: | cd e2e/bug-12606 export CONFIGTEST=test diff --git a/e2e/result-cache-restore-without-reflection/.gitignore b/e2e/result-cache-restore-without-reflection/.gitignore new file mode 100644 index 00000000000..8b7ef350326 --- /dev/null +++ b/e2e/result-cache-restore-without-reflection/.gitignore @@ -0,0 +1,2 @@ +/vendor +composer.lock diff --git a/e2e/result-cache-restore-without-reflection/composer.json b/e2e/result-cache-restore-without-reflection/composer.json new file mode 100644 index 00000000000..e21da9a5a48 --- /dev/null +++ b/e2e/result-cache-restore-without-reflection/composer.json @@ -0,0 +1,17 @@ +{ + "require-dev": { + "phpstan/phpstan-symfony": "@dev", + "phpstan/extension-installer": "^1.4", + "phpstan/phpstan-doctrine": "@dev" + }, + "autoload": { + "classmap": [ + "lib/" + ] + }, + "config": { + "allow-plugins": { + "phpstan/extension-installer": true + } + } +} diff --git a/e2e/result-cache-restore-without-reflection/lib/ThrowingSourceLocator.php b/e2e/result-cache-restore-without-reflection/lib/ThrowingSourceLocator.php new file mode 100644 index 00000000000..197e78e755d --- /dev/null +++ b/e2e/result-cache-restore-without-reflection/lib/ThrowingSourceLocator.php @@ -0,0 +1,25 @@ + Date: Sat, 9 May 2026 10:24:13 +0200 Subject: [PATCH 074/158] Test result-cache restore does not trigger reflection in all 1st party extensions (#5618) --- .../composer.json | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/e2e/result-cache-restore-without-reflection/composer.json b/e2e/result-cache-restore-without-reflection/composer.json index e21da9a5a48..c0d4ee43781 100644 --- a/e2e/result-cache-restore-without-reflection/composer.json +++ b/e2e/result-cache-restore-without-reflection/composer.json @@ -2,7 +2,14 @@ "require-dev": { "phpstan/phpstan-symfony": "@dev", "phpstan/extension-installer": "^1.4", - "phpstan/phpstan-doctrine": "@dev" + "phpstan/phpstan-doctrine": "@dev", + "phpstan/phpstan-beberlei-assert": "@dev", + "phpstan/phpstan-phpunit": "@dev", + "phpstan/phpstan-webmozart-assert": "@dev", + "phpstan/phpstan-mockery": "@dev", + "phpstan/phpstan-nette": "@dev", + "phpstan/phpstan-dibi": "@dev", + "php-standard-library/phpstan-extension": "@dev" }, "autoload": { "classmap": [ From e78b9553c45701257cc5b66f8e390a51485bdbeb Mon Sep 17 00:00:00 2001 From: phpstan-bot <79867460+phpstan-bot@users.noreply.github.com> Date: Sat, 9 May 2026 10:42:19 +0200 Subject: [PATCH 075/158] Mark `class_exists`, `interface_exists`, `trait_exists`, and `enum_exists` as having no side effects in function metadata (#5607) Co-authored-by: VincentLanglet <9052536+VincentLanglet@users.noreply.github.com> Co-authored-by: Claude Opus 4.6 --- src/Node/Expr/AlwaysRememberedExpr.php | 1 + ...sExistsFunctionTypeSpecifyingExtension.php | 9 ++-- ...member-possibly-impure-function-values.php | 47 +++++++++++++++++++ ...otRememberPossiblyImpureValuesRuleTest.php | 35 ++++++++++++++ tests/PHPStan/Rules/Classes/data/bug-8579.php | 13 +++++ .../doNotRememberPossiblyImpureValues.neon | 2 + 6 files changed, 104 insertions(+), 3 deletions(-) create mode 100644 tests/PHPStan/Rules/Classes/InstantiationDoNotRememberPossiblyImpureValuesRuleTest.php create mode 100644 tests/PHPStan/Rules/Classes/data/bug-8579.php create mode 100644 tests/PHPStan/Rules/Classes/doNotRememberPossiblyImpureValues.neon diff --git a/src/Node/Expr/AlwaysRememberedExpr.php b/src/Node/Expr/AlwaysRememberedExpr.php index 71f40b97244..f389fac356b 100644 --- a/src/Node/Expr/AlwaysRememberedExpr.php +++ b/src/Node/Expr/AlwaysRememberedExpr.php @@ -7,6 +7,7 @@ use PHPStan\Node\VirtualNode; use PHPStan\Type\Type; +/** Wraps an expression so its type is always remembered in the scope, bypassing impurity checks. */ final class AlwaysRememberedExpr extends Expr implements VirtualNode { diff --git a/src/Type/Php/ClassExistsFunctionTypeSpecifyingExtension.php b/src/Type/Php/ClassExistsFunctionTypeSpecifyingExtension.php index 8265a598b1f..06c1aad5937 100644 --- a/src/Type/Php/ClassExistsFunctionTypeSpecifyingExtension.php +++ b/src/Type/Php/ClassExistsFunctionTypeSpecifyingExtension.php @@ -12,7 +12,9 @@ use PHPStan\Analyser\TypeSpecifierAwareExtension; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; +use PHPStan\Node\Expr\AlwaysRememberedExpr; use PHPStan\Reflection\FunctionReflection; +use PHPStan\Type\BooleanType; use PHPStan\Type\ClassStringType; use PHPStan\Type\Constant\ConstantBooleanType; use PHPStan\Type\Constant\ConstantStringType; @@ -47,10 +49,11 @@ public function specifyTypes(FunctionReflection $functionReflection, FuncCall $n $args = $node->getArgs(); $argType = $scope->getType($args[0]->value); if ($argType instanceof ConstantStringType) { + $funcCall = new FuncCall(new FullyQualified('class_exists'), [ + new Arg(new String_(ltrim($argType->getValue(), '\\'))), + ]); return $this->typeSpecifier->create( - new FuncCall(new FullyQualified('class_exists'), [ - new Arg(new String_(ltrim($argType->getValue(), '\\'))), - ]), + new AlwaysRememberedExpr($funcCall, new BooleanType(), new BooleanType()), new ConstantBooleanType(true), $context, $scope, diff --git a/tests/PHPStan/Analyser/data/do-not-remember-possibly-impure-function-values.php b/tests/PHPStan/Analyser/data/do-not-remember-possibly-impure-function-values.php index 5adbbc52200..158ab83dec7 100644 --- a/tests/PHPStan/Analyser/data/do-not-remember-possibly-impure-function-values.php +++ b/tests/PHPStan/Analyser/data/do-not-remember-possibly-impure-function-values.php @@ -109,3 +109,50 @@ function test(): void assertType('int', impure()); } } + +function testClassExistsRemembered(): void +{ + if (\class_exists('Bug8579RememberedA')) { + assertType('true', \class_exists('Bug8579RememberedA')); + } else { + assertType('bool', \class_exists('Bug8579RememberedA')); + } + + assertType('bool', \class_exists('Bug8579RememberedA')); +} + +function testClassExistsFalseNotRemembered(): void +{ + if (!\class_exists('Bug8579FalseNotRememberedA')) { + assertType('bool', \class_exists('Bug8579FalseNotRememberedA')); + } + + assertType('bool', \class_exists('Bug8579FalseNotRememberedA')); +} + +function testInterfaceExistsFalseNotRemembered(): void +{ + if (!\interface_exists('Bug8579FalseNotRememberedC')) { + assertType('bool', \interface_exists('Bug8579FalseNotRememberedC')); + } + + assertType('bool', \interface_exists('Bug8579FalseNotRememberedC')); +} + +function testTraitExistsFalseNotRemembered(): void +{ + if (!\trait_exists('Bug8579FalseNotRememberedD')) { + assertType('bool', \trait_exists('Bug8579FalseNotRememberedD')); + } + + assertType('bool', \trait_exists('Bug8579FalseNotRememberedD')); +} + +function testEnumExistsFalseNotRemembered(): void +{ + if (!\enum_exists('Bug8579FalseNotRememberedE')) { + assertType('bool', \enum_exists('Bug8579FalseNotRememberedE')); + } + + assertType('bool', \enum_exists('Bug8579FalseNotRememberedE')); +} diff --git a/tests/PHPStan/Rules/Classes/InstantiationDoNotRememberPossiblyImpureValuesRuleTest.php b/tests/PHPStan/Rules/Classes/InstantiationDoNotRememberPossiblyImpureValuesRuleTest.php new file mode 100644 index 00000000000..d40092f3932 --- /dev/null +++ b/tests/PHPStan/Rules/Classes/InstantiationDoNotRememberPossiblyImpureValuesRuleTest.php @@ -0,0 +1,35 @@ + + */ +class InstantiationDoNotRememberPossiblyImpureValuesRuleTest extends RuleTestCase +{ + + protected function getRule(): Rule + { + return self::getContainer()->getByType(InstantiationRule::class); + } + + public function testBug8579(): void + { + $this->analyse([__DIR__ . '/data/bug-8579.php'], []); + } + + public static function getAdditionalConfigFiles(): array + { + return array_merge( + parent::getAdditionalConfigFiles(), + [ + __DIR__ . '/doNotRememberPossiblyImpureValues.neon', + ], + ); + } + +} diff --git a/tests/PHPStan/Rules/Classes/data/bug-8579.php b/tests/PHPStan/Rules/Classes/data/bug-8579.php new file mode 100644 index 00000000000..018ab54678c --- /dev/null +++ b/tests/PHPStan/Rules/Classes/data/bug-8579.php @@ -0,0 +1,13 @@ + Date: Sat, 9 May 2026 11:40:23 +0200 Subject: [PATCH 076/158] Fix build (#5620) --- .../MethodCallWithPossiblyRenamedNamedArgumentRuleTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/PHPStan/Rules/Methods/MethodCallWithPossiblyRenamedNamedArgumentRuleTest.php b/tests/PHPStan/Rules/Methods/MethodCallWithPossiblyRenamedNamedArgumentRuleTest.php index b5ecc3f37c4..5cad631977b 100644 --- a/tests/PHPStan/Rules/Methods/MethodCallWithPossiblyRenamedNamedArgumentRuleTest.php +++ b/tests/PHPStan/Rules/Methods/MethodCallWithPossiblyRenamedNamedArgumentRuleTest.php @@ -36,7 +36,7 @@ protected function getRule(): Rule ), new OverridingMethodRule( $phpVersion, - new MethodSignatureRule(new ParentMethodHelper($phpClassReflectionExtension), true, true), + new MethodSignatureRule(new ParentMethodHelper($phpClassReflectionExtension), true, true, true), false, new MethodParameterComparisonHelper($phpVersion), new MethodVisibilityComparisonHelper(), From a9ebd38905a8b5465e927046a67e0d7a13ca58e3 Mon Sep 17 00:00:00 2001 From: Vincent Langlet Date: Mon, 11 May 2026 10:02:00 +0200 Subject: [PATCH 077/158] Merge branch 2.1.x into 2.2.x (#5638) Co-authored-by: phpstan-bot <79867460+phpstan-bot@users.noreply.github.com> --- src/Analyser/MutatingScope.php | 48 ++++++----- src/Type/ConditionalType.php | 8 +- tests/PHPStan/Analyser/nsrt/bug-11894.php | 70 ++++++++++++++++ tests/PHPStan/Analyser/nsrt/bug-8048.php | 35 ++++++++ .../CallToFunctionParametersRuleTest.php | 5 ++ .../Rules/Functions/data/bug-11894.php | 79 ++++++++++++++++++ .../Rules/Methods/CallMethodsRuleTest.php | 16 ++++ .../Methods/CallStaticMethodsRuleTest.php | 6 ++ .../PHPStan/Rules/Methods/data/bug-11894.php | 81 +++++++++++++++++++ tests/PHPStan/Rules/Methods/data/bug-8048.php | 33 ++++++++ 10 files changed, 355 insertions(+), 26 deletions(-) create mode 100644 tests/PHPStan/Analyser/nsrt/bug-11894.php create mode 100644 tests/PHPStan/Analyser/nsrt/bug-8048.php create mode 100644 tests/PHPStan/Rules/Functions/data/bug-11894.php create mode 100644 tests/PHPStan/Rules/Methods/data/bug-11894.php create mode 100644 tests/PHPStan/Rules/Methods/data/bug-8048.php diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index c9d9f6339f0..691b77ec5a0 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -2023,18 +2023,7 @@ public function enterAnonymousFunctionWithoutReflection( $isNullable = $this->isParameterValueNullable($parameter); $parameterType = $this->getFunctionType($parameter->type, $isNullable, $parameter->variadic); if ($callableParameters !== null) { - if (isset($callableParameters[$i])) { - $parameterType = self::intersectButNotNever($parameterType, $callableParameters[$i]->getType()); - } elseif (count($callableParameters) > 0) { - $lastParameter = array_last($callableParameters); - if ($lastParameter->isVariadic()) { - $parameterType = self::intersectButNotNever($parameterType, $lastParameter->getType()); - } else { - $parameterType = self::intersectButNotNever($parameterType, new MixedType()); - } - } else { - $parameterType = self::intersectButNotNever($parameterType, new MixedType()); - } + $parameterType = self::intersectButNotNever($parameterType, $this->getCallableParameterType($callableParameters, $i)); } $holder = ExpressionTypeHolder::createYes($parameter->var, $parameterType); $expressionTypes[$paramExprString] = $holder; @@ -2233,20 +2222,8 @@ public function enterArrowFunctionWithoutReflection(Expr\ArrowFunction $arrowFun foreach ($arrowFunction->params as $i => $parameter) { $isNullable = $this->isParameterValueNullable($parameter); $parameterType = $this->getFunctionType($parameter->type, $isNullable, $parameter->variadic); - if ($callableParameters !== null) { - if (isset($callableParameters[$i])) { - $parameterType = self::intersectButNotNever($parameterType, $callableParameters[$i]->getType()); - } elseif (count($callableParameters) > 0) { - $lastParameter = array_last($callableParameters); - if ($lastParameter->isVariadic()) { - $parameterType = self::intersectButNotNever($parameterType, $lastParameter->getType()); - } else { - $parameterType = self::intersectButNotNever($parameterType, new MixedType()); - } - } else { - $parameterType = self::intersectButNotNever($parameterType, new MixedType()); - } + $parameterType = self::intersectButNotNever($parameterType, $this->getCallableParameterType($callableParameters, $i)); } if (!$parameter->var instanceof Variable || !is_string($parameter->var->name)) { @@ -2312,6 +2289,27 @@ public function getFunctionType($type, bool $isNullable, bool $isVariadic): Type return $this->initializerExprTypeResolver->getFunctionType($type, $isNullable, false, InitializerExprContext::fromScope($this)); } + /** + * @param ParameterReflection[] $callableParameters + */ + private function getCallableParameterType(array $callableParameters, int $index): Type + { + if (isset($callableParameters[$index])) { + return $callableParameters[$index]->getType(); + } + + if (count($callableParameters) === 0) { + return new MixedType(); + } + + $lastParameter = array_last($callableParameters); + if ($lastParameter->isVariadic()) { + return $lastParameter->getType(); + } + + return new MixedType(); + } + public static function intersectButNotNever(Type $nativeType, Type $inferredType): Type { if ($nativeType->isSuperTypeOf($inferredType)->no()) { diff --git a/src/Type/ConditionalType.php b/src/Type/ConditionalType.php index f154fb5368a..3819be25545 100644 --- a/src/Type/ConditionalType.php +++ b/src/Type/ConditionalType.php @@ -113,7 +113,13 @@ public function describe(VerbosityLevel $level): string public function isResolvable(): bool { - return !TypeUtils::containsTemplateType($this->subject) && !TypeUtils::containsTemplateType($this->target); + if (!TypeUtils::containsTemplateType($this->subject) && !TypeUtils::containsTemplateType($this->target)) { + return true; + } + + $isSuperType = $this->target->isSuperTypeOf($this->subject); + + return $isSuperType->yes() || $isSuperType->no(); } protected function getResult(): Type diff --git a/tests/PHPStan/Analyser/nsrt/bug-11894.php b/tests/PHPStan/Analyser/nsrt/bug-11894.php new file mode 100644 index 00000000000..cb91f1929c1 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-11894.php @@ -0,0 +1,70 @@ += 8.0 + +declare(strict_types = 1); + +namespace Bug11894Nsrt; + +use function PHPStan\Testing\assertType; + +/** + * @template T + * @param T $a + * @return (T is string ? string : T) + */ +function conditionalReturn(mixed $a): mixed +{ + if (!is_string($a)) { + return $a; + } + return trim($a); +} + +/** + * @template T of string|null + * @param T $a + */ +function testNarrowedToString(mixed $a): void +{ + if (!is_string($a)) { + return; + } + assertType('string', conditionalReturn($a)); +} + +/** + * @template T of int|null + * @param T $a + */ +function testNarrowedToNonMatchingType(mixed $a): void +{ + if (!is_int($a)) { + return; + } + assertType('T of int (function Bug11894Nsrt\testNarrowedToNonMatchingType(), argument)', conditionalReturn($a)); +} + +/** + * @template T of string|int + * @param T $a + */ +function testNotFullyNarrowable(mixed $a): void +{ + assertType('string|T of int (function Bug11894Nsrt\testNotFullyNarrowable(), argument)', conditionalReturn($a)); +} + +abstract class ConditionalArrayKeys +{ + /** + * @template TKey of array-key + * @template TArray of array + * @param TArray $array + * @return (TArray is non-empty-array ? non-empty-list : list) + */ + abstract public function arrayKeys(array $array): array; + + /** @param non-empty-array $nonEmpty */ + public function testMaybeStaysUnresolved(array $nonEmpty): void + { + assertType('non-empty-list', $this->arrayKeys($nonEmpty)); + } +} diff --git a/tests/PHPStan/Analyser/nsrt/bug-8048.php b/tests/PHPStan/Analyser/nsrt/bug-8048.php new file mode 100644 index 00000000000..504605669c2 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-8048.php @@ -0,0 +1,35 @@ +|null $responseType + * + * @return ($responseType is class-string ? T : null) + */ + public function request(?string $responseType = null): ?CustomResponseInterface + { + if ($responseType === null) { + return null; + } + + return new CustomResponse(); + } +} + +function (): void { + assertType('null', (new ApiService())->request(null)); + assertType('Bug8048Nsrt\CustomResponse', (new ApiService())->request(CustomResponse::class)); + $x = rand(0, 1) ? CustomResponse::class : null; + assertType('Bug8048Nsrt\CustomResponse|null', (new ApiService())->request($x)); +}; diff --git a/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php b/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php index 1b62fe7e8b8..f5b3ad7b2f0 100644 --- a/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php +++ b/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php @@ -2956,4 +2956,9 @@ public function testBug3842(): void $this->analyse([__DIR__ . '/../../Analyser/nsrt/bug-3842.php'], []); } + public function testBug11894(): void + { + $this->analyse([__DIR__ . '/data/bug-11894.php'], []); + } + } diff --git a/tests/PHPStan/Rules/Functions/data/bug-11894.php b/tests/PHPStan/Rules/Functions/data/bug-11894.php new file mode 100644 index 00000000000..798919a5d6e --- /dev/null +++ b/tests/PHPStan/Rules/Functions/data/bug-11894.php @@ -0,0 +1,79 @@ +checkThisOnly = false; + $this->checkNullables = true; + $this->checkUnionTypes = true; + $this->analyse([__DIR__ . '/data/bug-11894.php'], []); + } + + public function testBug8048(): void + { + $this->checkThisOnly = false; + $this->checkNullables = true; + $this->checkUnionTypes = true; + $this->analyse([__DIR__ . '/data/bug-8048.php'], []); + } + } diff --git a/tests/PHPStan/Rules/Methods/CallStaticMethodsRuleTest.php b/tests/PHPStan/Rules/Methods/CallStaticMethodsRuleTest.php index 751fc0b8771..076e4770e18 100644 --- a/tests/PHPStan/Rules/Methods/CallStaticMethodsRuleTest.php +++ b/tests/PHPStan/Rules/Methods/CallStaticMethodsRuleTest.php @@ -1031,4 +1031,10 @@ public function testConstantParameterCheckStatic(): void ]); } + public function testBug11894(): void + { + $this->checkThisOnly = false; + $this->analyse([__DIR__ . '/data/bug-11894.php'], []); + } + } diff --git a/tests/PHPStan/Rules/Methods/data/bug-11894.php b/tests/PHPStan/Rules/Methods/data/bug-11894.php new file mode 100644 index 00000000000..7b4daf0ada4 --- /dev/null +++ b/tests/PHPStan/Rules/Methods/data/bug-11894.php @@ -0,0 +1,81 @@ +conditionalReturn($a); + } + + /** + * @template T of string|null + * @param T $a + */ + public function testStaticMethod(mixed $a): mixed + { + if (!is_string($a)) { + return $a; + } + + return Converter::conditionalReturnStatic($a); + } + + /** + * @template T of string|int + * @param T $a + */ + public function testMaybeMethod(mixed $a): mixed + { + $c = new Converter(); + return $c->conditionalReturn($a); + } + + /** + * @template T of string|int + * @param T $a + */ + public function testMaybeStaticMethod(mixed $a): mixed + { + return Converter::conditionalReturnStatic($a); + } +} diff --git a/tests/PHPStan/Rules/Methods/data/bug-8048.php b/tests/PHPStan/Rules/Methods/data/bug-8048.php new file mode 100644 index 00000000000..d4f7e7831ea --- /dev/null +++ b/tests/PHPStan/Rules/Methods/data/bug-8048.php @@ -0,0 +1,33 @@ +|null $responseType + * + * @return ($responseType is class-string ? T : null) + */ + public function request(?string $responseType = null): ?CustomResponseInterface + { + if ($responseType === null) { + return null; + } + + return new CustomResponse(); + } +} + +function (): void { + (new ApiService())->request(null); + (new ApiService())->request(CustomResponse::class); + $x = rand(0, 1) ? CustomResponse::class : null; + (new ApiService())->request($x); +}; From 460cd50d461b51ee4810bf2a0a4f4f970ebae3ed Mon Sep 17 00:00:00 2001 From: phpstan-bot <79867460+phpstan-bot@users.noreply.github.com> Date: Mon, 11 May 2026 10:17:32 +0200 Subject: [PATCH 078/158] Return `null` from `ArgumentsNormalizer::reorderArgs()` when positional args after named args create holes beyond parameter count (#5637) Co-authored-by: Claude Opus 4.6 Co-authored-by: Markus Staab --- src/Analyser/ArgumentsNormalizer.php | 2 +- .../Analyser/AnalyserIntegrationTest.php | 7 ++++ .../Analyser/ArgumentsNormalizerTest.php | 27 +++++++++++++++ tests/PHPStan/Analyser/data/bug-14596.php | 34 +++++++++++++++++++ .../Rules/Methods/CallMethodsRuleTest.php | 14 ++++++++ .../Methods/CallStaticMethodsRuleTest.php | 12 +++++++ .../PHPStan/Rules/Methods/data/bug-14596.php | 13 +++++++ 7 files changed, 108 insertions(+), 1 deletion(-) create mode 100644 tests/PHPStan/Analyser/data/bug-14596.php create mode 100644 tests/PHPStan/Rules/Methods/data/bug-14596.php diff --git a/src/Analyser/ArgumentsNormalizer.php b/src/Analyser/ArgumentsNormalizer.php index 6c254df7632..ae6fb6511ca 100644 --- a/src/Analyser/ArgumentsNormalizer.php +++ b/src/Analyser/ArgumentsNormalizer.php @@ -403,7 +403,7 @@ public static function reorderArgs(ParametersAcceptor $parametersAcceptor, array continue; } if (!array_key_exists($j, $signatureParameters)) { - throw new ShouldNotHappenException('Parameter signatures cannot have holes'); + return null; } $parameter = $signatureParameters[$j]; diff --git a/tests/PHPStan/Analyser/AnalyserIntegrationTest.php b/tests/PHPStan/Analyser/AnalyserIntegrationTest.php index 803d37fe8c9..0763f789a5c 100644 --- a/tests/PHPStan/Analyser/AnalyserIntegrationTest.php +++ b/tests/PHPStan/Analyser/AnalyserIntegrationTest.php @@ -1563,6 +1563,13 @@ public function testBug14550(): void $this->assertNotEmpty($errors); } + public function testBug14596(): void + { + // crash + $errors = $this->runAnalyse(__DIR__ . '/data/bug-14596.php'); + $this->assertNotEmpty($errors); + } + /** * @param string[]|null $allAnalysedFiles * @return list diff --git a/tests/PHPStan/Analyser/ArgumentsNormalizerTest.php b/tests/PHPStan/Analyser/ArgumentsNormalizerTest.php index b2cc10be5da..a9226fc3131 100644 --- a/tests/PHPStan/Analyser/ArgumentsNormalizerTest.php +++ b/tests/PHPStan/Analyser/ArgumentsNormalizerTest.php @@ -323,6 +323,33 @@ public static function dataReorderInvalid(): iterable [new StringType(), 'three'], ], ]; + + // positional arg after named arg with variadic parameter + yield [ + [ + ['value', false, false, null], + ['values', true, true, new StringType()], + ], + [ + [new IntegerType(), null], + [new IntegerType(), null], + [new IntegerType(), null], + [new StringType(), 'd'], + [new IntegerType(), null], + ], + ]; + + // positional arg after named arg without variadic parameter + yield [ + [ + ['one', false, false, null], + ], + [ + [new IntegerType(), null], + [new StringType(), 'd'], + [new IntegerType(), null], + ], + ]; } /** diff --git a/tests/PHPStan/Analyser/data/bug-14596.php b/tests/PHPStan/Analyser/data/bug-14596.php new file mode 100644 index 00000000000..8904c0c8059 --- /dev/null +++ b/tests/PHPStan/Analyser/data/bug-14596.php @@ -0,0 +1,34 @@ +bar(1, 2, 3, d: 'foo', 5); + +// static method call +Foo::baz(1, 2, 3, d: 'foo', 5); + +// constructor +new Foo(1, 2, 3, d: 'foo', 5); + +// closure +$closure = function (int $a, int $b, int $c, string ...$rest): void {}; +$closure(1, 2, 3, d: 'foo', 5); + +// call_user_func +call_user_func('Bug14596\foo', 1, 2, 3, d: 'foo', 5); diff --git a/tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php b/tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php index 141f7fbb281..b785aac506e 100644 --- a/tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php +++ b/tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php @@ -4102,4 +4102,18 @@ public function testBug8048(): void $this->analyse([__DIR__ . '/data/bug-8048.php'], []); } + #[RequiresPhp('>= 8.0.0')] + public function testBug14596(): void + { + $this->checkThisOnly = false; + $this->checkNullables = true; + $this->checkUnionTypes = true; + $this->analyse([__DIR__ . '/data/bug-14596.php'], [ + [ + 'Named argument cannot be followed by a positional argument.', + 11, + ], + ]); + } + } diff --git a/tests/PHPStan/Rules/Methods/CallStaticMethodsRuleTest.php b/tests/PHPStan/Rules/Methods/CallStaticMethodsRuleTest.php index 076e4770e18..36c036efecf 100644 --- a/tests/PHPStan/Rules/Methods/CallStaticMethodsRuleTest.php +++ b/tests/PHPStan/Rules/Methods/CallStaticMethodsRuleTest.php @@ -1037,4 +1037,16 @@ public function testBug11894(): void $this->analyse([__DIR__ . '/data/bug-11894.php'], []); } + #[RequiresPhp('>= 8.0.0')] + public function testBug14596(): void + { + $this->checkThisOnly = false; + $this->analyse([__DIR__ . '/data/bug-14596.php'], [ + [ + 'Named argument cannot be followed by a positional argument.', + 12, + ], + ]); + } + } diff --git a/tests/PHPStan/Rules/Methods/data/bug-14596.php b/tests/PHPStan/Rules/Methods/data/bug-14596.php new file mode 100644 index 00000000000..f35305cdc31 --- /dev/null +++ b/tests/PHPStan/Rules/Methods/data/bug-14596.php @@ -0,0 +1,13 @@ +bar(1, 2, 3, d: 'foo', 5); + Foo::baz(1, 2, 3, d: 'foo', 5); +}; From 74703f0eb24ab796bfa70b4c2c3d7c1be8bc8a60 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Wed, 13 May 2026 21:17:16 +0200 Subject: [PATCH 079/158] Add CDK app for apiref.phpstan.org infrastructure Mirror the website infra modernization from phpstan/phpstan: replace the click-configured legacy stack for apiref.phpstan.org with code under apigen/infra/. - ApirefStack: private S3 bucket via OAC, CloudFront distribution (HTTP/2+3, TLS 1.2_2021), a single CloudFront Function 2.0 doing the per-version landing-page redirects that the legacy apiref-phpstan-org-viewer-request did (now with `/` -> 2.2.x as the new latest, and 301s instead of 302s for SEO), a Response Headers Policy replacing the shared secure-headers-response, and a DNS-validated ACM cert for apiref.phpstan.org. - OidcRolesStack: phpstan-apiref-infra-deploy role for the new workflow. Reuses the account-wide GitHub OIDC provider (the dist-repo CDK app created it); does not try to create a duplicate. - productionAlias context flag controls whether apiref.phpstan.org is attached to the new distribution. Stays false until the manual cutover detaches the alias from the legacy E37G1C2KWNAPBD; then flipped true. - New .github/workflows/apiref-infra.yml: test -> diff -> deploy gated on needs:[test,diff], OIDC, sticky PR diff comment. Same shape as the main-site website-infra.yml. - apiref.yml: switched off the static APIREF_AWS_* keys to OIDC via vars.APIREF_DEPLOY_ROLE_ARN, vars.APIREF_BUCKET, and vars.APIREF_DISTRIBUTION_ID. Added `!apigen/infra/**` to the path filter so infra-only edits don't trigger a (slow) ApiGen rebuild. After merge, set these repository variables in phpstan/phpstan-src: APIREF_INFRA_DEPLOY_ROLE_ARN (from PhpstanApirefOidcRoles output) APIREF_DEPLOY_ROLE_ARN (from PhpstanApirefWebsite output) APIREF_BUCKET phpstan-apiref-web APIREF_DISTRIBUTION_ID (from PhpstanApirefWebsite output) Full bootstrap + cutover + cleanup runbook in apigen/infra/README.md. Conventions and edit-this-when guide in apigen/infra/CLAUDE.md. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/apiref-infra.yml | 96 + .github/workflows/apiref.yml | 40 +- apigen/infra/.gitignore | 7 + apigen/infra/CLAUDE.md | 133 + apigen/infra/README.md | 109 + apigen/infra/bin/infra.ts | 49 + apigen/infra/cdk.json | 30 + .../functions/apiref-version-redirects.js | 52 + apigen/infra/lib/apiref-stack.ts | 181 ++ apigen/infra/lib/oidc-roles-stack.ts | 57 + apigen/infra/package-lock.json | 2295 +++++++++++++++++ apigen/infra/package.json | 29 + apigen/infra/test/apiref-stack.test.ts | 163 ++ .../test/apiref-version-redirects.test.ts | 75 + apigen/infra/tsconfig.json | 27 + apigen/infra/vitest.config.ts | 7 + 16 files changed, 3328 insertions(+), 22 deletions(-) create mode 100644 .github/workflows/apiref-infra.yml create mode 100644 apigen/infra/.gitignore create mode 100644 apigen/infra/CLAUDE.md create mode 100644 apigen/infra/README.md create mode 100644 apigen/infra/bin/infra.ts create mode 100644 apigen/infra/cdk.json create mode 100644 apigen/infra/functions/apiref-version-redirects.js create mode 100644 apigen/infra/lib/apiref-stack.ts create mode 100644 apigen/infra/lib/oidc-roles-stack.ts create mode 100644 apigen/infra/package-lock.json create mode 100644 apigen/infra/package.json create mode 100644 apigen/infra/test/apiref-stack.test.ts create mode 100644 apigen/infra/test/apiref-version-redirects.test.ts create mode 100644 apigen/infra/tsconfig.json create mode 100644 apigen/infra/vitest.config.ts 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/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..111458b60b6 --- /dev/null +++ b/apigen/infra/README.md @@ -0,0 +1,109 @@ +# 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`. Default `false`. + +- `false`: distribution carries no aliases, no ACM cert attached (serves on the CF default `*.cloudfront.net` domain). First deploy succeeds while `apiref.phpstan.org` is still owned by the legacy distribution `E37G1C2KWNAPBD`. +- `true`: distribution carries `apiref.phpstan.org` as its alias and uses the new ACM cert. Set after the manual cutover. + +## Out-of-band resources + +The Route 53 record for `apiref.phpstan.org` is **not** managed by CDK. The +cutover script UPSERTs the record (via raw `change-resource-record-sets`); CDK +isn't aware of it. Same pattern as apex/www on the main site. + +## 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) +``` + +## One-time bootstrap + +The CDK bootstrap roles for the AWS account already exist (created by the +phpstan-dist repo's CDK app). You only need to deploy the OIDC roles stack +once, from a maintainer's laptop with admin AWS credentials: + +```sh +npx cdk deploy PhpstanApirefOidcRoles +``` + +Note the `InfraDeployRoleArn` output and set the corresponding GitHub repo +variable. + +## GitHub repo variables to set (in phpstan/phpstan-src) + +After the first deploys, set these under Settings → Secrets and variables → Actions → Variables: + +| 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` | + +## Cutover runbook (legacy → new) + +This moves `apiref.phpstan.org` from the legacy distribution `E37G1C2KWNAPBD` +to the new CDK-managed distribution. Expect ~5–10 min of intermittent 403s on +`apiref.phpstan.org` while CloudFront edges propagate the alias swap. + +**Pre-cutover (with `productionAlias: false`):** + +1. Merge the PR that adds this `apigen/infra/` directory. `apiref-infra.yml` deploys both stacks. +2. Copy bucket contents: `aws s3 sync s3://web-apiref.phpstan.org/ s3://phpstan-apiref-web/` (~334 MB / 13.5k objects). +3. Smoke-test on the new distribution's CF domain (look up `DistributionDomain` output): + ```sh + D=$(aws cloudfront get-distribution --id --query 'Distribution.DomainName' --output text) + curl -sI "https://$D/" # 301 to /2.2.x/namespace-PHPStan.html + curl -sI "https://$D/2.2.x/namespace-PHPStan.html" # 200 + curl -sI "https://$D/1.9.x" # 301 to /1.9.x/namespace-PHPStan.html + curl -sI "https://$D/" | grep -iE 'strict-transport|x-content-type|x-frame|referrer-policy|x-xss' + ``` + Verify HSTS, XCTO, XFO=SAMEORIGIN, Referrer-Policy present; no X-XSS-Protection. + +**Cutover (with `productionAlias: true`):** + +The sequence (do Route 53 first — we learned the hard way on the main site that CloudFront's `AddAlias` does a DNS sanity check): + +1. UPSERT Route 53 `apiref.phpstan.org` CNAME → new distribution's CF domain. +2. Wait for Route 53 INSYNC (~30–60s). +3. Detach `apiref.phpstan.org` from `E37G1C2KWNAPBD` via `aws cloudfront update-distribution`. +4. Add `apiref.phpstan.org` to the new distribution (retry every 20s if CloudFront's DNS-check cache is stale). +5. Wait for new distribution `Deployed`. +6. Smoke-test against `https://apiref.phpstan.org/`. + +Then merge the PR that flips `productionAlias: true` in `cdk.json`. The +workflow's `cdk deploy` is a no-op for the alias (already attached by the +script) and just syncs CFN state. + +## Cleanup runbook (when stable for ~1 week) + +- Delete CloudFront distribution `E37G1C2KWNAPBD` (disable, wait, delete). +- Delete CloudFront Functions `apiref-phpstan-org-viewer-request` and `secure-headers-response` (the latter has no remaining users after `E37G1C2KWNAPBD` is gone). +- Delete the legacy ACM cert `arn:aws:acm:us-east-1:928192134594:certificate/18f4edec-8bec-4f52-a02b-a9738053b817` once unreferenced. +- Empty and delete S3 bucket `web-apiref.phpstan.org`. +- Delete the `APIREF_AWS_ACCESS_KEY_ID` and `APIREF_AWS_SECRET_ACCESS_KEY` GitHub secrets. +- (Optional follow-up) Migrate `update-playground-api.yml` and `update-playground-runner.yml` to OIDC — they're the last workflows in this repo still using `PLAYGROUND_RUNNER_AWS_*` static keys. 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..42cfa6b84e5 --- /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": false, + "@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'], + }, +}); From a25a45ab8bc8c30835d0b44c2f9c1ec80e0a9698 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Thu, 14 May 2026 09:51:51 +0200 Subject: [PATCH 080/158] Flip apiref productionAlias to true, trim cutover docs from README The cutover of apiref.phpstan.org from the legacy distribution to the CDK-managed one is done. Set productionAlias: true so the CDK code matches the live distribution (alias + ACM cert were attached out-of-band during the cutover; the next cdk deploy is a state-sync no-op). Trim the now-historical pre-cutover / cutover runbook from the README, keeping the steady-state reference and the legacy-resource cleanup runbook. Co-Authored-By: Claude Opus 4.7 (1M context) --- apigen/infra/README.md | 90 +++++++++++++----------------------------- apigen/infra/cdk.json | 2 +- 2 files changed, 28 insertions(+), 64 deletions(-) diff --git a/apigen/infra/README.md b/apigen/infra/README.md index 111458b60b6..b435b25ef83 100644 --- a/apigen/infra/README.md +++ b/apigen/infra/README.md @@ -19,43 +19,26 @@ Region: `us-east-1` (required for CloudFront + ACM). ## The `productionAlias` flag -Defined in `cdk.json` under `context`. Default `false`. +Defined in `cdk.json` under `context`. Currently `true` — the distribution +carries `apiref.phpstan.org` as its alias and uses the CDK-issued ACM cert. -- `false`: distribution carries no aliases, no ACM cert attached (serves on the CF default `*.cloudfront.net` domain). First deploy succeeds while `apiref.phpstan.org` is still owned by the legacy distribution `E37G1C2KWNAPBD`. -- `true`: distribution carries `apiref.phpstan.org` as its alias and uses the new ACM cert. Set after the manual cutover. +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 record for `apiref.phpstan.org` is **not** managed by CDK. The -cutover script UPSERTs the record (via raw `change-resource-record-sets`); CDK -isn't aware of it. Same pattern as apex/www on the main site. +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. -## 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) -``` - -## One-time bootstrap - -The CDK bootstrap roles for the AWS account already exist (created by the -phpstan-dist repo's CDK app). You only need to deploy the OIDC roles stack -once, from a maintainer's laptop with admin AWS credentials: - -```sh -npx cdk deploy PhpstanApirefOidcRoles -``` +## GitHub repo variables -Note the `InfraDeployRoleArn` output and set the corresponding GitHub repo -variable. - -## GitHub repo variables to set (in phpstan/phpstan-src) - -After the first deploys, set these under Settings → Secrets and variables → Actions → Variables: +Set under Settings → Secrets and variables → Actions → Variables in `phpstan/phpstan-src`: | Variable | Value | Used by | |---|---|---| @@ -64,42 +47,23 @@ After the first deploys, set these under Settings → Secrets and variables → | `APIREF_BUCKET` | `phpstan-apiref-web` | `apiref.yml` | | `APIREF_DISTRIBUTION_ID` | `DistributionId` output of `PhpstanApirefWebsite` | `apiref.yml` | -## Cutover runbook (legacy → new) - -This moves `apiref.phpstan.org` from the legacy distribution `E37G1C2KWNAPBD` -to the new CDK-managed distribution. Expect ~5–10 min of intermittent 403s on -`apiref.phpstan.org` while CloudFront edges propagate the alias swap. - -**Pre-cutover (with `productionAlias: false`):** - -1. Merge the PR that adds this `apigen/infra/` directory. `apiref-infra.yml` deploys both stacks. -2. Copy bucket contents: `aws s3 sync s3://web-apiref.phpstan.org/ s3://phpstan-apiref-web/` (~334 MB / 13.5k objects). -3. Smoke-test on the new distribution's CF domain (look up `DistributionDomain` output): - ```sh - D=$(aws cloudfront get-distribution --id --query 'Distribution.DomainName' --output text) - curl -sI "https://$D/" # 301 to /2.2.x/namespace-PHPStan.html - curl -sI "https://$D/2.2.x/namespace-PHPStan.html" # 200 - curl -sI "https://$D/1.9.x" # 301 to /1.9.x/namespace-PHPStan.html - curl -sI "https://$D/" | grep -iE 'strict-transport|x-content-type|x-frame|referrer-policy|x-xss' - ``` - Verify HSTS, XCTO, XFO=SAMEORIGIN, Referrer-Policy present; no X-XSS-Protection. - -**Cutover (with `productionAlias: true`):** +## Local development -The sequence (do Route 53 first — we learned the hard way on the main site that CloudFront's `AddAlias` does a DNS sanity check): +```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) +``` -1. UPSERT Route 53 `apiref.phpstan.org` CNAME → new distribution's CF domain. -2. Wait for Route 53 INSYNC (~30–60s). -3. Detach `apiref.phpstan.org` from `E37G1C2KWNAPBD` via `aws cloudfront update-distribution`. -4. Add `apiref.phpstan.org` to the new distribution (retry every 20s if CloudFront's DNS-check cache is stale). -5. Wait for new distribution `Deployed`. -6. Smoke-test against `https://apiref.phpstan.org/`. +Changes merged to `2.2.x` under `apigen/infra/**` are deployed automatically by +`.github/workflows/apiref-infra.yml`. -Then merge the PR that flips `productionAlias: true` in `cdk.json`. The -workflow's `cdk deploy` is a no-op for the alias (already attached by the -script) and just syncs CFN state. +## Cleanup runbook (legacy resources, when stable for ~1 week) -## Cleanup runbook (when stable for ~1 week) +The cutover from the legacy distribution is done; these legacy resources can be +removed once the new stack has been stable for a sensible cooling-off period: - Delete CloudFront distribution `E37G1C2KWNAPBD` (disable, wait, delete). - Delete CloudFront Functions `apiref-phpstan-org-viewer-request` and `secure-headers-response` (the latter has no remaining users after `E37G1C2KWNAPBD` is gone). diff --git a/apigen/infra/cdk.json b/apigen/infra/cdk.json index 42cfa6b84e5..bcb3acdeb9d 100644 --- a/apigen/infra/cdk.json +++ b/apigen/infra/cdk.json @@ -14,7 +14,7 @@ ] }, "context": { - "productionAlias": false, + "productionAlias": true, "@aws-cdk/aws-lambda:recognizeLayerVersion": true, "@aws-cdk/core:checkSecretUsage": true, "@aws-cdk/core:target-partitions": ["aws"], From bebabbb4ff6acc949a2b91786985b668996af941 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Thu, 14 May 2026 10:27:14 +0200 Subject: [PATCH 081/158] Apiref infra cleanup done --- apigen/infra/README.md | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/apigen/infra/README.md b/apigen/infra/README.md index b435b25ef83..2085f4daa67 100644 --- a/apigen/infra/README.md +++ b/apigen/infra/README.md @@ -59,15 +59,3 @@ 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`. - -## Cleanup runbook (legacy resources, when stable for ~1 week) - -The cutover from the legacy distribution is done; these legacy resources can be -removed once the new stack has been stable for a sensible cooling-off period: - -- Delete CloudFront distribution `E37G1C2KWNAPBD` (disable, wait, delete). -- Delete CloudFront Functions `apiref-phpstan-org-viewer-request` and `secure-headers-response` (the latter has no remaining users after `E37G1C2KWNAPBD` is gone). -- Delete the legacy ACM cert `arn:aws:acm:us-east-1:928192134594:certificate/18f4edec-8bec-4f52-a02b-a9738053b817` once unreferenced. -- Empty and delete S3 bucket `web-apiref.phpstan.org`. -- Delete the `APIREF_AWS_ACCESS_KEY_ID` and `APIREF_AWS_SECRET_ACCESS_KEY` GitHub secrets. -- (Optional follow-up) Migrate `update-playground-api.yml` and `update-playground-runner.yml` to OIDC — they're the last workflows in this repo still using `PLAYGROUND_RUNNER_AWS_*` static keys. From 0d01465bd48540b61bdee1d56f5a4dacc3cf4acb Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Sun, 17 May 2026 10:27:33 +0200 Subject: [PATCH 082/158] Fix 2.2.x build after f2af15b571 (#5690) --- src/Type/Accessory/AccessoryDecimalIntegerStringType.php | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/Type/Accessory/AccessoryDecimalIntegerStringType.php b/src/Type/Accessory/AccessoryDecimalIntegerStringType.php index 7732aceffc6..d965eb0ebc9 100644 --- a/src/Type/Accessory/AccessoryDecimalIntegerStringType.php +++ b/src/Type/Accessory/AccessoryDecimalIntegerStringType.php @@ -456,6 +456,11 @@ 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'); From e6ed7c42d53ce7c880985fb41d73d8d2f22cb95f Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Sat, 16 May 2026 17:17:51 +0200 Subject: [PATCH 083/158] Memoize ArrayType->isList() (#5680) --- src/Type/ArrayType.php | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/src/Type/ArrayType.php b/src/Type/ArrayType.php index 4507fc62261..7e7835f966c 100644 --- a/src/Type/ArrayType.php +++ b/src/Type/ArrayType.php @@ -61,6 +61,8 @@ class ArrayType implements Type private Type $keyType; private ?Type $cachedIterableKeyType = null; + + private ?TrinaryLogic $isList = null; /** @api */ public function __construct(Type $keyType, private Type $itemType) @@ -296,15 +298,19 @@ public function isConstantArray(): TrinaryLogic public function isList(): TrinaryLogic { - if (IntegerRangeType::fromInterval(0, null)->isSuperTypeOf($this->getKeyType())->no()) { - return TrinaryLogic::createNo(); - } + if ($this->isList === null) { + if (IntegerRangeType::fromInterval(0, null)->isSuperTypeOf($this->getKeyType())->no()) { + return $this->isList = TrinaryLogic::createNo(); + } - if ($this->getKeyType()->isSuperTypeOf(new ConstantIntegerType(0))->no()) { - return TrinaryLogic::createNo(); + if ($this->getKeyType()->isSuperTypeOf(new ConstantIntegerType(0))->no()) { + return $this->isList = TrinaryLogic::createNo(); + } + + return $this->isList = TrinaryLogic::createMaybe(); } - return TrinaryLogic::createMaybe(); + return $this->isList; } public function isConstantValue(): TrinaryLogic From f0de7c2fee32f780112b000ec3768d0ffa6d70d6 Mon Sep 17 00:00:00 2001 From: Nicolai <245527909+predictor2718@users.noreply.github.com> Date: Sat, 16 May 2026 22:11:33 +0200 Subject: [PATCH 084/158] Use pre-args scope for value types in array_push/array_unshift (#5579) --- src/Analyser/ExprHandler/FuncCallHandler.php | 5 ++-- tests/PHPStan/Analyser/nsrt/bug-13510.php | 25 ++++++++++++++++++++ 2 files changed, 28 insertions(+), 2 deletions(-) create mode 100644 tests/PHPStan/Analyser/nsrt/bug-13510.php diff --git a/src/Analyser/ExprHandler/FuncCallHandler.php b/src/Analyser/ExprHandler/FuncCallHandler.php index b0877cdf270..8872cf94b87 100644 --- a/src/Analyser/ExprHandler/FuncCallHandler.php +++ b/src/Analyser/ExprHandler/FuncCallHandler.php @@ -265,6 +265,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex } } + $scopeBeforeArgs = $scope; $argsResult = $nodeScopeResolver->processArgs($stmt, $functionReflection, null, $parametersAcceptor, $normalizedExpr, $scope, $storage, $nodeCallbackForArgs, $context); $scope = $argsResult->getScope(); $hasYield = $argsResult->hasYield(); @@ -395,8 +396,8 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $stmt, $arrayArg, new NativeTypeExpr( - $this->getArrayFunctionAppendingType($functionReflection, $scope, $normalizedExpr), - $this->getArrayFunctionAppendingType($functionReflection, $scope->doNotTreatPhpDocTypesAsCertain(), $normalizedExpr), + $this->getArrayFunctionAppendingType($functionReflection, $scopeBeforeArgs, $normalizedExpr), + $this->getArrayFunctionAppendingType($functionReflection, $scopeBeforeArgs->doNotTreatPhpDocTypesAsCertain(), $normalizedExpr), ), $nodeCallback, )->getScope(); diff --git a/tests/PHPStan/Analyser/nsrt/bug-13510.php b/tests/PHPStan/Analyser/nsrt/bug-13510.php new file mode 100644 index 00000000000..86405ed71ae --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-13510.php @@ -0,0 +1,25 @@ + $arr */ + public function test(array $arr): void + { + array_unshift($arr, array_pop($arr)); + assertType('non-empty-list', $arr); + } + + /** @param non-empty-list $arr */ + public function testTwoLines(array $arr): void + { + $popped = array_pop($arr); + array_unshift($arr, $popped); + assertType('non-empty-list', $arr); + } + +} From 71f351585f5d8fbbab3d96854c4cf940d9d86075 Mon Sep 17 00:00:00 2001 From: phpstan-bot <79867460+phpstan-bot@users.noreply.github.com> Date: Sun, 17 May 2026 09:50:41 +0200 Subject: [PATCH 085/158] Add regression test for circular class constant PHPDoc type references (#5685) Co-authored-by: VincentLanglet <9052536+VincentLanglet@users.noreply.github.com> Co-authored-by: Claude Opus 4.6 --- .../Analyser/AnalyserIntegrationTest.php | 6 +++++ tests/PHPStan/Analyser/data/bug-9172.php | 26 +++++++++++++++++++ 2 files changed, 32 insertions(+) create mode 100644 tests/PHPStan/Analyser/data/bug-9172.php diff --git a/tests/PHPStan/Analyser/AnalyserIntegrationTest.php b/tests/PHPStan/Analyser/AnalyserIntegrationTest.php index 27b9f44c3de..08b8f097f3a 100644 --- a/tests/PHPStan/Analyser/AnalyserIntegrationTest.php +++ b/tests/PHPStan/Analyser/AnalyserIntegrationTest.php @@ -1578,6 +1578,12 @@ public function testBug14596(): void $this->assertNotEmpty($errors); } + public function testBug9172(): void + { + $errors = $this->runAnalyse(__DIR__ . '/data/bug-9172.php'); + $this->assertNotEmpty($errors); + } + /** * @param string[]|null $allAnalysedFiles * @return list diff --git a/tests/PHPStan/Analyser/data/bug-9172.php b/tests/PHPStan/Analyser/data/bug-9172.php new file mode 100644 index 00000000000..0118ca9438d --- /dev/null +++ b/tests/PHPStan/Analyser/data/bug-9172.php @@ -0,0 +1,26 @@ + */ + public const MIN_DEPOSIT = 1_000; + + /** @var int */ + public const MAX_DEPOSIT = 20_000; + + /** @param int $amount */ + public function deposit(int $amount): void + { + } +} + +final class CircularValues +{ + /** @var int<0, self::MAX> */ + public const MIN = self::MAX - 19_000; + + /** @var int */ + public const MAX = self::MIN + 19_000; +} From d5ea3cc1d6f1bf374a2a459ffe0b960d00f99a2f Mon Sep 17 00:00:00 2001 From: phpstan-bot <79867460+phpstan-bot@users.noreply.github.com> Date: Sun, 17 May 2026 09:54:56 +0200 Subject: [PATCH 086/158] Use before-scope for evaluating `array_splice` argument types (#5682) Co-authored-by: VincentLanglet <9052536+VincentLanglet@users.noreply.github.com> Co-authored-by: Claude Opus 4.6 --- src/Analyser/ExprHandler/FuncCallHandler.php | 21 ++++++-- tests/PHPStan/Analyser/nsrt/bug-13510.php | 51 ++++++++++++++++++++ 2 files changed, 68 insertions(+), 4 deletions(-) diff --git a/src/Analyser/ExprHandler/FuncCallHandler.php b/src/Analyser/ExprHandler/FuncCallHandler.php index 8872cf94b87..5df15e3fd91 100644 --- a/src/Analyser/ExprHandler/FuncCallHandler.php +++ b/src/Analyser/ExprHandler/FuncCallHandler.php @@ -435,9 +435,22 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $arrayArgType = $scope->getType($arrayArg); $arrayArgNativeType = $scope->getNativeType($arrayArg); - $offsetType = $scope->getType($normalizedExpr->getArgs()[1]->value); - $lengthType = isset($normalizedExpr->getArgs()[2]) ? $scope->getType($normalizedExpr->getArgs()[2]->value) : new NullType(); - $replacementType = isset($normalizedExpr->getArgs()[3]) ? $scope->getType($normalizedExpr->getArgs()[3]->value) : new ConstantArrayType([], []); + $offsetType = $scopeBeforeArgs->getType($normalizedExpr->getArgs()[1]->value); + + if (isset($normalizedExpr->getArgs()[2])) { + $lengthType = $scopeBeforeArgs->getType($normalizedExpr->getArgs()[2]->value); + } else { + $lengthType = new NullType(); + } + + if (isset($normalizedExpr->getArgs()[3])) { + $replacementArg = $normalizedExpr->getArgs()[3]->value; + $replacementType = $scopeBeforeArgs->getType($replacementArg); + $replacementNativeType = $scopeBeforeArgs->getNativeType($replacementArg); + } else { + $replacementType = new ConstantArrayType([], []); + $replacementNativeType = new ConstantArrayType([], []); + } $scope = $nodeScopeResolver->processVirtualAssign( $scope, @@ -446,7 +459,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $arrayArg, new NativeTypeExpr( $arrayArgType->spliceArray($offsetType, $lengthType, $replacementType), - $arrayArgNativeType->spliceArray($offsetType, $lengthType, $replacementType), + $arrayArgNativeType->spliceArray($offsetType, $lengthType, $replacementNativeType), ), $nodeCallback, )->getScope(); diff --git a/tests/PHPStan/Analyser/nsrt/bug-13510.php b/tests/PHPStan/Analyser/nsrt/bug-13510.php index 86405ed71ae..2ddfccfdcdb 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-13510.php +++ b/tests/PHPStan/Analyser/nsrt/bug-13510.php @@ -21,5 +21,56 @@ public function testTwoLines(array $arr): void array_unshift($arr, $popped); assertType('non-empty-list', $arr); } +} + +class Bar +{ + /** @var array */ + public array $arr = []; + + public function test(): void + { + if (count($this->arr) === 0) { + throw new \Exception(); + } + assertType('non-empty-array', $this->arr); + array_unshift($this->arr, array_pop($this->arr)); + assertType('non-empty-array', $this->arr); + } + + public function testArrayPush(): void + { + if (count($this->arr) === 0) { + throw new \Exception(); + } + array_push($this->arr, array_pop($this->arr)); + assertType('non-empty-array', $this->arr); + } + public function testArrayUnshiftWithArrayShift(): void + { + if (count($this->arr) === 0) { + throw new \Exception(); + } + array_unshift($this->arr, array_shift($this->arr)); + assertType('non-empty-array', $this->arr); + } + + public function testArrayPushWithArrayShift(): void + { + if (count($this->arr) === 0) { + throw new \Exception(); + } + array_push($this->arr, array_shift($this->arr)); + assertType('non-empty-array', $this->arr); + } + + public function testArraySplice(): void + { + if (count($this->arr) === 0) { + throw new \Exception(); + } + array_splice($this->arr, 0, 0, [array_pop($this->arr)]); + assertType('non-empty-array<(int<0, max>|string), int>', $this->arr); + } } From 9eb9b0964f352f34e116f60af1446d111fcc138c Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Sun, 17 May 2026 10:32:40 +0200 Subject: [PATCH 087/158] cs --- src/Type/ArrayType.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Type/ArrayType.php b/src/Type/ArrayType.php index 7e7835f966c..0f59c04d316 100644 --- a/src/Type/ArrayType.php +++ b/src/Type/ArrayType.php @@ -61,7 +61,7 @@ class ArrayType implements Type private Type $keyType; private ?Type $cachedIterableKeyType = null; - + private ?TrinaryLogic $isList = null; /** @api */ From 3e258a59763fef2aea30208287ee59c40247d76d Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Fri, 17 Apr 2026 18:11:13 +0200 Subject: [PATCH 088/158] Initial implementation of unsealed array shapes Array shapes like `array{a: int}` in PHPDocs are only sealed in Bleeding Edge. Without Bleeding edge, the goal is to match the current flawed behaviour as close as possible. --- src/PhpDoc/TypeNodeResolver.php | 84 +++-- src/Type/Constant/ConstantArrayType.php | 348 ++++++++++++++++-- .../Constant/ConstantArrayTypeBuilder.php | 21 +- .../Generic/TemplateConstantArrayType.php | 9 +- ...nsafe-array-string-key-casting-prevent.php | 38 ++ tests/PHPStan/Analyser/nsrt/bug-12355.php | 10 +- tests/PHPStan/Analyser/nsrt/list-shapes.php | 2 +- .../Analyser/nsrt/unsealed-array-shapes.php | 85 +++++ .../CallToFunctionParametersRuleTest.php | 11 + .../Rules/Functions/ReturnTypeRuleTest.php | 13 + .../Rules/Functions/data/bug-11494.php | 18 + .../Rules/Functions/data/bug-13565.php | 19 + .../Type/Constant/ConstantArrayTypeTest.php | 276 +++++++++++++- tests/PHPStan/Type/TypeToPhpDocNodeTest.php | 11 + 14 files changed, 876 insertions(+), 69 deletions(-) create mode 100644 tests/PHPStan/Analyser/nsrt/unsealed-array-shapes.php create mode 100644 tests/PHPStan/Rules/Functions/data/bug-11494.php create mode 100644 tests/PHPStan/Rules/Functions/data/bug-13565.php diff --git a/src/PhpDoc/TypeNodeResolver.php b/src/PhpDoc/TypeNodeResolver.php index 99e0856fe70..1b841dccec4 100644 --- a/src/PhpDoc/TypeNodeResolver.php +++ b/src/PhpDoc/TypeNodeResolver.php @@ -705,24 +705,7 @@ static function (string $variance): TemplateTypeVariance { if (count($genericTypes) === 1) { // array $arrayType = new ArrayType((new BenevolentUnionType([new IntegerType(), new StringType()]))->toArrayKey(), $genericTypes[0]); } elseif (count($genericTypes) === 2) { // array - $originalKey = $genericTypes[0]; - if ($this->reportUnsafeArrayStringKeyCasting === ReportUnsafeArrayStringKeyCastingToggle::PREVENT) { - $originalKey = TypeTraverser::map($originalKey, 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; - }); - } - $keyType = TypeCombinator::intersect($originalKey->toArrayKey(), new UnionType([ - new IntegerType(), - new StringType(), - ]))->toArrayKey(); + $keyType = $this->transformUnsafeArrayKey($genericTypes[0]); $finiteTypes = $keyType->getFiniteTypes(); if ( count($finiteTypes) === 1 @@ -1002,6 +985,28 @@ static function (string $variance): TemplateTypeVariance { return new ErrorType(); } + private function transformUnsafeArrayKey(Type $keyType): Type + { + if ($this->reportUnsafeArrayStringKeyCasting === ReportUnsafeArrayStringKeyCastingToggle::PREVENT) { + $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 = []; @@ -1101,13 +1106,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/Type/Constant/ConstantArrayType.php b/src/Type/Constant/ConstantArrayType.php index 8850f7f45a2..1d717d79d97 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; @@ -27,15 +29,19 @@ 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\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; @@ -43,6 +49,8 @@ use PHPStan\Type\NullType; 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; @@ -92,6 +100,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; @@ -108,6 +119,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,6 +127,7 @@ public function __construct( private array $nextAutoIndexes = [0], private array $optionalKeys = [], ?TrinaryLogic $isList = null, + ?array $unsealed = null, ) { assert(count($keyTypes) === count($valueTypes)); @@ -128,6 +141,44 @@ public function __construct( $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()); + } + + /** + * @return array{Type, Type}|null + */ + public function getUnsealedTypes(): ?array + { + return $this->unsealed; } /** @@ -135,16 +186,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 @@ -185,6 +238,16 @@ public function getIterableKeyType(): Type $keyType = new UnionType($this->keyTypes); } + 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 = $keyType; } @@ -194,7 +257,12 @@ 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; } public function getKeyType(): Type @@ -335,10 +403,173 @@ public function accepts(Type $type, bool $strictTypes): AcceptsResult 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; + } + + [$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->getUnsealedTypes(); + 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,13 +616,6 @@ 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; } @@ -728,7 +952,7 @@ public function getOffsetValueType(Type $offsetType): Type $matchingValueTypes[] = $this->valueTypes[$i]; } - if ($all) { + if ($all && !$this->isUnsealed()->yes()) { return $this->getIterableValueType(); } @@ -816,7 +1040,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 +1085,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 +1115,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); } /** @@ -1115,7 +1339,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) { @@ -1404,11 +1628,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 @@ -1524,6 +1753,7 @@ private function removeLastElements(int $length): self $nextAutoindexes, array_values($optionalKeys), $this->isList, + $this->unsealed, ); } @@ -1622,7 +1852,7 @@ public function generalizeValues(): self $valueTypes[] = $valueType->generalize(GeneralizePrecision::lessSpecific()); } - return $this->recreate($this->keyTypes, $valueTypes, $this->nextAutoIndexes, $this->optionalKeys, $this->isList); + return $this->recreate($this->keyTypes, $valueTypes, $this->nextAutoIndexes, $this->optionalKeys, $this->isList, $this->unsealed); } private function degradeToGeneralArray(): Type @@ -1670,7 +1900,7 @@ private function getKeysOrValuesArray(array $types): self 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(), $this->unsealed); // todo unsealed } $keyTypes = []; @@ -1699,7 +1929,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(), $this->unsealed); // todo unsealed } public function describe(VerbosityLevel $level): string @@ -1744,6 +1974,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, @@ -1864,11 +2111,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 @@ -1894,7 +2151,7 @@ public function traverseSimultaneously(Type $right, callable $cb): Type 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, $this->unsealed); } public function isKeysSupersetOf(self $otherArray): bool @@ -1951,6 +2208,8 @@ public function isKeysSupersetOf(self $otherArray): bool } } + // todo unsealed + return true; } @@ -1977,7 +2236,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); // todo unsealed } /** @@ -2033,7 +2292,7 @@ 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; @@ -2052,7 +2311,9 @@ public function makeList(): Type return new NeverType(); } - return $this->recreate($this->keyTypes, $this->valueTypes, $this->nextAutoIndexes, $this->optionalKeys, TrinaryLogic::createYes()); + // todo can't be a list if keyTypes are not subsequent integers, or if unsealed type is not int keys + + return $this->recreate($this->keyTypes, $this->valueTypes, $this->nextAutoIndexes, $this->optionalKeys, TrinaryLogic::createYes(), $this->unsealed); } public function makeListMaybe(): Type @@ -2204,6 +2465,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, diff --git a/src/Type/Constant/ConstantArrayTypeBuilder.php b/src/Type/Constant/ConstantArrayTypeBuilder.php index cd7f5aa0265..100828e52d5 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) { @@ -386,13 +401,13 @@ public function getArray(): Type { $keyTypesCount = count($this->keyTypes); if ($keyTypesCount === 0) { - return new ConstantArrayType([], []); + 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()); } 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/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 index 163a996bd25..89e1be359b1 100644 --- 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 @@ -89,3 +89,41 @@ public function doArrayCreationAndAssign(string $s): void } } + +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|non-decimal-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|non-decimal-int-string', $k); + } + } + +} 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/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/unsealed-array-shapes.php b/tests/PHPStan/Analyser/nsrt/unsealed-array-shapes.php new file mode 100644 index 00000000000..f80da767b68 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/unsealed-array-shapes.php @@ -0,0 +1,85 @@ +} $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); + } + +} diff --git a/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php b/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php index f5b3ad7b2f0..11c72db5273 100644 --- a/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php +++ b/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php @@ -2961,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: Sealed array shape does not accept array with extra key 'extra'.\n• Type #2 from the union: Sealed array shape does not accept array with extra key 'extra'." + ], + ]); + } + } 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-13565.php b/tests/PHPStan/Rules/Functions/data/bug-13565.php new file mode 100644 index 00000000000..04270b99975 --- /dev/null +++ b/tests/PHPStan/Rules/Functions/data/bug-13565.php @@ -0,0 +1,19 @@ + '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/Type/Constant/ConstantArrayTypeTest.php b/tests/PHPStan/Type/Constant/ConstantArrayTypeTest.php index 6d6c41af1cc..f061882c1e7 100644 --- a/tests/PHPStan/Type/Constant/ConstantArrayTypeTest.php +++ b/tests/PHPStan/Type/Constant/ConstantArrayTypeTest.php @@ -3,6 +3,7 @@ namespace PHPStan\Type\Constant; use Closure; +use PHPStan\DependencyInjection\BleedingEdgeToggle; use PHPStan\Testing\PHPStanTestCase; use PHPStan\TrinaryLogic; use PHPStan\Type\Accessory\HasOffsetType; @@ -13,6 +14,7 @@ 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; @@ -26,6 +28,7 @@ use PHPStan\Type\UnionType; use PHPStan\Type\VerbosityLevel; use PHPUnit\Framework\Attributes\DataProvider; +use stdClass; use function array_map; use function sprintf; @@ -409,6 +412,9 @@ public static function dataAccepts(): iterable TrinaryLogic::createMaybe(), ]; + $bleedingEdgeBackup = BleedingEdgeToggle::isBleedingEdge(); + BleedingEdgeToggle::setBleedingEdge(false); + yield [ new ConstantArrayType([], []), new ConstantArrayType([], []), @@ -420,6 +426,7 @@ public static function dataAccepts(): iterable new ConstantArrayType([], []), new ConstantArrayType([new ConstantStringType('a')], [new StringType()]), TrinaryLogic::createNo(), + [], ]; // non-empty array (with unknown sealedness) accepts extra keys @@ -433,18 +440,184 @@ public static function dataAccepts(): iterable 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.', + ], ]; + + 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) { + $this->assertSame($reasons, $actualResult->reasons, $testDescription); + } } public static function dataIsSuperTypeOf(): iterable @@ -1116,4 +1289,99 @@ public function testHasOffsetValueType( ); } + 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(); + $this->assertInstanceOf(ConstantArrayType::class, $array); + $this->assertSame(TrinaryLogic::createNo()->describe(), $array->isSealed()->describe()); + $this->assertSame(TrinaryLogic::createYes()->describe(), $array->isUnsealed()->describe()); + } 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())); + } + } diff --git a/tests/PHPStan/Type/TypeToPhpDocNodeTest.php b/tests/PHPStan/Type/TypeToPhpDocNodeTest.php index fcc6c6d0cf9..41ce52e2e4d 100644 --- a/tests/PHPStan/Type/TypeToPhpDocNodeTest.php +++ b/tests/PHPStan/Type/TypeToPhpDocNodeTest.php @@ -562,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')] From f4a2f61af0dc0766ff31231fd5a4852ead19f5b3 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Wed, 22 Apr 2026 10:03:54 +0200 Subject: [PATCH 089/158] So intersecting of constant arrays works --- tests/PHPStan/Type/TypeCombinatorTest.php | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/tests/PHPStan/Type/TypeCombinatorTest.php b/tests/PHPStan/Type/TypeCombinatorTest.php index 1652e75bbb9..90b6127222b 100644 --- a/tests/PHPStan/Type/TypeCombinatorTest.php +++ b/tests/PHPStan/Type/TypeCombinatorTest.php @@ -5119,6 +5119,27 @@ 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'}", + ]; } /** From 183febb67954ae654b3e1529063ebfeb1664c532 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Wed, 22 Apr 2026 10:11:36 +0200 Subject: [PATCH 090/158] Dedup code --- src/Type/TypeCombinator.php | 45 +++++++++++++++---------------------- 1 file changed, 18 insertions(+), 27 deletions(-) diff --git a/src/Type/TypeCombinator.php b/src/Type/TypeCombinator.php index 9744491218f..eb37cc0708c 100644 --- a/src/Type/TypeCombinator.php +++ b/src/Type/TypeCombinator.php @@ -1666,42 +1666,33 @@ public static function intersect(Type ...$types): Type continue 2; } - if ($types[$i] instanceof ConstantArrayType && ($types[$j] instanceof ArrayType || $types[$j] instanceof ConstantArrayType)) { + $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]; + $newArray = ConstantArrayTypeBuilder::createEmpty(); - $valueTypes = $types[$i]->getValueTypes(); - foreach ($types[$i]->getKeyTypes() as $k => $keyType) { - $hasOffset = $types[$j]->hasOffsetValueType($keyType); + $valueTypes = $constArray->getValueTypes(); + foreach ($constArray->getKeyTypes() as $k => $keyType) { + $hasOffset = $otherArray->hasOffsetValueType($keyType); if ($hasOffset->no()) { continue; } $newArray->setOffsetValueType( - self::intersect($keyType, $types[$j]->getIterableKeyType()), - self::intersect($valueTypes[$k], $types[$j]->getOffsetValueType($keyType)), - $types[$i]->isOptionalKey($k) && !$hasOffset->yes(), + self::intersect($keyType, $otherArray->getIterableKeyType()), + self::intersect($valueTypes[$k], $otherArray->getOffsetValueType($keyType)), + $constArray->isOptionalKey($k) && !$hasOffset->yes(), ); } - $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] = $newArray->getArray(); + array_splice($types, $j--, 1); + } else { + $types[$j] = $newArray->getArray(); + array_splice($types, $i--, 1); } - $types[$j] = $newArray->getArray(); - array_splice($types, $i--, 1); $typesCount--; continue 2; } From b2c4d834f46b7f06472cb454ad09b210a38264cd Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Wed, 22 Apr 2026 10:42:23 +0200 Subject: [PATCH 091/158] intersecting improvement --- src/Type/Constant/ConstantArrayType.php | 30 +++ src/Type/TypeCombinator.php | 154 ++++++++++- tests/PHPStan/Type/TypeCombinatorTest.php | 295 ++++++++++++++++++---- 3 files changed, 418 insertions(+), 61 deletions(-) diff --git a/src/Type/Constant/ConstantArrayType.php b/src/Type/Constant/ConstantArrayType.php index 1d717d79d97..dbc1506cf6c 100644 --- a/src/Type/Constant/ConstantArrayType.php +++ b/src/Type/Constant/ConstantArrayType.php @@ -397,6 +397,36 @@ 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) { diff --git a/src/Type/TypeCombinator.php b/src/Type/TypeCombinator.php index eb37cc0708c..e2df506d9e5 100644 --- a/src/Type/TypeCombinator.php +++ b/src/Type/TypeCombinator.php @@ -1672,25 +1672,38 @@ public static function intersect(Type ...$types): Type $constArray = $constArrayIsI ? $types[$i] : $types[$j]; $otherArray = $constArrayIsI ? $types[$j] : $types[$i]; - $newArray = ConstantArrayTypeBuilder::createEmpty(); - $valueTypes = $constArray->getValueTypes(); - foreach ($constArray->getKeyTypes() as $k => $keyType) { - $hasOffset = $otherArray->hasOffsetValueType($keyType); - if ($hasOffset->no()) { - continue; + if ( + $otherArray instanceof ConstantArrayType + && !$constArray->isUnsealed()->maybe() + && !$otherArray->isUnsealed()->maybe() + ) { + $merged = self::intersectDefiniteConstantArrays($constArray, $otherArray); + if ($merged instanceof NeverType) { + return $merged; } - $newArray->setOffsetValueType( - self::intersect($keyType, $otherArray->getIterableKeyType()), - self::intersect($valueTypes[$k], $otherArray->getOffsetValueType($keyType)), - $constArray->isOptionalKey($k) && !$hasOffset->yes(), - ); + $newArrayType = $merged; + } else { + $newArray = ConstantArrayTypeBuilder::createEmpty(); + $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(); } if ($constArrayIsI) { - $types[$i] = $newArray->getArray(); + $types[$i] = $newArrayType; array_splice($types, $j--, 1); } else { - $types[$j] = $newArray->getArray(); + $types[$j] = $newArrayType; array_splice($types, $i--, 1); } $typesCount--; @@ -1770,6 +1783,121 @@ 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; + + $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)) { + $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 { + $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/tests/PHPStan/Type/TypeCombinatorTest.php b/tests/PHPStan/Type/TypeCombinatorTest.php index 90b6127222b..9747a0d38ef 100644 --- a/tests/PHPStan/Type/TypeCombinatorTest.php +++ b/tests/PHPStan/Type/TypeCombinatorTest.php @@ -14,8 +14,11 @@ 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\PhpDocParser\Parser\ParserException; use PHPStan\Reflection\Callables\SimpleImpurePoint; use PHPStan\Testing\PHPStanTestCase; use PHPStan\TrinaryLogic; @@ -5140,10 +5143,183 @@ public static function dataIntersect(): iterable 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{...>}', + ], + ConstantArrayType::class, + 'array{...>}', + ]; + + // 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{...}', + ], + NeverType::class, + '*NEVER*=implicit', + ]; + + // 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')] @@ -5153,35 +5329,24 @@ 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)) { + $types[$i] = $typeStringResolver->resolve($type, null); } } - if (get_class($actualType) === ObjectType::class && $actualType->isEnum()->no()) { - $actualClassReflection = $actualType->getClassReflection(); - if ( - $actualClassReflection !== null - && $actualClassReflection->hasFinalByKeywordOverride() - && $actualClassReflection->isFinal() - ) { - $actualTypeDescription .= '=final'; - } - } + BleedingEdgeToggle::setBleedingEdge($bleedingEdgeBackup); - $this->assertSame($expectedTypeDescription, $actualTypeDescription); + $actualType = TypeCombinator::intersect(...$types); + $actualTypeDescription = self::describeForIntersectTest($actualType); + + $this->assertSame( + self::sortExpectedDescription($expectedTypeDescription, $typeStringResolver), + $actualTypeDescription, + ); $this->assertInstanceOf($expectedTypeClass, $actualType); } @@ -5196,35 +5361,69 @@ 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'; - } - } - 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)) { + $types[$i] = $typeStringResolver->resolve($type, null); } } - if (get_class($actualType) === ObjectType::class && $actualType->isEnum()->no()) { - $actualClassReflection = $actualType->getClassReflection(); + BleedingEdgeToggle::setBleedingEdge($bleedingEdgeBackup); + + $actualType = TypeCombinator::intersect(...array_reverse($types)); + $actualTypeDescription = self::describeForIntersectTest($actualType); + + $this->assertSame( + self::sortExpectedDescription($expectedTypeDescription, $typeStringResolver), + $actualTypeDescription, + ); + $this->assertInstanceOf($expectedTypeClass, $actualType); + } + + private static function describeForIntersectTest(Type $type): string + { + if ($type instanceof ConstantArrayType) { + $type = $type->sortKeys(); + } + $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; + } + + private static function sortExpectedDescription(string $description, TypeStringResolver $resolver): string + { + $bleedingEdgeBackup = BleedingEdgeToggle::isBleedingEdge(); + BleedingEdgeToggle::setBleedingEdge(true); + try { + $type = $resolver->resolve($description, null); + } catch (ParserException) { + return $description; + } finally { + BleedingEdgeToggle::setBleedingEdge($bleedingEdgeBackup); + } + + if ($type instanceof ConstantArrayType) { + return $type->sortKeys()->describe(VerbosityLevel::precise()); + } + + return $description; } public static function dataRemove(): array From f389a5e4b2bbb6e3ce847b8ab6d25fb6450870ed Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Wed, 22 Apr 2026 12:20:40 +0200 Subject: [PATCH 092/158] isSuperTypeOf --- src/Type/Constant/ConstantArrayType.php | 78 ++++++++++++++++- src/Type/TypeCombinator.php | 6 +- .../CallToFunctionParametersRuleTest.php | 2 +- .../Type/Constant/ConstantArrayTypeTest.php | 86 ++++++++++++++++++- tests/PHPStan/Type/TypeCombinatorTest.php | 17 ++-- 5 files changed, 175 insertions(+), 14 deletions(-) diff --git a/src/Type/Constant/ConstantArrayType.php b/src/Type/Constant/ConstantArrayType.php index dbc1506cf6c..16156f38a60 100644 --- a/src/Type/Constant/ConstantArrayType.php +++ b/src/Type/Constant/ConstantArrayType.php @@ -58,6 +58,7 @@ 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; @@ -80,6 +81,7 @@ use function str_contains; use function strtolower; use function strtoupper; +use function usort; use const CASE_LOWER; use const CASE_UPPER; @@ -652,13 +654,29 @@ private function checkOurKeys(Type $type, bool $strictTypes): AcceptsResult public function isSuperTypeOf(Type $type): IsSuperTypeOfResult { if ($type instanceof self) { + $thisUnsealedness = $this->isUnsealed(); + $typeUnsealedness = $type->isUnsealed(); + $bothDefinite = !$thisUnsealedness->maybe() && !$typeUnsealedness->maybe(); + 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->getUnsealedTypes(); + if (!$typeUnsealedKey->isSuperTypeOf($keyType)->no()) { + $hasOffset = TrinaryLogic::createMaybe(); + } + } if ($hasOffset->no()) { if (!$this->isOptionalKey($i)) { return IsSuperTypeOfResult::createNo(); @@ -670,13 +688,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->getUnsealedTypes(); + $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->getUnsealedTypes(); + $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->getUnsealedTypes(); + [$typeUnsealedKey, $typeUnsealedValue] = $type->getUnsealedTypes(); + $results[] = $thisUnsealedKey->isSuperTypeOf($typeUnsealedKey); + $results[] = $thisUnsealedValue->isSuperTypeOf($typeUnsealedValue); + } + } + } + return IsSuperTypeOfResult::createYes()->and(...$results); } diff --git a/src/Type/TypeCombinator.php b/src/Type/TypeCombinator.php index e2df506d9e5..0019f2462ee 100644 --- a/src/Type/TypeCombinator.php +++ b/src/Type/TypeCombinator.php @@ -1850,9 +1850,11 @@ private static function intersectDefiniteConstantArrays(ConstantArrayType $a, Co $keysToProcess[$keyValue] = [$k, $bKeyByValue[$keyValue] ?? null]; } foreach ($bKeyByValue as $keyValue => $k) { - if (!array_key_exists($keyValue, $keysToProcess)) { - $keysToProcess[$keyValue] = [null, $k]; + if (array_key_exists($keyValue, $keysToProcess)) { + continue; } + + $keysToProcess[$keyValue] = [null, $k]; } foreach ($keysToProcess as [$aIdx, $bIdx]) { diff --git a/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php b/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php index 11c72db5273..40ae1db11cc 100644 --- a/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php +++ b/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php @@ -2967,7 +2967,7 @@ public function testBug11494(): void [ '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: Sealed array shape does not accept array with extra key 'extra'.\n• Type #2 from the union: Sealed array shape does not accept array with extra key 'extra'." + "• Type #1 from the union: Sealed array shape does not accept array with extra key 'extra'.\n• Type #2 from the union: Sealed array shape does not accept array with extra key 'extra'.", ], ]); } diff --git a/tests/PHPStan/Type/Constant/ConstantArrayTypeTest.php b/tests/PHPStan/Type/Constant/ConstantArrayTypeTest.php index f061882c1e7..dab3e8bf4bb 100644 --- a/tests/PHPStan/Type/Constant/ConstantArrayTypeTest.php +++ b/tests/PHPStan/Type/Constant/ConstantArrayTypeTest.php @@ -4,6 +4,7 @@ use Closure; use PHPStan\DependencyInjection\BleedingEdgeToggle; +use PHPStan\PhpDoc\TypeStringResolver; use PHPStan\Testing\PHPStanTestCase; use PHPStan\TrinaryLogic; use PHPStan\Type\Accessory\HasOffsetType; @@ -30,6 +31,7 @@ use PHPUnit\Framework\Attributes\DataProvider; use stdClass; use function array_map; +use function is_string; use function sprintf; class ConstantArrayTypeTest extends PHPStanTestCase @@ -615,9 +617,11 @@ public function testAccepts(Type $type, Type $otherType, TrinaryLogic $expectedR $actualResult->result->describe(), $testDescription, ); - if ($reasons !== null) { - $this->assertSame($reasons, $actualResult->reasons, $testDescription); + if ($reasons === null) { + return; } + + $this->assertSame($reasons, $actualResult->reasons, $testDescription); } public static function dataIsSuperTypeOf(): iterable @@ -909,11 +913,87 @@ public static function dataIsSuperTypeOf(): iterable ]), 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)) { + $resolved = $resolver->resolve($type, null); + $this->assertInstanceOf(ConstantArrayType::class, $resolved); + $type = $resolved; + } + if (is_string($otherType)) { + $otherType = $resolver->resolve($otherType, null); + } + } finally { + BleedingEdgeToggle::setBleedingEdge($bleedingEdgeBackup); + } + $actualResult = $type->isSuperTypeOf($otherType); $this->assertSame( $expectedResult->describe(), diff --git a/tests/PHPStan/Type/TypeCombinatorTest.php b/tests/PHPStan/Type/TypeCombinatorTest.php index 9747a0d38ef..a1c2f1a1ff3 100644 --- a/tests/PHPStan/Type/TypeCombinatorTest.php +++ b/tests/PHPStan/Type/TypeCombinatorTest.php @@ -67,6 +67,7 @@ use function array_reverse; use function get_class; use function implode; +use function is_string; use function sprintf; use const PHP_VERSION_ID; @@ -5130,7 +5131,7 @@ public static function dataIntersect(): iterable [new IntegerType(), new UnionType([ new ConstantStringType('0'), new ConstantStringType('foo'), - ])] + ])], ), new ConstantArrayType( [new ConstantIntegerType(0), new ConstantIntegerType(1)], @@ -5333,9 +5334,11 @@ public function testIntersect( $typeStringResolver = self::getContainer()->getByType(TypeStringResolver::class); foreach ($types as $i => $type) { BleedingEdgeToggle::setBleedingEdge(true); - if (is_string($type)) { - $types[$i] = $typeStringResolver->resolve($type, null); + if (!is_string($type)) { + continue; } + + $types[$i] = $typeStringResolver->resolve($type, null); } BleedingEdgeToggle::setBleedingEdge($bleedingEdgeBackup); @@ -5351,7 +5354,7 @@ public function testIntersect( } /** - * @param list $types + * @param list|list $types * @param class-string $expectedTypeClass */ #[DataProvider('dataIntersect')] @@ -5365,9 +5368,11 @@ public function testIntersectInversed( $typeStringResolver = self::getContainer()->getByType(TypeStringResolver::class); foreach ($types as $i => $type) { BleedingEdgeToggle::setBleedingEdge(true); - if (is_string($type)) { - $types[$i] = $typeStringResolver->resolve($type, null); + if (!is_string($type)) { + continue; } + + $types[$i] = $typeStringResolver->resolve($type, null); } BleedingEdgeToggle::setBleedingEdge($bleedingEdgeBackup); From bb4ea57bf40def7f85d86663f535ee9c8be57de9 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Wed, 22 Apr 2026 18:12:53 +0200 Subject: [PATCH 093/158] union improvement --- src/Type/Constant/ConstantArrayType.php | 223 +++++++++- .../Constant/ConstantArrayTypeBuilder.php | 7 + src/Type/TypeCombinator.php | 72 +++- .../Analyser/nsrt/array-append-count.php | 32 ++ tests/PHPStan/Analyser/nsrt/bug-14314.php | 2 +- tests/PHPStan/Analyser/nsrt/bug-5584.php | 2 +- tests/PHPStan/Analyser/nsrt/bug-9985.php | 2 +- .../nsrt/conditional-array-key-exists.php | 25 ++ .../nsrt/generalize-scope-recursive.php | 2 +- .../Analyser/nsrt/has-offset-type-bug.php | 6 +- tests/PHPStan/Analyser/nsrt/list-count.php | 8 +- .../Analyser/nsrt/narrow-tagged-union.php | 2 +- .../Analyser/nsrt/unsealed-array-shapes.php | 2 +- .../Rules/Comparison/data/bug-7898.php | 2 +- .../Constant/ConstantArrayTypeBuilderTest.php | 43 ++ .../Type/Constant/ConstantArrayTypeTest.php | 11 +- tests/PHPStan/Type/TypeCombinatorTest.php | 389 ++++++++++++++---- 17 files changed, 718 insertions(+), 112 deletions(-) create mode 100644 tests/PHPStan/Analyser/nsrt/array-append-count.php create mode 100644 tests/PHPStan/Analyser/nsrt/conditional-array-key-exists.php diff --git a/src/Type/Constant/ConstantArrayType.php b/src/Type/Constant/ConstantArrayType.php index 16156f38a60..01adc9a3b4a 100644 --- a/src/Type/Constant/ConstantArrayType.php +++ b/src/Type/Constant/ConstantArrayType.php @@ -136,7 +136,18 @@ public function __construct( $keyTypesCount = count($this->keyTypes); if ($keyTypesCount === 0) { - $isList = TrinaryLogic::createYes(); + 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(); + } + } } if ($isList === null) { @@ -554,7 +565,7 @@ public function accepts(Type $type, bool $strictTypes): AcceptsResult $result = $result->and($acceptsValue); } - $otherUnsealed = $constantArray->getUnsealedTypes(); + $otherUnsealed = $constantArray->unsealed; if ($otherUnsealed !== null && !$constantArray->isUnsealed()->no()) { [$otherUnsealedKeyType, $otherUnsealedValueType] = $otherUnsealed; @@ -656,7 +667,7 @@ public function isSuperTypeOf(Type $type): IsSuperTypeOfResult if ($type instanceof self) { $thisUnsealedness = $this->isUnsealed(); $typeUnsealedness = $type->isUnsealed(); - $bothDefinite = !$thisUnsealedness->maybe() && !$typeUnsealedness->maybe(); + $bothDefinite = $this->unsealed !== null && $type->unsealed !== null; if (count($this->keyTypes) === 0) { if (!$bothDefinite) { @@ -672,7 +683,7 @@ public function isSuperTypeOf(Type $type): IsSuperTypeOfResult foreach ($this->keyTypes as $i => $keyType) { $hasOffset = $type->hasOffsetValueType($keyType); if ($bothDefinite && $hasOffset->no() && $typeUnsealedness->yes()) { - [$typeUnsealedKey] = $type->getUnsealedTypes(); + [$typeUnsealedKey] = $type->unsealed; if (!$typeUnsealedKey->isSuperTypeOf($keyType)->no()) { $hasOffset = TrinaryLogic::createMaybe(); } @@ -690,7 +701,7 @@ public function isSuperTypeOf(Type $type): IsSuperTypeOfResult $otherValueType = $type->getOffsetValueType($keyType); if ($otherValueType instanceof ErrorType && $bothDefinite && $typeUnsealedness->yes()) { - [, $typeUnsealedValue] = $type->getUnsealedTypes(); + [, $typeUnsealedValue] = $type->unsealed; $otherValueType = $typeUnsealedValue; } $isValueSuperType = $this->valueTypes[$i]->isSuperTypeOf($otherValueType); @@ -719,7 +730,7 @@ public function isSuperTypeOf(Type $type): IsSuperTypeOfResult continue; } - [$thisUnsealedKey, $thisUnsealedValue] = $this->getUnsealedTypes(); + [$thisUnsealedKey, $thisUnsealedValue] = $this->unsealed; $keyCheck = $thisUnsealedKey->isSuperTypeOf($typeKey); if ($keyCheck->no()) { if ($type->isOptionalKey($i)) { @@ -743,8 +754,8 @@ public function isSuperTypeOf(Type $type): IsSuperTypeOfResult if ($thisUnsealedness->no()) { $results[] = IsSuperTypeOfResult::createMaybe(); } else { - [$thisUnsealedKey, $thisUnsealedValue] = $this->getUnsealedTypes(); - [$typeUnsealedKey, $typeUnsealedValue] = $type->getUnsealedTypes(); + [$thisUnsealedKey, $thisUnsealedValue] = $this->unsealed; + [$typeUnsealedKey, $typeUnsealedValue] = $type->unsealed; $results[] = $thisUnsealedKey->isSuperTypeOf($typeUnsealedKey); $results[] = $thisUnsealedValue->isSuperTypeOf($typeUnsealedValue); } @@ -1717,7 +1728,14 @@ public function isIterableAtLeastOnce(): TrinaryLogic { $keysCount = count($this->keyTypes); if ($keysCount === 0) { - return TrinaryLogic::createNo(); + if ($this->unsealed === null) { + return TrinaryLogic::createNo(); + } + [$unsealedKey] = $this->unsealed; + if ($unsealedKey instanceof NeverType && $unsealedKey->isExplicit()) { + return TrinaryLogic::createNo(); + } + return TrinaryLogic::createMaybe(); } $optionalKeysCount = count($this->optionalKeys); @@ -2259,6 +2277,85 @@ public function traverseSimultaneously(Type $right, callable $cb): Type } 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 = !($thisUnsealedKey instanceof NeverType && $thisUnsealedKey->isExplicit()); + $otherHasExtras = !($otherUnsealedKey instanceof NeverType && $otherUnsealedKey->isExplicit()); + + $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); @@ -2312,14 +2409,116 @@ public function isKeysSupersetOf(self $otherArray): bool } } - // todo unsealed - return true; } 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 (Type $sideUnsealedKey, Type $sideUnsealedValue, Type $keyType, Type $valueType): bool { + if ($sideUnsealedKey instanceof NeverType && $sideUnsealedKey->isExplicit()) { + return false; + } + 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($otherUnsealedKey, $otherUnsealedValue, $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($thisUnsealedKey, $thisUnsealedValue, $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) { @@ -2340,7 +2539,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), $this->unsealed); // todo unsealed + return $this->recreate($this->keyTypes, $valueTypes, $nextAutoIndexes, $optionalKeys, $this->isList->and($otherArray->isList), $this->unsealed); } /** diff --git a/src/Type/Constant/ConstantArrayTypeBuilder.php b/src/Type/Constant/ConstantArrayTypeBuilder.php index 100828e52d5..d3c69eb3a62 100644 --- a/src/Type/Constant/ConstantArrayTypeBuilder.php +++ b/src/Type/Constant/ConstantArrayTypeBuilder.php @@ -401,6 +401,13 @@ public function getArray(): Type { $keyTypesCount = count($this->keyTypes); if ($keyTypesCount === 0) { + if ($this->unsealed !== null) { + [$unsealedKey, $unsealedValue] = $this->unsealed; + $isExplicitNever = $unsealedKey instanceof NeverType && $unsealedKey->isExplicit(); + if (!$isExplicitNever) { + return new ArrayType($unsealedKey, $unsealedValue); + } + } return new ConstantArrayType([], [], unsealed: $this->unsealed); } diff --git a/src/Type/TypeCombinator.php b/src/Type/TypeCombinator.php index 0019f2462ee..869811f517a 100644 --- a/src/Type/TypeCombinator.php +++ b/src/Type/TypeCombinator.php @@ -31,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; @@ -919,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(); @@ -1232,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 = []; @@ -1317,6 +1325,61 @@ private static function reduceArrays(array $constantArrays, bool $preserveTagged } } + // Second pass: for arrays with definite sealedness, try to merge pairs that + // don't share any known key (the eligibleCombinations loop above only considers + // shared-key pairs). + $indices = array_keys($arraysToProcess); + $indicesCount = count($indices); + 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 ($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 @@ -1363,6 +1426,7 @@ private static function reduceArrays(array $constantArrays, bool $preserveTagged } } + return array_merge($newArrays, $arraysToProcess); } @@ -1594,6 +1658,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); @@ -1606,6 +1671,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); @@ -1787,7 +1853,7 @@ private static function intersectDefiniteConstantArrays(ConstantArrayType $a, Co { $aSealed = $a->isUnsealed()->no(); $bSealed = $b->isUnsealed()->no(); - $bothUnsealed = !$aSealed && !$bSealed; + $bothUnsealed = !$aSealed && !$bSealed && $a->getUnsealedTypes() !== null && $b->getUnsealedTypes() !== null; $aKeyByValue = []; foreach ($a->getKeyTypes() as $k => $keyType) { 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/bug-14314.php b/tests/PHPStan/Analyser/nsrt/bug-14314.php index ed6b323a051..ea451aa59e8 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-14314.php +++ b/tests/PHPStan/Analyser/nsrt/bug-14314.php @@ -79,7 +79,7 @@ public function testIntRangeWithUnionAndEmpty(array $arr, int $twoToFour): void assertType('array{string, string, string, string}', $arr); return; } - assertType('array{}|array{string, string, string, string}|array{string}', $arr); + assertType('array{}|array{string}', $arr); } } 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/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/generalize-scope-recursive.php b/tests/PHPStan/Analyser/nsrt/generalize-scope-recursive.php index d4a82c8dcb4..8d13c5526fe 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{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/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/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 index f80da767b68..0fa24cd435d 100644 --- a/tests/PHPStan/Analyser/nsrt/unsealed-array-shapes.php +++ b/tests/PHPStan/Analyser/nsrt/unsealed-array-shapes.php @@ -77,7 +77,7 @@ public function wrongKeyButResolvedToIntString(array $a): void */ public function edgeCases(array $a, array $b, array $c): void { - assertType('array{...}', $a); + assertType('array', $a); assertType('array{a: int, b?: string, c?: string}', $b); assertType('array{a: int, b: float|string, c?: string}', $c); } 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/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 dab3e8bf4bb..933d268cc4b 100644 --- a/tests/PHPStan/Type/Constant/ConstantArrayTypeTest.php +++ b/tests/PHPStan/Type/Constant/ConstantArrayTypeTest.php @@ -983,9 +983,7 @@ public function testIsSuperTypeOf($type, $otherType, TrinaryLogic $expectedResul try { $resolver = self::getContainer()->getByType(TypeStringResolver::class); if (is_string($type)) { - $resolved = $resolver->resolve($type, null); - $this->assertInstanceOf(ConstantArrayType::class, $resolved); - $type = $resolved; + $type = $resolver->resolve($type, null); } if (is_string($otherType)) { $otherType = $resolver->resolve($otherType, null); @@ -1392,9 +1390,10 @@ public function testSealedness(): void $builder = ConstantArrayTypeBuilder::createEmpty(); $builder->makeUnsealed(new IntegerType(), new StringType()); $array = $builder->getArray(); - $this->assertInstanceOf(ConstantArrayType::class, $array); - $this->assertSame(TrinaryLogic::createNo()->describe(), $array->isSealed()->describe()); - $this->assertSame(TrinaryLogic::createYes()->describe(), $array->isUnsealed()->describe()); + // 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); } diff --git a/tests/PHPStan/Type/TypeCombinatorTest.php b/tests/PHPStan/Type/TypeCombinatorTest.php index a1c2f1a1ff3..834c79975d5 100644 --- a/tests/PHPStan/Type/TypeCombinatorTest.php +++ b/tests/PHPStan/Type/TypeCombinatorTest.php @@ -18,7 +18,6 @@ use PHPStan\Fixture\FinalClass; use PHPStan\Generics\FunctionsAssertType\C; use PHPStan\PhpDoc\TypeStringResolver; -use PHPStan\PhpDocParser\Parser\ParserException; use PHPStan\Reflection\Callables\SimpleImpurePoint; use PHPStan\Testing\PHPStanTestCase; use PHPStan\TrinaryLogic; @@ -735,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}', ], [ [ @@ -753,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}', ], [ [ @@ -775,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}', ], [ [ @@ -2658,7 +2657,7 @@ public static function dataUnion(): iterable new NonAcceptingNeverType(), ], NeverType::class, - 'never', + 'never=explicit', ]; yield [ [ @@ -2932,10 +2931,260 @@ public static function dataUnion(): iterable 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')] @@ -2945,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, @@ -2991,7 +3233,7 @@ public function testUnion( } /** - * @param list $types + * @param list|list $types * @param class-string $expectedTypeClass */ #[DataProvider('dataUnion')] @@ -3002,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, @@ -5244,8 +5481,17 @@ public static function dataIntersect(): iterable 'array{...>}', 'array{...>}', ], + ArrayType::class, + 'array>', + ]; + + yield [ + [ + 'array{a: int, ...>}', + 'array{a: int, ...>}', + ], ConstantArrayType::class, - 'array{...>}', + 'array{a: int, ...>}', ]; // both unsealed, unsealed key types incompatible — no valid key overlap @@ -5274,8 +5520,8 @@ public static function dataIntersect(): iterable 'array{a: int, ...}', 'array{...}', ], - NeverType::class, - '*NEVER*=implicit', + ConstantArrayType::class, + 'array{a: *NEVER*}', ]; // both unsealed: known key value is compatible with other side's unsealed value @@ -5285,7 +5531,7 @@ public static function dataIntersect(): iterable 'array{...}', ], ConstantArrayType::class, - 'array{a: non-empty-string, ...}', + 'array{a: non-empty-string}', ]; // both unsealed with same known key, value types incompatible at that key @@ -5344,11 +5590,13 @@ public function testIntersect( BleedingEdgeToggle::setBleedingEdge($bleedingEdgeBackup); $actualType = TypeCombinator::intersect(...$types); - $actualTypeDescription = self::describeForIntersectTest($actualType); - $this->assertSame( - self::sortExpectedDescription($expectedTypeDescription, $typeStringResolver), - $actualTypeDescription, + $expectedTypeDescription, + self::describeForIntersectTest($actualType), + sprintf('intersect(%s)', implode(', ', array_map( + static fn (Type $type): string => $type->describe(VerbosityLevel::precise()), + $types, + ))), ); $this->assertInstanceOf($expectedTypeClass, $actualType); } @@ -5378,20 +5626,26 @@ public function testIntersectInversed( BleedingEdgeToggle::setBleedingEdge($bleedingEdgeBackup); $actualType = TypeCombinator::intersect(...array_reverse($types)); - $actualTypeDescription = self::describeForIntersectTest($actualType); - $this->assertSame( - self::sortExpectedDescription($expectedTypeDescription, $typeStringResolver), - $actualTypeDescription, + $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 { - if ($type instanceof ConstantArrayType) { - $type = $type->sortKeys(); - } + $type = TypeTraverser::map($type, static function (Type $type, callable $traverse): Type { + if ($type instanceof ConstantArrayType) { + return $traverse($type->sortKeys()); + } + + return $traverse($type); + }); $description = $type->describe(VerbosityLevel::precise()); if ($type instanceof MixedType) { $description .= $type->isExplicitMixed() ? '=explicit' : '=implicit'; @@ -5412,25 +5666,6 @@ private static function describeForIntersectTest(Type $type): string return $description; } - private static function sortExpectedDescription(string $description, TypeStringResolver $resolver): string - { - $bleedingEdgeBackup = BleedingEdgeToggle::isBleedingEdge(); - BleedingEdgeToggle::setBleedingEdge(true); - try { - $type = $resolver->resolve($description, null); - } catch (ParserException) { - return $description; - } finally { - BleedingEdgeToggle::setBleedingEdge($bleedingEdgeBackup); - } - - if ($type instanceof ConstantArrayType) { - return $type->sortKeys()->describe(VerbosityLevel::precise()); - } - - return $description; - } - public static function dataRemove(): array { return [ From a394713023bccb14cf5ecbfa0dd9ee213061a93c Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Thu, 23 Apr 2026 12:54:48 +0200 Subject: [PATCH 094/158] Regression tests --- tests/PHPStan/Analyser/nsrt/bug-14032.php | 47 ++ .../Rules/Methods/ReturnTypeRuleTest.php | 6 + .../PHPStan/Rules/Methods/data/bug-12110.php | 670 ++++++++++++++++++ 3 files changed, 723 insertions(+) create mode 100644 tests/PHPStan/Analyser/nsrt/bug-14032.php create mode 100644 tests/PHPStan/Rules/Methods/data/bug-12110.php 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 @@ +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; + } +} From e22e4200ae38bb9643a8f5e8353f8719b62c7179 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Thu, 23 Apr 2026 14:38:29 +0200 Subject: [PATCH 095/158] Optimization --- phpstan-baseline.neon | 8 +- src/Analyser/Ignore/IgnoredError.php | 5 +- src/Analyser/Ignore/IgnoredErrorHelper.php | 18 +++- .../Ignore/IgnoredErrorHelperResult.php | 91 ++++++++++++++----- src/Node/AnonymousClassNode.php | 2 +- src/Type/Constant/ConstantArrayType.php | 1 + src/Type/FileTypeMapper.php | 4 +- src/Type/TypeCombinator.php | 84 ++++++++++++----- .../Analyser/Ignore/IgnoreLexerTest.php | 2 +- .../BaselineNeonErrorFormatterTest.php | 2 +- 10 files changed, 155 insertions(+), 62 deletions(-) diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 4d07589b5bb..eb573b5d723 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 @@ -1722,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: 20 path: src/Type/TypeCombinator.php - 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/Node/AnonymousClassNode.php b/src/Node/AnonymousClassNode.php index afed122f56c..0a60ed358b0 100644 --- a/src/Node/AnonymousClassNode.php +++ b/src/Node/AnonymousClassNode.php @@ -14,7 +14,7 @@ final class AnonymousClassNode extends Class_ public static function createFromClassNode(Class_ $node): self { $subNodes = []; - foreach ($node->getSubNodeNames() as $subNodeName) { + foreach (['attrGroups', 'flags', 'extends', 'implements', 'stmts'] as $subNodeName) { $subNodes[$subNodeName] = $node->$subNodeName; } diff --git a/src/Type/Constant/ConstantArrayType.php b/src/Type/Constant/ConstantArrayType.php index 01adc9a3b4a..886e374bf66 100644 --- a/src/Type/Constant/ConstantArrayType.php +++ b/src/Type/Constant/ConstantArrayType.php @@ -187,6 +187,7 @@ public function isUnsealed(): TrinaryLogic } /** + * @phpstan-pure * @return array{Type, Type}|null */ public function getUnsealedTypes(): ?array 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/TypeCombinator.php b/src/Type/TypeCombinator.php index 869811f517a..568249dbf2c 100644 --- a/src/Type/TypeCombinator.php +++ b/src/Type/TypeCombinator.php @@ -1325,38 +1325,77 @@ private static function reduceArrays(array $constantArrays, bool $preserveTagged } } - // Second pass: for arrays with definite sealedness, try to merge pairs that - // don't share any known key (the eligibleCombinations loop above only considers - // shared-key pairs). + // 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); - 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)) { + if ($indicesCount > 1) { + $candidateFlags = []; + foreach ($indices as $idx) { + $arr = $arraysToProcess[$idx]; + $unsealed = $arr->getUnsealedTypes(); + if ($unsealed === null) { + $candidateFlags[$idx] = false; continue; } - if ($arraysToProcess[$j]->getUnsealedTypes() === null) { + [$unsealedKey] = $unsealed; + $hasRealExtras = !($unsealedKey instanceof NeverType && $unsealedKey->isExplicit()); + if ($hasRealExtras) { + $candidateFlags[$idx] = true; continue; } - if ($arraysToProcess[$j]->isKeysSupersetOf($arraysToProcess[$i])) { - $arraysToProcess[$j] = $arraysToProcess[$j]->mergeWith($arraysToProcess[$i]); - unset($arraysToProcess[$i]); - continue 2; + $keyTypesCount = count($arr->getKeyTypes()); + if ($keyTypesCount === 0) { + $candidateFlags[$idx] = true; + continue; } - if (!$arraysToProcess[$i]->isKeysSupersetOf($arraysToProcess[$j])) { + $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]); + $arraysToProcess[$i] = $arraysToProcess[$i]->mergeWith($arraysToProcess[$j]); + unset($arraysToProcess[$j]); + } } } @@ -1941,6 +1980,7 @@ private static function intersectDefiniteConstantArrays(ConstantArrayType $a, Co $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); 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/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 { From f2ddada38ae1568156a4cc1edad556d8c50d69b7 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Mon, 27 Apr 2026 10:54:48 +0200 Subject: [PATCH 096/158] Remove unrelated tip --- src/Type/Constant/ConstantArrayType.php | 4 +++ tests/PHPStan/Analyser/data/bug-7963.php | 2 +- tests/PHPStan/Analyser/nsrt/bug-14489.php | 2 +- .../CallToFunctionParametersRuleTest.php | 4 +-- .../Rules/Functions/data/bug-11518.php | 2 +- .../Rules/Functions/data/bug-11533.php | 2 +- .../PHPStan/Rules/Functions/data/bug-2911.php | 2 +- .../PHPStan/Rules/Functions/data/bug-3931.php | 6 ++-- .../PHPStan/Rules/Functions/data/bug-7156.php | 2 +- .../Rules/Methods/CallMethodsRuleTest.php | 33 ++++++++++++++++++- .../Methods/CallStaticMethodsRuleTest.php | 2 ++ .../Rules/Methods/MethodSignatureRuleTest.php | 8 +++++ .../Rules/Methods/ReturnTypeRuleTest.php | 4 +-- tests/PHPStan/Rules/Methods/data/bug-5232.php | 2 +- tests/PHPStan/Rules/Methods/data/bug-5258.php | 20 +++++++++++ tests/PHPStan/Rules/Methods/data/bug-6552.php | 2 +- .../Rules/Methods/data/bug-8146b-errors.php | 2 +- .../Rules/Methods/data/method-signature.php | 24 ++++++++++++++ .../Type/Constant/ConstantArrayTypeTest.php | 10 ++++++ 19 files changed, 116 insertions(+), 17 deletions(-) diff --git a/src/Type/Constant/ConstantArrayType.php b/src/Type/Constant/ConstantArrayType.php index 886e374bf66..27988d3f119 100644 --- a/src/Type/Constant/ConstantArrayType.php +++ b/src/Type/Constant/ConstantArrayType.php @@ -465,6 +465,10 @@ public function accepts(Type $type, bool $strictTypes): AcceptsResult return $result; } + if ($result->no()) { + return $result; + } + [$unsealedKeyType, $unsealedValueType] = $this->unsealed; if ($isUnsealed->no()) { diff --git a/tests/PHPStan/Analyser/data/bug-7963.php b/tests/PHPStan/Analyser/data/bug-7963.php index ac7d433943b..c2d278bc7e5 100644 --- a/tests/PHPStan/Analyser/data/bug-7963.php +++ b/tests/PHPStan/Analyser/data/bug-7963.php @@ -31,7 +31,7 @@ interface FieldDescriptionInterface class HelloWorld { /** - * @phpstan-return array}> + * @phpstan-return array, ...}> */ public function getRenderViewElementTests(): array { diff --git a/tests/PHPStan/Analyser/nsrt/bug-14489.php b/tests/PHPStan/Analyser/nsrt/bug-14489.php index f1471e754a7..cab77ee02ae 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-14489.php +++ b/tests/PHPStan/Analyser/nsrt/bug-14489.php @@ -42,7 +42,7 @@ function () { $cData[$c] = $ids; } } - assertType('array{}|array{c1?: array{1}|array{4}, c2?: array{1}|array{4}}', $cData); + assertType('array{c1?: array{1}|array{4}, c2?: array{1}|array{4}}', $cData); }; /** diff --git a/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php b/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php index 40ae1db11cc..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, ], ]); @@ -2967,7 +2967,7 @@ public function testBug11494(): void [ '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: Sealed array shape does not accept array with extra key 'extra'.\n• Type #2 from the union: Sealed array shape does not accept array with extra key 'extra'.", + "• 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/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-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..ec98f36e845 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("T of array (function Bug3931\addSomeKey(), argument)&hasOffsetValue('mykey', int)&non-empty-array", $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/Methods/CallMethodsRuleTest.php b/tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php index e496bad3976..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 diff --git a/tests/PHPStan/Rules/Methods/CallStaticMethodsRuleTest.php b/tests/PHPStan/Rules/Methods/CallStaticMethodsRuleTest.php index 36c036efecf..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.', ], ]); } 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/ReturnTypeRuleTest.php b/tests/PHPStan/Rules/Methods/ReturnTypeRuleTest.php index 70c6529be67..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.", ], ]); } 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-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/method-signature.php b/tests/PHPStan/Rules/Methods/data/method-signature.php index c9170738825..80a905ff3ce 100644 --- a/tests/PHPStan/Rules/Methods/data/method-signature.php +++ b/tests/PHPStan/Rules/Methods/data/method-signature.php @@ -481,3 +481,27 @@ public function foobar(): array ]; } } + +interface ConstantArrayInterfaceUnsealed +{ + + /** + * @return array{foo: string, ...} + */ + public function foobar(): array; + +} + +class ConstantArrayClass2 implements ConstantArrayInterfaceUnsealed +{ + /** + * @return array{foo: string, bar: string} + */ + public function foobar(): array + { + return [ + 'foo' => '', + 'bar' => '', + ]; + } +} diff --git a/tests/PHPStan/Type/Constant/ConstantArrayTypeTest.php b/tests/PHPStan/Type/Constant/ConstantArrayTypeTest.php index 933d268cc4b..28167c294c9 100644 --- a/tests/PHPStan/Type/Constant/ConstantArrayTypeTest.php +++ b/tests/PHPStan/Type/Constant/ConstantArrayTypeTest.php @@ -601,6 +601,16 @@ public static function dataAccepts(): iterable ], ]; + yield [ + new ConstantArrayType([new ConstantStringType('a')], [new StringType()]), + new UnionType([ + new ConstantArrayType([new ConstantStringType('a')], [new StringType()]), + new StringType(), + ]), + TrinaryLogic::createMaybe(), + [], + ]; + BleedingEdgeToggle::setBleedingEdge($bleedingEdgeBackup); } From d2a82f6acff08227c40852d049f5b284d6c17648 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Mon, 27 Apr 2026 12:53:11 +0200 Subject: [PATCH 097/158] Preserve unsealed extras when intersecting array{...} with another array MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `TypeCombinator::intersect` rebuilds the constant-array side from scratch via `ConstantArrayTypeBuilder::createEmpty()` whenever the other side is a non-constant `ArrayType` (or when the maybe-unsealed branch fires). The fresh builder is sealed, so `array{k: int, ...} & array<…>` silently collapsed to a sealed `array{k: int}` — losing the openness the user explicitly wrote in the source shape. When the source `ConstantArrayType` is unsealed, copy its unsealed extras onto the new builder, intersecting key/value with the other side's iterable key/value so the open part keeps both sides' refinements. If either side of the intersected extras becomes `never`, leave the new shape sealed. Update the bug-3931 fixture and two `TypeCombinatorTest` data sets to reflect the now-preserved unsealed marker on the result. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/Type/TypeCombinator.php | 13 +++++++++++++ tests/PHPStan/Analyser/data/bug-13978.php | 3 +++ tests/PHPStan/Analyser/data/bug-7963.php | 2 +- tests/PHPStan/Rules/Functions/data/bug-3931.php | 2 +- tests/PHPStan/Type/TypeCombinatorTest.php | 4 ++-- 5 files changed, 20 insertions(+), 4 deletions(-) diff --git a/src/Type/TypeCombinator.php b/src/Type/TypeCombinator.php index 568249dbf2c..39e97fc3ddb 100644 --- a/src/Type/TypeCombinator.php +++ b/src/Type/TypeCombinator.php @@ -1789,6 +1789,19 @@ public static function intersect(Type ...$types): Type $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); + } + } $valueTypes = $constArray->getValueTypes(); foreach ($constArray->getKeyTypes() as $k => $keyType) { $hasOffset = $otherArray->hasOffsetValueType($keyType); 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/bug-7963.php b/tests/PHPStan/Analyser/data/bug-7963.php index c2d278bc7e5..589fe85bbb3 100644 --- a/tests/PHPStan/Analyser/data/bug-7963.php +++ b/tests/PHPStan/Analyser/data/bug-7963.php @@ -31,7 +31,7 @@ interface FieldDescriptionInterface class HelloWorld { /** - * @phpstan-return array, ...}> + * @phpstan-return array> */ public function getRenderViewElementTests(): array { diff --git a/tests/PHPStan/Rules/Functions/data/bug-3931.php b/tests/PHPStan/Rules/Functions/data/bug-3931.php index ec98f36e845..637927871d4 100644 --- a/tests/PHPStan/Rules/Functions/data/bug-3931.php +++ b/tests/PHPStan/Rules/Functions/data/bug-3931.php @@ -22,5 +22,5 @@ function addSomeKey(array $arr, int $value): array { function test(array $arr): void { $r = addSomeKey($arr, 1); - assertType("T of array (function Bug3931\addSomeKey(), argument)&hasOffsetValue('mykey', int)&non-empty-array", $r); + assertType('array{mykey: int, ...}', $r); } diff --git a/tests/PHPStan/Type/TypeCombinatorTest.php b/tests/PHPStan/Type/TypeCombinatorTest.php index 834c79975d5..de64a500225 100644 --- a/tests/PHPStan/Type/TypeCombinatorTest.php +++ b/tests/PHPStan/Type/TypeCombinatorTest.php @@ -5521,7 +5521,7 @@ public static function dataIntersect(): iterable 'array{...}', ], ConstantArrayType::class, - 'array{a: *NEVER*}', + 'array{a: *NEVER*, ...}', ]; // both unsealed: known key value is compatible with other side's unsealed value @@ -5531,7 +5531,7 @@ public static function dataIntersect(): iterable 'array{...}', ], ConstantArrayType::class, - 'array{a: non-empty-string}', + 'array{a: non-empty-string, ...}', ]; // both unsealed with same known key, value types incompatible at that key From 6b57c1eeb2dc6a5598c9fc2e00c79225d0988934 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Mon, 27 Apr 2026 16:44:44 +0200 Subject: [PATCH 098/158] Tip in assertNoErrors --- src/Testing/PHPStanTestCase.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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; } From 77dcce4692a1e7e9911af8aebe7e801e59e414d5 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Mon, 27 Apr 2026 18:48:22 +0200 Subject: [PATCH 099/158] Re-tighten bug-7963 @phpstan-return on unsealed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The two-stage collapse merged from 2.1.x preserves the per-position record shape on unsealed too (the unsealed-types passes in reduceArrays absorb same-signature variants before the list-collapse, and the list-collapse now skips when every variant shares one signature). The earlier "Fix tests: bug-7963, bug-13978" commit's loosening of this @phpstan-return is therefore obsolete on unsealed — revert that one part to match the sealed form already on 2.2.x. Co-Authored-By: Claude Opus 4.7 (1M context) --- phpstan-baseline.neon | 2 +- src/Type/TypeCombinator.php | 1 - tests/PHPStan/Analyser/data/bug-7963.php | 2 +- 3 files changed, 2 insertions(+), 3 deletions(-) diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index eb573b5d723..2b3748ff2c6 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -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: 20 + count: 21 path: src/Type/TypeCombinator.php - diff --git a/src/Type/TypeCombinator.php b/src/Type/TypeCombinator.php index 39e97fc3ddb..b3a0e1b7579 100644 --- a/src/Type/TypeCombinator.php +++ b/src/Type/TypeCombinator.php @@ -1465,7 +1465,6 @@ private static function reduceArrays(array $constantArrays, bool $preserveTagged } } - return array_merge($newArrays, $arraysToProcess); } diff --git a/tests/PHPStan/Analyser/data/bug-7963.php b/tests/PHPStan/Analyser/data/bug-7963.php index 589fe85bbb3..ac7d433943b 100644 --- a/tests/PHPStan/Analyser/data/bug-7963.php +++ b/tests/PHPStan/Analyser/data/bug-7963.php @@ -31,7 +31,7 @@ interface FieldDescriptionInterface class HelloWorld { /** - * @phpstan-return array> + * @phpstan-return array}> */ public function getRenderViewElementTests(): array { From 219b075cb0e7dd0fff317d8420726886afd36d64 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Tue, 28 Apr 2026 14:50:49 +0200 Subject: [PATCH 100/158] Update levels tests --- tests/PHPStan/Levels/data/acceptTypes-5.json | 22 +++++++++++++++++++- tests/PHPStan/Levels/data/acceptTypes-7.json | 12 +---------- 2 files changed, 22 insertions(+), 12 deletions(-) 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 From 71b347e1fc0ba569e0c6543e979392f728b322a9 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Tue, 28 Apr 2026 15:47:29 +0200 Subject: [PATCH 101/158] Refactor - allow other message to be passed from getIterableTypesWithMissingValueTypehint --- src/Rules/Classes/LocalTypeAliasesCheck.php | 5 ++--- src/Rules/Classes/MethodTagCheck.php | 5 ++--- src/Rules/Classes/MixinCheck.php | 5 ++--- src/Rules/Classes/PropertyTagCheck.php | 5 ++--- .../MissingClassConstantTypehintRule.php | 5 ++--- .../MissingFunctionParameterTypehintRule.php | 5 ++--- .../MissingFunctionReturnTypehintRule.php | 5 ++--- .../MissingMethodParameterTypehintRule.php | 5 ++--- .../MissingMethodReturnTypehintRule.php | 5 ++--- .../Methods/MissingMethodSelfOutTypeRule.php | 5 ++--- src/Rules/MissingTypehintCheck.php | 19 ++++++++++++------- src/Rules/PhpDoc/AssertRuleHelper.php | 5 ++--- .../PhpDoc/InvalidPhpDocVarTagTypeRule.php | 6 ++---- .../MissingPropertyTypehintRule.php | 5 ++--- .../SetPropertyHookParameterRule.php | 5 ++--- 15 files changed, 40 insertions(+), 50 deletions(-) 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/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/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/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/MissingTypehintCheck.php b/src/Rules/MissingTypehintCheck.php index b43d51b6dd8..426875b87d1 100644 --- a/src/Rules/MissingTypehintCheck.php +++ b/src/Rules/MissingTypehintCheck.php @@ -23,6 +23,7 @@ use PHPStan\Type\ObjectType; use PHPStan\Type\Type; use PHPStan\Type\TypeTraverser; +use PHPStan\Type\VerbosityLevel; use Traversable; use function array_filter; use function array_keys; @@ -61,12 +62,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 +96,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()), ); @@ -102,7 +107,7 @@ public function getIterableTypesWithMissingValueTypehint(Type $type): array if ($type->isIterable()->yes()) { $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 +120,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/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/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(), From fab9c534e5090d221305b5ab5807457dc27cf975 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Tue, 28 Apr 2026 15:55:04 +0200 Subject: [PATCH 102/158] Better "missing iterable value type" for unsealed types --- src/Rules/MissingTypehintCheck.php | 22 ++++++++++++++++++ src/Type/Constant/ConstantArrayType.php | 15 ++++++++++++ ...MissingMethodParameterTypehintRuleTest.php | 10 ++++++++ .../missing-method-parameter-typehint.php | 23 +++++++++++++++++++ 4 files changed, 70 insertions(+) diff --git a/src/Rules/MissingTypehintCheck.php b/src/Rules/MissingTypehintCheck.php index 426875b87d1..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,7 @@ 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; @@ -105,6 +107,26 @@ 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()) { $descriptions[] = sprintf('iterable type %s', $type->describe(VerbosityLevel::typeOnly())); diff --git a/src/Type/Constant/ConstantArrayType.php b/src/Type/Constant/ConstantArrayType.php index 27988d3f119..77db3ff40a7 100644 --- a/src/Type/Constant/ConstantArrayType.php +++ b/src/Type/Constant/ConstantArrayType.php @@ -195,6 +195,21 @@ 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, + ); + } + /** * @param list $keyTypes * @param array $valueTypes 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/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 + { + + } + +} From 947902737d376a2255a49dd287f43eac6a657750 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Tue, 28 Apr 2026 16:06:44 +0200 Subject: [PATCH 103/158] Generics --- src/Type/Constant/ConstantArrayType.php | 37 ++++++++++++++++ .../Analyser/nsrt/unsealed-array-shapes.php | 43 +++++++++++++++++++ 2 files changed, 80 insertions(+) diff --git a/src/Type/Constant/ConstantArrayType.php b/src/Type/Constant/ConstantArrayType.php index 77db3ff40a7..55bfbe6597a 100644 --- a/src/Type/Constant/ConstantArrayType.php +++ b/src/Type/Constant/ConstantArrayType.php @@ -2181,6 +2181,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; } diff --git a/tests/PHPStan/Analyser/nsrt/unsealed-array-shapes.php b/tests/PHPStan/Analyser/nsrt/unsealed-array-shapes.php index 0fa24cd435d..544610b2cb8 100644 --- a/tests/PHPStan/Analyser/nsrt/unsealed-array-shapes.php +++ b/tests/PHPStan/Analyser/nsrt/unsealed-array-shapes.php @@ -3,6 +3,7 @@ namespace UnsealedArrayShapes; use DateTimeImmutable; +use stdClass; use function PHPStan\Testing\assertType; class Foo @@ -83,3 +84,45 @@ public function edgeCases(array $a, array $b, array $c): void } } + +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)); +}; From c685b9c7386ede1206e2ba7cb29101da323852cd Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Wed, 29 Apr 2026 17:01:35 +0200 Subject: [PATCH 104/158] Preserve array shape and make it unsealed when non-constant key is assigned --- .../Constant/ConstantArrayTypeBuilder.php | 117 ++++++++++++------ .../Analyser/AnalyserIntegrationTest.php | 2 +- .../Analyser/nsrt/array-keys-branches.php | 4 +- tests/PHPStan/Analyser/nsrt/bug-14333.php | 4 +- .../Analyser/nsrt/constant-array-type-set.php | 10 +- tests/PHPStan/Analyser/nsrt/pr-4390.php | 2 +- .../Analyser/nsrt/unsealed-array-shapes.php | 53 ++++++++ 7 files changed, 142 insertions(+), 50 deletions(-) diff --git a/src/Type/Constant/ConstantArrayTypeBuilder.php b/src/Type/Constant/ConstantArrayTypeBuilder.php index d3c69eb3a62..c6f18b8dee8 100644 --- a/src/Type/Constant/ConstantArrayTypeBuilder.php +++ b/src/Type/Constant/ConstantArrayTypeBuilder.php @@ -283,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; @@ -304,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; + } - if ( - !$this->disableArrayDegradation - && count($this->keyTypes) > self::ARRAY_COUNT_LIMIT - ) { - $this->degradeToGeneralArray = true; - $this->oversized = true; + return; + } + + $this->isList = TrinaryLogic::createNo(); + + // 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) { @@ -405,7 +440,11 @@ public function getArray(): Type [$unsealedKey, $unsealedValue] = $this->unsealed; $isExplicitNever = $unsealedKey instanceof NeverType && $unsealedKey->isExplicit(); if (!$isExplicitNever) { - return new ArrayType($unsealedKey, $unsealedValue); + $arrayType = new ArrayType($unsealedKey, $unsealedValue); + if ($this->isNonEmpty->yes()) { + return TypeCombinator::intersect($arrayType, new NonEmptyArrayType()); + } + return $arrayType; } } return new ConstantArrayType([], [], unsealed: $this->unsealed); @@ -414,7 +453,7 @@ public function getArray(): Type if (!$this->degradeToGeneralArray) { /** @var list $keyTypes */ $keyTypes = $this->keyTypes; - $array = new ConstantArrayType($keyTypes, $this->valueTypes, $this->nextAutoIndexes, $this->optionalKeys, $this->isList, , $this->unsealed); + $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()); } diff --git a/tests/PHPStan/Analyser/AnalyserIntegrationTest.php b/tests/PHPStan/Analyser/AnalyserIntegrationTest.php index 08b8f097f3a..7f0f0fac5d3 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}, array{foo?: string, bar?: 5|6|7, baz?: bool, ...} given.', $errors[5]->getMessage()); $this->assertSame(29, $errors[5]->getLine()); } diff --git a/tests/PHPStan/Analyser/nsrt/array-keys-branches.php b/tests/PHPStan/Analyser/nsrt/array-keys-branches.php index f688a124645..b803e0bb1fc 100644 --- a/tests/PHPStan/Analyser/nsrt/array-keys-branches.php +++ b/tests/PHPStan/Analyser/nsrt/array-keys-branches.php @@ -58,7 +58,7 @@ function (array $generalArray) { assertType('mixed~null', $generalArray['key']); assertType('array{0: \'foo\', 1: \'bar\', 2?: \'baz\'}', $arrayAppendedInIf); assertType('non-empty-list<\'bar\'|\'baz\'|\'foo\'>', $arrayAppendedInForeach); - assertType('non-empty-array, literal-string&lowercase-string&non-falsy-string>', $anotherArrayAppendedInForeach); + assertType("array{literal-string&lowercase-string&non-falsy-string, literal-string&lowercase-string&non-falsy-string, ..., 'baz'>}", $anotherArrayAppendedInForeach); assertType('\'str\'', $array['n']); assertType('int<0, max>', $incremented); assertType('0|1', $setFromZeroToOne); @@ -124,7 +124,7 @@ function (array $generalArray, array $xs) { assertType('mixed~null', $generalArray['key']); assertType('array{0: \'foo\', 1: \'bar\', 2?: \'baz\'}', $arrayAppendedInIf); assertType('non-empty-list<\'bar\'|\'baz\'|\'foo\'>', $arrayAppendedInForeach); - assertType('non-empty-array, literal-string&lowercase-string&non-falsy-string>', $anotherArrayAppendedInForeach); + assertType("array{literal-string&lowercase-string&non-falsy-string, literal-string&lowercase-string&non-falsy-string, ..., 'baz'>}", $anotherArrayAppendedInForeach); assertType('\'str\'', $array['n']); assertType('int<0, max>', $incremented); assertType('0|1', $setFromZeroToOne); diff --git a/tests/PHPStan/Analyser/nsrt/bug-14333.php b/tests/PHPStan/Analyser/nsrt/bug-14333.php index e989a87bc63..5251039ac1d 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-14333.php +++ b/tests/PHPStan/Analyser/nsrt/bug-14333.php @@ -45,8 +45,8 @@ function testNonConstantKeyBreaksImplicitIndex(int $key): void // Since $key is non-constant, we don't know the implicit indices of &$a and &$c // so we can't correctly track the reference propagation $b[2] = 2; - assertType("1|2|'test'|'x'", $a); // Could be 1|2 - assertType("1|2|'test'|'x'", $c); // Could be 'test'|2 + assertType("1|2|'test'", $a); // Could be 1|2 + assertType("1|2|'test'", $c); // Could be 'test'|2 } function testNested(): void 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/pr-4390.php b/tests/PHPStan/Analyser/nsrt/pr-4390.php index c318b9b6ee8..8f16a609665 100644 --- a/tests/PHPStan/Analyser/nsrt/pr-4390.php +++ b/tests/PHPStan/Analyser/nsrt/pr-4390.php @@ -13,6 +13,6 @@ function (string $s): void { } } - assertType('non-empty-array, non-empty-array, string>>', $locations); + assertType('non-empty-list, string>>', $locations); assertType('non-empty-array, string>', $locations[0]); }; diff --git a/tests/PHPStan/Analyser/nsrt/unsealed-array-shapes.php b/tests/PHPStan/Analyser/nsrt/unsealed-array-shapes.php index 544610b2cb8..89a4473d118 100644 --- a/tests/PHPStan/Analyser/nsrt/unsealed-array-shapes.php +++ b/tests/PHPStan/Analyser/nsrt/unsealed-array-shapes.php @@ -83,6 +83,59 @@ public function edgeCases(array $a, array $b, array $c): void 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); + } + } class Generics From 58e0f05d6cd8046175d6c2b7bcc96a9c46ab0362 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Thu, 30 Apr 2026 10:35:56 +0200 Subject: [PATCH 105/158] Correctly generalize unsealed array shapes --- src/Analyser/MutatingScope.php | 24 ++++- .../Analyser/AnalyserIntegrationTest.php | 2 +- .../Analyser/nsrt/array-keys-branches.php | 2 +- tests/PHPStan/Analyser/nsrt/bug-14552.php | 2 +- .../bug-yield-oversized-self-rejection.php | 2 +- tests/PHPStan/Analyser/nsrt/pr-4390.php | 2 +- .../Analyser/nsrt/unsealed-array-shapes.php | 95 ++++++++++++++++++- 7 files changed, 122 insertions(+), 7 deletions(-) diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index f6bfa8f5195..16e2112b1f5 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -4231,8 +4231,30 @@ 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. When taking the + // fall-through ArrayType path we still recurse into + // `generalizeType` for the iterable key, which would + // widen e.g. `0|1` to `int<0, max>` and lose the loop's + // per-iteration precision. Instead, keep the literal + // union of constant keys so the loop's bound stays + // visible. + $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()); + } else { + $resultKeyType = TypeCombinator::union($this->generalizeType($constantArraysA->getIterableKeyType(), $constantArraysB->getIterableKeyType(), $depth + 1)); + } $resultType = new ArrayType( - TypeCombinator::union($this->generalizeType($constantArraysA->getIterableKeyType(), $constantArraysB->getIterableKeyType(), $depth + 1)), + $resultKeyType, TypeCombinator::union($this->generalizeType($constantArraysA->getIterableValueType(), $constantArraysB->getIterableValueType(), $depth + 1)), ); $accessories = []; diff --git a/tests/PHPStan/Analyser/AnalyserIntegrationTest.php b/tests/PHPStan/Analyser/AnalyserIntegrationTest.php index 7f0f0fac5d3..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}, array{foo?: string, bar?: 5|6|7, baz?: bool, ...} 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/nsrt/array-keys-branches.php b/tests/PHPStan/Analyser/nsrt/array-keys-branches.php index b803e0bb1fc..96d6e4b4a33 100644 --- a/tests/PHPStan/Analyser/nsrt/array-keys-branches.php +++ b/tests/PHPStan/Analyser/nsrt/array-keys-branches.php @@ -124,7 +124,7 @@ function (array $generalArray, array $xs) { assertType('mixed~null', $generalArray['key']); assertType('array{0: \'foo\', 1: \'bar\', 2?: \'baz\'}', $arrayAppendedInIf); assertType('non-empty-list<\'bar\'|\'baz\'|\'foo\'>', $arrayAppendedInForeach); - assertType("array{literal-string&lowercase-string&non-falsy-string, literal-string&lowercase-string&non-falsy-string, ..., 'baz'>}", $anotherArrayAppendedInForeach); + assertType("array{literal-string&lowercase-string&non-falsy-string, literal-string&lowercase-string&non-falsy-string, ..., 'baz'>}", $anotherArrayAppendedInForeach); assertType('\'str\'', $array['n']); assertType('int<0, max>', $incremented); assertType('0|1', $setFromZeroToOne); diff --git a/tests/PHPStan/Analyser/nsrt/bug-14552.php b/tests/PHPStan/Analyser/nsrt/bug-14552.php index 6f9d06c6669..1a527fe83e5 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-14552.php +++ b/tests/PHPStan/Analyser/nsrt/bug-14552.php @@ -25,7 +25,7 @@ function possiblyEmptyListForeach(array $keys): void foreach ($keys as $k) { $out[$k] = 1; } - assertType("array{}|array{a?: 1, b?: 1}", $out); + assertType("array{a?: 1, b?: 1}", $out); } /** 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/pr-4390.php b/tests/PHPStan/Analyser/nsrt/pr-4390.php index 8f16a609665..c318b9b6ee8 100644 --- a/tests/PHPStan/Analyser/nsrt/pr-4390.php +++ b/tests/PHPStan/Analyser/nsrt/pr-4390.php @@ -13,6 +13,6 @@ function (string $s): void { } } - assertType('non-empty-list, string>>', $locations); + assertType('non-empty-array, non-empty-array, string>>', $locations); assertType('non-empty-array, string>', $locations[0]); }; diff --git a/tests/PHPStan/Analyser/nsrt/unsealed-array-shapes.php b/tests/PHPStan/Analyser/nsrt/unsealed-array-shapes.php index 89a4473d118..29704a04651 100644 --- a/tests/PHPStan/Analyser/nsrt/unsealed-array-shapes.php +++ b/tests/PHPStan/Analyser/nsrt/unsealed-array-shapes.php @@ -109,7 +109,7 @@ public function sealedBecomesUnsealed(string $s, int $i): void $a[$s] = 6; assertType('array{5, ...}', $a); $a[$i] = 7; - assertType('array{5|7, ...|int<1,max>|string, 6|7>}', $a); + assertType('array{5|7, ...|int<1, max>|string, 6|7>}', $a); $b = []; $b[$s] = 1; @@ -136,6 +136,99 @@ public function sealedBecomesUnsealed(string $s, int $i): void 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, max>>", $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, literal-string&lowercase-string&non-falsy-string>", $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); + } + } class Generics From b4700eead0cd7075f9ea20d3aec8942ef32ca1fb Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Thu, 30 Apr 2026 14:52:47 +0200 Subject: [PATCH 106/158] Fix array_search --- src/Type/Constant/ConstantArrayType.php | 20 ++++++ .../Analyser/nsrt/unsealed-array-shapes.php | 71 +++++++++++++++++++ 2 files changed, 91 insertions(+) diff --git a/src/Type/Constant/ConstantArrayType.php b/src/Type/Constant/ConstantArrayType.php index 55bfbe6597a..60f04475dc9 100644 --- a/src/Type/Constant/ConstantArrayType.php +++ b/src/Type/Constant/ConstantArrayType.php @@ -1419,6 +1419,26 @@ 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->unsealed !== null) { + [$unsealedKeyType, $unsealedValueType] = $this->unsealed; + $isExplicitNever = $unsealedKeyType instanceof NeverType && $unsealedKeyType->isExplicit(); + if (!$isExplicitNever) { + $considerUnsealed = true; + if ($strict->yes()) { + $considerUnsealed = !$unsealedValueType->isSuperTypeOf($needleType)->no(); + } + if ($considerUnsealed) { + $matches[] = $unsealedKeyType; + } + } + } + if (count($matches) > 0) { if ($hasIdenticalValue) { return TypeCombinator::union(...$matches); diff --git a/tests/PHPStan/Analyser/nsrt/unsealed-array-shapes.php b/tests/PHPStan/Analyser/nsrt/unsealed-array-shapes.php index 29704a04651..457c9fd3180 100644 --- a/tests/PHPStan/Analyser/nsrt/unsealed-array-shapes.php +++ b/tests/PHPStan/Analyser/nsrt/unsealed-array-shapes.php @@ -229,6 +229,77 @@ public function twoUnsealedJoined(array $u1, array $u2, bool $cond): void 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 From 99156bfb3999f6028e6ddbfc7294207e44e05a0e Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Tue, 28 Apr 2026 16:28:09 +0200 Subject: [PATCH 107/158] Unsealed types awareness in more methods --- src/Type/Constant/ConstantArrayType.php | 39 +++++++++++++++++++ .../ExistingClassesInTypehintsRuleTest.php | 4 ++ .../Rules/Functions/data/typehints.php | 8 ++++ .../Rules/Generics/ClassAncestorsRuleTest.php | 4 ++ .../MethodSignatureVarianceRuleTest.php | 4 ++ .../Generics/data/cross-check-interfaces.php | 37 ++++++++++++++++++ .../method-signature-variance-covariant.php | 3 ++ 7 files changed, 99 insertions(+) diff --git a/src/Type/Constant/ConstantArrayType.php b/src/Type/Constant/ConstantArrayType.php index 60f04475dc9..25f0f466cdc 100644 --- a/src/Type/Constant/ConstantArrayType.php +++ b/src/Type/Constant/ConstantArrayType.php @@ -249,6 +249,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; } @@ -856,6 +866,25 @@ public function equals(Type $type): bool return false; } + // Both `unsealed === null` and `unsealed === [explicitNever, explicitNever]` + // mean "sealed", just from different code paths (pre-bleeding-edge vs. + // fresh bleeding-edge builder). Treat them as equivalent here, only + // comparing the actual extras when both sides have real ones. + $thisIsSealed = $this->isUnsealed()->no(); + $otherIsSealed = $type->isUnsealed()->no(); + if ($thisIsSealed !== $otherIsSealed) { + return false; + } + + if (!$thisIsSealed && $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; } @@ -2268,6 +2297,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; } 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/data/typehints.php b/tests/PHPStan/Rules/Functions/data/typehints.php index 67ca9b736c5..cee3041de26 100644 --- a/tests/PHPStan/Rules/Functions/data/typehints.php +++ b/tests/PHPStan/Rules/Functions/data/typehints.php @@ -97,3 +97,11 @@ function templateTypeMissingInParameter(string $a) { } + +/** + * @param array{a: int, ...} $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) {} } From 8b2a6c6b7572b6af7352aa9310b345e274bcb8a0 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Sat, 2 May 2026 18:06:23 +0200 Subject: [PATCH 108/158] Fix byref --- src/Analyser/MutatingScope.php | 41 ++++++++++++++++++++--- src/Type/Constant/ConstantArrayType.php | 12 ++++++- tests/PHPStan/Analyser/nsrt/bug-14333.php | 11 +++--- 3 files changed, 55 insertions(+), 9 deletions(-) diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index 16e2112b1f5..15b15e09249 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -2654,6 +2654,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 @@ -2664,10 +2684,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]), ); @@ -2678,8 +2711,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, ); } } diff --git a/src/Type/Constant/ConstantArrayType.php b/src/Type/Constant/ConstantArrayType.php index 25f0f466cdc..e50ed5fd5c8 100644 --- a/src/Type/Constant/ConstantArrayType.php +++ b/src/Type/Constant/ConstantArrayType.php @@ -1090,7 +1090,7 @@ private function recursiveHasOffsetValueType(Type $offsetType): TrinaryLogic public function getOffsetValueType(Type $offsetType): Type { - if (count($this->keyTypes) === 0) { + if (count($this->keyTypes) === 0 && $this->unsealed === null) { return new ErrorType(); } @@ -1116,6 +1116,16 @@ public function getOffsetValueType(Type $offsetType): Type $matchingValueTypes[] = $this->valueTypes[$i]; } + // Unsealed extras may also satisfy the offset — when their key type + // overlaps with the requested offset, their value is a possible result. + if ($this->unsealed !== null) { + [$unsealedKeyType, $unsealedValueType] = $this->unsealed; + $isExplicitNever = $unsealedKeyType instanceof NeverType && $unsealedKeyType->isExplicit(); + if (!$isExplicitNever && !$unsealedKeyType->isSuperTypeOf($offsetType)->no()) { + $matchingValueTypes[] = $unsealedValueType; + } + } + if ($all && !$this->isUnsealed()->yes()) { return $this->getIterableValueType(); } diff --git a/tests/PHPStan/Analyser/nsrt/bug-14333.php b/tests/PHPStan/Analyser/nsrt/bug-14333.php index 5251039ac1d..a01178586b6 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-14333.php +++ b/tests/PHPStan/Analyser/nsrt/bug-14333.php @@ -45,8 +45,8 @@ function testNonConstantKeyBreaksImplicitIndex(int $key): void // Since $key is non-constant, we don't know the implicit indices of &$a and &$c // so we can't correctly track the reference propagation $b[2] = 2; - assertType("1|2|'test'", $a); // Could be 1|2 - assertType("1|2|'test'", $c); // Could be 'test'|2 + assertType("1|2|'test'|'x'", $a); // Could be 1|2 + assertType("1|2|'test'|'x'", $c); // Could be 'test'|2 } function testNested(): void @@ -188,7 +188,10 @@ function moreTest(bool $bool, int $int) { assertType("'a0'", $a); assertType("'a2'", $b); assertType("'a3'", $c); - assertType("1|2|3|4|5|6|'a0'|'a1'|'a2'|'a3'|'a4'|'a5'|'aKey'", $d); - assertType("1|2|3|4|5|6|'a0'|'a1'|'a2'|'a3'|'a4'|'a5'|'aKey'", $e); + // $d's slot is at $int (general int), so it accumulates every int-keyed + // value that has ever been at $array[$int] across the lifetime of the + // byref, but never the string-keyed `'key'` slot ($int can't equal 'key'). + assertType("1|2|3|4|5|'a0'|'a1'|'a2'|'a3'|'a4'|'a5'", $d); + assertType("1|2|3|4|5|'a0'|'a1'|'a2'|'a3'|'a4'|'a5'", $e); assertType("'aKey'", $f); } From 612ae507d8d57f57901ecda65a701180aae09cae Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Sat, 2 May 2026 17:57:10 +0200 Subject: [PATCH 109/158] Awareness of unsealed array shape when unrolling foreach with constant array --- src/Analyser/NodeScopeResolver.php | 38 +++++++++++++++++++ src/Type/Constant/ConstantArrayType.php | 2 +- .../Analyser/nsrt/unsealed-array-shapes.php | 16 ++++++++ 3 files changed, 55 insertions(+), 1 deletion(-) diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index d013b03359a..e4f59991783 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -4027,8 +4027,12 @@ private function tryProcessUnrolledConstantArrayForeach( } $totalKeys = 0; + $hasUnsealed = false; foreach ($constantArrays as $constantArray) { $totalKeys += count($constantArray->getKeyTypes()); + if ($constantArray->isUnsealed()->yes()) { + $hasUnsealed = true; + } } if ($totalKeys === 0 || $totalKeys > self::FOREACH_UNROLL_LIMIT) { return null; @@ -4161,6 +4165,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/Type/Constant/ConstantArrayType.php b/src/Type/Constant/ConstantArrayType.php index e50ed5fd5c8..49fe5cbd3ea 100644 --- a/src/Type/Constant/ConstantArrayType.php +++ b/src/Type/Constant/ConstantArrayType.php @@ -1090,7 +1090,7 @@ private function recursiveHasOffsetValueType(Type $offsetType): TrinaryLogic public function getOffsetValueType(Type $offsetType): Type { - if (count($this->keyTypes) === 0 && $this->unsealed === null) { + if (count($this->keyTypes) === 0 && !$this->isUnsealed()->yes()) { return new ErrorType(); } diff --git a/tests/PHPStan/Analyser/nsrt/unsealed-array-shapes.php b/tests/PHPStan/Analyser/nsrt/unsealed-array-shapes.php index 457c9fd3180..95af9f7b6ba 100644 --- a/tests/PHPStan/Analyser/nsrt/unsealed-array-shapes.php +++ b/tests/PHPStan/Analyser/nsrt/unsealed-array-shapes.php @@ -343,3 +343,19 @@ function doFoo(Generics $g, array $a, array $b, array $c, array $d): void { 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); +} From c3e0ba2a3f327340570c990608f2bd426852da31 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Sat, 2 May 2026 18:24:51 +0200 Subject: [PATCH 110/158] Less `instanceof NeverType` to detect sealed array --- src/Analyser/NodeScopeResolver.php | 5 +- src/Type/Constant/ConstantArrayType.php | 60 +++++++++++-------- .../Analyser/nsrt/unsealed-array-shapes.php | 34 +++++++++++ 3 files changed, 71 insertions(+), 28 deletions(-) diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index e4f59991783..ac1db665256 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -4030,9 +4030,10 @@ private function tryProcessUnrolledConstantArrayForeach( $hasUnsealed = false; foreach ($constantArrays as $constantArray) { $totalKeys += count($constantArray->getKeyTypes()); - if ($constantArray->isUnsealed()->yes()) { - $hasUnsealed = true; + if (!$constantArray->isUnsealed()->yes()) { + continue; } + $hasUnsealed = true; } if ($totalKeys === 0 || $totalKeys > self::FOREACH_UNROLL_LIMIT) { return null; diff --git a/src/Type/Constant/ConstantArrayType.php b/src/Type/Constant/ConstantArrayType.php index 49fe5cbd3ea..3886bc63ff4 100644 --- a/src/Type/Constant/ConstantArrayType.php +++ b/src/Type/Constant/ConstantArrayType.php @@ -112,6 +112,8 @@ class ConstantArrayType implements Type private ?Type $iterableValueType = null; + private ?Type $keyTypesUnion = null; + /** @var array|null */ private ?array $keyIndexMap = null; @@ -304,6 +306,13 @@ public function getIterableValueType(): Type 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 { return $this->getIterableKeyType(); @@ -1116,12 +1125,14 @@ public function getOffsetValueType(Type $offsetType): Type $matchingValueTypes[] = $this->valueTypes[$i]; } - // Unsealed extras may also satisfy the offset — when their key type - // overlaps with the requested offset, their value is a possible result. - if ($this->unsealed !== null) { + // 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; - $isExplicitNever = $unsealedKeyType instanceof NeverType && $unsealedKeyType->isExplicit(); - if (!$isExplicitNever && !$unsealedKeyType->isSuperTypeOf($offsetType)->no()) { + if (!$this->getKeyTypesUnion()->isSuperTypeOf($offsetType)->yes() && !$unsealedKeyType->isSuperTypeOf($offsetType)->no()) { $matchingValueTypes[] = $unsealedValueType; } } @@ -1464,17 +1475,14 @@ public function searchArray(Type $needleType, ?TrinaryLogic $strict = null): Typ // entries), so they can never make the needle "definitely found" // (`hasIdenticalValue` stays false) — `false` always remains a // possible result. - if ($this->unsealed !== null) { + if ($this->isUnsealed()->yes() && $this->unsealed !== null) { [$unsealedKeyType, $unsealedValueType] = $this->unsealed; - $isExplicitNever = $unsealedKeyType instanceof NeverType && $unsealedKeyType->isExplicit(); - if (!$isExplicitNever) { - $considerUnsealed = true; - if ($strict->yes()) { - $considerUnsealed = !$unsealedValueType->isSuperTypeOf($needleType)->no(); - } - if ($considerUnsealed) { - $matches[] = $unsealedKeyType; - } + $considerUnsealed = true; + if ($strict->yes()) { + $considerUnsealed = !$unsealedValueType->isSuperTypeOf($needleType)->no(); + } + if ($considerUnsealed) { + $matches[] = $unsealedKeyType; } } @@ -1807,11 +1815,7 @@ public function isIterableAtLeastOnce(): TrinaryLogic { $keysCount = count($this->keyTypes); if ($keysCount === 0) { - if ($this->unsealed === null) { - return TrinaryLogic::createNo(); - } - [$unsealedKey] = $this->unsealed; - if ($unsealedKey instanceof NeverType && $unsealedKey->isExplicit()) { + if (!$this->isUnsealed()->yes()) { return TrinaryLogic::createNo(); } return TrinaryLogic::createMaybe(); @@ -2410,8 +2414,8 @@ public function isKeysSupersetOf(self $otherArray): bool [$thisUnsealedKey, $thisUnsealedValue] = $this->unsealed; [$otherUnsealedKey, $otherUnsealedValue] = $otherArray->unsealed; - $thisHasExtras = !($thisUnsealedKey instanceof NeverType && $thisUnsealedKey->isExplicit()); - $otherHasExtras = !($otherUnsealedKey instanceof NeverType && $otherUnsealedKey->isExplicit()); + $thisHasExtras = $this->isUnsealed()->yes(); + $otherHasExtras = $otherArray->isUnsealed()->yes(); $otherHasRequiredKeys = false; foreach ($otherArray->keyTypes as $j => $keyType) { @@ -2556,10 +2560,14 @@ public function mergeWith(self $otherArray): self $mergedUnsealedValue = TypeCombinator::union($mergedUnsealedValue, $valueType); }; - $canAbsorb = static function (Type $sideUnsealedKey, Type $sideUnsealedValue, Type $keyType, Type $valueType): bool { - if ($sideUnsealedKey instanceof NeverType && $sideUnsealedKey->isExplicit()) { + $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; } @@ -2596,7 +2604,7 @@ public function mergeWith(self $otherArray): self continue; } - if ($canAbsorb($otherUnsealedKey, $otherUnsealedValue, $keyType, $valueType)) { + if ($canAbsorb($otherArray, $keyType, $valueType)) { $absorbIntoExtras($keyType, $valueType); continue; } @@ -2613,7 +2621,7 @@ public function mergeWith(self $otherArray): self } $valueType = $otherArray->valueTypes[$j]; - if ($canAbsorb($thisUnsealedKey, $thisUnsealedValue, $keyType, $valueType)) { + if ($canAbsorb($this, $keyType, $valueType)) { $absorbIntoExtras($keyType, $valueType); continue; } diff --git a/tests/PHPStan/Analyser/nsrt/unsealed-array-shapes.php b/tests/PHPStan/Analyser/nsrt/unsealed-array-shapes.php index 95af9f7b6ba..0649d71e8c3 100644 --- a/tests/PHPStan/Analyser/nsrt/unsealed-array-shapes.php +++ b/tests/PHPStan/Analyser/nsrt/unsealed-array-shapes.php @@ -359,3 +359,37 @@ function unsealedForeach(array $a): void 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]); +} From e7a87918a2a0bdca3adec7bc7f04934ebfaebb38 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Tue, 5 May 2026 17:35:36 +0200 Subject: [PATCH 111/158] Consider unsealed types in hasOffsetValueType --- .../NonexistentOffsetInArrayDimFetchCheck.php | 15 +++-- src/Type/Constant/ConstantArrayType.php | 32 +++++++++ .../Analyser/nsrt/array-keys-branches.php | 4 +- .../Analyser/nsrt/unsealed-array-shapes.php | 2 +- ...nexistentOffsetInArrayDimFetchRuleTest.php | 65 +++++++++++++++++++ .../data/unsealed-array-shapes-has-offset.php | 28 ++++++++ 6 files changed, 136 insertions(+), 10 deletions(-) create mode 100644 tests/PHPStan/Rules/Arrays/data/unsealed-array-shapes-has-offset.php 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/Type/Constant/ConstantArrayType.php b/src/Type/Constant/ConstantArrayType.php index 3886bc63ff4..32ff8fcc108 100644 --- a/src/Type/Constant/ConstantArrayType.php +++ b/src/Type/Constant/ConstantArrayType.php @@ -381,11 +381,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) { @@ -1072,10 +1084,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(); } @@ -1094,6 +1112,20 @@ 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; } diff --git a/tests/PHPStan/Analyser/nsrt/array-keys-branches.php b/tests/PHPStan/Analyser/nsrt/array-keys-branches.php index 96d6e4b4a33..f688a124645 100644 --- a/tests/PHPStan/Analyser/nsrt/array-keys-branches.php +++ b/tests/PHPStan/Analyser/nsrt/array-keys-branches.php @@ -58,7 +58,7 @@ function (array $generalArray) { assertType('mixed~null', $generalArray['key']); assertType('array{0: \'foo\', 1: \'bar\', 2?: \'baz\'}', $arrayAppendedInIf); assertType('non-empty-list<\'bar\'|\'baz\'|\'foo\'>', $arrayAppendedInForeach); - assertType("array{literal-string&lowercase-string&non-falsy-string, literal-string&lowercase-string&non-falsy-string, ..., 'baz'>}", $anotherArrayAppendedInForeach); + assertType('non-empty-array, literal-string&lowercase-string&non-falsy-string>', $anotherArrayAppendedInForeach); assertType('\'str\'', $array['n']); assertType('int<0, max>', $incremented); assertType('0|1', $setFromZeroToOne); @@ -124,7 +124,7 @@ function (array $generalArray, array $xs) { assertType('mixed~null', $generalArray['key']); assertType('array{0: \'foo\', 1: \'bar\', 2?: \'baz\'}', $arrayAppendedInIf); assertType('non-empty-list<\'bar\'|\'baz\'|\'foo\'>', $arrayAppendedInForeach); - assertType("array{literal-string&lowercase-string&non-falsy-string, literal-string&lowercase-string&non-falsy-string, ..., 'baz'>}", $anotherArrayAppendedInForeach); + assertType('non-empty-array, literal-string&lowercase-string&non-falsy-string>', $anotherArrayAppendedInForeach); assertType('\'str\'', $array['n']); assertType('int<0, max>', $incremented); assertType('0|1', $setFromZeroToOne); diff --git a/tests/PHPStan/Analyser/nsrt/unsealed-array-shapes.php b/tests/PHPStan/Analyser/nsrt/unsealed-array-shapes.php index 0649d71e8c3..98330beec35 100644 --- a/tests/PHPStan/Analyser/nsrt/unsealed-array-shapes.php +++ b/tests/PHPStan/Analyser/nsrt/unsealed-array-shapes.php @@ -191,7 +191,7 @@ public function loopSealedBecomesUnsealedEachIteration(string $s): void $arr[$i] = 'sealed'; $arr[$s . '_' . $i] = 'unsealed'; } - assertType("non-empty-array|non-falsy-string, literal-string&lowercase-string&non-falsy-string>", $arr); + assertType("non-empty-array|non-falsy-string, 'sealed'|'unsealed'>", $arr); } /** 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]; + } + +} From d960da36c5b378ac21eeb8102bd7183671e1fae3 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Wed, 6 May 2026 12:26:07 +0200 Subject: [PATCH 112/158] makeOffsetRequired --- src/Type/Constant/ConstantArrayType.php | 38 +++++++++++++- .../Analyser/nsrt/unsealed-array-shapes.php | 52 +++++++++++++++++++ 2 files changed, 89 insertions(+), 1 deletion(-) diff --git a/src/Type/Constant/ConstantArrayType.php b/src/Type/Constant/ConstantArrayType.php index 32ff8fcc108..c7ba038bfe0 100644 --- a/src/Type/Constant/ConstantArrayType.php +++ b/src/Type/Constant/ConstantArrayType.php @@ -2764,7 +2764,36 @@ public function makeOffsetRequired(Type $offsetType): self 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; @@ -2797,6 +2826,7 @@ public function makeListMaybe(): Type $this->nextAutoIndexes, $this->optionalKeys, TrinaryLogic::createMaybe(), + $this->unsealed, ); } @@ -2807,12 +2837,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, ); } @@ -2838,6 +2873,7 @@ public function makeAllArrayKeysOptional(): Type $this->nextAutoIndexes, range(0, $keyCount - 1), $this->isList, + $this->unsealed, ); } diff --git a/tests/PHPStan/Analyser/nsrt/unsealed-array-shapes.php b/tests/PHPStan/Analyser/nsrt/unsealed-array-shapes.php index 98330beec35..a5bc969eaac 100644 --- a/tests/PHPStan/Analyser/nsrt/unsealed-array-shapes.php +++ b/tests/PHPStan/Analyser/nsrt/unsealed-array-shapes.php @@ -393,3 +393,55 @@ function unsealedOffsetAccess(array $a, array $b, array $c, array $d, array $e, // 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); + } +} From 94d65a4a11128ca9f56f47f6f604bf621b032c4f Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Thu, 7 May 2026 21:38:20 +0200 Subject: [PATCH 113/158] Make `ConstantArrayType::filterArrayRemovingFalsey()` unsealed-aware MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Group 3 unsealed semantics for `array_filter()` (no callback). The explicit-key path already drops definitely-falsey entries and marks maybe-falsey ones optional with the falsey types subtracted. The unsealed slot was silently dropped — for an `array{a: int, ...}`, the result was `array{a?: int|int<1, max>}`, losing the unsealed extras. Carry through the unsealed slot with the falsey union subtracted from the unsealed value type. If the residue is `NeverType` the unsealed extras can no longer hold any value and the slot is dropped. The new NSRT case in `unsealed-derivations.php` fails before the fix (`Actual: array{a?: int|int<1, max>}`) and passes after. --- src/Type/Constant/ConstantArrayType.php | 7 ++++++ .../Analyser/nsrt/unsealed-derivations.php | 25 +++++++++++++++++++ 2 files changed, 32 insertions(+) create mode 100644 tests/PHPStan/Analyser/nsrt/unsealed-derivations.php diff --git a/src/Type/Constant/ConstantArrayType.php b/src/Type/Constant/ConstantArrayType.php index c7ba038bfe0..ee855aa8ff3 100644 --- a/src/Type/Constant/ConstantArrayType.php +++ b/src/Type/Constant/ConstantArrayType.php @@ -2912,6 +2912,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(); } diff --git a/tests/PHPStan/Analyser/nsrt/unsealed-derivations.php b/tests/PHPStan/Analyser/nsrt/unsealed-derivations.php new file mode 100644 index 00000000000..6f93c1223a7 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/unsealed-derivations.php @@ -0,0 +1,25 @@ +} $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), + ); + } + +} From ace369da2905127fb57fe9c61b668bdb9189730f Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Thu, 7 May 2026 22:07:33 +0200 Subject: [PATCH 114/158] Make `ConstantArrayType::changeKeyCaseArray()` unsealed-aware MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Group 3 unsealed semantics for `array_change_key_case()`. The explicit-key path folds case on `ConstantStringType` keys and passes non-constant keys through. The unsealed slot was silently dropped — for `array{Foo: int, ...}`, the result was `array{foo: int}`, losing the unsealed extras entirely. Carry the unsealed slot through with the same per-leaf rule: - `ConstantStringType` unsealed keys: case-fold the literal value (delegates to the existing `foldConstantStringKeyCase` helper). - Other definite-string unsealed keys: intersect with `AccessoryLowercaseStringType` (CASE_LOWER) / `AccessoryUppercaseStringType` (CASE_UPPER), or a union of both for `null`. After CASE_LOWER, every key in the unsealed range is guaranteed lowercase, so the accessory tracks that precisely. - `UnionType` unsealed keys (e.g. `...`): distribute recursively so the int portion stays intact while only the string portion picks up the accessory. - Non-string unsealed keys (e.g. `...`): pass through — `array_change_key_case` only folds string keys. The new NSRT cases (`lowerCaseUnsealed`, `upperCaseUnsealed`, `mixedKeyUnsealed`) all fail before the fix and pass after. --- src/Type/Constant/ConstantArrayType.php | 61 ++++++++++ .../Analyser/nsrt/unsealed-derivations.php | 112 ++++++++++++++++++ 2 files changed, 173 insertions(+) diff --git a/src/Type/Constant/ConstantArrayType.php b/src/Type/Constant/ConstantArrayType.php index ee855aa8ff3..2877743fa51 100644 --- a/src/Type/Constant/ConstantArrayType.php +++ b/src/Type/Constant/ConstantArrayType.php @@ -25,6 +25,11 @@ 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; @@ -2888,6 +2893,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()); @@ -2937,6 +2947,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 = []; diff --git a/tests/PHPStan/Analyser/nsrt/unsealed-derivations.php b/tests/PHPStan/Analyser/nsrt/unsealed-derivations.php index 6f93c1223a7..4fc46a4b180 100644 --- a/tests/PHPStan/Analyser/nsrt/unsealed-derivations.php +++ b/tests/PHPStan/Analyser/nsrt/unsealed-derivations.php @@ -23,3 +23,115 @@ public function filterUnsealed(array $arr): void } } + +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), + ); + } + +} From 7baa327c837e362759fce3c7de3d7612c8faec54 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Fri, 8 May 2026 09:04:28 +0200 Subject: [PATCH 115/158] Carry unsealed slot through `array_unshift` prepend branch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Group 3 unsealed semantics for `array_unshift()`. The prepend branch in `FuncCallHandler` starts with a fresh `ConstantArrayTypeBuilder` and re-attaches the original constant array's keys/values, but silently dropped the unsealed slot — for `list{int, string, ...}` the result was `array{true, null, int, string}`, losing the unsealed tail. After re-attaching the explicit tail, copy the original CAT's unsealed types onto the builder via `makeUnsealed()`. The non-prepend branch (`array_push`) already preserves it through `createFromConstantArray()`. The new NSRT case (`prependPreservesUnsealed`) fails before the fix and passes after. --- src/Analyser/ExprHandler/FuncCallHandler.php | 5 +++++ .../Analyser/nsrt/unsealed-derivations.php | 18 ++++++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/src/Analyser/ExprHandler/FuncCallHandler.php b/src/Analyser/ExprHandler/FuncCallHandler.php index 5df15e3fd91..102dc00a43a 100644 --- a/src/Analyser/ExprHandler/FuncCallHandler.php +++ b/src/Analyser/ExprHandler/FuncCallHandler.php @@ -699,6 +699,11 @@ 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(); diff --git a/tests/PHPStan/Analyser/nsrt/unsealed-derivations.php b/tests/PHPStan/Analyser/nsrt/unsealed-derivations.php index 4fc46a4b180..3245be5155b 100644 --- a/tests/PHPStan/Analyser/nsrt/unsealed-derivations.php +++ b/tests/PHPStan/Analyser/nsrt/unsealed-derivations.php @@ -135,3 +135,21 @@ public function unknownCase(array $arr, int $case): void } } + +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); + } + +} From 52021ac5807f1b2fe11461d41e59e169884b6dbe Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Fri, 8 May 2026 09:24:26 +0200 Subject: [PATCH 116/158] Carry unsealed slot through `count()` narrowing (unbounded max) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Group 3 unsealed semantics for `TypeSpecifier::specifyTypesForCount`. The IntegerRange-with-unbounded-max branch (e.g. `count(\$a) >= 5`) needs three changes for unsealed inputs: 1. The `elseif (\$arrayType->isConstantArray()->yes())` probe runs `for (\$i = min;; \$i++)` and breaks on `hasOffset->no()`. For an unsealed CAT, `hasOffset` is `Maybe` for every in-range key and the probe runs until `ARRAY_COUNT_LIMIT` bails (slow + lossy; in practice the prior bug-`count >= 5` test exhausted memory). Detect unsealed input via `isUnsealed()->yes()` and stop probing once the explicit keys are exhausted. 2. After the builder is populated, attach the unsealed slot via `\$builder->makeUnsealed()` — only for the unbounded-max branch, since a bounded-max range caps the result size and the unsealed extras can't fit. 3. The truthy-shortcut at line 1541 returns just a `HasOffsetValueType(N-1, mixed)` constraint when the input has no optional keys. Intersecting an unsealed CAT with a single-slot constraint produces `NeverType` (the shape's slot semantics don't compose with a flat hasOffset). Extend the existing `\$hasOptionalKeys` gate to also catch real unsealed inputs so they fall through to the full builder-based narrowing. Use `isUnsealed()->yes()` everywhere instead of `getUnsealedTypes() !== null` — the latter would also match `[NeverType, NeverType]` (the explicit "sealed" sentinel under bleeding edge) and break sealed-CAT narrowing in `bug-4700`, `bug11480`, `list-count`. The new NSRT case (`geMinPreservesUnsealed`) hangs / OOMs before the fix and produces `array{int, string, float, float, float, ...}` after. --- src/Analyser/TypeSpecifier.php | 20 +++++++++---------- src/Type/Constant/ConstantArrayType.php | 16 +++++++++++++++ .../Analyser/nsrt/unsealed-derivations.php | 19 ++++++++++++++++++ 3 files changed, 45 insertions(+), 10 deletions(-) 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/Type/Constant/ConstantArrayType.php b/src/Type/Constant/ConstantArrayType.php index 2877743fa51..29542e34914 100644 --- a/src/Type/Constant/ConstantArrayType.php +++ b/src/Type/Constant/ConstantArrayType.php @@ -1794,12 +1794,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()]; } } @@ -1813,6 +1822,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 diff --git a/tests/PHPStan/Analyser/nsrt/unsealed-derivations.php b/tests/PHPStan/Analyser/nsrt/unsealed-derivations.php index 3245be5155b..b3094b5fbf1 100644 --- a/tests/PHPStan/Analyser/nsrt/unsealed-derivations.php +++ b/tests/PHPStan/Analyser/nsrt/unsealed-derivations.php @@ -153,3 +153,22 @@ public function prependPreservesUnsealed(array $arr): void } } + +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); + } + } + +} From 3f419282771183a329d5c42bab10e14bee62d728 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Fri, 8 May 2026 09:26:20 +0200 Subject: [PATCH 117/158] Carry unsealed slots through `array_merge` all-constant fast path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Group 3 unsealed semantics for `ArrayMergeFunctionDynamicReturnTypeExtension`. The all-constant fast path (lines 80-103) walks each argument's explicit keys into a fresh builder but silently drops the unsealed slots — for `array_merge(array{a: int, ...}, ['b' => true])` the result was `array{a: int, b: true}`, losing the `` extras. Collect every input CAT's unsealed `[keyType, valueType]` tuple during the walk; after writing the explicit keys, union all the collected tuples and attach them via `\$builder->makeUnsealed()`. The unsealed slot of the result represents "any other key not in the explicit set could come from any of the merged inputs". Use `isUnsealed()->yes()` to detect real unsealed extras (not the `[NeverType, NeverType]` sealed sentinel used under bleeding edge). The new NSRT case (`mergePreservesUnsealed`) fails before the fix and passes after. The companion `array_replace` case is still failing — to be addressed in the next commit. --- ...ergeFunctionDynamicReturnTypeExtension.php | 18 ++++++++++ .../Analyser/nsrt/unsealed-derivations.php | 36 +++++++++++++++++++ 2 files changed, 54 insertions(+) diff --git a/src/Type/Php/ArrayMergeFunctionDynamicReturnTypeExtension.php b/src/Type/Php/ArrayMergeFunctionDynamicReturnTypeExtension.php index 5ae8cbc48b9..9148eb6dd28 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,13 @@ public function getTypeFromFunctionCall(FunctionReflection $functionReflection, foreach ($constantArray->getKeyTypes() as $keyType) { $keyTypes[$keyType->getValue()] = $keyType; } + if ($constantArray->isUnsealed()->yes()) { + $unsealedTypes = $constantArray->getUnsealedTypes(); + if ($unsealedTypes !== null) { + $unsealedKeys[] = $unsealedTypes[0]; + $unsealedValues[] = $unsealedTypes[1]; + } + } } foreach ($keyTypes as $keyType) { @@ -99,6 +108,15 @@ 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(); } diff --git a/tests/PHPStan/Analyser/nsrt/unsealed-derivations.php b/tests/PHPStan/Analyser/nsrt/unsealed-derivations.php index b3094b5fbf1..233658ae2b9 100644 --- a/tests/PHPStan/Analyser/nsrt/unsealed-derivations.php +++ b/tests/PHPStan/Analyser/nsrt/unsealed-derivations.php @@ -154,6 +154,42 @@ public function prependPreservesUnsealed(array $arr): void } +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 CountNarrowing { From bf996e0d86def8bcb923a8fd4a8e8cfe0326f15a Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Fri, 8 May 2026 09:27:33 +0200 Subject: [PATCH 118/158] Carry unsealed slots through `array_replace` all-constant fast path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same shape as the previous `array_merge` fix — `array_replace`'s all-constant fast path also walks each argument's explicit keys into a fresh builder and dropped the unsealed slots. Apply the same union-and-attach treatment. The `replacePreservesUnsealed` NSRT case from the previous commit (which was failing on `array_replace` only) now passes. --- ...ArrayReplaceFunctionReturnTypeExtension.php | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/src/Type/Php/ArrayReplaceFunctionReturnTypeExtension.php b/src/Type/Php/ArrayReplaceFunctionReturnTypeExtension.php index c53f0929d5d..690c1bd2a8c 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,13 @@ public function getTypeFromFunctionCall(FunctionReflection $functionReflection, foreach ($constantArray->getKeyTypes() as $keyType) { $keyTypes[$keyType->getValue()] = $keyType; } + if ($constantArray->isUnsealed()->yes()) { + $unsealedTypes = $constantArray->getUnsealedTypes(); + if ($unsealedTypes !== null) { + $unsealedKeys[] = $unsealedTypes[0]; + $unsealedValues[] = $unsealedTypes[1]; + } + } } foreach ($keyTypes as $keyType) { @@ -100,6 +109,15 @@ 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(); } From 98e869916a58bf31079191a93074ac2b1b059f52 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Fri, 8 May 2026 09:29:28 +0200 Subject: [PATCH 119/158] Carry unsealed slot through `array_filter` callback path Group 3 unsealed semantics for the callback variant of `array_filter` in `ArrayFilterFunctionReturnTypeHelper::filterByTruthyValue`. The explicit-key loop already runs each pair through `processKeyAndItemType()` to apply the predicate's truthy projection; the unsealed slot was simply skipped, so for `array_filter(array{a: int, ...}, fn (\$v) => \$v !== null)` the result was `array{a: int}`, losing the `` extras. After the explicit loop, run the same projection over the unsealed `[keyType, valueType]` tuple. Drop the unsealed slot if either the key or the value narrows to `NeverType` (the predicate rejects every possible extra). Use `isUnsealed()->yes()` to detect real unsealed extras (not the `[NeverType, NeverType]` sealed sentinel under bleeding edge). The new NSRT case (`preserveUnsealed`) fails before the fix and passes after. --- .../ArrayFilterFunctionReturnTypeHelper.php | 13 +++++++++++++ .../Analyser/nsrt/unsealed-derivations.php | 19 +++++++++++++++++++ 2 files changed, 32 insertions(+) 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/tests/PHPStan/Analyser/nsrt/unsealed-derivations.php b/tests/PHPStan/Analyser/nsrt/unsealed-derivations.php index 233658ae2b9..b53f3890646 100644 --- a/tests/PHPStan/Analyser/nsrt/unsealed-derivations.php +++ b/tests/PHPStan/Analyser/nsrt/unsealed-derivations.php @@ -154,6 +154,25 @@ public function prependPreservesUnsealed(array $arr): void } +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 ArrayMerge { From 1a8af3ea81cf0a07bc3883d4a95767ef0afffdfa Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Fri, 8 May 2026 09:31:45 +0200 Subject: [PATCH 120/158] Carry unsealed slot through `array_column` constant-array path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Group 3 unsealed semantics for `ArrayColumnHelper::handleConstantArray`. The explicit-key loop already plucks each row's column via `getOffsetOrProperty()` and rebuilds via the builder; the unsealed slot was simply skipped, so for `array_column(list{Row, Row, ...}, 'name')` the result was `array{string, string}`, losing the unsealed tail. After the explicit loop, run the same column lookup on `unsealedTypes[1]` (the unsealed value, i.e. the row type at the unsealed keys). Drop the unsealed slot if the column extraction narrows to `Never` or returns `Maybe` certainty (matches the existing per-row early-`return null`). When `\$indexType` is non-null, additionally look up the index key on the unsealed row type — same logic as the explicit-row path. With a null `\$indexType` the unsealed keys retain their original range (typically `int`). The new NSRT case (`preserveUnsealed`) fails before the fix (`array{string, string}`) and passes after. --- src/Type/Php/ArrayColumnHelper.php | 29 +++++++++++++++++++ .../Analyser/nsrt/unsealed-derivations.php | 20 +++++++++++++ 2 files changed, 49 insertions(+) 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/tests/PHPStan/Analyser/nsrt/unsealed-derivations.php b/tests/PHPStan/Analyser/nsrt/unsealed-derivations.php index b53f3890646..8937d60a248 100644 --- a/tests/PHPStan/Analyser/nsrt/unsealed-derivations.php +++ b/tests/PHPStan/Analyser/nsrt/unsealed-derivations.php @@ -173,6 +173,26 @@ public function preserveUnsealed(array $arr): void } +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 ArrayMerge { From e215c058f7fa35829936b1f62cb99908a8e9f2c5 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Fri, 8 May 2026 09:35:13 +0200 Subject: [PATCH 121/158] Carry unsealed slot through `filter_var_array` constant-input path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Group 3 unsealed semantics for `FilterVarArrayDynamicReturnTypeExtension`. The constant-input path (`\$filterArgType instanceof ConstantIntegerType` or constant-array filter) walks the input's explicit keys through `filterFunctionReturnTypeHelper->getType()` and attaches each filtered value to a fresh builder. The unsealed slot was silently dropped — for `filter_var_array(array{a: int, ...}, FILTER_VALIDATE_INT)` the result was `array{a: int}`, losing the `` extras. After the explicit-key loop, run the same filter resolution over the input's unsealed value type and attach the result via `\$builder->makeUnsealed()`. The unsealed key range comes through unchanged (filtering changes values, not key shape). Apply the `addEmpty` `null`-injection on the unsealed value too, matching the per-key path. For the `ConstantIntegerType` filter case the same scalar filter applies to every value (including unsealed); for the rest, fall back to a `MixedType` filter spec via `fetchFilter()` — the constant-array filter case can't have a per-unsealed-key spec. The new NSRT case (`preserveUnsealed`) fails before the fix and passes after. --- ...lterVarArrayDynamicReturnTypeExtension.php | 21 +++++++++++++++++++ .../Analyser/nsrt/unsealed-derivations.php | 20 ++++++++++++++++++ 2 files changed, 41 insertions(+) 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/tests/PHPStan/Analyser/nsrt/unsealed-derivations.php b/tests/PHPStan/Analyser/nsrt/unsealed-derivations.php index 8937d60a248..96eb068f2b1 100644 --- a/tests/PHPStan/Analyser/nsrt/unsealed-derivations.php +++ b/tests/PHPStan/Analyser/nsrt/unsealed-derivations.php @@ -193,6 +193,26 @@ public function preserveUnsealed(array $rows): void } +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 { From e7e2c3bcf7613cff063be0b047b24865b545a74c Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Fri, 8 May 2026 11:51:27 +0200 Subject: [PATCH 122/158] Make `array_unshift` produce unsealed CAT for assoc + non-constant unpack MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When `array_unshift(\$arr, ...\$items)` is called with `\$items` of an unknown size (e.g. a generic `list`), the prior degradation discarded both the input's explicit shape and the "unknown count of prepended values" — for `array_unshift(array{a: int, b: string}, ...\$listOfFloat)` the result was `non-empty-array`, losing the `a: int, b: string` precision. Split the degradation by `isList()`: - **List input**: still degrade to `non-empty-list` — every original index is shifted by the unknown count, so the precise indices can't be recovered. - **Associative input**: keep the string keys explicit and add an unsealed `int` slot whose value is the union of the int-keyed values that the unpack-arg loop wrote into the builder. The result becomes `array{a: int, b: string, ...}` — the unknown count is faithfully expressed by the unsealed slot. When the input CAT was itself unsealed, union the existing unsealed key/value with the new unsealed-int slot so both sources of "more entries possible" are preserved. Two new NSRT cases (`unshiftListWithUnpack` locking in the unchanged list behavior, and `unshiftAssocWithUnpack` failing before / passing after for the associative case). --- phpstan-baseline.neon | 2 +- src/Analyser/ExprHandler/FuncCallHandler.php | 52 ++++++++++++++++--- ...ergeFunctionDynamicReturnTypeExtension.php | 16 +++--- ...rrayReplaceFunctionReturnTypeExtension.php | 16 +++--- .../Analyser/nsrt/unsealed-derivations.php | 32 ++++++++++++ 5 files changed, 97 insertions(+), 21 deletions(-) diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 2b3748ff2c6..cc53f0f2e31 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -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 - diff --git a/src/Analyser/ExprHandler/FuncCallHandler.php b/src/Analyser/ExprHandler/FuncCallHandler.php index 102dc00a43a..c784dda48c0 100644 --- a/src/Analyser/ExprHandler/FuncCallHandler.php +++ b/src/Analyser/ExprHandler/FuncCallHandler.php @@ -709,14 +709,50 @@ static function (?Type $offsetType, Type $valueType, bool $optional) use (&$arra $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/Type/Php/ArrayMergeFunctionDynamicReturnTypeExtension.php b/src/Type/Php/ArrayMergeFunctionDynamicReturnTypeExtension.php index 9148eb6dd28..20c29b40d63 100644 --- a/src/Type/Php/ArrayMergeFunctionDynamicReturnTypeExtension.php +++ b/src/Type/Php/ArrayMergeFunctionDynamicReturnTypeExtension.php @@ -90,13 +90,17 @@ public function getTypeFromFunctionCall(FunctionReflection $functionReflection, foreach ($constantArray->getKeyTypes() as $keyType) { $keyTypes[$keyType->getValue()] = $keyType; } - if ($constantArray->isUnsealed()->yes()) { - $unsealedTypes = $constantArray->getUnsealedTypes(); - if ($unsealedTypes !== null) { - $unsealedKeys[] = $unsealedTypes[0]; - $unsealedValues[] = $unsealedTypes[1]; - } + if (!$constantArray->isUnsealed()->yes()) { + continue; + } + + $unsealedTypes = $constantArray->getUnsealedTypes(); + if ($unsealedTypes === null) { + continue; } + + $unsealedKeys[] = $unsealedTypes[0]; + $unsealedValues[] = $unsealedTypes[1]; } foreach ($keyTypes as $keyType) { diff --git a/src/Type/Php/ArrayReplaceFunctionReturnTypeExtension.php b/src/Type/Php/ArrayReplaceFunctionReturnTypeExtension.php index 690c1bd2a8c..48c26330ce1 100644 --- a/src/Type/Php/ArrayReplaceFunctionReturnTypeExtension.php +++ b/src/Type/Php/ArrayReplaceFunctionReturnTypeExtension.php @@ -91,13 +91,17 @@ public function getTypeFromFunctionCall(FunctionReflection $functionReflection, foreach ($constantArray->getKeyTypes() as $keyType) { $keyTypes[$keyType->getValue()] = $keyType; } - if ($constantArray->isUnsealed()->yes()) { - $unsealedTypes = $constantArray->getUnsealedTypes(); - if ($unsealedTypes !== null) { - $unsealedKeys[] = $unsealedTypes[0]; - $unsealedValues[] = $unsealedTypes[1]; - } + if (!$constantArray->isUnsealed()->yes()) { + continue; + } + + $unsealedTypes = $constantArray->getUnsealedTypes(); + if ($unsealedTypes === null) { + continue; } + + $unsealedKeys[] = $unsealedTypes[0]; + $unsealedValues[] = $unsealedTypes[1]; } foreach ($keyTypes as $keyType) { diff --git a/tests/PHPStan/Analyser/nsrt/unsealed-derivations.php b/tests/PHPStan/Analyser/nsrt/unsealed-derivations.php index 96eb068f2b1..efdecc0030e 100644 --- a/tests/PHPStan/Analyser/nsrt/unsealed-derivations.php +++ b/tests/PHPStan/Analyser/nsrt/unsealed-derivations.php @@ -249,6 +249,38 @@ public function replacePreservesUnsealed(array $arr): void } +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 CountNarrowing { From 095b8ff14917f8e05dd05c15ea69057b5d69ae7f Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Tue, 12 May 2026 15:14:39 +0200 Subject: [PATCH 123/158] Make `array_merge` slow path produce unsealed CAT for non-constant unpack When at least one input is non-constant and all CAT inputs have explicit sealedness (not `isUnsealed()->maybe()`), build the result as `array{known-keys, ...}` instead of the equivalent `ArrayType & HasOffsetValueType(...) & NonEmptyArrayType` intersection. The two are semantically equivalent but the unsealed CAT form is far more readable in error messages and composes naturally with other unsealed-CAT operations. Gated on bleeding-edge representation: when any input CAT has `isUnsealed()->maybe()` (the pre-bleeding-edge legacy form where sealedness was implicit), keep the original intersection output to preserve behaviour for non-bleeding-edge users. Co-Authored-By: Claude Opus 4.7 (1M context) --- ...ergeFunctionDynamicReturnTypeExtension.php | 62 +++++++++++++++++++ .../PHPStan/Analyser/nsrt/array-functions.php | 4 +- .../nsrt/array-merge-const-non-const.php | 38 ++++++------ tests/PHPStan/Analyser/nsrt/bug-2911.php | 18 +++--- .../nsrt/generalize-scope-recursive.php | 2 +- 5 files changed, 93 insertions(+), 31 deletions(-) diff --git a/src/Type/Php/ArrayMergeFunctionDynamicReturnTypeExtension.php b/src/Type/Php/ArrayMergeFunctionDynamicReturnTypeExtension.php index 20c29b40d63..637c4908d7a 100644 --- a/src/Type/Php/ArrayMergeFunctionDynamicReturnTypeExtension.php +++ b/src/Type/Php/ArrayMergeFunctionDynamicReturnTypeExtension.php @@ -125,6 +125,24 @@ public function getTypeFromFunctionCall(FunctionReflection $functionReflection, } $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; @@ -141,6 +159,21 @@ public function getTypeFromFunctionCall(FunctionReflection $functionReflection, ]; } } + } elseif ($canRebuildAsUnsealedCat) { + $nonConstantArrayWasUnpacked = true; + $iterableValue = $argType->getIterableValueType(); + $unsealedKeyTypes[] = $argType->getIterableKeyType(); + $unsealedValueTypes[] = $iterableValue; + foreach ($offsetTypes as $key => [$hasOffsetValue, $offsetValueType]) { + // Existing 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. @@ -193,6 +226,35 @@ 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(); + foreach ($offsetTypes as $key => [$hasOffsetValue, $offsetType]) { + if (is_int($key) || $hasOffsetValue->no()) { + continue; + } + $builder->setOffsetValueType(new ConstantStringType((string) $key), $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/tests/PHPStan/Analyser/nsrt/array-functions.php b/tests/PHPStan/Analyser/nsrt/array-functions.php index ab06eb4256a..6255e09caf7 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/bug-2911.php b/tests/PHPStan/Analyser/nsrt/bug-2911.php index b845c0189b5..226abe9d9b4 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-2911.php +++ b/tests/PHPStan/Analyser/nsrt/bug-2911.php @@ -49,32 +49,32 @@ public function __construct(MutatorConfig $config) 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 $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/generalize-scope-recursive.php b/tests/PHPStan/Analyser/nsrt/generalize-scope-recursive.php index 8d13c5526fe..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{foo?: array}', $data); + assertType('array{}|array{foo: array>}', $data); } /** From 4fc494b71ce8e8594860d5bd5704a43fb8b5ccb3 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Tue, 12 May 2026 15:14:49 +0200 Subject: [PATCH 124/158] Make `array_replace` slow path produce unsealed CAT for non-constant unpack Mirrors the `array_merge` change: when at least one input is non-constant and all CAT inputs have explicit sealedness (not `isUnsealed()->maybe()`), build the result as `array{known-keys, ...}` instead of the equivalent intersection of `ArrayType`, `HasOffsetValueType`/`HasOffsetType`, and `NonEmptyArrayType`. Same bleeding-edge gate so non-bleeding-edge users keep the original output. Co-Authored-By: Claude Opus 4.7 (1M context) --- ...rrayReplaceFunctionReturnTypeExtension.php | 51 +++++++++++++++++++ .../nsrt/array-replace-const-non-const.php | 40 +++++++-------- tests/PHPStan/Analyser/nsrt/array-replace.php | 8 +-- 3 files changed, 75 insertions(+), 24 deletions(-) diff --git a/src/Type/Php/ArrayReplaceFunctionReturnTypeExtension.php b/src/Type/Php/ArrayReplaceFunctionReturnTypeExtension.php index 48c26330ce1..834175222a2 100644 --- a/src/Type/Php/ArrayReplaceFunctionReturnTypeExtension.php +++ b/src/Type/Php/ArrayReplaceFunctionReturnTypeExtension.php @@ -126,6 +126,22 @@ public function getTypeFromFunctionCall(FunctionReflection $functionReflection, } $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; @@ -142,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. @@ -194,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/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)); } From 4585c2d647c4a9f8033980f036af869132be8124 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Tue, 12 May 2026 16:59:44 +0200 Subject: [PATCH 125/158] Preserve int-keyed values from CATs in `array_merge` slow path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous commit rebuilt the slow-path result as `array{string-keys, ...}` but dropped int-keyed values from sealed CAT inputs entirely. `array_merge` renumbers int keys rather than overwriting them, so a CAT's `1 => stdClass` survives in the output under a renumbered int key — but with the int key gone from the offsets and not redirected anywhere, the result type lost both the int key contribution and its value. Push CAT int-keyed values into the unsealed slot under an `int` key instead. Also skip the int-key broadening when a non-constant input is mixed in: `array_merge` does not overwrite int keys with a later arg's value, so broadening them with the non-const's iterableValue was overstating the value type. Co-Authored-By: Claude Opus 4.7 (1M context) --- ...ergeFunctionDynamicReturnTypeExtension.php | 33 +++++++++++++++---- .../PHPStan/Analyser/nsrt/array-functions.php | 4 +-- 2 files changed, 29 insertions(+), 8 deletions(-) diff --git a/src/Type/Php/ArrayMergeFunctionDynamicReturnTypeExtension.php b/src/Type/Php/ArrayMergeFunctionDynamicReturnTypeExtension.php index 637c4908d7a..b82ce7bb971 100644 --- a/src/Type/Php/ArrayMergeFunctionDynamicReturnTypeExtension.php +++ b/src/Type/Php/ArrayMergeFunctionDynamicReturnTypeExtension.php @@ -165,10 +165,16 @@ public function getTypeFromFunctionCall(FunctionReflection $functionReflection, $unsealedKeyTypes[] = $argType->getIterableKeyType(); $unsealedValueTypes[] = $iterableValue; foreach ($offsetTypes as $key => [$hasOffsetValue, $offsetValueType]) { - // Existing 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. + 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), @@ -234,11 +240,26 @@ public function getTypeFromFunctionCall(FunctionReflection $functionReflection, // result. if ($nonConstantArrayWasUnpacked) { $builder = ConstantArrayTypeBuilder::createEmpty(); + $intKeyValuesFromCats = []; foreach ($offsetTypes as $key => [$hasOffsetValue, $offsetType]) { - if (is_int($key) || $hasOffsetValue->no()) { + 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; } - $builder->setOffsetValueType(new ConstantStringType((string) $key), $offsetType, !$hasOffsetValue->yes()); + 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), diff --git a/tests/PHPStan/Analyser/nsrt/array-functions.php b/tests/PHPStan/Analyser/nsrt/array-functions.php index 6255e09caf7..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('array{foo: stdClass, ...}', array_merge($generalStringKeys, $stringOrIntegerKeys)); -assertType('array{foo: int|stdClass, ...}', 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]])); From 1f6a697d18f4f3e48483ca74fd997373fb8d5ad4 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Wed, 13 May 2026 07:58:18 +0200 Subject: [PATCH 126/158] Honour unsealed shape in `ConstantArrayType::isCallable()` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `doFindTypeAndMethodNames()` previously rejected anything other than exactly two explicit keys, so two related cases came out wrong: - `array{0: object, 1: 'method', ...}` returned `Yes` even though the unsealed slot could add extra keys, voiding the `[classOrObject, method]` shape. Should be `Maybe`. - `array{0: object, ...}` returned `No` because only one explicit key was present, even though a `1 => 'method'` extra is exactly what the unsealed slot might supply. Should be `Maybe`. Drive the slot-filling from the unsealed types: if a slot is missing, check that the unsealed key range covers the missing index *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 the CAT could ever be callable — return `[]` so `isCallable()` answers `No`. When the array is unsealed, downgrade the per-method certainty to `Maybe` — extras at positions other than 0/1 would void callability, but the unsealed slot describes "zero or more" extras so it's genuinely uncertain. Added tests cover both wrong-value-type unsealed slots (int can't be object|class-string nor non-falsy-string) and the symmetric "only key 1 explicit" case, plus the >2-keys and stray-key sealed disqualifiers. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/Type/Constant/ConstantArrayType.php | 61 ++++++- .../Type/Constant/ConstantArrayTypeTest.php | 170 ++++++++++++++++++ 2 files changed, 228 insertions(+), 3 deletions(-) diff --git a/src/Type/Constant/ConstantArrayType.php b/src/Type/Constant/ConstantArrayType.php index 29542e34914..bf33b77a98f 100644 --- a/src/Type/Constant/ConstantArrayType.php +++ b/src/Type/Constant/ConstantArrayType.php @@ -36,6 +36,7 @@ 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; @@ -52,6 +53,7 @@ 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; @@ -981,7 +983,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 []; } @@ -993,11 +1005,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) { @@ -1045,6 +1093,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); } diff --git a/tests/PHPStan/Type/Constant/ConstantArrayTypeTest.php b/tests/PHPStan/Type/Constant/ConstantArrayTypeTest.php index 28167c294c9..2bd1a6452ad 100644 --- a/tests/PHPStan/Type/Constant/ConstantArrayTypeTest.php +++ b/tests/PHPStan/Type/Constant/ConstantArrayTypeTest.php @@ -11,6 +11,7 @@ use PHPStan\Type\Accessory\HasOffsetValueType; use PHPStan\Type\ArrayType; use PHPStan\Type\CallableType; +use PHPStan\Type\ClassStringType; use PHPStan\Type\Generic\GenericClassStringType; use PHPStan\Type\Generic\TemplateTypeFactory; use PHPStan\Type\Generic\TemplateTypeScope; @@ -23,6 +24,7 @@ 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; @@ -1209,6 +1211,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 From 71efd0d354613521d15cd7badc5913c35a481863 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Mon, 18 May 2026 18:44:48 +0200 Subject: [PATCH 127/158] isConstantValue --- src/Type/Constant/ConstantArrayType.php | 4 ++++ src/Type/VerbosityLevel.php | 6 +++++- .../Type/Constant/ConstantArrayTypeTest.php | 14 +++++++------- 3 files changed, 16 insertions(+), 8 deletions(-) diff --git a/src/Type/Constant/ConstantArrayType.php b/src/Type/Constant/ConstantArrayType.php index bf33b77a98f..c4e744e7df7 100644 --- a/src/Type/Constant/ConstantArrayType.php +++ b/src/Type/Constant/ConstantArrayType.php @@ -332,6 +332,10 @@ public function getItemType(): Type public function isConstantValue(): TrinaryLogic { + if ($this->isUnsealed()->yes()) { + return TrinaryLogic::createNo(); + } + return TrinaryLogic::createYes(); } diff --git a/src/Type/VerbosityLevel.php b/src/Type/VerbosityLevel.php index 73513641eef..bf488ee83ad 100644 --- a/src/Type/VerbosityLevel.php +++ b/src/Type/VerbosityLevel.php @@ -141,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); } diff --git a/tests/PHPStan/Type/Constant/ConstantArrayTypeTest.php b/tests/PHPStan/Type/Constant/ConstantArrayTypeTest.php index 2bd1a6452ad..c23fde3f6bd 100644 --- a/tests/PHPStan/Type/Constant/ConstantArrayTypeTest.php +++ b/tests/PHPStan/Type/Constant/ConstantArrayTypeTest.php @@ -1272,7 +1272,7 @@ public static function dataIsCallable(): iterable yield [ new ConstantArrayType([ - new ConstantIntegerType(0) + new ConstantIntegerType(0), ], [ new ObjectWithoutClassType(), ], unsealed: [new IntegerType(), new StringType()]), @@ -1281,7 +1281,7 @@ public static function dataIsCallable(): iterable yield [ new ConstantArrayType([ - new ConstantIntegerType(0) + new ConstantIntegerType(0), ], [ new ObjectWithoutClassType(), ], unsealed: $sealed), @@ -1290,7 +1290,7 @@ public static function dataIsCallable(): iterable yield [ new ConstantArrayType([ - new ConstantIntegerType(0) + new ConstantIntegerType(0), ], [ new ObjectWithoutClassType(), ], unsealed: [IntegerRangeType::createAllGreaterThanOrEqualTo(2), new StringType()]), @@ -1299,7 +1299,7 @@ public static function dataIsCallable(): iterable yield [ new ConstantArrayType([ - new ConstantIntegerType(0) + new ConstantIntegerType(0), ], [ new ObjectWithoutClassType(), ], unsealed: [new StringType(), new StringType()]), @@ -1310,7 +1310,7 @@ public static function dataIsCallable(): iterable // a non-falsy-string (int → not a string at all). yield [ new ConstantArrayType([ - new ConstantIntegerType(0) + new ConstantIntegerType(0), ], [ new ObjectWithoutClassType(), ], unsealed: [new IntegerType(), new IntegerType()]), @@ -1321,7 +1321,7 @@ public static function dataIsCallable(): iterable // object|class-string; int can never be that. yield [ new ConstantArrayType([ - new ConstantIntegerType(1) + new ConstantIntegerType(1), ], [ new ConstantStringType('bind'), ], unsealed: [new IntegerType(), new IntegerType()]), @@ -1333,7 +1333,7 @@ public static function dataIsCallable(): iterable // it could line up. yield [ new ConstantArrayType([ - new ConstantIntegerType(1) + new ConstantIntegerType(1), ], [ new ConstantStringType('bind'), ], unsealed: [new IntegerType(), new StringType()]), From 65917b68bdde854a5dc09b569070403199630826 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Tue, 19 May 2026 10:13:45 +0200 Subject: [PATCH 128/158] More ConstantArrayType methods --- src/Type/Constant/ConstantArrayType.php | 28 +++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/src/Type/Constant/ConstantArrayType.php b/src/Type/Constant/ConstantArrayType.php index c4e744e7df7..e601ee7dab2 100644 --- a/src/Type/Constant/ConstantArrayType.php +++ b/src/Type/Constant/ConstantArrayType.php @@ -1967,6 +1967,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); } @@ -1980,6 +1990,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); } @@ -1993,6 +2013,10 @@ public function getFirstIterableValueType(): Type } } + if ($this->isUnsealed()->yes() && $this->unsealed !== null) { + $valueTypes[] = $this->unsealed[1]; + } + return TypeCombinator::union(...$valueTypes); } @@ -2006,6 +2030,10 @@ public function getLastIterableValueType(): Type } } + if ($this->isUnsealed()->yes() && $this->unsealed !== null) { + $valueTypes[] = $this->unsealed[1]; + } + return TypeCombinator::union(...$valueTypes); } From 8202c48cfad59c1eb358c75f39a63e4d0cb65ca3 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Tue, 19 May 2026 10:20:42 +0200 Subject: [PATCH 129/158] Cover `ConstantArrayType::getFiniteTypes()` with a data provider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Twelve cases pinning the current behaviour: empty array, single and multi-key all-finite shapes, union-value cartesian-product expansion, `bool` → `true|false` fork, non-finite value short-circuiting the whole result to `[]`, mixed finite + non-finite, single optional key forking with/without, all-optional, optional + union combined, and the >128 cartesian-product bail-out via `CALCULATE_SCALARS_LIMIT`. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/Type/Constant/ConstantArrayType.php | 4 + .../Type/Constant/ConstantArrayTypeTest.php | 216 ++++++++++++++++++ 2 files changed, 220 insertions(+) diff --git a/src/Type/Constant/ConstantArrayType.php b/src/Type/Constant/ConstantArrayType.php index e601ee7dab2..0e55c8069f6 100644 --- a/src/Type/Constant/ConstantArrayType.php +++ b/src/Type/Constant/ConstantArrayType.php @@ -3183,6 +3183,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. diff --git a/tests/PHPStan/Type/Constant/ConstantArrayTypeTest.php b/tests/PHPStan/Type/Constant/ConstantArrayTypeTest.php index c23fde3f6bd..f83711027bf 100644 --- a/tests/PHPStan/Type/Constant/ConstantArrayTypeTest.php +++ b/tests/PHPStan/Type/Constant/ConstantArrayTypeTest.php @@ -10,6 +10,7 @@ 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\Generic\GenericClassStringType; @@ -1643,4 +1644,219 @@ 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())), + ); + } + } From ccb668b4e3fd350a5ef35057e858ff29609b8d39 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Tue, 19 May 2026 10:30:08 +0200 Subject: [PATCH 130/158] hasTemplateOrLateResolvableType --- src/Type/Constant/ConstantArrayType.php | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/Type/Constant/ConstantArrayType.php b/src/Type/Constant/ConstantArrayType.php index 0e55c8069f6..812926d3391 100644 --- a/src/Type/Constant/ConstantArrayType.php +++ b/src/Type/Constant/ConstantArrayType.php @@ -3247,6 +3247,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; } From 9ee0108f819485441997364afadb60c83bda3bf8 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Tue, 19 May 2026 13:35:18 +0200 Subject: [PATCH 131/158] Carry unsealed slot through `ConstantArrayType::flipArray()` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `array_flip` swaps keys and values pair-by-pair. For an unsealed source `array{a: 'foo', b: 'bar', ...}`, the explicit entries flip as before and the unsealed slot becomes `` — `unsealed[1]->toArrayKey()` lands in the new key slot, `unsealed[0]` in the new value slot. Sealed stays sealed. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/Type/Constant/ConstantArrayType.php | 5 ++++ .../Analyser/nsrt/unsealed-derivations.php | 24 +++++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/src/Type/Constant/ConstantArrayType.php b/src/Type/Constant/ConstantArrayType.php index 812926d3391..fb44d7f054f 100644 --- a/src/Type/Constant/ConstantArrayType.php +++ b/src/Type/Constant/ConstantArrayType.php @@ -1496,6 +1496,11 @@ public function flipArray(): Type ); } + if ($this->isUnsealed()->yes() && $this->unsealed !== null) { + [$unsealedKey, $unsealedValue] = $this->unsealed; + $builder->makeUnsealed($unsealedValue->toArrayKey(), $unsealedKey); + } + return $builder->getArray(); } diff --git a/tests/PHPStan/Analyser/nsrt/unsealed-derivations.php b/tests/PHPStan/Analyser/nsrt/unsealed-derivations.php index efdecc0030e..87220395415 100644 --- a/tests/PHPStan/Analyser/nsrt/unsealed-derivations.php +++ b/tests/PHPStan/Analyser/nsrt/unsealed-derivations.php @@ -281,6 +281,30 @@ public function unshiftAssocWithUnpack(array $sealed, array $unknownItems): void } +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 CountNarrowing { From 805bdb8047643efcbb408400631dd5312ea96b21 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Tue, 19 May 2026 13:36:53 +0200 Subject: [PATCH 132/158] Carry unsealed slot through `ConstantArrayType::fillKeysArray()` `array_fill_keys` uses the source's *values* as result keys (after `toArrayKey()`), filling every value slot with `\$valueType`. On an unsealed source, the unsealed value type becomes the unsealed key type of the result, and `\$valueType` fills the new unsealed value slot. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/Type/Constant/ConstantArrayType.php | 5 +++++ .../Analyser/nsrt/unsealed-derivations.php | 18 ++++++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/src/Type/Constant/ConstantArrayType.php b/src/Type/Constant/ConstantArrayType.php index fb44d7f054f..c97a1562b39 100644 --- a/src/Type/Constant/ConstantArrayType.php +++ b/src/Type/Constant/ConstantArrayType.php @@ -1480,6 +1480,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(); } diff --git a/tests/PHPStan/Analyser/nsrt/unsealed-derivations.php b/tests/PHPStan/Analyser/nsrt/unsealed-derivations.php index 87220395415..8aca74d3c9a 100644 --- a/tests/PHPStan/Analyser/nsrt/unsealed-derivations.php +++ b/tests/PHPStan/Analyser/nsrt/unsealed-derivations.php @@ -305,6 +305,24 @@ public function flipSealedStaysSealed(array $sealed): void } +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 CountNarrowing { From ffb3adfd4507c3f02b03054580b9ef75019a536b Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Tue, 19 May 2026 13:38:28 +0200 Subject: [PATCH 133/158] Carry unsealed slot through `ConstantArrayType::intersectKeyArray()` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `array_intersect_key(\$this, \$other)` keeps entries from `\$this` whose keys appear in `\$other`. The unsealed extras in `\$this` survive only at keys that `\$other` can also have — narrow the unsealed key type to the intersection of `\$this->unsealed[0]` and `\$other->getIterableKeyType()`. If the intersection is `Never`, drop the unsealed slot entirely. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/Type/Constant/ConstantArrayType.php | 12 +++++++ .../Analyser/nsrt/unsealed-derivations.php | 35 +++++++++++++++++++ 2 files changed, 47 insertions(+) diff --git a/src/Type/Constant/ConstantArrayType.php b/src/Type/Constant/ConstantArrayType.php index c97a1562b39..3f70437425a 100644 --- a/src/Type/Constant/ConstantArrayType.php +++ b/src/Type/Constant/ConstantArrayType.php @@ -1522,6 +1522,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(); } diff --git a/tests/PHPStan/Analyser/nsrt/unsealed-derivations.php b/tests/PHPStan/Analyser/nsrt/unsealed-derivations.php index 8aca74d3c9a..d92bc459475 100644 --- a/tests/PHPStan/Analyser/nsrt/unsealed-derivations.php +++ b/tests/PHPStan/Analyser/nsrt/unsealed-derivations.php @@ -323,6 +323,41 @@ public function fillKeysPreservesUnsealed(array $arr): void } +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 CountNarrowing { From 80eadc5db70272fbe73d7ff9a7f2e06d9ca7608b Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Tue, 19 May 2026 13:39:34 +0200 Subject: [PATCH 134/158] Carry unsealed slot through `ConstantArrayType::reverseArray()` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `array_reverse` only permutes element positions. The unsealed slot describes "zero or more extras at unspecified positions" — that property is unchanged by reversal, so pass `\$this->unsealed` through to the result. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/Type/Constant/ConstantArrayType.php | 8 ++++++++ .../Analyser/nsrt/unsealed-derivations.php | 16 ++++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/src/Type/Constant/ConstantArrayType.php b/src/Type/Constant/ConstantArrayType.php index 3f70437425a..a3861078e3e 100644 --- a/src/Type/Constant/ConstantArrayType.php +++ b/src/Type/Constant/ConstantArrayType.php @@ -1553,6 +1553,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(); } diff --git a/tests/PHPStan/Analyser/nsrt/unsealed-derivations.php b/tests/PHPStan/Analyser/nsrt/unsealed-derivations.php index d92bc459475..e86c79f44ee 100644 --- a/tests/PHPStan/Analyser/nsrt/unsealed-derivations.php +++ b/tests/PHPStan/Analyser/nsrt/unsealed-derivations.php @@ -358,6 +358,22 @@ public function intersectWithIntKeys(array $arr, array $other): void } +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 CountNarrowing { From b2fa4e3896d62b136024a0ece03fe2421f90a7ac Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Tue, 19 May 2026 13:41:31 +0200 Subject: [PATCH 135/158] Visit unsealed value in `ConstantArrayType::traverseSimultaneously()` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `traverse()` already invokes the callback on the unsealed value type (commit history c. \`traverse\` unsealed support). Its \`traverseSimultaneously\` sibling skipped that step — only the explicit \`valueTypes\` were paired with \`\$right->getOffsetValueType()\`, leaving the unsealed value untransformed. Pair the unsealed value with \`\$right->getIterableValueType()\` and thread the result back into the new \`unsealed\` tuple, mirroring how \`traverse()\` handles it. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/Type/Constant/ConstantArrayType.php | 12 ++++++- .../Type/Constant/ConstantArrayTypeTest.php | 36 +++++++++++++++++++ 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/src/Type/Constant/ConstantArrayType.php b/src/Type/Constant/ConstantArrayType.php index a3861078e3e..929ac7a53e5 100644 --- a/src/Type/Constant/ConstantArrayType.php +++ b/src/Type/Constant/ConstantArrayType.php @@ -2569,11 +2569,21 @@ 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, $this->unsealed); + return $this->recreate($this->keyTypes, $valueTypes, $this->nextAutoIndexes, $this->optionalKeys, $this->isList, $unsealed); } public function isKeysSupersetOf(self $otherArray): bool diff --git a/tests/PHPStan/Type/Constant/ConstantArrayTypeTest.php b/tests/PHPStan/Type/Constant/ConstantArrayTypeTest.php index f83711027bf..f3079eded36 100644 --- a/tests/PHPStan/Type/Constant/ConstantArrayTypeTest.php +++ b/tests/PHPStan/Type/Constant/ConstantArrayTypeTest.php @@ -1859,4 +1859,40 @@ public function testGetFiniteTypes(ConstantArrayType $type, array $expectedDescr ); } + 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()), + ); + } + } From c919101da7b235e6d331e879c0a7e355b4bafdc9 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Tue, 19 May 2026 13:43:20 +0200 Subject: [PATCH 136/158] Make `popArray` / `removeLastElements` unsealed-aware MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit \`array_pop\` removes the last element. On a sealed array the last explicit key is the one that disappears, but with real unsealed extras on the source the popped element might come from the unsealed range instead — the actual value might have zero extras (so a trailing explicit key is popped) or one+ extras (so an extra is popped, leaving the explicit keys intact). Encode the union: when the source is unsealed, the trailing \`\$length\` explicit keys become optional and the unsealed slot is preserved. Sealed inputs keep the previous "hard-remove last keys" behaviour. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/Type/Constant/ConstantArrayType.php | 28 +++++++++++++++++++ .../Analyser/nsrt/unsealed-derivations.php | 19 +++++++++++++ 2 files changed, 47 insertions(+) diff --git a/src/Type/Constant/ConstantArrayType.php b/src/Type/Constant/ConstantArrayType.php index 929ac7a53e5..d330a9312f9 100644 --- a/src/Type/Constant/ConstantArrayType.php +++ b/src/Type/Constant/ConstantArrayType.php @@ -80,6 +80,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; @@ -2085,6 +2086,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; diff --git a/tests/PHPStan/Analyser/nsrt/unsealed-derivations.php b/tests/PHPStan/Analyser/nsrt/unsealed-derivations.php index e86c79f44ee..277eb41fcf9 100644 --- a/tests/PHPStan/Analyser/nsrt/unsealed-derivations.php +++ b/tests/PHPStan/Analyser/nsrt/unsealed-derivations.php @@ -374,6 +374,25 @@ public function reversePreservesUnsealed(array $arr): void } +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 CountNarrowing { From 8c8917693cf6814b7eb32a2770034b3f6f53be87 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Tue, 19 May 2026 13:44:36 +0200 Subject: [PATCH 137/158] Carry unsealed slot through `shiftArray` / `removeFirstElements` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit \`array_shift\` removes the first element. With explicit keys present on the source, the shifted element is always one of them — the unsealed extras live "after" the explicit keys in insertion order and are untouched by the operation. Pass \`\$this->unsealed\` into the builder so the result keeps its zero-or-more extras. (Re-indexing of int keys doesn't change the unsealed range — a \`\` slot stays \`\`.) Co-Authored-By: Claude Opus 4.7 (1M context) --- src/Type/Constant/ConstantArrayType.php | 10 ++++++++++ .../Analyser/nsrt/unsealed-derivations.php | 18 ++++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/src/Type/Constant/ConstantArrayType.php b/src/Type/Constant/ConstantArrayType.php index d330a9312f9..df838abc139 100644 --- a/src/Type/Constant/ConstantArrayType.php +++ b/src/Type/Constant/ConstantArrayType.php @@ -2188,6 +2188,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(); } diff --git a/tests/PHPStan/Analyser/nsrt/unsealed-derivations.php b/tests/PHPStan/Analyser/nsrt/unsealed-derivations.php index 277eb41fcf9..e35313bfbba 100644 --- a/tests/PHPStan/Analyser/nsrt/unsealed-derivations.php +++ b/tests/PHPStan/Analyser/nsrt/unsealed-derivations.php @@ -393,6 +393,24 @@ public function popMakesLastKeyOptional(array $arr): void } +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 CountNarrowing { From fd44d98b2cf5104068c96acf0a61b7d31fd9437c Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Tue, 19 May 2026 13:56:17 +0200 Subject: [PATCH 138/158] Carry unsealed slot through `getKeysArray` / `getValuesArray` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `array_keys($source)` / `array_values($source)` produce a list whose explicit values come from `$source`'s keys/values. With real unsealed extras on `$source`, the source's unsealed *key* type becomes the new unsealed value for `array_keys`, and the source's unsealed *value* type becomes the new unsealed value for `array_values`. The new unsealed key range is `int<0, max>` — the conventional shape for list-style extras, which also collapses the describe output to the `` short form. Previously the shared helper passed `$this->unsealed` through verbatim (the two `// todo unsealed` markers); for an unsealed source that produced a result with the *source's* unsealed key/value in the *result's* unsealed slot — semantically nonsense. Pass an explicit `$unsealedSourceType` so each caller injects the right projection. `getKeysArrayFiltered` flows through the same helper and benefits implicitly: its `getIterableValueType()` now includes the right unsealed value contribution. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/Type/Constant/ConstantArrayType.php | 24 +++++++++--- .../Analyser/nsrt/unsealed-derivations.php | 39 +++++++++++++++++++ 2 files changed, 57 insertions(+), 6 deletions(-) diff --git a/src/Type/Constant/ConstantArrayType.php b/src/Type/Constant/ConstantArrayType.php index df838abc139..30b39766725 100644 --- a/src/Type/Constant/ConstantArrayType.php +++ b/src/Type/Constant/ConstantArrayType.php @@ -2278,7 +2278,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( @@ -2291,29 +2291,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(), $this->unsealed); // todo unsealed + return $this->recreate($keyTypes, $types, $autoIndexes, $this->optionalKeys, TrinaryLogic::createYes(), $resultUnsealed); } $keyTypes = []; @@ -2342,7 +2354,7 @@ private function getKeysOrValuesArray(array $types): self $maxIndex++; } - return $this->recreate($keyTypes, $valueTypes, $autoIndexes, $optionalKeys, TrinaryLogic::createYes(), $this->unsealed); // todo unsealed + return $this->recreate($keyTypes, $valueTypes, $autoIndexes, $optionalKeys, TrinaryLogic::createYes(), $resultUnsealed); } public function describe(VerbosityLevel $level): string diff --git a/tests/PHPStan/Analyser/nsrt/unsealed-derivations.php b/tests/PHPStan/Analyser/nsrt/unsealed-derivations.php index e35313bfbba..2c16126be77 100644 --- a/tests/PHPStan/Analyser/nsrt/unsealed-derivations.php +++ b/tests/PHPStan/Analyser/nsrt/unsealed-derivations.php @@ -411,6 +411,45 @@ public function shiftPreservesUnsealed(array $arr): void } +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 CountNarrowing { From 3e49a3ee8691e5a4f84fae26db79742bc1d57381 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Tue, 19 May 2026 13:58:14 +0200 Subject: [PATCH 139/158] Carry unsealed slot through `ConstantArrayType::sliceArray()` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `array_slice($arr, $offset, $length)` returns up to `$length` elements starting at `$offset`. When the slice fits within the source's explicit keys, the result is sealed as before. When the requested length runs past the explicit keys, the trailing slots could be filled by the source's unsealed extras (or be absent) — carry the unsealed slot through to the result so those potential extras are preserved. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/Type/Constant/ConstantArrayType.php | 13 ++++++++++ .../Analyser/nsrt/unsealed-derivations.php | 26 +++++++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/src/Type/Constant/ConstantArrayType.php b/src/Type/Constant/ConstantArrayType.php index 30b39766725..ae5f3bbe8b6 100644 --- a/src/Type/Constant/ConstantArrayType.php +++ b/src/Type/Constant/ConstantArrayType.php @@ -1731,6 +1731,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(); } diff --git a/tests/PHPStan/Analyser/nsrt/unsealed-derivations.php b/tests/PHPStan/Analyser/nsrt/unsealed-derivations.php index 2c16126be77..9882d6bbbf4 100644 --- a/tests/PHPStan/Analyser/nsrt/unsealed-derivations.php +++ b/tests/PHPStan/Analyser/nsrt/unsealed-derivations.php @@ -450,6 +450,32 @@ public function keysFromUnsealedWithStringKeys(array $arr): void } +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 CountNarrowing { From 6a43069dc48816650976523a0bb06c3e0a7e49e4 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Tue, 19 May 2026 14:00:23 +0200 Subject: [PATCH 140/158] Degrade `chunkArray` on unsealed sources to the general trait path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `array_chunk` with a constant length precisely enumerates the chunks for sealed CATs: each chunk is a known slice of the source. With real unsealed extras the source has an unknown number of trailing entries that could form additional partial or full chunks — the precise enumeration would lie about the chunk count. Fall back to `traitChunkArray`, which yields the general `non-empty-list` form. Less precise but correct. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/Type/Constant/ConstantArrayType.php | 9 +++++++++ .../Analyser/nsrt/unsealed-derivations.php | 20 +++++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/src/Type/Constant/ConstantArrayType.php b/src/Type/Constant/ConstantArrayType.php index ae5f3bbe8b6..15928f3b8b1 100644 --- a/src/Type/Constant/ConstantArrayType.php +++ b/src/Type/Constant/ConstantArrayType.php @@ -1436,6 +1436,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) { diff --git a/tests/PHPStan/Analyser/nsrt/unsealed-derivations.php b/tests/PHPStan/Analyser/nsrt/unsealed-derivations.php index 9882d6bbbf4..2e81c32d8b1 100644 --- a/tests/PHPStan/Analyser/nsrt/unsealed-derivations.php +++ b/tests/PHPStan/Analyser/nsrt/unsealed-derivations.php @@ -476,6 +476,26 @@ public function sliceBeyondExplicit(array $arr): void } +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 CountNarrowing { From c726c2a8d0e048a4a68f7352aff501424ed3b192 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Tue, 19 May 2026 14:02:29 +0200 Subject: [PATCH 141/158] Include unsealed extras in `ConstantArrayTypeBuilder` degraded-array form MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ConstantArrayTypeBuilder::getArray()` in the degrade-to-general-array branch builds `new ArrayType(union($keyTypes), union($valueTypes))` from the explicit slots only — the unsealed extras' key/value contribution was silently dropped. Union the unsealed key/value into the degraded `ArrayType`'s key and value unions when the builder carries real extras. Fixes shuffleArray (and any other call site that round-trips through `degradeToGeneralArray()`) on unsealed sources, where the unsealed value type now reaches the final list/array form. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/Type/Constant/ConstantArrayTypeBuilder.php | 15 ++++++++++++++- .../Analyser/nsrt/unsealed-derivations.php | 18 ++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/src/Type/Constant/ConstantArrayTypeBuilder.php b/src/Type/Constant/ConstantArrayTypeBuilder.php index c6f18b8dee8..e81a4d694eb 100644 --- a/src/Type/Constant/ConstantArrayTypeBuilder.php +++ b/src/Type/Constant/ConstantArrayTypeBuilder.php @@ -473,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/tests/PHPStan/Analyser/nsrt/unsealed-derivations.php b/tests/PHPStan/Analyser/nsrt/unsealed-derivations.php index 2e81c32d8b1..950c81d3607 100644 --- a/tests/PHPStan/Analyser/nsrt/unsealed-derivations.php +++ b/tests/PHPStan/Analyser/nsrt/unsealed-derivations.php @@ -496,6 +496,24 @@ public function chunkPreservingKeys(array $arr): void } +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 CountNarrowing { From ea71a355d5afd11e44cd7fbb86d08ebe69dcf0db Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Tue, 19 May 2026 14:04:51 +0200 Subject: [PATCH 142/158] Carry unsealed slot through `ConstantArrayType::spliceArray()` `array_splice` removes a slice at an explicit offset and inserts a replacement at that position. Real unsealed extras live at positions past the explicit keys, so they're not affected by either operation; the re-indexing of int keys also leaves a `` unsealed range unchanged. Pass the source's unsealed tuple into the builder before producing the result for each replacement variant. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/Type/Constant/ConstantArrayType.php | 10 ++++++++++ .../Analyser/nsrt/unsealed-derivations.php | 17 +++++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/src/Type/Constant/ConstantArrayType.php b/src/Type/Constant/ConstantArrayType.php index 15928f3b8b1..78b47442f9a 100644 --- a/src/Type/Constant/ConstantArrayType.php +++ b/src/Type/Constant/ConstantArrayType.php @@ -1857,6 +1857,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()); diff --git a/tests/PHPStan/Analyser/nsrt/unsealed-derivations.php b/tests/PHPStan/Analyser/nsrt/unsealed-derivations.php index 950c81d3607..6b85cae6bcf 100644 --- a/tests/PHPStan/Analyser/nsrt/unsealed-derivations.php +++ b/tests/PHPStan/Analyser/nsrt/unsealed-derivations.php @@ -514,6 +514,23 @@ public function shufflePreservesUnsealedValues(array $arr): void } +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 { From 51b7794aed0c487912e03e26bab24928e15886c6 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Tue, 19 May 2026 14:26:38 +0200 Subject: [PATCH 143/158] Cover `ConstantArrayType::generalize()` with a data provider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Twelve cases pinning the current behaviour: empty arrays (legacy null vs. bleeding-edge NeverType marker), sealed inputs at each precision (lessSpecific, moreSpecific, templateArgument), single and multi-key shapes, list shape, all-optional keys, and unsealed inputs — including the "no explicit keys + real extras" case where the current implementation returns `$this` unchanged instead of generalizing. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Type/Constant/ConstantArrayTypeTest.php | 130 ++++++++++++++++++ 1 file changed, 130 insertions(+) diff --git a/tests/PHPStan/Type/Constant/ConstantArrayTypeTest.php b/tests/PHPStan/Type/Constant/ConstantArrayTypeTest.php index f3079eded36..4dc1daf33d0 100644 --- a/tests/PHPStan/Type/Constant/ConstantArrayTypeTest.php +++ b/tests/PHPStan/Type/Constant/ConstantArrayTypeTest.php @@ -13,6 +13,7 @@ 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; @@ -1859,6 +1860,135 @@ public function testGetFiniteTypes(ConstantArrayType $type, array $expectedDescr ); } + 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(), + "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 testTraverseSimultaneouslyVisitsUnsealedValue(): void { $left = new ConstantArrayType( From f9c295a1710f2e1244b59b8e51b5ee1ab77ac969 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Tue, 19 May 2026 14:28:55 +0200 Subject: [PATCH 144/158] Generalize unsealed `ConstantArrayType` instead of returning it unchanged MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `generalize()` early-returned `$this` when `count($keyTypes) === 0`, which is correct for actually empty sealed arrays but wrong for a CAT with no explicit keys but real unsealed extras (`array{...}` — type-checked construction; the builder normally collapses this shape to `ArrayType`, but it can still appear through `recreate()` paths). The constant values inside the unsealed slot survived `generalize()` untouched. Two fixes, mirroring each other: 1. Only early-return for "no explicit keys *and* not unsealed". When the CAT carries real extras, proceed through the normal path so the unsealed key/value are generalized through `getIterableKeyType()` / `getIterableValueType()`. 2. Switch the `NonEmptyArrayType` gate from `keyTypesCount > optionalKeysCount` to `isIterableAtLeastOnce()->yes()`. The old gate was a proxy for "the array is definitely non-empty" that only looked at explicit keys; for "no explicit keys + real extras" it would incorrectly require the accessory. The `isIterableAtLeastOnce()` answer is what we actually want — and for sealed shapes it matches the previous gate exactly. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/Type/Constant/ConstantArrayType.php | 12 ++++++++++-- .../PHPStan/Type/Constant/ConstantArrayTypeTest.php | 12 +++++++++++- 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/src/Type/Constant/ConstantArrayType.php b/src/Type/Constant/ConstantArrayType.php index 78b47442f9a..e795f682734 100644 --- a/src/Type/Constant/ConstantArrayType.php +++ b/src/Type/Constant/ConstantArrayType.php @@ -2250,7 +2250,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; } @@ -2275,7 +2276,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(); } diff --git a/tests/PHPStan/Type/Constant/ConstantArrayTypeTest.php b/tests/PHPStan/Type/Constant/ConstantArrayTypeTest.php index 4dc1daf33d0..6b4bdd4d2ad 100644 --- a/tests/PHPStan/Type/Constant/ConstantArrayTypeTest.php +++ b/tests/PHPStan/Type/Constant/ConstantArrayTypeTest.php @@ -1932,7 +1932,17 @@ public static function dataGeneralize(): iterable yield 'unsealed only, lessSpecific' => [ new ConstantArrayType([], [], unsealed: [new IntegerType(), new ConstantStringType('foo')]), GeneralizePrecision::lessSpecific(), - "array{...}", + // 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' => [ From 38b3496fa955e6086bf7ab22c8385840cd1a5824 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Tue, 19 May 2026 14:35:43 +0200 Subject: [PATCH 145/158] Broaden unsealed value in `ConstantArrayType::generalizeValues()` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `generalizeValues()` walked the explicit `valueTypes` only — for an unsealed source the unsealed value type (e.g. `'foo'`) survived unchanged, leaving the result inconsistent: explicit values generalized to `int`/`string`/etc. but the unsealed slot still carried a constant. Generalize the unsealed value too, mirroring the explicit loop. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/Type/Constant/ConstantArrayType.php | 8 +++++++- .../Type/Constant/ConstantArrayTypeTest.php | 14 ++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/src/Type/Constant/ConstantArrayType.php b/src/Type/Constant/ConstantArrayType.php index e795f682734..fb414a36345 100644 --- a/src/Type/Constant/ConstantArrayType.php +++ b/src/Type/Constant/ConstantArrayType.php @@ -2305,7 +2305,13 @@ public function generalizeValues(): self $valueTypes[] = $valueType->generalize(GeneralizePrecision::lessSpecific()); } - return $this->recreate($this->keyTypes, $valueTypes, $this->nextAutoIndexes, $this->optionalKeys, $this->isList, $this->unsealed); + $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 diff --git a/tests/PHPStan/Type/Constant/ConstantArrayTypeTest.php b/tests/PHPStan/Type/Constant/ConstantArrayTypeTest.php index 6b4bdd4d2ad..3f97412e561 100644 --- a/tests/PHPStan/Type/Constant/ConstantArrayTypeTest.php +++ b/tests/PHPStan/Type/Constant/ConstantArrayTypeTest.php @@ -1999,6 +1999,20 @@ public function testGeneralize(ConstantArrayType $type, GeneralizePrecision $pre ); } + 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( From 82d775431ba8deb8711a3ab41bf6dd9f3407b983 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Tue, 19 May 2026 18:04:17 +0200 Subject: [PATCH 146/158] Treat the `isList`-from-shape inference in `ConstantArrayType` symmetrically MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The empty-keys block unconditionally overrode `$isList` from the unsealed key type, while the non-empty path only filled it in when null — an asymmetry. No caller actually passes a non-null `$isList` for an empty CAT, so the override and the only-when-null path were equivalent today, but the rule was inconsistent on its face. Wrap both branches in `if ($isList === null)` so the constructor consistently says "trust the caller when given, infer from shape otherwise". Also drop the stale `makeList()` TODO — the comment was speculating about validation that would require `isList=Maybe` co-occurring with a non-list-compatible shape, a state that no source-level flow actually constructs. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/Type/Constant/ConstantArrayType.php | 34 +++++++++++++------------ 1 file changed, 18 insertions(+), 16 deletions(-) diff --git a/src/Type/Constant/ConstantArrayType.php b/src/Type/Constant/ConstantArrayType.php index fb414a36345..55ca5c638b8 100644 --- a/src/Type/Constant/ConstantArrayType.php +++ b/src/Type/Constant/ConstantArrayType.php @@ -144,25 +144,29 @@ public function __construct( { assert(count($keyTypes) === count($valueTypes)); - $keyTypesCount = count($this->keyTypes); - if ($keyTypesCount === 0) { - if ($unsealed === null) { - $isList = TrinaryLogic::createYes(); - } else { - [$unsealedKeyType] = $unsealed; - if ($unsealedKeyType instanceof NeverType && $unsealedKeyType->isExplicit()) { + // 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) { + if (count($this->keyTypes) === 0) { + if ($unsealed === null) { $isList = TrinaryLogic::createYes(); - } elseif ($unsealedKeyType->isInteger()->yes()) { - $isList = TrinaryLogic::createMaybe(); } else { - $isList = TrinaryLogic::createNo(); + [$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(); } } - - if ($isList === null) { - $isList = TrinaryLogic::createNo(); - } $this->isList = $isList; if ($unsealed !== null) { @@ -3053,8 +3057,6 @@ public function makeList(): Type return new NeverType(); } - // todo can't be a list if keyTypes are not subsequent integers, or if unsealed type is not int keys - return $this->recreate($this->keyTypes, $this->valueTypes, $this->nextAutoIndexes, $this->optionalKeys, TrinaryLogic::createYes(), $this->unsealed); } From 741527c653b9dd02fe9765de34b7806f193ae85c Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Wed, 20 May 2026 10:09:54 +0200 Subject: [PATCH 147/158] Fold unsealed extras into `array_sum()` over a constant array MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `array_sum` summed only the explicit `getValueTypes()` of a constant array, silently dropping the unsealed extras — `array_sum(array{1, 2, ...})` inferred `3` even though the real sum is `3` plus zero-or-more further ints. That's unsound, not just imprecise. Add two result variants for unsealed inputs: the zero-extras case (the exact explicit sum, so a float unsealed value can't erase the precise int sum) and the one-or-more-extras case (explicit sum plus the unsealed value type multiplied by an unbounded count). Sealed constant arrays are unaffected. Co-Authored-By: Claude Opus 4.7 (1M context) --- ...ySumFunctionDynamicReturnTypeExtension.php | 12 +++++++++++ tests/PHPStan/Analyser/nsrt/array-sum.php | 20 +++++++++++++++++++ 2 files changed, 32 insertions(+) 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/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)); +} From d67b3a8909d4015745c5a279d94d70a2be67625f Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Wed, 20 May 2026 10:12:47 +0200 Subject: [PATCH 148/158] Skip `implode()` constant-fold for unsealed constant arrays MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `inferConstantType` built the exact result string from the explicit `getValueTypes()` only — `implode(',', array{'a', 'b', ...})` inferred `'a,b'` even though the unsealed extras can append further segments. That's an unsound constant fold. Bail out of the constant fold when the input `isUnsealed()->yes()` and fall through to the accessory-based result, which only keeps what's provable (e.g. `non-falsy-string` when the separator is non-falsy and the explicit prefix guarantees at least one separator). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Php/ImplodeFunctionReturnTypeExtension.php | 7 +++++++ tests/PHPStan/Analyser/nsrt/implode.php | 17 +++++++++++++++++ 2 files changed, 24 insertions(+) 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/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)); + } } From 11b6609cfa7497650d61fa50a2994b27222dc1c5 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Wed, 20 May 2026 10:16:59 +0200 Subject: [PATCH 149/158] Range `min()` / `max()` over unsealed extras of a constant array MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `processArrayType` fed only the explicit `getValueTypes()` into the min/max comparison, so `min(array{1, 2, ...})` inferred `1` and `max(...)` inferred `2` — unsound, since the unsealed extras can be any int above or below the explicit entries. Append the unsealed value type to the comparison arguments when the constant array `isUnsealed()->yes()`, so the result widens to cover the extras (`int` here). Sealed constant arrays keep their exact min/max. `vsprintf` was on the same verify list but is already sound: it reads only the positions the format references (extras beyond them are ignored) and bails to a general string when a referenced position falls outside the explicit keys. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/Type/Php/MinMaxFunctionReturnTypeExtension.php | 8 ++++++++ tests/PHPStan/Analyser/nsrt/minmax.php | 12 ++++++++++++ 2 files changed, 20 insertions(+) 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/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])); } From 6988c8191035f09780582dd01be212dfe5f8b0e5 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Wed, 20 May 2026 11:02:35 +0200 Subject: [PATCH 150/158] Carry unsealed slots through `array_combine()` all-constant fast path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ArrayCombineHelper` paired only the explicit `getValueTypes()` of the keys and values arrays, dropping unsealed extras — `array_combine(array{'a', 'b', ...}, array{1, 2, ...})` inferred a sealed `array{a: 1, b: 2}` even though the matching extras pair up into further entries. When both inputs are unsealed (their extra counts are both unbounded and must match for the call to succeed), attach the result's unsealed slot: the keys' unsealed value (as an array key) mapped to the values' unsealed value. If only one side is unsealed the sealed side caps the size, so no extras survive and the result stays sealed. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/Type/Php/ArrayCombineHelper.php | 16 +++++++++++++ .../Analyser/nsrt/array-combine-php8.php | 24 +++++++++++++++++++ 2 files changed, 40 insertions(+) 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/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])); +} From 3699e9544602dd2c653272611dc56eb7cd1f31e8 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Wed, 20 May 2026 11:04:43 +0200 Subject: [PATCH 151/158] Bail `compact()` enumeration for unsealed name arrays MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `findConstantStrings` walked a constant array's explicit `getValueTypes()` to collect the variable names, so an unsealed names array — `compact(array{'foo', 'bar', ...})` — produced a sealed `array{foo: ..., bar: ...}`, ignoring that the unsealed extras are further unknown variable names. Return `null` (give up the precise shape) when the names array is `isUnsealed()->yes()`, so the caller falls back to the general `array` signature. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/Type/Php/CompactFunctionReturnTypeExtension.php | 7 +++++++ tests/PHPStan/Analyser/nsrt/compact.php | 12 ++++++++++++ 2 files changed, 19 insertions(+) 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/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)); +}; From e40cef49549aaefed20b5b10a1994ba84cd3bd52 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Wed, 20 May 2026 11:07:11 +0200 Subject: [PATCH 152/158] Treat unsealed from-encoding arrays as multi-candidate in `mb_convert_encoding()` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The PHP 8 "can this return false?" check counted only the explicit `getValueTypes()` of the from-encoding array, so a one-explicit-key unsealed array — `mb_convert_encoding($s, 'UTF-8', array{'FOO', ...})` — was treated as a single guaranteed encoding and the `false` branch was dropped. The unsealed extras can supply further candidates, making it an auto-detect list that may fail. Extend the `count(...) > 1` gate with `|| isUnsealed()->yes()` so the result keeps `false` as a possible outcome. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Php/MbConvertEncodingFunctionReturnTypeExtension.php | 6 +++++- tests/PHPStan/Analyser/nsrt/mb-convert-encoding-php8.php | 7 +++++++ 2 files changed, 12 insertions(+), 1 deletion(-) 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/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)); }; From e2d4e60270163acc0b2ed0a40680ce20e6ed5cd9 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Wed, 20 May 2026 11:14:46 +0200 Subject: [PATCH 153/158] Treat legacy-null and sealed-marker as equal in `ConstantArrayType::equals()` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `equals()` already documented that a null unsealed slot (legacy / pre-bleeding-edge, `isUnsealed()` = `Maybe`) and the `[NeverType, NeverType]` sealed marker (bleeding edge, `isUnsealed()` = `No`) both mean "no real extras" and should compare equal. But the implementation used `isUnsealed()->no()`, which is `false` for the null case and `true` for the marker — so a legacy-null shape and a marker-sealed shape of otherwise-identical structure compared *unequal*. This surfaced as `TypeToPhpDocNodeTest::testToPhpDocNode` failing only under old PHPUnit (PHP < 8.2): there the data provider runs before the test container enables bleeding edge, so the directly-constructed expected type gets a null slot while the round-trip-parsed type (built after bleeding edge is on) gets the marker. New PHPUnit runs data providers after container init, so both sides got the marker and the bug stayed hidden. Compare on `isUnsealed()->yes()` ("has real extras") instead, so null and marker are both treated as sealed and only genuine extras are compared. Added a timing-independent unit test that constructs both forms directly via `BleedingEdgeToggle`. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/Type/Constant/ConstantArrayType.php | 20 ++++++++------ .../Type/Constant/ConstantArrayTypeTest.php | 26 +++++++++++++++++++ 2 files changed, 38 insertions(+), 8 deletions(-) diff --git a/src/Type/Constant/ConstantArrayType.php b/src/Type/Constant/ConstantArrayType.php index 55ca5c638b8..5a22f5b189a 100644 --- a/src/Type/Constant/ConstantArrayType.php +++ b/src/Type/Constant/ConstantArrayType.php @@ -903,17 +903,21 @@ public function equals(Type $type): bool return false; } - // Both `unsealed === null` and `unsealed === [explicitNever, explicitNever]` - // mean "sealed", just from different code paths (pre-bleeding-edge vs. - // fresh bleeding-edge builder). Treat them as equivalent here, only - // comparing the actual extras when both sides have real ones. - $thisIsSealed = $this->isUnsealed()->no(); - $otherIsSealed = $type->isUnsealed()->no(); - if ($thisIsSealed !== $otherIsSealed) { + // 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 (!$thisIsSealed && $this->unsealed !== null && $type->unsealed !== null) { + if ($thisHasExtras && $this->unsealed !== null && $type->unsealed !== null) { if (!$this->unsealed[0]->equals($type->unsealed[0])) { return false; } diff --git a/tests/PHPStan/Type/Constant/ConstantArrayTypeTest.php b/tests/PHPStan/Type/Constant/ConstantArrayTypeTest.php index 3f97412e561..ced494973f0 100644 --- a/tests/PHPStan/Type/Constant/ConstantArrayTypeTest.php +++ b/tests/PHPStan/Type/Constant/ConstantArrayTypeTest.php @@ -1549,6 +1549,32 @@ 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(); From 1065a0fb63763289e5097719de66c7e32eb4e106 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Thu, 21 May 2026 09:43:20 +0200 Subject: [PATCH 154/158] Preserve integer range bounds for sealed array values in loop generalization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When `generalizeType` merges two sealed constant-array shapes on the fall-through ArrayType path, it kept the literal key union (so loop-bounded counter keys stayed within range) but still recursed into `generalizeType` for the values — which applies the loop-widening heuristic and widened e.g. `int<0, 5>` to `int<0, max>`. Keep the literal value union too (with a fallback to generalization when it outgrows the shape limit), so values like loop-built `array{int<0, 5>, int<1, 6>}` keep their bounds. Scoped to sealed shapes so the general `generalize()` widening contract for legacy arrays (ScopeTest::testGeneralize) is unaffected. Refs https://github.com/phpstan/phpstan/issues/13647 Refs https://github.com/phpstan/phpstan/issues/13637 Co-Authored-By: Claude Opus 4.7 (1M context) --- src/Analyser/MutatingScope.php | 28 +++++++++----- tests/PHPStan/Analyser/nsrt/bug-13637.php | 38 +++++++++++++++++++ tests/PHPStan/Analyser/nsrt/bug-13647.php | 28 ++++++++++++++ .../Analyser/nsrt/unsealed-array-shapes.php | 2 +- 4 files changed, 86 insertions(+), 10 deletions(-) create mode 100644 tests/PHPStan/Analyser/nsrt/bug-13637.php create mode 100644 tests/PHPStan/Analyser/nsrt/bug-13647.php diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index 15b15e09249..715c7fbff7f 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -4264,14 +4264,15 @@ 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. When taking the - // fall-through ArrayType path we still recurse into - // `generalizeType` for the iterable key, which would - // widen e.g. `0|1` to `int<0, max>` and lose the loop's - // per-iteration precision. Instead, keep the literal - // union of constant keys so the loop's bound stays - // visible. + // 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) { @@ -4283,12 +4284,21 @@ private function generalizeType(Type $a, Type $b, int $depth): Type } 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( $resultKeyType, - TypeCombinator::union($this->generalizeType($constantArraysA->getIterableValueType(), $constantArraysB->getIterableValueType(), $depth + 1)), + $resultValueType, ); $accessories = []; if ( 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/unsealed-array-shapes.php b/tests/PHPStan/Analyser/nsrt/unsealed-array-shapes.php index a5bc969eaac..58fe90c4603 100644 --- a/tests/PHPStan/Analyser/nsrt/unsealed-array-shapes.php +++ b/tests/PHPStan/Analyser/nsrt/unsealed-array-shapes.php @@ -174,7 +174,7 @@ public function loopWithExistingSealedKey(): void for ($i = 0; $i < 5; $i++) { $arr[$i] = $i; } - assertType("non-empty-array<'x'|int<0, 4>, int<0, max>>", $arr); + assertType("non-empty-array<'x'|int<0, 4>, int<0, 4>>", $arr); } /** From cf5b91e4de195c390a1724a8a1412e37267e755d Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Thu, 21 May 2026 10:40:11 +0200 Subject: [PATCH 155/158] Type acceptance tests --- ...ringKeyCastingDetectTypeAcceptanceTest.php | 10 ++++ ...ingKeyCastingPreventTypeAcceptanceTest.php | 20 ++++++++ ...ringKeyCastingUnsafeTypeAcceptanceTest.php | 10 ++++ ...nsafe-array-string-key-casting-accepts.php | 46 +++++++++++++++++++ 4 files changed, 86 insertions(+) diff --git a/tests/PHPStan/Analyser/ReportUnsafeArrayStringKeyCastingDetectTypeAcceptanceTest.php b/tests/PHPStan/Analyser/ReportUnsafeArrayStringKeyCastingDetectTypeAcceptanceTest.php index 02412d59406..21f6bf9f337 100644 --- a/tests/PHPStan/Analyser/ReportUnsafeArrayStringKeyCastingDetectTypeAcceptanceTest.php +++ b/tests/PHPStan/Analyser/ReportUnsafeArrayStringKeyCastingDetectTypeAcceptanceTest.php @@ -29,6 +29,16 @@ public function testRule(): void '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/ReportUnsafeArrayStringKeyCastingPreventTypeAcceptanceTest.php b/tests/PHPStan/Analyser/ReportUnsafeArrayStringKeyCastingPreventTypeAcceptanceTest.php index 846ab7512a5..62deedd2655 100644 --- a/tests/PHPStan/Analyser/ReportUnsafeArrayStringKeyCastingPreventTypeAcceptanceTest.php +++ b/tests/PHPStan/Analyser/ReportUnsafeArrayStringKeyCastingPreventTypeAcceptanceTest.php @@ -37,6 +37,26 @@ public function testRule(): void '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.', + ], ]); } diff --git a/tests/PHPStan/Analyser/ReportUnsafeArrayStringKeyCastingUnsafeTypeAcceptanceTest.php b/tests/PHPStan/Analyser/ReportUnsafeArrayStringKeyCastingUnsafeTypeAcceptanceTest.php index 429a6a438ae..fcf3edfe0e5 100644 --- a/tests/PHPStan/Analyser/ReportUnsafeArrayStringKeyCastingUnsafeTypeAcceptanceTest.php +++ b/tests/PHPStan/Analyser/ReportUnsafeArrayStringKeyCastingUnsafeTypeAcceptanceTest.php @@ -28,6 +28,16 @@ public function testRule(): void '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/report-unsafe-array-string-key-casting-accepts.php b/tests/PHPStan/Analyser/data/report-unsafe-array-string-key-casting-accepts.php index d04e42a4990..8bd3b4a104f 100644 --- 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 @@ -40,3 +40,49 @@ public function doTest(string $s): void } } + +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); + } + +} From 9db66d041d5eb7b8233e828a002712973f360cc8 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Thu, 21 May 2026 10:47:08 +0200 Subject: [PATCH 156/158] Fix type inference for `reportUnsafeArrayStringKeyCasting: detect` --- src/Type/ArrayType.php | 31 +---------- src/Type/Constant/ConstantArrayType.php | 3 +- .../UnsafeArrayStringKeyCastingTraverser.php | 53 +++++++++++++++++++ ...unsafe-array-string-key-casting-detect.php | 38 +++++++++++++ 4 files changed, 95 insertions(+), 30 deletions(-) create mode 100644 src/Type/Traverser/UnsafeArrayStringKeyCastingTraverser.php diff --git a/src/Type/ArrayType.php b/src/Type/ArrayType.php index 833f42d12c8..b38fb858c45 100644 --- a/src/Type/ArrayType.php +++ b/src/Type/ArrayType.php @@ -2,7 +2,6 @@ namespace PHPStan\Type; -use PHPStan\DependencyInjection\ReportUnsafeArrayStringKeyCastingToggle; use PHPStan\Php\PhpVersion; use PHPStan\PhpDocParser\Ast\Type\GenericTypeNode; use PHPStan\PhpDocParser\Ast\Type\IdentifierTypeNode; @@ -13,7 +12,6 @@ use PHPStan\ShouldNotHappenException; 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\AccessoryNonFalsyStringType; @@ -37,6 +35,7 @@ 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; @@ -239,33 +238,7 @@ public function getIterableKeyType(): Type $keyType = (new BenevolentUnionType([new IntegerType(), new StringType()]))->toArrayKey(); } - $level = ReportUnsafeArrayStringKeyCastingToggle::getLevel(); - if ($level === null) { - return $this->cachedIterableKeyType = $keyType; - } - - if ($level === ReportUnsafeArrayStringKeyCastingToggle::PREVENT) { - return $this->cachedIterableKeyType = $keyType; - } - - if ($level !== ReportUnsafeArrayStringKeyCastingToggle::DETECT) { // @phpstan-ignore notIdentical.alwaysFalse - throw new ShouldNotHappenException(); - } - - return $this->cachedIterableKeyType = TypeTraverser::map($keyType, static function (Type $type, callable $traverse): Type { - if ($type instanceof UnionType) { - return $traverse($type); - } - - if ($type->isString()->yes() && !$type->isDecimalIntegerString()->no()) { - return TypeCombinator::union( - new IntegerType(), - TypeCombinator::intersect($type, new AccessoryDecimalIntegerStringType(inverse: true)), - ); - } - - return $type; - }); + return $this->cachedIterableKeyType = UnsafeArrayStringKeyCastingTraverser::castKeyType($keyType); } public function getFirstIterableKeyType(): Type diff --git a/src/Type/Constant/ConstantArrayType.php b/src/Type/Constant/ConstantArrayType.php index 5a22f5b189a..d772d54771e 100644 --- a/src/Type/Constant/ConstantArrayType.php +++ b/src/Type/Constant/ConstantArrayType.php @@ -61,6 +61,7 @@ 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; @@ -301,7 +302,7 @@ public function getIterableKeyType(): Type $keyType = TypeCombinator::union($keyType, $unsealedKeyType); } - return $this->iterableKeyType = $keyType; + return $this->iterableKeyType = UnsafeArrayStringKeyCastingTraverser::castKeyType($keyType); } public function getIterableValueType(): Type 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/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 index df88c4437db..15c9387e99d 100644 --- 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 @@ -89,3 +89,41 @@ public function doArrayCreationAndAssign(string $s): void } } + +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); + } + } + +} From 1636fb9ba0f40528cd30642502f56472906e8843 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Thu, 21 May 2026 11:53:05 +0200 Subject: [PATCH 157/158] Array with `int|string` in `prevent` mode should accept any string --- src/PhpDoc/TypeNodeResolver.php | 20 ++++++++++--------- src/Type/UnionType.php | 14 ++++++++++++- ...ingKeyCastingPreventTypeAcceptanceTest.php | 10 ++++++++-- ...nsafe-array-string-key-casting-accepts.php | 19 ++++++++++++++++++ ...nsafe-array-string-key-casting-prevent.php | 12 +++++------ 5 files changed, 57 insertions(+), 18 deletions(-) diff --git a/src/PhpDoc/TypeNodeResolver.php b/src/PhpDoc/TypeNodeResolver.php index 1b841dccec4..a704d15a289 100644 --- a/src/PhpDoc/TypeNodeResolver.php +++ b/src/PhpDoc/TypeNodeResolver.php @@ -988,17 +988,19 @@ static function (string $variance): TemplateTypeVariance { private function transformUnsafeArrayKey(Type $keyType): Type { if ($this->reportUnsafeArrayStringKeyCasting === ReportUnsafeArrayStringKeyCastingToggle::PREVENT) { - $keyType = TypeTraverser::map($keyType, static function (Type $type, callable $traverse) { - if ($type instanceof UnionType || $type instanceof IntersectionType) { - return $traverse($type); - } + 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)); - } + if ($type instanceof StringType) { + return TypeCombinator::intersect($type, new AccessoryDecimalIntegerStringType(inverse: true)); + } - return $type; - }); + return $type; + }); + } } return TypeCombinator::intersect($keyType->toArrayKey(), new UnionType([ diff --git a/src/Type/UnionType.php b/src/Type/UnionType.php index 6a9628dd6f7..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; @@ -1186,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/tests/PHPStan/Analyser/ReportUnsafeArrayStringKeyCastingPreventTypeAcceptanceTest.php b/tests/PHPStan/Analyser/ReportUnsafeArrayStringKeyCastingPreventTypeAcceptanceTest.php index 62deedd2655..a7cf439c073 100644 --- a/tests/PHPStan/Analyser/ReportUnsafeArrayStringKeyCastingPreventTypeAcceptanceTest.php +++ b/tests/PHPStan/Analyser/ReportUnsafeArrayStringKeyCastingPreventTypeAcceptanceTest.php @@ -3,19 +3,25 @@ namespace PHPStan\Analyser; use PHPStan\Rules\Methods\CallMethodsRule; +use PHPStan\Rules\Methods\ReturnTypeRule; use PHPStan\Rules\Rule; +use PHPStan\Testing\CompositeRule; use PHPStan\Testing\RuleTestCase; use function array_merge; /** - * @extends RuleTestCase + * @extends RuleTestCase */ class ReportUnsafeArrayStringKeyCastingPreventTypeAcceptanceTest extends RuleTestCase { public function getRule(): Rule { - return self::getContainer()->getByType(CallMethodsRule::class); + // @phpstan-ignore argument.type + return new CompositeRule([ + self::getContainer()->getByType(CallMethodsRule::class), + self::getContainer()->getByType(ReturnTypeRule::class), + ]); } public function testRule(): void 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 index 8bd3b4a104f..0e240ad02a0 100644 --- 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 @@ -86,3 +86,22 @@ public function doTest(string $s): void } } + +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-prevent.php b/tests/PHPStan/Analyser/data/report-unsafe-array-string-key-casting-prevent.php index 89e1be359b1..ba5f9f46650 100644 --- 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 @@ -25,7 +25,7 @@ public function doBar(array $a): void { assertType('array', $a); foreach ($a as $k => $v) { - assertType('(int|non-decimal-int-string)', $k); + assertType('(int|string)', $k); } } @@ -34,9 +34,9 @@ public function doBar(array $a): void */ public function doBaz(array $a): void { - assertType('array', $a); + assertType('array', $a); foreach ($a as $k => $v) { - assertType('int|non-decimal-int-string', $k); + assertType('int|string', $k); } } @@ -111,7 +111,7 @@ public function doBar(array $a): void { assertType('array{a: int, ...}', $a); foreach ($a as $k => $v) { - assertType('(int|non-decimal-int-string)', $k); + assertType('(int|string)', $k); } } @@ -120,9 +120,9 @@ public function doBar(array $a): void */ public function doBaz(array $a): void { - assertType('array{a: int, ...}', $a); + assertType('array{a: int, ...}', $a); foreach ($a as $k => $v) { - assertType('int|non-decimal-int-string', $k); + assertType('int|string', $k); } } From db51c3909f00c4fa7cd8ffd08bf55d09db0a01c7 Mon Sep 17 00:00:00 2001 From: Daniel Khalil Date: Fri, 22 May 2026 16:20:12 +0200 Subject: [PATCH 158/158] Fix hSet/Nx function signatures --- resources/functionMap.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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'],