diff --git a/.github/workflows/coding-standards.yml b/.github/workflows/coding-standards.yml index 195d4ce0883fc..809abd1da9230 100644 --- a/.github/workflows/coding-standards.yml +++ b/.github/workflows/coding-standards.yml @@ -8,11 +8,6 @@ on: - trunk - '3.[89]' - '[4-9].[0-9]' - tags: - - '3.[89]' - - '3.[89].[0-9]+' - - '[4-9].[0-9]' - - '[4-9].[0-9].[0-9]+' pull_request: branches: - trunk diff --git a/.github/workflows/end-to-end-tests.yml b/.github/workflows/end-to-end-tests.yml index 28092ae468b91..890314f25be49 100644 --- a/.github/workflows/end-to-end-tests.yml +++ b/.github/workflows/end-to-end-tests.yml @@ -7,11 +7,6 @@ on: - trunk - '5.[3-9]' - '[6-9].[0-9]' - tags: - - '5.[3-9]' - - '5.[3-9].[0-9]+' - - '[6-9]+.[0-9]' - - '[6-9]+.[0-9].[0-9]+' pull_request: branches: - trunk diff --git a/.github/workflows/javascript-tests.yml b/.github/workflows/javascript-tests.yml index df5aab9288c49..8d96c826d7a57 100644 --- a/.github/workflows/javascript-tests.yml +++ b/.github/workflows/javascript-tests.yml @@ -7,11 +7,6 @@ on: - trunk - '3.[89]' - '[4-9].[0-9]' - tags: - - '3.[89]' - - '3.[89].[0-9]+' - - '[4-9].[0-9]' - - '[4-9].[0-9].[0-9]+' pull_request: branches: - trunk diff --git a/.github/workflows/javascript-type-checking.yml b/.github/workflows/javascript-type-checking.yml index 4327a815d620c..2572bb4c2fe36 100644 --- a/.github/workflows/javascript-type-checking.yml +++ b/.github/workflows/javascript-type-checking.yml @@ -6,9 +6,6 @@ on: branches: - trunk - '[7-9].[0-9]' - tags: - - '[7-9].[0-9]' - - '[7-9]+.[0-9].[0-9]+' pull_request: branches: - trunk diff --git a/.github/workflows/performance.yml b/.github/workflows/performance.yml index d64bd37c91f7b..3690e6822512f 100644 --- a/.github/workflows/performance.yml +++ b/.github/workflows/performance.yml @@ -7,11 +7,6 @@ on: - trunk - '6.[2-9]' - '[7-9].[0-9]' - tags: - - '6.[2-9]' - - '6.[2-9].[0-9]+' - - '[7-9].[0-9]' - - '[7-9].[0-9].[0-9]+' pull_request: branches: - trunk diff --git a/.github/workflows/php-compatibility.yml b/.github/workflows/php-compatibility.yml index 48c881a3df485..9e30670cdae7e 100644 --- a/.github/workflows/php-compatibility.yml +++ b/.github/workflows/php-compatibility.yml @@ -7,11 +7,6 @@ on: - trunk - '5.[5-9]' - '[6-9].[0-9]' - tags: - - '5.[5-9]' - - '5.[5-9].[0-9]+' - - '[6-9].[0-9]' - - '[6-9].[0-9].[0-9]+' pull_request: branches: - trunk diff --git a/.github/workflows/phpstan-static-analysis.yml b/.github/workflows/phpstan-static-analysis.yml index e09fbc44ce9c2..7d7043ac0c1ec 100644 --- a/.github/workflows/phpstan-static-analysis.yml +++ b/.github/workflows/phpstan-static-analysis.yml @@ -6,9 +6,6 @@ on: branches: - trunk - '[7-9].[0-9]' - tags: - - '[7-9].[0-9]' - - '[7-9]+.[0-9].[0-9]+' pull_request: branches: - trunk @@ -21,7 +18,7 @@ on: # These files configure PHPStan. Changes could affect the outcome. - 'phpstan.neon.dist' - 'tests/phpstan/base.neon' - - 'tests/phpstan/baseline.php' + - 'tests/phpstan/baselines/**' # Confirm any changes to relevant workflow files. - '.github/workflows/phpstan-static-analysis.yml' - '.github/workflows/reusable-phpstan-static-analysis-v1.yml' diff --git a/.github/workflows/phpunit-tests.yml b/.github/workflows/phpunit-tests.yml index 81afd70449141..2b9bbfe891640 100644 --- a/.github/workflows/phpunit-tests.yml +++ b/.github/workflows/phpunit-tests.yml @@ -6,9 +6,6 @@ on: - trunk - '3.[7-9]' - '[4-9].[0-9]' - tags: - - '[0-9]+.[0-9]' - - '[0-9]+.[0-9].[0-9]+' pull_request: branches: - trunk @@ -37,6 +34,7 @@ on: # Confirm any changes to relevant workflow files. - '.github/workflows/phpunit-tests.yml' - '.github/workflows/reusable-phpunit-tests-*.yml' + - '.github/workflows/reusable-prepare-gutenberg.yml' workflow_dispatch: # Once weekly On Sundays at 00:00 UTC. schedule: @@ -54,6 +52,20 @@ concurrency: permissions: {} jobs: + # Downloads and verifies the Gutenberg build once for all PHPUnit jobs. + # + # This condition is the union of the conditions on the jobs that need it, reduced. + # The org matrices require `WordPress/wordpress-develop` or a pull request, and the + # fork matrix requires a pull request, so `wordpress-develop` or a pull request + # covers every case. Keep it in step with those jobs: a narrower condition orphans + # them, because a job that needs a skipped job is skipped too, and a broader one + # downloads a build that nothing consumes. + prepare-gutenberg: + uses: ./.github/workflows/reusable-prepare-gutenberg.yml + permissions: + contents: read + if: ${{ github.repository == 'WordPress/wordpress-develop' || github.event_name == 'pull_request' }} + # # Creates a PHPUnit test job for each PHP/MySQL combination. # @@ -64,6 +76,7 @@ jobs: test-with-mysql: name: PHP ${{ matrix.php }} uses: ./.github/workflows/reusable-phpunit-tests-v3.yml + needs: prepare-gutenberg permissions: contents: read secrets: @@ -74,7 +87,8 @@ jobs: fail-fast: false matrix: os: [ ubuntu-24.04 ] - php: [ '7.4', '8.0', '8.1', '8.2', '8.3', '8.4', '8.5' ] + # The scheduled run tests every supported PHP version. Other events test the highest and lowest of each major. + php: ${{ github.event_name == 'schedule' && fromJSON('["7.4","8.0","8.1","8.2","8.3","8.4","8.5"]') || fromJSON('["7.4","8.0","8.5"]') }} db-type: [ 'mysql' ] db-version: [ '5.7', '8.0', '8.4', '9.7' ] tests-domain: [ 'example.org' ] @@ -130,6 +144,8 @@ jobs: phpunit-config: ${{ matrix.multisite && 'tests/phpunit/multisite.xml' || 'phpunit.xml.dist' }} tests-domain: ${{ matrix.tests-domain }} report: ${{ matrix.report || false }} + gutenberg-artifact: gutenberg-build + gutenberg-sha: ${{ needs.prepare-gutenberg.outputs.gutenberg-sha }} # # Creates a PHPUnit test job for each PHP/MariaDB combination. @@ -143,6 +159,7 @@ jobs: test-with-mariadb: name: PHP ${{ matrix.php }} uses: ./.github/workflows/reusable-phpunit-tests-v3.yml + needs: prepare-gutenberg permissions: contents: read secrets: @@ -153,7 +170,8 @@ jobs: fail-fast: false matrix: os: [ ubuntu-24.04 ] - php: [ '7.4', '8.0', '8.1', '8.2', '8.3', '8.4', '8.5' ] + # The scheduled run tests every supported PHP version. Other events test the highest and lowest of each major. + php: ${{ github.event_name == 'schedule' && fromJSON('["7.4","8.0","8.1","8.2","8.3","8.4","8.5"]') || fromJSON('["7.4","8.0","8.5"]') }} db-type: [ 'mariadb' ] db-version: [ '5.5', '10.3', '10.5', '10.6', '10.11', '11.4', '11.8' ] multisite: [ false, true ] @@ -182,6 +200,8 @@ jobs: memcached: ${{ matrix.memcached }} phpunit-config: ${{ matrix.multisite && 'tests/phpunit/multisite.xml' || 'phpunit.xml.dist' }} report: false + gutenberg-artifact: gutenberg-build + gutenberg-sha: ${{ needs.prepare-gutenberg.outputs.gutenberg-sha }} # # Creates PHPUnit test jobs to test MariaDB and MySQL innovation releases. @@ -197,6 +217,7 @@ jobs: test-innovation-releases: name: PHP ${{ matrix.php }} uses: ./.github/workflows/reusable-phpunit-tests-v3.yml + needs: prepare-gutenberg permissions: contents: read secrets: @@ -207,7 +228,8 @@ jobs: fail-fast: false matrix: os: [ ubuntu-24.04 ] - php: [ '7.4', '8.0', '8.1', '8.2', '8.3', '8.4', '8.5' ] + # The scheduled run tests every supported PHP version. Other events test the highest and lowest of each major. + php: ${{ github.event_name == 'schedule' && fromJSON('["7.4","8.0","8.1","8.2","8.3","8.4","8.5"]') || fromJSON('["7.4","8.0","8.5"]') }} db-type: [ 'mysql', 'mariadb' ] db-version: [ '12.1' ] multisite: [ false, true ] @@ -228,6 +250,8 @@ jobs: memcached: ${{ matrix.memcached }} phpunit-config: ${{ matrix.multisite && 'tests/phpunit/multisite.xml' || 'phpunit.xml.dist' }} report: false + gutenberg-artifact: gutenberg-build + gutenberg-sha: ${{ needs.prepare-gutenberg.outputs.gutenberg-sha }} # # Runs the HTML API test group. @@ -240,6 +264,7 @@ jobs: html-api-test-groups: name: ${{ matrix.label }} uses: ./.github/workflows/reusable-phpunit-tests-v3.yml + needs: prepare-gutenberg permissions: contents: read secrets: @@ -260,6 +285,8 @@ jobs: db-type: ${{ matrix.db-type }} db-version: ${{ matrix.db-version }} phpunit-test-groups: ${{ matrix.phpunit-test-groups }} + gutenberg-artifact: gutenberg-build + gutenberg-sha: ${{ needs.prepare-gutenberg.outputs.gutenberg-sha }} # # Runs unit tests for forks. @@ -271,6 +298,7 @@ jobs: limited-matrix-for-forks: name: PHP ${{ matrix.php }} uses: ./.github/workflows/reusable-phpunit-tests-v3.yml + needs: prepare-gutenberg permissions: contents: read secrets: @@ -318,8 +346,12 @@ jobs: php: ${{ matrix.php }} db-version: ${{ matrix.db-version }} db-type: ${{ matrix.db-type }} + multisite: ${{ matrix.multisite }} memcached: ${{ matrix.memcached || false }} + phpunit-config: ${{ matrix.multisite && 'tests/phpunit/multisite.xml' || 'phpunit.xml.dist' }} phpunit-test-groups: ${{ matrix.phpunit-test-groups || '' }} + gutenberg-artifact: gutenberg-build + gutenberg-sha: ${{ needs.prepare-gutenberg.outputs.gutenberg-sha }} slack-notifications: name: Slack Notifications @@ -327,7 +359,7 @@ jobs: permissions: actions: read contents: read - needs: [ test-with-mysql, test-with-mariadb, test-innovation-releases, html-api-test-groups, limited-matrix-for-forks ] + needs: [ prepare-gutenberg, test-with-mysql, test-with-mariadb, test-innovation-releases, html-api-test-groups, limited-matrix-for-forks ] if: ${{ github.repository == 'WordPress/wordpress-develop' && github.event_name != 'pull_request' && always() }} with: calling_status: ${{ contains( needs.*.result, 'cancelled' ) && 'cancelled' || contains( needs.*.result, 'failure' ) && 'failure' || 'success' }} diff --git a/.github/workflows/reusable-phpstan-static-analysis-v1.yml b/.github/workflows/reusable-phpstan-static-analysis-v1.yml index a69b3b46fdea4..26a14ba8d890f 100644 --- a/.github/workflows/reusable-phpstan-static-analysis-v1.yml +++ b/.github/workflows/reusable-phpstan-static-analysis-v1.yml @@ -32,6 +32,7 @@ jobs: # - Builds WordPress. # - Configures caching for PHPStan static analysis scans. # - Runs PHPStan static analysis (with Pull Request annotations). + # - Checks whether the baselines need regenerating. # - Saves the PHPStan result cache. # - Ensures version-controlled files are not modified or deleted. phpstan: @@ -93,7 +94,99 @@ jobs: - name: Run PHP static analysis tests id: phpstan - run: composer run phpstan -- -vvv --error-format=checkstyle | cs2pr --errors-as-warnings --graceful-warnings + run: | + # The report is written to a file as well as piped to cs2pr, so that the step below + # can look at it. + # + # cs2pr exits successfully so that reported errors annotate the pull request without + # failing the run. A pipeline reports only the status of its last command, so that + # also discards the status of the analysis itself. Recover it from PIPESTATUS. + composer run phpstan -- -vvv --error-format=checkstyle | tee "${RUNNER_TEMP}/phpstan-report.xml" | cs2pr --errors-as-warnings --graceful-warnings + status="${PIPESTATUS[0]}" + + # PHPStan exits 1 when it has errors to report, which is the expected case here and + # is what the annotations are for. Anything higher means it did not finish at all, + # which would otherwise pass silently, since the discarded status was the only sign. + if [ "${status}" -gt 1 ]; then + echo "::error title=PHPStan did not complete::The analysis exited with status ${status}, so the code was not fully checked. This is a failure of the run itself rather than a problem found in the code." + exit "${status}" + fi + + # An ignored error that no longer occurs, or occurs a different number of times, is + # reported under an `ignore.*` identifier. That is not something to fix in the code: the + # usual cause is that the error *was* fixed, leaving a baseline describing a state that + # no longer exists. PHPStan does not allow those reports to be ignored or baselined. + # + # The analysis above is reported as warnings, so this would otherwise surface as a + # passing run carrying an annotation that reads like a complaint about a fix. Call it + # out on its own, and say what to do about it. + # + # Detection is on the identifier rather than the message, which is prose and may be + # reworded in any release. The checkstyle format carries it in the `source` attribute. + - name: Check whether the baselines need regenerating + if: ${{ !cancelled() }} + env: + BASELINES_URL: ${{ github.server_url }}/${{ github.repository }}/tree/${{ github.sha }}/tests/phpstan/baselines + README_URL: ${{ github.server_url }}/${{ github.repository }}/blob/${{ github.sha }}/tests/phpstan/README.md + run: | + # This step runs even when the analysis above it failed, in which case the report may + # never have been written. That failure is reported there, so there is nothing to add + # here beyond staying quiet about a file that was never going to exist. + if [ ! -f "${RUNNER_TEMP}/phpstan-report.xml" ]; then + exit 0 + fi + + # Leave the run alone unless PHPStan reported an ignore error, because everything + # below concerns an ignore configuration that no longer describes the code, and + # nothing else. Those errors are `ignore.unmatched`, where a pattern matched nothing + # at all, and `ignore.count`, where it matched a different number of times than the + # entry records. The `ignore.` prefix is matched rather than those two names so that + # any later addition to the group is caught as well. + if ! grep -q 'source="ignore\.' "${RUNNER_TEMP}/phpstan-report.xml"; then + exit 0 + fi + + # The summary is Markdown, and its code spans and fences are written literally, so the + # heredoc is quoted to keep the backticks out of the shell's hands. The links are + # written in reference style for the same reason: the URLs are the only part needing + # a variable, so defining them afterwards keeps the whole of the prose in here. + # + # A newline renders as a line break rather than a space, so each paragraph is one + # line however long that makes it, and the rendered summary wraps to its own width. + cat >> "${GITHUB_STEP_SUMMARY}" <<'SUMMARY' + ## PHPStan baselines are out of date + + An ignored error no longer occurs, or occurs a different number of times, so PHPStan reported it under an `ignore.unmatched` or `ignore.count` identifier. + + **If you fixed the error, this is expected.** Each baseline entry records an exact count for a specific file, so that a new occurrence of an already baselined error is reported rather than absorbed. That same exactness means fixing one leaves the baseline describing a state that no longer exists. There is nothing to fix in the code; the baselines just need to catch up. + + Regenerate them and commit the result: + + ```bash + npm run typecheck:php:baselines + ``` + + or, outside the Docker environment: + + ```bash + composer phpstan:baselines + ``` + + That rewrites the files under [`tests/phpstan/baselines`][baselines], deletes any whose errors are now all fixed, and updates the list of them in `phpstan.neon.dist`. + + Where the report names an `@phpstan-ignore` annotation in the code rather than a baseline entry, remove that annotation instead; regenerating will not clear it. + + See [`tests/phpstan/README.md`][readme] for details. + + SUMMARY + + { + echo "[baselines]: ${BASELINES_URL}" + echo "[readme]: ${README_URL}" + } >> "${GITHUB_STEP_SUMMARY}" + + echo "::error title=PHPStan baselines are out of date::An ignored error no longer occurs, or occurs a different number of times. If you fixed it, that is expected: run \`npm run typecheck:php:baselines\` or \`composer phpstan:baselines\` and commit the updated baselines. See ${README_URL}" + exit 1 - name: "Save result cache" uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 diff --git a/.github/workflows/reusable-phpunit-tests-v3.yml b/.github/workflows/reusable-phpunit-tests-v3.yml index dfe678e82a0da..6a7f49fba468c 100644 --- a/.github/workflows/reusable-phpunit-tests-v3.yml +++ b/.github/workflows/reusable-phpunit-tests-v3.yml @@ -72,6 +72,16 @@ on: required: false type: boolean default: false + gutenberg-artifact: + description: 'The name of a same-workflow artifact containing the prepared Gutenberg build. Optional: callers that omit it download Gutenberg per job.' + required: false + type: string + default: '' + gutenberg-sha: + description: 'The immutable Gutenberg source SHA verified by the calling workflow.' + required: false + type: string + default: '' secrets: CODECOV_TOKEN: description: 'The Codecov token required for uploading reports.' @@ -101,6 +111,7 @@ jobs: # Performs the following steps: # - Sets environment variables. # - Checks out the repository. + # - Downloads the prepared Gutenberg build provided by the calling workflow. # - Sets up Node.js. # - Sets up PHP. # - Installs Composer dependencies. @@ -118,7 +129,7 @@ jobs: # - Submit the test results to the WordPress.org host test results. phpunit-tests: name: ${{ ( inputs.phpunit-test-groups || inputs.coverage-report ) && format( 'PHP {0} with ', inputs.php ) || '' }} ${{ 'mariadb' == inputs.db-type && 'MariaDB' || 'MySQL' }} ${{ inputs.db-version }}${{ inputs.multisite && ' multisite' || '' }}${{ inputs.db-innovation && ' (innovation release)' || '' }}${{ inputs.memcached && ' with memcached' || '' }}${{ inputs.report && ' (test reporting enabled)' || '' }} ${{ 'example.org' != inputs.tests-domain && inputs.tests-domain || '' }} - runs-on: ${{ inputs.os }} + runs-on: ${{ vars.RUNNERS_NAME || inputs.os }} timeout-minutes: ${{ inputs.coverage-report && 120 || inputs.php == '8.4' && 30 || 20 }} permissions: contents: read @@ -135,6 +146,17 @@ jobs: show-progress: ${{ runner.debug == '1' && 'true' || 'false' }} persist-credentials: false + # Branches >= 5.9 call this workflow at @trunk without the producer job, so they + # pass no artifact. They skip this step and fall back to a per-job download. + - name: Download prepared Gutenberg build + if: inputs.gutenberg-artifact != '' + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + # Without a run ID, this action reads only from the caller's current workflow run. + name: ${{ inputs.gutenberg-artifact }} + path: gutenberg + digest-mismatch: error + - name: Set up Node.js uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: @@ -166,6 +188,9 @@ jobs: - name: Build WordPress run: npm run build:dev + env: + # The producer resolved this once. Matrix jobs must not re-resolve mutable GHCR tags. + GUTENBERG_EXPECTED_SHA: ${{ inputs.gutenberg-sha }} - name: General debug information run: | @@ -180,6 +205,22 @@ jobs: run: | docker -v + - name: Pull Docker images (with retry) + run: | + for attempt in 1 2 3; do + if npm run env:pull; then + break + fi + + if [ "$attempt" -eq 3 ]; then + echo "npm run env:pull failed after $attempt attempts." + exit 1 + fi + + echo "npm run env:pull failed (attempt $attempt); retrying..." + sleep $(( attempt * 10 )) + done + - name: Start Docker environment run: | npm run env:start diff --git a/.github/workflows/reusable-prepare-gutenberg.yml b/.github/workflows/reusable-prepare-gutenberg.yml new file mode 100644 index 0000000000000..c5e1f904a9680 --- /dev/null +++ b/.github/workflows/reusable-prepare-gutenberg.yml @@ -0,0 +1,60 @@ +## +# A reusable workflow that downloads and verifies the Gutenberg build once per +# calling workflow run. +## +name: Prepare Gutenberg build + +on: + workflow_call: + outputs: + gutenberg-sha: + description: 'The immutable Gutenberg source SHA verified by this workflow.' + value: ${{ jobs.prepare-gutenberg.outputs.gutenberg-sha }} + +# Disable permissions for all available scopes by default. +# Any needed permissions should be configured at the job level. +permissions: {} + +jobs: + prepare-gutenberg: + name: Gutenberg + runs-on: ubuntu-24.04 + timeout-minutes: 10 + outputs: + gutenberg-sha: ${{ steps.download.outputs.gutenberg-sha }} + permissions: + contents: read + + steps: + - name: Checkout repository + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + # Resolve the Gutenberg ref from the exact commit that started this workflow run. + ref: ${{ github.sha }} + show-progress: ${{ runner.debug == '1' && 'true' || 'false' }} + persist-credentials: false + + - name: Set up Node.js + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version-file: '.nvmrc' + + - name: Download and verify Gutenberg build + id: download + run: | + node tools/gutenberg/download.js + gutenberg_sha="$(tr -d '\n' < gutenberg/.gutenberg-hash)" + if [[ ! "$gutenberg_sha" =~ ^[a-fA-F0-9]{40}$ ]]; then + echo "Expected a 40-character Gutenberg SHA, received: $gutenberg_sha" >&2 + exit 1 + fi + echo "gutenberg-sha=$gutenberg_sha" >> "$GITHUB_OUTPUT" + + - name: Upload Gutenberg build + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: gutenberg-build + path: gutenberg/ + if-no-files-found: error + include-hidden-files: true + retention-days: 1 diff --git a/.github/workflows/reusable-workflow-lint.yml b/.github/workflows/reusable-workflow-lint.yml index 5be73442a006d..6ae6b0df10968 100644 --- a/.github/workflows/reusable-workflow-lint.yml +++ b/.github/workflows/reusable-workflow-lint.yml @@ -44,6 +44,8 @@ jobs: zizmor: name: Zizmor runs-on: ubuntu-24.04 + # GitHub Code Security is not enabled on any of the private mirrors. + if: ${{ github.event.repository.private == false }} permissions: security-events: write actions: read diff --git a/.github/workflows/test-build-processes.yml b/.github/workflows/test-build-processes.yml index f52e31177b27c..d344c5371bad5 100644 --- a/.github/workflows/test-build-processes.yml +++ b/.github/workflows/test-build-processes.yml @@ -6,9 +6,6 @@ on: - trunk - '3.[7-9]' - '[4-9].[0-9]' - tags: - - '[0-9]+.[0-9]' - - '[0-9]+.[0-9].[0-9]+' pull_request: branches: - trunk diff --git a/.github/workflows/test-coverage.yml b/.github/workflows/test-coverage.yml index d6fe09904a925..ffc650ec370fb 100644 --- a/.github/workflows/test-coverage.yml +++ b/.github/workflows/test-coverage.yml @@ -8,6 +8,10 @@ on: paths: - '.github/workflows/test-coverage.yml' - '.github/workflows/reusable-phpunit-tests-v3.yml' + - '.github/workflows/reusable-prepare-gutenberg.yml' + - 'tools/gutenberg/**' + - 'package*.json' + - '.nvmrc' - 'docker-compose.yml' - 'phpunit.xml.dist' - 'tests/phpunit/multisite.xml' @@ -17,6 +21,10 @@ on: paths: - '.github/workflows/test-coverage.yml' - '.github/workflows/reusable-phpunit-tests-v3.yml' + - '.github/workflows/reusable-prepare-gutenberg.yml' + - 'tools/gutenberg/**' + - 'package*.json' + - '.nvmrc' - 'docker-compose.yml' - 'phpunit.xml.dist' - 'tests/phpunit/multisite.xml' @@ -42,12 +50,20 @@ env: PUPPETEER_SKIP_DOWNLOAD: true jobs: + # Downloads and verifies the Gutenberg build once for all coverage jobs. + prepare-gutenberg: + uses: ./.github/workflows/reusable-prepare-gutenberg.yml + permissions: + contents: read + if: ${{ github.repository == 'WordPress/wordpress-develop' }} + # # Creates a PHPUnit test jobs for generating code coverage reports. # test-coverage-report: name: ${{ matrix.multisite && 'Multisite' || 'Single site' }} report uses: ./.github/workflows/reusable-phpunit-tests-v3.yml + needs: prepare-gutenberg permissions: contents: read if: ${{ github.repository == 'WordPress/wordpress-develop' }} @@ -60,6 +76,8 @@ jobs: php: '8.3' multisite: ${{ matrix.multisite }} coverage-report: ${{ matrix.coverage-report }} + gutenberg-artifact: gutenberg-build + gutenberg-sha: ${{ needs.prepare-gutenberg.outputs.gutenberg-sha }} secrets: CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} @@ -69,7 +87,7 @@ jobs: permissions: actions: read contents: read - needs: [ test-coverage-report ] + needs: [ prepare-gutenberg, test-coverage-report ] if: ${{ github.repository == 'WordPress/wordpress-develop' && github.event_name != 'pull_request' && always() }} with: calling_status: ${{ contains( needs.*.result, 'cancelled' ) && 'cancelled' || contains( needs.*.result, 'failure' ) && 'failure' || 'success' }} diff --git a/.github/workflows/upgrade-develop-testing.yml b/.github/workflows/upgrade-develop-testing.yml index 54623834c4bc4..831b470beb418 100644 --- a/.github/workflows/upgrade-develop-testing.yml +++ b/.github/workflows/upgrade-develop-testing.yml @@ -9,9 +9,6 @@ on: - trunk - '6.[8-9]' - '[7-9].[0-9]' - tags: - - '[0-9]+.[0-9]' - - '[0-9]+.[0-9].[0-9]+' paths: # Any change to a source PHP file should run checks. - 'src/**.php' diff --git a/.mailmap b/.mailmap index bbcf17b3ec9d4..dd6623258ff4d 100644 --- a/.mailmap +++ b/.mailmap @@ -11,6 +11,7 @@ Aaron Jorbin Adam Zieliński Adam Zieliński +Aki Hamano Alex King Alex Shiels André diff --git a/README.md b/README.md index bb6d06c034651..eb88e407cb612 100644 --- a/README.md +++ b/README.md @@ -97,6 +97,17 @@ npm run test:php -- --filter npm run test:php -- --group ``` +To run the JavaScript (QUnit) tests: + +``` +npm run grunt qunit:compiled +``` + +`qunit:compiled` builds first, then runs the suite. The QUnit runner loads +scripts from the built `build/` directory, so a plain `npm run grunt qunit` +requires a completed `npm run build` first without a build, every test fails +with a `jQuery is not defined` error. + #### To lint the workflow files GitHub Actions workflows operate in a privileged software supply chain environment, therefore all workflow files must adhere to a high degree of quality and security standards. diff --git a/composer.json b/composer.json index fe2963b7a3574..a04cdedd18d84 100644 --- a/composer.json +++ b/composer.json @@ -16,15 +16,40 @@ "php": ">=7.4" }, "suggest": { + "ext-apcu": "*", + "ext-bc": "*", + "ext-curl": "*", "ext-dom": "*", + "ext-exif": "*", + "ext-fileinfo": "*", + "ext-filter": "*", "ext-ftp": "*", + "ext-gd": "*", + "ext-iconv": "*", + "ext-igbinary": "*", + "ext-imagick": "*", + "ext-intl": "*", + "ext-mbstring": "*", + "ext-memcached": "*", "ext-mysqli": "*", - "ext-ssh2": "*" + "ext-opcache": "*", + "ext-openssl": "*", + "ext-redis": "*", + "ext-shmop": "*", + "ext-simplexml": "*", + "ext-sockets": "*", + "ext-sodium": "*", + "ext-ssh2": "*", + "ext-timezonedb": "*", + "ext-xml": "*", + "ext-xmlreader": "*", + "ext-zip": "*", + "ext-zlib": "*" }, "require-dev": { - "composer/ca-bundle": "1.5.12", + "composer/ca-bundle": "1.5.13", "squizlabs/php_codesniffer": "3.13.5", - "wp-coding-standards/wpcs": "~3.4.0", + "wp-coding-standards/wpcs": "~3.4.1", "phpcompatibility/phpcompatibility-wp": "~2.1.3", "phpstan/phpstan": "2.2.5", "phpstan/phpstan-phpunit": "2.0.18", @@ -43,6 +68,7 @@ }, "scripts": { "phpstan": "@php ./vendor/bin/phpstan analyse --memory-limit=2G", + "phpstan:baselines": [ "Composer\\Config::disableProcessTimeout", "@php ./tests/phpstan/generate-baselines.php" ], "compat": "@php ./vendor/squizlabs/php_codesniffer/bin/phpcs --standard=phpcompat.xml.dist --report=summary,source", "format": "@php ./vendor/squizlabs/php_codesniffer/bin/phpcbf --report=summary,source", "lint": "@php ./vendor/squizlabs/php_codesniffer/bin/phpcs --report=summary,source", diff --git a/docker-compose.yml b/docker-compose.yml index cc2ed8d94975e..7ab6ae8c9b4d5 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -87,6 +87,7 @@ services: timeout: 5s interval: 5s retries: 10 + start_period: 60s ## # The WP CLI container. diff --git a/package-lock.json b/package-lock.json index 9bb47a3281b6d..78985e8f955c0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14237,16 +14237,6 @@ "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0" } }, - "node_modules/eslint-plugin-react-hooks/node_modules/zod": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", - "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/colinhacks" - } - }, "node_modules/eslint-plugin-react/node_modules/estraverse": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", @@ -33728,10 +33718,11 @@ } }, "node_modules/zod": { - "version": "3.23.8", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.23.8.tgz", - "integrity": "sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==", + "version": "3.25.1", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.1.tgz", + "integrity": "sha512-bkxUGQiqWDTXHSgqtevYDri5ee2GPC9szPct4pqpzLEpswgDQmuseDz81ZF0AnNu1xsmnBVmbtv/t/WeUIHlpg==", "dev": true, + "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/package.json b/package.json index a4aff38cf3dd7..1fa7810849728 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "url": "https://develop.svn.wordpress.org/trunk" }, "gutenberg": { - "sha": "4997026b75c922d8a6f77a03d72ed7cad04c7073", + "sha": "f05e40e91c54f29c449b1f33d0db89f5166812d9", "ghcrRepo": "WordPress/gutenberg/gutenberg-wp-develop-build" }, "engines": { @@ -112,6 +112,9 @@ "whatwg-fetch": "3.6.20", "wicg-inert": "3.1.3" }, + "overrides": { + "zod": "3.25.1" + }, "scripts": { "build": "grunt build", "build:dev": "grunt build --dev", @@ -138,7 +141,8 @@ "test:coverage": "npm run test:php -- --coverage-html ./coverage/html/ --coverage-php ./coverage/php/report.php --coverage-text=./coverage/text/report.txt", "test:e2e": "wp-scripts test-playwright --config tests/e2e/playwright.config.js", "test:visual": "wp-scripts test-playwright --config tests/visual-regression/playwright.config.js", - "typecheck:php": "node ./tools/local-env/scripts/docker.js run --rm php composer phpstan", + "typecheck:php": "node ./tools/local-env/scripts/docker.js run --rm php composer phpstan --", + "typecheck:php:baselines": "node ./tools/local-env/scripts/docker.js run --rm php composer phpstan:baselines --", "gutenberg:copy": "node tools/gutenberg/copy.js", "gutenberg:verify": "node tools/gutenberg/utils.js", "gutenberg:download": "node tools/gutenberg/download.js && grunt build:gutenberg" diff --git a/phpstan.neon.dist b/phpstan.neon.dist index 93e6c1f6653b3..3dcf0f6c2c0de 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -14,15 +14,106 @@ includes: # new strict rules. - vendor/phpstan/phpstan-phpunit/extension.neon - # The baseline file includes preexisting errors in the codebase that should be ignored. + # Preexisting errors that should be ignored, one baseline per error identifier + # so that the remaining work on each is visible as a single shrinking file. + # Each is meant to reach zero and be deleted, taking its line below with it. # https://phpstan.org/user-guide/baseline - - tests/phpstan/baseline.php + # + # Regenerate with `composer phpstan:baselines`, which rewrites both the files + # and the list between the markers. Do not edit that list by hand. + # phpstan:baselines start + - tests/phpstan/baselines/argument.type.neon + - tests/phpstan/baselines/argument.unresolvableType.neon + - tests/phpstan/baselines/arguments.count.neon + - tests/phpstan/baselines/arrayValues.list.neon + - tests/phpstan/baselines/assign.propertyType.neon + - tests/phpstan/baselines/binaryOp.invalid.neon + - tests/phpstan/baselines/booleanAnd.alwaysFalse.neon + - tests/phpstan/baselines/booleanAnd.alwaysTrue.neon + - tests/phpstan/baselines/booleanAnd.leftAlwaysTrue.neon + - tests/phpstan/baselines/booleanAnd.rightAlwaysFalse.neon + - tests/phpstan/baselines/booleanAnd.rightAlwaysTrue.neon + - tests/phpstan/baselines/booleanNot.alwaysFalse.neon + - tests/phpstan/baselines/booleanNot.alwaysTrue.neon + - tests/phpstan/baselines/booleanOr.alwaysFalse.neon + - tests/phpstan/baselines/booleanOr.alwaysTrue.neon + - tests/phpstan/baselines/booleanOr.rightAlwaysTrue.neon + - tests/phpstan/baselines/catch.neverThrown.neon + - tests/phpstan/baselines/class.nameCase.neon + - tests/phpstan/baselines/class.notFound.neon + - tests/phpstan/baselines/deadCode.unreachable.neon + - tests/phpstan/baselines/empty.offset.neon + - tests/phpstan/baselines/empty.property.neon + - tests/phpstan/baselines/empty.variable.neon + - tests/phpstan/baselines/encapsedStringPart.nonString.neon + - tests/phpstan/baselines/foreach.nonIterable.neon + - tests/phpstan/baselines/function.alreadyNarrowedType.neon + - tests/phpstan/baselines/function.impossibleType.neon + - tests/phpstan/baselines/function.resultUnused.neon + - tests/phpstan/baselines/greater.invalid.neon + - tests/phpstan/baselines/greaterOrEqual.alwaysTrue.neon + - tests/phpstan/baselines/identical.alwaysFalse.neon + - tests/phpstan/baselines/identical.alwaysTrue.neon + - tests/phpstan/baselines/if.alwaysFalse.neon + - tests/phpstan/baselines/if.alwaysTrue.neon + - tests/phpstan/baselines/instanceof.alwaysTrue.neon + - tests/phpstan/baselines/isset.offset.neon + - tests/phpstan/baselines/isset.property.neon + - tests/phpstan/baselines/isset.variable.neon + - tests/phpstan/baselines/method.childParameterType.neon + - tests/phpstan/baselines/method.nonObject.neon + - tests/phpstan/baselines/method.notFound.neon + - tests/phpstan/baselines/method.unused.neon + - tests/phpstan/baselines/notIdentical.alwaysTrue.neon + - tests/phpstan/baselines/nullCoalesce.offset.neon + - tests/phpstan/baselines/nullCoalesce.property.neon + - tests/phpstan/baselines/offsetAccess.nonOffsetAccessible.neon + - tests/phpstan/baselines/offsetAccess.notFound.neon + - tests/phpstan/baselines/offsetAssign.valueType.neon + - tests/phpstan/baselines/parameter.defaultValue.neon + - tests/phpstan/baselines/parameter.notFound.neon + - tests/phpstan/baselines/parameter.phpDocType.neon + - tests/phpstan/baselines/parameter.unresolvableType.neon + - tests/phpstan/baselines/parameterByRef.type.neon + - tests/phpstan/baselines/parameterByRef.unusedType.neon + - tests/phpstan/baselines/property.defaultValue.neon + - tests/phpstan/baselines/property.nonObject.neon + - tests/phpstan/baselines/property.notFound.neon + - tests/phpstan/baselines/property.onlyWritten.neon + - tests/phpstan/baselines/property.phpDocType.neon + - tests/phpstan/baselines/property.private.neon + - tests/phpstan/baselines/property.protected.neon + - tests/phpstan/baselines/property.unusedType.neon + - tests/phpstan/baselines/return.empty.neon + - tests/phpstan/baselines/return.missing.neon + - tests/phpstan/baselines/return.type.neon + - tests/phpstan/baselines/return.unusedType.neon + - tests/phpstan/baselines/smallerOrEqual.alwaysTrue.neon + - tests/phpstan/baselines/staticClassAccess.privateMethod.neon + - tests/phpstan/baselines/ternary.alwaysFalse.neon + - tests/phpstan/baselines/ternary.alwaysTrue.neon + - tests/phpstan/baselines/varTag.noVariable.neon + - tests/phpstan/baselines/variable.undefined.neon + - tests/phpstan/baselines/while.alwaysFalse.neon + - tests/phpstan/baselines/while.alwaysTrue.neon + # phpstan:baselines end parameters: # https://phpstan.org/user-guide/rule-levels - level: 0 + level: 5 reportUnmatchedIgnoredErrors: true + # The following ignored errors are not intended to be fixed, as distinct from the baselines + # included above. + # + # A baseline records work still to be done. Every entry in one is in scope to be fixed, and + # each file is meant to reach zero and then be deleted. An entry here is the opposite: a + # decision that the code is right as written and the report is not actionable, whether + # because PHPStan cannot see what makes the code safe, or because satisfying it would mean + # changing code that has no other reason to change. + # + # So prefer fixing an error, and baseline it when it cannot be fixed yet. Add it here only + # when it should never be fixed, and say why. ignoreErrors: # Level 0: - # Inner functions aren't supported by PHPStan. @@ -40,6 +131,7 @@ parameters: identifier: function.inner path: src/wp-includes/canonical.php count: 1 + # Level 2: # ValueError is PHP 8.0+; core throws it conditionally so the docblocks are correct for WP's 7.4+ range, # but bleedingEdge's version-aware check treats the class as non-existent against the PHP 7.4 floor. diff --git a/src/js/_enqueues/admin/inline-edit-post.js b/src/js/_enqueues/admin/inline-edit-post.js index 6e9f4e9f20503..36ffaf18ef778 100644 --- a/src/js/_enqueues/admin/inline-edit-post.js +++ b/src/js/_enqueues/admin/inline-edit-post.js @@ -191,7 +191,7 @@ window.wp = window.wp || {}; */ setBulk : function(){ var te = '', type = this.type, c = true; - var checkedPosts = $( 'tbody th.check-column input[type="checkbox"]:checked' ); + var checkedPosts = $( 'tbody .check-column input[type="checkbox"]:checked' ); var categories = {}; this.revert(); @@ -207,7 +207,7 @@ window.wp = window.wp || {}; * * Get the selected posts based on the checked checkboxes in the post table. */ - $( 'tbody th.check-column input[type="checkbox"]' ).each( function() { + $( 'tbody .check-column input[type="checkbox"]' ).each( function() { // If the checkbox for a post is selected, add the post to the edit list. if ( $(this).prop('checked') ) { diff --git a/src/js/_enqueues/admin/tags.js b/src/js/_enqueues/admin/tags.js index ff7761adb8d3e..88e38b6926309 100644 --- a/src/js/_enqueues/admin/tags.js +++ b/src/js/_enqueues/admin/tags.js @@ -59,8 +59,10 @@ jQuery( function($) { nextFocus = prevFocus; } } - - tr.fadeOut('normal', function(){ tr.remove(); }); + tr.fadeOut('normal', function() { + tr.remove(); + updateTableNavCount(); + }); /** * Removes the term from the parent box and the tag cloud. @@ -73,7 +75,7 @@ jQuery( function($) { $('a.tag-link-' + data.match(/tag_ID=(\d+)/)[1]).remove(); nextFocus.trigger( 'focus' ); message = wp.i18n.__( 'The selected tag has been deleted.' ); - + } else if ( '-1' == r ) { message = wp.i18n.__( 'Sorry, you are not allowed to do that.' ); $('#ajax-response').empty().append('

' + message + '

'); @@ -103,6 +105,53 @@ jQuery( function($) { tr.find( ':input, a' ).prop( 'disabled', false ).removeAttr( 'tabindex' ); } + /** + * Updates the item count and table navigation after a tag is added or removed. + * + * Tags are added and removed client-side, but the item count, the `.tablenav` + * regions, the search box, and the empty-state row are otherwise only + * reconciled by PHP on a full page reload. This keeps them in sync. + * + * @param {string} [action] Pass 'add' when a tag was added. Any other value, + * including none, is treated as a removal. + * + * @return {void} + */ + function updateTableNavCount( action ) { + var $displayingNum = $( '.tablenav-pages .displaying-num' ), + currentCount = parseInt( $displayingNum.first().text().replace( /[^0-9]/g, '' ), 10 ) || 0, + itemCount = ( 'add' === action ) ? currentCount + 1 : Math.max( currentCount - 1, 0 ), + formattedCount = itemCount.toLocaleString(); + + $displayingNum.text( + wp.i18n.sprintf( + /* translators: %s: Number of items. */ + wp.i18n._n( '%s item', '%s items', itemCount ), + formattedCount + ) + ); + + if ( itemCount < 1 ) { + // No tags remain: show the empty-state row and hide the navigation. + var $list = $( '#the-list' ); + + if ( ! $list.find( 'tr.no-items' ).length ) { + var colspan = $list.closest( 'table' ).find( 'thead > tr' ).first().children( ':not(.hidden)' ).length; + $list.append( + '' + + wp.i18n.__( 'No tags found.' ) + + '' + ); + } + $( '.tablenav > *' ).hide(); + $( 'p.search-box' ).hide(); + } else { + $( '#the-list' ).find( 'tr.no-items' ).remove(); + $( '.tablenav > *' ).show(); + $( 'p.search-box' ).show(); + } + } + /** * Adds a deletion confirmation when removing a tag. * @@ -192,6 +241,8 @@ jQuery( function($) { } $('input:not([type="checkbox"]):not([type="radio"]):not([type="button"]):not([type="submit"]):not([type="reset"]):visible, textarea:visible', form).val(''); + + updateTableNavCount( 'add' ); }); return false; diff --git a/src/js/_enqueues/wp/customize/controls.js b/src/js/_enqueues/wp/customize/controls.js index a5846d45f687c..00123f9141c30 100644 --- a/src/js/_enqueues/wp/customize/controls.js +++ b/src/js/_enqueues/wp/customize/controls.js @@ -1701,6 +1701,7 @@ filtersHeight: 0, headerContainer: null, updateCountDebounced: null, + announceThemeDebounced: null, /** * wp.customize.ThemesSection @@ -1724,6 +1725,13 @@ section.$body = $( document.body ); api.Section.prototype.initialize.call( section, id, options ); section.updateCountDebounced = _.debounce( section.updateCount, 500 ); + section.announceThemeDebounced = _.debounce( function( name ) { + if ( ! name ) { + return; + } + + wp.a11y.speak( api.settings.l10n.announceThemeDetails.replace( '%s', name ) ); + }, 500 ); }, /** @@ -1777,13 +1785,20 @@ return; } + // Require the alt key for arrow events. + if ( 27 !== event.keyCode && ! event.altKey ) { + return; + } + // Pressing the right arrow key fires a theme:next event. if ( 39 === event.keyCode ) { + event.preventDefault(); // Prevent browser from triggering history shortcuts. section.nextTheme(); } // Pressing the left arrow key fires a theme:previous event. if ( 37 === event.keyCode ) { + event.preventDefault(); // Prevent browser from triggering history shortcuts. section.previousTheme(); } @@ -2602,7 +2617,8 @@ section.$body.addClass( 'modal-open' ); section.containFocus( section.overlay ); section.updateLimits(); - wp.a11y.speak( api.settings.l10n.announceThemeDetails.replace( '%s', theme.name ) ); + + section.announceThemeDebounced( theme.name ); if ( callback ) { callback(); } @@ -2620,6 +2636,8 @@ section.$body.removeClass( 'modal-open' ); section.overlay.fadeOut( 'fast' ); api.control( section.params.action + '_theme_' + section.currentTheme ).container.find( '.theme' ).focus(); + // Cancel any pending navigation announcement. + section.announceThemeDebounced.cancel(); }, /** diff --git a/src/js/_enqueues/wp/theme.js b/src/js/_enqueues/wp/theme.js index 56107ee475057..cfcea1fcc58b7 100644 --- a/src/js/_enqueues/wp/theme.js +++ b/src/js/_enqueues/wp/theme.js @@ -16,6 +16,30 @@ themes = wp.themes = wp.themes || {}; themes.data = _wpThemeSettings; l10n = themes.data.l10n; +/** + * Announces to screen readers the theme shown after previous/next navigation. + * + * @since 7.1.0 + * + * @param {Object} model The theme model. + * @return {void} + */ +themes.announceThemeDebounced = _.debounce( function( model ) { + var name; + + if ( ! model ) { + return; + } + + name = model.get( 'name' ) || model.get( 'id' ); + + if ( ! name ) { + return; + } + + wp.a11y.speak( l10n.themeViewed.replace( '%s', name ) ); +}, 500 ); + // Shortcut for isInstall check. themes.isInstall = !! themes.data.settings.isInstall; @@ -549,6 +573,7 @@ themes.view.Theme = wp.Backbone.View.extend({ preview.render(); this.setNavButtonsState(); $( '.next-theme' ).trigger( 'focus' ); + themes.announceThemeDebounced( self.current ); }) .listenTo( preview, 'theme:previous', function() { @@ -579,6 +604,7 @@ themes.view.Theme = wp.Backbone.View.extend({ preview.render(); this.setNavButtonsState(); $( '.previous-theme' ).trigger( 'focus' ); + themes.announceThemeDebounced( self.current ); }); this.listenTo( preview, 'preview:close', function() { @@ -769,6 +795,9 @@ themes.view.Details = wp.Backbone.View.extend({ } }); } + + // Cancel any pending navigation announcement. + themes.announceThemeDebounced.cancel(); }, // Handles .disabled classes for next/previous buttons. @@ -909,7 +938,7 @@ themes.view.Preview = themes.view.Details.extend({ 'click .devices button': 'previewDevice', 'click .previous-theme': 'previousTheme', 'click .next-theme': 'nextTheme', - 'keyup': 'keyEvent', + 'keydown': 'keyEvent', 'click .theme-install': 'installTheme' }, @@ -967,6 +996,9 @@ themes.view.Preview = themes.view.Details.extend({ this.trigger( 'preview:close' ); this.undelegateEvents(); this.unbind(); + + // Cancel any pending navigation announcement. + themes.announceThemeDebounced.cancel(); return false; }, @@ -1012,18 +1044,20 @@ themes.view.Preview = themes.view.Details.extend({ this.close(); } - // Return if Ctrl + Shift or Shift key pressed - if ( event.shiftKey || ( event.ctrlKey && event.shiftKey ) ) { + // Arrow key navigation requires Alt key to avoid interfering with screen reader navigation. + if ( ! event.altKey ) { return; } // The right arrow key, next theme. if ( event.keyCode === 39 ) { - _.once( this.nextTheme() ); + event.preventDefault(); + this.nextTheme(); } // The left arrow key, previous theme. if ( event.keyCode === 37 ) { + event.preventDefault(); this.previousTheme(); } }, @@ -1111,7 +1145,7 @@ themes.view.Themes = wp.Backbone.View.extend({ } ); // Bind keyboard events. - $( 'body' ).on( 'keyup', function( event ) { + $( 'body' ).on( 'keydown.wp-themes', function( event ) { if ( ! self.overlay ) { return; } @@ -1121,25 +1155,27 @@ themes.view.Themes = wp.Backbone.View.extend({ return; } - // Return if Ctrl + Shift or Shift key pressed - if ( event.shiftKey || ( event.ctrlKey && event.shiftKey ) ) { + // Pressing the escape key fires a theme:collapse event. + if ( event.keyCode === 27 ) { + self.overlay.collapse( event ); + } + + // Arrow key navigation requires Alt key to avoid interfering with screen reader navigation. + if ( ! event.altKey ) { return; } - // Pressing the right arrow key fires a theme:next event. + // Pressing Alt + right arrow key fires a theme:next event. if ( event.keyCode === 39 ) { + event.preventDefault(); self.overlay.nextTheme(); } - // Pressing the left arrow key fires a theme:previous event. + // Pressing Alt + left arrow key fires a theme:previous event. if ( event.keyCode === 37 ) { + event.preventDefault(); self.overlay.previousTheme(); } - - // Pressing the escape key fires a theme:collapse event. - if ( event.keyCode === 27 ) { - self.overlay.collapse( event ); - } }); }, @@ -1322,7 +1358,7 @@ themes.view.Themes = wp.Backbone.View.extend({ // Trigger a route update for the current model. self.theme.trigger( 'theme:expand', nextModel.cid ); - + themes.announceThemeDebounced( nextModel ); } }, @@ -1334,12 +1370,20 @@ themes.view.Themes = wp.Backbone.View.extend({ */ previous: function( args ) { var self = this, - model, previousModel; + model, previousModel, index; // Get the current theme. model = self.collection.get( args[0] ); + + index = self.collection.indexOf( model ); + + // Bail early if the current theme is the first one or the model does not exist. + if ( index <= 0 ) { + return; + } + // Find the previous model within the collection. - previousModel = self.collection.at( self.collection.indexOf( model ) - 1 ); + previousModel = self.collection.at( index - 1 ); if ( previousModel !== undefined ) { @@ -1349,7 +1393,7 @@ themes.view.Themes = wp.Backbone.View.extend({ // Trigger a route update for the current model. self.theme.trigger( 'theme:expand', previousModel.cid ); - + themes.announceThemeDebounced( previousModel ); } }, diff --git a/src/js/media/controllers/cropper.js b/src/js/media/controllers/cropper.js index b0a7a394400e5..2685f743ea8c7 100644 --- a/src/js/media/controllers/cropper.js +++ b/src/js/media/controllers/cropper.js @@ -116,7 +116,7 @@ Cropper = wp.media.controller.State.extend(/** @lends wp.media.controller.Croppe selection.set({cropDetails: controller.state().imgSelect.getSelection()}); this.$el.text(l10n.cropping); - this.$el.attr('disabled', true); + this.$el.prop( 'disabled', true ); controller.state().doCrop( selection ).done( function( croppedImage ) { controller.trigger('cropped', croppedImage ); diff --git a/src/js/media/models/attachments.js b/src/js/media/models/attachments.js index 1683494388283..fb31ba09ab9d6 100644 --- a/src/js/media/models/attachments.js +++ b/src/js/media/models/attachments.js @@ -32,6 +32,8 @@ var Attachments = Backbone.Collection.extend(/** @lends wp.media.model.Attachmen * @param {Object} [options={}] */ initialize: function( models, options ) { + var normalizedOrder; + options = options || {}; this.props = new Backbone.Model(); @@ -44,7 +46,19 @@ var Attachments = Backbone.Collection.extend(/** @lends wp.media.model.Attachmen this.props.on( 'change:orderby', this._changeOrderby, this ); this.props.on( 'change:query', this._changeQuery, this ); - this.props.set( _.defaults( options.props || {} ) ); + options.props = options.props || {}; + + /* + * Normalize the order, if one is set. `Attachments.comparator()` and the + * `order` filter in `wp.media.model.Query` both test for the literal + * strings 'ASC' and 'DESC', so anything else has to fall back to 'DESC'. + */ + if ( ! _.isUndefined( options.props.order ) && ! _.isNull( options.props.order ) ) { + normalizedOrder = String( options.props.order ).toUpperCase(); + options.props.order = ( 'ASC' === normalizedOrder || 'DESC' === normalizedOrder ) ? normalizedOrder : 'DESC'; + } + + this.props.set( options.props ); if ( options.observe ) { this.observe( options.observe ); diff --git a/src/js/media/models/query.js b/src/js/media/models/query.js index b3f62018f5cd4..3c47215c39833 100644 --- a/src/js/media/models/query.js +++ b/src/js/media/models/query.js @@ -251,12 +251,6 @@ Query = Attachments.extend(/** @lends wp.media.model.Query.prototype */{ // Fill default args. _.defaults( props, defaults ); - // Normalize the order. - props.order = props.order.toUpperCase(); - if ( 'DESC' !== props.order && 'ASC' !== props.order ) { - props.order = defaults.order.toUpperCase(); - } - // Ensure we have a valid orderby value. if ( ! _.contains( orderby.allowed, props.orderby ) ) { props.orderby = defaults.orderby; diff --git a/src/js/media/views/attachment.js b/src/js/media/views/attachment.js index 4ecda8347b00a..5761c959ea481 100644 --- a/src/js/media/views/attachment.js +++ b/src/js/media/views/attachment.js @@ -18,10 +18,20 @@ Attachment = View.extend(/** @lends wp.media.view.Attachment.prototype */{ template: wp.template('attachment'), attributes: function() { + var ariaLabel = this.model.get( 'title' ); + + if ( ! ariaLabel ) { + if ( this.model.get( 'uploading' ) ) { + ariaLabel = wp.i18n.__( 'uploading…' ); + } else { + ariaLabel = wp.i18n.__( '(no title)' ); + } + } + return { 'tabIndex': 0, 'role': 'checkbox', - 'aria-label': this.model.get( 'title' ) || wp.i18n.__( 'uploading…' ), + 'aria-label': ariaLabel, 'aria-checked': false, 'data-id': this.model.get( 'id' ) }; diff --git a/src/js/media/views/attachments/browser.js b/src/js/media/views/attachments/browser.js index 26218ea2fa1ae..82b7359eb832a 100644 --- a/src/js/media/views/attachments/browser.js +++ b/src/js/media/views/attachments/browser.js @@ -223,7 +223,7 @@ AttachmentsBrowser = View.extend(/** @lends wp.media.view.AttachmentsBrowser.pro this.toolbar.set( 'filters', Filters.render() ); } } - + /* * Feels odd to bring the global media library switcher into the Attachment browser view. * Is this a use case for doAction( 'add:toolbar-items:attachments-browser', this.toolbar ); @@ -241,7 +241,7 @@ AttachmentsBrowser = View.extend(/** @lends wp.media.view.AttachmentsBrowser.pro }).render() ); // DateFilter is a diff --git a/src/wp-admin/export-personal-data.php b/src/wp-admin/export-personal-data.php index 64b9653c3c1ba..e9ccedc491c14 100644 --- a/src/wp-admin/export-personal-data.php +++ b/src/wp-admin/export-personal-data.php @@ -127,8 +127,8 @@ + diff --git a/src/wp-admin/font-library.php b/src/wp-admin/font-library.php index abc2ea4f4da70..6129211f45545 100644 --- a/src/wp-admin/font-library.php +++ b/src/wp-admin/font-library.php @@ -28,10 +28,26 @@ } // Set the page title -$title = _x( 'Fonts', 'Font Library admin page title' ); +$title = _x( 'Fonts', 'Font Library admin page title' ); +$js_required_message = __( 'The Fonts screen requires JavaScript. Please enable JavaScript in your browser settings to install and manage fonts.' ); require_once ABSPATH . 'wp-admin/admin-header.php'; +?> +
+

+ 'error', + 'additional_classes' => array( 'hide-if-js' ), + ) + ); + ?> +
+ false, - 'search' => '*' . $_REQUEST['term'] . '*', + 'search' => '*' . $term . '*', 'include' => $include_blog_users, 'exclude' => $exclude_blog_users, 'search_columns' => array( 'user_login', 'user_nicename', 'user_email' ), diff --git a/src/wp-admin/includes/class-wp-application-passwords-list-table.php b/src/wp-admin/includes/class-wp-application-passwords-list-table.php index 9a60853016fc5..976c162058983 100644 --- a/src/wp-admin/includes/class-wp-application-passwords-list-table.php +++ b/src/wp-admin/includes/class-wp-application-passwords-list-table.php @@ -97,7 +97,7 @@ public function column_last_ip( $item ) { if ( empty( $item['last_ip'] ) ) { echo '—'; } else { - echo $item['last_ip']; + echo esc_html( $item['last_ip'] ); } } diff --git a/src/wp-admin/includes/class-wp-automatic-updater.php b/src/wp-admin/includes/class-wp-automatic-updater.php index 2facbeb1d522f..cd9426c6ef88b 100644 --- a/src/wp-admin/includes/class-wp-automatic-updater.php +++ b/src/wp-admin/includes/class-wp-automatic-updater.php @@ -1785,9 +1785,6 @@ protected function has_fatal_error() { 'Cache-Control' => 'no-cache', ); - /** This filter is documented in wp-includes/class-wp-http-streams.php */ - $sslverify = apply_filters( 'https_local_ssl_verify', false ); - // Include Basic auth in the loopback request. if ( isset( $_SERVER['PHP_AUTH_USER'] ) && isset( $_SERVER['PHP_AUTH_PW'] ) ) { $headers['Authorization'] = 'Basic ' . base64_encode( wp_unslash( $_SERVER['PHP_AUTH_USER'] ) . ':' . wp_unslash( $_SERVER['PHP_AUTH_PW'] ) ); @@ -1804,7 +1801,10 @@ protected function has_fatal_error() { $needle_start = "###### wp_scraping_result_start:$scrape_key ######"; $needle_end = "###### wp_scraping_result_end:$scrape_key ######"; $url = add_query_arg( $scrape_params, home_url( '/' ) ); - $response = wp_remote_get( $url, compact( 'cookies', 'headers', 'timeout', 'sslverify' ) ); + + /** This filter is documented in wp-includes/class-wp-http-streams.php */ + $sslverify = apply_filters( 'https_local_ssl_verify', false, $url ); + $response = wp_remote_get( $url, compact( 'cookies', 'headers', 'timeout', 'sslverify' ) ); if ( is_wp_error( $response ) ) { if ( $is_debug ) { diff --git a/src/wp-admin/includes/class-wp-filesystem-direct.php b/src/wp-admin/includes/class-wp-filesystem-direct.php index dad8e329b2421..33aa14ce47cb9 100644 --- a/src/wp-admin/includes/class-wp-filesystem-direct.php +++ b/src/wp-admin/includes/class-wp-filesystem-direct.php @@ -176,7 +176,7 @@ public function chmod( $file, $mode = false, $recursive = false ) { } if ( ! $recursive || ! $this->is_dir( $file ) ) { - $current_mode = fileperms( $file ) & 0777 | 0644; + $current_mode = fileperms( $file ) & 0777; /* * fileperms() populates the stat cache, so have to clear it diff --git a/src/wp-admin/includes/class-wp-links-list-table.php b/src/wp-admin/includes/class-wp-links-list-table.php index de116ecf944bc..ae1f50175c35b 100644 --- a/src/wp-admin/includes/class-wp-links-list-table.php +++ b/src/wp-admin/includes/class-wp-links-list-table.php @@ -364,4 +364,22 @@ protected function handle_row_actions( $item, $column_name, $primary ) { return $this->row_actions( $actions ); } + + /** + * Returns a clean label for the primary (Name) column's row header `aria-label`. + * + * Provides screen readers with just the link name as the row header name, + * preventing them from computing the name from the full cell content. + * + * @since 7.1.0 + * + * @param object $link The current link object. + * @return string The link name. + */ + protected function get_primary_column_aria_label( $link ) { + $link_name = html_entity_decode( $link->link_name, ENT_QUOTES, get_bloginfo( 'charset' ) ); + $link_name = wp_strip_all_tags( $link_name ); + + return $link_name; + } } diff --git a/src/wp-admin/includes/class-wp-list-table.php b/src/wp-admin/includes/class-wp-list-table.php index d32f08a438c60..5e6bcdb0d237c 100644 --- a/src/wp-admin/includes/class-wp-list-table.php +++ b/src/wp-admin/includes/class-wp-list-table.php @@ -1029,6 +1029,8 @@ protected function get_items_per_page( $option, $default_value = 20 ) { */ protected function pagination( $which ) { if ( empty( $this->_pagination_args['total_items'] ) ) { + // translators: Number is a fixed value. This is default text when no items are found. + echo '
' . __( '0 items' ) . '
'; return; } @@ -1685,12 +1687,16 @@ protected function display_tablenav( $which ) { ?>
- has_items() ) : ?> -
+ has_items() ) { + $visibility = ''; + } + ?> +
bulk_actions( $which ); ?>
- extra_tablenav( $which ); $this->pagination( $which ); ?> @@ -1767,6 +1773,26 @@ protected function column_default( $item, $column_name ) {} */ protected function column_cb( $item ) {} + /** + * Returns a clean, human-readable label for the primary column's row header. + * + * Used as the `aria-label` attribute value on the `` element, + * giving screen readers a concise cell name instead of computing it from + * the full cell content (which may include row action links, excerpts, etc.). + * + * Subclasses should override this method to return the item's primary + * identifier (e.g. post title, plugin name, username). Return an empty string + * to omit the attribute. + * + * @since 7.1.0 + * + * @param object|array $item The current item. + * @return string The aria-label value, or an empty string. + */ + protected function get_primary_column_aria_label( $item ) { + return ''; + } + /** * Generates the columns for a single row of the table. * @@ -1796,9 +1822,9 @@ protected function single_row_columns( $item ) { $attributes = "class='$classes' $data"; if ( 'cb' === $column_name ) { - echo ''; + echo ''; echo $this->column_cb( $item ); - echo ''; + echo ''; } elseif ( method_exists( $this, '_column_' . $column_name ) ) { echo call_user_func( array( $this, '_column_' . $column_name ), @@ -1807,16 +1833,29 @@ protected function single_row_columns( $item ) { $data, $primary ); - } elseif ( method_exists( $this, 'column_' . $column_name ) ) { - echo ""; - echo call_user_func( array( $this, 'column_' . $column_name ), $item ); - echo $this->handle_row_actions( $item, $column_name, $primary ); - echo ''; } else { - echo ""; - echo $this->column_default( $item, $column_name ); + $is_primary = ( $primary === $column_name ); + $tag = $is_primary ? 'th' : 'td'; + $scope = $is_primary ? ' scope="row"' : ''; + + $aria_label = ''; + if ( $is_primary ) { + $label = $this->get_primary_column_aria_label( $item ); + if ( '' !== $label ) { + $aria_label = ' aria-label="' . esc_attr( $label ) . '"'; + } + } + + echo "<$tag $attributes$scope$aria_label>"; + + if ( method_exists( $this, 'column_' . $column_name ) ) { + echo call_user_func( array( $this, 'column_' . $column_name ), $item ); + } else { + echo $this->column_default( $item, $column_name ); + } + echo $this->handle_row_actions( $item, $column_name, $primary ); - echo ''; + echo ""; } } } diff --git a/src/wp-admin/includes/class-wp-media-list-table.php b/src/wp-admin/includes/class-wp-media-list-table.php index a14b498ea525b..feac4753d3e92 100644 --- a/src/wp-admin/includes/class-wp-media-list-table.php +++ b/src/wp-admin/includes/class-wp-media-list-table.php @@ -932,4 +932,23 @@ protected function handle_row_actions( $item, $column_name, $primary ) { return $this->row_actions( $actions ); } + + /** + * Returns a clean label for the primary (File) column's row header `aria-label`. + * + * Provides screen readers with just the attachment title as the row header + * name, preventing them from computing the name from the full cell content. + * + * @since 7.1.0 + * + * @param WP_Post $post The current WP_Post object. + * @return string The attachment title. + */ + protected function get_primary_column_aria_label( $post ) { + // The title may contain HTML. The printed aria-label uses esc_attr() later. + $attachment_title = html_entity_decode( _draft_or_post_title( $post ), ENT_QUOTES, get_bloginfo( 'charset' ) ); + $attachment_title = wp_strip_all_tags( $attachment_title ); + + return $attachment_title; + } } diff --git a/src/wp-admin/includes/class-wp-ms-sites-list-table.php b/src/wp-admin/includes/class-wp-ms-sites-list-table.php index f4a937962ed19..1ffa24ea24106 100644 --- a/src/wp-admin/includes/class-wp-ms-sites-list-table.php +++ b/src/wp-admin/includes/class-wp-ms-sites-list-table.php @@ -884,4 +884,23 @@ protected function handle_row_actions( $item, $column_name, $primary ) { return $this->row_actions( $actions ); } + + /** + * Returns a clean label for the primary (URL) column's row header `aria-label`. + * + * Provides screen readers with just the site title as the row header name, + * preventing them from computing the name from the full cell content. + * + * @since 7.1.0 + * + * @param array $blog The current site properties array. + * @return string The site title, or the site URL (domain + path) if the title is empty. + */ + protected function get_primary_column_aria_label( $blog ) { + $blog_name = html_entity_decode( (string) get_blog_option( $blog['blog_id'], 'blogname', '' ), ENT_QUOTES, get_bloginfo( 'charset' ) ); + $blog_name = wp_strip_all_tags( $blog_name ); + + // Fall back to the blog URL and path if the blog name is empty. + return '' !== $blog_name ? $blog_name : untrailingslashit( $blog['domain'] . $blog['path'] ); + } } diff --git a/src/wp-admin/includes/class-wp-ms-themes-list-table.php b/src/wp-admin/includes/class-wp-ms-themes-list-table.php index a0fca2fd60fe4..81c35414d9053 100644 --- a/src/wp-admin/includes/class-wp-ms-themes-list-table.php +++ b/src/wp-admin/includes/class-wp-ms-themes-list-table.php @@ -940,11 +940,11 @@ public function single_row_columns( $item ) { switch ( $column_name ) { case 'cb': - echo ''; + echo ''; $this->column_cb( $item ); - echo ''; + echo ''; break; case 'name': @@ -966,11 +966,11 @@ public function single_row_columns( $item ) { } } - echo "" . $item->display( 'Name' ) . $active_theme_label . ''; + echo "" . $item->display( 'Name' ) . $active_theme_label . ''; $this->column_name( $item ); - echo ''; + echo ''; break; case 'description': diff --git a/src/wp-admin/includes/class-wp-ms-users-list-table.php b/src/wp-admin/includes/class-wp-ms-users-list-table.php index b4cbf8457a0b4..145299bcc26cb 100644 --- a/src/wp-admin/includes/class-wp-ms-users-list-table.php +++ b/src/wp-admin/includes/class-wp-ms-users-list-table.php @@ -502,7 +502,7 @@ public function display_rows() { } ?> - + single_row_columns( $user ); ?> row_actions( $actions ); } + + /** + * Returns a clean label for the primary (Username) column's row header `aria-label`. + * + * Provides screen readers with just the user login as the row header name, + * preventing them from computing the name from the full cell content. + * + * @since 7.1.0 + * + * @param WP_User $user The current WP_User object. + * @return string The user login. + */ + protected function get_primary_column_aria_label( $user ) { + return $user->user_login; + } } diff --git a/src/wp-admin/includes/class-wp-plugins-list-table.php b/src/wp-admin/includes/class-wp-plugins-list-table.php index 08b2e982e702f..d8945e103064e 100644 --- a/src/wp-admin/includes/class-wp-plugins-list-table.php +++ b/src/wp-admin/includes/class-wp-plugins-list-table.php @@ -1233,12 +1233,12 @@ public function single_row( $item ) { switch ( $column_name ) { case 'cb': - echo "$checkbox"; + echo "$checkbox"; break; case 'name': - echo "$plugin_name"; + echo "$plugin_name"; echo $this->row_actions( $actions, true ); - echo ''; + echo ''; break; case 'description': $classes = 'column-description desc'; diff --git a/src/wp-admin/includes/class-wp-posts-list-table.php b/src/wp-admin/includes/class-wp-posts-list-table.php index 7522f8561ba44..8a319986766b8 100644 --- a/src/wp-admin/includes/class-wp-posts-list-table.php +++ b/src/wp-admin/includes/class-wp-posts-list-table.php @@ -1114,10 +1114,28 @@ public function column_cb( $item ) { * @param string $primary */ protected function _column_title( $post, $classes, $data, $primary ) { - echo ''; + $aria_label = $this->get_primary_column_aria_label( $post ); + $aria_attr = ( '' !== $aria_label ) ? ' aria-label="' . esc_attr( $aria_label ) . '"' : ''; + echo ''; echo $this->column_title( $post ); echo $this->handle_row_actions( $post, 'title', $primary ); - echo ''; + echo ''; + } + + /** + * Returns a clean label for the primary (title) column's row header `aria-label`. + * + * Provides screen readers with just the post title as the row header name, + * preventing them from computing the name from the full cell content + * (which includes row action links, post states, and possibly an excerpt). + * + * @since 7.1.0 + * + * @param WP_Post $item The current post object. + * @return string The post title, or 'no title' if no title. + */ + protected function get_primary_column_aria_label( $item ) { + return isset( $item->post_title ) && ! empty( $item->post_title ) ? $item->post_title : __( 'no title' ); } /** @@ -1197,7 +1215,7 @@ public function column_title( $post ) { /* translators: %s: Parent post title. */ esc_html( sprintf( __( 'Child of %s' ), wp_strip_all_tags( $parent_title ) ) ) ); - $hierarchy_nolink = sprintf( + $hierarchy_nolink = sprintf( ' (%2$s)', esc_attr( $hierarchy_id ), /* translators: %s: Parent post title. */ diff --git a/src/wp-admin/includes/class-wp-privacy-data-export-requests-list-table.php b/src/wp-admin/includes/class-wp-privacy-data-export-requests-list-table.php index aa68c84649e2f..c7ce460f75a70 100644 --- a/src/wp-admin/includes/class-wp-privacy-data-export-requests-list-table.php +++ b/src/wp-admin/includes/class-wp-privacy-data-export-requests-list-table.php @@ -157,4 +157,19 @@ public function column_next_steps( $item ) { break; } } + + /** + * Returns a clean label for the primary (Requester) column's row header `aria-label`. + * + * Provides screen readers with just the item email as the row header name, + * preventing them from computing the name from the full cell content. + * + * @since 7.1.0 + * + * @param WP_User_Request $item Item being shown. + * @return string The user request item email. + */ + protected function get_primary_column_aria_label( $item ) { + return $item->email; + } } diff --git a/src/wp-admin/includes/class-wp-privacy-data-removal-requests-list-table.php b/src/wp-admin/includes/class-wp-privacy-data-removal-requests-list-table.php index 716535160f12d..36d8ba5384590 100644 --- a/src/wp-admin/includes/class-wp-privacy-data-removal-requests-list-table.php +++ b/src/wp-admin/includes/class-wp-privacy-data-removal-requests-list-table.php @@ -164,4 +164,19 @@ public function column_next_steps( $item ) { break; } } + + /** + * Returns a clean label for the primary (Requester) column's row header `aria-label`. + * + * Provides screen readers with just the item email as the row header name, + * preventing them from computing the name from the full cell content. + * + * @since 7.1.0 + * + * @param WP_User_Request $item Item being shown. + * @return string The user request item email. + */ + protected function get_primary_column_aria_label( $item ) { + return $item->email; + } } diff --git a/src/wp-admin/includes/class-wp-privacy-policy-content.php b/src/wp-admin/includes/class-wp-privacy-policy-content.php index 2f7ec2108d22f..141f073cd260c 100644 --- a/src/wp-admin/includes/class-wp-privacy-policy-content.php +++ b/src/wp-admin/includes/class-wp-privacy-policy-content.php @@ -329,6 +329,12 @@ public static function notice( $post = null ) { $current_screen = get_current_screen(); $policy_page_id = (int) get_option( 'wp_page_for_privacy_policy' ); + // If the privacy policy page has been deleted, reset the option and bail. + if ( $policy_page_id && ! get_post( $policy_page_id ) ) { + update_option( 'wp_page_for_privacy_policy', 0 ); + return; + } + if ( 'post' !== $current_screen->base || $policy_page_id !== $post->ID ) { return; } diff --git a/src/wp-admin/includes/class-wp-screen.php b/src/wp-admin/includes/class-wp-screen.php index ab7dfef77f67c..b0b689d412edb 100644 --- a/src/wp-admin/includes/class-wp-screen.php +++ b/src/wp-admin/includes/class-wp-screen.php @@ -89,7 +89,7 @@ final class WP_Screen { * have a `$parent_base` of 'edit'. * * @since 3.3.0 - * @var string|null + * @var ?string */ public $parent_base; @@ -99,7 +99,7 @@ final class WP_Screen { * Some `$parent_file` values are 'edit.php?post_type=page', 'edit.php', and 'options-general.php'. * * @since 3.3.0 - * @var string|null + * @var ?string */ public $parent_file; @@ -186,7 +186,7 @@ final class WP_Screen { * Stores the 'screen_settings' section of screen options. * * @since 3.3.0 - * @var string + * @var ?string */ private $_screen_settings; diff --git a/src/wp-admin/includes/class-wp-site-health.php b/src/wp-admin/includes/class-wp-site-health.php index 9eb4c8525a942..2ddfaadc4b39d 100644 --- a/src/wp-admin/includes/class-wp-site-health.php +++ b/src/wp-admin/includes/class-wp-site-health.php @@ -2212,9 +2212,6 @@ public function get_test_rest_availability() { 'Cache-Control' => 'no-cache', 'X-WP-Nonce' => wp_create_nonce( 'wp_rest' ), ); - /** This filter is documented in wp-includes/class-wp-http-streams.php */ - $sslverify = apply_filters( 'https_local_ssl_verify', false ); - // Include Basic auth in loopback requests. if ( isset( $_SERVER['PHP_AUTH_USER'] ) && isset( $_SERVER['PHP_AUTH_PW'] ) ) { $headers['Authorization'] = 'Basic ' . base64_encode( wp_unslash( $_SERVER['PHP_AUTH_USER'] ) . ':' . wp_unslash( $_SERVER['PHP_AUTH_PW'] ) ); @@ -2230,6 +2227,9 @@ public function get_test_rest_availability() { $url ); + /** This filter is documented in wp-includes/class-wp-http-streams.php */ + $sslverify = apply_filters( 'https_local_ssl_verify', false, $url ); + $r = wp_remote_get( $url, compact( 'cookies', 'headers', 'timeout', 'sslverify' ) ); if ( is_wp_error( $r ) ) { @@ -3279,8 +3279,6 @@ public function can_perform_loopback() { $headers = array( 'Cache-Control' => 'no-cache', ); - /** This filter is documented in wp-includes/class-wp-http-streams.php */ - $sslverify = apply_filters( 'https_local_ssl_verify', false ); // Include Basic auth in loopback requests. if ( isset( $_SERVER['PHP_AUTH_USER'] ) && isset( $_SERVER['PHP_AUTH_PW'] ) ) { @@ -3289,6 +3287,9 @@ public function can_perform_loopback() { $url = site_url( 'wp-cron.php' ); + /** This filter is documented in wp-includes/class-wp-http-streams.php */ + $sslverify = apply_filters( 'https_local_ssl_verify', false, $url ); + /* * A post request is used for the wp-cron.php loopback test to cause the file * to finish early without triggering cron jobs. This has two benefits: @@ -3611,7 +3612,7 @@ public function get_page_cache_headers(): array { private function check_for_page_caching() { /** This filter is documented in wp-includes/class-wp-http-streams.php */ - $sslverify = apply_filters( 'https_local_ssl_verify', false ); + $sslverify = apply_filters( 'https_local_ssl_verify', false, home_url( '/' ) ); $headers = array(); diff --git a/src/wp-admin/includes/class-wp-terms-list-table.php b/src/wp-admin/includes/class-wp-terms-list-table.php index 75e37e45d5c4f..561888fed8394 100644 --- a/src/wp-admin/includes/class-wp-terms-list-table.php +++ b/src/wp-admin/includes/class-wp-terms-list-table.php @@ -751,4 +751,20 @@ public function inline_edit() { name; + } } diff --git a/src/wp-admin/includes/class-wp-users-list-table.php b/src/wp-admin/includes/class-wp-users-list-table.php index 9a8709b438e05..1212c2db531e5 100644 --- a/src/wp-admin/includes/class-wp-users-list-table.php +++ b/src/wp-admin/includes/class-wp-users-list-table.php @@ -563,9 +563,16 @@ public function single_row( $user_object, $style = '', $role = '', $numposts = 0 $attributes = "class='$classes' $data"; if ( 'cb' === $column_name ) { - $row .= "$checkbox"; + $row .= "$checkbox"; } else { - $row .= ""; + $is_primary = ( $primary === $column_name ); + $tag = $is_primary ? 'th' : 'td'; + $scope = $is_primary ? ' scope="row"' : ''; + $aria_label = ''; + if ( $is_primary ) { + $aria_label = ' aria-label="' . esc_attr( $user_object->user_login ) . '"'; + } + $row .= "<$tag $attributes$scope$aria_label>"; switch ( $column_name ) { case 'username': $row .= "$avatar $edit"; @@ -628,7 +635,8 @@ public function single_row( $user_object, $style = '', $role = '', $numposts = 0 if ( $primary === $column_name ) { $row .= $this->row_actions( $actions ); } - $row .= ''; + $tag = ( $primary === $column_name ) ? 'th' : 'td'; + $row .= ""; } } $row .= ''; diff --git a/src/wp-admin/includes/dashboard-on-this-day.php b/src/wp-admin/includes/dashboard-on-this-day.php deleted file mode 100644 index 1939f8ca4b0e3..0000000000000 --- a/src/wp-admin/includes/dashboard-on-this-day.php +++ /dev/null @@ -1,238 +0,0 @@ -' . esc_html__( 'No posts were published on this day in previous years.' ) . '

'; - return; - } - - $posts_by_year = array(); - $post_count = count( $posts ); - - foreach ( $posts as $post ) { - $year = get_the_date( 'Y', $post ); - - if ( ! isset( $posts_by_year[ $year ] ) ) { - $posts_by_year[ $year ] = array(); - } - - $posts_by_year[ $year ][] = $post; - } - - /* translators: Date format for the On This Day widget date, without year. See https://www.php.net/manual/datetime.format.php */ - $date = '' . esc_html( wp_date( _x( 'F jS', 'on this day date format' ) ) ) . ''; - ?> -
-

- -

-
    - $year_posts ) : ?> -
  • -

    -
      - - post_author; - $author_name = $author_id > 0 ? (string) get_the_author_meta( 'display_name', $author_id ) : ''; - $show_author = '' !== trim( $author_name ) && get_current_user_id() !== $author_id; - ?> -
    • - - - ' . esc_html( - sprintf( - /* translators: %s: Post author's display name. */ - __( 'by %s' ), - $author_name - ) - ) . ''; - ?> - -
    • - -
    -
  • - -
-
- format( 'Y' ); - $date_query = array( - 'relation' => 'AND', - array( - 'before' => array( 'year' => $year ), - ), - _wp_dashboard_on_this_day_date_query_clause( $today ), - ); - - $args = array( - 'post_type' => 'post', - 'post_status' => array( 'publish' ), - 'posts_per_page' => 10, - 'ignore_sticky_posts' => true, - 'orderby' => 'date', - 'order' => 'DESC', - 'no_found_rows' => true, - 'update_post_term_cache' => false, - 'update_post_meta_cache' => false, - 'date_query' => $date_query, - ); - - /** - * Filters the arguments used to query posts for the On This Day dashboard widget. - * - * @since 7.1.0 - * - * @param array $args WP_Query arguments. - */ - $args = apply_filters( 'wp_dashboard_on_this_day_query_args', $args ); - - $query = new WP_Query( $args ); - - return $query->posts; -} - -/** - * Builds the date query clause for today's anniversary date. - * - * On February 28 in a non-leap year, February 29 posts are included so - * leap-day anniversaries still appear. - * - * @since 7.1.0 - * @access private - * - * @param DateTimeInterface $date Date to build the clause for. - * @return array Date query clause. - */ -function _wp_dashboard_on_this_day_date_query_clause( $date ) { - $month = (int) $date->format( 'm' ); - $day = (int) $date->format( 'd' ); - $clause = array( - 'month' => $month, - 'day' => $day, - ); - - // Display leap day posts on Feb 28 in non leap years. - if ( - 28 === $day - && 2 === $month - && false === (bool) $date->format( 'L' ) - ) { - $clause = array( - 'relation' => 'OR', - $clause, - array( - 'month' => 2, - 'day' => 29, - ), - ); - } - - return $clause; -} diff --git a/src/wp-admin/includes/dashboard.php b/src/wp-admin/includes/dashboard.php index a0b0ac6c77239..0fe5c62064b64 100644 --- a/src/wp-admin/includes/dashboard.php +++ b/src/wp-admin/includes/dashboard.php @@ -88,13 +88,6 @@ function wp_dashboard_setup() { wp_add_dashboard_widget( 'dashboard_quick_press', $quick_draft_title, 'wp_dashboard_quick_press' ); } - // On This Day. - if ( ! function_exists( 'wp_dashboard_on_this_day_setup' ) ) { - require_once ABSPATH . 'wp-admin/includes/dashboard-on-this-day.php'; - } - - wp_dashboard_on_this_day_setup(); - // WordPress Events and News. wp_add_dashboard_widget( 'dashboard_primary', __( 'WordPress Events and News' ), 'wp_dashboard_events_news' ); @@ -1294,7 +1287,7 @@ function wp_dashboard_rss_control( $widget_id, $form_inputs = array() ) { $widget_options[ $widget_id ] = wp_widget_rss_process( $_POST['widget-rss'][ $number ] ); $widget_options[ $widget_id ]['number'] = $number; - // Title is optional. If black, fill it if possible. + // Title is optional. If blank, fill it if possible. if ( ! $widget_options[ $widget_id ]['title'] && isset( $_POST['widget-rss'][ $number ]['title'] ) ) { $rss = fetch_feed( $widget_options[ $widget_id ]['url'] ); if ( is_wp_error( $rss ) ) { @@ -1377,7 +1370,7 @@ function wp_dashboard_events_news() { * @since 4.8.0 */ function wp_print_community_events_markup() { - $community_events_notice = '

' . ( 'This widget requires JavaScript.' ) . '

'; + $community_events_notice = '

' . __( 'This widget requires JavaScript.' ) . '

'; $community_events_notice .= ''; $community_events_notice .= ''; diff --git a/src/wp-admin/includes/file.php b/src/wp-admin/includes/file.php index d7c771444ac98..8c0015020f35d 100644 --- a/src/wp-admin/includes/file.php +++ b/src/wp-admin/includes/file.php @@ -541,9 +541,6 @@ function wp_edit_theme_plugin_file( $args ) { 'Cache-Control' => 'no-cache', ); - /** This filter is documented in wp-includes/class-wp-http-streams.php */ - $sslverify = apply_filters( 'https_local_ssl_verify', false ); - // Include Basic auth in loopback requests. if ( isset( $_SERVER['PHP_AUTH_USER'] ) && isset( $_SERVER['PHP_AUTH_PW'] ) ) { $headers['Authorization'] = 'Basic ' . base64_encode( wp_unslash( $_SERVER['PHP_AUTH_USER'] ) . ':' . wp_unslash( $_SERVER['PHP_AUTH_PW'] ) ); @@ -583,7 +580,11 @@ function wp_edit_theme_plugin_file( $args ) { session_write_close(); } - $url = add_query_arg( $scrape_params, $url ); + $url = add_query_arg( $scrape_params, $url ); + + /** This filter is documented in wp-includes/class-wp-http-streams.php */ + $sslverify = apply_filters( 'https_local_ssl_verify', false, $url ); + $r = wp_remote_get( $url, compact( 'cookies', 'headers', 'timeout', 'sslverify' ) ); $body = wp_remote_retrieve_body( $r ); $scrape_result_position = strpos( $body, $needle_start ); diff --git a/src/wp-admin/includes/image-edit.php b/src/wp-admin/includes/image-edit.php index a9ddc55e1bf96..2f6bc25740e2d 100644 --- a/src/wp-admin/includes/image-edit.php +++ b/src/wp-admin/includes/image-edit.php @@ -151,12 +151,17 @@ function wp_image_editor( $post_id, $msg = false ) { - +
@@ -815,11 +820,13 @@ function wp_restore_image( $post_id ) { $restored = false; $msg = new stdClass(); - if ( ! is_array( $backup_sizes ) ) { + if ( ! is_array( $meta ) || ! is_array( $backup_sizes ) ) { $msg->error = __( 'Cannot load image metadata.' ); return $msg; } + $meta['sizes'] ??= array(); + $parts = pathinfo( $file ); $suffix = time() . rand( 100, 999 ); $default_sizes = get_intermediate_image_sizes(); @@ -978,6 +985,8 @@ function wp_save_image( $post_id ) { return $return; } + $meta['sizes'] ??= array(); + if ( ! is_array( $backup_sizes ) ) { $backup_sizes = array(); } diff --git a/src/wp-admin/includes/media.php b/src/wp-admin/includes/media.php index c2d15d758ef2e..6c50a1daba4fd 100644 --- a/src/wp-admin/includes/media.php +++ b/src/wp-admin/includes/media.php @@ -3425,9 +3425,9 @@ function attachment_submitbox_metadata() { $file_size = false; - if ( isset( $meta['filesize'] ) ) { - $file_size = $meta['filesize']; - } elseif ( file_exists( $file ) ) { + if ( isset( $meta['filesize'] ) && is_numeric( $meta['filesize'] ) && (int) $meta['filesize'] > 0 ) { + $file_size = (int) $meta['filesize']; + } elseif ( is_string( $file ) && '' !== $file && is_readable( $file ) ) { $file_size = wp_filesize( $file ); } diff --git a/src/wp-admin/includes/ms.php b/src/wp-admin/includes/ms.php index 50066bf14d18c..56e17113653d2 100644 --- a/src/wp-admin/includes/ms.php +++ b/src/wp-admin/includes/ms.php @@ -692,7 +692,7 @@ function site_admin_notice() { if ( (int) get_site_option( 'wpmu_upgrade_site' ) !== $wp_db_version ) { $upgrade_network_message = sprintf( /* translators: %s: URL to Upgrade Network screen. */ - __( 'Thank you for Updating! Please visit the Upgrade Network page to update all your sites.' ), + __( 'Thank you for updating! Please visit the Upgrade Network page to update all your sites.' ), esc_url( network_admin_url( 'upgrade.php' ) ) ); @@ -939,7 +939,7 @@ function confirm_delete_users( $users ) { if ( is_array( $blog_users ) && ! empty( $blog_users ) ) { $user_site = "{$details->blogname}"; switch_to_blog( $details->userblog_id ); - /* This filter is documented in wp-admin/users.php */ + /** This filter is documented in wp-admin/users.php */ $user_has_content = (bool) apply_filters( 'users_have_additional_content', false, array( $delete_user->ID ) ); if ( ! $user_has_content ) { diff --git a/src/wp-admin/includes/nav-menu.php b/src/wp-admin/includes/nav-menu.php index 70263a2034807..f26d63d528e78 100644 --- a/src/wp-admin/includes/nav-menu.php +++ b/src/wp-admin/includes/nav-menu.php @@ -1509,7 +1509,7 @@ function wp_nav_menu_update_menu_items( $nav_menu_selected_id, $nav_menu_selecte wp_defer_term_counting( false ); /** This action is documented in wp-includes/nav-menu.php */ - do_action( 'wp_update_nav_menu', $nav_menu_selected_id ); + do_action( 'wp_update_nav_menu', $nav_menu_selected_id, array() ); /* translators: %s: Nav menu title. */ $message = sprintf( __( '%s has been updated.' ), '' . $nav_menu_selected_title . '' ); diff --git a/src/wp-admin/includes/template.php b/src/wp-admin/includes/template.php index bafd5f0904769..418bfd7def697 100644 --- a/src/wp-admin/includes/template.php +++ b/src/wp-admin/includes/template.php @@ -316,11 +316,13 @@ function get_inline_data( $post ) { $title = esc_textarea( trim( $post->post_title ) ); + /** This filter is documented in wp-admin/edit-tag-form.php */ + $editable_slug = apply_filters( 'editable_slug', $post->post_name, $post ); + echo ' - diff --git a/src/wp-admin/network/themes.php b/src/wp-admin/network/themes.php index 763a13712a59b..8a27669f73b67 100644 --- a/src/wp-admin/network/themes.php +++ b/src/wp-admin/network/themes.php @@ -293,7 +293,7 @@ check_admin_referer( 'bulk-themes' ); /** This action is documented in wp-admin/network/site-themes.php */ - $referer = apply_filters( 'handle_network_bulk_actions-' . get_current_screen()->id, $referer, $action, $themes ); // phpcs:ignore WordPress.NamingConventions.ValidHookName.UseUnderscores + $referer = apply_filters( 'handle_network_bulk_actions-' . get_current_screen()->id, $referer, $action, $themes, 0 ); // phpcs:ignore WordPress.NamingConventions.ValidHookName.UseUnderscores wp_safe_redirect( $referer ); exit; diff --git a/src/wp-admin/network/users.php b/src/wp-admin/network/users.php index 3e18aae9673a4..144f98d0f90d8 100644 --- a/src/wp-admin/network/users.php +++ b/src/wp-admin/network/users.php @@ -158,7 +158,7 @@ $user_ids = (array) $_POST['allusers']; /** This action is documented in wp-admin/network/site-themes.php */ - $sendback = apply_filters( 'handle_network_bulk_actions-' . get_current_screen()->id, $sendback, $doaction, $user_ids ); // phpcs:ignore WordPress.NamingConventions.ValidHookName.UseUnderscores + $sendback = apply_filters( 'handle_network_bulk_actions-' . get_current_screen()->id, $sendback, $doaction, $user_ids, 0 ); // phpcs:ignore WordPress.NamingConventions.ValidHookName.UseUnderscores wp_safe_redirect( $sendback ); exit; diff --git a/src/wp-admin/options-connectors.php b/src/wp-admin/options-connectors.php index c67d857b2b60d..7dc3b13508abe 100644 --- a/src/wp-admin/options-connectors.php +++ b/src/wp-admin/options-connectors.php @@ -27,13 +27,29 @@ } // Set the page title. -$title = __( 'Connectors' ); +$title = __( 'Connectors' ); +$js_required_message = __( 'The Connectors screen requires JavaScript. Please enable JavaScript in your browser settings to manage your connectors.' ); // Set parent file for menu highlighting. $parent_file = 'options-general.php'; require_once ABSPATH . 'wp-admin/admin-header.php'; +?> +
+

+ 'error', + 'additional_classes' => array( 'hide-if-js' ), + ) + ); + ?> +
+
-

+

+ + +

diff --git a/src/wp-admin/options-privacy.php b/src/wp-admin/options-privacy.php index 4205967acb3a8..739c8edab1cda 100644 --- a/src/wp-admin/options-privacy.php +++ b/src/wp-admin/options-privacy.php @@ -51,12 +51,15 @@ static function ( $body_class ) { check_admin_referer( $action ); if ( 'set-privacy-page' === $action ) { - $privacy_policy_page_id = isset( $_POST['page_for_privacy_policy'] ) ? (int) $_POST['page_for_privacy_policy'] : 0; + $previous_privacy_policy_page_id = (int) get_option( 'wp_page_for_privacy_policy' ); + $privacy_policy_page_id = isset( $_POST['page_for_privacy_policy'] ) ? (int) $_POST['page_for_privacy_policy'] : 0; update_option( 'wp_page_for_privacy_policy', $privacy_policy_page_id ); - $privacy_page_updated_message = __( 'Privacy Policy page updated successfully.' ); + $privacy_page_message_type = 'success'; if ( $privacy_policy_page_id ) { + $privacy_page_updated_message = __( 'Privacy Policy page updated successfully.' ); + /* * Don't always link to the menu customizer: * @@ -75,9 +78,16 @@ static function ( $body_class ) { esc_url( add_query_arg( 'autofocus[panel]', 'nav_menus', admin_url( 'customize.php' ) ) ) ); } + } elseif ( $previous_privacy_policy_page_id ) { + // A previously set Privacy Policy page was cleared. + $privacy_page_updated_message = __( 'Privacy Policy page removed.' ); + } else { + // No Privacy Policy page was set before, and none is set now. + $privacy_page_updated_message = __( 'No Privacy Policy page is currently set.' ); + $privacy_page_message_type = 'info'; } - add_settings_error( 'page_for_privacy_policy', 'page_for_privacy_policy', $privacy_page_updated_message, 'success' ); + add_settings_error( 'page_for_privacy_policy', 'page_for_privacy_policy', $privacy_page_updated_message, $privacy_page_message_type ); } elseif ( 'create-privacy-page' === $action ) { if ( ! class_exists( 'WP_Privacy_Policy_Content' ) ) { diff --git a/src/wp-admin/options-reading.php b/src/wp-admin/options-reading.php index 31facac7edcca..d52d51bbe3ae4 100644 --- a/src/wp-admin/options-reading.php +++ b/src/wp-admin/options-reading.php @@ -175,14 +175,14 @@ - + - + - - + + diff --git a/src/wp-admin/plugin-install.php b/src/wp-admin/plugin-install.php index 5c8be143bf332..395e55b8eca30 100644 --- a/src/wp-admin/plugin-install.php +++ b/src/wp-admin/plugin-install.php @@ -40,6 +40,15 @@ $wp_list_table->prepare_items(); +/** + * WP_Plugin_Install_List_Table::prepare_items() populates these globals, which + * are used throughout the rest of this file. + * + * @global string $tab The current tab of the Install Plugins screen. + * @global int $paged The current page number of the plugins list. + */ +global $tab, $paged; + $total_pages = $wp_list_table->get_pagination_arg( 'total_pages' ); if ( $pagenum > $total_pages && $total_pages > 0 ) { @@ -169,7 +178,7 @@

- diff --git a/src/wp-admin/site-editor.php b/src/wp-admin/site-editor.php index 9a8268c3392d7..4289f89f7102c 100644 --- a/src/wp-admin/site-editor.php +++ b/src/wp-admin/site-editor.php @@ -211,19 +211,21 @@ static function ( $classes ) { array( '/wp/v2/settings', 'OPTIONS' ), // Used by getBlockPatternCategories in useBlockEditorSettings. '/wp/v2/block-patterns/categories', - // @see packages/core-data/src/entities.js + /** + * The preloaded URL must exactly match the request the client makes, + * including the field order. + * @link https://github.com/WordPress/gutenberg/blob/trunk/packages/core-data/src/entities.js + */ '/?_fields=' . implode( ',', array( 'description', 'gmt_offset', 'home', + 'image_max_bit_depth', 'image_sizes', 'image_size_threshold', - 'image_output_formats', - 'jpeg_interlaced', - 'png_interlaced', - 'gif_interlaced', + 'image_strip_meta', 'name', 'site_icon', 'site_icon_url', diff --git a/src/wp-admin/theme-install.php b/src/wp-admin/theme-install.php index fc24334abff85..8e6fc5d1eea2c 100644 --- a/src/wp-admin/theme-install.php +++ b/src/wp-admin/theme-install.php @@ -67,6 +67,8 @@ /* translators: %d: Number of themes. */ 'themesFound' => __( 'Number of Themes found: %d' ), 'noThemesFound' => __( 'No themes found. Try a different search.' ), + /* translators: %s: Theme name. */ + 'themeViewed' => __( 'Theme details: %s' ), 'collapseSidebar' => __( 'Collapse Sidebar' ), 'expandSidebar' => __( 'Expand Sidebar' ), /* translators: Hidden accessibility text. */ diff --git a/src/wp-admin/themes.php b/src/wp-admin/themes.php index a9f24765ce742..ac2cd4a9824cb 100644 --- a/src/wp-admin/themes.php +++ b/src/wp-admin/themes.php @@ -131,9 +131,10 @@ if ( current_user_can( 'switch_themes' ) ) { $help_overview = '

' . __( 'This screen is used for managing your installed themes. Aside from the default theme(s) included with your WordPress installation, themes are designed and developed by third parties.' ) . '

' . '

' . __( 'From this screen you can:' ) . '

' . - '
  • ' . __( 'Hover or tap to see Activate and Live Preview buttons' ) . '
  • ' . - '
  • ' . __( 'Click on the theme to see the theme name, version, author, description, tags, and the Delete link' ) . '
  • ' . - '
  • ' . __( 'Click Customize for the active theme or Live Preview for any other theme to see a live preview' ) . '
' . + '
  • ' . __( 'Hover or tap to see Activate and Live Preview buttons.' ) . '
  • ' . + '
  • ' . __( 'Click Customize for the active theme or Live Preview for any other theme to see a live preview.' ) . '
  • ' . + '
  • ' . __( 'Click on a theme to open the Theme Details dialog and see the theme name, version, author, description, tags, and the Delete link.' ) . '
  • ' . + '
  • ' . __( 'Use the buttons at the top of the dialog, or alt/option plus the left or right arrow keys on your keyboard, to navigate between themes quickly.' ) . '
' . '

' . __( 'The active theme is displayed highlighted as the first theme.' ) . '

' . '

' . __( 'The search for installed themes will search for terms in their name, description, author, or tag.' ) . ' ' . __( 'The search results will be updated as you type.' ) . '

'; @@ -236,6 +237,8 @@ /* translators: %d: Number of themes. */ 'themesFound' => __( 'Number of Themes found: %d' ), 'noThemesFound' => __( 'No themes found. Try a different search.' ), + /* translators: %s: Theme name. */ + 'themeViewed' => __( 'Theme details: %s' ), ), ) ); diff --git a/src/wp-admin/upload.php b/src/wp-admin/upload.php index 1f42a287e4957..7cf0f6fe10108 100644 --- a/src/wp-admin/upload.php +++ b/src/wp-admin/upload.php @@ -190,7 +190,7 @@ function () { 'title' => __( 'Attachment Details' ), 'content' => '

' . __( 'Clicking an item will display an Attachment Details dialog, which allows you to preview media and make quick edits. Any changes you make to the attachment details will be automatically saved.' ) . '

' . - '

' . __( 'Use the arrow buttons at the top of the dialog, or the left and right arrow keys on your keyboard, to navigate between media items quickly.' ) . '

' . + '

' . __( 'Use the buttons at the top of the dialog, or alt/option plus the left or right arrow keys on your keyboard, to navigate between media items quickly.' ) . '

' . '

' . __( 'You can also delete individual items and access the extended edit screen from the details dialog.' ) . '

', ) ); diff --git a/src/wp-admin/user-new.php b/src/wp-admin/user-new.php index ba027b06bb366..3136705f60a03 100644 --- a/src/wp-admin/user-new.php +++ b/src/wp-admin/user-new.php @@ -603,7 +603,7 @@
- diff --git a/src/wp-content/themes/twentyeleven/author.php b/src/wp-content/themes/twentyeleven/author.php index ad2117324f571..23ca4310c1317 100644 --- a/src/wp-content/themes/twentyeleven/author.php +++ b/src/wp-content/themes/twentyeleven/author.php @@ -57,7 +57,7 @@ * * @since Twenty Eleven 1.0 * - * @param int The height and width avatar dimension in pixels. Default 60. + * @param int $size The height and width avatar dimension in pixels. Default 60. */ $author_bio_avatar_size = apply_filters( 'twentyeleven_author_bio_avatar_size', 60 ); echo get_avatar( get_the_author_meta( 'user_email' ), $author_bio_avatar_size ); diff --git a/src/wp-content/themes/twentyeleven/content-status.php b/src/wp-content/themes/twentyeleven/content-status.php index 15484232cd0d6..c76760b321111 100644 --- a/src/wp-content/themes/twentyeleven/content-status.php +++ b/src/wp-content/themes/twentyeleven/content-status.php @@ -39,7 +39,7 @@ * * @since Twenty Eleven 1.0 * - * @param int The height and width avatar dimensions in pixels. Default 65. + * @param int $size The height and width avatar dimensions in pixels. Default 65. */ echo get_avatar( get_the_author_meta( 'ID' ), apply_filters( 'twentyeleven_status_avatar', 65 ) ); ?> diff --git a/src/wp-content/themes/twentyeleven/functions.php b/src/wp-content/themes/twentyeleven/functions.php index 6434507effd7a..251d2807a41e8 100644 --- a/src/wp-content/themes/twentyeleven/functions.php +++ b/src/wp-content/themes/twentyeleven/functions.php @@ -689,7 +689,7 @@ function twentyeleven_get_first_url() { } /** This filter is documented in wp-includes/link-template.php */ - return ( $has_url ) ? $has_url : apply_filters( 'the_permalink', get_permalink() ); + return ( $has_url ) ? $has_url : apply_filters( 'the_permalink', get_permalink(), get_post() ); } /** diff --git a/src/wp-content/themes/twentyeleven/image.php b/src/wp-content/themes/twentyeleven/image.php index 54bfb17498fe7..1bab57b581259 100644 --- a/src/wp-content/themes/twentyeleven/image.php +++ b/src/wp-content/themes/twentyeleven/image.php @@ -99,7 +99,7 @@ * * @since Twenty Eleven 1.0 * - * @param int The width for the image attachment size in pixels. Default 848. + * @param int $size The width for the image attachment size in pixels. Default 848. */ $attachment_size = apply_filters( 'twentyeleven_attachment_size', 848 ); echo wp_get_attachment_image( $post->ID, array( $attachment_size, 1024 ) ); diff --git a/src/wp-content/themes/twentyeleven/inc/widgets.php b/src/wp-content/themes/twentyeleven/inc/widgets.php index 4e82cdf6a055e..72411d01a8064 100644 --- a/src/wp-content/themes/twentyeleven/inc/widgets.php +++ b/src/wp-content/themes/twentyeleven/inc/widgets.php @@ -70,7 +70,7 @@ public function widget( $args, $instance ) { ob_start(); - /** This filter is documented in wp-includes/default-widgets.php */ + /** This filter is documented in wp-includes/widgets/class-wp-widget-pages.php */ $args['title'] = apply_filters( 'widget_title', empty( $instance['title'] ) ? __( 'Ephemera', 'twentyeleven' ) : $instance['title'], $instance, $this->id_base ); if ( ! isset( $instance['number'] ) ) { diff --git a/src/wp-content/themes/twentyeleven/tag.php b/src/wp-content/themes/twentyeleven/tag.php index 23517622f0cb6..966dc7c603a93 100644 --- a/src/wp-content/themes/twentyeleven/tag.php +++ b/src/wp-content/themes/twentyeleven/tag.php @@ -30,7 +30,7 @@ * * @since Twenty Eleven 1.0 * - * @param string The default tag description. + * @param string $tag_archive_meta The default tag description. */ echo apply_filters( 'tag_archive_meta', '
' . $tag_description . '
' ); } diff --git a/src/wp-content/themes/twentyfifteen/inc/template-tags.php b/src/wp-content/themes/twentyfifteen/inc/template-tags.php index 7f39cdc194a7c..f77e13250965f 100644 --- a/src/wp-content/themes/twentyfifteen/inc/template-tags.php +++ b/src/wp-content/themes/twentyfifteen/inc/template-tags.php @@ -246,7 +246,8 @@ function twentyfifteen_post_thumbnail() { function twentyfifteen_get_link_url() { $has_url = get_url_in_content( get_the_content() ); - return $has_url ? $has_url : apply_filters( 'the_permalink', get_permalink() ); + /** This filter is documented in wp-includes/link-template.php */ + return $has_url ? $has_url : apply_filters( 'the_permalink', get_permalink(), get_post() ); } endif; diff --git a/src/wp-content/themes/twentyfourteen/inc/widgets.php b/src/wp-content/themes/twentyfourteen/inc/widgets.php index 8ffac4cd83057..36846daa84860 100644 --- a/src/wp-content/themes/twentyfourteen/inc/widgets.php +++ b/src/wp-content/themes/twentyfourteen/inc/widgets.php @@ -113,7 +113,8 @@ public function widget( $args, $instance ) { $number = ! empty( $instance['number'] ) ? absint( $instance['number'] ) : 2; $title = ! empty( $instance['title'] ) ? $instance['title'] : $format_string; - $title = apply_filters( 'widget_title', $title, $instance, $this->id_base ); + /** This filter is documented in wp-includes/widgets/class-wp-widget-pages.php */ + $title = apply_filters( 'widget_title', $title, $instance, $this->id_base ); $ephemera = new WP_Query( array( diff --git a/src/wp-content/themes/twentyten/functions.php b/src/wp-content/themes/twentyten/functions.php index 6d3e505670bc7..8cfd93bbf6faa 100644 --- a/src/wp-content/themes/twentyten/functions.php +++ b/src/wp-content/themes/twentyten/functions.php @@ -170,7 +170,7 @@ function twentyten_setup() { * * @since Twenty Ten 1.0 * - * @param int The default header image width in pixels. Default 940. + * @param int $width The default header image width in pixels. Default 940. */ 'width' => apply_filters( 'twentyten_header_image_width', 940 ), /** @@ -178,7 +178,7 @@ function twentyten_setup() { * * @since Twenty Ten 1.0 * - * @param int The default header image height in pixels. Default 198. + * @param int $height The default header image height in pixels. Default 198. */ 'height' => apply_filters( 'twentyten_header_image_height', 198 ), // Support flexible heights. diff --git a/src/wp-content/themes/twentythirteen/functions.php b/src/wp-content/themes/twentythirteen/functions.php index 6cbbb1c7adf94..9aa4fca7871cf 100644 --- a/src/wp-content/themes/twentythirteen/functions.php +++ b/src/wp-content/themes/twentythirteen/functions.php @@ -739,7 +739,8 @@ function twentythirteen_get_link_url() { $content = get_the_content(); $has_url = get_url_in_content( $content ); - return ( $has_url ) ? $has_url : apply_filters( 'the_permalink', get_permalink() ); + /** This filter is documented in wp-includes/link-template.php */ + return ( $has_url ) ? $has_url : apply_filters( 'the_permalink', get_permalink(), get_post() ); } if ( ! function_exists( 'twentythirteen_excerpt_more' ) && ! is_admin() ) : diff --git a/src/wp-content/themes/twentytwentyfour/functions.php b/src/wp-content/themes/twentytwentyfour/functions.php index 06d60695e9041..5dd34dd34c470 100644 --- a/src/wp-content/themes/twentytwentyfour/functions.php +++ b/src/wp-content/themes/twentytwentyfour/functions.php @@ -4,7 +4,8 @@ * * @link https://developer.wordpress.org/themes/basics/theme-functions/ * - * @package Twenty Twenty-Four + * @package WordPress + * @subpackage Twenty_Twenty_Four * @since Twenty Twenty-Four 1.0 */ diff --git a/src/wp-content/themes/twentytwentyfour/patterns/banner-hero.php b/src/wp-content/themes/twentytwentyfour/patterns/banner-hero.php index 9971884d6399e..dd5704a77e698 100644 --- a/src/wp-content/themes/twentytwentyfour/patterns/banner-hero.php +++ b/src/wp-content/themes/twentytwentyfour/patterns/banner-hero.php @@ -5,7 +5,12 @@ * Categories: banner, call-to-action, featured * Viewport width: 1400 * Description: A hero section with a title, a paragraph, a CTA button, and an image. + * + * @package WordPress + * @subpackage Twenty_Twenty_Four + * @since Twenty Twenty-Four 1.0 */ + ?> diff --git a/src/wp-content/themes/twentytwentyfour/patterns/banner-project-description.php b/src/wp-content/themes/twentytwentyfour/patterns/banner-project-description.php index 7ddeea7aa1721..32e9025cfb34c 100644 --- a/src/wp-content/themes/twentytwentyfour/patterns/banner-project-description.php +++ b/src/wp-content/themes/twentytwentyfour/patterns/banner-project-description.php @@ -5,8 +5,14 @@ * Categories: featured, banner, about, portfolio * Viewport width: 1400 * Description: Project description section with title, paragraph, and an image. + * + * @package WordPress + * @subpackage Twenty_Twenty_Four + * @since Twenty Twenty-Four 1.0 */ + ?> +
diff --git a/src/wp-content/themes/twentytwentyfour/patterns/cta-content-image-on-right.php b/src/wp-content/themes/twentytwentyfour/patterns/cta-content-image-on-right.php index 49bceb2eb96ac..88c6dbc13fb22 100644 --- a/src/wp-content/themes/twentytwentyfour/patterns/cta-content-image-on-right.php +++ b/src/wp-content/themes/twentytwentyfour/patterns/cta-content-image-on-right.php @@ -5,7 +5,12 @@ * Categories: call-to-action, banner * Viewport width: 1400 * Description: A title, paragraph, two CTA buttons, and an image for a general CTA section. + * + * @package WordPress + * @subpackage Twenty_Twenty_Four + * @since Twenty Twenty-Four 1.0 */ + ?> diff --git a/src/wp-content/themes/twentytwentyfour/patterns/cta-pricing.php b/src/wp-content/themes/twentytwentyfour/patterns/cta-pricing.php index 4ba39374342de..ba03a11f5cffc 100644 --- a/src/wp-content/themes/twentytwentyfour/patterns/cta-pricing.php +++ b/src/wp-content/themes/twentytwentyfour/patterns/cta-pricing.php @@ -5,7 +5,12 @@ * Categories: call-to-action, services * Viewport width: 1400 * Description: A pricing section with a title, a paragraph and three pricing levels. + * + * @package WordPress + * @subpackage Twenty_Twenty_Four + * @since Twenty Twenty-Four 1.0 */ + ?> diff --git a/src/wp-content/themes/twentytwentyfour/patterns/cta-rsvp.php b/src/wp-content/themes/twentytwentyfour/patterns/cta-rsvp.php index a12386949b1a8..30c59ffc06d8d 100644 --- a/src/wp-content/themes/twentytwentyfour/patterns/cta-rsvp.php +++ b/src/wp-content/themes/twentytwentyfour/patterns/cta-rsvp.php @@ -5,7 +5,12 @@ * Categories: call-to-action, featured * Viewport width: 1100 * Description: A large RSVP heading sideways, a description, and a CTA button. + * + * @package WordPress + * @subpackage Twenty_Twenty_Four + * @since Twenty Twenty-Four 1.0 */ + ?> diff --git a/src/wp-content/themes/twentytwentyfour/patterns/cta-services-image-left.php b/src/wp-content/themes/twentytwentyfour/patterns/cta-services-image-left.php index b6315b0cbd30b..9b6cdf486d43d 100644 --- a/src/wp-content/themes/twentytwentyfour/patterns/cta-services-image-left.php +++ b/src/wp-content/themes/twentytwentyfour/patterns/cta-services-image-left.php @@ -5,7 +5,12 @@ * Categories: call-to-action, banner, featured, services * Viewport width: 1400 * Description: An image, title, paragraph and a CTA button to describe services. + * + * @package WordPress + * @subpackage Twenty_Twenty_Four + * @since Twenty Twenty-Four 1.0 */ + ?> diff --git a/src/wp-content/themes/twentytwentyfour/patterns/cta-subscribe-centered.php b/src/wp-content/themes/twentytwentyfour/patterns/cta-subscribe-centered.php index c11b9133510bb..fbb1b641c84e5 100644 --- a/src/wp-content/themes/twentytwentyfour/patterns/cta-subscribe-centered.php +++ b/src/wp-content/themes/twentytwentyfour/patterns/cta-subscribe-centered.php @@ -5,7 +5,12 @@ * Categories: call-to-action * Keywords: newsletter, subscribe, button * Description: Subscribers CTA section with a title, a paragraph and a CTA button. + * + * @package WordPress + * @subpackage Twenty_Twenty_Four + * @since Twenty Twenty-Four 1.0 */ + ?> diff --git a/src/wp-content/themes/twentytwentyfour/patterns/footer-centered-logo-nav.php b/src/wp-content/themes/twentytwentyfour/patterns/footer-centered-logo-nav.php index 39258764fc9e4..13b827418bf7a 100644 --- a/src/wp-content/themes/twentytwentyfour/patterns/footer-centered-logo-nav.php +++ b/src/wp-content/themes/twentytwentyfour/patterns/footer-centered-logo-nav.php @@ -5,7 +5,12 @@ * Categories: footer * Block Types: core/template-part/footer * Description: A footer section with a centered logo, navigation, and WordPress credits. + * + * @package WordPress + * @subpackage Twenty_Twenty_Four + * @since Twenty Twenty-Four 1.0 */ + ?> diff --git a/src/wp-content/themes/twentytwentyfour/patterns/footer-colophon-3-col.php b/src/wp-content/themes/twentytwentyfour/patterns/footer-colophon-3-col.php index e0de63b6f97ee..6dbbe880dc2fe 100644 --- a/src/wp-content/themes/twentytwentyfour/patterns/footer-colophon-3-col.php +++ b/src/wp-content/themes/twentytwentyfour/patterns/footer-colophon-3-col.php @@ -5,7 +5,12 @@ * Categories: footer * Block Types: core/template-part/footer * Description: A footer section with a colophon and 3 columns. + * + * @package WordPress + * @subpackage Twenty_Twenty_Four + * @since Twenty Twenty-Four 1.0 */ + ?> diff --git a/src/wp-content/themes/twentytwentyfour/patterns/footer.php b/src/wp-content/themes/twentytwentyfour/patterns/footer.php index 4b8aeb2797883..b653e8c287027 100644 --- a/src/wp-content/themes/twentytwentyfour/patterns/footer.php +++ b/src/wp-content/themes/twentytwentyfour/patterns/footer.php @@ -5,7 +5,12 @@ * Categories: footer * Block Types: core/template-part/footer * Description: A footer section with a colophon and 4 columns. + * + * @package WordPress + * @subpackage Twenty_Twenty_Four + * @since Twenty Twenty-Four 1.0 */ + ?> diff --git a/src/wp-content/themes/twentytwentyfour/patterns/gallery-full-screen-image.php b/src/wp-content/themes/twentytwentyfour/patterns/gallery-full-screen-image.php index dc9cc5d339ba2..a22f6d5899940 100644 --- a/src/wp-content/themes/twentytwentyfour/patterns/gallery-full-screen-image.php +++ b/src/wp-content/themes/twentytwentyfour/patterns/gallery-full-screen-image.php @@ -4,7 +4,12 @@ * Slug: twentytwentyfour/gallery-full-screen-image * Categories: gallery, portfolio * Description: A cover image section that covers the entire width. + * + * @package WordPress + * @subpackage Twenty_Twenty_Four + * @since Twenty Twenty-Four 1.0 */ + ?> diff --git a/src/wp-content/themes/twentytwentyfour/patterns/gallery-offset-images-grid-2-col.php b/src/wp-content/themes/twentytwentyfour/patterns/gallery-offset-images-grid-2-col.php index 8e2222efe74cd..53e7996262b45 100644 --- a/src/wp-content/themes/twentytwentyfour/patterns/gallery-offset-images-grid-2-col.php +++ b/src/wp-content/themes/twentytwentyfour/patterns/gallery-offset-images-grid-2-col.php @@ -6,8 +6,14 @@ * Keywords: project, images, media, masonry, columns * Viewport width: 1400 * Description: A gallery section with 2 columns and offset images. + * + * @package WordPress + * @subpackage Twenty_Twenty_Four + * @since Twenty Twenty-Four 1.0 */ + ?> +
diff --git a/src/wp-content/themes/twentytwentyfour/patterns/gallery-offset-images-grid-3-col.php b/src/wp-content/themes/twentytwentyfour/patterns/gallery-offset-images-grid-3-col.php index 5f32f7f861c05..a24bffc8f0f67 100644 --- a/src/wp-content/themes/twentytwentyfour/patterns/gallery-offset-images-grid-3-col.php +++ b/src/wp-content/themes/twentytwentyfour/patterns/gallery-offset-images-grid-3-col.php @@ -6,8 +6,14 @@ * Keywords: project, images, media, masonry, columns * Viewport width: 1400 * Description: A gallery section with 3 columns and offset images. + * + * @package WordPress + * @subpackage Twenty_Twenty_Four + * @since Twenty Twenty-Four 1.0 */ + ?> +
diff --git a/src/wp-content/themes/twentytwentyfour/patterns/gallery-offset-images-grid-4-col.php b/src/wp-content/themes/twentytwentyfour/patterns/gallery-offset-images-grid-4-col.php index df9341d0457fb..c999ae84f0a11 100644 --- a/src/wp-content/themes/twentytwentyfour/patterns/gallery-offset-images-grid-4-col.php +++ b/src/wp-content/themes/twentytwentyfour/patterns/gallery-offset-images-grid-4-col.php @@ -6,7 +6,12 @@ * Keywords: project, images, media, masonry, columns * Viewport width: 1400 * Description: A gallery section with 4 columns and offset images. + * + * @package WordPress + * @subpackage Twenty_Twenty_Four + * @since Twenty Twenty-Four 1.0 */ + ?> diff --git a/src/wp-content/themes/twentytwentyfour/patterns/gallery-project-layout.php b/src/wp-content/themes/twentytwentyfour/patterns/gallery-project-layout.php index 143d61bd7a3f7..ea0d17fcea529 100644 --- a/src/wp-content/themes/twentytwentyfour/patterns/gallery-project-layout.php +++ b/src/wp-content/themes/twentytwentyfour/patterns/gallery-project-layout.php @@ -5,7 +5,12 @@ * Categories: gallery, featured, portfolio * Viewport width: 1600 * Description: A gallery section with a project layout with 2 images. + * + * @package WordPress + * @subpackage Twenty_Twenty_Four + * @since Twenty Twenty-Four 1.0 */ + ?> diff --git a/src/wp-content/themes/twentytwentyfour/patterns/hidden-404.php b/src/wp-content/themes/twentytwentyfour/patterns/hidden-404.php index d033a13f44d1b..7900e75c2ce17 100644 --- a/src/wp-content/themes/twentytwentyfour/patterns/hidden-404.php +++ b/src/wp-content/themes/twentytwentyfour/patterns/hidden-404.php @@ -3,7 +3,12 @@ * Title: 404 * Slug: twentytwentyfour/hidden-404 * Inserter: no + * + * @package WordPress + * @subpackage Twenty_Twenty_Four + * @since Twenty Twenty-Four 1.0 */ + ?> diff --git a/src/wp-content/themes/twentytwentyfour/patterns/hidden-comments.php b/src/wp-content/themes/twentytwentyfour/patterns/hidden-comments.php index bd106684624c2..ffbea0bb411f0 100644 --- a/src/wp-content/themes/twentytwentyfour/patterns/hidden-comments.php +++ b/src/wp-content/themes/twentytwentyfour/patterns/hidden-comments.php @@ -3,7 +3,12 @@ * Title: Comments * Slug: twentytwentyfour/hidden-comments * Inserter: no + * + * @package WordPress + * @subpackage Twenty_Twenty_Four + * @since Twenty Twenty-Four 1.0 */ + ?> diff --git a/src/wp-content/themes/twentytwentyfour/patterns/hidden-no-results.php b/src/wp-content/themes/twentytwentyfour/patterns/hidden-no-results.php index 00bbf9761bf7f..bb9242c263a7a 100644 --- a/src/wp-content/themes/twentytwentyfour/patterns/hidden-no-results.php +++ b/src/wp-content/themes/twentytwentyfour/patterns/hidden-no-results.php @@ -3,8 +3,14 @@ * Title: No results * Slug: twentytwentyfour/hidden-no-results * Inserter: no + * + * @package WordPress + * @subpackage Twenty_Twenty_Four + * @since Twenty Twenty-Four 1.0 */ + ?> +

diff --git a/src/wp-content/themes/twentytwentyfour/patterns/hidden-portfolio-hero.php b/src/wp-content/themes/twentytwentyfour/patterns/hidden-portfolio-hero.php index 1af3e7dbd17c1..467456f0e700c 100644 --- a/src/wp-content/themes/twentytwentyfour/patterns/hidden-portfolio-hero.php +++ b/src/wp-content/themes/twentytwentyfour/patterns/hidden-portfolio-hero.php @@ -3,7 +3,12 @@ * Title: Portfolio hero * Slug: twentytwentyfour/hidden-portfolio-hero * Inserter: no + * + * @package WordPress + * @subpackage Twenty_Twenty_Four + * @since Twenty Twenty-Four 1.0 */ + ?> diff --git a/src/wp-content/themes/twentytwentyfour/patterns/hidden-post-meta.php b/src/wp-content/themes/twentytwentyfour/patterns/hidden-post-meta.php index add4a1c8f3716..fc103c84aa3cd 100644 --- a/src/wp-content/themes/twentytwentyfour/patterns/hidden-post-meta.php +++ b/src/wp-content/themes/twentytwentyfour/patterns/hidden-post-meta.php @@ -3,7 +3,12 @@ * Title: Post meta * Slug: twentytwentyfour/hidden-post-meta * Inserter: no + * + * @package WordPress + * @subpackage Twenty_Twenty_Four + * @since Twenty Twenty-Four 1.0 */ + ?> diff --git a/src/wp-content/themes/twentytwentyfour/patterns/hidden-post-navigation.php b/src/wp-content/themes/twentytwentyfour/patterns/hidden-post-navigation.php index be2dcb28b7f28..2113fd89ced44 100644 --- a/src/wp-content/themes/twentytwentyfour/patterns/hidden-post-navigation.php +++ b/src/wp-content/themes/twentytwentyfour/patterns/hidden-post-navigation.php @@ -3,7 +3,12 @@ * Title: Post navigation * Slug: twentytwentyfour/hidden-post-navigation * Inserter: no + * + * @package WordPress + * @subpackage Twenty_Twenty_Four + * @since Twenty Twenty-Four 1.0 */ + ?> diff --git a/src/wp-content/themes/twentytwentyfour/patterns/hidden-posts-heading.php b/src/wp-content/themes/twentytwentyfour/patterns/hidden-posts-heading.php index 9b2811568ab6a..03a67a4bbe0ff 100644 --- a/src/wp-content/themes/twentytwentyfour/patterns/hidden-posts-heading.php +++ b/src/wp-content/themes/twentytwentyfour/patterns/hidden-posts-heading.php @@ -4,8 +4,14 @@ * Slug: twentytwentyfour/hidden-posts-heading * Categories: hidden * Inserter: no + * + * @package WordPress + * @subpackage Twenty_Twenty_Four + * @since Twenty Twenty-Four 1.3 */ + ?> +

diff --git a/src/wp-content/themes/twentytwentyfour/patterns/hidden-search.php b/src/wp-content/themes/twentytwentyfour/patterns/hidden-search.php index e4aaa64ece726..a0c9a4d16704a 100644 --- a/src/wp-content/themes/twentytwentyfour/patterns/hidden-search.php +++ b/src/wp-content/themes/twentytwentyfour/patterns/hidden-search.php @@ -3,7 +3,12 @@ * Title: Search * Slug: twentytwentyfour/hidden-search * Inserter: no + * + * @package WordPress + * @subpackage Twenty_Twenty_Four + * @since Twenty Twenty-Four 1.0 */ + ?> diff --git a/src/wp-content/themes/twentytwentyfour/patterns/hidden-sidebar.php b/src/wp-content/themes/twentytwentyfour/patterns/hidden-sidebar.php index abcb01b7bf0ba..093d60c3fe818 100644 --- a/src/wp-content/themes/twentytwentyfour/patterns/hidden-sidebar.php +++ b/src/wp-content/themes/twentytwentyfour/patterns/hidden-sidebar.php @@ -3,8 +3,14 @@ * Title: Sidebar * Slug: twentytwentyfour/hidden-sidebar * Inserter: no + * + * @package WordPress + * @subpackage Twenty_Twenty_Four + * @since Twenty Twenty-Four 1.0 */ + ?> +
diff --git a/src/wp-content/themes/twentytwentyfour/patterns/page-about-business.php b/src/wp-content/themes/twentytwentyfour/patterns/page-about-business.php index 7b662c243b98d..78741b9779022 100644 --- a/src/wp-content/themes/twentytwentyfour/patterns/page-about-business.php +++ b/src/wp-content/themes/twentytwentyfour/patterns/page-about-business.php @@ -8,7 +8,12 @@ * Post Types: page, wp_template * Viewport width: 1400 * Description: A business about page with a hero section, a text section, a services section, a team section, a clients section, a FAQ section, and a CTA section. + * + * @package WordPress + * @subpackage Twenty_Twenty_Four + * @since Twenty Twenty-Four 1.0 */ + ?> diff --git a/src/wp-content/themes/twentytwentyfour/patterns/page-home-blogging.php b/src/wp-content/themes/twentytwentyfour/patterns/page-home-blogging.php index bd08b6e04317b..905b8151dcb49 100644 --- a/src/wp-content/themes/twentytwentyfour/patterns/page-home-blogging.php +++ b/src/wp-content/themes/twentytwentyfour/patterns/page-home-blogging.php @@ -7,7 +7,12 @@ * Post Types: page, wp_template * Viewport width: 1400 * Description: A blogging home page with a hero section, a text section, a blog section, and a CTA section. + * + * @package WordPress + * @subpackage Twenty_Twenty_Four + * @since Twenty Twenty-Four 1.0 */ + ?> diff --git a/src/wp-content/themes/twentytwentyfour/patterns/page-home-business.php b/src/wp-content/themes/twentytwentyfour/patterns/page-home-business.php index 3f2c748e78553..8a665c5366dbc 100644 --- a/src/wp-content/themes/twentytwentyfour/patterns/page-home-business.php +++ b/src/wp-content/themes/twentytwentyfour/patterns/page-home-business.php @@ -8,7 +8,12 @@ * Post Types: page, wp_template * Viewport width: 1400 * Description: A business home page with a hero section, a text section, a services section, a team section, a clients section, a FAQ section, and a CTA section. + * + * @package WordPress + * @subpackage Twenty_Twenty_Four + * @since Twenty Twenty-Four 1.0 */ + ?> diff --git a/src/wp-content/themes/twentytwentyfour/patterns/page-home-portfolio-gallery.php b/src/wp-content/themes/twentytwentyfour/patterns/page-home-portfolio-gallery.php index 6c64adebe8dbd..f68471d36d040 100644 --- a/src/wp-content/themes/twentytwentyfour/patterns/page-home-portfolio-gallery.php +++ b/src/wp-content/themes/twentytwentyfour/patterns/page-home-portfolio-gallery.php @@ -8,7 +8,12 @@ * Post Types: page, wp_template * Viewport width: 1400 * Description: A portfolio home page that features a gallery. + * + * @package WordPress + * @subpackage Twenty_Twenty_Four + * @since Twenty Twenty-Four 1.0 */ + ?> diff --git a/src/wp-content/themes/twentytwentyfour/patterns/page-home-portfolio.php b/src/wp-content/themes/twentytwentyfour/patterns/page-home-portfolio.php index c03d1b13d57c3..45b1f05c80411 100644 --- a/src/wp-content/themes/twentytwentyfour/patterns/page-home-portfolio.php +++ b/src/wp-content/themes/twentytwentyfour/patterns/page-home-portfolio.php @@ -8,7 +8,12 @@ * Post Types: page, wp_template * Viewport width: 1400 * Description: A portfolio home page with a description and a 4-column post section with only feature images. + * + * @package WordPress + * @subpackage Twenty_Twenty_Four + * @since Twenty Twenty-Four 1.0 */ + ?> diff --git a/src/wp-content/themes/twentytwentyfour/patterns/page-newsletter-landing.php b/src/wp-content/themes/twentytwentyfour/patterns/page-newsletter-landing.php index 6ad69ff20ece9..2233bf3ba3a15 100644 --- a/src/wp-content/themes/twentytwentyfour/patterns/page-newsletter-landing.php +++ b/src/wp-content/themes/twentytwentyfour/patterns/page-newsletter-landing.php @@ -8,7 +8,12 @@ * Post Types: page, wp_template * Viewport width: 1100 * Description: A block with a newsletter subscription CTA for a landing page. + * + * @package WordPress + * @subpackage Twenty_Twenty_Four + * @since Twenty Twenty-Four 1.0 */ + ?> diff --git a/src/wp-content/themes/twentytwentyfour/patterns/page-portfolio-overview.php b/src/wp-content/themes/twentytwentyfour/patterns/page-portfolio-overview.php index 6aee66b1a610a..c6d87b4971119 100644 --- a/src/wp-content/themes/twentytwentyfour/patterns/page-portfolio-overview.php +++ b/src/wp-content/themes/twentytwentyfour/patterns/page-portfolio-overview.php @@ -8,7 +8,12 @@ * Post Types: page, wp_template * Viewport width: 1400 * Description: A full portfolio page with a section for project description, project details, a full screen image, and a gallery section with two images. + * + * @package WordPress + * @subpackage Twenty_Twenty_Four + * @since Twenty Twenty-Four 1.0 */ + ?> diff --git a/src/wp-content/themes/twentytwentyfour/patterns/page-rsvp-landing.php b/src/wp-content/themes/twentytwentyfour/patterns/page-rsvp-landing.php index 6ec67323ee79f..5e0dfd0196e9b 100644 --- a/src/wp-content/themes/twentytwentyfour/patterns/page-rsvp-landing.php +++ b/src/wp-content/themes/twentytwentyfour/patterns/page-rsvp-landing.php @@ -8,7 +8,12 @@ * Post Types: page, wp_template * Viewport width: 1100 * Description: A large RSVP heading sideways, a description, and a CTA button. + * + * @package WordPress + * @subpackage Twenty_Twenty_Four + * @since Twenty Twenty-Four 1.0 */ + ?> diff --git a/src/wp-content/themes/twentytwentyfour/patterns/posts-1-col.php b/src/wp-content/themes/twentytwentyfour/patterns/posts-1-col.php index f2bbaa1efd323..f672bd7bbbd77 100644 --- a/src/wp-content/themes/twentytwentyfour/patterns/posts-1-col.php +++ b/src/wp-content/themes/twentytwentyfour/patterns/posts-1-col.php @@ -5,7 +5,12 @@ * Categories: query * Block Types: core/query * Description: A list of posts, 1 column. + * + * @package WordPress + * @subpackage Twenty_Twenty_Four + * @since Twenty Twenty-Four 1.0 */ + ?> diff --git a/src/wp-content/themes/twentytwentyfour/patterns/posts-3-col.php b/src/wp-content/themes/twentytwentyfour/patterns/posts-3-col.php index 855615471b5e9..9c0f85e9d32e8 100644 --- a/src/wp-content/themes/twentytwentyfour/patterns/posts-3-col.php +++ b/src/wp-content/themes/twentytwentyfour/patterns/posts-3-col.php @@ -5,7 +5,12 @@ * Categories: query * Block Types: core/query * Description: A list of posts, 3 columns. + * + * @package WordPress + * @subpackage Twenty_Twenty_Four + * @since Twenty Twenty-Four 1.0 */ + ?> diff --git a/src/wp-content/themes/twentytwentyfour/patterns/posts-grid-2-col.php b/src/wp-content/themes/twentytwentyfour/patterns/posts-grid-2-col.php index d6a584f6f6c3b..600f7e29642c7 100644 --- a/src/wp-content/themes/twentytwentyfour/patterns/posts-grid-2-col.php +++ b/src/wp-content/themes/twentytwentyfour/patterns/posts-grid-2-col.php @@ -5,7 +5,12 @@ * Categories: query * Block Types: core/query * Description: A grid of posts featuring the first post, 2 columns. + * + * @package WordPress + * @subpackage Twenty_Twenty_Four + * @since Twenty Twenty-Four 1.0 */ + ?> diff --git a/src/wp-content/themes/twentytwentyfour/patterns/posts-images-only-3-col.php b/src/wp-content/themes/twentytwentyfour/patterns/posts-images-only-3-col.php index 7aee49301e524..c28906224a05c 100644 --- a/src/wp-content/themes/twentytwentyfour/patterns/posts-images-only-3-col.php +++ b/src/wp-content/themes/twentytwentyfour/patterns/posts-images-only-3-col.php @@ -5,7 +5,12 @@ * Categories: query * Block Types: core/query * Description: A list of posts with featured images only, 3 columns. + * + * @package WordPress + * @subpackage Twenty_Twenty_Four + * @since Twenty Twenty-Four 1.0 */ + ?> diff --git a/src/wp-content/themes/twentytwentyfour/patterns/posts-images-only-offset-4-col.php b/src/wp-content/themes/twentytwentyfour/patterns/posts-images-only-offset-4-col.php index 2cbcaff47e574..b6de9f474d7f4 100644 --- a/src/wp-content/themes/twentytwentyfour/patterns/posts-images-only-offset-4-col.php +++ b/src/wp-content/themes/twentytwentyfour/patterns/posts-images-only-offset-4-col.php @@ -4,7 +4,12 @@ * Slug: twentytwentyfour/posts-images-only-offset-4-col * Categories: posts * Description: A list of posts with featured images only, 4 columns. + * + * @package WordPress + * @subpackage Twenty_Twenty_Four + * @since Twenty Twenty-Four 1.0 */ + ?> diff --git a/src/wp-content/themes/twentytwentyfour/patterns/posts-list.php b/src/wp-content/themes/twentytwentyfour/patterns/posts-list.php index e298a544a324b..4a424b78af9da 100644 --- a/src/wp-content/themes/twentytwentyfour/patterns/posts-list.php +++ b/src/wp-content/themes/twentytwentyfour/patterns/posts-list.php @@ -5,7 +5,12 @@ * Categories: query, posts * Block Types: core/query * Description: A list of posts without images, 1 column. + * + * @package WordPress + * @subpackage Twenty_Twenty_Four + * @since Twenty Twenty-Four 1.0 */ + ?> diff --git a/src/wp-content/themes/twentytwentyfour/patterns/team-4-col.php b/src/wp-content/themes/twentytwentyfour/patterns/team-4-col.php index 86a8bfac2b362..62406b2ae51e9 100644 --- a/src/wp-content/themes/twentytwentyfour/patterns/team-4-col.php +++ b/src/wp-content/themes/twentytwentyfour/patterns/team-4-col.php @@ -5,7 +5,12 @@ * Categories: team, about * Viewport width: 1400 * Description: A team section, with a heading, a paragraph, and 4 columns for team members. + * + * @package WordPress + * @subpackage Twenty_Twenty_Four + * @since Twenty Twenty-Four 1.0 */ + ?> diff --git a/src/wp-content/themes/twentytwentyfour/patterns/template-archive-blogging.php b/src/wp-content/themes/twentytwentyfour/patterns/template-archive-blogging.php index 6d193a4cdd390..f5409a60053f8 100644 --- a/src/wp-content/themes/twentytwentyfour/patterns/template-archive-blogging.php +++ b/src/wp-content/themes/twentytwentyfour/patterns/template-archive-blogging.php @@ -5,7 +5,12 @@ * Template Types: archive, category, tag, author, date * Viewport width: 1400 * Inserter: no + * + * @package WordPress + * @subpackage Twenty_Twenty_Four + * @since Twenty Twenty-Four 1.0 */ + ?> diff --git a/src/wp-content/themes/twentytwentyfour/patterns/template-archive-portfolio.php b/src/wp-content/themes/twentytwentyfour/patterns/template-archive-portfolio.php index aa347ad1c2140..8db5343bb3c3a 100644 --- a/src/wp-content/themes/twentytwentyfour/patterns/template-archive-portfolio.php +++ b/src/wp-content/themes/twentytwentyfour/patterns/template-archive-portfolio.php @@ -5,7 +5,12 @@ * Template Types: archive * Viewport width: 1400 * Inserter: no + * + * @package WordPress + * @subpackage Twenty_Twenty_Four + * @since Twenty Twenty-Four 1.0 */ + ?> diff --git a/src/wp-content/themes/twentytwentyfour/patterns/template-home-blogging.php b/src/wp-content/themes/twentytwentyfour/patterns/template-home-blogging.php index 4a96d748004fb..515f3e6ad778f 100644 --- a/src/wp-content/themes/twentytwentyfour/patterns/template-home-blogging.php +++ b/src/wp-content/themes/twentytwentyfour/patterns/template-home-blogging.php @@ -5,7 +5,12 @@ * Template Types: front-page, index, home * Viewport width: 1400 * Inserter: no + * + * @package WordPress + * @subpackage Twenty_Twenty_Four + * @since Twenty Twenty-Four 1.0 */ + ?> diff --git a/src/wp-content/themes/twentytwentyfour/patterns/template-home-business.php b/src/wp-content/themes/twentytwentyfour/patterns/template-home-business.php index e84a5aca903c0..a214262ed494c 100644 --- a/src/wp-content/themes/twentytwentyfour/patterns/template-home-business.php +++ b/src/wp-content/themes/twentytwentyfour/patterns/template-home-business.php @@ -5,7 +5,12 @@ * Template Types: front-page, home * Viewport width: 1400 * Inserter: no + * + * @package WordPress + * @subpackage Twenty_Twenty_Four + * @since Twenty Twenty-Four 1.0 */ + ?> diff --git a/src/wp-content/themes/twentytwentyfour/patterns/template-home-portfolio.php b/src/wp-content/themes/twentytwentyfour/patterns/template-home-portfolio.php index 4fc4f072ba3c9..fe8a895ba8595 100644 --- a/src/wp-content/themes/twentytwentyfour/patterns/template-home-portfolio.php +++ b/src/wp-content/themes/twentytwentyfour/patterns/template-home-portfolio.php @@ -5,7 +5,12 @@ * Template Types: front-page, home * Viewport width: 1400 * Inserter: no + * + * @package WordPress + * @subpackage Twenty_Twenty_Four + * @since Twenty Twenty-Four 1.0 */ + ?> diff --git a/src/wp-content/themes/twentytwentyfour/patterns/template-index-blogging.php b/src/wp-content/themes/twentytwentyfour/patterns/template-index-blogging.php index b810186324aaf..c53ce0fc31a7b 100644 --- a/src/wp-content/themes/twentytwentyfour/patterns/template-index-blogging.php +++ b/src/wp-content/themes/twentytwentyfour/patterns/template-index-blogging.php @@ -5,7 +5,12 @@ * Template Types: index, home * Viewport width: 1400 * Inserter: no + * + * @package WordPress + * @subpackage Twenty_Twenty_Four + * @since Twenty Twenty-Four 1.0 */ + ?> diff --git a/src/wp-content/themes/twentytwentyfour/patterns/template-index-portfolio.php b/src/wp-content/themes/twentytwentyfour/patterns/template-index-portfolio.php index 7b78323d2b13c..2c9417debb3d8 100644 --- a/src/wp-content/themes/twentytwentyfour/patterns/template-index-portfolio.php +++ b/src/wp-content/themes/twentytwentyfour/patterns/template-index-portfolio.php @@ -5,7 +5,12 @@ * Template Types: index * Viewport width: 1400 * Inserter: no + * + * @package WordPress + * @subpackage Twenty_Twenty_Four + * @since Twenty Twenty-Four 1.0 */ + ?> diff --git a/src/wp-content/themes/twentytwentyfour/patterns/template-search-blogging.php b/src/wp-content/themes/twentytwentyfour/patterns/template-search-blogging.php index ec9a29b8860c6..6bc185efc22a1 100644 --- a/src/wp-content/themes/twentytwentyfour/patterns/template-search-blogging.php +++ b/src/wp-content/themes/twentytwentyfour/patterns/template-search-blogging.php @@ -5,7 +5,12 @@ * Template Types: search * Viewport width: 1400 * Inserter: no + * + * @package WordPress + * @subpackage Twenty_Twenty_Four + * @since Twenty Twenty-Four 1.0 */ + ?> diff --git a/src/wp-content/themes/twentytwentyfour/patterns/template-search-portfolio.php b/src/wp-content/themes/twentytwentyfour/patterns/template-search-portfolio.php index 1c60eb18e2127..d6643b2fe4a53 100644 --- a/src/wp-content/themes/twentytwentyfour/patterns/template-search-portfolio.php +++ b/src/wp-content/themes/twentytwentyfour/patterns/template-search-portfolio.php @@ -5,7 +5,12 @@ * Template Types: search * Viewport width: 1400 * Inserter: no + * + * @package WordPress + * @subpackage Twenty_Twenty_Four + * @since Twenty Twenty-Four 1.0 */ + ?> diff --git a/src/wp-content/themes/twentytwentyfour/patterns/template-single-portfolio.php b/src/wp-content/themes/twentytwentyfour/patterns/template-single-portfolio.php index ba54d58110ce3..7934310cbe9b6 100644 --- a/src/wp-content/themes/twentytwentyfour/patterns/template-single-portfolio.php +++ b/src/wp-content/themes/twentytwentyfour/patterns/template-single-portfolio.php @@ -5,7 +5,12 @@ * Template Types: posts, single * Viewport width: 1400 * Inserter: no + * + * @package WordPress + * @subpackage Twenty_Twenty_Four + * @since Twenty Twenty-Four 1.0 */ + ?> diff --git a/src/wp-content/themes/twentytwentyfour/patterns/testimonial-centered.php b/src/wp-content/themes/twentytwentyfour/patterns/testimonial-centered.php index 1bac825e9e5d3..2c250f6b9f37c 100644 --- a/src/wp-content/themes/twentytwentyfour/patterns/testimonial-centered.php +++ b/src/wp-content/themes/twentytwentyfour/patterns/testimonial-centered.php @@ -6,7 +6,12 @@ * Categories: testimonials, text * Viewport width: 1300 * Description: A centered testimonial section with an avatar, name, and job title. + * + * @package WordPress + * @subpackage Twenty_Twenty_Four + * @since Twenty Twenty-Four 1.0 */ + ?> diff --git a/src/wp-content/themes/twentytwentyfour/patterns/text-alternating-images.php b/src/wp-content/themes/twentytwentyfour/patterns/text-alternating-images.php index 6ee225137dfa5..4f9f758965d39 100644 --- a/src/wp-content/themes/twentytwentyfour/patterns/text-alternating-images.php +++ b/src/wp-content/themes/twentytwentyfour/patterns/text-alternating-images.php @@ -5,7 +5,12 @@ * Categories: text, about * Viewport width: 1400 * Description: A text section, then a two-column section with text in one column and an image in the other. + * + * @package WordPress + * @subpackage Twenty_Twenty_Four + * @since Twenty Twenty-Four 1.0 */ + ?> diff --git a/src/wp-content/themes/twentytwentyfour/patterns/text-centered-statement-small.php b/src/wp-content/themes/twentytwentyfour/patterns/text-centered-statement-small.php index c558518823965..c7240750f741c 100644 --- a/src/wp-content/themes/twentytwentyfour/patterns/text-centered-statement-small.php +++ b/src/wp-content/themes/twentytwentyfour/patterns/text-centered-statement-small.php @@ -6,7 +6,12 @@ * Keywords: mission, introduction * Viewport width: 1200 * Description: A centered italic text statement with compact padding. + * + * @package WordPress + * @subpackage Twenty_Twenty_Four + * @since Twenty Twenty-Four 1.0 */ + ?> diff --git a/src/wp-content/themes/twentytwentyfour/patterns/text-centered-statement.php b/src/wp-content/themes/twentytwentyfour/patterns/text-centered-statement.php index ca909ca773c09..4c1ce23844a52 100644 --- a/src/wp-content/themes/twentytwentyfour/patterns/text-centered-statement.php +++ b/src/wp-content/themes/twentytwentyfour/patterns/text-centered-statement.php @@ -6,7 +6,12 @@ * Keywords: mission, introduction * Viewport width: 1400 * Description: A centered text statement with a large amount of padding on all sides. + * + * @package WordPress + * @subpackage Twenty_Twenty_Four + * @since Twenty Twenty-Four 1.0 */ + ?> diff --git a/src/wp-content/themes/twentytwentyfour/patterns/text-faq.php b/src/wp-content/themes/twentytwentyfour/patterns/text-faq.php index 3e0c3aeecb1e7..891bfe3782527 100644 --- a/src/wp-content/themes/twentytwentyfour/patterns/text-faq.php +++ b/src/wp-content/themes/twentytwentyfour/patterns/text-faq.php @@ -6,7 +6,12 @@ * Keywords: faq, about, frequently asked * Viewport width: 1400 * Description: A FAQ section with a large FAQ heading and a group of questions and answers. + * + * @package WordPress + * @subpackage Twenty_Twenty_Four + * @since Twenty Twenty-Four 1.0 */ + ?> diff --git a/src/wp-content/themes/twentytwentyfour/patterns/text-feature-grid-3-col.php b/src/wp-content/themes/twentytwentyfour/patterns/text-feature-grid-3-col.php index 03310f564f214..67fa4c6c1750d 100644 --- a/src/wp-content/themes/twentytwentyfour/patterns/text-feature-grid-3-col.php +++ b/src/wp-content/themes/twentytwentyfour/patterns/text-feature-grid-3-col.php @@ -5,7 +5,12 @@ * Categories: text, about * Viewport width: 1400 * Description: A feature grid of 2 rows and 3 columns with headings and text. + * + * @package WordPress + * @subpackage Twenty_Twenty_Four + * @since Twenty Twenty-Four 1.0 */ + ?> diff --git a/src/wp-content/themes/twentytwentyfour/patterns/text-project-details.php b/src/wp-content/themes/twentytwentyfour/patterns/text-project-details.php index 9116a974a3466..c95004776502c 100644 --- a/src/wp-content/themes/twentytwentyfour/patterns/text-project-details.php +++ b/src/wp-content/themes/twentytwentyfour/patterns/text-project-details.php @@ -5,7 +5,12 @@ * Categories: text, portfolio * Viewport width: 1400 * Description: A text-only section for project details. + * + * @package WordPress + * @subpackage Twenty_Twenty_Four + * @since Twenty Twenty-Four 1.0 */ + ?> diff --git a/src/wp-content/themes/twentytwentyfour/patterns/text-title-left-image-right.php b/src/wp-content/themes/twentytwentyfour/patterns/text-title-left-image-right.php index cfcbf6dfc1260..ca32e5fb95fe0 100644 --- a/src/wp-content/themes/twentytwentyfour/patterns/text-title-left-image-right.php +++ b/src/wp-content/themes/twentytwentyfour/patterns/text-title-left-image-right.php @@ -5,7 +5,12 @@ * Categories: banner, about, featured * Viewport width: 1400 * Description: A title, a paragraph and a CTA button on the left with an image on the right. + * + * @package WordPress + * @subpackage Twenty_Twenty_Four + * @since Twenty Twenty-Four 1.0 */ + ?> diff --git a/src/wp-content/themes/twentytwentyone/functions.php b/src/wp-content/themes/twentytwentyone/functions.php index a020e62bf00c5..13e5db52031b7 100644 --- a/src/wp-content/themes/twentytwentyone/functions.php +++ b/src/wp-content/themes/twentytwentyone/functions.php @@ -581,7 +581,7 @@ function twentytwentyone_the_html_classes() { * * @since Twenty Twenty-One 1.0 * - * @param string The list of classes. Default empty string. + * @param string $classes The list of classes. Default empty string. */ $classes = apply_filters( 'twentytwentyone_html_classes', '' ); if ( ! $classes ) { diff --git a/src/wp-content/themes/twentytwentyone/inc/template-functions.php b/src/wp-content/themes/twentytwentyone/inc/template-functions.php index 529564c295319..f141b99a8cf8f 100644 --- a/src/wp-content/themes/twentytwentyone/inc/template-functions.php +++ b/src/wp-content/themes/twentytwentyone/inc/template-functions.php @@ -182,12 +182,17 @@ function twenty_twenty_one_continue_reading_link() { * Adds a title to posts and pages that are missing titles. * * @since Twenty Twenty-One 1.0 + * @since Twenty Twenty-One 2.9 Only applies the filter on the front end. * * @param string $title The title. * @return string */ function twenty_twenty_one_post_title( $title ) { - return '' === $title ? esc_html_x( 'Untitled', 'Added to posts and pages that are missing titles', 'twentytwentyone' ) : $title; + if ( is_admin() ) { + return $title; + } + + return '' === $title ? esc_html_x( 'Untitled', 'Added on the front end to posts and pages that are missing titles', 'twentytwentyone' ) : $title; } } add_filter( 'the_title', 'twenty_twenty_one_post_title' ); diff --git a/src/wp-content/themes/twentytwentythree/patterns/call-to-action.php b/src/wp-content/themes/twentytwentythree/patterns/call-to-action.php index 15427904bd22b..ca89451d26787 100644 --- a/src/wp-content/themes/twentytwentythree/patterns/call-to-action.php +++ b/src/wp-content/themes/twentytwentythree/patterns/call-to-action.php @@ -6,8 +6,14 @@ * Keywords: Call to action * Block Types: core/buttons * Description: Left-aligned text with a CTA button and a separator. + * + * @package WordPress + * @subpackage Twenty_Twenty_Three + * @since Twenty Twenty-Three 1.0 */ + ?> +
diff --git a/src/wp-content/themes/twentytwentythree/patterns/footer-default.php b/src/wp-content/themes/twentytwentythree/patterns/footer-default.php index e3827248be5a5..cd02cde2629ac 100644 --- a/src/wp-content/themes/twentytwentythree/patterns/footer-default.php +++ b/src/wp-content/themes/twentytwentythree/patterns/footer-default.php @@ -5,8 +5,14 @@ * Categories: footer * Block Types: core/template-part/footer * Description: Footer with site title and powered by WordPress. + * + * @package WordPress + * @subpackage Twenty_Twenty_Three + * @since Twenty Twenty-Three 1.0 */ + ?> +
diff --git a/src/wp-content/themes/twentytwentythree/patterns/hidden-404.php b/src/wp-content/themes/twentytwentythree/patterns/hidden-404.php index ae7dbb14ca089..7c38c9b3452c1 100644 --- a/src/wp-content/themes/twentytwentythree/patterns/hidden-404.php +++ b/src/wp-content/themes/twentytwentythree/patterns/hidden-404.php @@ -3,8 +3,14 @@ * Title: Hidden 404 * Slug: twentytwentythree/hidden-404 * Inserter: no + * + * @package WordPress + * @subpackage Twenty_Twenty_Three + * @since Twenty Twenty-Three 1.0 */ + ?> + diff --git a/src/wp-content/themes/twentytwentythree/patterns/hidden-comments.php b/src/wp-content/themes/twentytwentythree/patterns/hidden-comments.php index 84d4a78779e1c..285116db20e5d 100644 --- a/src/wp-content/themes/twentytwentythree/patterns/hidden-comments.php +++ b/src/wp-content/themes/twentytwentythree/patterns/hidden-comments.php @@ -3,8 +3,14 @@ * Title: Hidden Comments * Slug: twentytwentythree/hidden-comments * Inserter: no + * + * @package WordPress + * @subpackage Twenty_Twenty_Three + * @since Twenty Twenty-Three 1.0 */ + ?> +
diff --git a/src/wp-content/themes/twentytwentythree/patterns/hidden-heading.php b/src/wp-content/themes/twentytwentythree/patterns/hidden-heading.php index 542f9bed1a947..8147dc62a4519 100644 --- a/src/wp-content/themes/twentytwentythree/patterns/hidden-heading.php +++ b/src/wp-content/themes/twentytwentythree/patterns/hidden-heading.php @@ -3,8 +3,14 @@ * Title: Hidden Heading for Homepage * Slug: twentytwentythree/hidden-heading * Inserter: no + * + * @package WordPress + * @subpackage Twenty_Twenty_Three + * @since Twenty Twenty-Three 1.6 */ + ?> +

diff --git a/src/wp-content/themes/twentytwentythree/patterns/hidden-no-results.php b/src/wp-content/themes/twentytwentythree/patterns/hidden-no-results.php index d3f7ae0586556..9c22497100ad7 100644 --- a/src/wp-content/themes/twentytwentythree/patterns/hidden-no-results.php +++ b/src/wp-content/themes/twentytwentythree/patterns/hidden-no-results.php @@ -3,8 +3,14 @@ * Title: Hidden No Results Content * Slug: twentytwentythree/hidden-no-results-content * Inserter: no + * + * @package WordPress + * @subpackage Twenty_Twenty_Three + * @since Twenty Twenty-Three 1.0 */ + ?> +

diff --git a/src/wp-content/themes/twentytwentythree/patterns/post-meta.php b/src/wp-content/themes/twentytwentythree/patterns/post-meta.php index a1a92264d5718..2282058318a5a 100644 --- a/src/wp-content/themes/twentytwentythree/patterns/post-meta.php +++ b/src/wp-content/themes/twentytwentythree/patterns/post-meta.php @@ -6,8 +6,14 @@ * Keywords: post meta * Block Types: core/template-part/post-meta * Description: Post meta information with separator on the top. + * + * @package WordPress + * @subpackage Twenty_Twenty_Three + * @since Twenty Twenty-Three 1.0 */ + ?> +

diff --git a/src/wp-content/themes/twentytwentytwo/inc/block-patterns.php b/src/wp-content/themes/twentytwentytwo/inc/block-patterns.php index f47e26e5dd6c9..e9def1eb63614 100644 --- a/src/wp-content/themes/twentytwentytwo/inc/block-patterns.php +++ b/src/wp-content/themes/twentytwentytwo/inc/block-patterns.php @@ -2,6 +2,8 @@ /** * Twenty Twenty-Two: Block Patterns * + * @package WordPress + * @subpackage Twenty_Twenty_Two * @since Twenty Twenty-Two 1.0 */ diff --git a/src/wp-content/themes/twentytwentytwo/inc/patterns/footer-about-title-logo.php b/src/wp-content/themes/twentytwentytwo/inc/patterns/footer-about-title-logo.php index dab9b04a86e85..8ddd949231733 100644 --- a/src/wp-content/themes/twentytwentytwo/inc/patterns/footer-about-title-logo.php +++ b/src/wp-content/themes/twentytwentytwo/inc/patterns/footer-about-title-logo.php @@ -1,7 +1,12 @@ __( 'Footer with text, title, and logo', 'twentytwentytwo' ), 'categories' => array( 'footer' ), diff --git a/src/wp-content/themes/twentytwentytwo/inc/patterns/footer-blog.php b/src/wp-content/themes/twentytwentytwo/inc/patterns/footer-blog.php index c1306cbb833e0..c6e7a45a32cf9 100644 --- a/src/wp-content/themes/twentytwentytwo/inc/patterns/footer-blog.php +++ b/src/wp-content/themes/twentytwentytwo/inc/patterns/footer-blog.php @@ -1,7 +1,12 @@ __( 'Blog footer', 'twentytwentytwo' ), 'categories' => array( 'footer' ), diff --git a/src/wp-content/themes/twentytwentytwo/inc/patterns/footer-dark.php b/src/wp-content/themes/twentytwentytwo/inc/patterns/footer-dark.php index b2ec3626fbaf8..35928ff581f84 100644 --- a/src/wp-content/themes/twentytwentytwo/inc/patterns/footer-dark.php +++ b/src/wp-content/themes/twentytwentytwo/inc/patterns/footer-dark.php @@ -1,7 +1,12 @@ __( 'Dark footer with title and citation', 'twentytwentytwo' ), 'categories' => array( 'footer' ), diff --git a/src/wp-content/themes/twentytwentytwo/inc/patterns/footer-default.php b/src/wp-content/themes/twentytwentytwo/inc/patterns/footer-default.php index 3682643084326..1fbc4ed53825a 100644 --- a/src/wp-content/themes/twentytwentytwo/inc/patterns/footer-default.php +++ b/src/wp-content/themes/twentytwentytwo/inc/patterns/footer-default.php @@ -1,7 +1,12 @@ __( 'Default footer', 'twentytwentytwo' ), 'categories' => array( 'footer' ), diff --git a/src/wp-content/themes/twentytwentytwo/inc/patterns/footer-logo.php b/src/wp-content/themes/twentytwentytwo/inc/patterns/footer-logo.php index caa44e8c34900..16211476f2bad 100644 --- a/src/wp-content/themes/twentytwentytwo/inc/patterns/footer-logo.php +++ b/src/wp-content/themes/twentytwentytwo/inc/patterns/footer-logo.php @@ -1,7 +1,12 @@ __( 'Footer with logo and citation', 'twentytwentytwo' ), 'categories' => array( 'footer' ), diff --git a/src/wp-content/themes/twentytwentytwo/inc/patterns/footer-navigation-copyright.php b/src/wp-content/themes/twentytwentytwo/inc/patterns/footer-navigation-copyright.php index d0b554249d4fd..e9ef59eaff0bc 100644 --- a/src/wp-content/themes/twentytwentytwo/inc/patterns/footer-navigation-copyright.php +++ b/src/wp-content/themes/twentytwentytwo/inc/patterns/footer-navigation-copyright.php @@ -1,7 +1,12 @@ __( 'Footer with navigation and copyright', 'twentytwentytwo' ), 'categories' => array( 'footer' ), diff --git a/src/wp-content/themes/twentytwentytwo/inc/patterns/footer-navigation.php b/src/wp-content/themes/twentytwentytwo/inc/patterns/footer-navigation.php index 79792c730df0a..5ff93771c58d4 100644 --- a/src/wp-content/themes/twentytwentytwo/inc/patterns/footer-navigation.php +++ b/src/wp-content/themes/twentytwentytwo/inc/patterns/footer-navigation.php @@ -1,7 +1,12 @@ __( 'Footer with navigation and citation', 'twentytwentytwo' ), 'categories' => array( 'footer' ), diff --git a/src/wp-content/themes/twentytwentytwo/inc/patterns/footer-query-images-title-citation.php b/src/wp-content/themes/twentytwentytwo/inc/patterns/footer-query-images-title-citation.php index a79c1d44cd51d..12e92ede9e12f 100644 --- a/src/wp-content/themes/twentytwentytwo/inc/patterns/footer-query-images-title-citation.php +++ b/src/wp-content/themes/twentytwentytwo/inc/patterns/footer-query-images-title-citation.php @@ -1,7 +1,12 @@ __( 'Footer with query, featured images, title, and citation', 'twentytwentytwo' ), 'categories' => array( 'footer' ), diff --git a/src/wp-content/themes/twentytwentytwo/inc/patterns/footer-query-title-citation.php b/src/wp-content/themes/twentytwentytwo/inc/patterns/footer-query-title-citation.php index 13bb43df5c34a..f3bb899d9de3b 100644 --- a/src/wp-content/themes/twentytwentytwo/inc/patterns/footer-query-title-citation.php +++ b/src/wp-content/themes/twentytwentytwo/inc/patterns/footer-query-title-citation.php @@ -1,7 +1,12 @@ __( 'Footer with query, title, and citation', 'twentytwentytwo' ), 'categories' => array( 'footer' ), diff --git a/src/wp-content/themes/twentytwentytwo/inc/patterns/footer-social-copyright.php b/src/wp-content/themes/twentytwentytwo/inc/patterns/footer-social-copyright.php index 0c7e4ae5178e6..9119f7f203e2a 100644 --- a/src/wp-content/themes/twentytwentytwo/inc/patterns/footer-social-copyright.php +++ b/src/wp-content/themes/twentytwentytwo/inc/patterns/footer-social-copyright.php @@ -1,7 +1,12 @@ __( 'Footer with social links and copyright', 'twentytwentytwo' ), 'categories' => array( 'footer' ), diff --git a/src/wp-content/themes/twentytwentytwo/inc/patterns/footer-title-tagline-social.php b/src/wp-content/themes/twentytwentytwo/inc/patterns/footer-title-tagline-social.php index 84d888b22d40d..30fa6b5d54475 100644 --- a/src/wp-content/themes/twentytwentytwo/inc/patterns/footer-title-tagline-social.php +++ b/src/wp-content/themes/twentytwentytwo/inc/patterns/footer-title-tagline-social.php @@ -1,7 +1,12 @@ __( 'Footer with title, tagline, and social links on a dark background', 'twentytwentytwo' ), 'categories' => array( 'footer' ), diff --git a/src/wp-content/themes/twentytwentytwo/inc/patterns/general-divider-dark.php b/src/wp-content/themes/twentytwentytwo/inc/patterns/general-divider-dark.php index 3255e9d7067aa..42a9072fa5ea7 100644 --- a/src/wp-content/themes/twentytwentytwo/inc/patterns/general-divider-dark.php +++ b/src/wp-content/themes/twentytwentytwo/inc/patterns/general-divider-dark.php @@ -1,7 +1,12 @@ __( 'Divider with image and color (dark)', 'twentytwentytwo' ), 'categories' => array( 'featured' ), diff --git a/src/wp-content/themes/twentytwentytwo/inc/patterns/general-divider-light.php b/src/wp-content/themes/twentytwentytwo/inc/patterns/general-divider-light.php index a29b8252d7be1..a7d18dd55fc19 100644 --- a/src/wp-content/themes/twentytwentytwo/inc/patterns/general-divider-light.php +++ b/src/wp-content/themes/twentytwentytwo/inc/patterns/general-divider-light.php @@ -1,7 +1,12 @@ __( 'Divider with image and color (light)', 'twentytwentytwo' ), 'categories' => array( 'featured' ), diff --git a/src/wp-content/themes/twentytwentytwo/inc/patterns/general-featured-posts.php b/src/wp-content/themes/twentytwentytwo/inc/patterns/general-featured-posts.php index 55cbdfd92d87b..711b532dbaede 100644 --- a/src/wp-content/themes/twentytwentytwo/inc/patterns/general-featured-posts.php +++ b/src/wp-content/themes/twentytwentytwo/inc/patterns/general-featured-posts.php @@ -1,7 +1,12 @@ __( 'Featured posts', 'twentytwentytwo' ), 'categories' => array( 'featured', 'query' ), diff --git a/src/wp-content/themes/twentytwentytwo/inc/patterns/general-image-with-caption.php b/src/wp-content/themes/twentytwentytwo/inc/patterns/general-image-with-caption.php index 0870da3951855..b681930180589 100644 --- a/src/wp-content/themes/twentytwentytwo/inc/patterns/general-image-with-caption.php +++ b/src/wp-content/themes/twentytwentytwo/inc/patterns/general-image-with-caption.php @@ -1,7 +1,12 @@ __( 'Image with caption', 'twentytwentytwo' ), 'categories' => array( 'featured', 'columns', 'gallery' ), diff --git a/src/wp-content/themes/twentytwentytwo/inc/patterns/general-large-list-names.php b/src/wp-content/themes/twentytwentytwo/inc/patterns/general-large-list-names.php index d7bd8168fa6a8..13db435ff9674 100644 --- a/src/wp-content/themes/twentytwentytwo/inc/patterns/general-large-list-names.php +++ b/src/wp-content/themes/twentytwentytwo/inc/patterns/general-large-list-names.php @@ -1,7 +1,12 @@ __( 'Large list of names', 'twentytwentytwo' ), 'categories' => array( 'featured', 'text' ), diff --git a/src/wp-content/themes/twentytwentytwo/inc/patterns/general-layered-images-with-duotone.php b/src/wp-content/themes/twentytwentytwo/inc/patterns/general-layered-images-with-duotone.php index 629a0a2a12a7f..8a865b29e2fda 100644 --- a/src/wp-content/themes/twentytwentytwo/inc/patterns/general-layered-images-with-duotone.php +++ b/src/wp-content/themes/twentytwentytwo/inc/patterns/general-layered-images-with-duotone.php @@ -1,7 +1,12 @@ __( 'Layered images with duotone', 'twentytwentytwo' ), 'categories' => array( 'featured', 'gallery' ), diff --git a/src/wp-content/themes/twentytwentytwo/inc/patterns/general-list-events.php b/src/wp-content/themes/twentytwentytwo/inc/patterns/general-list-events.php index af680dc2cdf4b..b2c7317e8bdcf 100644 --- a/src/wp-content/themes/twentytwentytwo/inc/patterns/general-list-events.php +++ b/src/wp-content/themes/twentytwentytwo/inc/patterns/general-list-events.php @@ -1,7 +1,12 @@ __( 'List of events', 'twentytwentytwo' ), 'categories' => array( 'featured', 'text' ), diff --git a/src/wp-content/themes/twentytwentytwo/inc/patterns/general-pricing-table.php b/src/wp-content/themes/twentytwentytwo/inc/patterns/general-pricing-table.php index b385eddf25f23..21ad117669de0 100644 --- a/src/wp-content/themes/twentytwentytwo/inc/patterns/general-pricing-table.php +++ b/src/wp-content/themes/twentytwentytwo/inc/patterns/general-pricing-table.php @@ -1,7 +1,12 @@ __( 'Pricing table', 'twentytwentytwo' ), 'categories' => array( 'featured', 'columns', 'buttons' ), diff --git a/src/wp-content/themes/twentytwentytwo/inc/patterns/general-subscribe.php b/src/wp-content/themes/twentytwentytwo/inc/patterns/general-subscribe.php index 34137cd20a8d8..bd7194f464ad3 100644 --- a/src/wp-content/themes/twentytwentytwo/inc/patterns/general-subscribe.php +++ b/src/wp-content/themes/twentytwentytwo/inc/patterns/general-subscribe.php @@ -1,7 +1,12 @@ __( 'Subscribe callout', 'twentytwentytwo' ), 'categories' => array( 'featured', 'buttons' ), diff --git a/src/wp-content/themes/twentytwentytwo/inc/patterns/general-two-images-text.php b/src/wp-content/themes/twentytwentytwo/inc/patterns/general-two-images-text.php index 5534b6ffba701..7390b16fd7ce5 100644 --- a/src/wp-content/themes/twentytwentytwo/inc/patterns/general-two-images-text.php +++ b/src/wp-content/themes/twentytwentytwo/inc/patterns/general-two-images-text.php @@ -1,7 +1,12 @@ __( 'Two images with text', 'twentytwentytwo' ), 'categories' => array( 'featured', 'columns', 'gallery' ), diff --git a/src/wp-content/themes/twentytwentytwo/inc/patterns/general-video-header-details.php b/src/wp-content/themes/twentytwentytwo/inc/patterns/general-video-header-details.php index 3b15a5206616e..21d96e0125664 100644 --- a/src/wp-content/themes/twentytwentytwo/inc/patterns/general-video-header-details.php +++ b/src/wp-content/themes/twentytwentytwo/inc/patterns/general-video-header-details.php @@ -1,7 +1,12 @@ __( 'Video with header and details', 'twentytwentytwo' ), 'categories' => array( 'featured', 'columns' ), diff --git a/src/wp-content/themes/twentytwentytwo/inc/patterns/general-video-trailer.php b/src/wp-content/themes/twentytwentytwo/inc/patterns/general-video-trailer.php index 22ed24860b6ad..6cb15b5c5d5c5 100644 --- a/src/wp-content/themes/twentytwentytwo/inc/patterns/general-video-trailer.php +++ b/src/wp-content/themes/twentytwentytwo/inc/patterns/general-video-trailer.php @@ -1,7 +1,12 @@ __( 'Video trailer', 'twentytwentytwo' ), 'categories' => array( 'featured', 'columns' ), diff --git a/src/wp-content/themes/twentytwentytwo/inc/patterns/general-wide-image-intro-buttons.php b/src/wp-content/themes/twentytwentytwo/inc/patterns/general-wide-image-intro-buttons.php index 373b0f48bb1cc..4bb7be79b19bb 100644 --- a/src/wp-content/themes/twentytwentytwo/inc/patterns/general-wide-image-intro-buttons.php +++ b/src/wp-content/themes/twentytwentytwo/inc/patterns/general-wide-image-intro-buttons.php @@ -1,7 +1,12 @@ __( 'Wide image with introduction and buttons', 'twentytwentytwo' ), 'categories' => array( 'featured', 'columns' ), diff --git a/src/wp-content/themes/twentytwentytwo/inc/patterns/header-centered-logo-black-background.php b/src/wp-content/themes/twentytwentytwo/inc/patterns/header-centered-logo-black-background.php index b10ed5b9b6350..14c51664f8d34 100644 --- a/src/wp-content/themes/twentytwentytwo/inc/patterns/header-centered-logo-black-background.php +++ b/src/wp-content/themes/twentytwentytwo/inc/patterns/header-centered-logo-black-background.php @@ -1,7 +1,12 @@ __( 'Header with centered logo and background', 'twentytwentytwo' ), 'categories' => array( 'header' ), diff --git a/src/wp-content/themes/twentytwentytwo/inc/patterns/header-centered-logo.php b/src/wp-content/themes/twentytwentytwo/inc/patterns/header-centered-logo.php index a116c390867ff..6b6d132aa5ae1 100644 --- a/src/wp-content/themes/twentytwentytwo/inc/patterns/header-centered-logo.php +++ b/src/wp-content/themes/twentytwentytwo/inc/patterns/header-centered-logo.php @@ -1,7 +1,12 @@ __( 'Header with centered logo', 'twentytwentytwo' ), 'categories' => array( 'header' ), diff --git a/src/wp-content/themes/twentytwentytwo/inc/patterns/header-centered-title-navigation-social.php b/src/wp-content/themes/twentytwentytwo/inc/patterns/header-centered-title-navigation-social.php index cb1b310e110d8..3f042864dbdf3 100644 --- a/src/wp-content/themes/twentytwentytwo/inc/patterns/header-centered-title-navigation-social.php +++ b/src/wp-content/themes/twentytwentytwo/inc/patterns/header-centered-title-navigation-social.php @@ -1,7 +1,12 @@ __( 'Centered header with navigation, social links, and background', 'twentytwentytwo' ), 'categories' => array( 'header' ), diff --git a/src/wp-content/themes/twentytwentytwo/inc/patterns/header-default.php b/src/wp-content/themes/twentytwentytwo/inc/patterns/header-default.php index ff25793275a3c..1ab974d3ef893 100644 --- a/src/wp-content/themes/twentytwentytwo/inc/patterns/header-default.php +++ b/src/wp-content/themes/twentytwentytwo/inc/patterns/header-default.php @@ -1,7 +1,12 @@ __( 'Default header', 'twentytwentytwo' ), 'categories' => array( 'header' ), diff --git a/src/wp-content/themes/twentytwentytwo/inc/patterns/header-image-background-overlay.php b/src/wp-content/themes/twentytwentytwo/inc/patterns/header-image-background-overlay.php index 55422aa41bf31..aa03e9922ca56 100644 --- a/src/wp-content/themes/twentytwentytwo/inc/patterns/header-image-background-overlay.php +++ b/src/wp-content/themes/twentytwentytwo/inc/patterns/header-image-background-overlay.php @@ -1,7 +1,12 @@ __( 'Header with image background and overlay', 'twentytwentytwo' ), 'categories' => array( 'header' ), diff --git a/src/wp-content/themes/twentytwentytwo/inc/patterns/header-image-background.php b/src/wp-content/themes/twentytwentytwo/inc/patterns/header-image-background.php index 4afd36c551ace..fcbb3c4e4b923 100644 --- a/src/wp-content/themes/twentytwentytwo/inc/patterns/header-image-background.php +++ b/src/wp-content/themes/twentytwentytwo/inc/patterns/header-image-background.php @@ -1,7 +1,12 @@ __( 'Header with image background', 'twentytwentytwo' ), 'categories' => array( 'header' ), diff --git a/src/wp-content/themes/twentytwentytwo/inc/patterns/header-large-dark.php b/src/wp-content/themes/twentytwentytwo/inc/patterns/header-large-dark.php index 5e4fea69ed5d4..dec8d972eef10 100644 --- a/src/wp-content/themes/twentytwentytwo/inc/patterns/header-large-dark.php +++ b/src/wp-content/themes/twentytwentytwo/inc/patterns/header-large-dark.php @@ -1,7 +1,12 @@ __( 'Large header with dark background', 'twentytwentytwo' ), 'categories' => array( 'header' ), diff --git a/src/wp-content/themes/twentytwentytwo/inc/patterns/header-logo-navigation-gray-background.php b/src/wp-content/themes/twentytwentytwo/inc/patterns/header-logo-navigation-gray-background.php index 3ddea755f02c2..ec71a58392491 100644 --- a/src/wp-content/themes/twentytwentytwo/inc/patterns/header-logo-navigation-gray-background.php +++ b/src/wp-content/themes/twentytwentytwo/inc/patterns/header-logo-navigation-gray-background.php @@ -1,7 +1,12 @@ __( 'Logo and navigation header with background', 'twentytwentytwo' ), 'categories' => array( 'header' ), diff --git a/src/wp-content/themes/twentytwentytwo/inc/patterns/header-logo-navigation-offset-tagline.php b/src/wp-content/themes/twentytwentytwo/inc/patterns/header-logo-navigation-offset-tagline.php index 299586f0ece4f..c9e539e0b4950 100644 --- a/src/wp-content/themes/twentytwentytwo/inc/patterns/header-logo-navigation-offset-tagline.php +++ b/src/wp-content/themes/twentytwentytwo/inc/patterns/header-logo-navigation-offset-tagline.php @@ -1,7 +1,12 @@ __( 'Logo, navigation, and offset tagline Header', 'twentytwentytwo' ), 'categories' => array( 'header' ), diff --git a/src/wp-content/themes/twentytwentytwo/inc/patterns/header-logo-navigation-social-black-background.php b/src/wp-content/themes/twentytwentytwo/inc/patterns/header-logo-navigation-social-black-background.php index c9b0f886d1dda..1fcd253ae1c7e 100644 --- a/src/wp-content/themes/twentytwentytwo/inc/patterns/header-logo-navigation-social-black-background.php +++ b/src/wp-content/themes/twentytwentytwo/inc/patterns/header-logo-navigation-social-black-background.php @@ -1,7 +1,12 @@ __( 'Logo, navigation, and social links header with background', 'twentytwentytwo' ), 'categories' => array( 'header' ), diff --git a/src/wp-content/themes/twentytwentytwo/inc/patterns/header-small-dark.php b/src/wp-content/themes/twentytwentytwo/inc/patterns/header-small-dark.php index 832f414220921..6db5cef7eb7f8 100644 --- a/src/wp-content/themes/twentytwentytwo/inc/patterns/header-small-dark.php +++ b/src/wp-content/themes/twentytwentytwo/inc/patterns/header-small-dark.php @@ -1,7 +1,12 @@ __( 'Small header with dark background', 'twentytwentytwo' ), 'categories' => array( 'header' ), diff --git a/src/wp-content/themes/twentytwentytwo/inc/patterns/header-stacked.php b/src/wp-content/themes/twentytwentytwo/inc/patterns/header-stacked.php index bd36bbf4b9fad..3b8c7a48133b5 100644 --- a/src/wp-content/themes/twentytwentytwo/inc/patterns/header-stacked.php +++ b/src/wp-content/themes/twentytwentytwo/inc/patterns/header-stacked.php @@ -1,7 +1,12 @@ __( 'Logo and navigation header', 'twentytwentytwo' ), 'categories' => array( 'header' ), diff --git a/src/wp-content/themes/twentytwentytwo/inc/patterns/header-text-only-green-background.php b/src/wp-content/themes/twentytwentytwo/inc/patterns/header-text-only-green-background.php index 468a696dd709c..5b1a7507dd2f2 100644 --- a/src/wp-content/themes/twentytwentytwo/inc/patterns/header-text-only-green-background.php +++ b/src/wp-content/themes/twentytwentytwo/inc/patterns/header-text-only-green-background.php @@ -1,7 +1,12 @@ __( 'Text-only header with background', 'twentytwentytwo' ), 'categories' => array( 'header' ), diff --git a/src/wp-content/themes/twentytwentytwo/inc/patterns/header-text-only-salmon-background.php b/src/wp-content/themes/twentytwentytwo/inc/patterns/header-text-only-salmon-background.php index 4a4913ec472cf..5cdc16e7cd4ad 100644 --- a/src/wp-content/themes/twentytwentytwo/inc/patterns/header-text-only-salmon-background.php +++ b/src/wp-content/themes/twentytwentytwo/inc/patterns/header-text-only-salmon-background.php @@ -1,7 +1,12 @@ __( 'Text-only header with background', 'twentytwentytwo' ), 'categories' => array( 'header' ), diff --git a/src/wp-content/themes/twentytwentytwo/inc/patterns/header-text-only-with-tagline-black-background.php b/src/wp-content/themes/twentytwentytwo/inc/patterns/header-text-only-with-tagline-black-background.php index d529fe851dcff..f73c60b69d8b3 100644 --- a/src/wp-content/themes/twentytwentytwo/inc/patterns/header-text-only-with-tagline-black-background.php +++ b/src/wp-content/themes/twentytwentytwo/inc/patterns/header-text-only-with-tagline-black-background.php @@ -1,7 +1,12 @@ __( 'Text-only header with tagline and background', 'twentytwentytwo' ), 'categories' => array( 'header' ), diff --git a/src/wp-content/themes/twentytwentytwo/inc/patterns/header-title-and-button.php b/src/wp-content/themes/twentytwentytwo/inc/patterns/header-title-and-button.php index 5c780697dabe5..ccb27a87cb64f 100644 --- a/src/wp-content/themes/twentytwentytwo/inc/patterns/header-title-and-button.php +++ b/src/wp-content/themes/twentytwentytwo/inc/patterns/header-title-and-button.php @@ -1,7 +1,12 @@ __( 'Title and button header', 'twentytwentytwo' ), 'categories' => array( 'header' ), diff --git a/src/wp-content/themes/twentytwentytwo/inc/patterns/header-title-navigation-social.php b/src/wp-content/themes/twentytwentytwo/inc/patterns/header-title-navigation-social.php index 384cda5368337..cf74bfd2540d5 100644 --- a/src/wp-content/themes/twentytwentytwo/inc/patterns/header-title-navigation-social.php +++ b/src/wp-content/themes/twentytwentytwo/inc/patterns/header-title-navigation-social.php @@ -1,7 +1,12 @@ __( 'Title, navigation, and social links header', 'twentytwentytwo' ), 'categories' => array( 'header' ), diff --git a/src/wp-content/themes/twentytwentytwo/inc/patterns/header-with-tagline.php b/src/wp-content/themes/twentytwentytwo/inc/patterns/header-with-tagline.php index 40e633a2682a6..477a138473928 100644 --- a/src/wp-content/themes/twentytwentytwo/inc/patterns/header-with-tagline.php +++ b/src/wp-content/themes/twentytwentytwo/inc/patterns/header-with-tagline.php @@ -1,7 +1,12 @@ __( 'Header with tagline', 'twentytwentytwo' ), 'categories' => array( 'header' ), diff --git a/src/wp-content/themes/twentytwentytwo/inc/patterns/hidden-404.php b/src/wp-content/themes/twentytwentytwo/inc/patterns/hidden-404.php index 9cfd50d56e0c1..959ae2bb8ab5d 100644 --- a/src/wp-content/themes/twentytwentytwo/inc/patterns/hidden-404.php +++ b/src/wp-content/themes/twentytwentytwo/inc/patterns/hidden-404.php @@ -1,7 +1,12 @@ __( '404 content', 'twentytwentytwo' ), 'inserter' => false, diff --git a/src/wp-content/themes/twentytwentytwo/inc/patterns/hidden-bird.php b/src/wp-content/themes/twentytwentytwo/inc/patterns/hidden-bird.php index cfc9db579450a..6da806d3996b0 100644 --- a/src/wp-content/themes/twentytwentytwo/inc/patterns/hidden-bird.php +++ b/src/wp-content/themes/twentytwentytwo/inc/patterns/hidden-bird.php @@ -4,7 +4,12 @@ * * This pattern is used only to reference a dynamic image URL. * It does not appear in the inserter. + * + * @package WordPress + * @subpackage Twenty_Twenty_Two + * @since Twenty Twenty-Two 1.0 */ + return array( 'title' => __( 'Heading and bird image', 'twentytwentytwo' ), 'inserter' => false, diff --git a/src/wp-content/themes/twentytwentytwo/inc/patterns/hidden-heading-and-bird.php b/src/wp-content/themes/twentytwentytwo/inc/patterns/hidden-heading-and-bird.php index d3f1788ac56d4..d7e2ec77c29b8 100644 --- a/src/wp-content/themes/twentytwentytwo/inc/patterns/hidden-heading-and-bird.php +++ b/src/wp-content/themes/twentytwentytwo/inc/patterns/hidden-heading-and-bird.php @@ -5,7 +5,12 @@ * This pattern is used only for translation * and to reference a dynamic image URL. It does * not appear in the inserter. + * + * @package WordPress + * @subpackage Twenty_Twenty_Two + * @since Twenty Twenty-Two 1.0 */ + return array( 'title' => __( 'Heading and bird image', 'twentytwentytwo' ), 'inserter' => false, diff --git a/src/wp-content/themes/twentytwentytwo/inc/patterns/page-about-large-image-and-buttons.php b/src/wp-content/themes/twentytwentytwo/inc/patterns/page-about-large-image-and-buttons.php index 52dbd0e2eb895..e1aaa1c6d3911 100644 --- a/src/wp-content/themes/twentytwentytwo/inc/patterns/page-about-large-image-and-buttons.php +++ b/src/wp-content/themes/twentytwentytwo/inc/patterns/page-about-large-image-and-buttons.php @@ -1,7 +1,12 @@ __( 'About page with large image and buttons', 'twentytwentytwo' ), 'categories' => array( 'twentytwentytwo_pages', 'buttons' ), diff --git a/src/wp-content/themes/twentytwentytwo/inc/patterns/page-about-links-dark.php b/src/wp-content/themes/twentytwentytwo/inc/patterns/page-about-links-dark.php index 943524f49db49..7c1a8cab38a0f 100644 --- a/src/wp-content/themes/twentytwentytwo/inc/patterns/page-about-links-dark.php +++ b/src/wp-content/themes/twentytwentytwo/inc/patterns/page-about-links-dark.php @@ -1,7 +1,12 @@ __( 'About page links (dark)', 'twentytwentytwo' ), 'categories' => array( 'twentytwentytwo_pages', 'buttons' ), diff --git a/src/wp-content/themes/twentytwentytwo/inc/patterns/page-about-links.php b/src/wp-content/themes/twentytwentytwo/inc/patterns/page-about-links.php index 3cf27173db9cd..841d872ee7f04 100644 --- a/src/wp-content/themes/twentytwentytwo/inc/patterns/page-about-links.php +++ b/src/wp-content/themes/twentytwentytwo/inc/patterns/page-about-links.php @@ -1,7 +1,12 @@ __( 'About page links', 'twentytwentytwo' ), 'categories' => array( 'twentytwentytwo_pages', 'buttons' ), diff --git a/src/wp-content/themes/twentytwentytwo/inc/patterns/page-about-media-left.php b/src/wp-content/themes/twentytwentytwo/inc/patterns/page-about-media-left.php index f5c769a6ca801..0dd5f7fac8eae 100644 --- a/src/wp-content/themes/twentytwentytwo/inc/patterns/page-about-media-left.php +++ b/src/wp-content/themes/twentytwentytwo/inc/patterns/page-about-media-left.php @@ -1,7 +1,12 @@ __( 'About page with media on the left', 'twentytwentytwo' ), 'categories' => array( 'twentytwentytwo_pages' ), diff --git a/src/wp-content/themes/twentytwentytwo/inc/patterns/page-about-media-right.php b/src/wp-content/themes/twentytwentytwo/inc/patterns/page-about-media-right.php index 8f291150e17a1..7559aded1f7a7 100644 --- a/src/wp-content/themes/twentytwentytwo/inc/patterns/page-about-media-right.php +++ b/src/wp-content/themes/twentytwentytwo/inc/patterns/page-about-media-right.php @@ -1,7 +1,12 @@ __( 'About page with media on the right', 'twentytwentytwo' ), 'categories' => array( 'twentytwentytwo_pages' ), diff --git a/src/wp-content/themes/twentytwentytwo/inc/patterns/page-about-simple-dark.php b/src/wp-content/themes/twentytwentytwo/inc/patterns/page-about-simple-dark.php index fe0715965962b..50ede062d8c0a 100644 --- a/src/wp-content/themes/twentytwentytwo/inc/patterns/page-about-simple-dark.php +++ b/src/wp-content/themes/twentytwentytwo/inc/patterns/page-about-simple-dark.php @@ -1,7 +1,12 @@ __( 'Simple dark about page', 'twentytwentytwo' ), 'categories' => array( 'twentytwentytwo_pages' ), diff --git a/src/wp-content/themes/twentytwentytwo/inc/patterns/page-about-solid-color.php b/src/wp-content/themes/twentytwentytwo/inc/patterns/page-about-solid-color.php index 6c07725dbaf7c..983a743919c78 100644 --- a/src/wp-content/themes/twentytwentytwo/inc/patterns/page-about-solid-color.php +++ b/src/wp-content/themes/twentytwentytwo/inc/patterns/page-about-solid-color.php @@ -1,7 +1,12 @@ __( 'About page on solid color background', 'twentytwentytwo' ), 'categories' => array( 'twentytwentytwo_pages' ), diff --git a/src/wp-content/themes/twentytwentytwo/inc/patterns/page-layout-image-and-text.php b/src/wp-content/themes/twentytwentytwo/inc/patterns/page-layout-image-and-text.php index 20e08e01ff03c..4b35a161487ab 100644 --- a/src/wp-content/themes/twentytwentytwo/inc/patterns/page-layout-image-and-text.php +++ b/src/wp-content/themes/twentytwentytwo/inc/patterns/page-layout-image-and-text.php @@ -1,7 +1,12 @@ __( 'Page layout with image and text', 'twentytwentytwo' ), 'categories' => array( 'twentytwentytwo_pages' ), diff --git a/src/wp-content/themes/twentytwentytwo/inc/patterns/page-layout-image-text-and-video.php b/src/wp-content/themes/twentytwentytwo/inc/patterns/page-layout-image-text-and-video.php index 0ee87f11ff8bc..c2beb20df437f 100644 --- a/src/wp-content/themes/twentytwentytwo/inc/patterns/page-layout-image-text-and-video.php +++ b/src/wp-content/themes/twentytwentytwo/inc/patterns/page-layout-image-text-and-video.php @@ -1,7 +1,12 @@ __( 'Page layout with image, text and video', 'twentytwentytwo' ), 'categories' => array( 'twentytwentytwo_pages' ), diff --git a/src/wp-content/themes/twentytwentytwo/inc/patterns/page-layout-two-columns.php b/src/wp-content/themes/twentytwentytwo/inc/patterns/page-layout-two-columns.php index b24c9c57dc0d6..e32ced62f584e 100644 --- a/src/wp-content/themes/twentytwentytwo/inc/patterns/page-layout-two-columns.php +++ b/src/wp-content/themes/twentytwentytwo/inc/patterns/page-layout-two-columns.php @@ -1,7 +1,12 @@ __( 'Page layout with two columns', 'twentytwentytwo' ), 'categories' => array( 'twentytwentytwo_pages' ), diff --git a/src/wp-content/themes/twentytwentytwo/inc/patterns/page-sidebar-blog-posts-right.php b/src/wp-content/themes/twentytwentytwo/inc/patterns/page-sidebar-blog-posts-right.php index 070aef04b8622..afa82baad3889 100644 --- a/src/wp-content/themes/twentytwentytwo/inc/patterns/page-sidebar-blog-posts-right.php +++ b/src/wp-content/themes/twentytwentytwo/inc/patterns/page-sidebar-blog-posts-right.php @@ -1,7 +1,12 @@ __( 'Blog posts with right sidebar', 'twentytwentytwo' ), 'categories' => array( 'twentytwentytwo_pages' ), diff --git a/src/wp-content/themes/twentytwentytwo/inc/patterns/page-sidebar-blog-posts.php b/src/wp-content/themes/twentytwentytwo/inc/patterns/page-sidebar-blog-posts.php index e535a81a76a0b..7107b28a4a120 100644 --- a/src/wp-content/themes/twentytwentytwo/inc/patterns/page-sidebar-blog-posts.php +++ b/src/wp-content/themes/twentytwentytwo/inc/patterns/page-sidebar-blog-posts.php @@ -1,7 +1,12 @@ __( 'Blog posts with left sidebar', 'twentytwentytwo' ), 'categories' => array( 'twentytwentytwo_pages' ), diff --git a/src/wp-content/themes/twentytwentytwo/inc/patterns/page-sidebar-grid-posts.php b/src/wp-content/themes/twentytwentytwo/inc/patterns/page-sidebar-grid-posts.php index b4aa6a560a0a6..4bd63ffef9ba8 100644 --- a/src/wp-content/themes/twentytwentytwo/inc/patterns/page-sidebar-grid-posts.php +++ b/src/wp-content/themes/twentytwentytwo/inc/patterns/page-sidebar-grid-posts.php @@ -1,7 +1,12 @@ __( 'Grid of posts with left sidebar', 'twentytwentytwo' ), 'categories' => array( 'twentytwentytwo_pages' ), diff --git a/src/wp-content/themes/twentytwentytwo/inc/patterns/page-sidebar-poster.php b/src/wp-content/themes/twentytwentytwo/inc/patterns/page-sidebar-poster.php index 9cbcef2a7a97e..035c9c8104717 100644 --- a/src/wp-content/themes/twentytwentytwo/inc/patterns/page-sidebar-poster.php +++ b/src/wp-content/themes/twentytwentytwo/inc/patterns/page-sidebar-poster.php @@ -1,7 +1,12 @@ __( 'Poster with right sidebar', 'twentytwentytwo' ), 'categories' => array( 'twentytwentytwo_pages' ), diff --git a/src/wp-content/themes/twentytwentytwo/inc/patterns/query-default.php b/src/wp-content/themes/twentytwentytwo/inc/patterns/query-default.php index 7cebaccf75f5d..ed41f2f2c2807 100644 --- a/src/wp-content/themes/twentytwentytwo/inc/patterns/query-default.php +++ b/src/wp-content/themes/twentytwentytwo/inc/patterns/query-default.php @@ -1,7 +1,12 @@ __( 'Default posts', 'twentytwentytwo' ), 'categories' => array( 'query' ), diff --git a/src/wp-content/themes/twentytwentytwo/inc/patterns/query-grid.php b/src/wp-content/themes/twentytwentytwo/inc/patterns/query-grid.php index f695eba354047..c1eeb9e2dadef 100644 --- a/src/wp-content/themes/twentytwentytwo/inc/patterns/query-grid.php +++ b/src/wp-content/themes/twentytwentytwo/inc/patterns/query-grid.php @@ -1,7 +1,12 @@ __( 'Grid of posts', 'twentytwentytwo' ), 'categories' => array( 'query' ), diff --git a/src/wp-content/themes/twentytwentytwo/inc/patterns/query-image-grid.php b/src/wp-content/themes/twentytwentytwo/inc/patterns/query-image-grid.php index e5672e3c1cfe9..4c0b0e26049ae 100644 --- a/src/wp-content/themes/twentytwentytwo/inc/patterns/query-image-grid.php +++ b/src/wp-content/themes/twentytwentytwo/inc/patterns/query-image-grid.php @@ -1,7 +1,12 @@ __( 'Grid of image posts', 'twentytwentytwo' ), 'categories' => array( 'query' ), diff --git a/src/wp-content/themes/twentytwentytwo/inc/patterns/query-irregular-grid.php b/src/wp-content/themes/twentytwentytwo/inc/patterns/query-irregular-grid.php index c5da08ab749b6..ca67baa7d6097 100644 --- a/src/wp-content/themes/twentytwentytwo/inc/patterns/query-irregular-grid.php +++ b/src/wp-content/themes/twentytwentytwo/inc/patterns/query-irregular-grid.php @@ -1,7 +1,12 @@ __( 'Irregular grid of posts', 'twentytwentytwo' ), 'categories' => array( 'query' ), diff --git a/src/wp-content/themes/twentytwentytwo/inc/patterns/query-large-titles.php b/src/wp-content/themes/twentytwentytwo/inc/patterns/query-large-titles.php index 6ff8afadfe15d..2732a9766fbd1 100644 --- a/src/wp-content/themes/twentytwentytwo/inc/patterns/query-large-titles.php +++ b/src/wp-content/themes/twentytwentytwo/inc/patterns/query-large-titles.php @@ -1,7 +1,12 @@ __( 'Large post titles', 'twentytwentytwo' ), 'categories' => array( 'query' ), diff --git a/src/wp-content/themes/twentytwentytwo/inc/patterns/query-simple-blog.php b/src/wp-content/themes/twentytwentytwo/inc/patterns/query-simple-blog.php index 4b089846dd292..4921ecba65f48 100644 --- a/src/wp-content/themes/twentytwentytwo/inc/patterns/query-simple-blog.php +++ b/src/wp-content/themes/twentytwentytwo/inc/patterns/query-simple-blog.php @@ -1,7 +1,12 @@ __( 'Simple blog posts', 'twentytwentytwo' ), 'categories' => array( 'query' ), diff --git a/src/wp-content/themes/twentytwentytwo/inc/patterns/query-text-grid.php b/src/wp-content/themes/twentytwentytwo/inc/patterns/query-text-grid.php index a8f84d3cf2a91..b1fdab67b0003 100644 --- a/src/wp-content/themes/twentytwentytwo/inc/patterns/query-text-grid.php +++ b/src/wp-content/themes/twentytwentytwo/inc/patterns/query-text-grid.php @@ -1,7 +1,12 @@ __( 'Text-based grid of posts', 'twentytwentytwo' ), 'categories' => array( 'query' ), diff --git a/src/wp-includes/PHPMailer/PHPMailer.php b/src/wp-includes/PHPMailer/PHPMailer.php index 2bb3578c7e0d9..4900cbc43afef 100644 --- a/src/wp-includes/PHPMailer/PHPMailer.php +++ b/src/wp-includes/PHPMailer/PHPMailer.php @@ -59,6 +59,7 @@ class PHPMailer const ICAL_METHOD_REFRESH = 'REFRESH'; const ICAL_METHOD_COUNTER = 'COUNTER'; const ICAL_METHOD_DECLINECOUNTER = 'DECLINECOUNTER'; + const RFC822_DATE_FORMAT = 'D, j M Y H:i:s O'; /** * Email priority. @@ -77,7 +78,7 @@ class PHPMailer public $CharSet = self::CHARSET_ISO88591; /** - * The MIME Content-type of the message. + * The MIME Content-Type of the message. * * @var string */ @@ -159,7 +160,7 @@ class PHPMailer public $Ical = ''; /** - * Value-array of "method" in Contenttype header "text/calendar" + * Value-array of "method" in Content-Type header "text/calendar" * * @var string[] */ @@ -768,7 +769,7 @@ class PHPMailer * * @var string */ - const VERSION = '7.0.2'; + const VERSION = '7.1.1'; /** * Error severity: message only, continue processing. @@ -1283,26 +1284,27 @@ protected function addAnAddress($kind, $address, $name = '') /** * Parse and validate a string containing one or more RFC822-style comma-separated email addresses * of the form "display name
" into an array of name/address pairs. - * Uses the imap_rfc822_parse_adrlist function if the IMAP extension is available. + * Uses the imap_rfc822_parse_adrlist function if the IMAP extension is available and + * the deprecated $useimap argument is truthy. * Note that quotes in the name part are removed. * * @see https://www.andrew.cmu.edu/user/agreen1/testing/mrbs/web/Mail/RFC822.php A more careful implementation * * @param string $addrstr The address list string - * @param null $useimap Unused. Argument has been deprecated in PHPMailer 6.11.0. - * Previously this argument determined whether to use - * the IMAP extension to parse the list and accepted a boolean value. + * @param bool|null $useimap Deprecated in PHPMailer 6.11.0. + * Truthy values request the deprecated IMAP parser + * and trigger a deprecation warning. * @param string $charset The charset to use when decoding the address list string. * * @return array */ public static function parseAddresses($addrstr, $useimap = null, $charset = self::CHARSET_ISO88591) { - if ($useimap !== null) { + if ($useimap == true) { trigger_error(self::lang('deprecated_argument') . '$useimap', E_USER_DEPRECATED); } $addresses = []; - if (function_exists('imap_rfc822_parse_adrlist')) { + if ($useimap == true && function_exists('imap_rfc822_parse_adrlist')) { //Use this built-in parser if it's available // phpcs:ignore PHPCompatibility.FunctionUse.RemovedFunctions.imap_rfc822_parse_adrlistRemoved -- wrapped in function_exists() $list = imap_rfc822_parse_adrlist($addrstr, ''); @@ -1779,6 +1781,8 @@ public function preSend() //Trim subject consistently $this->Subject = trim($this->Subject); + + //Create body before headers in case body makes changes to headers (e.g. altering transfer encoding) $this->MIMEHeader = ''; $this->MIMEBody = $this->createBody(); @@ -1853,7 +1857,7 @@ public function postSend() return $this->mailSend($this->MIMEHeader, $this->MIMEBody); default: $sendMethod = $this->Mailer . 'Send'; - if (method_exists($this, $sendMethod)) { + if (!empty($this->Mailer) && method_exists($this, $sendMethod)) { return $this->{$sendMethod}($this->MIMEHeader, $this->MIMEBody); } @@ -1911,7 +1915,7 @@ protected function sendmailSend($header, $body) // CVE-2016-10033, CVE-2016-10045: Don't pass -f if characters will be escaped. // Also don't add the -f automatically unless it has been set either via Sender - // or sendmail_path. Otherwise it can introduce new problems. + // or sendmail_path. Otherwise, it can introduce new problems. // @see http://github.com/PHPMailer/PHPMailer/issues/2298 if (!empty($this->Sender) && static::validateAddress($this->Sender) && self::isShellSafe($this->Sender)) { $sendmailArgs[] = '-f' . $this->Sender; @@ -2510,7 +2514,7 @@ public static function setLanguage($langcode = 'en', $lang_path = '') 'authenticate' => 'SMTP Error: Could not authenticate.', 'buggy_php' => 'Your version of PHP is affected by a bug that may result in corrupted messages.' . ' To fix it, switch to sending using SMTP, disable the mail.add_x_header option in' . - ' your php.ini, switch to MacOS or Linux, or upgrade your PHP to version 7.0.17+ or 7.1.3+.', + ' your php.ini, switch to macOS or Linux, or upgrade your PHP to version 7.0.17+ or 7.1.3+.', 'connect_host' => 'SMTP Error: Could not connect to SMTP host.', 'data_not_accepted' => 'SMTP Error: data not accepted.', 'empty_message' => 'Message body empty', @@ -2847,7 +2851,10 @@ public function createHeader() { $result = ''; - $result .= $this->headerLine('Date', '' === $this->MessageDate ? self::rfcDate() : $this->MessageDate); + $result .= $this->headerLine( + 'Date', + self::sanitiseDate($this->MessageDate) + ); //The To header is created automatically by mail(), so needs to be omitted here if ('mail' !== $this->Mailer) { @@ -2916,7 +2923,7 @@ public function createHeader() ); } elseif (is_string($this->XMailer) && trim($this->XMailer) !== '') { //Some string - $result .= $this->headerLine('X-Mailer', trim($this->XMailer)); + $result .= $this->headerLine('X-Mailer', $this->secureHeader(trim($this->XMailer))); } //Other values result in no X-Mailer header if ('' !== $this->ConfirmReadingTo) { @@ -2966,13 +2973,20 @@ public function getMailMIME() break; default: //Catches case 'plain': and case '': - $result .= $this->textLine('Content-Type: ' . $this->ContentType . '; charset=' . $this->CharSet); + $result .= $this->textLine( + 'Content-Type: ' . + $this->secureHeader($this->ContentType) . + '; charset=' . $this->secureHeader($this->CharSet) + ); $ismultipart = false; break; } + if (!$this->validateEncoding($this->Encoding)) { + throw new Exception(self::lang('encoding') . $this->Encoding); + } //RFC1341 part 5 says 7bit is assumed if not specified if (static::ENCODING_7BIT !== $this->Encoding) { - //RFC 2045 section 6.4 says multipart MIME parts may only use 7bit, 8bit or binary CTE + //RFC 2045 section 6.4 says multipart MIME parts may only use 7bit, 8bit, or binary CTE if ($ismultipart) { if (static::ENCODING_8BIT === $this->Encoding) { $result .= $this->headerLine('Content-Transfer-Encoding', static::ENCODING_8BIT); @@ -3047,6 +3061,9 @@ public function createBody() $this->setWordWrap(); + if (!$this->validateEncoding($this->Encoding)) { + throw new Exception(self::lang('encoding') . $this->Encoding); + } $bodyEncoding = $this->Encoding; $bodyCharSet = $this->CharSet; //Can we do a 7-bit downgrade? @@ -4166,7 +4183,7 @@ public function addStringEmbeddedImage( protected function validateEncoding($encoding) { return in_array( - $encoding, + strtolower($encoding), [ self::ENCODING_7BIT, self::ENCODING_QUOTED_PRINTABLE, @@ -4426,7 +4443,7 @@ protected function setError($msg) } /** - * Return an RFC 822 formatted date. + * Return the current date and time as an RFC 822 formatted date. * * @return string */ @@ -4436,7 +4453,51 @@ public static function rfcDate() //Will default to UTC if it's not set properly in php.ini date_default_timezone_set(@date_default_timezone_get()); - return date('D, j M Y H:i:s O'); + return date(self::RFC822_DATE_FORMAT); + } + + /** + * Normalise a user-supplied date into a correctly-formatted RFC 5322 date value + * string suitable for use in the Date header. + * + * Accepts: + * - A {@see \DateTime} (or \DateTimeImmutable) object + * - Any date/time string understood by PHP's DateTime constructor (RFC 5322, ISO 8601, + * Unix timestamp with leading "@", natural-language strings, etc.) + * + * Dates in the future are not permitted for email headers; if the parsed date is later + * than "now" the method falls back to the current time via {@see self::rfcDate()}. + * An empty value, a non-string/non-DateTime argument, or any value that cannot be + * parsed will likewise fall back to {@see self::rfcDate()}. + * + * @param \DateTime|\DateTimeImmutable|string $date The date to normalise + * + * @return string An RFC 5322-formatted date string + */ + private static function sanitiseDate($date) + { + try { + //Ensure the default timezone is set properly + date_default_timezone_set(@date_default_timezone_get()); + + if ($date instanceof \DateTimeInterface) { + $dt = $date; + } elseif (is_string($date) && $date !== '') { + $dt = new \DateTime($date); + } else { + //Empty string, null, or any unsupported type + return self::rfcDate(); + } + + //Reject future dates — they are invalid for outgoing message headers + if ($dt->getTimestamp() > time()) { + return self::rfcDate(); + } + + return $dt->format(self::RFC822_DATE_FORMAT); + } catch (\Exception $e) { + return self::rfcDate(); + } } /** diff --git a/src/wp-includes/PHPMailer/POP3.php b/src/wp-includes/PHPMailer/POP3.php index 186fe9fe47ab7..0ba9678373217 100644 --- a/src/wp-includes/PHPMailer/POP3.php +++ b/src/wp-includes/PHPMailer/POP3.php @@ -47,7 +47,7 @@ class POP3 * @var string * @deprecated This constant will be removed in PHPMailer 8.0. Use `PHPMailer::VERSION` instead. */ - const VERSION = '7.0.2'; + const VERSION = '7.1.1'; /** * Default POP3 port number. @@ -212,9 +212,9 @@ public function authorise($host, $port = false, $timeout = false, $username = '' } else { $this->tval = (int) $timeout; } - $this->do_debug = $debug_level; - $this->username = $username; - $this->password = $password; + $this->do_debug = (int) $debug_level; + $this->username = self::stripControls($username); + $this->password = self::stripControls($password); //Reset the error log $this->errors = []; //Connect @@ -319,7 +319,8 @@ public function login($username = '', $password = '') if (empty($password)) { $password = $this->password; } - + $username = self::stripControls($username); + $password = self::stripControls($password); //Send the Username $this->sendString("USER $username" . static::LE); $pop3_response = $this->getResponse(); @@ -407,7 +408,7 @@ protected function sendString($string) /** * Checks the POP3 server response. - * Looks for for +OK or -ERR. + * Looks for +OK or -ERR. * * @param string $string * @@ -467,4 +468,16 @@ protected function catchWarning($errno, $errstr, $errfile, $errline) "errno: $errno errstr: $errstr; errfile: $errfile; errline: $errline" ); } + + /** + * Strip all control chars from a string. + * + * @param $string + * + * @return string + */ + protected static function stripControls($string) + { + return preg_replace('/[\x00-\x1F\x7F]/u', '', $string); + } } diff --git a/src/wp-includes/PHPMailer/SMTP.php b/src/wp-includes/PHPMailer/SMTP.php index 559b52c45e8f8..f0957b80a919f 100644 --- a/src/wp-includes/PHPMailer/SMTP.php +++ b/src/wp-includes/PHPMailer/SMTP.php @@ -36,7 +36,7 @@ class SMTP * @var string * @deprecated This constant will be removed in PHPMailer 8.0. Use `PHPMailer::VERSION` instead. */ - const VERSION = '7.0.2'; + const VERSION = '7.1.1'; /** * SMTP line break constant. @@ -1289,7 +1289,7 @@ public function getServerExtList() * 3. EHLO has been sent - * $name == 'HELO'|'EHLO': returns the server name * $name == any other string: if extension $name exists, returns True - * or its options (e.g. AUTH mechanisms supported). Otherwise returns False. + * or its options (e.g. AUTH mechanisms supported). Otherwise, returns False. * * @param string $name Name of SMTP extension or 'HELO'|'EHLO' * diff --git a/src/wp-includes/abilities-api/class-wp-ability.php b/src/wp-includes/abilities-api/class-wp-ability.php index 2127417a887b9..9efefc6713f5a 100644 --- a/src/wp-includes/abilities-api/class-wp-ability.php +++ b/src/wp-includes/abilities-api/class-wp-ability.php @@ -782,6 +782,8 @@ public function execute( $input = null ) { */ do_action( 'wp_ability_invoked', $this->name, $input, $this ); + $pre_execute_sentinel = new WP_Filter_Sentinel(); + /** * Filters whether to short-circuit ability execution. * @@ -804,8 +806,7 @@ public function execute( $input = null ) { * @param mixed $input The raw input passed to `execute()`. * @param WP_Ability $ability The ability instance. */ - $pre_execute_sentinel = new WP_Filter_Sentinel(); - $pre = apply_filters( 'wp_pre_execute_ability', $pre_execute_sentinel, $this->name, $input, $this ); + $pre = apply_filters( 'wp_pre_execute_ability', $pre_execute_sentinel, $this->name, $input, $this ); if ( $pre !== $pre_execute_sentinel ) { return $pre; } diff --git a/src/wp-includes/ai-client/adapters/class-wp-ai-client-http-client.php b/src/wp-includes/ai-client/adapters/class-wp-ai-client-http-client.php index 1c1db9a26c707..d2ec1cbf3a91a 100644 --- a/src/wp-includes/ai-client/adapters/class-wp-ai-client-http-client.php +++ b/src/wp-includes/ai-client/adapters/class-wp-ai-client-http-client.php @@ -217,7 +217,7 @@ private function create_psr_response( array $wp_response ): ResponseInterface { } } - if ( ! empty( $body ) ) { + if ( '' !== $body ) { $stream = $this->stream_factory->createStream( $body ); $response = $response->withBody( $stream ); } diff --git a/src/wp-includes/assets/script-loader-packages.php b/src/wp-includes/assets/script-loader-packages.php index 7420056b8553b..6b0ed2811e9d4 100644 --- a/src/wp-includes/assets/script-loader-packages.php +++ b/src/wp-includes/assets/script-loader-packages.php @@ -4,7 +4,7 @@ 'wp-dom-ready', 'wp-i18n' ), - 'version' => '483af07a6016f640f456' + 'version' => '31c6cec5a4ff7aff483d' ), 'annotations.js' => array( 'dependencies' => array( @@ -13,7 +13,7 @@ 'wp-i18n', 'wp-rich-text' ), - 'version' => 'd4fe1eeb787c2fd5ee89' + 'version' => '348a030f1b5717cfaba4' ), 'api-fetch.js' => array( 'dependencies' => array( @@ -21,25 +21,25 @@ 'wp-private-apis', 'wp-url' ), - 'version' => 'b5b51750518787a93005' + 'version' => '6f2a4faeee3c722b1e57' ), 'autop.js' => array( 'dependencies' => array( ), - 'version' => '9d0d0901b46f0a9027c9' + 'version' => '4e10a18cb6f21a043fc0' ), 'base-styles.js' => array( 'dependencies' => array( ), - 'version' => '8ebe97b095beb7e9279b' + 'version' => '67fd7250ac73fa2feba5' ), 'blob.js' => array( 'dependencies' => array( ), - 'version' => '198af75fe06d924090d8' + 'version' => 'c7582a735ddd2edc9731' ), 'block-directory.js' => array( 'dependencies' => array( @@ -66,7 +66,7 @@ 'wp-theme', 'wp-url' ), - 'version' => 'e534a0f04643c4175bf3' + 'version' => '7c679438bdaf9853987a' ), 'block-editor.js' => array( 'dependencies' => array( @@ -104,7 +104,7 @@ 'wp-url', 'wp-warning' ), - 'version' => '77626afea4a1cac03204' + 'version' => '3e3993ced88d35b1fafc' ), 'block-library.js' => array( 'dependencies' => array( @@ -150,19 +150,19 @@ 'import' => 'dynamic' ) ), - 'version' => 'd24e08348f91bcfce1b7' + 'version' => '6569ec7523b3693a2b2e' ), 'block-serialization-default-parser.js' => array( 'dependencies' => array( ), - 'version' => 'bff55bd3f1ce9df0c99c' + 'version' => '4c6f3dd40077f7c17604' ), 'block-serialization-spec-parser.js' => array( 'dependencies' => array( ), - 'version' => '9ebc5e95e1de1cabd1e6' + 'version' => '7b0b496c3d48b1ef3f9e' ), 'blocks.js' => array( 'dependencies' => array( @@ -183,7 +183,7 @@ 'wp-shortcode', 'wp-warning' ), - 'version' => 'dc4bdf700024000fd427' + 'version' => 'c0e57a630a0b6f6c3bb5' ), 'commands.js' => array( 'dependencies' => array( @@ -199,7 +199,7 @@ 'wp-primitives', 'wp-private-apis' ), - 'version' => '148d9b31ef4d2952561e' + 'version' => '1a4910212c7ed2355300' ), 'components.js' => array( 'dependencies' => array( @@ -224,7 +224,7 @@ 'wp-theme', 'wp-warning' ), - 'version' => 'd54375c07776a218ee99' + 'version' => '7937a1d6ffdc16e88517' ), 'compose.js' => array( 'dependencies' => array( @@ -239,7 +239,7 @@ 'wp-private-apis', 'wp-undo-manager' ), - 'version' => '6176e314156a3d1f9501' + 'version' => '0e8bde2a499ea6073b42' ), 'core-commands.js' => array( 'dependencies' => array( @@ -256,7 +256,7 @@ 'wp-router', 'wp-url' ), - 'version' => '8fc41d3503f7892d3ed8' + 'version' => '426a33508599b7f31db3' ), 'core-data.js' => array( 'dependencies' => array( @@ -277,7 +277,7 @@ 'wp-url', 'wp-warning' ), - 'version' => 'c7a571126b75599516cf' + 'version' => 'f0176a9c136b2962fdc4' ), 'customize-widgets.js' => array( 'dependencies' => array( @@ -306,7 +306,13 @@ 'wp-theme', 'wp-widgets' ), - 'version' => 'f28ae391ffd39b8db426' + 'module_dependencies' => array( + array( + 'id' => '@wordpress/route', + 'import' => 'static' + ) + ), + 'version' => 'a73c35651dc8614d5fb3' ), 'data.js' => array( 'dependencies' => array( @@ -319,7 +325,7 @@ 'wp-private-apis', 'wp-redux-routine' ), - 'version' => 'c547bd40753de57cdc64' + 'version' => '14a216e0932d72c22976' ), 'data-controls.js' => array( 'dependencies' => array( @@ -327,32 +333,32 @@ 'wp-data', 'wp-deprecated' ), - 'version' => '730061ade69d7f341014' + 'version' => '7e8f932da184d5537725' ), 'date.js' => array( 'dependencies' => array( 'moment', 'wp-deprecated' ), - 'version' => '2faaf49020b2074de156' + 'version' => '8173fc0fc12b7bb7eaf0' ), 'deprecated.js' => array( 'dependencies' => array( 'wp-hooks' ), - 'version' => '990e85f234fee8f7d446' + 'version' => 'fe587bac92b7d0ef760e' ), 'dom.js' => array( 'dependencies' => array( 'wp-deprecated' ), - 'version' => '22d969bde5c7182cdd2f' + 'version' => 'c95f94cbbc1ac3fde84f' ), 'dom-ready.js' => array( 'dependencies' => array( ), - 'version' => 'a06281ae5cf5500e9317' + 'version' => '3fe927cab37bf38d6a23' ), 'edit-post.js' => array( 'dependencies' => array( @@ -396,7 +402,7 @@ 'import' => 'static' ) ), - 'version' => '4aeb2f3aa372be39adb2' + 'version' => 'e566fa04fc489a642398' ), 'edit-site.js' => array( 'dependencies' => array( @@ -446,7 +452,7 @@ 'import' => 'static' ) ), - 'version' => 'c33d508cfc124b1b3e2d' + 'version' => 'b818e670c0d0297645f2' ), 'edit-widgets.js' => array( 'dependencies' => array( @@ -487,7 +493,7 @@ 'import' => 'static' ) ), - 'version' => 'b6608ebdd73ddae5a250' + 'version' => 'a84bb1dba0b91cf80efa' ), 'editor.js' => array( 'dependencies' => array( @@ -537,7 +543,7 @@ 'import' => 'static' ) ), - 'version' => 'cf691bc72eeac5643913' + 'version' => 'e3c5b4a412541c51a59d' ), 'element.js' => array( 'dependencies' => array( @@ -545,13 +551,13 @@ 'react-dom', 'wp-escape-html' ), - 'version' => 'ce395381f7d64d2a6d71' + 'version' => '4a4370b2b349066fd440' ), 'escape-html.js' => array( 'dependencies' => array( ), - 'version' => '3f093e5cca67aa0f8b56' + 'version' => '87ebe53e97bba59805a5' ), 'format-library.js' => array( 'dependencies' => array( @@ -578,31 +584,31 @@ 'import' => 'dynamic' ) ), - 'version' => 'fc1a40ac6923d97797a4' + 'version' => 'd69ac704b4a81b89c946' ), 'hooks.js' => array( 'dependencies' => array( ), - 'version' => '7496969728ca0f95732d' + 'version' => 'f0f188028580e8dc1255' ), 'html-entities.js' => array( 'dependencies' => array( ), - 'version' => '8c6fa5b869dfeadc4af2' + 'version' => 'a976ff3a0f00bc2999a3' ), 'i18n.js' => array( 'dependencies' => array( 'wp-hooks' ), - 'version' => '125448662852c5e18937' + 'version' => '1dfe7db3940c23ea9216' ), 'is-shallow-equal.js' => array( 'dependencies' => array( ), - 'version' => '5d84b9f3cb50d2ce7d04' + 'version' => '7ad271045c1fe60f5496' ), 'keyboard-shortcuts.js' => array( 'dependencies' => array( @@ -611,13 +617,13 @@ 'wp-element', 'wp-keycodes' ), - 'version' => '0dd268b2132a3f82b1d4' + 'version' => '37da95806f2339bc80d0' ), 'keycodes.js' => array( 'dependencies' => array( 'wp-i18n' ), - 'version' => 'b156d58a707bff518176' + 'version' => 'd0b4204e4bbeb412df6e' ), 'list-reusable-blocks.js' => array( 'dependencies' => array( @@ -629,7 +635,7 @@ 'wp-element', 'wp-i18n' ), - 'version' => 'a44da9be02cdfef6e44d' + 'version' => '68a57d388ce085b9691e' ), 'media-utils.js' => array( 'dependencies' => array( @@ -657,7 +663,7 @@ 'wp-url', 'wp-warning' ), - 'version' => '8addf2ae46aa60243073' + 'version' => 'b8bf604c1cc119e63ee6' ), 'notices.js' => array( 'dependencies' => array( @@ -665,14 +671,14 @@ 'wp-components', 'wp-data' ), - 'version' => '505026883bbd05994872' + 'version' => 'c09a068fdab0eb465e14' ), 'nux.js' => array( 'dependencies' => array( 'wp-data', 'wp-deprecated' ), - 'version' => 'b0afe722eacfd6e9a364' + 'version' => '1a78c05bba2c02820a7e' ), 'patterns.js' => array( 'dependencies' => array( @@ -695,7 +701,7 @@ 'wp-theme', 'wp-url' ), - 'version' => '1d5dc833056614a65601' + 'version' => 'be5af192f57cc14d340f' ), 'plugins.js' => array( 'dependencies' => array( @@ -707,7 +713,7 @@ 'wp-is-shallow-equal', 'wp-primitives' ), - 'version' => '50bcc9bb42e4c0723a8c' + 'version' => '673d1e05ca49004ab160' ), 'preferences.js' => array( 'dependencies' => array( @@ -723,32 +729,32 @@ 'wp-primitives', 'wp-private-apis' ), - 'version' => 'ba5e81b3db928d4649c6' + 'version' => '5a169e3fc0e657f74172' ), 'preferences-persistence.js' => array( 'dependencies' => array( 'wp-api-fetch' ), - 'version' => 'e8033be98338d1861bca' + 'version' => 'a34abbdacd8f50f9acb1' ), 'primitives.js' => array( 'dependencies' => array( 'react-jsx-runtime', 'wp-element' ), - 'version' => 'a5c905ec27bcd76ef287' + 'version' => '44cc5a35c7b9fe07a838' ), 'priority-queue.js' => array( 'dependencies' => array( ), - 'version' => '1f0e89e247bc0bd3f9b9' + 'version' => '6c0aa59b65d55dfd509b' ), 'private-apis.js' => array( 'dependencies' => array( ), - 'version' => 'd253db066c622f144ae7' + 'version' => 'eb85f28c4c729bb4f002' ), 'react-i18n.js' => array( 'dependencies' => array( @@ -756,13 +762,13 @@ 'wp-element', 'wp-i18n' ), - 'version' => '9b74577dbd7e50f6b77b' + 'version' => 'ba2bd3d7a3817f0494af' ), 'redux-routine.js' => array( 'dependencies' => array( ), - 'version' => '64f9f5001aabc046c605' + 'version' => 'acca2b4857d83ad1790e' ), 'reusable-blocks.js' => array( 'dependencies' => array( @@ -779,7 +785,7 @@ 'wp-primitives', 'wp-url' ), - 'version' => '00a57a244d360831336a' + 'version' => '5161508c6662b8490ee8' ), 'rich-text.js' => array( 'dependencies' => array( @@ -794,7 +800,7 @@ 'wp-keycodes', 'wp-private-apis' ), - 'version' => '1c4b61567c93d486f1dc' + 'version' => '3e5852e42cee1c239bae' ), 'router.js' => array( 'dependencies' => array( @@ -804,7 +810,7 @@ 'wp-private-apis', 'wp-url' ), - 'version' => '0249e6724784b1c2583b' + 'version' => 'dda75cd9ff9d7e0eb19f' ), 'server-side-render.js' => array( 'dependencies' => array( @@ -818,19 +824,19 @@ 'wp-i18n', 'wp-url' ), - 'version' => '77621917ec58330ec283' + 'version' => '83e806a0634df6b93530' ), 'shortcode.js' => array( 'dependencies' => array( ), - 'version' => '11742fe18cc215d3d5ab' + 'version' => 'f6273476300cc5fad4cd' ), 'style-engine.js' => array( 'dependencies' => array( ), - 'version' => '50b0461aa90d44c4123b' + 'version' => '914befb08774033e6265' ), 'sync.js' => array( 'dependencies' => array( @@ -838,7 +844,7 @@ 'wp-hooks', 'wp-private-apis' ), - 'version' => '82121af3ec5dd7ba0296' + 'version' => '15f3a34404da1c4bb483' ), 'theme.js' => array( 'dependencies' => array( @@ -848,19 +854,19 @@ 'wp-element', 'wp-private-apis' ), - 'version' => 'f017490f1df372de8462' + 'version' => '48f91740a3d737558e9c' ), 'token-list.js' => array( 'dependencies' => array( ), - 'version' => '16f0aebdd39d87c2a84b' + 'version' => 'e86ab419d8302d57822c' ), 'undo-manager.js' => array( 'dependencies' => array( 'wp-is-shallow-equal' ), - 'version' => '27bb0ae036a2c9d4a1b5' + 'version' => '4554fce6276d8910a4ae' ), 'upload-media.js' => array( 'dependencies' => array( @@ -883,13 +889,13 @@ 'import' => 'dynamic' ) ), - 'version' => 'a16fcecc49ab54f868c2' + 'version' => 'f7174b0617bcd68e57c3' ), 'url.js' => array( 'dependencies' => array( ), - 'version' => '9dd5f16a5ce37bf4ba2c' + 'version' => '7b0de086d4ae11d55704' ), 'viewport.js' => array( 'dependencies' => array( @@ -897,13 +903,13 @@ 'wp-data', 'wp-element' ), - 'version' => '83b39beb77dcc56c4d26' + 'version' => 'a56e3489ed4faeac7720' ), 'warning.js' => array( 'dependencies' => array( ), - 'version' => '36fdbdc984d93aee8a97' + 'version' => 'a0978839debc564a6608' ), 'widgets.js' => array( 'dependencies' => array( @@ -920,12 +926,12 @@ 'wp-notices', 'wp-primitives' ), - 'version' => '2a2e101698084ec9e2c3' + 'version' => '087235ca647aa1a33227' ), 'wordcount.js' => array( 'dependencies' => array( ), - 'version' => 'f53ba7c5b085d7a53357' + 'version' => 'f0b1f0e977b2ff6e0132' ) ); \ No newline at end of file diff --git a/src/wp-includes/assets/script-modules-packages.php b/src/wp-includes/assets/script-modules-packages.php index 90e2c2a1f32fb..1213445dd9d48 100644 --- a/src/wp-includes/assets/script-modules-packages.php +++ b/src/wp-includes/assets/script-modules-packages.php @@ -76,7 +76,7 @@ 'import' => 'static' ) ), - 'version' => '96a846e1d7b789c39ab9' + 'version' => '1bf28ded04f9f188bdcb' ), 'block-library/playlist/view.js' => array( 'dependencies' => array( @@ -128,7 +128,7 @@ 'import' => 'static' ) ), - 'version' => '581cf5c9168a7665f2dd' + 'version' => 'cc1a34b1bee3c2e17bc4' ), 'boot/index.js' => array( 'dependencies' => array( @@ -164,7 +164,7 @@ 'import' => 'static' ) ), - 'version' => '4b0281842169241e3d0e' + 'version' => 'e6158521d3acdf579ed2' ), 'connectors/index.js' => array( 'dependencies' => array( @@ -211,7 +211,7 @@ 'import' => 'static' ) ), - 'version' => '2fe152df83cad8d59403' + 'version' => 'b9a1df775b12692a9ffb' ), 'core-abilities/index.js' => array( 'dependencies' => array( @@ -247,7 +247,7 @@ 'import' => 'static' ) ), - 'version' => '35485e5cfea4689dcaa1' + 'version' => 'e2f82d3d1c3179d25626' ), 'interactivity/index.js' => array( 'dependencies' => array( @@ -315,7 +315,7 @@ 'wp-private-apis', 'wp-style-engine' ), - 'version' => '9d008e280440935933bc' + 'version' => '0e40b71e65fda1397a4b' ), 'route/index.js' => array( 'dependencies' => array( diff --git a/src/wp-includes/block-supports/layout.php b/src/wp-includes/block-supports/layout.php index 896f6d25543cf..3be4f07b055ea 100644 --- a/src/wp-includes/block-supports/layout.php +++ b/src/wp-includes/block-supports/layout.php @@ -955,9 +955,24 @@ function wp_get_layout_style( $selector, $layout, $has_block_gap_support = false function wp_render_layout_support_flag( $block_content, $block ) { static $global_styles = null; - $block_type = WP_Block_Type_Registry::get_instance()->get_registered( $block['blockName'] ); - $block_supports_layout = block_has_support( $block_type, 'layout', false ) || block_has_support( $block_type, '__experimentalLayout', false ); - $style_attr = $block['attrs']['style'] ?? array(); + $block_type = WP_Block_Type_Registry::get_instance()->get_registered( $block['blockName'] ); + $block_supports_layout = block_has_support( $block_type, 'layout', false ) || block_has_support( $block_type, '__experimentalLayout', false ); + $style_attr = $block['attrs']['style'] ?? array(); + /* + * A block with no layout support and no style attribute at all cannot + * produce layout output, so return before resolving global settings. + * + * Resolving settings is not read-only: on a cold cache it queries the + * user's `wp_global_styles` post, which fires `the_posts`. A callback on + * that hook that renders blocks re-enters this filter, and the content it + * renders at that point is the global styles post itself, which parses to a + * single block with no name and no attributes. Without this return that + * block resolves settings again and the recursion has no base case. + */ + if ( ! $block_supports_layout && empty( $style_attr ) ) { + return $block_content; + } + $global_settings = wp_get_global_settings(); $viewport_settings = $global_settings['viewport'] ?? null; $responsive_media_queries = WP_Theme_JSON::get_viewport_media_queries( $viewport_settings ); diff --git a/src/wp-includes/block-template-utils.php b/src/wp-includes/block-template-utils.php index a7d5d4aa1141d..c6b3db5192e61 100644 --- a/src/wp-includes/block-template-utils.php +++ b/src/wp-includes/block-template-utils.php @@ -343,8 +343,18 @@ function _get_block_template_file( $template_type, $slug ) { ); foreach ( $themes as $theme_slug => $theme_dir ) { $template_base_paths = get_block_theme_folders( $theme_slug ); - $file_path = $theme_dir . '/' . $template_base_paths[ $template_type ] . '/' . $slug . '.html'; - if ( file_exists( $file_path ) ) { + $template_base_path = $theme_dir . '/' . $template_base_paths[ $template_type ]; + $file_path = $template_base_path . '/' . $slug . '.html'; + $real_base_path = realpath( $template_base_path ); + $real_file_path = realpath( $file_path ); + if ( + false !== $real_base_path && + false !== $real_file_path && + str_starts_with( + wp_normalize_path( $real_file_path ), + trailingslashit( wp_normalize_path( $real_base_path ) ) + ) + ) { $new_template_item = array( 'slug' => $slug, 'path' => $file_path, diff --git a/src/wp-includes/blocks.php b/src/wp-includes/blocks.php index 41e11f4a2a75f..a0360ffdc8bf8 100644 --- a/src/wp-includes/blocks.php +++ b/src/wp-includes/blocks.php @@ -43,6 +43,11 @@ function remove_block_asset_path_prefix( $asset_handle_or_path ) { * @param int $index Optional. Index of the asset when multiple items passed. * Default 0. * @return string Generated asset name for the block's field. + * + * @phpstan-param non-falsy-string $block_name + * @phpstan-param 'editorScript'|'editorStyle'|'script'|'style'|'viewScript'|'viewScriptModule'|'viewStyle' $field_name + * @phpstan-param int<0, max> $index + * @phpstan-return non-falsy-string */ function generate_block_asset_handle( $block_name, $field_name, $index = 0 ) { if ( str_starts_with( $block_name, 'core/' ) ) { @@ -86,6 +91,8 @@ function generate_block_asset_handle( $block_name, $field_name, $index = 0 ) { * * @param string $path A normalized path to a block asset. * @return string|false The URL to the block asset or false on failure. + * + * @phpstan-return non-falsy-string|false */ function get_block_asset_url( $path ) { if ( empty( $path ) ) { @@ -102,6 +109,7 @@ function get_block_asset_url( $path ) { return includes_url( str_replace( $wpinc_path_norm, '', $path ) ); } + /** @var array $template_paths_norm */ static $template_paths_norm = array(); $template = get_template(); @@ -128,11 +136,12 @@ function get_block_asset_url( $path ) { } /** - * Finds a script module ID for the selected block metadata field. It detects - * when a path to file was provided and optionally finds a corresponding asset - * file with details necessary to register the script module under with an - * automatically generated module ID. It returns unprocessed script module - * ID otherwise. + * Finds a script module ID for the selected block metadata field. + * + * Detects when a path to a file was provided and optionally finds a + * corresponding asset file with details necessary to register the script + * module with an automatically generated module ID. It returns the + * unprocessed script module ID otherwise. * * @since 6.5.0 * @@ -141,6 +150,21 @@ function get_block_asset_url( $path ) { * @param int $index Optional. Index of the script module ID to register when multiple * items passed. Default 0. * @return string|false Script module ID or false on failure. + * + * @phpstan-param array{ + * name?: non-falsy-string, + * file: non-falsy-string|null, + * version?: string, + * supports?: array{ + * interactivity?: bool|array{interactive?: bool, clientNavigation?: bool, ...}, + * ... + * }, + * viewScriptModule?: string|list, + * ... + * } $metadata + * @phpstan-param 'viewScriptModule' $field_name + * @phpstan-param int<0, max> $index + * @phpstan-return non-falsy-string|false */ function register_block_script_module_id( $metadata, $field_name, $index = 0 ) { if ( empty( $metadata[ $field_name ] ) ) { @@ -170,6 +194,7 @@ function register_block_script_module_id( $metadata, $field_name, $index = 0 ) { $module_path_norm = wp_normalize_path( realpath( $path . '/' . $module_path ) ); $module_uri = get_block_asset_url( $module_path_norm ); + /** @var array{ dependencies?: list, version?: string|false|null, ... } $module_asset */ $module_asset = ! empty( $module_asset_path ) ? require $module_asset_path : array(); $module_dependencies = $module_asset['dependencies'] ?? array(); $block_version = $metadata['version'] ?? false; @@ -206,10 +231,13 @@ function register_block_script_module_id( $metadata, $field_name, $index = 0 ) { } /** - * Finds a script handle for the selected block metadata field. It detects - * when a path to file was provided and optionally finds a corresponding asset - * file with details necessary to register the script under automatically - * generated handle name. It returns unprocessed script handle otherwise. + * Finds a script handle for the selected block metadata field. + * + * Detects when a path to a file was provided and optionally finds a + * corresponding asset file with details necessary to register the script. The + * handle is taken from the asset file when it provides one, and is otherwise + * generated automatically. It returns the unprocessed script handle when a + * handle rather than a path was given. * * @since 5.5.0 * @since 6.1.0 Added `$index` parameter. @@ -221,6 +249,20 @@ function register_block_script_module_id( $metadata, $field_name, $index = 0 ) { * Default 0. * @return string|false Script handle provided directly or created through * script's registration, or false on failure. + * + * @phpstan-param array{ + * name?: non-falsy-string, + * file: non-falsy-string|null, + * version?: string, + * textdomain?: string, + * editorScript?: string|list, + * script?: string|list, + * viewScript?: string|list, + * ... + * } $metadata + * @phpstan-param 'editorScript'|'script'|'viewScript' $field_name + * @phpstan-param int<0, max> $index + * @phpstan-return non-falsy-string|false */ function register_block_script_handle( $metadata, $field_name, $index = 0 ) { if ( empty( $metadata[ $field_name ] ) ) { @@ -247,6 +289,7 @@ function register_block_script_handle( $metadata, $field_name, $index = 0 ) { ); // Asset file for blocks is optional. See https://core.trac.wordpress.org/ticket/60460. + /** @var array{ handle?: non-falsy-string, dependencies?: list, version?: string|false|null, ... } $script_asset */ $script_asset = ! empty( $script_asset_path ) ? require $script_asset_path : array(); $script_handle = $script_asset['handle'] ?? generate_block_asset_handle( $metadata['name'], $field_name, $index ); @@ -283,9 +326,13 @@ function register_block_script_handle( $metadata, $field_name, $index = 0 ) { } /** - * Finds a style handle for the block metadata field. It detects when a path - * to file was provided and registers the style under automatically - * generated handle name. It returns unprocessed style handle otherwise. + * Finds a style handle for the block metadata field. + * + * Detects when a path to a file was provided and registers the style under an + * automatically generated handle name. It returns the unprocessed style handle + * otherwise, except for the first style of a core block, which is instead + * registered from the block's own stylesheet when separate core block assets + * are loaded. Core blocks accept only handles, not paths. * * @since 5.5.0 * @since 6.1.0 Added `$index` parameter. @@ -296,6 +343,19 @@ function register_block_script_handle( $metadata, $field_name, $index = 0 ) { * Default 0. * @return string|false Style handle provided directly or created through * style's registration, or false on failure. + * + * @phpstan-param array{ + * name?: non-falsy-string, + * file: non-falsy-string|null, + * version?: string, + * editorStyle?: string|list, + * style?: string|list, + * viewStyle?: string|list, + * ... + * } $metadata + * @phpstan-param 'editorStyle'|'style'|'viewStyle' $field_name + * @phpstan-param int<0, max> $index + * @phpstan-return non-falsy-string|false */ function register_block_style_handle( $metadata, $field_name, $index = 0 ) { if ( empty( $metadata[ $field_name ] ) ) { @@ -2778,7 +2838,7 @@ function build_query_vars_from_query_block( $block, $page ) { if ( 'only' === $block->context['query']['sticky'] ) { /* * Passing an empty array to post__in will return have_posts() as true (and all posts will be returned). - * Logic should be used before hand to determine if WP_Query should be used in the event that the array + * Logic should be used beforehand to determine if WP_Query should be used in the event that the array * being passed to post__in is empty. * * @see https://core.trac.wordpress.org/ticket/28099 diff --git a/src/wp-includes/blocks/blocks-json.php b/src/wp-includes/blocks/blocks-json.php index 422eca780bfd3..4e3a2878536a4 100644 --- a/src/wp-includes/blocks/blocks-json.php +++ b/src/wp-includes/blocks/blocks-json.php @@ -4812,7 +4812,6 @@ 'full' ), 'splitting' => true, - 'editableRoot' => true, 'anchor' => true, 'className' => false, '__experimentalBorder' => array( @@ -4976,6 +4975,7 @@ 'supports' => array( 'anchor' => true, 'align' => true, + '__experimentalExposeControlsToChildren' => true, 'color' => array( 'gradients' => true, 'link' => true, diff --git a/src/wp-includes/blocks/navigation.php b/src/wp-includes/blocks/navigation.php index 802909a39e648..fcb8f5f97af8e 100644 --- a/src/wp-includes/blocks/navigation.php +++ b/src/wp-includes/blocks/navigation.php @@ -1250,7 +1250,7 @@ function block_core_navigation_add_directives_to_submenu( $tags, $block_attribut ) ) ) { $tags->set_attribute( 'data-wp-on--click', 'actions.toggleMenuOnClick' ); - $tags->set_attribute( 'data-wp-bind--aria-expanded', 'state.isMenuOpen' ); + $tags->set_attribute( 'data-wp-bind--aria-expanded', 'state.isSubmenuOpen' ); // The `aria-expanded` attribute for SSR is already added in the submenu block. } // Add directives to the submenu. diff --git a/src/wp-includes/blocks/paragraph/block.json b/src/wp-includes/blocks/paragraph/block.json index 1b6ad873c6b66..556c2870557f7 100644 --- a/src/wp-includes/blocks/paragraph/block.json +++ b/src/wp-includes/blocks/paragraph/block.json @@ -29,7 +29,6 @@ "supports": { "align": [ "wide", "full" ], "splitting": true, - "editableRoot": true, "anchor": true, "className": false, "__experimentalBorder": { diff --git a/src/wp-includes/blocks/playlist/block.json b/src/wp-includes/blocks/playlist/block.json index 796b3d580e6a6..566174e50d3c9 100644 --- a/src/wp-includes/blocks/playlist/block.json +++ b/src/wp-includes/blocks/playlist/block.json @@ -69,6 +69,7 @@ "supports": { "anchor": true, "align": true, + "__experimentalExposeControlsToChildren": true, "color": { "gradients": true, "link": true, diff --git a/src/wp-includes/build/pages/font-library/page-wp-admin.php b/src/wp-includes/build/pages/font-library/page-wp-admin.php index 0ec524abcf6dd..bbca5fa7964fb 100644 --- a/src/wp-includes/build/pages/font-library/page-wp-admin.php +++ b/src/wp-includes/build/pages/font-library/page-wp-admin.php @@ -87,9 +87,10 @@ function wp_get_font_library_wp_admin_menu_items() { */ function wp_font_library_wp_admin_preload_data() { // Define paths to preload - same for all pages - // Please also change packages/core-data/src/entities.js when changing this. + // This must exactly match the _fields list in packages/core-data/src/entities.js, + // same fields in the same order, or the preload is never consumed. $preload_paths = array( - '/?_fields=description,gmt_offset,home,image_sizes,image_size_threshold,name,site_icon,site_icon_url,site_logo,timezone_string,url,page_for_posts,page_on_front,show_on_front', + '/?_fields=description,gmt_offset,home,image_max_bit_depth,image_sizes,image_size_threshold,image_strip_meta,name,site_icon,site_icon_url,site_logo,timezone_string,url,page_for_posts,page_on_front,show_on_front', array( '/wp/v2/settings', 'OPTIONS' ), ); @@ -269,23 +270,23 @@ function wp_font_library_wp_admin_render_page() { #wpwrap { overflow-y: auto; } - body { + body.js { background: #fff; } /* Reset wp-admin padding */ - #wpcontent { + body.js #wpcontent { padding-inline-start: 0; } - #wpbody-content { + body.js #wpbody-content { padding-bottom: 0; } /* Hide legacy admin elements */ - #wpbody-content > div:not(.boot-layout-container):not(#screen-meta) { + body.js #wpbody-content > div:not(.boot-layout-container):not(#screen-meta) { display: none; } - #wpfooter { + body.js #wpfooter { display: none; } diff --git a/src/wp-includes/build/pages/font-library/page.php b/src/wp-includes/build/pages/font-library/page.php index dae179c60987c..0aaec2d145acf 100644 --- a/src/wp-includes/build/pages/font-library/page.php +++ b/src/wp-includes/build/pages/font-library/page.php @@ -88,9 +88,10 @@ function wp_get_font_library_menu_items() { */ function wp_font_library_preload_data() { // Define paths to preload - same for all pages - // Please also change packages/core-data/src/entities.js when changing this. + // This must exactly match the _fields list in packages/core-data/src/entities.js, + // same fields in the same order, or the preload is never consumed. $preload_paths = array( - '/?_fields=description,gmt_offset,home,image_sizes,image_size_threshold,name,site_icon,site_icon_url,site_logo,timezone_string,url,page_for_posts,page_on_front,show_on_front', + '/?_fields=description,gmt_offset,home,image_max_bit_depth,image_sizes,image_size_threshold,image_strip_meta,name,site_icon,site_icon_url,site_logo,timezone_string,url,page_for_posts,page_on_front,show_on_front', array( '/wp/v2/settings', 'OPTIONS' ), ); diff --git a/src/wp-includes/build/pages/options-connectors/page-wp-admin.php b/src/wp-includes/build/pages/options-connectors/page-wp-admin.php index e32cf496549a8..3b11db178f809 100644 --- a/src/wp-includes/build/pages/options-connectors/page-wp-admin.php +++ b/src/wp-includes/build/pages/options-connectors/page-wp-admin.php @@ -87,9 +87,10 @@ function wp_get_options_connectors_wp_admin_menu_items() { */ function wp_options_connectors_wp_admin_preload_data() { // Define paths to preload - same for all pages - // Please also change packages/core-data/src/entities.js when changing this. + // This must exactly match the _fields list in packages/core-data/src/entities.js, + // same fields in the same order, or the preload is never consumed. $preload_paths = array( - '/?_fields=description,gmt_offset,home,image_sizes,image_size_threshold,name,site_icon,site_icon_url,site_logo,timezone_string,url,page_for_posts,page_on_front,show_on_front', + '/?_fields=description,gmt_offset,home,image_max_bit_depth,image_sizes,image_size_threshold,image_strip_meta,name,site_icon,site_icon_url,site_logo,timezone_string,url,page_for_posts,page_on_front,show_on_front', array( '/wp/v2/settings', 'OPTIONS' ), ); @@ -269,23 +270,23 @@ function wp_options_connectors_wp_admin_render_page() { #wpwrap { overflow-y: auto; } - body { + body.js { background: #fff; } /* Reset wp-admin padding */ - #wpcontent { + body.js #wpcontent { padding-inline-start: 0; } - #wpbody-content { + body.js #wpbody-content { padding-bottom: 0; } /* Hide legacy admin elements */ - #wpbody-content > div:not(.boot-layout-container):not(#screen-meta) { + body.js #wpbody-content > div:not(.boot-layout-container):not(#screen-meta) { display: none; } - #wpfooter { + body.js #wpfooter { display: none; } diff --git a/src/wp-includes/build/pages/options-connectors/page.php b/src/wp-includes/build/pages/options-connectors/page.php index 1ece3b8003e97..7695969c7c060 100644 --- a/src/wp-includes/build/pages/options-connectors/page.php +++ b/src/wp-includes/build/pages/options-connectors/page.php @@ -88,9 +88,10 @@ function wp_get_options_connectors_menu_items() { */ function wp_options_connectors_preload_data() { // Define paths to preload - same for all pages - // Please also change packages/core-data/src/entities.js when changing this. + // This must exactly match the _fields list in packages/core-data/src/entities.js, + // same fields in the same order, or the preload is never consumed. $preload_paths = array( - '/?_fields=description,gmt_offset,home,image_sizes,image_size_threshold,name,site_icon,site_icon_url,site_logo,timezone_string,url,page_for_posts,page_on_front,show_on_front', + '/?_fields=description,gmt_offset,home,image_max_bit_depth,image_sizes,image_size_threshold,image_strip_meta,name,site_icon,site_icon_url,site_logo,timezone_string,url,page_for_posts,page_on_front,show_on_front', array( '/wp/v2/settings', 'OPTIONS' ), ); diff --git a/src/wp-includes/build/routes/connectors-home/content.js b/src/wp-includes/build/routes/connectors-home/content.js index 186807ef4d8e6..521cbcd5b9dc1 100644 --- a/src/wp-includes/build/routes/connectors-home/content.js +++ b/src/wp-includes/build/routes/connectors-home/content.js @@ -8937,9 +8937,9 @@ if (typeof process === "undefined" || true) { } var resets_default = { "box-sizing": "_336cd3e4e743482f__box-sizing" }; if (typeof process === "undefined" || true) { - registerStyle3("5f8e7aa0bc", "@layer wp-ui{@layer utilities, components, compositions, overrides;@layer utilities{._08e8a2e44959f892__outset-ring--focus:focus,._970d04df7376df67__outset-ring--focus-within-except-active:focus-within:not(:has(:active)),.c5cb3ee4bddaa8e4__outset-ring--focus-within-visible:focus-within:has(:focus-visible),.cd83dfc2126a0846__outset-ring--focus-within:focus-within,.d0541bc9dd9dc7b6__outset-ring--focus-visible:focus-visible,.e25b2bdd7aa21721__outset-ring--focus-except-active:focus:not(:active),:focus-visible .ecadb9e080e2dfa5__outset-ring--focus-parent-visible{--_gcd-a-outline:var(--wpds-border-width-focus,var(--wp-admin-border-width-focus,2px)) solid var(--wpds-color-stroke-focus,var(--wp-admin-theme-color,#3858e9));--_gcd-div-outline:var(--wpds-border-width-focus,var(--wp-admin-border-width-focus,2px)) solid var(--wpds-color-stroke-focus,var(--wp-admin-theme-color,#3858e9));outline:var(--wpds-border-width-focus,var(--wp-admin-border-width-focus,2px)) solid var(--wpds-color-stroke-focus,var(--wp-admin-theme-color,#3858e9));outline-offset:var(--wpds-border-width-focus,var(--wp-admin-border-width-focus,2px))}}}"); + registerStyle3("da99a163ac", "@layer wp-ui{@layer utilities, components, compositions, overrides;@layer utilities{._08e8a2e44959f892__outset-ring--focus:focus,.c5cb3ee4bddaa8e4__outset-ring--focus-within-visible:focus-within:has(:focus-visible),.cd83dfc2126a0846__outset-ring--focus-within:focus-within,.d0541bc9dd9dc7b6__outset-ring--focus-visible:focus-visible,:focus-visible .ecadb9e080e2dfa5__outset-ring--focus-parent-visible{--_gcd-a-outline:var(--wpds-border-width-focus,var(--wp-admin-border-width-focus,2px)) solid var(--wpds-color-stroke-focus,var(--wp-admin-theme-color,#3858e9));--_gcd-div-outline:var(--wpds-border-width-focus,var(--wp-admin-border-width-focus,2px)) solid var(--wpds-color-stroke-focus,var(--wp-admin-theme-color,#3858e9));outline:var(--wpds-border-width-focus,var(--wp-admin-border-width-focus,2px)) solid var(--wpds-color-stroke-focus,var(--wp-admin-theme-color,#3858e9));outline-offset:var(--wpds-border-width-focus,var(--wp-admin-border-width-focus,2px))}._970d04df7376df67__outset-ring--focus-within-except-active:focus-within,.e25b2bdd7aa21721__outset-ring--focus-except-active:focus{outline:none}._970d04df7376df67__outset-ring--focus-within-except-active:focus-within:not(:has(:active)),.e25b2bdd7aa21721__outset-ring--focus-except-active:focus:not(:active){@include mixins.focus-ring()}}}"); } -var focus_default = { "outset-ring--focus": "_08e8a2e44959f892__outset-ring--focus", "outset-ring--focus-except-active": "e25b2bdd7aa21721__outset-ring--focus-except-active", "outset-ring--focus-visible": "d0541bc9dd9dc7b6__outset-ring--focus-visible", "outset-ring--focus-within": "cd83dfc2126a0846__outset-ring--focus-within", "outset-ring--focus-within-except-active": "_970d04df7376df67__outset-ring--focus-within-except-active", "outset-ring--focus-within-visible": "c5cb3ee4bddaa8e4__outset-ring--focus-within-visible", "outset-ring--focus-parent-visible": "ecadb9e080e2dfa5__outset-ring--focus-parent-visible" }; +var focus_default = { "outset-ring--focus": "_08e8a2e44959f892__outset-ring--focus", "outset-ring--focus-visible": "d0541bc9dd9dc7b6__outset-ring--focus-visible", "outset-ring--focus-within": "cd83dfc2126a0846__outset-ring--focus-within", "outset-ring--focus-within-visible": "c5cb3ee4bddaa8e4__outset-ring--focus-within-visible", "outset-ring--focus-parent-visible": "ecadb9e080e2dfa5__outset-ring--focus-parent-visible", "outset-ring--focus-except-active": "e25b2bdd7aa21721__outset-ring--focus-except-active", "outset-ring--focus-within-except-active": "_970d04df7376df67__outset-ring--focus-within-except-active" }; if (typeof process === "undefined" || true) { registerStyle3("af6d9984a6", "._6defc79820e382c6__button{box-sizing:var(--_gcd-button-box-sizing,border-box);font-family:var(--_gcd-button-font-family,inherit);font-size:var(--_gcd-button-font-size,inherit);font-weight:var(--_gcd-button-font-weight,inherit)}.d2cff2e5dea83bd1__input{box-sizing:var(--_gcd-input-box-sizing,border-box);font-family:var(--_gcd-input-font-family,inherit);font-size:var(--_gcd-input-font-size,inherit);font-weight:var(--_gcd-input-font-weight,inherit);margin:var(--_gcd-input-margin,0);&:is(textarea,[type=text],[type=password],[type=color],[type=date],[type=datetime],[type=datetime-local],[type=email],[type=month],[type=number],[type=search],[type=tel],[type=time],[type=url],[type=week]){background-color:var(--_gcd-input-background-color,transparent);border:var(--_gcd-input-border,none);border-radius:var(--_gcd-input-border-radius,0);box-shadow:var(--_gcd-input-box-shadow,0 0 0 transparent);color:var(--_gcd-input-color,var(--wpds-color-foreground-interactive-neutral,#1e1e1e));&:focus{border-color:var(--_gcd-input-border-color-focus,var(--wp-admin-theme-color));box-shadow:var(--_gcd-input-box-shadow-focus,none);outline:var(--_gcd-input-outline-focus,none)}&:disabled{background:var(--_gcd-input-background-disabled,transparent);border-color:var(--_gcd-input-border-color-disabled,transparent);box-shadow:var(--_gcd-input-box-shadow-disabled,none);color:var(--_gcd-input-color-disabled,var(--wpds-color-foreground-interactive-neutral-disabled,#8d8d8d))}&::placeholder{color:var(--_gcd-input-placeholder-color,var(--wpds-color-foreground-interactive-neutral-disabled,#8d8d8d))}}&:is(textarea,[type=text],[type=password],[type=date],[type=datetime],[type=datetime-local],[type=email],[type=month],[type=number],[type=search],[type=tel],[type=time],[type=url],[type=week]){line-height:var(--_gcd-input-line-height,inherit);min-height:var(--_gcd-input-min-height,auto);padding:var(--_gcd-input-padding,0)}}._547d86373d02e108__textarea{box-sizing:var(--_gcd-textarea-box-sizing,border-box);overflow:var(--_gcd-textarea-overflow,auto);resize:var(--_gcd-textarea-resize,block)}._8c15fd0ed9f28ba4__div{outline:var(--_gcd-div-outline,0 solid transparent)}p._43cec3e1eec1066d__p{font-size:var(--_gcd-p-font-size,13px);line-height:var(--_gcd-p-line-height,1.5);margin:var(--_gcd-p-margin,0)}:is(h1,h2,h3,h4,h5,h6).e97669c6d9a38497__heading{color:var(--_gcd-heading-color,var(--wpds-color-foreground-content-neutral,#1e1e1e));font-size:var(--_gcd-heading-font-size,inherit);font-weight:var(--_gcd-heading-font-weight,var(--wpds-typography-font-weight-emphasis,600));margin:var(--_gcd-heading-margin,0)}._2c0831b0499dbd6e__a,._2c0831b0499dbd6e__a:is(:hover,:focus,:active){border-radius:var(--_gcd-a-border-radius,0);box-shadow:var(--_gcd-a-box-shadow,none);color:var(--_gcd-a-color,inherit);outline:var(--_gcd-a-outline,0 solid transparent);transition:var(--_gcd-a-transition,none)}"); } @@ -9904,9 +9904,9 @@ if (typeof process === "undefined" || true) { } var resets_default3 = { "box-sizing": "_336cd3e4e743482f__box-sizing" }; if (typeof process === "undefined" || true) { - registerStyle10("5f8e7aa0bc", "@layer wp-ui{@layer utilities, components, compositions, overrides;@layer utilities{._08e8a2e44959f892__outset-ring--focus:focus,._970d04df7376df67__outset-ring--focus-within-except-active:focus-within:not(:has(:active)),.c5cb3ee4bddaa8e4__outset-ring--focus-within-visible:focus-within:has(:focus-visible),.cd83dfc2126a0846__outset-ring--focus-within:focus-within,.d0541bc9dd9dc7b6__outset-ring--focus-visible:focus-visible,.e25b2bdd7aa21721__outset-ring--focus-except-active:focus:not(:active),:focus-visible .ecadb9e080e2dfa5__outset-ring--focus-parent-visible{--_gcd-a-outline:var(--wpds-border-width-focus,var(--wp-admin-border-width-focus,2px)) solid var(--wpds-color-stroke-focus,var(--wp-admin-theme-color,#3858e9));--_gcd-div-outline:var(--wpds-border-width-focus,var(--wp-admin-border-width-focus,2px)) solid var(--wpds-color-stroke-focus,var(--wp-admin-theme-color,#3858e9));outline:var(--wpds-border-width-focus,var(--wp-admin-border-width-focus,2px)) solid var(--wpds-color-stroke-focus,var(--wp-admin-theme-color,#3858e9));outline-offset:var(--wpds-border-width-focus,var(--wp-admin-border-width-focus,2px))}}}"); + registerStyle10("da99a163ac", "@layer wp-ui{@layer utilities, components, compositions, overrides;@layer utilities{._08e8a2e44959f892__outset-ring--focus:focus,.c5cb3ee4bddaa8e4__outset-ring--focus-within-visible:focus-within:has(:focus-visible),.cd83dfc2126a0846__outset-ring--focus-within:focus-within,.d0541bc9dd9dc7b6__outset-ring--focus-visible:focus-visible,:focus-visible .ecadb9e080e2dfa5__outset-ring--focus-parent-visible{--_gcd-a-outline:var(--wpds-border-width-focus,var(--wp-admin-border-width-focus,2px)) solid var(--wpds-color-stroke-focus,var(--wp-admin-theme-color,#3858e9));--_gcd-div-outline:var(--wpds-border-width-focus,var(--wp-admin-border-width-focus,2px)) solid var(--wpds-color-stroke-focus,var(--wp-admin-theme-color,#3858e9));outline:var(--wpds-border-width-focus,var(--wp-admin-border-width-focus,2px)) solid var(--wpds-color-stroke-focus,var(--wp-admin-theme-color,#3858e9));outline-offset:var(--wpds-border-width-focus,var(--wp-admin-border-width-focus,2px))}._970d04df7376df67__outset-ring--focus-within-except-active:focus-within,.e25b2bdd7aa21721__outset-ring--focus-except-active:focus{outline:none}._970d04df7376df67__outset-ring--focus-within-except-active:focus-within:not(:has(:active)),.e25b2bdd7aa21721__outset-ring--focus-except-active:focus:not(:active){@include mixins.focus-ring()}}}"); } -var focus_default2 = { "outset-ring--focus": "_08e8a2e44959f892__outset-ring--focus", "outset-ring--focus-except-active": "e25b2bdd7aa21721__outset-ring--focus-except-active", "outset-ring--focus-visible": "d0541bc9dd9dc7b6__outset-ring--focus-visible", "outset-ring--focus-within": "cd83dfc2126a0846__outset-ring--focus-within", "outset-ring--focus-within-except-active": "_970d04df7376df67__outset-ring--focus-within-except-active", "outset-ring--focus-within-visible": "c5cb3ee4bddaa8e4__outset-ring--focus-within-visible", "outset-ring--focus-parent-visible": "ecadb9e080e2dfa5__outset-ring--focus-parent-visible" }; +var focus_default2 = { "outset-ring--focus": "_08e8a2e44959f892__outset-ring--focus", "outset-ring--focus-visible": "d0541bc9dd9dc7b6__outset-ring--focus-visible", "outset-ring--focus-within": "cd83dfc2126a0846__outset-ring--focus-within", "outset-ring--focus-within-visible": "c5cb3ee4bddaa8e4__outset-ring--focus-within-visible", "outset-ring--focus-parent-visible": "ecadb9e080e2dfa5__outset-ring--focus-parent-visible", "outset-ring--focus-except-active": "e25b2bdd7aa21721__outset-ring--focus-except-active", "outset-ring--focus-within-except-active": "_970d04df7376df67__outset-ring--focus-within-except-active" }; if (typeof process === "undefined" || true) { registerStyle10("e8e6a9be37", '@layer wp-ui{@layer utilities, components, compositions, overrides;@layer components{.d4250949359b05ce__link{text-decoration-thickness:from-font;text-underline-offset:.2em}.c6055659b8e2cd2c__is-brand,.c6055659b8e2cd2c__is-brand:visited{--_gcd-a-color:var(--wpds-color-foreground-interactive-brand,var(--wp-admin-theme-color,#3858e9));color:var(--wpds-color-foreground-interactive-brand,var(--wp-admin-theme-color,#3858e9))}.c6055659b8e2cd2c__is-brand:active,.c6055659b8e2cd2c__is-brand:hover{--_gcd-a-color:var(--wpds-color-foreground-interactive-brand-active,color-mix(in oklch,var(--wp-admin-theme-color,#3858e9) 52%,#000));color:var(--wpds-color-foreground-interactive-brand-active,color-mix(in oklch,var(--wp-admin-theme-color,#3858e9) 52%,#000))}._92e0dfcaeee15b88__is-neutral,._92e0dfcaeee15b88__is-neutral:visited{--_gcd-a-color:var(--wpds-color-foreground-interactive-neutral,#1e1e1e);color:var(--wpds-color-foreground-interactive-neutral,#1e1e1e);text-decoration-color:var(--wpds-color-stroke-interactive-neutral,#8d8d8d)}._92e0dfcaeee15b88__is-neutral:active,._92e0dfcaeee15b88__is-neutral:hover{--_gcd-a-color:var(--wpds-color-foreground-interactive-neutral-active,#1e1e1e);color:var(--wpds-color-foreground-interactive-neutral-active,#1e1e1e)}.cf122a9bf1035d42__is-unstyled{--_gcd-a-color:inherit;color:inherit;text-decoration:none}._0cb411afac4c86c7__link-icon{display:inline-block;font-weight:var(--wpds-typography-font-weight-default,400);line-height:1;margin-inline-start:var(--wpds-dimension-padding-xs,4px);text-decoration:none}._0cb411afac4c86c7__link-icon:after{content:"\\2197"}._0cb411afac4c86c7__link-icon:dir(rtl):after{content:"\\2196"}}}'); } diff --git a/src/wp-includes/build/routes/connectors-home/content.min.asset.php b/src/wp-includes/build/routes/connectors-home/content.min.asset.php index 666c2ec30313d..d033468a89d76 100644 --- a/src/wp-includes/build/routes/connectors-home/content.min.asset.php +++ b/src/wp-includes/build/routes/connectors-home/content.min.asset.php @@ -1 +1 @@ - array('react', 'react-dom', 'react-jsx-runtime', 'wp-components', 'wp-compose', 'wp-core-data', 'wp-data', 'wp-element', 'wp-i18n', 'wp-notices', 'wp-primitives', 'wp-private-apis', 'wp-theme', 'wp-url'), 'module_dependencies' => array(array('id' => '@wordpress/a11y', 'import' => 'static'), array('id' => '@wordpress/connectors', 'import' => 'static'), array('id' => '@wordpress/route', 'import' => 'static')), 'version' => '22188cb77ae78d025593'); \ No newline at end of file + array('react', 'react-dom', 'react-jsx-runtime', 'wp-components', 'wp-compose', 'wp-core-data', 'wp-data', 'wp-element', 'wp-i18n', 'wp-notices', 'wp-primitives', 'wp-private-apis', 'wp-theme', 'wp-url'), 'module_dependencies' => array(array('id' => '@wordpress/a11y', 'import' => 'static'), array('id' => '@wordpress/connectors', 'import' => 'static'), array('id' => '@wordpress/route', 'import' => 'static')), 'version' => '1c478cb5cadaf4aded06'); \ No newline at end of file diff --git a/src/wp-includes/build/routes/connectors-home/content.min.js b/src/wp-includes/build/routes/connectors-home/content.min.js index 3f51fd2690b81..62f17f892185e 100644 --- a/src/wp-includes/build/routes/connectors-home/content.min.js +++ b/src/wp-includes/build/routes/connectors-home/content.min.js @@ -1,4 +1,4 @@ -var wf=Object.create;var _r=Object.defineProperty;var vf=Object.getOwnPropertyDescriptor;var _f=Object.getOwnPropertyNames;var yf=Object.getPrototypeOf,xf=Object.prototype.hasOwnProperty;var Re=(e,t)=>()=>(t||e((t={exports:{}}).exports,t),t.exports),At=(e,t)=>{for(var o in t)_r(e,o,{get:t[o],enumerable:!0})},Rf=(e,t,o,n)=>{if(t&&typeof t=="object"||typeof t=="function")for(let r of _f(t))!xf.call(e,r)&&r!==o&&_r(e,r,{get:()=>t[r],enumerable:!(n=vf(t,r))||n.enumerable});return e};var h=(e,t,o)=>(o=e!=null?wf(yf(e)):{},Rf(t||!e||!e.__esModule?_r(o,"default",{value:e,enumerable:!0}):o,e));var Ot=Re((g0,Gs)=>{Gs.exports=window.wp.i18n});var de=Re((h0,Ks)=>{Ks.exports=window.wp.element});var z=Re((w0,qs)=>{qs.exports=window.React});var Q=Re((E0,$s)=>{$s.exports=window.ReactJSXRuntime});var Mt=Re((Ah,Ta)=>{Ta.exports=window.ReactDOM});var Mc=Re(Ic=>{"use strict";var wo=z();function Tm(e,t){return e===t&&(e!==0||1/e===1/t)||e!==e&&t!==t}var km=typeof Object.is=="function"?Object.is:Tm,Pm=wo.useState,Cm=wo.useEffect,Am=wo.useLayoutEffect,Om=wo.useDebugValue;function Nm(e,t){var o=t(),n=Pm({inst:{value:o,getSnapshot:t}}),r=n[0].inst,i=n[1];return Am(function(){r.value=o,r.getSnapshot=t,ri(r)&&i({inst:r})},[e,o,t]),Cm(function(){return ri(r)&&i({inst:r}),e(function(){ri(r)&&i({inst:r})})},[e]),Om(o),o}function ri(e){var t=e.getSnapshot;e=e.value;try{var o=t();return!km(e,o)}catch{return!0}}function Lm(e,t){return t()}var Im=typeof window>"u"||typeof window.document>"u"||typeof window.document.createElement>"u"?Lm:Nm;Ic.useSyncExternalStore=wo.useSyncExternalStore!==void 0?wo.useSyncExternalStore:Im});var ii=Re((w1,Bc)=>{"use strict";Bc.exports=Mc()});var zc=Re(Hc=>{"use strict";var Dn=z(),Mm=ii();function Bm(e,t){return e===t&&(e!==0||1/e===1/t)||e!==e&&t!==t}var Hm=typeof Object.is=="function"?Object.is:Bm,zm=Mm.useSyncExternalStore,Dm=Dn.useRef,jm=Dn.useEffect,Fm=Dn.useMemo,Vm=Dn.useDebugValue;Hc.useSyncExternalStoreWithSelector=function(e,t,o,n,r){var i=Dm(null);if(i.current===null){var s={hasValue:!1,value:null};i.current=s}else s=i.current;i=Fm(function(){function d(m){if(!c){if(c=!0,l=m,m=n(m),r!==void 0&&s.hasValue){var u=s.value;if(r(u,m))return f=u}return f=m}if(u=f,Hm(l,m))return u;var g=n(m);return r!==void 0&&r(u,g)?(l=m,u):(l=m,f=g)}var c=!1,l,f,p=o===void 0?null:o;return[function(){return d(t())},p===null?void 0:function(){return d(p())}]},[t,o,n,r]);var a=zm(e,i[0],i[1]);return jm(function(){s.hasValue=!0,s.value=a},[a]),Vm(a),a}});var jc=Re((_1,Dc)=>{"use strict";Dc.exports=zc()});var $t=Re((X2,md)=>{md.exports=window.wp.primitives});var Rd=Re((g4,xd)=>{xd.exports=window.wp.theme});var Qi=Re((b4,Sd)=>{Sd.exports=window.wp.privateApis});var on=Re((q5,Au)=>{Au.exports=window.wp.components});var rn=Re((a3,zu)=>{zu.exports=window.wp.data});var mr=Re((c3,Du)=>{Du.exports=window.wp.coreData});var Hs=Re((u3,Fu)=>{Fu.exports=window.wp.notices});var Wu=Re((f3,Vu)=>{Vu.exports=window.wp.url});function Xs(e){var t,o,n="";if(typeof e=="string"||typeof e=="number")n+=e;else if(typeof e=="object")if(Array.isArray(e)){var r=e.length;for(t=0;te();function Y(e){let t=Se(kf).current;return t.next=e,Tf(t.effect),t.trampoline}function kf(){let e={next:void 0,callback:Pf,trampoline:(...t)=>e.callback?.(...t),effect:()=>{e.callback=e.next}};return e}function Pf(){}var Js=h(z(),1),Cf=()=>{},D=typeof document<"u"?Js.useLayoutEffect:Cf;var hn=h(z(),1),Af=hn.createContext(void 0);function so(){return hn.useContext(Af)?.direction??"ltr"}function Of(e,t){return function(n,...r){let i=new URL(e);return i.searchParams.set("code",n.toString()),r.forEach(s=>i.searchParams.append("args[]",s)),`${t} error #${n}; visit ${i} for the full message.`}}var Nf=Of("https://base-ui.com/production-error","Base UI"),Pe=Nf;var Wt=h(z(),1);function xr(e,t,o,n){let r=Se(ta).current;return Lf(r,e,t,o,n)&&oa(r,[e,t,o,n]),r.callback}function ea(e){let t=Se(ta).current;return If(t,e)&&oa(t,e),t.callback}function ta(){return{callback:null,cleanup:null,refs:[]}}function Lf(e,t,o,n,r){return e.refs[0]!==t||e.refs[1]!==o||e.refs[2]!==n||e.refs[3]!==r}function If(e,t){return e.refs.length!==t.length||e.refs.some((o,n)=>o!==t[n])}function oa(e,t){if(e.refs=t,t.every(o=>o==null)){e.callback=null;return}e.callback=o=>{if(e.cleanup&&(e.cleanup(),e.cleanup=null),o!=null){let n=Array(t.length).fill(null);for(let r=0;r{for(let r=0;r=e}function Rr(e){if(!ra.isValidElement(e))return null;let t=e,o=t.props;return(ao(19)?o?.ref:t.ref)??null}function Bo(e,t){if(e&&!t)return e;if(!e&&t)return t;if(e||t)return{...e,...t}}function Nt(){}var I0=Object.freeze([]),be=Object.freeze({});function ia(e,t){let o={};for(let n in e){let r=e[n];if(t?.hasOwnProperty(n)){let i=t[n](r);i!=null&&Object.assign(o,i);continue}r===!0?o[`data-${n.toLowerCase()}`]="":r&&(o[`data-${n.toLowerCase()}`]=r.toString())}return o}function sa(e,t){return typeof e=="function"?e(t):e}function aa(e,t){return typeof e=="function"?e(t):e}var Sr={};function ye(e,t,o,n,r){if(!o&&!n&&!r&&!e)return wn(t);let i=wn(e);return t&&(i=Ho(i,t)),o&&(i=Ho(i,o)),n&&(i=Ho(i,n)),r&&(i=Ho(i,r)),i}function ca(e){if(e.length===0)return Sr;if(e.length===1)return wn(e[0]);let t=wn(e[0]);for(let o=1;o=65&&r<=90&&(typeof t=="function"||typeof t>"u")}function Er(e){return typeof e=="function"}function da(e,t){return Er(e)?e(t):e??Sr}function zf(e,t){return t?e?(...o)=>{let n=o[0];if(fa(n)){let i=n;zo(i);let s=t(...o);return i.baseUIHandlerPrevented||e?.(...o),s}let r=t(...o);return e?.(...o),r}:ua(t):e}function ua(e){return e&&((...t)=>{let o=t[0];return fa(o)&&zo(o),e(...t)})}function zo(e){return e.preventBaseUIHandler=()=>{e.baseUIHandlerPrevented=!0},e}function Tr(e,t){return t?e?t+" "+e:t:e}function fa(e){return e!=null&&typeof e=="object"&&"nativeEvent"in e}var kr=h(z(),1);function Ce(e,t,o={}){let n=t.render,r=Df(t,o);if(o.enabled===!1)return null;let i=o.state??be;return Vf(e,n,r,i)}function Df(e,t={}){let{className:o,style:n,render:r}=e,{state:i=be,ref:s,props:a,stateAttributesMapping:d,enabled:c=!0}=t,l=c?sa(o,i):void 0,f=c?aa(n,i):void 0,p=c?ia(i,d):be,m=c&&a?jf(a):void 0,u=c?Bo(p,m)??{}:be;return typeof document<"u"&&(c?Array.isArray(s)?u.ref=ea([u.ref,Rr(r),...s]):u.ref=xr(u.ref,Rr(r),s):xr(null,null)),c?(l!==void 0&&(u.className=Tr(u.className,l)),f!==void 0&&(u.style=Bo(u.style,f)),u):be}function jf(e){return Array.isArray(e)?ca(e):ye(void 0,e)}var Ff=Symbol.for("react.lazy");function Vf(e,t,o,n){if(t){if(typeof t=="function")return t(o,n);let r=ye(o,t.props);r.ref=o.ref;let i=t;return i?.$$typeof===Ff&&(i=Wt.Children.toArray(t)[0]),Wt.cloneElement(i,r)}if(e&&typeof e=="string")return Wf(e,o);throw new Error(Pe(8))}function Wf(e,t){return e==="button"?(0,kr.createElement)("button",{type:"button",...t,key:t.key}):e==="img"?(0,kr.createElement)("img",{alt:"",...t,key:t.key}):Wt.createElement(e,t)}var vn=h(z(),1);var pa=0;function Yf(e,t="mui"){let[o,n]=vn.useState(e),r=e||o;return vn.useEffect(()=>{o==null&&(pa+=1,n(`${t}-${pa}`))},[o,t]),r}var ma=Mo.useId;function Lt(e,t){if(ma!==void 0){let o=ma();return e??(t?`${t}-${o}`:o)}return Yf(e,t)}function ga(e){return Lt(e,"base-ui")}var U={};At(U,{cancelOpen:()=>wp,chipRemovePress:()=>ep,clearPress:()=>$f,closePress:()=>Qf,closeWatcher:()=>up,decrementPress:()=>np,disabled:()=>_p,drag:()=>gp,escapeKey:()=>dp,focusOut:()=>lp,imperativeAction:()=>Rp,incrementPress:()=>op,initial:()=>xp,inputBlur:()=>sp,inputChange:()=>rp,inputClear:()=>ip,inputPaste:()=>ap,inputPress:()=>cp,itemPress:()=>Zf,keyboard:()=>pp,linkPress:()=>Jf,listNavigation:()=>fp,missing:()=>yp,none:()=>Uf,outsidePress:()=>qf,pointer:()=>mp,scrub:()=>hp,siblingOpen:()=>vp,swipe:()=>Sp,trackPress:()=>tp,triggerFocus:()=>Kf,triggerHover:()=>Xf,triggerPress:()=>Gf,wheel:()=>bp,windowResize:()=>Ep});var Uf="none",Gf="trigger-press",Xf="trigger-hover",Kf="trigger-focus",qf="outside-press",Zf="item-press",Qf="close-press",Jf="link-press",$f="clear-press",ep="chip-remove-press",tp="track-press",op="increment-press",np="decrement-press",rp="input-change",ip="input-clear",sp="input-blur",ap="input-paste",cp="input-press",lp="focus-out",dp="escape-key",up="close-watcher",fp="list-navigation",pp="keyboard",mp="pointer",gp="drag",bp="wheel",hp="scrub",wp="cancel-open",vp="sibling-open",_p="disabled",yp="missing",xp="initial",Rp="imperative-action",Sp="swipe",Ep="window-resize";function ee(e,t,o,n){let r=!1,i=!1,s=n??be;return{reason:e,event:t??new Event("base-ui"),cancel(){r=!0},allowPropagation(){i=!0},get isCanceled(){return r},get isPropagationAllowed(){return i},trigger:o,...s}}var Cr=h(z(),1);var ba=h(z(),1),Tp=[];function co(e){ba.useEffect(e,Tp)}var _n=null,ah=globalThis.requestAnimationFrame,Pr=class{callbacks=[];callbacksCount=0;nextId=1;startId=1;isScheduled=!1;tick=t=>{this.isScheduled=!1;let o=this.callbacks,n=this.callbacksCount;if(this.callbacks=[],this.callbacksCount=0,this.startId=this.nextId,n>0)for(let r=0;r=this.callbacks.length||(this.callbacks[o]=null,this.callbacksCount-=1)}},yn=new Pr,ft=class e{static create(){return new e}static request(t){return yn.request(t)}static cancel(t){return yn.cancel(t)}currentId=_n;request(t){this.cancel(),this.currentId=yn.request(()=>{this.currentId=_n,t()})}cancel=()=>{this.currentId!==_n&&(yn.cancel(this.currentId),this.currentId=_n)};disposeEffect=()=>this.cancel};function lo(){let e=Se(ft.create).current;return co(e.disposeEffect),e}function ha(e,t=!1,o=!1){let[n,r]=Cr.useState(e&&t?"idle":void 0),[i,s]=Cr.useState(e);return e&&!i&&(s(!0),r("starting")),!e&&i&&n!=="ending"&&!o&&r("ending"),!e&&!i&&n==="ending"&&r(void 0),D(()=>{if(!e&&i&&n!=="ending"&&o){let a=ft.request(()=>{r("ending")});return()=>{ft.cancel(a)}}},[e,i,n,o]),D(()=>{if(!e||t)return;let a=ft.request(()=>{r(void 0)});return()=>{ft.cancel(a)}},[t,e]),D(()=>{if(!e||!t)return;e&&i&&n!=="idle"&&r("starting");let a=ft.request(()=>{r("idle")});return()=>{ft.cancel(a)}},[t,e,i,n]),{mounted:i,setMounted:s,transitionStatus:n}}var Yt=(function(e){return e.startingStyle="data-starting-style",e.endingStyle="data-ending-style",e})({}),kp={[Yt.startingStyle]:""},Pp={[Yt.endingStyle]:""},wa={transitionStatus(e){return e==="starting"?kp:e==="ending"?Pp:null}};var po=h(z(),1);function xn(){return typeof window<"u"}function Gt(e){return Rn(e)?(e.nodeName||"").toLowerCase():"#document"}function ge(e){var t;return(e==null||(t=e.ownerDocument)==null?void 0:t.defaultView)||window}function ot(e){var t;return(t=(Rn(e)?e.ownerDocument:e.document)||window.document)==null?void 0:t.documentElement}function Rn(e){return xn()?e instanceof Node||e instanceof ge(e).Node:!1}function V(e){return xn()?e instanceof Element||e instanceof ge(e).Element:!1}function we(e){return xn()?e instanceof HTMLElement||e instanceof ge(e).HTMLElement:!1}function uo(e){return!xn()||typeof ShadowRoot>"u"?!1:e instanceof ShadowRoot||e instanceof ge(e).ShadowRoot}function fo(e){let{overflow:t,overflowX:o,overflowY:n,display:r}=Ae(e);return/auto|scroll|overlay|hidden|clip/.test(t+n+o)&&r!=="inline"&&r!=="contents"}function va(e){return/^(table|td|th)$/.test(Gt(e))}function Do(e){try{if(e.matches(":popover-open"))return!0}catch{}try{return e.matches(":modal")}catch{return!1}}var Cp=/transform|translate|scale|rotate|perspective|filter/,Ap=/paint|layout|strict|content/,Ut=e=>!!e&&e!=="none",Ar;function Sn(e){let t=V(e)?Ae(e):e;return Ut(t.transform)||Ut(t.translate)||Ut(t.scale)||Ut(t.rotate)||Ut(t.perspective)||!En()&&(Ut(t.backdropFilter)||Ut(t.filter))||Cp.test(t.willChange||"")||Ap.test(t.contain||"")}function _a(e){let t=tt(e);for(;we(t)&&!nt(t);){if(Sn(t))return t;if(Do(t))return null;t=tt(t)}return null}function En(){return Ar==null&&(Ar=typeof CSS<"u"&&CSS.supports&&CSS.supports("-webkit-backdrop-filter","none")),Ar}function nt(e){return/^(html|body|#document)$/.test(Gt(e))}function Ae(e){return ge(e).getComputedStyle(e)}function jo(e){return V(e)?{scrollLeft:e.scrollLeft,scrollTop:e.scrollTop}:{scrollLeft:e.scrollX,scrollTop:e.scrollY}}function tt(e){if(Gt(e)==="html")return e;let t=e.assignedSlot||e.parentNode||uo(e)&&e.host||ot(e);return uo(t)?t.host:t}function ya(e){let t=tt(e);return nt(t)?e.ownerDocument?e.ownerDocument.body:e.body:we(t)&&fo(t)?t:ya(t)}function It(e,t,o){var n;t===void 0&&(t=[]),o===void 0&&(o=!0);let r=ya(e),i=r===((n=e.ownerDocument)==null?void 0:n.body),s=ge(r);if(i){let a=Tn(s);return t.concat(s,s.visualViewport||[],fo(r)?r:[],a&&o?It(a):[])}else return t.concat(r,It(r,[],o))}function Tn(e){return e.parent&&Object.getPrototypeOf(e.parent)?e.frameElement:null}var kn=h(z(),1),Op=kn.createContext(void 0);function xa(e=!1){let t=kn.useContext(Op);if(t===void 0&&!e)throw new Error(Pe(16));return t}var Ra=h(z(),1);function Sa(e){let{focusableWhenDisabled:t,disabled:o,composite:n=!1,tabIndex:r=0,isNativeButton:i}=e,s=n&&t!==!1,a=n&&t===!1;return{props:Ra.useMemo(()=>{let c={onKeyDown(l){o&&t&&l.key!=="Tab"&&l.preventDefault()}};return n||(c.tabIndex=r,!i&&o&&(c.tabIndex=t?r:-1)),(i&&(t||s)||!i&&o)&&(c["aria-disabled"]=o),i&&(!t||a)&&(c.disabled=o),c},[n,o,t,s,a,i,r])}}function Ea(e={}){let{disabled:t=!1,focusableWhenDisabled:o,tabIndex:n=0,native:r=!0,composite:i}=e,s=po.useRef(null),a=xa(!0),d=i??a!==void 0,{props:c}=Sa({focusableWhenDisabled:o,disabled:t,composite:d,tabIndex:n,isNativeButton:r}),l=po.useCallback(()=>{let m=s.current;Or(m)&&d&&t&&c.disabled===void 0&&m.disabled&&(m.disabled=!1)},[t,c.disabled,d]);D(l,[l]);let f=po.useCallback((m={})=>{let{onClick:u,onMouseDown:g,onKeyUp:v,onKeyDown:_,onPointerDown:w,...y}=m;return ye({onClick(b){if(t){b.preventDefault();return}u?.(b)},onMouseDown(b){t||g?.(b)},onKeyDown(b){if(t||(zo(b),_?.(b),b.baseUIHandlerPrevented))return;let S=b.target===b.currentTarget,x=b.currentTarget,E=Or(x),T=!r&&Np(x),k=S&&(r?E:!T),C=b.key==="Enter",j=b.key===" ",A=x.getAttribute("role"),L=A?.startsWith("menuitem")||A==="option"||A==="gridcell";if(S&&d&&j){if(b.defaultPrevented&&L)return;b.preventDefault(),T||r&&E?(x.click(),b.preventBaseUIHandler()):k&&(u?.(b),b.preventBaseUIHandler());return}k&&(!r&&(j||C)&&b.preventDefault(),!r&&C&&u?.(b))},onKeyUp(b){if(!t){if(zo(b),v?.(b),b.target===b.currentTarget&&r&&d&&Or(b.currentTarget)&&b.key===" "){b.preventDefault();return}b.baseUIHandlerPrevented||b.target===b.currentTarget&&!r&&!d&&b.key===" "&&u?.(b)}},onPointerDown(b){if(t){b.preventDefault();return}w?.(b)}},r?{type:"button"}:{role:"button"},c,y)},[t,c,d,r]),p=Y(m=>{s.current=m,l()});return{getButtonProps:f,buttonRef:p}}function Or(e){return we(e)&&e.tagName==="BUTTON"}function Np(e){return!!(e?.tagName==="A"&&e?.href)}function re(e,t,o,n){return e.addEventListener(t,o,n),()=>{e.removeEventListener(t,o,n)}}function ze(e){let t=Se(Lp,e).current;return t.next=e,D(t.effect),t}function Lp(e){let t={current:e,next:e,effect:()=>{t.current=t.next}};return t}function xe(e){return e?.ownerDocument||document}var Ca=h(z(),1);var Pa=h(Mt(),1);function ka(e){return e==null?e:"current"in e?e.current:e}function mo(e,t=!1,o=!0){let n=lo();return Y((r,i=null)=>{n.cancel();let s=ka(e);if(s==null)return;let a=s,d=()=>{Pa.flushSync(r)};if(typeof a.getAnimations!="function"||globalThis.BASE_UI_ANIMATIONS_DISABLED){r();return}function c(){Promise.all(a.getAnimations().map(l=>l.finished)).then(()=>{i?.aborted||d()}).catch(()=>{if(o){i?.aborted||d();return}let l=a.getAnimations();!i?.aborted&&l.length>0&&l.some(f=>f.pending||f.playState!=="finished")&&c()})}if(t){let l=Yt.startingStyle;if(!a.hasAttribute(l)){n.request(c);return}let f=new MutationObserver(()=>{a.hasAttribute(l)||(f.disconnect(),c())});f.observe(a,{attributes:!0,attributeFilter:[l]}),i?.addEventListener("abort",()=>f.disconnect(),{once:!0});return}n.request(c)})}function Pn(e){let{enabled:t=!0,open:o,ref:n,onComplete:r}=e,i=Y(r),s=mo(n,o,!1);Ca.useEffect(()=>{if(!t)return;let a=new AbortController;return s(i,a.signal),()=>{a.abort()}},[t,o,i,s])}var Aa=h(z(),1);function Oa(e){let t=Aa.useRef(!0);t.current&&(t.current=!1,e())}var xt={};At(xt,{engine:()=>Br,env:()=>zr,os:()=>Ir,screenReader:()=>Hr});var Ir={};At(Ir,{android:()=>Ia,apple:()=>Lr,ios:()=>Nr,linux:()=>zp,mac:()=>Ma,windows:()=>Hp});function Ip(){return typeof navigator>"u"?{userAgent:"",platform:"",maxTouchPoints:0}:{userAgent:navigator.userAgent,platform:navigator.platform??"",maxTouchPoints:navigator.maxTouchPoints??0}}var{userAgent:Mp,platform:Bp,maxTouchPoints:Na}=Ip(),Xt=Mp.toLowerCase(),Kt=Bp.toLowerCase();var Nr=/^i(os$|p)/.test(Kt)||Kt==="macintel"&&Na>1,La="android",Ia=Kt===La||Xt.includes(La),Ma=!Nr&&Kt.startsWith("mac"),Hp=Kt.startsWith("win"),zp=!Ia&&/^(linux|chrome os)/.test(Kt),Lr=Ma||Nr;var Br={};At(Br,{blink:()=>jp,gecko:()=>Dp,webkit:()=>Mr});var Mr=typeof CSS<"u"&&!!CSS.supports?.("-webkit-backdrop-filter:none"),Dp=!Mr&&Xt.includes("firefox"),jp=!Mr&&Xt.includes("chrom");var Hr={};At(Hr,{voiceOver:()=>Fp});var Fp=Lr;var zr={};At(zr,{jsdom:()=>Vp});var Vp=/jsdom|happydom/.test(Xt);var Fo=0,Ye=class e{static create(){return new e}currentId=Fo;start(t,o){this.clear(),this.currentId=setTimeout(()=>{this.currentId=Fo,o()},t)}isStarted(){return this.currentId!==Fo}clear=()=>{this.currentId!==Fo&&(clearTimeout(this.currentId),this.currentId=Fo)};disposeEffect=()=>this.clear};function rt(){let e=Se(Ye.create).current;return co(e.disposeEffect),e}var Oe=h(z(),1);function Ba(e){return"nativeEvent"in e}function Rt(e,t){let o=["mouse","pen"];return t||o.push("",void 0),o.includes(e)}function Ha(e){let t=e.type;return t==="click"||t==="mousedown"||t==="keydown"||t==="keyup"}var Dr="data-base-ui-focusable";var jr="input:not([type='hidden']):not([disabled]),[contenteditable]:not([contenteditable='false']),textarea:not([disabled])";function Cn(e){let t=e.activeElement;for(;t?.shadowRoot?.activeElement!=null;)t=t.shadowRoot.activeElement;return t}function ie(e,t){if(!e||!t)return!1;let o=t.getRootNode?.();if(e.contains(t))return!0;if(o&&uo(o)){let n=t;for(;n;){if(e===n)return!0;n=n.parentNode||n.host}}return!1}function Me(e){return"composedPath"in e?e.composedPath()[0]:e.target}function Bt(e,t){if(!V(e))return!1;let o=e;if(t.hasElement(o))return!o.hasAttribute("data-trigger-disabled");for(let[,n]of t.entries())if(ie(n,o))return!n.hasAttribute("data-trigger-disabled");return!1}function An(e,t){if(t==null)return!1;if("composedPath"in e)return e.composedPath().includes(t);let o=e;return o.target!=null&&t.contains(o.target)}function za(e){return e.matches("html,body")}function Da(e){return we(e)&&e.matches(jr)}function Fr(e){return e?.closest(`button,a[href],[role="button"],select,[tabindex]:not([tabindex="-1"]),${jr}`)!=null}function ja(e){if(!e||xt.env.jsdom)return!0;try{return e.matches(":focus-visible")}catch{return!0}}function Wp(e,t){return t!=null&&!Rt(t)?0:typeof e=="function"?e():e}function St(e,t,o){let n=Wp(e,o);return typeof n=="number"?n:n?.[t]}function Vr(e){return typeof e=="function"?e():e}function On(e,t){return t||e==="click"||e==="mousedown"}function Fa(e){return e?.includes("mouse")&&e!=="mousedown"}var Va=h(Q(),1),Wa=Oe.createContext({hasProvider:!1,timeoutMs:0,delayRef:{current:0},initialDelayRef:{current:0},timeout:new Ye,currentIdRef:{current:null},currentContextRef:{current:null}});function Yp(e,t){e.current=t.current}function Wr(e){let{children:t,delay:o,timeoutMs:n=0}=e,r=Oe.useRef(o),i=Oe.useRef(o),s=Oe.useRef(null),a=Oe.useRef(null),d=rt();return D(()=>{if(i.current=o,!s.current){r.current=o;return}r.current={open:St(r.current,"open"),close:St(o,"close")}},[o,s,r,i]),(0,Va.jsx)(Wa.Provider,{value:Oe.useMemo(()=>({hasProvider:!0,delayRef:r,initialDelayRef:i,currentIdRef:s,timeoutMs:n,currentContextRef:a,timeout:d}),[n,d]),children:t})}function Yr(e,t={open:!1}){let{open:o}=t,n="rootStore"in e?e.rootStore:e,r=n.useState("floatingId"),i=Oe.useContext(Wa),{currentIdRef:s,delayRef:a,timeoutMs:d,initialDelayRef:c,currentContextRef:l,hasProvider:f,timeout:p}=i,[m,u]=Oe.useState(!1),g=Oe.useRef(o),v=Oe.useRef(!1);return D(()=>{g.current=o},[o]),D(()=>()=>{v.current=!0},[]),D(()=>{function _(){v.current||u(!1),l.current?.setIsInstantPhase(!1),s.current=null,l.current=null,a.current=c.current,p.clear()}if(s.current&&!o&&s.current===r){if(u(!1),d){let w=r;return p.start(d,()=>{n.select("open")||s.current&&s.current!==w||_()}),()=>{(g.current||s.current!==w)&&p.clear()}}_()}},[o,r,s,a,d,c,l,p,n]),D(()=>{if(!o)return;let _=l.current,w=s.current;p.clear(),l.current={onOpenChange:n.setOpen,setIsInstantPhase:u},s.current=r,a.current={open:0,close:St(c.current,"close")},w!==null&&w!==r?(u(!0),_?.setIsInstantPhase(!0),_?.onOpenChange(!1,ee(U.none))):(u(!1),_?.setIsInstantPhase(!1))},[o,r,n,s,a,c,l,p]),D(()=>()=>{if(s.current===r){if(l.current=null,!g.current)return;s.current=null,Yp(a,c),p.clear()}},[l,s,a,r,c,p]),Oe.useMemo(()=>({hasProvider:f,delayRef:a,isInstantPhase:m}),[f,a,m])}function it(...e){return()=>{for(let t=0;t({x:e,y:e}),Up={left:"right",right:"left",bottom:"top",top:"bottom"};function Yo(e,t,o){return Be(e,Ht(t,o))}function at(e,t){return typeof e=="function"?e(t):e}function Ee(e){return e.split("-")[0]}function ct(e){return e.split("-")[1]}function Ln(e){return e==="x"?"y":"x"}function Uo(e){return e==="y"?"height":"width"}function De(e){let t=e[0];return t==="t"||t==="b"?"y":"x"}function Go(e){return Ln(De(e))}function Xa(e,t,o){o===void 0&&(o=!1);let n=ct(e),r=Go(e),i=Uo(r),s=r==="x"?n===(o?"end":"start")?"right":"left":n==="start"?"bottom":"top";return t.reference[i]>t.floating[i]&&(s=Vo(s)),[s,Vo(s)]}function Ka(e){let t=Vo(e);return[Nn(e),t,Nn(t)]}function Nn(e){return e.includes("start")?e.replace("start","end"):e.replace("end","start")}var Ya=["left","right"],Ua=["right","left"],Gp=["top","bottom"],Xp=["bottom","top"];function Kp(e,t,o){switch(e){case"top":case"bottom":return o?t?Ua:Ya:t?Ya:Ua;case"left":case"right":return t?Gp:Xp;default:return[]}}function qa(e,t,o,n){let r=ct(e),i=Kp(Ee(e),o==="start",n);return r&&(i=i.map(s=>s+"-"+r),t&&(i=i.concat(i.map(Nn)))),i}function Vo(e){let t=Ee(e);return Up[t]+e.slice(t.length)}function qp(e){return{top:0,right:0,bottom:0,left:0,...e}}function In(e){return typeof e!="number"?qp(e):{top:e,right:e,bottom:e,left:e}}function qt(e){let{x:t,y:o,width:n,height:r}=e;return{width:n,height:r,top:o,left:t,right:t+n,bottom:o+r,x:t,y:o}}function Et(e,t,o=!0){return e.filter(r=>r.parentId===t).flatMap(r=>[...!o||r.context?.open?[r]:[],...Et(e,r.id,o)])}function go(e){return`data-base-ui-${e}`}var Ue=h(z(),1),Ja=h(Mt(),1);var Za={style:{transition:"none"}};var Zp="data-base-ui-swipe-ignore",Qp="data-swipe-ignore",ww=`[${Zp}]`,vw=`[${Qp}]`;var Qa={fallbackAxisSide:"end"};var $a=h(Q(),1),Jp=Ue.createContext(null),$p=()=>Ue.useContext(Jp),em=go("portal");function Ur(e={}){let{ref:t,container:o,componentProps:n=be,elementProps:r}=e,i=Lt(),a=$p()?.portalNode,[d,c]=Ue.useState(null),[l,f]=Ue.useState(null),p=Y(v=>{v!==null&&f(v)}),m=Ue.useRef(null);D(()=>{if(o===null){m.current&&(m.current=null,f(null),c(null));return}if(i==null)return;let v=(o&&(Rn(o)?o:o.current))??a??document.body;if(v==null){m.current&&(m.current=null,f(null),c(null));return}m.current!==v&&(m.current=v,f(null),c(v))},[o,a,i]);let u=Ce("div",n,{ref:[t,p],props:[{id:i,[em]:""},r]});return{portalNode:l,portalSubtree:d&&u?Ja.createPortal(u,d):null}}var Zt=h(z(),1);function ec(){let e=new Map;return{emit(t,o){e.get(t)?.forEach(n=>n(o))},on(t,o){e.has(t)||e.set(t,new Set),e.get(t).add(o)},off(t,o){e.get(t)?.delete(o)}}}var tm=h(Q(),1),om=Zt.createContext(null),nm=Zt.createContext(null),bo=()=>Zt.useContext(om)?.id||null,Dt=e=>{let t=Zt.useContext(nm);return e??t};var je=h(z(),1);function rm(e,t){let o=null,n=null,r=!1;return{contextElement:e||void 0,getBoundingClientRect(){let i=e?.getBoundingClientRect()||{width:0,height:0,x:0,y:0},s=t.axis==="x"||t.axis==="both",a=t.axis==="y"||t.axis==="both",d=["mouseenter","mousemove"].includes(t.dataRef.current.openEvent?.type||"")&&t.pointerType!=="touch",c=i.width,l=i.height,f=i.x,p=i.y;return o==null&&t.x&&s&&(o=i.x-t.x),n==null&&t.y&&a&&(n=i.y-t.y),f-=o||0,p-=n||0,c=0,l=0,!r||d?(c=t.axis==="y"?i.width:0,l=t.axis==="x"?i.height:0,f=s&&t.x!=null?t.x:f,p=a&&t.y!=null?t.y:p):r&&!d&&(l=t.axis==="x"?i.height:l,c=t.axis==="y"?i.width:c),r=!0,{width:c,height:l,x:f,y:p,top:p,right:f+c,bottom:p+l,left:f}}}}function tc(e){return e!=null&&e.clientX!=null}function Gr(e,t={}){let{enabled:o=!0,axis:n="both"}=t,r="rootStore"in e?e.rootStore:e,i=r.useState("open"),s=r.useState("floatingElement"),a=r.useState("domReferenceElement"),d=r.context.dataRef,c=je.useRef(!1),l=je.useRef(null),[f,p]=je.useState(),[m,u]=je.useState([]),g=Y(b=>{r.set("positionReference",b)}),v=Y((b,S,x)=>{c.current||d.current.openEvent&&!tc(d.current.openEvent)||r.set("positionReference",rm(x??a,{x:b,y:S,axis:n,dataRef:d,pointerType:f}))}),_=Y(b=>{i?l.current||(v(b.clientX,b.clientY,b.currentTarget),u([])):v(b.clientX,b.clientY,b.currentTarget)}),w=Rt(f)?s:i;je.useEffect(()=>{if(!o){g(a);return}if(!w)return;function b(){l.current?.(),l.current=null}let S=ge(s);function x(E){let T=Me(E);ie(s,T)?b():v(E.clientX,E.clientY)}return!d.current.openEvent||tc(d.current.openEvent)?l.current=re(S,"mousemove",x):g(a),b},[w,o,s,d,a,r,v,g,m]),je.useEffect(()=>()=>{r.set("positionReference",null)},[r]),je.useEffect(()=>{o&&!s&&(c.current=!1)},[o,s]),je.useEffect(()=>{!o&&i&&(c.current=!0)},[o,i]);let y=je.useMemo(()=>{function b(S){p(S.pointerType)}return{onPointerDown:b,onPointerEnter:b,onMouseMove:_,onMouseEnter:_}},[_]);return je.useMemo(()=>o?{reference:y,trigger:y}:{},[o,y])}var Fe=h(z(),1);function im(){return!1}function sm(e){return{escapeKey:typeof e=="boolean"?e:e?.escapeKey??!1,outsidePress:typeof e=="boolean"?e:e?.outsidePress??!0}}function Xr(e,t={}){let{enabled:o=!0,escapeKey:n=!0,outsidePress:r=!0,outsidePressEvent:i="sloppy",referencePress:s=im,bubbles:a,externalTree:d}=t,c="rootStore"in e?e.rootStore:e,l=c.useState("open"),f=c.useState("floatingElement"),{dataRef:p}=c.context,m=Dt(d),u=Y(typeof r=="function"?r:()=>!1),g=typeof r=="function"?u:r,v=g!==!1,_=Y(()=>i),{escapeKey:w,outsidePress:y}=sm(a),b=Fe.useRef(!1),S=Fe.useRef(!1),x=Fe.useRef(!1),E=Fe.useRef(!1),T=Fe.useRef(""),k=Fe.useRef(null),C=rt(),j=rt(),A=Y(()=>{j.clear(),p.current.insideReactTree=!1}),L=Y(W=>{let oe=p.current.floatingContext?.nodeId;return(m?Et(m.nodesRef.current,oe):[]).some(se=>se.context?.open&&!se.context.dataRef.current[W])}),I=Y(W=>An(W,c.select("floatingElement"))||An(W,c.select("domReferenceElement"))),R=Y(W=>{s()&&c.setOpen(!1,ee(U.triggerPress,W.nativeEvent))}),N=Y(W=>{if(!l||!o||!n||W.key!=="Escape"||E.current||!w&&L("__escapeKeyBubbles"))return;let oe=Ba(W)?W.nativeEvent:W,te=ee(U.escapeKey,oe);c.setOpen(!1,te),te.isCanceled||W.preventDefault(),!w&&!te.isPropagationAllowed&&W.stopPropagation()}),H=Y(()=>{p.current.insideReactTree=!0,j.start(0,A)}),P=Y(W=>{if(!l||!o||W.button!==0)return;let oe=Me(W.nativeEvent);ie(c.select("floatingElement"),oe)&&(b.current||(b.current=!0,S.current=!1))}),O=Y(W=>{!l||!o||(W.defaultPrevented||W.nativeEvent.defaultPrevented)&&b.current&&(S.current=!0)});Fe.useEffect(()=>{if(!l||!o)return;p.current.__escapeKeyBubbles=w,p.current.__outsidePressBubbles=y;let W=new Ye,oe=new Ye;function te(){W.clear(),E.current=!0}function se(){W.start(xt.engine.webkit?5:0,()=>{E.current=!1})}function G(){x.current=!0,oe.start(0,()=>{x.current=!1})}function K(){b.current=!1,S.current=!1}function J(){let B=T.current,F=B==="pen"||!B?"mouse":B,he=_(),ke=typeof he=="function"?he():he;return typeof ke=="string"?ke:ke[F]}function ne(B){let F=J();return F==="intentional"&&B.type!=="click"||F==="sloppy"&&B.type==="click"}function me(B){let F=p.current.floatingContext?.nodeId,he=m&&Et(m.nodesRef.current,F).some(ke=>An(B,ke.context?.elements.floating));return I(B)||he}function le(B){if(ne(B)){B.type!=="click"&&!I(B)&&(oe.clear(),x.current=!1),A();return}if(p.current.insideReactTree){A();return}let F=Me(B),he=`[${go("inert")}]`,ke=V(F)?F.getRootNode():null,kt=Array.from((uo(ke)?ke:xe(c.select("floatingElement"))).querySelectorAll(he)),Lo=c.context.triggerElements;if(F&&(Lo.hasElement(F)||Lo.hasMatchingElement(We=>ie(We,F))))return;let _t=V(F)?F:null;for(;_t&&!nt(_t);){let We=tt(_t);if(nt(We)||!V(We))break;_t=We}if(!(kt.length&&V(F)&&!za(F)&&!ie(F,c.select("floatingElement"))&&kt.every(We=>!ie(_t,We)))){if(we(F)&&!("touches"in B)){let We=nt(F),Pt=Ae(F),Ct=/auto|scroll/,un=We||Ct.test(Pt.overflowX),fn=We||Ct.test(Pt.overflowY),pn=un&&F.clientWidth>0&&F.scrollWidth>F.clientWidth,mn=fn&&F.clientHeight>0&&F.scrollHeight>F.clientHeight,gn=Pt.direction==="rtl",ae=mn&&(gn?B.offsetX<=F.offsetWidth-F.clientWidth:B.offsetX>F.clientWidth),Ie=pn&&B.offsetY>F.clientHeight;if(ae||Ie)return}if(!me(B)){if(J()==="intentional"&&x.current){oe.clear(),x.current=!1;return}typeof g=="function"&&!g(B)||L("__outsidePressBubbles")||(c.setOpen(!1,ee(U.outsidePress,B)),A())}}}function X(B){J()!=="sloppy"||B.pointerType==="touch"||!c.select("open")||!o||I(B)||le(B)}function pe(B){if(J()!=="sloppy"||!c.select("open")||!o||I(B))return;let F=B.touches[0];F&&(k.current={startTime:Date.now(),startX:F.clientX,startY:F.clientY,dismissOnTouchEnd:!1,dismissOnMouseDown:!0},C.start(1e3,()=>{k.current&&(k.current.dismissOnTouchEnd=!1,k.current.dismissOnMouseDown=!1)}))}function ue(B,F){let he=Me(B);if(!he)return;let ke=re(he,B.type,()=>{F(B),ke()})}function vt(B){T.current="touch",ue(B,pe)}function Te(B){C.clear(),B.type==="pointerdown"&&(T.current=B.pointerType),!(B.type==="mousedown"&&k.current&&!k.current.dismissOnMouseDown)&&ue(B,F=>{F.type==="pointerdown"?X(F):le(F)})}function Ve(B){if(!b.current)return;let F=S.current;if(K(),J()==="intentional"){if(B.type==="pointercancel"){F&&G();return}if(!me(B)){if(F){G();return}typeof g=="function"&&!g(B)||(oe.clear(),x.current=!0,A())}}}function Ke(B){if(J()!=="sloppy"||!k.current||I(B))return;let F=B.touches[0];if(!F)return;let he=Math.abs(F.clientX-k.current.startX),ke=Math.abs(F.clientY-k.current.startY),kt=Math.sqrt(he*he+ke*ke);kt>5&&(k.current.dismissOnTouchEnd=!0),kt>10&&(le(B),C.clear(),k.current=null)}function He(B){ue(B,Ke)}function no(B){J()!=="sloppy"||!k.current||I(B)||(k.current.dismissOnTouchEnd&&le(B),C.clear(),k.current=null)}function dn(B){ue(B,no)}let _e=xe(f),ro=it(n&&it(re(_e,"keydown",N),re(_e,"compositionstart",te),re(_e,"compositionend",se)),v&&it(re(_e,"click",Te,!0),re(_e,"pointerdown",Te,!0),re(_e,"pointerup",Ve,!0),re(_e,"pointercancel",Ve,!0),re(_e,"mousedown",Te,!0),re(_e,"mouseup",Ve,!0),re(_e,"touchstart",vt,!0),re(_e,"touchmove",He,!0),re(_e,"touchend",dn,!0)));return()=>{ro(),W.clear(),oe.clear(),K(),x.current=!1}},[p,f,n,v,g,l,o,w,y,N,A,_,L,I,m,c,C]),Fe.useEffect(A,[g,A]);let M=Fe.useMemo(()=>({onKeyDown:N,onPointerDown:R,onClick:R}),[N,R]),Z=Fe.useMemo(()=>({onKeyDown:N,onPointerDown:O,onMouseDown:O,onClickCapture:H,onMouseDownCapture(W){H(),P(W)},onPointerDownCapture(W){H(),P(W)},onMouseUpCapture:H,onTouchEndCapture:H,onTouchMoveCapture:H}),[N,H,P,O]);return Fe.useMemo(()=>o?{reference:M,floating:Z,trigger:M}:{},[o,M,Z])}var Ne=h(z(),1);function oc(e,t,o){let{reference:n,floating:r}=e,i=De(t),s=Go(t),a=Uo(s),d=Ee(t),c=i==="y",l=n.x+n.width/2-r.width/2,f=n.y+n.height/2-r.height/2,p=n[a]/2-r[a]/2,m;switch(d){case"top":m={x:l,y:n.y-r.height};break;case"bottom":m={x:l,y:n.y+n.height};break;case"right":m={x:n.x+n.width,y:f};break;case"left":m={x:n.x-r.width,y:f};break;default:m={x:n.x,y:n.y}}switch(ct(t)){case"start":m[s]-=p*(o&&c?-1:1);break;case"end":m[s]+=p*(o&&c?-1:1);break}return m}async function ic(e,t){var o;t===void 0&&(t={});let{x:n,y:r,platform:i,rects:s,elements:a,strategy:d}=e,{boundary:c="clippingAncestors",rootBoundary:l="viewport",elementContext:f="floating",altBoundary:p=!1,padding:m=0}=at(t,e),u=In(m),v=a[p?f==="floating"?"reference":"floating":f],_=qt(await i.getClippingRect({element:(o=await(i.isElement==null?void 0:i.isElement(v)))==null||o?v:v.contextElement||await(i.getDocumentElement==null?void 0:i.getDocumentElement(a.floating)),boundary:c,rootBoundary:l,strategy:d})),w=f==="floating"?{x:n,y:r,width:s.floating.width,height:s.floating.height}:s.reference,y=await(i.getOffsetParent==null?void 0:i.getOffsetParent(a.floating)),b=await(i.isElement==null?void 0:i.isElement(y))?await(i.getScale==null?void 0:i.getScale(y))||{x:1,y:1}:{x:1,y:1},S=qt(i.convertOffsetParentRelativeRectToViewportRelativeRect?await i.convertOffsetParentRelativeRectToViewportRelativeRect({elements:a,rect:w,offsetParent:y,strategy:d}):w);return{top:(_.top-S.top+u.top)/b.y,bottom:(S.bottom-_.bottom+u.bottom)/b.y,left:(_.left-S.left+u.left)/b.x,right:(S.right-_.right+u.right)/b.x}}var am=50,sc=async(e,t,o)=>{let{placement:n="bottom",strategy:r="absolute",middleware:i=[],platform:s}=o,a=s.detectOverflow?s:{...s,detectOverflow:ic},d=await(s.isRTL==null?void 0:s.isRTL(t)),c=await s.getElementRects({reference:e,floating:t,strategy:r}),{x:l,y:f}=oc(c,n,d),p=n,m=0,u={};for(let g=0;gI<=0)){var j,A;let I=(((j=i.flip)==null?void 0:j.index)||0)+1,R=E[I];if(R&&(!(f==="alignment"?w!==De(R):!1)||C.every(P=>De(P.placement)===w?P.overflows[0]>0:!0)))return{data:{index:I,overflows:C},reset:{placement:R}};let N=(A=C.filter(H=>H.overflows[0]<=0).sort((H,P)=>H.overflows[1]-P.overflows[1])[0])==null?void 0:A.placement;if(!N)switch(m){case"bestFit":{var L;let H=(L=C.filter(P=>{if(x){let O=De(P.placement);return O===w||O==="y"}return!0}).map(P=>[P.placement,P.overflows.filter(O=>O>0).reduce((O,M)=>O+M,0)]).sort((P,O)=>P[1]-O[1])[0])==null?void 0:L[0];H&&(N=H);break}case"initialPlacement":N=a;break}if(r!==N)return{reset:{placement:N}}}return{}}}};function nc(e,t){return{top:e.top-t.height,right:e.right-t.width,bottom:e.bottom-t.height,left:e.left-t.width}}function rc(e){return Ga.some(t=>e[t]>=0)}var cc=function(e){return e===void 0&&(e={}),{name:"hide",options:e,async fn(t){let{rects:o,platform:n}=t,{strategy:r="referenceHidden",...i}=at(e,t);switch(r){case"referenceHidden":{let s=await n.detectOverflow(t,{...i,elementContext:"reference"}),a=nc(s,o.reference);return{data:{referenceHiddenOffsets:a,referenceHidden:rc(a)}}}case"escaped":{let s=await n.detectOverflow(t,{...i,altBoundary:!0}),a=nc(s,o.floating);return{data:{escapedOffsets:a,escaped:rc(a)}}}default:return{}}}}};var lc=new Set(["left","top"]);async function cm(e,t){let{placement:o,platform:n,elements:r}=e,i=await(n.isRTL==null?void 0:n.isRTL(r.floating)),s=Ee(o),a=ct(o),d=De(o)==="y",c=lc.has(s)?-1:1,l=i&&d?-1:1,f=at(t,e),{mainAxis:p,crossAxis:m,alignmentAxis:u}=typeof f=="number"?{mainAxis:f,crossAxis:0,alignmentAxis:null}:{mainAxis:f.mainAxis||0,crossAxis:f.crossAxis||0,alignmentAxis:f.alignmentAxis};return a&&typeof u=="number"&&(m=a==="end"?u*-1:u),d?{x:m*l,y:p*c}:{x:p*c,y:m*l}}var dc=function(e){return e===void 0&&(e=0),{name:"offset",options:e,async fn(t){var o,n;let{x:r,y:i,placement:s,middlewareData:a}=t,d=await cm(t,e);return s===((o=a.offset)==null?void 0:o.placement)&&(n=a.arrow)!=null&&n.alignmentOffset?{}:{x:r+d.x,y:i+d.y,data:{...d,placement:s}}}}},uc=function(e){return e===void 0&&(e={}),{name:"shift",options:e,async fn(t){let{x:o,y:n,placement:r,platform:i}=t,{mainAxis:s=!0,crossAxis:a=!1,limiter:d={fn:_=>{let{x:w,y}=_;return{x:w,y}}},...c}=at(e,t),l={x:o,y:n},f=await i.detectOverflow(t,c),p=De(Ee(r)),m=Ln(p),u=l[m],g=l[p];if(s){let _=m==="y"?"top":"left",w=m==="y"?"bottom":"right",y=u+f[_],b=u-f[w];u=Yo(y,u,b)}if(a){let _=p==="y"?"top":"left",w=p==="y"?"bottom":"right",y=g+f[_],b=g-f[w];g=Yo(y,g,b)}let v=d.fn({...t,[m]:u,[p]:g});return{...v,data:{x:v.x-o,y:v.y-n,enabled:{[m]:s,[p]:a}}}}}},fc=function(e){return e===void 0&&(e={}),{options:e,fn(t){let{x:o,y:n,placement:r,rects:i,middlewareData:s}=t,{offset:a=0,mainAxis:d=!0,crossAxis:c=!0}=at(e,t),l={x:o,y:n},f=De(r),p=Ln(f),m=l[p],u=l[f],g=at(a,t),v=typeof g=="number"?{mainAxis:g,crossAxis:0}:{mainAxis:0,crossAxis:0,...g};if(d){let y=p==="y"?"height":"width",b=i.reference[p]-i.floating[y]+v.mainAxis,S=i.reference[p]+i.reference[y]-v.mainAxis;mS&&(m=S)}if(c){var _,w;let y=p==="y"?"width":"height",b=lc.has(Ee(r)),S=i.reference[f]-i.floating[y]+(b&&((_=s.offset)==null?void 0:_[f])||0)+(b?0:v.crossAxis),x=i.reference[f]+i.reference[y]+(b?0:((w=s.offset)==null?void 0:w[f])||0)-(b?v.crossAxis:0);ux&&(u=x)}return{[p]:m,[f]:u}}}},pc=function(e){return e===void 0&&(e={}),{name:"size",options:e,async fn(t){var o,n;let{placement:r,rects:i,platform:s,elements:a}=t,{apply:d=()=>{},...c}=at(e,t),l=await s.detectOverflow(t,c),f=Ee(r),p=ct(r),m=De(r)==="y",{width:u,height:g}=i.floating,v,_;f==="top"||f==="bottom"?(v=f,_=p===(await(s.isRTL==null?void 0:s.isRTL(a.floating))?"start":"end")?"left":"right"):(_=f,v=p==="end"?"top":"bottom");let w=g-l.top-l.bottom,y=u-l.left-l.right,b=Ht(g-l[v],w),S=Ht(u-l[_],y),x=!t.middlewareData.shift,E=b,T=S;if((o=t.middlewareData.shift)!=null&&o.enabled.x&&(T=y),(n=t.middlewareData.shift)!=null&&n.enabled.y&&(E=w),x&&!p){let C=Be(l.left,0),j=Be(l.right,0),A=Be(l.top,0),L=Be(l.bottom,0);m?T=u-2*(C!==0||j!==0?C+j:Be(l.left,l.right)):E=g-2*(A!==0||L!==0?A+L:Be(l.top,l.bottom))}await d({...t,availableWidth:T,availableHeight:E});let k=await s.getDimensions(a.floating);return u!==k.width||g!==k.height?{reset:{rects:!0}}:{}}}};function hc(e){let t=Ae(e),o=parseFloat(t.width)||0,n=parseFloat(t.height)||0,r=we(e),i=r?e.offsetWidth:o,s=r?e.offsetHeight:n,a=zt(o)!==i||zt(n)!==s;return a&&(o=i,n=s),{width:o,height:n,$:a}}function qr(e){return V(e)?e:e.contextElement}function ho(e){let t=qr(e);if(!we(t))return st(1);let o=t.getBoundingClientRect(),{width:n,height:r,$:i}=hc(t),s=(i?zt(o.width):o.width)/n,a=(i?zt(o.height):o.height)/r;return(!s||!Number.isFinite(s))&&(s=1),(!a||!Number.isFinite(a))&&(a=1),{x:s,y:a}}var lm=st(0);function wc(e){let t=ge(e);return!En()||!t.visualViewport?lm:{x:t.visualViewport.offsetLeft,y:t.visualViewport.offsetTop}}function dm(e,t,o){return t===void 0&&(t=!1),!o||t&&o!==ge(e)?!1:t}function Qt(e,t,o,n){t===void 0&&(t=!1),o===void 0&&(o=!1);let r=e.getBoundingClientRect(),i=qr(e),s=st(1);t&&(n?V(n)&&(s=ho(n)):s=ho(e));let a=dm(i,o,n)?wc(i):st(0),d=(r.left+a.x)/s.x,c=(r.top+a.y)/s.y,l=r.width/s.x,f=r.height/s.y;if(i){let p=ge(i),m=n&&V(n)?ge(n):n,u=p,g=Tn(u);for(;g&&n&&m!==u;){let v=ho(g),_=g.getBoundingClientRect(),w=Ae(g),y=_.left+(g.clientLeft+parseFloat(w.paddingLeft))*v.x,b=_.top+(g.clientTop+parseFloat(w.paddingTop))*v.y;d*=v.x,c*=v.y,l*=v.x,f*=v.y,d+=y,c+=b,u=ge(g),g=Tn(u)}}return qt({width:l,height:f,x:d,y:c})}function Mn(e,t){let o=jo(e).scrollLeft;return t?t.left+o:Qt(ot(e)).left+o}function vc(e,t){let o=e.getBoundingClientRect(),n=o.left+t.scrollLeft-Mn(e,o),r=o.top+t.scrollTop;return{x:n,y:r}}function um(e){let{elements:t,rect:o,offsetParent:n,strategy:r}=e,i=r==="fixed",s=ot(n),a=t?Do(t.floating):!1;if(n===s||a&&i)return o;let d={scrollLeft:0,scrollTop:0},c=st(1),l=st(0),f=we(n);if((f||!f&&!i)&&((Gt(n)!=="body"||fo(s))&&(d=jo(n)),f)){let m=Qt(n);c=ho(n),l.x=m.x+n.clientLeft,l.y=m.y+n.clientTop}let p=s&&!f&&!i?vc(s,d):st(0);return{width:o.width*c.x,height:o.height*c.y,x:o.x*c.x-d.scrollLeft*c.x+l.x+p.x,y:o.y*c.y-d.scrollTop*c.y+l.y+p.y}}function fm(e){return Array.from(e.getClientRects())}function pm(e){let t=ot(e),o=jo(e),n=e.ownerDocument.body,r=Be(t.scrollWidth,t.clientWidth,n.scrollWidth,n.clientWidth),i=Be(t.scrollHeight,t.clientHeight,n.scrollHeight,n.clientHeight),s=-o.scrollLeft+Mn(e),a=-o.scrollTop;return Ae(n).direction==="rtl"&&(s+=Be(t.clientWidth,n.clientWidth)-r),{width:r,height:i,x:s,y:a}}var mc=25;function mm(e,t){let o=ge(e),n=ot(e),r=o.visualViewport,i=n.clientWidth,s=n.clientHeight,a=0,d=0;if(r){i=r.width,s=r.height;let l=En();(!l||l&&t==="fixed")&&(a=r.offsetLeft,d=r.offsetTop)}let c=Mn(n);if(c<=0){let l=n.ownerDocument,f=l.body,p=getComputedStyle(f),m=l.compatMode==="CSS1Compat"&&parseFloat(p.marginLeft)+parseFloat(p.marginRight)||0,u=Math.abs(n.clientWidth-f.clientWidth-m);u<=mc&&(i-=u)}else c<=mc&&(i+=c);return{width:i,height:s,x:a,y:d}}function gm(e,t){let o=Qt(e,!0,t==="fixed"),n=o.top+e.clientTop,r=o.left+e.clientLeft,i=we(e)?ho(e):st(1),s=e.clientWidth*i.x,a=e.clientHeight*i.y,d=r*i.x,c=n*i.y;return{width:s,height:a,x:d,y:c}}function gc(e,t,o){let n;if(t==="viewport")n=mm(e,o);else if(t==="document")n=pm(ot(e));else if(V(t))n=gm(t,o);else{let r=wc(e);n={x:t.x-r.x,y:t.y-r.y,width:t.width,height:t.height}}return qt(n)}function _c(e,t){let o=tt(e);return o===t||!V(o)||nt(o)?!1:Ae(o).position==="fixed"||_c(o,t)}function bm(e,t){let o=t.get(e);if(o)return o;let n=It(e,[],!1).filter(a=>V(a)&&Gt(a)!=="body"),r=null,i=Ae(e).position==="fixed",s=i?tt(e):e;for(;V(s)&&!nt(s);){let a=Ae(s),d=Sn(s);!d&&a.position==="fixed"&&(r=null),(i?!d&&!r:!d&&a.position==="static"&&!!r&&(r.position==="absolute"||r.position==="fixed")||fo(s)&&!d&&_c(e,s))?n=n.filter(l=>l!==s):r=a,s=tt(s)}return t.set(e,n),n}function hm(e){let{element:t,boundary:o,rootBoundary:n,strategy:r}=e,s=[...o==="clippingAncestors"?Do(t)?[]:bm(t,this._c):[].concat(o),n],a=gc(t,s[0],r),d=a.top,c=a.right,l=a.bottom,f=a.left;for(let p=1;p{s(!1,1e-7)},1e3)}E===1&&!xc(c,e.getBoundingClientRect())&&s(),b=!1}try{o=new IntersectionObserver(S,{...y,root:r.ownerDocument})}catch{o=new IntersectionObserver(S,y)}o.observe(e)}return s(!0),i}function Xo(e,t,o,n){n===void 0&&(n={});let{ancestorScroll:r=!0,ancestorResize:i=!0,elementResize:s=typeof ResizeObserver=="function",layoutShift:a=typeof IntersectionObserver=="function",animationFrame:d=!1}=n,c=qr(e),l=r||i?[...c?It(c):[],...t?It(t):[]]:[];l.forEach(_=>{r&&_.addEventListener("scroll",o,{passive:!0}),i&&_.addEventListener("resize",o)});let f=c&&a?xm(c,o):null,p=-1,m=null;s&&(m=new ResizeObserver(_=>{let[w]=_;w&&w.target===c&&m&&t&&(m.unobserve(t),cancelAnimationFrame(p),p=requestAnimationFrame(()=>{var y;(y=m)==null||y.observe(t)})),o()}),c&&!d&&m.observe(c),t&&m.observe(t));let u,g=d?Qt(e):null;d&&v();function v(){let _=Qt(e);g&&!xc(g,_)&&o(),g=_,u=requestAnimationFrame(v)}return o(),()=>{var _;l.forEach(w=>{r&&w.removeEventListener("scroll",o),i&&w.removeEventListener("resize",o)}),f?.(),(_=m)==null||_.disconnect(),m=null,d&&cancelAnimationFrame(u)}}var Rc=dc;var Sc=uc,Ec=ac,Tc=pc,kc=cc;var Pc=fc,Bn=(e,t,o)=>{let n=new Map,r={platform:Zr,...o},i={...r.platform,_c:n};return sc(e,t,{...r,platform:i})};var ve=h(z(),1),Ac=h(z(),1),Oc=h(Mt(),1),Sm=typeof document<"u",Em=function(){},Hn=Sm?Ac.useLayoutEffect:Em;function zn(e,t){if(e===t)return!0;if(typeof e!=typeof t)return!1;if(typeof e=="function"&&e.toString()===t.toString())return!0;let o,n,r;if(e&&t&&typeof e=="object"){if(Array.isArray(e)){if(o=e.length,o!==t.length)return!1;for(n=o;n--!==0;)if(!zn(e[n],t[n]))return!1;return!0}if(r=Object.keys(e),o=r.length,o!==Object.keys(t).length)return!1;for(n=o;n--!==0;)if(!{}.hasOwnProperty.call(t,r[n]))return!1;for(n=o;n--!==0;){let i=r[n];if(!(i==="_owner"&&e.$$typeof)&&!zn(e[i],t[i]))return!1}return!0}return e!==e&&t!==t}function Nc(e){return typeof window>"u"?1:(e.ownerDocument.defaultView||window).devicePixelRatio||1}function Cc(e,t){let o=Nc(e);return Math.round(t*o)/o}function Qr(e){let t=ve.useRef(e);return Hn(()=>{t.current=e}),t}function Lc(e){e===void 0&&(e={});let{placement:t="bottom",strategy:o="absolute",middleware:n=[],platform:r,elements:{reference:i,floating:s}={},transform:a=!0,whileElementsMounted:d,open:c}=e,[l,f]=ve.useState({x:0,y:0,strategy:o,placement:t,middlewareData:{},isPositioned:!1}),[p,m]=ve.useState(n);zn(p,n)||m(n);let[u,g]=ve.useState(null),[v,_]=ve.useState(null),w=ve.useCallback(P=>{P!==x.current&&(x.current=P,g(P))},[]),y=ve.useCallback(P=>{P!==E.current&&(E.current=P,_(P))},[]),b=i||u,S=s||v,x=ve.useRef(null),E=ve.useRef(null),T=ve.useRef(l),k=d!=null,C=Qr(d),j=Qr(r),A=Qr(c),L=ve.useCallback(()=>{if(!x.current||!E.current)return;let P={placement:t,strategy:o,middleware:p};j.current&&(P.platform=j.current),Bn(x.current,E.current,P).then(O=>{let M={...O,isPositioned:A.current!==!1};I.current&&!zn(T.current,M)&&(T.current=M,Oc.flushSync(()=>{f(M)}))})},[p,t,o,j,A]);Hn(()=>{c===!1&&T.current.isPositioned&&(T.current.isPositioned=!1,f(P=>({...P,isPositioned:!1})))},[c]);let I=ve.useRef(!1);Hn(()=>(I.current=!0,()=>{I.current=!1}),[]),Hn(()=>{if(b&&(x.current=b),S&&(E.current=S),b&&S){if(C.current)return C.current(b,S,L);L()}},[b,S,L,C,k]);let R=ve.useMemo(()=>({reference:x,floating:E,setReference:w,setFloating:y}),[w,y]),N=ve.useMemo(()=>({reference:b,floating:S}),[b,S]),H=ve.useMemo(()=>{let P={position:o,left:0,top:0};if(!N.floating)return P;let O=Cc(N.floating,l.x),M=Cc(N.floating,l.y);return a?{...P,transform:"translate("+O+"px, "+M+"px)",...Nc(N.floating)>=1.5&&{willChange:"transform"}}:{position:o,left:O,top:M}},[o,a,N.floating,l.x,l.y]);return ve.useMemo(()=>({...l,update:L,refs:R,elements:N,floatingStyles:H}),[l,L,R,N,H])}var Jr=(e,t)=>{let o=Rc(e);return{name:o.name,fn:o.fn,options:[e,t]}},$r=(e,t)=>{let o=Sc(e);return{name:o.name,fn:o.fn,options:[e,t]}},ei=(e,t)=>({fn:Pc(e).fn,options:[e,t]}),ti=(e,t)=>{let o=Ec(e);return{name:o.name,fn:o.fn,options:[e,t]}},oi=(e,t)=>{let o=Tc(e);return{name:o.name,fn:o.fn,options:[e,t]}};var ni=(e,t)=>{let o=kc(e);return{name:o.name,fn:o.fn,options:[e,t]}};var _o=h(z(),1),qc=h(Mt(),1);var Xc=h(z(),1);var q=(e,t,o,n,r,i,...s)=>{if(s.length>0)throw new Error(Pe(1));let a;if(e&&t&&o&&n&&r&&i)a=(d,c,l,f)=>{let p=e(d,c,l,f),m=t(d,c,l,f),u=o(d,c,l,f),g=n(d,c,l,f),v=r(d,c,l,f);return i(p,m,u,g,v,c,l,f)};else if(e&&t&&o&&n&&r)a=(d,c,l,f)=>{let p=e(d,c,l,f),m=t(d,c,l,f),u=o(d,c,l,f),g=n(d,c,l,f);return r(p,m,u,g,c,l,f)};else if(e&&t&&o&&n)a=(d,c,l,f)=>{let p=e(d,c,l,f),m=t(d,c,l,f),u=o(d,c,l,f);return n(p,m,u,c,l,f)};else if(e&&t&&o)a=(d,c,l,f)=>{let p=e(d,c,l,f),m=t(d,c,l,f);return o(p,m,c,l,f)};else if(e&&t)a=(d,c,l,f)=>{let p=e(d,c,l,f);return t(p,c,l,f)};else if(e)a=e;else throw new Error("Missing arguments");return a};var Uc=h(z(),1),li=h(ii(),1),Gc=h(jc(),1);var Fc=h(z(),1);var si=[],ai;function Vc(){return ai}function Wc(e){si.push(e)}function ci(e){let t=(o,n)=>{let r=Se(Wm).current,i;try{ai=r;for(let s of si)s.before(r);i=e(o,n);for(let s of si)s.after(r);r.didInitialize=!0}finally{ai=void 0}return i};return t.displayName=e.displayName||e.name,t}function Yc(e){return Fc.forwardRef(ci(e))}function Wm(){return{didInitialize:!1}}var Ym=ao(19),Um=Ym?Xm:Km;function jn(e,t,o,n,r){return Um(e,t,o,n,r)}function Gm(e,t,o,n,r){let i=Uc.useCallback(()=>t(e.getSnapshot(),o,n,r),[e,t,o,n,r]);return(0,li.useSyncExternalStore)(e.subscribe,i,i)}Wc({before(e){e.syncIndex=0,e.didInitialize||(e.syncTick=1,e.syncHooks=[],e.didChangeStore=!0,e.getSnapshot=()=>{let t=!1;for(let o=0;o0&&(e.didChangeStore&&(e.didChangeStore=!1,e.subscribe=t=>{let o=new Set;for(let r of e.syncHooks)o.add(r.store);let n=[];for(let r of o)n.push(r.subscribe(t));return()=>{for(let r of n)r()}}),(0,li.useSyncExternalStore)(e.subscribe,e.getSnapshot,e.getSnapshot))}});function Xm(e,t,o,n,r){let i=Vc();if(!i)return Gm(e,t,o,n,r);let s=i.syncIndex;i.syncIndex+=1;let a;return i.didInitialize?(a=i.syncHooks[s],(a.store!==e||a.selector!==t||!Object.is(a.a1,o)||!Object.is(a.a2,n)||!Object.is(a.a3,r))&&(a.store!==e&&(i.didChangeStore=!0),a.store=e,a.selector=t,a.a1=o,a.a2=n,a.a3=r,a.value=t(e.getSnapshot(),o,n,r))):(a={store:e,selector:t,a1:o,a2:n,a3:r,value:t(e.getSnapshot(),o,n,r)},i.syncHooks.push(a)),a.value}function Km(e,t,o,n,r){return(0,Gc.useSyncExternalStoreWithSelector)(e.subscribe,e.getSnapshot,e.getSnapshot,i=>t(i,o,n,r))}var Fn=class{constructor(t){this.state=t,this.listeners=new Set,this.updateTick=0}subscribe=t=>(this.listeners.add(t),()=>{this.listeners.delete(t)});getSnapshot=()=>this.state;setState(t){if(this.state===t)return;this.state=t,this.updateTick+=1;let o=this.updateTick;for(let n of this.listeners){if(o!==this.updateTick)return;n(t)}}update(t){for(let o in t)if(!Object.is(this.state[o],t[o])){this.setState({...this.state,...t});return}}set(t,o){Object.is(this.state[t],o)||this.setState({...this.state,[t]:o})}notifyAll(){let t={...this.state};this.setState(t)}use(t,o,n,r){return jn(this,t,o,n,r)}};var Jt=h(z(),1);var vo=class extends Fn{constructor(t,o={},n){super(t),this.context=o,this.selectors=n}useSyncedValue(t,o){Jt.useDebugValue(t);let n=this;D(()=>{n.state[t]!==o&&n.set(t,o)},[n,t,o])}useSyncedValueWithCleanup(t,o){let n=this;D(()=>(n.state[t]!==o&&n.set(t,o),()=>{n.set(t,void 0)}),[n,t,o])}useSyncedValues(t){let o=this,n=Object.values(t);D(()=>{o.update(t)},[o,...n])}useControlledProp(t,o){Jt.useDebugValue(t);let n=this,r=o!==void 0;D(()=>{r&&!Object.is(n.state[t],o)&&n.setState({...n.state,[t]:o})},[n,t,o,r])}select(t,o,n,r){let i=this.selectors[t];return i(this.state,o,n,r)}useState(t,o,n,r){return Jt.useDebugValue(t),jn(this,this.selectors[t],o,n,r)}useContextCallback(t,o){Jt.useDebugValue(t);let n=Y(o??Nt);this.context[t]=n}useStateSetter(t){let o=Jt.useRef(void 0);return o.current===void 0&&(o.current=n=>{this.set(t,n)}),o.current}observe(t,o){let n;typeof t=="function"?n=t:n=this.selectors[t];let r=n(this.state);return o(r,r,this),this.subscribe(i=>{let s=n(i);if(!Object.is(r,s)){let a=r;r=s,o(s,a,this)}})}};var qm={open:q(e=>e.open),transitionStatus:q(e=>e.transitionStatus),domReferenceElement:q(e=>e.domReferenceElement),referenceElement:q(e=>e.positionReference??e.referenceElement),floatingElement:q(e=>e.floatingElement),floatingId:q(e=>e.floatingId)},pt=class extends vo{constructor(t){let{syncOnly:o,nested:n,onOpenChange:r,triggerElements:i,...s}=t;super({...s,positionReference:s.referenceElement,domReferenceElement:s.referenceElement},{onOpenChange:r,dataRef:{current:{}},events:ec(),nested:n,triggerElements:i},qm),this.syncOnly=o}syncOpenEvent=(t,o)=>{(!t||!this.state.open||o!=null&&Ha(o))&&(this.context.dataRef.current.openEvent=t?o:void 0)};dispatchOpenChange=(t,o)=>{this.syncOpenEvent(t,o.event);let n={open:t,reason:o.reason,nativeEvent:o.event,nested:this.context.nested,triggerElement:o.trigger};this.context.events.emit("openchange",n)};setOpen=(t,o)=>{if(this.syncOnly){this.context.onOpenChange?.(t,o);return}this.dispatchOpenChange(t,o),this.context.onOpenChange?.(t,o)}};function Kc(e){let{popupStore:t,treatPopupAsFloatingElement:o=!1,floatingRootContext:n,floatingId:r,nested:i,onOpenChange:s}=e,a=t.useState("open"),d=t.useState("activeTriggerElement"),c=t.useState(o?"popupElement":"positionerElement"),l=t.context.triggerElements,f=s,p=Xc.useRef(null);n===void 0&&p.current===null&&(p.current=new pt({open:a,transitionStatus:void 0,referenceElement:d,floatingElement:c,triggerElements:l,onOpenChange:f,floatingId:r,syncOnly:!0,nested:i}));let m=n??p.current;return t.useSyncedValue("floatingId",r),D(()=>{let u={open:a,floatingId:r,referenceElement:d,floatingElement:c};V(d)&&(u.domReferenceElement=d),m.state.positionReference===m.state.referenceElement&&(u.positionReference=d),m.update(u)},[a,r,d,c,m]),m.context.onOpenChange=f,m.context.nested=i,m}var Zc={tabIndex:-1,[Dr]:""};function Qc(e,t,o=!1){let n=Lt(),r=bo()!=null,i=_o.useRef(null);e===void 0&&i.current===null&&(i.current=t(n,r));let s=e??i.current;return Kc({popupStore:s,treatPopupAsFloatingElement:o,floatingRootContext:s.state.floatingRootContext,floatingId:n,nested:r,onOpenChange:s.setOpen}),{store:s,internalStore:i.current}}function Zm(e,t){let o=_o.useRef(null),n=_o.useRef(null);return _o.useCallback(r=>{if(e===void 0)return;let i=!1;if(o.current!==null){let s=o.current,a=n.current,d=t.context.triggerElements.getById(s);a&&d===a&&(t.context.triggerElements.delete(s),i=!0),o.current=null,n.current=null}if(r!==null&&(o.current=e,n.current=r,t.context.triggerElements.add(e,r),i=!0),i){let s=t.context.triggerElements.size;t.select("open")&&t.state.triggerCount!==s&&t.set("triggerCount",s)}},[t,e])}function Qm(e,t,o,n=!1){t?e.preventUnmountingOnClose=!1:n&&(e.preventUnmountingOnClose=!0);let r=o?.id??null;(r||t)&&(e.activeTriggerId=r,e.activeTriggerElement=o??null)}function Jm(e){let t=!1;return e.preventUnmountOnClose=()=>{t=!0},()=>t}function Jc(e,t,o,n={}){let r=o.reason,i=r===U.triggerHover,s=t&&r===U.triggerFocus,a=!t&&(r===U.triggerPress||r===U.escapeKey),d=Jm(o);if(e.context.onOpenChange?.(t,o),o.isCanceled)return;n.onBeforeDispatch?.(),e.state.floatingRootContext.dispatchOpenChange(t,o);let c=()=>{let l={...n.extraState,open:t};s?l.instantType="focus":a?l.instantType="dismiss":i&&(l.instantType=void 0),Qm(l,t,o.trigger,d()),e.update(l)};i?qc.flushSync(c):c()}function $c(e,t,o,n){Oa(()=>{t===void 0&&e.state.open===!1&&o&&(e.state={...e.state,open:!0,activeTriggerId:n,preventUnmountingOnClose:!1})})}function el(e,t,o,n){let r=o.useState("isMountedByTrigger",e),i=Zm(e,o),s=Y(a=>{if(i(a),!a)return;let d=o.select("open"),c=o.select("activeTriggerId");if(c===e){o.update({activeTriggerElement:a,...d?n:null});return}c==null&&d&&o.update({activeTriggerId:e,activeTriggerElement:a,...n})});return D(()=>{r&&o.update({activeTriggerElement:t.current,...n})},[r,o,t,...Object.values(n)]),{registerTrigger:s,isMountedByThisTrigger:r}}function tl(e,t={}){let{closeOnActiveTriggerUnmount:o=!1}=t,n=e.useState("open"),r=e.useState("triggerCount");D(()=>{if(!n){e.state.triggerCount!==0&&e.set("triggerCount",0);return}let i=e.context.triggerElements.size,s={};e.state.triggerCount!==i&&(s.triggerCount=i);let a=e.select("activeTriggerId"),d=null;if(a){let c=e.context.triggerElements.getById(a);c?c!==e.state.activeTriggerElement&&(s.activeTriggerElement=c):d=a}if(!d&&!a&&i===1){let c=e.context.triggerElements.entries().next();if(!c.done){let[l,f]=c.value;s.activeTriggerId=l,s.activeTriggerElement=f}}(s.triggerCount!==void 0||s.activeTriggerId!==void 0||s.activeTriggerElement!==void 0)&&e.update(s),d&&o&&queueMicrotask(()=>{if(e.select("open")&&e.select("activeTriggerId")===d&&!e.context.triggerElements.getById(d)){let c=ee(U.none);e.setOpen(!1,c),c.isCanceled||e.update({activeTriggerId:null,activeTriggerElement:null})}})},[n,e,r,o])}function ol(e,t,o){let{mounted:n,setMounted:r,transitionStatus:i}=ha(e),s=t.useState("preventUnmountingOnClose"),a=e?!1:s;t.useSyncedValues({mounted:n,transitionStatus:i,preventUnmountingOnClose:a});let d=Y(()=>{r(!1),t.update({activeTriggerId:null,activeTriggerElement:null,mounted:!1,preventUnmountingOnClose:!1}),o?.(),t.context.onOpenChangeComplete?.(!1)});return Pn({enabled:n&&!e&&!a,open:e,ref:t.context.popupRef,onComplete(){e||d()}}),{forceUnmount:d,transitionStatus:i}}function nl(e,t){e.useSyncedValues(t),D(()=>()=>{e.update({activeTriggerProps:be,inactiveTriggerProps:be,popupProps:be})},[e])}var jt=class{constructor(){this.elementsSet=new Set,this.idMap=new Map}add(t,o){let n=this.idMap.get(t);n!==o&&(n!==void 0&&this.elementsSet.delete(n),this.elementsSet.add(o),this.idMap.set(t,o))}delete(t){let o=this.idMap.get(t);o&&(this.elementsSet.delete(o),this.idMap.delete(t))}hasElement(t){return this.elementsSet.has(t)}hasMatchingElement(t){for(let o of this.elementsSet)if(t(o))return!0;return!1}getById(t){return this.idMap.get(t)}entries(){return this.idMap.entries()}elements(){return this.elementsSet.values()}get size(){return this.idMap.size}};function rl(){return new pt({open:!1,transitionStatus:void 0,floatingElement:null,referenceElement:null,triggerElements:new jt,floatingId:void 0,syncOnly:!1,nested:!1,onOpenChange:void 0})}function sl(){return{open:!1,openProp:void 0,mounted:!1,transitionStatus:void 0,floatingRootContext:rl(),floatingId:void 0,triggerCount:0,preventUnmountingOnClose:!1,payload:void 0,activeTriggerId:null,activeTriggerElement:null,triggerIdProp:void 0,popupElement:null,positionerElement:null,activeTriggerProps:be,inactiveTriggerProps:be,popupProps:be}}function al(e,t,o=!1){return new pt({open:!1,transitionStatus:void 0,floatingElement:null,referenceElement:null,triggerElements:e,floatingId:t,syncOnly:!0,nested:o,onOpenChange:void 0})}var Ko=q(e=>e.triggerIdProp??e.activeTriggerId),di=q(e=>e.openProp??e.open),il=q(e=>(e.popupElement?.id??e.floatingId)||void 0);function cl(e,t){return t!==void 0&&di(e)&&Ko(e)===t}function $m(e,t){return cl(e,t)?!0:t!==void 0&&di(e)&&Ko(e)==null&&e.triggerCount===1}var ll={open:di,mounted:q(e=>e.mounted),transitionStatus:q(e=>e.transitionStatus),floatingRootContext:q(e=>e.floatingRootContext),triggerCount:q(e=>e.triggerCount),preventUnmountingOnClose:q(e=>e.preventUnmountingOnClose),payload:q(e=>e.payload),activeTriggerId:Ko,activeTriggerElement:q(e=>e.mounted?e.activeTriggerElement:null),popupId:il,isTriggerActive:q((e,t)=>t!==void 0&&Ko(e)===t),isOpenedByTrigger:q((e,t)=>cl(e,t)),isMountedByTrigger:q((e,t)=>t!==void 0&&Ko(e)===t&&e.mounted),triggerProps:q((e,t)=>t?e.activeTriggerProps:e.inactiveTriggerProps),triggerPopupId:q((e,t)=>$m(e,t)?il(e):void 0),popupProps:q(e=>e.popupProps),popupElement:q(e=>e.popupElement),positionerElement:q(e=>e.positionerElement)};function dl(e){let{open:t=!1,onOpenChange:o,elements:n={}}=e,r=Lt(),i=bo()!=null,s=Se(()=>new pt({open:t,transitionStatus:void 0,onOpenChange:o,referenceElement:n.reference??null,floatingElement:n.floating??null,triggerElements:new jt,floatingId:r,syncOnly:!1,nested:i})).current;return D(()=>{let a={open:t,floatingId:r};n.reference!==void 0&&(a.referenceElement=n.reference,a.domReferenceElement=V(n.reference)?n.reference:null),n.floating!==void 0&&(a.floatingElement=n.floating),s.update(a)},[t,r,n.reference,n.floating,s]),s.context.onOpenChange=o,s.context.nested=i,s}function ui(e={}){let{nodeId:t,externalTree:o}=e,n=dl(e),r=e.rootContext||n,i=r.useState("referenceElement"),s=r.useState("floatingElement"),a=r.useState("domReferenceElement"),d=r.useState("open"),c=r.useState("floatingId"),[l,f]=Ne.useState(null),[p,m]=Ne.useState(void 0),[u,g]=Ne.useState(void 0),v=Ne.useRef(null),_=Dt(o),w=Ne.useMemo(()=>({reference:i,floating:s,domReference:a}),[i,s,a]),y=Lc({...e,elements:{...w,...l&&{reference:l}}}),b=V(p)?p:null,S=u===void 0?r.state.floatingElement:u;r.useSyncedValue("referenceElement",p??null),r.useSyncedValue("domReferenceElement",p===void 0?a:b),r.useSyncedValue("floatingElement",S);let x=Ne.useCallback(A=>{let L=V(A)?{getBoundingClientRect:()=>A.getBoundingClientRect(),getClientRects:()=>A.getClientRects(),contextElement:A}:A;f(L),y.refs.setReference(L)},[y.refs]),E=Ne.useCallback(A=>{(V(A)||A===null)&&(v.current=A,m(A)),(V(y.refs.reference.current)||y.refs.reference.current===null||A!==null&&!V(A))&&y.refs.setReference(A)},[y.refs,m]),T=Ne.useCallback(A=>{g(A),y.refs.setFloating(A)},[y.refs]),k=Ne.useMemo(()=>({...y.refs,setReference:E,setFloating:T,setPositionReference:x,domReference:v}),[y.refs,E,T,x]),C=Ne.useMemo(()=>({...y.elements,domReference:a}),[y.elements,a]),j=Ne.useMemo(()=>({...y,dataRef:r.context.dataRef,open:d,onOpenChange:r.setOpen,events:r.context.events,floatingId:c,refs:k,elements:C,nodeId:t,rootStore:r}),[y,k,C,t,r,d,c]);return D(()=>{a&&(v.current=a)},[a]),D(()=>{r.context.dataRef.current.floatingContext=j;let A=_?.nodesRef.current.find(L=>L.id===t);A&&(A.context=j)}),Ne.useMemo(()=>({...y,context:j,refs:k,elements:C,rootStore:r}),[y,k,C,j,r])}var mt=h(z(),1);var fi=xt.os.mac&&xt.engine.webkit;function pi(e,t={}){let{enabled:o=!0,delay:n}=t,r="rootStore"in e?e.rootStore:e,{events:i,dataRef:s}=r.context,a=mt.useRef(!1),d=mt.useRef(null),c=mt.useRef(!0),l=rt();mt.useEffect(()=>{let p=r.select("domReferenceElement");if(!o)return;let m=ge(p);function u(){let _=r.select("domReferenceElement");!r.select("open")&&we(_)&&_===Cn(xe(_))&&(a.current=!0)}function g(){c.current=!0}function v(){c.current=!1}return it(re(m,"blur",u),fi&&re(m,"keydown",g,!0),fi&&re(m,"pointerdown",v,!0))},[r,o]),mt.useEffect(()=>{if(!o)return;function p(m){if(m.reason===U.triggerPress||m.reason===U.escapeKey){let u=r.select("domReferenceElement");V(u)&&(d.current=u,a.current=!0)}}return i.on("openchange",p),()=>{i.off("openchange",p)}},[i,o,r]);let f=mt.useMemo(()=>{function p(){a.current=!1,d.current=null}return{onMouseLeave(){p()},onFocus(m){let u=m.currentTarget;if(a.current){if(d.current===u)return;p()}let g=Me(m.nativeEvent);if(V(g)){if(fi&&!m.relatedTarget){if(!c.current&&!Da(g))return}else if(!ja(g))return}let v=Bt(m.relatedTarget,r.context.triggerElements),{nativeEvent:_,currentTarget:w}=m,y=typeof n=="function"?n():n;if(r.select("open")&&v||y===0||y===void 0){r.setOpen(!0,ee(U.triggerFocus,_,w));return}l.start(y,()=>{a.current||r.setOpen(!0,ee(U.triggerFocus,_,w))})},onBlur(m){p();let u=m.relatedTarget,g=m.nativeEvent,v=V(u)&&u.hasAttribute(go("focus-guard"))&&u.getAttribute("data-type")==="outside";l.start(0,()=>{let _=r.select("domReferenceElement"),w=Cn(xe(_));!u&&w===_||ie(s.current.floatingContext?.refs.floating.current,w)||ie(_,w)||v||Bt(u??w,r.context.triggerElements)||r.setOpen(!1,ee(U.triggerFocus,g))})}}},[s,n,r,l]);return mt.useMemo(()=>o?{reference:f,trigger:f}:{},[o,f])}var gi=h(z(),1);var mi=class e{constructor(){this.pointerType=void 0,this.interactedInside=!1,this.handler=void 0,this.blockMouseMove=!0,this.performedPointerEventsMutation=!1,this.pointerEventsScopeElement=null,this.pointerEventsReferenceElement=null,this.pointerEventsFloatingElement=null,this.restTimeoutPending=!1,this.openChangeTimeout=new Ye,this.restTimeout=new Ye,this.handleCloseOptions=void 0}static create(){return new e}dispose=()=>{this.openChangeTimeout.clear(),this.restTimeout.clear()};disposeEffect=()=>this.dispose},Vn=new WeakMap;function yo(e){if(!e.performedPointerEventsMutation)return;let t=e.pointerEventsScopeElement;t&&Vn.get(t)===e&&(e.pointerEventsScopeElement?.style.removeProperty("pointer-events"),e.pointerEventsReferenceElement?.style.removeProperty("pointer-events"),e.pointerEventsFloatingElement?.style.removeProperty("pointer-events"),Vn.delete(t)),e.performedPointerEventsMutation=!1,e.pointerEventsScopeElement=null,e.pointerEventsReferenceElement=null,e.pointerEventsFloatingElement=null}function Wn(e,t){let{scopeElement:o,referenceElement:n,floatingElement:r}=t,i=Vn.get(o);i&&i!==e&&yo(i),yo(e),e.performedPointerEventsMutation=!0,e.pointerEventsScopeElement=o,e.pointerEventsReferenceElement=n,e.pointerEventsFloatingElement=r,Vn.set(o,e),o.style.pointerEvents="none",n.style.pointerEvents="auto",r.style.pointerEvents="auto"}function xo(e){let t=e.context.dataRef.current,o=Se(()=>t.hoverInteractionState??mi.create()).current;return t.hoverInteractionState||(t.hoverInteractionState=o),co(t.hoverInteractionState.disposeEffect),t.hoverInteractionState}function bi(e,t={}){let{enabled:o=!0,closeDelay:n=0,nodeId:r}=t,i="rootStore"in e?e.rootStore:e,s=i.useState("open"),a=i.useState("floatingElement"),d=i.useState("domReferenceElement"),{dataRef:c}=i.context,l=Dt(),f=bo(),p=xo(i),m=rt(),u=Y(()=>On(c.current.openEvent?.type,p.interactedInside)),g=Y(()=>Fa(c.current.openEvent?.type)),v=Y(()=>{yo(p)});D(()=>{s||(p.pointerType=void 0,p.restTimeoutPending=!1,p.interactedInside=!1,v())},[s,p,v]),gi.useEffect(()=>v,[v]),D(()=>{if(o&&s&&p.handleCloseOptions?.blockPointerEvents&&g()&&V(d)&&a){let _=d,w=a,y=xe(a),b=l?.nodesRef.current.find(T=>T.id===f)?.context?.elements.floating;b&&(b.style.pointerEvents="");let S=p.pointerEventsScopeElement!==w?p.pointerEventsScopeElement:null,x=b!==w?b:null,E=p.handleCloseOptions?.getScope?.()??S??x??_.closest("[data-rootownerid]")??y.body;return Wn(p,{scopeElement:E,referenceElement:_,floatingElement:w}),()=>{v()}}},[o,s,d,a,p,g,l,f,v]),gi.useEffect(()=>{if(!o)return;function _(){return!!(l&&f&&Et(l.nodesRef.current,f).length>0)}function w(T){let k=St(n,"close",p.pointerType),C=()=>{i.setOpen(!1,ee(U.triggerHover,T)),l?.events.emit("floating.closed",T)};k?p.openChangeTimeout.start(k,C):(p.openChangeTimeout.clear(),C())}function y(T){let k=Me(T);if(!Fr(k)){p.interactedInside=!1;return}p.interactedInside=k?.closest("[aria-haspopup]")!=null}function b(){p.openChangeTimeout.clear(),m.clear(),l?.events.off("floating.closed",x),v()}function S(T){if(_()&&l){l.events.on("floating.closed",x);return}if(Bt(T.relatedTarget,i.context.triggerElements))return;let k=c.current.floatingContext?.nodeId??r,C=T.relatedTarget;if(!(l&&k&&V(C)&&Et(l.nodesRef.current,k,!1).some(A=>ie(A.context?.elements.floating,C)))){if(p.handler){p.handler(T);return}v(),g()&&!u()&&w(T)}}function x(T){!l||!f||_()||m.start(0,()=>{l.events.off("floating.closed",x),i.setOpen(!1,ee(U.triggerHover,T)),l.events.emit("floating.closed",T)})}let E=a;return it(E&&re(E,"mouseenter",b),E&&re(E,"mouseleave",S),E&&re(E,"pointerdown",y,!0),()=>{l?.events.off("floating.closed",x)})},[o,a,i,c,n,r,g,u,v,p,l,f,m])}var Ft=h(z(),1),ul=h(Mt(),1);var eg={current:null};function hi(e,t={}){let{enabled:o=!0,delay:n=0,handleClose:r=null,mouseOnly:i=!1,restMs:s=0,move:a=!0,triggerElementRef:d=eg,externalTree:c,isActiveTrigger:l=!0,getHandleCloseContext:f,isClosing:p,shouldOpen:m}=t,u="rootStore"in e?e.rootStore:e,{dataRef:g,events:v}=u.context,_=Dt(c),w=xo(u),y=Ft.useRef(!1),b=ze(r),S=ze(n),x=ze(s),E=ze(o),T=ze(m),k=ze(p),C=Y(()=>On(g.current.openEvent?.type,w.interactedInside)),j=Y(()=>T.current?.()!==!1),A=Y((R,N,H)=>{let P=u.context.triggerElements;if(P.hasElement(N))return!R||!ie(R,N);if(!V(H))return!1;let O=H;return P.hasMatchingElement(M=>ie(M,O))&&(!R||!ie(R,O))}),L=Y(()=>{if(!w.handler)return;xe(u.select("domReferenceElement")).removeEventListener("mousemove",w.handler),w.handler=void 0}),I=Y(()=>{yo(w)});return l&&(w.handleCloseOptions=b.current?.__options),Ft.useEffect(()=>L,[L]),Ft.useEffect(()=>{if(!o)return;function R(N){N.open?y.current=!1:(y.current=N.reason===U.triggerHover,L(),w.openChangeTimeout.clear(),w.restTimeout.clear(),w.blockMouseMove=!0,w.restTimeoutPending=!1)}return v.on("openchange",R),()=>{v.off("openchange",R)}},[o,v,w,L]),Ft.useEffect(()=>{if(!o)return;function R(O,M=!0){let Z=St(S.current,"close",w.pointerType);Z?w.openChangeTimeout.start(Z,()=>{u.setOpen(!1,ee(U.triggerHover,O)),_?.events.emit("floating.closed",O)}):M&&(w.openChangeTimeout.clear(),u.setOpen(!1,ee(U.triggerHover,O)),_?.events.emit("floating.closed",O))}let N=d.current??(l?u.select("domReferenceElement"):null);if(!V(N))return;function H(O){if(w.openChangeTimeout.clear(),w.blockMouseMove=!1,i&&!Rt(w.pointerType))return;let M=Vr(x.current),Z=St(S.current,"open",w.pointerType),W=Me(O),oe=O.currentTarget??null,te=u.select("domReferenceElement"),se=oe;if(V(W)&&!u.context.triggerElements.hasElement(W)){for(let ue of u.context.triggerElements.elements())if(ie(ue,W)){se=ue;break}}V(oe)&&V(te)&&!u.context.triggerElements.hasElement(oe)&&ie(oe,te)&&(se=te);let G=se==null?!1:A(te,se,W),K=u.select("open"),J=k.current?.()??u.select("transitionStatus")==="ending",ne=!K&&J&&y.current,me=!G&&V(se)&&V(te)&&ie(te,se)&&ne,le=M>0&&!Z,X=G&&(K||ne)||me,pe=!K||G;if(X){j()&&u.setOpen(!0,ee(U.triggerHover,O,se));return}le||(Z?w.openChangeTimeout.start(Z,()=>{pe&&j()&&u.setOpen(!0,ee(U.triggerHover,O,se))}):pe&&j()&&u.setOpen(!0,ee(U.triggerHover,O,se)))}function P(O){if(C()){I();return}L();let M=u.select("domReferenceElement"),Z=xe(M);w.restTimeout.clear(),w.restTimeoutPending=!1;let W=g.current.floatingContext??f?.();if(Bt(O.relatedTarget,u.context.triggerElements))return;if(b.current&&W){u.select("open")||w.openChangeTimeout.clear();let te=d.current;w.handler=b.current({...W,tree:_,x:O.clientX,y:O.clientY,onClose(){I(),L(),E.current&&!C()&&te===u.select("domReferenceElement")&&R(O,!0)}}),Z.addEventListener("mousemove",w.handler),w.handler(O);return}(w.pointerType!=="touch"||!ie(u.select("floatingElement"),O.relatedTarget))&&R(O)}return a?it(re(N,"mousemove",H,{once:!0}),re(N,"mouseenter",H),re(N,"mouseleave",P)):it(re(N,"mouseenter",H),re(N,"mouseleave",P))},[L,I,g,S,u,o,b,w,l,A,C,i,a,x,d,_,E,f,k,j]),Ft.useMemo(()=>{if(!o)return;function R(N){w.pointerType=N.pointerType}return{onPointerDown:R,onPointerEnter:R,onMouseMove(N){let{nativeEvent:H}=N,P=N.currentTarget,O=u.select("domReferenceElement"),M=u.select("open"),Z=A(O,P,N.target);if(i&&!Rt(w.pointerType))return;if(M&&Z&&w.handleCloseOptions?.blockPointerEvents){let te=u.select("floatingElement");if(te){let se=w.handleCloseOptions?.getScope?.()??P.ownerDocument.body;Wn(w,{scopeElement:se,referenceElement:P,floatingElement:te})}}let W=Vr(x.current);if(M&&!Z||W===0||!Z&&w.restTimeoutPending&&N.movementX**2+N.movementY**2<2)return;w.restTimeout.clear();function oe(){if(w.restTimeoutPending=!1,C())return;let te=u.select("open");!w.blockMouseMove&&(!te||Z)&&j()&&u.setOpen(!0,ee(U.triggerHover,H,P))}w.pointerType==="touch"?ul.flushSync(()=>{oe()}):Z&&M?oe():(w.restTimeoutPending=!0,w.restTimeout.start(W,oe))}}},[o,w,C,A,i,u,x,j])}var fl=.1,tg=fl*fl,ce=.5;function Yn(e,t,o,n,r,i){return n>=t!=i>=t&&e<=(r-o)*(t-n)/(i-n)+o}function Un(e,t,o,n,r,i,s,a,d,c){let l=!1;return Yn(e,t,o,n,r,i)&&(l=!l),Yn(e,t,r,i,s,a)&&(l=!l),Yn(e,t,s,a,d,c)&&(l=!l),Yn(e,t,d,c,o,n)&&(l=!l),l}function og(e,t,o){return e>=o.x&&e<=o.x+o.width&&t>=o.y&&t<=o.y+o.height}function Gn(e,t,o,n,r,i){let s=Math.min(o,r),a=Math.max(o,r),d=Math.min(n,i),c=Math.max(n,i);return e>=s&&e<=a&&t>=d&&t<=c}function wi(e={}){let{blockPointerEvents:t=!1}=e,o=new Ye,n=({x:r,y:i,placement:s,elements:a,onClose:d,nodeId:c,tree:l})=>{let f=s?.split("-")[0],p=!1,m=null,u=null,g=typeof performance<"u"?performance.now():0;function v(w,y){let b=performance.now(),S=b-g;if(m===null||u===null||S===0)return m=w,u=y,g=b,!1;let x=w-m,E=y-u,T=x*x+E*E,k=S*S*tg;return m=w,u=y,g=b,T0)}function L(){A()||_()}if(A())return;let I=b.getBoundingClientRect(),R=S.getBoundingClientRect(),N=r>R.right-R.width/2,H=i>R.bottom-R.height/2,P=R.width>I.width,O=R.height>I.height,M=(P?I:R).left,Z=(P?I:R).right,W=(O?I:R).top,oe=(O?I:R).bottom;if(f==="top"&&i>=I.bottom-1||f==="bottom"&&i<=I.top+1||f==="left"&&r>=I.right-1||f==="right"&&r<=I.left+1){L();return}let te=!1;switch(f){case"top":te=Gn(x,E,M,I.top+1,Z,R.bottom-1);break;case"bottom":te=Gn(x,E,M,R.top+1,Z,I.bottom-1);break;case"left":te=Gn(x,E,R.right-1,oe,I.left+1,W);break;case"right":te=Gn(x,E,I.right-1,oe,R.left+1,W);break;default:}if(te)return;if(p&&!og(x,E,I)){L();return}if(!k&&v(x,E)){L();return}let se=!1;switch(f){case"top":{let G=P?ce/2:ce*4,K=P||N?r+G:r-G,J=P?r-G:N?r+G:r-G,ne=i+ce+1,me=N||P?R.bottom-ce:R.top,le=N?P?R.bottom-ce:R.top:R.bottom-ce;se=Un(x,E,K,ne,J,ne,R.left,me,R.right,le);break}case"bottom":{let G=P?ce/2:ce*4,K=P||N?r+G:r-G,J=P?r-G:N?r+G:r-G,ne=i-ce,me=N||P?R.top+ce:R.bottom,le=N?P?R.top+ce:R.bottom:R.top+ce;se=Un(x,E,K,ne,J,ne,R.left,me,R.right,le);break}case"left":{let G=O?ce/2:ce*4,K=O||H?i+G:i-G,J=O?i-G:H?i+G:i-G,ne=r+ce+1,me=H||O?R.right-ce:R.left,le=H?O?R.right-ce:R.left:R.right-ce;se=Un(x,E,me,R.top,le,R.bottom,ne,K,ne,J);break}case"right":{let G=O?ce/2:ce*4,K=O||H?i+G:i-G,J=O?i-G:H?i+G:i-G,ne=r-ce,me=H||O?R.left+ce:R.right,le=H?O?R.left+ce:R.right:R.left+ce;se=Un(x,E,ne,K,ne,J,me,R.top,le,R.bottom);break}default:}se?p||o.start(40,L):L()}};return n.__options={...e,blockPointerEvents:t},n}var vi=(function(e){return e.open="data-open",e.closed="data-closed",e[e.startingStyle=Yt.startingStyle]="startingStyle",e[e.endingStyle=Yt.endingStyle]="endingStyle",e.anchorHidden="data-anchor-hidden",e.side="data-side",e.align="data-align",e})({}),qo=(function(e){return e.popupOpen="data-popup-open",e.pressed="data-pressed",e})({}),ng={[qo.popupOpen]:""},B_={[qo.popupOpen]:"",[qo.pressed]:""},rg={[vi.open]:""},ig={[vi.closed]:""},sg={[vi.anchorHidden]:""},pl={open(e){return e?ng:null}};var Ro={open(e){return e?rg:ig},anchorHidden(e){return e?sg:null}};function ml(e){return ao(19)?e:e?"true":void 0}var Ge=h(z(),1);var ag=e=>({name:"arrow",options:e,async fn(t){let{x:o,y:n,placement:r,rects:i,platform:s,elements:a,middlewareData:d}=t,{element:c,padding:l=0,offsetParent:f="real"}=at(e,t)||{};if(c==null)return{};let p=In(l),m={x:o,y:n},u=Go(r),g=Uo(u),v=await s.getDimensions(c),_=u==="y",w=_?"top":"left",y=_?"bottom":"right",b=_?"clientHeight":"clientWidth",S=i.reference[g]+i.reference[u]-m[u]-i.floating[g],x=m[u]-i.reference[u],E=f==="real"?await s.getOffsetParent?.(c):a.floating,T=a.floating[b]||i.floating[g];(!T||!await s.isElement?.(E))&&(T=a.floating[b]||i.floating[g]);let k=S/2-x/2,C=T/2-v[g]/2-1,j=Math.min(p[w],C),A=Math.min(p[y],C),L=j,I=T-v[g]-A,R=T/2-v[g]/2+k,N=Yo(L,R,I),H=!d.arrow&&ct(r)!=null&&R!==N&&i.reference[g]/2-(R({...ag(e),options:[e,t]});var cg=ni().fn,bl={name:"hide",async fn(e){let{width:t,height:o,x:n,y:r}=e.rects.reference,i=t===0&&o===0&&n===0&&r===0;return{data:{referenceHidden:(await cg(e)).data?.referenceHidden||i}}}};var Zo={sideX:"left",sideY:"top"},hl={name:"adaptiveOrigin",async fn(e){let{x:t,y:o,rects:{floating:n},elements:{floating:r},platform:i,strategy:s,placement:a}=e,d=ge(r),c=d.getComputedStyle(r);if(!(c.transitionDuration!=="0s"&&c.transitionDuration!==""))return{x:t,y:o,data:Zo};let f=await i.getOffsetParent?.(r),p={width:0,height:0};if(s==="fixed"&&d?.visualViewport)p={width:d.visualViewport.width,height:d.visualViewport.height};else if(f===d){let w=xe(r);p={width:w.documentElement.clientWidth,height:w.documentElement.clientHeight}}else await i.isElement?.(f)&&(p=await i.getDimensions(f));let m=Ee(a),u=t,g=o;m==="left"&&(u=p.width-(t+n.width)),m==="top"&&(g=p.height-(o+n.height));let v=m==="left"?"right":Zo.sideX,_=m==="top"?"bottom":Zo.sideY;return{x:u,y:g,data:{sideX:v,sideY:_}}}};function _l(e,t,o){let n=e==="inline-start"||e==="inline-end";return{top:"top",right:n?o?"inline-start":"inline-end":"right",bottom:"bottom",left:n?o?"inline-end":"inline-start":"left"}[t]}function wl(e,t,o){let{rects:n,placement:r}=e;return{side:_l(t,Ee(r),o),align:ct(r)||"center",anchor:{width:n.reference.width,height:n.reference.height},positioner:{width:n.floating.width,height:n.floating.height}}}function yl(e){let{anchor:t,positionMethod:o="absolute",side:n="bottom",sideOffset:r=0,align:i="center",alignOffset:s=0,collisionBoundary:a,collisionPadding:d=5,sticky:c=!1,arrowPadding:l=5,disableAnchorTracking:f=!1,inline:p,keepMounted:m=!1,floatingRootContext:u,mounted:g,collisionAvoidance:v,shiftCrossAxis:_=!1,nodeId:w,adaptiveOrigin:y,lazyFlip:b=!1,externalTree:S}=e,[x,E]=Ge.useState(null);!g&&x!==null&&E(null);let T=v.side||"flip",k=v.align||"flip",C=v.fallbackAxisSide||"end",j=typeof t=="function"?t:void 0,A=Y(j),L=j?A:t,I=ze(t),R=ze(g),H=so()==="rtl",P=x||{top:"top",right:"right",bottom:"bottom",left:"left","inline-end":H?"left":"right","inline-start":H?"right":"left"}[n],O=i==="center"?P:`${P}-${i}`,M=d,Z=1,W=n==="bottom"?Z:0,oe=n==="top"?Z:0,te=n==="right"?Z:0,se=n==="left"?Z:0;typeof M=="number"?M={top:M+W,right:M+se,bottom:M+oe,left:M+te}:M&&(M={top:(M.top||0)+W,right:(M.right||0)+se,bottom:(M.bottom||0)+oe,left:(M.left||0)+te});let G={boundary:a==="clipping-ancestors"?"clippingAncestors":a,padding:M},K=Ge.useRef(null),J=ze(r),ne=ze(s),me=typeof r!="function"?r:0,le=typeof s!="function"?s:0,X=[];p&&X.push(p),X.push(Jr(ae=>{let Ie=wl(ae,n,H),ut=typeof J.current=="function"?J.current(Ie):J.current,qe=typeof ne.current=="function"?ne.current(Ie):ne.current;return{mainAxis:ut,crossAxis:qe,alignmentAxis:qe}},[me,le,H,n]));let pe=k==="none"&&T!=="shift",ue=!pe&&(c||_||T==="shift"),vt=T==="none"?null:ti({...G,padding:{top:M.top+Z,right:M.right+Z,bottom:M.bottom+Z,left:M.left+Z},mainAxis:!_&&T==="flip",crossAxis:k==="flip"?"alignment":!1,fallbackAxisSideDirection:C}),Te=pe?null:$r(ae=>{let Ie=xe(ae.elements.floating).documentElement;return{...G,rootBoundary:_?{x:0,y:0,width:Ie.clientWidth,height:Ie.clientHeight}:void 0,mainAxis:k!=="none",crossAxis:ue,limiter:c||_?void 0:ei(ut=>{if(!K.current)return{};let{width:qe,height:yt}=K.current.getBoundingClientRect(),et=De(Ee(ut.placement)),Vt=et==="y"?qe:yt,io=et==="y"?M.left+M.right:M.top+M.bottom;return{offset:Vt/2+io/2}})}},[G,c,_,M,k]);T==="shift"||k==="shift"||i==="center"?X.push(Te,vt):X.push(vt,Te),X.push(oi({...G,apply({elements:{floating:ae},availableWidth:Ie,availableHeight:ut,rects:qe}){if(!R.current)return;let yt=ae.style;yt.setProperty("--available-width",`${Ie}px`),yt.setProperty("--available-height",`${ut}px`);let et=ge(ae).devicePixelRatio||1,{x:Vt,y:io,width:bn,height:br}=qe.reference,hr=(Math.round((Vt+bn)*et)-Math.round(Vt*et))/et,wr=(Math.round((io+br)*et)-Math.round(io*et))/et;yt.setProperty("--anchor-width",`${hr}px`),yt.setProperty("--anchor-height",`${wr}px`)}}),gl(ae=>({element:K.current||xe(ae.elements.floating).createElement("div"),padding:l,offsetParent:"floating"}),[l]),{name:"transformOrigin",fn(ae){let{elements:Ie,middlewareData:ut,placement:qe,rects:yt,y:et}=ae,Vt=Ee(qe),io=De(Vt),bn=K.current,br=ut.arrow?.x||0,hr=ut.arrow?.y||0,wr=bn?.clientWidth||0,ff=bn?.clientHeight||0,vr=br+wr/2,Us=hr+ff/2,pf=Math.abs(ut.shift?.y||0),mf=yt.reference.height/2,Io=typeof r=="function"?r(wl(ae,n,H)):r,gf=pf>Io,bf={top:`${vr}px calc(100% + ${Io}px)`,bottom:`${vr}px ${-Io}px`,left:`calc(100% + ${Io}px) ${Us}px`,right:`${-Io}px ${Us}px`}[Vt],hf=`${vr}px ${yt.reference.y+mf-et}px`;return Ie.floating.style.setProperty("--transform-origin",ue&&io==="y"&&gf?hf:bf),{}}},bl,y),D(()=>{!g&&u&&u.update({referenceElement:null,floatingElement:null,domReferenceElement:null,positionReference:null})},[g,u]);let Ve=Ge.useMemo(()=>({elementResize:!f&&typeof ResizeObserver<"u",layoutShift:!f&&typeof IntersectionObserver<"u"}),[f]),{refs:Ke,elements:He,x:no,y:dn,middlewareData:_e,update:ro,placement:B,context:F,isPositioned:he,floatingStyles:ke}=ui({rootContext:u,open:m?g:void 0,placement:O,middleware:X,strategy:o,whileElementsMounted:m?void 0:(...ae)=>Xo(...ae,Ve),nodeId:w,externalTree:S}),{sideX:kt,sideY:Lo}=_e.adaptiveOrigin||Zo,_t=he?o:"fixed",We=Ge.useMemo(()=>{let ae=y?{position:_t,[kt]:no,[Lo]:dn}:{position:_t,...ke};return he||(ae.opacity=0),ae},[y,_t,kt,no,Lo,dn,ke,he]),Pt=Ge.useRef(null);D(()=>{if(!g)return;let ae=I.current,Ie=typeof ae=="function"?ae():ae,qe=(vl(Ie)?Ie.current:Ie)||null||null;qe!==Pt.current&&(Ke.setPositionReference(qe),Pt.current=qe)},[g,Ke,L,I]),Ge.useEffect(()=>{if(!g)return;let ae=I.current;typeof ae!="function"&&vl(ae)&&ae.current!==Pt.current&&(Ke.setPositionReference(ae.current),Pt.current=ae.current)},[g,Ke,L,I]),Ge.useEffect(()=>{if(m&&g&&He.reference&&He.floating)return Xo(He.reference,He.floating,ro,Ve)},[m,g,He,ro,Ve]);let Ct=Ee(B),un=_l(n,Ct,H),fn=ct(B)||"center",pn=!!_e.hide?.referenceHidden;D(()=>{b&&g&&he&&E(Ct)},[b,g,he,Ct]);let mn=Ge.useMemo(()=>({position:"absolute",top:_e.arrow?.y,left:_e.arrow?.x}),[_e.arrow]),gn=_e.arrow?.centerOffset!==0;return Ge.useMemo(()=>({positionerStyles:We,arrowStyles:mn,arrowRef:K,arrowUncentered:gn,side:un,align:fn,physicalSide:Ct,anchorHidden:pn,refs:Ke,context:F,isPositioned:he,update:ro}),[We,mn,K,gn,un,fn,Ct,pn,Ke,F,he,ro])}function vl(e){return e!=null&&"current"in e}function Xn(e){return e==="starting"?Za:be}function xl(e,t,{styles:o,transitionStatus:n,props:r,refs:i,hidden:s,inert:a=!1}){let d={...o};return a&&(d.pointerEvents="none"),Ce("div",e,{state:t,ref:i,props:[{role:"presentation",hidden:s,style:d},Xn(n),r],stateAttributesMapping:Ro})}var Rl=h(z(),1);var _i=Rl.forwardRef(function(t,o){let{render:n,className:r,disabled:i=!1,focusableWhenDisabled:s=!1,nativeButton:a=!0,style:d,...c}=t,{getButtonProps:l,buttonRef:f}=Ea({disabled:i,focusableWhenDisabled:s,native:a});return Ce("button",t,{state:{disabled:i},ref:[o,f],props:[c,l]})});var Le=h(z(),1),Cl=h(Mt(),1);var Sl=h(z(),1);function El(e){let[t,o]=Sl.useState({current:e,previous:null});return e!==t.current&&o({current:e,previous:t.current}),t.previous}var So=h(z(),1);function yi(e){let t=Ae(e),o=parseFloat(t.width)||0,n=parseFloat(t.height)||0,r=we(e),i=r?e.offsetWidth:o,s=r?e.offsetHeight:n;return(zt(o)!==i||zt(n)!==s)&&(o=i,n=s),{width:o,height:n}}function kl(e){let{popupElement:t,positionerElement:o,content:n,mounted:r,onMeasureLayout:i,onMeasureLayoutComplete:s,side:a,direction:d}=e,c=mo(t,!0,!1),l=lo(),f=So.useRef(null),p=So.useRef(!0),m=So.useRef(Nt),u=Y(i),g=Y(s),v=So.useMemo(()=>{let _=a==="top",w=a==="left";return d==="rtl"?(_=_||a==="inline-end",w=w||a==="inline-end"):(_=_||a==="inline-start",w=w||a==="inline-start"),_?{position:"absolute",[a==="top"?"bottom":"top"]:"0",[w?"right":"left"]:"0"}:be},[a,d]);D(()=>{if(!r){m.current=Nt,p.current=!0,f.current=null;return}if(!t||!o)return;m.current=Tl(t,v),xi(t,"auto");let _=qn(t,"position","static"),w=qn(t,"transform","none"),y=qn(t,"scale","1"),b=Tl(o,{"--available-width":"max-content","--available-height":"max-content"});function S(){_(),w(),b()}function x(){S(),y()}if(u?.(),p.current||f.current===null){Kn(o,"max-content");let C=yi(t);return f.current=C,Kn(o,C),x(),g?.(null,C),p.current=!1,()=>{m.current(),m.current=Nt}}Kn(o,"max-content");let E=f.current,T=yi(t);f.current=T,xi(t,E),x(),g?.(E,T),Kn(o,T);let k=new AbortController;return l.request(()=>{xi(t,T),c(()=>{t.style.setProperty("--popup-width","auto"),t.style.setProperty("--popup-height","auto")},k.signal)}),()=>{k.abort(),l.cancel(),m.current(),m.current=Nt}},[n,t,o,c,l,r,u,g,v])}function qn(e,t,o){let n=e.style.getPropertyValue(t);return e.style.setProperty(t,o),()=>{e.style.setProperty(t,n)}}function Tl(e,t){let o=[];for(let[n,r]of Object.entries(t))o.push(qn(e,n,r));return o.length?()=>{o.forEach(n=>n())}:Nt}function xi(e,t){let o=t==="auto"?"auto":`${t.width}px`,n=t==="auto"?"auto":`${t.height}px`;e.style.setProperty("--popup-width",o),e.style.setProperty("--popup-height",n)}function Kn(e,t){let o=t==="max-content"?"max-content":`${t.width}px`,n=t==="max-content"?"max-content":`${t.height}px`;e.style.setProperty("--positioner-width",o),e.style.setProperty("--positioner-height",n)}var Eo=h(Q(),1);function Al(e){let{store:t,side:o,cssVars:n,children:r}=e,i=so(),s=t.useState("activeTriggerElement"),a=t.useState("activeTriggerId"),d=t.useState("open"),c=t.useState("payload"),l=t.useState("mounted"),f=t.useState("popupElement"),p=t.useState("positionerElement"),m=El(d?s:null),u=ug(a,c),g=Le.useRef(null),[v,_]=Le.useState(null),[w,y]=Le.useState(null),b=Le.useRef(null),S=Le.useRef(null),x=mo(b,!0,!1),E=lo(),[T,k]=Le.useState(null),[C,j]=Le.useState(!1);D(()=>(t.set("hasViewport",!0),()=>{t.set("hasViewport",!1)}),[t]);let A=Y(()=>{b.current?.style.setProperty("animation","none"),b.current?.style.setProperty("transition","none"),S.current?.style.setProperty("display","none")}),L=Y(P=>{b.current?.style.removeProperty("animation"),b.current?.style.removeProperty("transition"),S.current?.style.removeProperty("display"),P&&k(P)}),I=Le.useRef(null);D(()=>{(!d||!l)&&(I.current=null)},[d,l]),D(()=>{if(s&&m&&s!==m&&I.current!==s&&g.current){_(g.current),j(!0);let P=dg(m,s);y(P),E.request(()=>{Cl.flushSync(()=>{j(!1)}),x(()=>{_(null),k(null),g.current=null})}),I.current=s}},[s,m,v,x,E]),D(()=>{let P=b.current;if(!P)return;let O=xe(P).createElement("div");for(let M of Array.from(P.childNodes))O.appendChild(M.cloneNode(!0));g.current=O});let R=v!=null,N;R?N=(0,Eo.jsxs)(Le.Fragment,{children:[(0,Eo.jsx)("div",{"data-previous":!0,inert:ml(!0),ref:S,style:{...T?{[n.popupWidth]:`${T.width}px`,[n.popupHeight]:`${T.height}px`}:null,position:"absolute"},"data-ending-style":C?void 0:""},"previous"),(0,Eo.jsx)("div",{"data-current":!0,ref:b,"data-starting-style":C?"":void 0,children:r},u)]}):N=(0,Eo.jsx)("div",{"data-current":!0,ref:b,children:r},u),D(()=>{let P=S.current;!P||!v||P.replaceChildren(...Array.from(v.childNodes))},[v]),kl({popupElement:f,positionerElement:p,mounted:l,content:c,onMeasureLayout:A,onMeasureLayoutComplete:L,side:o,direction:i});let H={activationDirection:lg(w),transitioning:R};return{children:N,state:H}}function lg(e){if(e)return`${Pl(e.horizontal,5,"right","left")} ${Pl(e.vertical,5,"down","up")}`}function Pl(e,t,o,n){return e>t?o:e<-t?n:""}function dg(e,t){let o=e.getBoundingClientRect(),n=t.getBoundingClientRect(),r={x:o.left+o.width/2,y:o.top+o.height/2},i={x:n.left+n.width/2,y:n.top+n.height/2};return{horizontal:i.x-r.x,vertical:i.y-r.y}}function ug(e,t){let[o,n]=Le.useState(0),r=Le.useRef(e),i=Le.useRef(t),s=Le.useRef(!1);return D(()=>{let a=r.current,d=i.current,c=e!==a,l=t!==d;c?(n(f=>f+1),s.current=!l):s.current&&l&&(n(f=>f+1),s.current=!1),r.current=e,i.current=t},[e,t]),`${e??"current"}-${o}`}var Zn=h(z(),1),Ol=h(Mt(),1);var Nl=h(Q(),1),Ll=Zn.forwardRef(function(t,o){let{children:n,container:r,className:i,render:s,style:a,...d}=t,{portalNode:c,portalSubtree:l}=Ur({container:r,ref:o,componentProps:t,elementProps:d});return!l&&!c?null:(0,Nl.jsxs)(Zn.Fragment,{children:[l,c&&Ol.createPortal(n,c)]})});var Qe={};At(Qe,{Arrow:()=>ql,Handle:()=>Qo,Popup:()=>Xl,Portal:()=>Wl,Positioner:()=>Ul,Provider:()=>Zl,Root:()=>Ml,Trigger:()=>jl,Viewport:()=>$l,createHandle:()=>ed});var gt=h(z(),1);var Qn=h(z(),1),Ri=Qn.createContext(void 0);function Ze(e){let t=Qn.useContext(Ri);if(t===void 0&&!e)throw new Error(Pe(72));return t}var Il=h(z(),1);var fg={...ll,disabled:q(e=>e.disabled),instantType:q(e=>e.instantType),isInstantPhase:q(e=>e.isInstantPhase),trackCursorAxis:q(e=>e.trackCursorAxis),disableHoverablePopup:q(e=>e.disableHoverablePopup),lastOpenChangeReason:q(e=>e.openChangeReason),closeOnClick:q(e=>e.closeOnClick),closeDelay:q(e=>e.closeDelay),hasViewport:q(e=>e.hasViewport)},To=class e extends vo{constructor(t,o,n=!1){let r=new jt,i={...pg(),...t};i.floatingRootContext=al(r,o,n),super(i,{popupRef:Il.createRef(),onOpenChange:void 0,onOpenChangeComplete:void 0,triggerElements:r},fg)}setOpen=(t,o)=>{Jc(this,t,o,{extraState:{openChangeReason:o.reason}})};cancelPendingOpen(t){this.state.floatingRootContext.dispatchOpenChange(!1,ee(U.triggerPress,t))}static useStore(t,o){return Qc(t,(r,i)=>new e(o,r,i)).store}};function pg(){return{...sl(),disabled:!1,instantType:void 0,isInstantPhase:!1,trackCursorAxis:"none",disableHoverablePopup:!1,openChangeReason:null,closeOnClick:!0,closeDelay:0,hasViewport:!1}}var Jn=h(Q(),1),Ml=ci(function(t){let{disabled:o=!1,defaultOpen:n=!1,open:r,disableHoverablePopup:i=!1,trackCursorAxis:s="none",actionsRef:a,onOpenChange:d,onOpenChangeComplete:c,handle:l,triggerId:f,defaultTriggerId:p=null,children:m}=t,u=To.useStore(l?.store,{open:n,openProp:r,activeTriggerId:p,triggerIdProp:f});$c(u,r,n,p),u.useControlledProp("openProp",r),u.useControlledProp("triggerIdProp",f),u.useContextCallback("onOpenChange",d),u.useContextCallback("onOpenChangeComplete",c);let g=u.useState("open"),v=!o&&g,_=u.useState("activeTriggerId"),w=u.useState("mounted"),y=u.useState("payload");u.useSyncedValues({trackCursorAxis:s,disableHoverablePopup:i}),u.useSyncedValue("disabled",o),tl(u,{closeOnActiveTriggerUnmount:!0});let{forceUnmount:b,transitionStatus:S}=ol(v,u),x=u.useState("isInstantPhase"),E=u.useState("instantType"),T=u.useState("lastOpenChangeReason"),k=gt.useRef(null);D(()=>{g&&o&&u.setOpen(!1,ee(U.disabled))},[g,o,u]),D(()=>{S==="ending"&&T===U.none||S!=="ending"&&x?(E!=="delay"&&(k.current=E),u.set("instantType","delay")):k.current!==null&&(u.set("instantType",k.current),k.current=null)},[S,x,T,E,u]),D(()=>{v&&_==null&&u.set("payload",void 0)},[u,_,v]);let C=gt.useCallback(()=>{u.setOpen(!1,ee(U.imperativeAction))},[u]);gt.useImperativeHandle(a,()=>({unmount:b,close:C}),[b,C]);let j=v||w||!o&&s!=="none";return(0,Jn.jsxs)(Ri.Provider,{value:u,children:[j&&(0,Jn.jsx)(mg,{store:u,disabled:o,trackCursorAxis:s}),typeof m=="function"?m({payload:y}):m]})});function mg({store:e,disabled:t,trackCursorAxis:o}){let n=e.useState("floatingRootContext"),r=Xr(n,{enabled:!t,referencePress:()=>e.select("closeOnClick")}),i=Gr(n,{enabled:!t&&o!=="none",axis:o==="none"?void 0:o}),s=gt.useMemo(()=>ye(i.reference,r.reference),[i.reference,r.reference]),a=gt.useMemo(()=>ye(i.trigger,r.trigger),[i.trigger,r.trigger]),d=gt.useMemo(()=>ye(Zc,i.floating,r.floating),[i.floating,r.floating]);return nl(e,{activeTriggerProps:s,inactiveTriggerProps:a,popupProps:d}),null}var er=h(z(),1);var $n=h(z(),1),Si=$n.createContext(void 0);function Bl(){return $n.useContext(Si)}var Hl=(function(e){return e[e.popupOpen=qo.popupOpen]="popupOpen",e.triggerDisabled="data-trigger-disabled",e})({});var Dl="data-base-ui-tooltip-trigger";function zl(e){if("composedPath"in e){let o=e.composedPath();for(let n=0;ng.select("transitionStatus")==="ending",shouldOpen(){return!O.current}}),G=pi(y,{enabled:!R}).reference,K=X=>{let pe=O.current,ue=zl(X),vt=te(ue),Te=b.current,Ve=Te&&ue&&ie(Te,ue);if(vt&&g.select("open")&&g.select("lastOpenChangeReason")===U.triggerHover){g.setOpen(!1,ee(U.triggerHover,X));return}if(pe&&!vt&&Ve&&!N.current&&!g.select("open")&&Te&&Rt(Z.current)){let Ke=()=>{!O.current&&!N.current&&!g.select("open")&&g.setOpen(!0,ee(U.triggerHover,X,Te))},He=W();He===0?(M.clear(),Ke()):M.start(He,Ke)}},J=g.useState("triggerProps",T);return Ce("button",t,{state:{open:w},ref:[o,E,b],props:[se,G,T||H!=="none"?J:void 0,{onMouseOver(X){K(X.nativeEvent)},onFocus(X){oe(zl(X.nativeEvent))&&X.preventBaseUIHandler()},onMouseLeave(){O.current=!1,M.clear(),Z.current=void 0},onPointerEnter(X){Z.current=X.pointerType},onPointerDown(X){Z.current=X.pointerType,g.set("closeOnClick",l),l&&!g.select("open")&&g.cancelPendingOpen(X.nativeEvent)},onClick(X){l&&!g.select("open")&&g.cancelPendingOpen(X.nativeEvent)},id:v,[Hl.triggerDisabled]:R?"":void 0,[Dl]:R?void 0:""},m],stateAttributesMapping:pl})});var Vl=h(z(),1);var tr=h(z(),1),Ei=tr.createContext(void 0);function Fl(){let e=tr.useContext(Ei);if(e===void 0)throw new Error(Pe(70));return e}var Ti=h(Q(),1),Wl=Vl.forwardRef(function(t,o){let{keepMounted:n=!1,...r}=t;return Ze().useState("mounted")||n?(0,Ti.jsx)(Ei.Provider,{value:n,children:(0,Ti.jsx)(Ll,{ref:o,...r})}):null});var nr=h(z(),1);var or=h(z(),1),ki=or.createContext(void 0);function ko(){let e=or.useContext(ki);if(e===void 0)throw new Error(Pe(71));return e}var Yl=h(Q(),1),Ul=nr.forwardRef(function(t,o){let{render:n,className:r,anchor:i,positionMethod:s="absolute",side:a="top",align:d="center",sideOffset:c=0,alignOffset:l=0,collisionBoundary:f="clipping-ancestors",collisionPadding:p=5,arrowPadding:m=5,sticky:u=!1,disableAnchorTracking:g=!1,collisionAvoidance:v=Qa,style:_,...w}=t,y=Ze(),b=Fl(),S=y.useState("open"),x=y.useState("mounted"),E=y.useState("trackCursorAxis"),T=y.useState("disableHoverablePopup"),k=y.useState("floatingRootContext"),C=y.useState("instantType"),j=y.useState("transitionStatus"),A=y.useState("hasViewport"),L=yl({anchor:i,positionMethod:s,floatingRootContext:k,mounted:x,side:a,sideOffset:c,align:d,alignOffset:l,collisionBoundary:f,collisionPadding:p,sticky:u,arrowPadding:m,disableAnchorTracking:g,keepMounted:b,collisionAvoidance:v,adaptiveOrigin:A?hl:void 0}),I=nr.useMemo(()=>({open:S,side:L.side,align:L.align,anchorHidden:L.anchorHidden,instant:E!=="none"?"tracking-cursor":C}),[S,L.side,L.align,L.anchorHidden,E,C]),R=xl(t,I,{styles:L.positionerStyles,transitionStatus:j,props:w,refs:[o,y.useStateSetter("positionerElement")],hidden:!x,inert:!S||E==="both"||T});return(0,Yl.jsx)(ki.Provider,{value:L,children:R})});var Gl=h(z(),1);var bg={...Ro,...wa},Xl=Gl.forwardRef(function(t,o){let{render:n,className:r,style:i,...s}=t,a=Ze(),{side:d,align:c}=ko(),l=a.useState("open"),f=a.useState("instantType"),p=a.useState("transitionStatus"),m=a.useState("popupProps"),u=a.useState("floatingRootContext"),g=a.useState("disabled"),v=a.useState("closeDelay");Pn({open:l,ref:a.context.popupRef,onComplete(){l&&a.context.onOpenChangeComplete?.(!0)}}),bi(u,{enabled:!g,closeDelay:v});let _=a.useStateSetter("popupElement");return Ce("div",t,{state:{open:l,side:d,align:c,instant:f,transitionStatus:p},ref:[o,a.context.popupRef,_],props:[m,Xn(p),s],stateAttributesMapping:bg})});var Kl=h(z(),1);var ql=Kl.forwardRef(function(t,o){let{render:n,className:r,style:i,...s}=t,a=Ze(),{arrowRef:d,side:c,align:l,arrowUncentered:f,arrowStyles:p}=ko(),m=a.useState("open"),u=a.useState("instantType");return Ce("div",t,{state:{open:m,side:c,align:l,uncentered:f,instant:u},ref:[o,d],props:[{style:p,"aria-hidden":!0},s],stateAttributesMapping:Ro})});var Pi=h(z(),1);var Ci=h(Q(),1),Zl=function(t){let{delay:o,closeDelay:n,timeout:r=400}=t,i=Pi.useMemo(()=>({delay:o,closeDelay:n}),[o,n]),s=Pi.useMemo(()=>({open:o,close:n}),[o,n]);return(0,Ci.jsx)(Si.Provider,{value:i,children:(0,Ci.jsx)(Wr,{delay:s,timeoutMs:r,children:t.children})})};var Jl=h(z(),1);var Ql=(function(e){return e.popupWidth="--popup-width",e.popupHeight="--popup-height",e})({});var hg={activationDirection:e=>e?{"data-activation-direction":e}:null},$l=Jl.forwardRef(function(t,o){let{render:n,className:r,style:i,children:s,...a}=t,d=Ze(),c=ko(),l=d.useState("instantType"),{children:f,state:p}=Al({store:d,side:c.side,cssVars:Ql,children:s}),m={activationDirection:p.activationDirection,transitioning:p.transitioning,instant:l};return Ce("div",t,{state:m,ref:o,props:[a,{children:f}],stateAttributesMapping:hg})});var Qo=class{constructor(){this.store=new To}open(t){let o=t?this.store.context.triggerElements.getById(t):void 0;if(t&&!o)throw new Error(Pe(81,t));this.store.setOpen(!0,ee(U.imperativeAction,void 0,o))}close(){this.store.setOpen(!1,ee(U.imperativeAction,void 0,void 0))}get isOpen(){return this.store.select("open")}};function ed(){return new Qo}function bt(e){return Ce(e.defaultTagName??"div",e,e)}var nd=h(de(),1),Ai="data-wp-hash";function Oi(){let e=globalThis;return e.__wpStyleRuntime||(e.__wpStyleRuntime={documents:new Map,styles:new Map,injectedStyles:new WeakMap},typeof document<"u"&&vg(document)),e.__wpStyleRuntime}function wg(e,t){if(!e.head)return!1;for(let o of e.head.querySelectorAll(`style[${Ai}]`))if(o.getAttribute(Ai)===t)return!0;return!1}function rd(e,t,o){if(!e.head)return;let n=Oi(),r=n.injectedStyles.get(e);if(r||(r=new Set,n.injectedStyles.set(e,r)),r.has(t))return;if(wg(e,t)){r.add(t);return}let i=e.createElement("style");i.setAttribute(Ai,t),i.appendChild(e.createTextNode(o)),e.head.appendChild(i),r.add(t)}function vg(e){let t=Oi();t.documents.set(e,(t.documents.get(e)??0)+1);for(let[o,n]of t.styles)rd(e,o,n);return()=>{let o=t.documents.get(e);if(o!==void 0){if(o<=1){t.documents.delete(e);return}t.documents.set(e,o-1)}}}function id(e,t){let o=Oi();o.styles.set(e,t);for(let n of o.documents.keys())rd(n,e,t)}typeof process>"u",id("a495f9d138",'@layer wp-ui{@layer utilities, components, compositions, overrides;@layer components{._83ed8a8da5dd50ea__text{margin:0}._14437cfb77831647__heading-2xl{--_gcd-heading-font-size:var(--wpds-typography-font-size-2xl,32px);--_gcd-heading-font-weight:var(--wpds-typography-font-weight-emphasis,600);--_gcd-p-font-size:var(--wpds-typography-font-size-2xl,32px);--_gcd-p-line-height:var(--wpds-typography-line-height-2xl,40px);font-size:var(--wpds-typography-font-size-2xl,32px);line-height:var(--wpds-typography-line-height-2xl,40px)}._14437cfb77831647__heading-2xl,._3c78b7fa9b4072dd__heading-xl{font-family:var(--wpds-typography-font-family-heading,-apple-system,system-ui,"Segoe UI","Roboto","Oxygen-Sans","Ubuntu","Cantarell","Helvetica Neue",sans-serif);font-weight:var(--wpds-typography-font-weight-emphasis,600)}._3c78b7fa9b4072dd__heading-xl{--_gcd-heading-font-size:var(--wpds-typography-font-size-xl,20px);--_gcd-heading-font-weight:var(--wpds-typography-font-weight-emphasis,600);--_gcd-p-font-size:var(--wpds-typography-font-size-xl,20px);--_gcd-p-line-height:var(--wpds-typography-line-height-md,24px);font-size:var(--wpds-typography-font-size-xl,20px);line-height:var(--wpds-typography-line-height-md,24px)}.aa58f227716bcde2__heading-lg{--_gcd-heading-font-size:var(--wpds-typography-font-size-lg,15px);--_gcd-heading-font-weight:var(--wpds-typography-font-weight-emphasis,600);--_gcd-p-font-size:var(--wpds-typography-font-size-lg,15px);--_gcd-p-line-height:var(--wpds-typography-line-height-sm,20px);font-size:var(--wpds-typography-font-size-lg,15px)}.aa58f227716bcde2__heading-lg,.fc4da56d8dfe52c4__heading-md{font-family:var(--wpds-typography-font-family-heading,-apple-system,system-ui,"Segoe UI","Roboto","Oxygen-Sans","Ubuntu","Cantarell","Helvetica Neue",sans-serif);font-weight:var(--wpds-typography-font-weight-emphasis,600);line-height:var(--wpds-typography-line-height-sm,20px)}.fc4da56d8dfe52c4__heading-md{--_gcd-heading-font-size:var(--wpds-typography-font-size-md,13px);--_gcd-heading-font-weight:var(--wpds-typography-font-weight-emphasis,600);--_gcd-p-font-size:var(--wpds-typography-font-size-md,13px);--_gcd-p-line-height:var(--wpds-typography-line-height-sm,20px);font-size:var(--wpds-typography-font-size-md,13px)}.a9b78c7c82e8dff7__heading-sm{--_gcd-heading-font-size:var(--wpds-typography-font-size-xs,11px);--_gcd-heading-font-weight:var(--wpds-typography-font-weight-emphasis,600);--_gcd-p-font-size:var(--wpds-typography-font-size-xs,11px);--_gcd-p-line-height:var(--wpds-typography-line-height-xs,16px);font-family:var(--wpds-typography-font-family-heading,-apple-system,system-ui,"Segoe UI","Roboto","Oxygen-Sans","Ubuntu","Cantarell","Helvetica Neue",sans-serif);font-size:var(--wpds-typography-font-size-xs,11px);font-weight:var(--wpds-typography-font-weight-emphasis,600);line-height:var(--wpds-typography-line-height-xs,16px);text-transform:uppercase}._305ff559e52180d5__body-xl{--_gcd-heading-font-size:var(--wpds-typography-font-size-xl,20px);--_gcd-heading-font-weight:var(--wpds-typography-font-weight-default,400);--_gcd-p-font-size:var(--wpds-typography-font-size-xl,20px);--_gcd-p-line-height:var(--wpds-typography-line-height-xl,32px);font-size:var(--wpds-typography-font-size-xl,20px);line-height:var(--wpds-typography-line-height-xl,32px)}._305ff559e52180d5__body-xl,.ca1aa3fc2029e958__body-lg{font-family:var(--wpds-typography-font-family-body,-apple-system,system-ui,"Segoe UI","Roboto","Oxygen-Sans","Ubuntu","Cantarell","Helvetica Neue",sans-serif);font-weight:var(--wpds-typography-font-weight-default,400)}.ca1aa3fc2029e958__body-lg{--_gcd-heading-font-size:var(--wpds-typography-font-size-lg,15px);--_gcd-heading-font-weight:var(--wpds-typography-font-weight-default,400);--_gcd-p-font-size:var(--wpds-typography-font-size-lg,15px);--_gcd-p-line-height:var(--wpds-typography-line-height-md,24px);font-size:var(--wpds-typography-font-size-lg,15px);line-height:var(--wpds-typography-line-height-md,24px)}._131101940be12424__body-md{--_gcd-heading-font-size:var(--wpds-typography-font-size-md,13px);--_gcd-heading-font-weight:var(--wpds-typography-font-weight-default,400);--_gcd-p-font-size:var(--wpds-typography-font-size-md,13px);--_gcd-p-line-height:var(--wpds-typography-line-height-sm,20px);font-size:var(--wpds-typography-font-size-md,13px);line-height:var(--wpds-typography-line-height-sm,20px)}._0e8d87a42c1f75fa__body-sm,._131101940be12424__body-md{font-family:var(--wpds-typography-font-family-body,-apple-system,system-ui,"Segoe UI","Roboto","Oxygen-Sans","Ubuntu","Cantarell","Helvetica Neue",sans-serif);font-weight:var(--wpds-typography-font-weight-default,400)}._0e8d87a42c1f75fa__body-sm{--_gcd-heading-font-size:var(--wpds-typography-font-size-sm,12px);--_gcd-heading-font-weight:var(--wpds-typography-font-weight-default,400);--_gcd-p-font-size:var(--wpds-typography-font-size-sm,12px);--_gcd-p-line-height:var(--wpds-typography-line-height-xs,16px);font-size:var(--wpds-typography-font-size-sm,12px);line-height:var(--wpds-typography-line-height-xs,16px)}}}');var td={text:"_83ed8a8da5dd50ea__text","heading-2xl":"_14437cfb77831647__heading-2xl","heading-xl":"_3c78b7fa9b4072dd__heading-xl","heading-lg":"aa58f227716bcde2__heading-lg","heading-md":"fc4da56d8dfe52c4__heading-md","heading-sm":"a9b78c7c82e8dff7__heading-sm","body-xl":"_305ff559e52180d5__body-xl","body-lg":"ca1aa3fc2029e958__body-lg","body-md":"_131101940be12424__body-md","body-sm":"_0e8d87a42c1f75fa__body-sm"};typeof process>"u",id("af6d9984a6","._6defc79820e382c6__button{box-sizing:var(--_gcd-button-box-sizing,border-box);font-family:var(--_gcd-button-font-family,inherit);font-size:var(--_gcd-button-font-size,inherit);font-weight:var(--_gcd-button-font-weight,inherit)}.d2cff2e5dea83bd1__input{box-sizing:var(--_gcd-input-box-sizing,border-box);font-family:var(--_gcd-input-font-family,inherit);font-size:var(--_gcd-input-font-size,inherit);font-weight:var(--_gcd-input-font-weight,inherit);margin:var(--_gcd-input-margin,0);&:is(textarea,[type=text],[type=password],[type=color],[type=date],[type=datetime],[type=datetime-local],[type=email],[type=month],[type=number],[type=search],[type=tel],[type=time],[type=url],[type=week]){background-color:var(--_gcd-input-background-color,transparent);border:var(--_gcd-input-border,none);border-radius:var(--_gcd-input-border-radius,0);box-shadow:var(--_gcd-input-box-shadow,0 0 0 transparent);color:var(--_gcd-input-color,var(--wpds-color-foreground-interactive-neutral,#1e1e1e));&:focus{border-color:var(--_gcd-input-border-color-focus,var(--wp-admin-theme-color));box-shadow:var(--_gcd-input-box-shadow-focus,none);outline:var(--_gcd-input-outline-focus,none)}&:disabled{background:var(--_gcd-input-background-disabled,transparent);border-color:var(--_gcd-input-border-color-disabled,transparent);box-shadow:var(--_gcd-input-box-shadow-disabled,none);color:var(--_gcd-input-color-disabled,var(--wpds-color-foreground-interactive-neutral-disabled,#8d8d8d))}&::placeholder{color:var(--_gcd-input-placeholder-color,var(--wpds-color-foreground-interactive-neutral-disabled,#8d8d8d))}}&:is(textarea,[type=text],[type=password],[type=date],[type=datetime],[type=datetime-local],[type=email],[type=month],[type=number],[type=search],[type=tel],[type=time],[type=url],[type=week]){line-height:var(--_gcd-input-line-height,inherit);min-height:var(--_gcd-input-min-height,auto);padding:var(--_gcd-input-padding,0)}}._547d86373d02e108__textarea{box-sizing:var(--_gcd-textarea-box-sizing,border-box);overflow:var(--_gcd-textarea-overflow,auto);resize:var(--_gcd-textarea-resize,block)}._8c15fd0ed9f28ba4__div{outline:var(--_gcd-div-outline,0 solid transparent)}p._43cec3e1eec1066d__p{font-size:var(--_gcd-p-font-size,13px);line-height:var(--_gcd-p-line-height,1.5);margin:var(--_gcd-p-margin,0)}:is(h1,h2,h3,h4,h5,h6).e97669c6d9a38497__heading{color:var(--_gcd-heading-color,var(--wpds-color-foreground-content-neutral,#1e1e1e));font-size:var(--_gcd-heading-font-size,inherit);font-weight:var(--_gcd-heading-font-weight,var(--wpds-typography-font-weight-emphasis,600));margin:var(--_gcd-heading-margin,0)}._2c0831b0499dbd6e__a,._2c0831b0499dbd6e__a:is(:hover,:focus,:active){border-radius:var(--_gcd-a-border-radius,0);box-shadow:var(--_gcd-a-box-shadow,none);color:var(--_gcd-a-color,inherit);outline:var(--_gcd-a-outline,0 solid transparent);transition:var(--_gcd-a-transition,none)}");var od={button:"_6defc79820e382c6__button",input:"d2cff2e5dea83bd1__input",textarea:"_547d86373d02e108__textarea",div:"_8c15fd0ed9f28ba4__div",p:"_43cec3e1eec1066d__p",heading:"e97669c6d9a38497__heading",a:"_2c0831b0499dbd6e__a"},Je=(0,nd.forwardRef)(function({variant:t="body-md",render:o,className:n,...r},i){return bt({render:o,defaultTagName:"span",ref:i,props:ye(r,{className:$(td.text,od.heading,od.p,td[t],n)})})});var ld=h(Q(),1),Ni="data-wp-hash";function Li(){let e=globalThis;return e.__wpStyleRuntime||(e.__wpStyleRuntime={documents:new Map,styles:new Map,injectedStyles:new WeakMap},typeof document<"u"&&yg(document)),e.__wpStyleRuntime}function _g(e,t){if(!e.head)return!1;for(let o of e.head.querySelectorAll(`style[${Ni}]`))if(o.getAttribute(Ni)===t)return!0;return!1}function cd(e,t,o){if(!e.head)return;let n=Li(),r=n.injectedStyles.get(e);if(r||(r=new Set,n.injectedStyles.set(e,r)),r.has(t))return;if(_g(e,t)){r.add(t);return}let i=e.createElement("style");i.setAttribute(Ni,t),i.appendChild(e.createTextNode(o)),e.head.appendChild(i),r.add(t)}function yg(e){let t=Li();t.documents.set(e,(t.documents.get(e)??0)+1);for(let[o,n]of t.styles)cd(e,o,n);return()=>{let o=t.documents.get(e);if(o!==void 0){if(o<=1){t.documents.delete(e);return}t.documents.set(e,o-1)}}}function xg(e,t){let o=Li();o.styles.set(e,t);for(let n of o.documents.keys())cd(n,e,t)}typeof process>"u",xg("9db2873e7f","@layer wp-ui{@layer utilities, components, compositions, overrides;@layer components{._96e6251aad1a6136__badge{border-radius:var(--wpds-border-radius-lg,8px);padding-block:var(--wpds-dimension-padding-xs,4px);padding-inline:var(--wpds-dimension-padding-sm,8px)}._99f7158cb520f750__is-high-intent{background-color:var(--wpds-color-background-surface-error,#f6e6e3);color:var(--wpds-color-foreground-content-error,#470000)}.c20ebef2365bc8b7__is-medium-intent{background-color:var(--wpds-color-background-surface-warning,#fde6be);color:var(--wpds-color-foreground-content-warning,#2e1900)}._365e1626c6202e52__is-low-intent{background-color:var(--wpds-color-background-surface-caution,#fee995);color:var(--wpds-color-foreground-content-caution,#281d00)}._33f8198127ddf4ef__is-stable-intent{background-color:var(--wpds-color-background-surface-success,#c6f7cd);color:var(--wpds-color-foreground-content-success,#002900)}._04c1aca8fc449412__is-informational-intent{background-color:var(--wpds-color-background-surface-info,#deebfa);color:var(--wpds-color-foreground-content-info,#001b4f)}._90726e69d495ec19__is-draft-intent{background-color:var(--wpds-color-background-surface-neutral-weak,#f4f4f4);color:var(--wpds-color-foreground-content-neutral,#1e1e1e)}._898f4a544993bd39__is-none-intent{background-color:var(--wpds-color-background-surface-neutral-strong,#fff);border:var(--wpds-border-width-xs,1px) solid var(--wpds-color-stroke-surface-neutral,#dbdbdb);color:var(--wpds-color-foreground-content-neutral,#1e1e1e);padding-block:calc(var(--wpds-dimension-padding-xs, 4px) - var(--wpds-border-width-xs, 1px));padding-inline:calc(var(--wpds-dimension-padding-sm, 8px) - var(--wpds-border-width-xs, 1px))}}}");var sd={badge:"_96e6251aad1a6136__badge","is-high-intent":"_99f7158cb520f750__is-high-intent","is-medium-intent":"c20ebef2365bc8b7__is-medium-intent","is-low-intent":"_365e1626c6202e52__is-low-intent","is-stable-intent":"_33f8198127ddf4ef__is-stable-intent","is-informational-intent":"_04c1aca8fc449412__is-informational-intent","is-draft-intent":"_90726e69d495ec19__is-draft-intent","is-none-intent":"_898f4a544993bd39__is-none-intent"},Ii=(0,ad.forwardRef)(function({intent:t="none",className:o,...n},r){return(0,ld.jsx)(Je,{ref:r,className:$(sd.badge,sd[`is-${t}-intent`],o),...n,variant:"body-sm"})});var rr=h(de(),1),dd=h(Ot(),1),fd=h(Q(),1);import{speak as Rg}from"@wordpress/a11y";var Mi="data-wp-hash";function Bi(){let e=globalThis;return e.__wpStyleRuntime||(e.__wpStyleRuntime={documents:new Map,styles:new Map,injectedStyles:new WeakMap},typeof document<"u"&&Eg(document)),e.__wpStyleRuntime}function Sg(e,t){if(!e.head)return!1;for(let o of e.head.querySelectorAll(`style[${Mi}]`))if(o.getAttribute(Mi)===t)return!0;return!1}function ud(e,t,o){if(!e.head)return;let n=Bi(),r=n.injectedStyles.get(e);if(r||(r=new Set,n.injectedStyles.set(e,r)),r.has(t))return;if(Sg(e,t)){r.add(t);return}let i=e.createElement("style");i.setAttribute(Mi,t),i.appendChild(e.createTextNode(o)),e.head.appendChild(i),r.add(t)}function Eg(e){let t=Bi();t.documents.set(e,(t.documents.get(e)??0)+1);for(let[o,n]of t.styles)ud(e,o,n);return()=>{let o=t.documents.get(e);if(o!==void 0){if(o<=1){t.documents.delete(e);return}t.documents.set(e,o-1)}}}function ir(e,t){let o=Bi();o.styles.set(e,t);for(let n of o.documents.keys())ud(n,e,t)}typeof process>"u",ir("b74f1ac304",'@layer wp-ui{@layer utilities, components, compositions, overrides;@layer components{._97b0fc33c028be1a__button,.abbb272e2ce49bd6__is-unstyled{appearance:none;padding:0}._97b0fc33c028be1a__button{--wp-ui-button-font-weight:var(--wpds-typography-font-weight-emphasis,600);--wp-ui-button-background-color:var(--wpds-color-background-interactive-brand-strong,var(--wp-admin-theme-color,#3858e9));--wp-ui-button-background-color-active:var(--wpds-color-background-interactive-brand-strong-active,color-mix(in oklch,var(--wp-admin-theme-color,#3858e9) 93%,#000));--wp-ui-button-background-color-disabled:var(--wpds-color-background-interactive-brand-strong-disabled,#e6e6e6);--wp-ui-button-foreground-color:var(--wpds-color-foreground-interactive-brand-strong,#fff);--wp-ui-button-foreground-color-active:var(--wpds-color-foreground-interactive-brand-strong-active,#fff);--wp-ui-button-foreground-color-disabled:var(--wpds-color-foreground-interactive-brand-strong-disabled,#8d8d8d);--wp-ui-button-padding-block:var(--wpds-dimension-padding-xs,4px);--wp-ui-button-padding-inline:var(--wpds-dimension-padding-md,12px);--wp-ui-button-height:var(--wpds-dimension-size-lg,40px);--wp-ui-button-aspect-ratio:auto;--wp-ui-button-font-size:var(--wpds-typography-font-size-md,13px);--wp-ui-button-min-width:calc(4ch + var(--wp-ui-button-padding-inline)*2);--wp-ui-button-icon-margin:calc((var(--wpds-dimension-size-2xs, 16px) - var(--wpds-dimension-size-sm, 24px))/2);--wp-ui-button-border-color:var(--wp-ui-button-background-color);--wp-ui-button-border-color-active:var(--wp-ui-button-background-color-active);--wp-ui-button-border-color-disabled:var(--wp-ui-button-background-color-disabled);--_gcd-button-font-family:var(--wpds-typography-font-family-body,-apple-system,system-ui,"Segoe UI","Roboto","Oxygen-Sans","Ubuntu","Cantarell","Helvetica Neue",sans-serif);--_gcd-button-font-size:var(--wp-ui-button-font-size);--_gcd-button-font-weight:var(--wp-ui-button-font-weight);align-items:center;aspect-ratio:var(--wp-ui-button-aspect-ratio);background-clip:border-box;background-color:var(--wp-ui-button-background-color);border-color:var(--wp-ui-button-border-color);border-radius:var(--wpds-border-radius-sm,2px);border-style:solid;border-width:1px;color:var(--wp-ui-button-foreground-color);display:inline-flex;font-family:var(--wpds-typography-font-family-body,-apple-system,system-ui,"Segoe UI","Roboto","Oxygen-Sans","Ubuntu","Cantarell","Helvetica Neue",sans-serif);font-size:var(--wp-ui-button-font-size);font-weight:var(--wp-ui-button-font-weight);gap:var(--wpds-dimension-gap-sm,8px);justify-content:center;line-height:var(--wpds-typography-line-height-sm,20px);max-width:100%;min-height:var(--wp-ui-button-height);min-width:var(--wp-ui-button-min-width);overflow-wrap:anywhere;padding-block:var(--wp-ui-button-padding-block);padding-inline:var(--wp-ui-button-padding-inline);position:relative;text-align:center;text-decoration:none;&:not([data-disabled]){cursor:var(--wpds-cursor-control,pointer)}@media not (prefers-reduced-motion){transition:color .1s ease-out;*{transition:opacity .1s ease-out}}&[href]{cursor:pointer}[href]{color:inherit;text-decoration:inherit}&:not([data-disabled]):is(:hover,:active,:focus){background-color:var(--wp-ui-button-background-color-active);border-color:var(--wp-ui-button-border-color-active);color:var(--wp-ui-button-foreground-color-active)}&[data-disabled]:not(._914b42f315c0e580__is-loading){background-color:var(--wp-ui-button-background-color-disabled);border-color:var(--wp-ui-button-border-color-disabled);color:var(--wp-ui-button-foreground-color-disabled);@media (forced-colors:active){border-bottom-color:GrayText;border-left-color:GrayText;border-right-color:GrayText;border-top-color:GrayText;color:GrayText}}&:before{aspect-ratio:1;border:var(--wpds-border-width-focus,var(--wp-admin-border-width-focus,2px)) solid;border-block-end-color:transparent;border-block-start-color:var(--wp-ui-button-foreground-color);border-inline-end-color:var(--wp-ui-button-foreground-color);border-inline-start-color:transparent;border-radius:50%;box-sizing:border-box;content:"";display:block;height:var(--wp-ui-button-font-size);inset-inline-start:50%;opacity:0;pointer-events:none;position:absolute;top:50%;transform:translate(-50%,-50%);@media not (prefers-reduced-motion){transition:opacity .1s ease-out}@media (forced-colors:active){border-block-end-style:none;border-bottom-color:ButtonText;border-inline-start-style:none;border-left-color:ButtonText;border-right-color:ButtonText;border-top-color:ButtonText}}}._908205475f9f2a92__is-small{--wp-ui-button-padding-block:0px;--wp-ui-button-padding-inline:var(--wpds-dimension-padding-sm,8px);--wp-ui-button-height:var(--wpds-dimension-size-sm,24px)}._9f6fc6553aeb36fe__icon{margin:var(--wp-ui-button-icon-margin)}.dd460c965226cc77__is-brand{&._62d5a778b7b258ee__is-outline,&.ad0619a3217c6a5b__is-minimal{--wp-ui-button-foreground-color:var(--wpds-color-foreground-interactive-brand,var(--wp-admin-theme-color,#3858e9));--wp-ui-button-foreground-color-active:var(--wpds-color-foreground-interactive-brand-active,color-mix(in oklch,var(--wp-admin-theme-color,#3858e9) 52%,#000));--wp-ui-button-foreground-color-disabled:var(--wpds-color-foreground-interactive-brand-disabled,#8d8d8d)}&._62d5a778b7b258ee__is-outline{--wp-ui-button-background-color:var(--wpds-color-background-interactive-brand-weak,#0000);--wp-ui-button-background-color-active:var(--wpds-color-background-interactive-brand-weak-active,color-mix(in oklch,var(--wp-admin-theme-color,#3858e9) 12%,#fff));--wp-ui-button-background-color-disabled:var(--wpds-color-background-interactive-brand-weak-disabled,#0000);--wp-ui-button-border-color:var(--wpds-color-stroke-interactive-brand,var(--wp-admin-theme-color,#3858e9));--wp-ui-button-border-color-active:var(--wpds-color-stroke-interactive-brand-active,color-mix(in oklch,var(--wp-admin-theme-color,#3858e9) 85%,#000));--wp-ui-button-border-color-disabled:var(--wpds-color-stroke-interactive-brand-disabled,#dbdbdb)}&.ad0619a3217c6a5b__is-minimal{--wp-ui-button-background-color:var(--wpds-color-background-interactive-brand-weak,#0000);--wp-ui-button-background-color-active:var(--wpds-color-background-interactive-brand-weak-active,color-mix(in oklch,var(--wp-admin-theme-color,#3858e9) 12%,#fff));--wp-ui-button-background-color-disabled:var(--wpds-color-background-interactive-brand-weak-disabled,#0000)}}.e722a8f96726aa99__is-neutral{&.ad0619a3217c6a5b__is-minimal[aria-pressed=true],&.b50b3358c5fb4d0b__is-solid{--wp-ui-button-background-color:var(--wpds-color-background-interactive-neutral-strong,#2d2d2d);--wp-ui-button-background-color-active:var(--wpds-color-background-interactive-neutral-strong-active,#1e1e1e);--wp-ui-button-background-color-disabled:var(--wpds-color-background-interactive-neutral-strong-disabled,#e6e6e6);--wp-ui-button-foreground-color:var(--wpds-color-foreground-interactive-neutral-strong,#f0f0f0);--wp-ui-button-foreground-color-active:var(--wpds-color-foreground-interactive-neutral-strong-active,#f0f0f0);--wp-ui-button-foreground-color-disabled:var(--wpds-color-foreground-interactive-neutral-strong-disabled,#8d8d8d)}&._62d5a778b7b258ee__is-outline,&.ad0619a3217c6a5b__is-minimal:not([aria-pressed=true]){--wp-ui-button-foreground-color:var(--wpds-color-foreground-interactive-neutral,#1e1e1e);--wp-ui-button-foreground-color-active:var(--wpds-color-foreground-interactive-neutral-active,#1e1e1e);--wp-ui-button-foreground-color-disabled:var(--wpds-color-foreground-interactive-neutral-disabled,#8d8d8d)}&._62d5a778b7b258ee__is-outline{--wp-ui-button-background-color:var(--wpds-color-background-interactive-neutral-weak,#0000);--wp-ui-button-background-color-active:var(--wpds-color-background-interactive-neutral-weak-active,#ededed);--wp-ui-button-background-color-disabled:var(--wpds-color-background-interactive-neutral-weak-disabled,#0000);--wp-ui-button-border-color:var(--wpds-color-stroke-interactive-neutral,#8d8d8d);--wp-ui-button-border-color-active:var(--wpds-color-stroke-interactive-neutral-active,#6e6e6e);--wp-ui-button-border-color-disabled:var(--wpds-color-stroke-interactive-neutral-disabled,#dbdbdb)}&.ad0619a3217c6a5b__is-minimal:not([aria-pressed=true]){--wp-ui-button-background-color:var(--wpds-color-background-interactive-neutral-weak,#0000);--wp-ui-button-background-color-active:var(--wpds-color-background-interactive-neutral-weak-active,#ededed);--wp-ui-button-background-color-disabled:var(--wpds-color-background-interactive-neutral-weak-disabled,#0000)}}.abbb272e2ce49bd6__is-unstyled{background:none;border:none;min-width:unset}.cf59cf1b69629838__is-compact{--wp-ui-button-height:var(--wpds-dimension-size-md,32px)}._914b42f315c0e580__is-loading:not(.abbb272e2ce49bd6__is-unstyled){color:transparent;&:not([data-disabled]):is(:hover,:active,:focus){color:transparent}@media (forced-colors:active){color:ButtonFace}*{opacity:0}&:before{opacity:1;transition-delay:.05s;@media not (prefers-reduced-motion){animation:_5a1d53da6f830c8d__loading-animation 1s linear infinite}}}}@keyframes _5a1d53da6f830c8d__loading-animation{0%{transform:translate(-50%,-50%) rotate(0deg)}to{transform:translate(-50%,-50%) rotate(1turn)}}}');var Jo={button:"_97b0fc33c028be1a__button","is-unstyled":"abbb272e2ce49bd6__is-unstyled","is-loading":"_914b42f315c0e580__is-loading","is-small":"_908205475f9f2a92__is-small",icon:"_9f6fc6553aeb36fe__icon","is-brand":"dd460c965226cc77__is-brand","is-outline":"_62d5a778b7b258ee__is-outline","is-minimal":"ad0619a3217c6a5b__is-minimal","is-neutral":"e722a8f96726aa99__is-neutral","is-solid":"b50b3358c5fb4d0b__is-solid","is-compact":"cf59cf1b69629838__is-compact","loading-animation":"_5a1d53da6f830c8d__loading-animation"};typeof process>"u",ir("10f3806643","@layer wp-ui{@layer utilities, components, compositions, overrides;@layer utilities{._336cd3e4e743482f__box-sizing{box-sizing:border-box;*,:after,:before{box-sizing:inherit}}}}");var Tg={"box-sizing":"_336cd3e4e743482f__box-sizing"};typeof process>"u",ir("5f8e7aa0bc","@layer wp-ui{@layer utilities, components, compositions, overrides;@layer utilities{._08e8a2e44959f892__outset-ring--focus:focus,._970d04df7376df67__outset-ring--focus-within-except-active:focus-within:not(:has(:active)),.c5cb3ee4bddaa8e4__outset-ring--focus-within-visible:focus-within:has(:focus-visible),.cd83dfc2126a0846__outset-ring--focus-within:focus-within,.d0541bc9dd9dc7b6__outset-ring--focus-visible:focus-visible,.e25b2bdd7aa21721__outset-ring--focus-except-active:focus:not(:active),:focus-visible .ecadb9e080e2dfa5__outset-ring--focus-parent-visible{--_gcd-a-outline:var(--wpds-border-width-focus,var(--wp-admin-border-width-focus,2px)) solid var(--wpds-color-stroke-focus,var(--wp-admin-theme-color,#3858e9));--_gcd-div-outline:var(--wpds-border-width-focus,var(--wp-admin-border-width-focus,2px)) solid var(--wpds-color-stroke-focus,var(--wp-admin-theme-color,#3858e9));outline:var(--wpds-border-width-focus,var(--wp-admin-border-width-focus,2px)) solid var(--wpds-color-stroke-focus,var(--wp-admin-theme-color,#3858e9));outline-offset:var(--wpds-border-width-focus,var(--wp-admin-border-width-focus,2px))}}}");var kg={"outset-ring--focus":"_08e8a2e44959f892__outset-ring--focus","outset-ring--focus-except-active":"e25b2bdd7aa21721__outset-ring--focus-except-active","outset-ring--focus-visible":"d0541bc9dd9dc7b6__outset-ring--focus-visible","outset-ring--focus-within":"cd83dfc2126a0846__outset-ring--focus-within","outset-ring--focus-within-except-active":"_970d04df7376df67__outset-ring--focus-within-except-active","outset-ring--focus-within-visible":"c5cb3ee4bddaa8e4__outset-ring--focus-within-visible","outset-ring--focus-parent-visible":"ecadb9e080e2dfa5__outset-ring--focus-parent-visible"};typeof process>"u",ir("af6d9984a6","._6defc79820e382c6__button{box-sizing:var(--_gcd-button-box-sizing,border-box);font-family:var(--_gcd-button-font-family,inherit);font-size:var(--_gcd-button-font-size,inherit);font-weight:var(--_gcd-button-font-weight,inherit)}.d2cff2e5dea83bd1__input{box-sizing:var(--_gcd-input-box-sizing,border-box);font-family:var(--_gcd-input-font-family,inherit);font-size:var(--_gcd-input-font-size,inherit);font-weight:var(--_gcd-input-font-weight,inherit);margin:var(--_gcd-input-margin,0);&:is(textarea,[type=text],[type=password],[type=color],[type=date],[type=datetime],[type=datetime-local],[type=email],[type=month],[type=number],[type=search],[type=tel],[type=time],[type=url],[type=week]){background-color:var(--_gcd-input-background-color,transparent);border:var(--_gcd-input-border,none);border-radius:var(--_gcd-input-border-radius,0);box-shadow:var(--_gcd-input-box-shadow,0 0 0 transparent);color:var(--_gcd-input-color,var(--wpds-color-foreground-interactive-neutral,#1e1e1e));&:focus{border-color:var(--_gcd-input-border-color-focus,var(--wp-admin-theme-color));box-shadow:var(--_gcd-input-box-shadow-focus,none);outline:var(--_gcd-input-outline-focus,none)}&:disabled{background:var(--_gcd-input-background-disabled,transparent);border-color:var(--_gcd-input-border-color-disabled,transparent);box-shadow:var(--_gcd-input-box-shadow-disabled,none);color:var(--_gcd-input-color-disabled,var(--wpds-color-foreground-interactive-neutral-disabled,#8d8d8d))}&::placeholder{color:var(--_gcd-input-placeholder-color,var(--wpds-color-foreground-interactive-neutral-disabled,#8d8d8d))}}&:is(textarea,[type=text],[type=password],[type=date],[type=datetime],[type=datetime-local],[type=email],[type=month],[type=number],[type=search],[type=tel],[type=time],[type=url],[type=week]){line-height:var(--_gcd-input-line-height,inherit);min-height:var(--_gcd-input-min-height,auto);padding:var(--_gcd-input-padding,0)}}._547d86373d02e108__textarea{box-sizing:var(--_gcd-textarea-box-sizing,border-box);overflow:var(--_gcd-textarea-overflow,auto);resize:var(--_gcd-textarea-resize,block)}._8c15fd0ed9f28ba4__div{outline:var(--_gcd-div-outline,0 solid transparent)}p._43cec3e1eec1066d__p{font-size:var(--_gcd-p-font-size,13px);line-height:var(--_gcd-p-line-height,1.5);margin:var(--_gcd-p-margin,0)}:is(h1,h2,h3,h4,h5,h6).e97669c6d9a38497__heading{color:var(--_gcd-heading-color,var(--wpds-color-foreground-content-neutral,#1e1e1e));font-size:var(--_gcd-heading-font-size,inherit);font-weight:var(--_gcd-heading-font-weight,var(--wpds-typography-font-weight-emphasis,600));margin:var(--_gcd-heading-margin,0)}._2c0831b0499dbd6e__a,._2c0831b0499dbd6e__a:is(:hover,:focus,:active){border-radius:var(--_gcd-a-border-radius,0);box-shadow:var(--_gcd-a-box-shadow,none);color:var(--_gcd-a-color,inherit);outline:var(--_gcd-a-outline,0 solid transparent);transition:var(--_gcd-a-transition,none)}");var Pg={button:"_6defc79820e382c6__button",input:"d2cff2e5dea83bd1__input",textarea:"_547d86373d02e108__textarea",div:"_8c15fd0ed9f28ba4__div",p:"_43cec3e1eec1066d__p",heading:"e97669c6d9a38497__heading",a:"_2c0831b0499dbd6e__a"},pd=(0,rr.forwardRef)(function({tone:t="brand",variant:o="solid",size:n="default",className:r,focusableWhenDisabled:i=!0,disabled:s,loading:a,loadingAnnouncement:d=(0,dd.__)("Loading"),children:c,...l},f){let p=$(Pg.button,Tg["box-sizing"],kg["outset-ring--focus-except-active"],o!=="unstyled"&&Jo.button,Jo[`is-${t}`],Jo[`is-${o}`],Jo[`is-${n}`],a&&Jo["is-loading"],r);return(0,rr.useEffect)(()=>{a&&d&&Rg(d)},[a,d]),(0,fd.jsx)(_i,{ref:f,className:p,focusableWhenDisabled:i,disabled:s??a,...l,children:c})});var wd=h(de(),1);var gd=h(de(),1),bd=h($t(),1),hd=h(Q(),1),eo=(0,gd.forwardRef)(function({icon:t,size:o=24,...n},r){return(0,hd.jsx)(bd.SVG,{ref:r,...t.props,...n,width:o,height:o})});var _d=h(Q(),1),Hi="data-wp-hash";function zi(){let e=globalThis;return e.__wpStyleRuntime||(e.__wpStyleRuntime={documents:new Map,styles:new Map,injectedStyles:new WeakMap},typeof document<"u"&&Ag(document)),e.__wpStyleRuntime}function Cg(e,t){if(!e.head)return!1;for(let o of e.head.querySelectorAll(`style[${Hi}]`))if(o.getAttribute(Hi)===t)return!0;return!1}function vd(e,t,o){if(!e.head)return;let n=zi(),r=n.injectedStyles.get(e);if(r||(r=new Set,n.injectedStyles.set(e,r)),r.has(t))return;if(Cg(e,t)){r.add(t);return}let i=e.createElement("style");i.setAttribute(Hi,t),i.appendChild(e.createTextNode(o)),e.head.appendChild(i),r.add(t)}function Ag(e){let t=zi();t.documents.set(e,(t.documents.get(e)??0)+1);for(let[o,n]of t.styles)vd(e,o,n);return()=>{let o=t.documents.get(e);if(o!==void 0){if(o<=1){t.documents.delete(e);return}t.documents.set(e,o-1)}}}function Og(e,t){let o=zi();o.styles.set(e,t);for(let n of o.documents.keys())vd(n,e,t)}typeof process>"u",Og("b74f1ac304",'@layer wp-ui{@layer utilities, components, compositions, overrides;@layer components{._97b0fc33c028be1a__button,.abbb272e2ce49bd6__is-unstyled{appearance:none;padding:0}._97b0fc33c028be1a__button{--wp-ui-button-font-weight:var(--wpds-typography-font-weight-emphasis,600);--wp-ui-button-background-color:var(--wpds-color-background-interactive-brand-strong,var(--wp-admin-theme-color,#3858e9));--wp-ui-button-background-color-active:var(--wpds-color-background-interactive-brand-strong-active,color-mix(in oklch,var(--wp-admin-theme-color,#3858e9) 93%,#000));--wp-ui-button-background-color-disabled:var(--wpds-color-background-interactive-brand-strong-disabled,#e6e6e6);--wp-ui-button-foreground-color:var(--wpds-color-foreground-interactive-brand-strong,#fff);--wp-ui-button-foreground-color-active:var(--wpds-color-foreground-interactive-brand-strong-active,#fff);--wp-ui-button-foreground-color-disabled:var(--wpds-color-foreground-interactive-brand-strong-disabled,#8d8d8d);--wp-ui-button-padding-block:var(--wpds-dimension-padding-xs,4px);--wp-ui-button-padding-inline:var(--wpds-dimension-padding-md,12px);--wp-ui-button-height:var(--wpds-dimension-size-lg,40px);--wp-ui-button-aspect-ratio:auto;--wp-ui-button-font-size:var(--wpds-typography-font-size-md,13px);--wp-ui-button-min-width:calc(4ch + var(--wp-ui-button-padding-inline)*2);--wp-ui-button-icon-margin:calc((var(--wpds-dimension-size-2xs, 16px) - var(--wpds-dimension-size-sm, 24px))/2);--wp-ui-button-border-color:var(--wp-ui-button-background-color);--wp-ui-button-border-color-active:var(--wp-ui-button-background-color-active);--wp-ui-button-border-color-disabled:var(--wp-ui-button-background-color-disabled);--_gcd-button-font-family:var(--wpds-typography-font-family-body,-apple-system,system-ui,"Segoe UI","Roboto","Oxygen-Sans","Ubuntu","Cantarell","Helvetica Neue",sans-serif);--_gcd-button-font-size:var(--wp-ui-button-font-size);--_gcd-button-font-weight:var(--wp-ui-button-font-weight);align-items:center;aspect-ratio:var(--wp-ui-button-aspect-ratio);background-clip:border-box;background-color:var(--wp-ui-button-background-color);border-color:var(--wp-ui-button-border-color);border-radius:var(--wpds-border-radius-sm,2px);border-style:solid;border-width:1px;color:var(--wp-ui-button-foreground-color);display:inline-flex;font-family:var(--wpds-typography-font-family-body,-apple-system,system-ui,"Segoe UI","Roboto","Oxygen-Sans","Ubuntu","Cantarell","Helvetica Neue",sans-serif);font-size:var(--wp-ui-button-font-size);font-weight:var(--wp-ui-button-font-weight);gap:var(--wpds-dimension-gap-sm,8px);justify-content:center;line-height:var(--wpds-typography-line-height-sm,20px);max-width:100%;min-height:var(--wp-ui-button-height);min-width:var(--wp-ui-button-min-width);overflow-wrap:anywhere;padding-block:var(--wp-ui-button-padding-block);padding-inline:var(--wp-ui-button-padding-inline);position:relative;text-align:center;text-decoration:none;&:not([data-disabled]){cursor:var(--wpds-cursor-control,pointer)}@media not (prefers-reduced-motion){transition:color .1s ease-out;*{transition:opacity .1s ease-out}}&[href]{cursor:pointer}[href]{color:inherit;text-decoration:inherit}&:not([data-disabled]):is(:hover,:active,:focus){background-color:var(--wp-ui-button-background-color-active);border-color:var(--wp-ui-button-border-color-active);color:var(--wp-ui-button-foreground-color-active)}&[data-disabled]:not(._914b42f315c0e580__is-loading){background-color:var(--wp-ui-button-background-color-disabled);border-color:var(--wp-ui-button-border-color-disabled);color:var(--wp-ui-button-foreground-color-disabled);@media (forced-colors:active){border-bottom-color:GrayText;border-left-color:GrayText;border-right-color:GrayText;border-top-color:GrayText;color:GrayText}}&:before{aspect-ratio:1;border:var(--wpds-border-width-focus,var(--wp-admin-border-width-focus,2px)) solid;border-block-end-color:transparent;border-block-start-color:var(--wp-ui-button-foreground-color);border-inline-end-color:var(--wp-ui-button-foreground-color);border-inline-start-color:transparent;border-radius:50%;box-sizing:border-box;content:"";display:block;height:var(--wp-ui-button-font-size);inset-inline-start:50%;opacity:0;pointer-events:none;position:absolute;top:50%;transform:translate(-50%,-50%);@media not (prefers-reduced-motion){transition:opacity .1s ease-out}@media (forced-colors:active){border-block-end-style:none;border-bottom-color:ButtonText;border-inline-start-style:none;border-left-color:ButtonText;border-right-color:ButtonText;border-top-color:ButtonText}}}._908205475f9f2a92__is-small{--wp-ui-button-padding-block:0px;--wp-ui-button-padding-inline:var(--wpds-dimension-padding-sm,8px);--wp-ui-button-height:var(--wpds-dimension-size-sm,24px)}._9f6fc6553aeb36fe__icon{margin:var(--wp-ui-button-icon-margin)}.dd460c965226cc77__is-brand{&._62d5a778b7b258ee__is-outline,&.ad0619a3217c6a5b__is-minimal{--wp-ui-button-foreground-color:var(--wpds-color-foreground-interactive-brand,var(--wp-admin-theme-color,#3858e9));--wp-ui-button-foreground-color-active:var(--wpds-color-foreground-interactive-brand-active,color-mix(in oklch,var(--wp-admin-theme-color,#3858e9) 52%,#000));--wp-ui-button-foreground-color-disabled:var(--wpds-color-foreground-interactive-brand-disabled,#8d8d8d)}&._62d5a778b7b258ee__is-outline{--wp-ui-button-background-color:var(--wpds-color-background-interactive-brand-weak,#0000);--wp-ui-button-background-color-active:var(--wpds-color-background-interactive-brand-weak-active,color-mix(in oklch,var(--wp-admin-theme-color,#3858e9) 12%,#fff));--wp-ui-button-background-color-disabled:var(--wpds-color-background-interactive-brand-weak-disabled,#0000);--wp-ui-button-border-color:var(--wpds-color-stroke-interactive-brand,var(--wp-admin-theme-color,#3858e9));--wp-ui-button-border-color-active:var(--wpds-color-stroke-interactive-brand-active,color-mix(in oklch,var(--wp-admin-theme-color,#3858e9) 85%,#000));--wp-ui-button-border-color-disabled:var(--wpds-color-stroke-interactive-brand-disabled,#dbdbdb)}&.ad0619a3217c6a5b__is-minimal{--wp-ui-button-background-color:var(--wpds-color-background-interactive-brand-weak,#0000);--wp-ui-button-background-color-active:var(--wpds-color-background-interactive-brand-weak-active,color-mix(in oklch,var(--wp-admin-theme-color,#3858e9) 12%,#fff));--wp-ui-button-background-color-disabled:var(--wpds-color-background-interactive-brand-weak-disabled,#0000)}}.e722a8f96726aa99__is-neutral{&.ad0619a3217c6a5b__is-minimal[aria-pressed=true],&.b50b3358c5fb4d0b__is-solid{--wp-ui-button-background-color:var(--wpds-color-background-interactive-neutral-strong,#2d2d2d);--wp-ui-button-background-color-active:var(--wpds-color-background-interactive-neutral-strong-active,#1e1e1e);--wp-ui-button-background-color-disabled:var(--wpds-color-background-interactive-neutral-strong-disabled,#e6e6e6);--wp-ui-button-foreground-color:var(--wpds-color-foreground-interactive-neutral-strong,#f0f0f0);--wp-ui-button-foreground-color-active:var(--wpds-color-foreground-interactive-neutral-strong-active,#f0f0f0);--wp-ui-button-foreground-color-disabled:var(--wpds-color-foreground-interactive-neutral-strong-disabled,#8d8d8d)}&._62d5a778b7b258ee__is-outline,&.ad0619a3217c6a5b__is-minimal:not([aria-pressed=true]){--wp-ui-button-foreground-color:var(--wpds-color-foreground-interactive-neutral,#1e1e1e);--wp-ui-button-foreground-color-active:var(--wpds-color-foreground-interactive-neutral-active,#1e1e1e);--wp-ui-button-foreground-color-disabled:var(--wpds-color-foreground-interactive-neutral-disabled,#8d8d8d)}&._62d5a778b7b258ee__is-outline{--wp-ui-button-background-color:var(--wpds-color-background-interactive-neutral-weak,#0000);--wp-ui-button-background-color-active:var(--wpds-color-background-interactive-neutral-weak-active,#ededed);--wp-ui-button-background-color-disabled:var(--wpds-color-background-interactive-neutral-weak-disabled,#0000);--wp-ui-button-border-color:var(--wpds-color-stroke-interactive-neutral,#8d8d8d);--wp-ui-button-border-color-active:var(--wpds-color-stroke-interactive-neutral-active,#6e6e6e);--wp-ui-button-border-color-disabled:var(--wpds-color-stroke-interactive-neutral-disabled,#dbdbdb)}&.ad0619a3217c6a5b__is-minimal:not([aria-pressed=true]){--wp-ui-button-background-color:var(--wpds-color-background-interactive-neutral-weak,#0000);--wp-ui-button-background-color-active:var(--wpds-color-background-interactive-neutral-weak-active,#ededed);--wp-ui-button-background-color-disabled:var(--wpds-color-background-interactive-neutral-weak-disabled,#0000)}}.abbb272e2ce49bd6__is-unstyled{background:none;border:none;min-width:unset}.cf59cf1b69629838__is-compact{--wp-ui-button-height:var(--wpds-dimension-size-md,32px)}._914b42f315c0e580__is-loading:not(.abbb272e2ce49bd6__is-unstyled){color:transparent;&:not([data-disabled]):is(:hover,:active,:focus){color:transparent}@media (forced-colors:active){color:ButtonFace}*{opacity:0}&:before{opacity:1;transition-delay:.05s;@media not (prefers-reduced-motion){animation:_5a1d53da6f830c8d__loading-animation 1s linear infinite}}}}@keyframes _5a1d53da6f830c8d__loading-animation{0%{transform:translate(-50%,-50%) rotate(0deg)}to{transform:translate(-50%,-50%) rotate(1turn)}}}');var Ng={button:"_97b0fc33c028be1a__button","is-unstyled":"abbb272e2ce49bd6__is-unstyled","is-loading":"_914b42f315c0e580__is-loading","is-small":"_908205475f9f2a92__is-small",icon:"_9f6fc6553aeb36fe__icon","is-brand":"dd460c965226cc77__is-brand","is-outline":"_62d5a778b7b258ee__is-outline","is-minimal":"ad0619a3217c6a5b__is-minimal","is-neutral":"e722a8f96726aa99__is-neutral","is-solid":"b50b3358c5fb4d0b__is-solid","is-compact":"cf59cf1b69629838__is-compact","loading-animation":"_5a1d53da6f830c8d__loading-animation"},Di=(0,wd.forwardRef)(function({className:t,icon:o,...n},r){return(0,_d.jsx)(eo,{ref:r,icon:o,className:$(Ng.icon,t),size:24,...n})});Di.displayName="Button.Icon";var sr=Object.assign(pd,{Icon:Di});var ar=h($t(),1),ji=h(Q(),1),Fi=(0,ji.jsx)(ar.SVG,{xmlns:"http://www.w3.org/2000/svg",viewBox:"0 0 24 24",fill:"currentColor",children:(0,ji.jsx)(ar.Path,{fillRule:"evenodd",clipRule:"evenodd",d:"M5.5 12a6.5 6.5 0 1 0 13 0 6.5 6.5 0 0 0-13 0ZM12 4a8 8 0 1 0 0 16 8 8 0 0 0 0-16Zm-.75 12v-1.5h1.5V16h-1.5Zm0-8v5h1.5V8h-1.5Z"})});var cr=h($t(),1),Vi=h(Q(),1),Wi=(0,Vi.jsx)(cr.SVG,{xmlns:"http://www.w3.org/2000/svg",viewBox:"0 0 24 24",fill:"currentColor",children:(0,Vi.jsx)(cr.Path,{d:"M12 13.06l3.712 3.713 1.061-1.06L13.061 12l3.712-3.712-1.06-1.06L12 10.938 8.288 7.227l-1.061 1.06L10.939 12l-3.712 3.712 1.06 1.061L12 13.061z"})});var lr=h($t(),1),Yi=h(Q(),1),Ui=(0,Yi.jsx)(lr.SVG,{xmlns:"http://www.w3.org/2000/svg",viewBox:"0 0 24 24",fill:"currentColor",children:(0,Yi.jsx)(lr.Path,{fillRule:"evenodd",clipRule:"evenodd",d:"M12.218 5.377a.25.25 0 0 0-.436 0l-7.29 12.96a.25.25 0 0 0 .218.373h14.58a.25.25 0 0 0 .218-.372l-7.29-12.96Zm-1.743-.735c.669-1.19 2.381-1.19 3.05 0l7.29 12.96a1.75 1.75 0 0 1-1.525 2.608H4.71a1.75 1.75 0 0 1-1.525-2.608l7.29-12.96ZM12.75 17.46h-1.5v-1.5h1.5v1.5Zm-1.5-3h1.5v-5h-1.5v5Z"})});var dr=h($t(),1),Gi=h(Q(),1),Xi=(0,Gi.jsx)(dr.SVG,{xmlns:"http://www.w3.org/2000/svg",viewBox:"0 0 24 24",fill:"currentColor",children:(0,Gi.jsx)(dr.Path,{fillRule:"evenodd",clipRule:"evenodd",d:"M5.5 12a6.5 6.5 0 1 0 13 0 6.5 6.5 0 0 0-13 0ZM12 4a8 8 0 1 0 0 16 8 8 0 0 0 0-16Zm.75 4v1.5h-1.5V8h1.5Zm0 8v-5h-1.5v5h1.5Z"})});var ur=h($t(),1),Ki=h(Q(),1),qi=(0,Ki.jsx)(ur.SVG,{xmlns:"http://www.w3.org/2000/svg",viewBox:"0 0 24 24",fill:"currentColor",children:(0,Ki.jsx)(ur.Path,{fillRule:"evenodd",clipRule:"evenodd",d:"M12 18.5a6.5 6.5 0 1 1 0-13 6.5 6.5 0 0 1 0 13ZM4 12a8 8 0 1 1 16 0 8 8 0 0 1-16 0Zm11.53-1.47-1.06-1.06L11 12.94l-1.47-1.47-1.06 1.06L11 15.06l4.53-4.53Z"})});var yd=h(de(),1);function Zi(e,t,o){return(0,yd.cloneElement)(e??t,{children:o})}var Lg=h(Rd(),1);var Ed=h(Qi(),1),{lock:h4,unlock:Td}=(0,Ed.__dangerousOptInToUnstableAPIsOnlyForCoreModules)("I acknowledge private features are not for use in themes or plugins and doing so will break in the next version of WordPress.","@wordpress/ui");function Ig(){let e=Lg;if(e.ThemeProvider)return e.ThemeProvider;if(!e.privateApis)throw new Error("@wordpress/ui: @wordpress/theme must expose `ThemeProvider` or `privateApis.ThemeProvider`.");return Td(e.privateApis).ThemeProvider}var kd=Ig();var Pd=h(de(),1),Ji="data-wp-hash";function $i(){let e=globalThis;return e.__wpStyleRuntime||(e.__wpStyleRuntime={documents:new Map,styles:new Map,injectedStyles:new WeakMap},typeof document<"u"&&Bg(document)),e.__wpStyleRuntime}function Mg(e,t){if(!e.head)return!1;for(let o of e.head.querySelectorAll(`style[${Ji}]`))if(o.getAttribute(Ji)===t)return!0;return!1}function Cd(e,t,o){if(!e.head)return;let n=$i(),r=n.injectedStyles.get(e);if(r||(r=new Set,n.injectedStyles.set(e,r)),r.has(t))return;if(Mg(e,t)){r.add(t);return}let i=e.createElement("style");i.setAttribute(Ji,t),i.appendChild(e.createTextNode(o)),e.head.appendChild(i),r.add(t)}function Bg(e){let t=$i();t.documents.set(e,(t.documents.get(e)??0)+1);for(let[o,n]of t.styles)Cd(e,o,n);return()=>{let o=t.documents.get(e);if(o!==void 0){if(o<=1){t.documents.delete(e);return}t.documents.set(e,o-1)}}}function Hg(e,t){let o=$i();o.styles.set(e,t);for(let n of o.documents.keys())Cd(n,e,t)}typeof process>"u",Hg("32aba35fe1","@layer wp-ui{@layer utilities, components, compositions, overrides;@layer components{._19ce0419607e1896__stack{display:flex}}}");var zg={stack:"_19ce0419607e1896__stack"},Dg={xs:"var(--wpds-dimension-gap-xs, 4px)",sm:"var(--wpds-dimension-gap-sm, 8px)",md:"var(--wpds-dimension-gap-md, 12px)",lg:"var(--wpds-dimension-gap-lg, 16px)",xl:"var(--wpds-dimension-gap-xl, 24px)","2xl":"var(--wpds-dimension-gap-2xl, 32px)","3xl":"var(--wpds-dimension-gap-3xl, 40px)"},Po=(0,Pd.forwardRef)(function({direction:t,gap:o,align:n,justify:r,wrap:i,render:s,...a},d){let c={gap:o&&Dg[o],alignItems:n,justifyContent:r,flexDirection:t,flexWrap:i};return bt({render:s,ref:d,props:ye(a,{style:c,className:zg.stack})})});var Kd=h(de(),1);var Vd=h(de(),1);var Id=h(de(),1);var ts="data-wp-hash";function os(){let e=globalThis;return e.__wpStyleRuntime||(e.__wpStyleRuntime={documents:new Map,styles:new Map,injectedStyles:new WeakMap},typeof document<"u"&&Fg(document)),e.__wpStyleRuntime}function jg(e,t){if(!e.head)return!1;for(let o of e.head.querySelectorAll(`style[${ts}]`))if(o.getAttribute(ts)===t)return!0;return!1}function Od(e,t,o){if(!e.head)return;let n=os(),r=n.injectedStyles.get(e);if(r||(r=new Set,n.injectedStyles.set(e,r)),r.has(t))return;if(jg(e,t)){r.add(t);return}let i=e.createElement("style");i.setAttribute(ts,t),i.appendChild(e.createTextNode(o)),e.head.appendChild(i),r.add(t)}function Fg(e){let t=os();t.documents.set(e,(t.documents.get(e)??0)+1);for(let[o,n]of t.styles)Od(e,o,n);return()=>{let o=t.documents.get(e);if(o!==void 0){if(o<=1){t.documents.delete(e);return}t.documents.set(e,o-1)}}}function Vg(e,t){let o=os();o.styles.set(e,t);for(let n of o.documents.keys())Od(n,e,t)}typeof process>"u",Vg("be37f31c1e","._11fc52b637ff8a7e__slot{inset:0;isolation:isolate;pointer-events:none;position:fixed;z-index:1000000003}@layer wp-ui{@layer utilities, components, compositions, overrides;@layer utilities{._11fc52b637ff8a7e__slot>*{pointer-events:auto}}}");var Ad={slot:"_11fc52b637ff8a7e__slot"},Nd="data-wp-compat-overlay-slot";function Wg(){return typeof document>"u"?null:document}function Yg(){let e;try{e=window.top?.wp}catch{}let t=e??window.wp;return typeof t?.components=="object"&&t.components!==null}var ht=null;function es(e){return e.setAttribute("aria-hidden","false"),e}function Ug(e){let t=e.createElement("div");return t.setAttribute(Nd,""),Ad.slot&&t.classList.add(Ad.slot),e.body.appendChild(t),t}function Ld(){if(typeof window>"u"||!Yg()&&window.__wpUiCompatOverlaySlotEnabled!==!0)return;let e=Wg();if(!e||!e.body)return;if(ht&&ht.ownerDocument===e&&ht.isConnected)return es(ht);let t=e.querySelector(`[${Nd}]`);return t instanceof HTMLDivElement?(ht=es(t),ht):(ht?.isConnected&&ht.remove(),ht=es(Ug(e)),ht)}var Md=h(Q(),1),Bd=(0,Id.forwardRef)(function({container:t,...o},n){return(0,Md.jsx)(Qe.Portal,{container:t??Ld(),...o,ref:n})});var Hd=h(de(),1),jd=h(Q(),1),ns="data-wp-hash";function rs(){let e=globalThis;return e.__wpStyleRuntime||(e.__wpStyleRuntime={documents:new Map,styles:new Map,injectedStyles:new WeakMap},typeof document<"u"&&Xg(document)),e.__wpStyleRuntime}function Gg(e,t){if(!e.head)return!1;for(let o of e.head.querySelectorAll(`style[${ns}]`))if(o.getAttribute(ns)===t)return!0;return!1}function zd(e,t,o){if(!e.head)return;let n=rs(),r=n.injectedStyles.get(e);if(r||(r=new Set,n.injectedStyles.set(e,r)),r.has(t))return;if(Gg(e,t)){r.add(t);return}let i=e.createElement("style");i.setAttribute(ns,t),i.appendChild(e.createTextNode(o)),e.head.appendChild(i),r.add(t)}function Xg(e){let t=rs();t.documents.set(e,(t.documents.get(e)??0)+1);for(let[o,n]of t.styles)zd(e,o,n);return()=>{let o=t.documents.get(e);if(o!==void 0){if(o<=1){t.documents.delete(e);return}t.documents.set(e,o-1)}}}function Dd(e,t){let o=rs();o.styles.set(e,t);for(let n of o.documents.keys())zd(n,e,t)}typeof process>"u",Dd("10f3806643","@layer wp-ui{@layer utilities, components, compositions, overrides;@layer utilities{._336cd3e4e743482f__box-sizing{box-sizing:border-box;*,:after,:before{box-sizing:inherit}}}}");var Kg={"box-sizing":"_336cd3e4e743482f__box-sizing"};typeof process>"u",Dd("19fcc06039",'@layer wp-ui{@layer utilities, components, compositions, overrides;@layer components{._480b748dd3510e64__positioner{z-index:var(--wp-ui-tooltip-z-index,initial)}._50096b232db7709d__popup{--_wp-ui-elevation-sm:0 1px 2px rgba(0,0,0,.05),0 2px 3px rgba(0,0,0,.04),0 6px 6px rgba(0,0,0,.03),0 8px 8px rgba(0,0,0,.02);background-color:var(--wpds-color-background-surface-neutral-strong,#fff);border-radius:var(--wpds-border-radius-md,4px);box-shadow:var(--_wp-ui-elevation-sm);color:var(--wpds-color-foreground-content-neutral,#1e1e1e);font-family:var(--wpds-typography-font-family-body,-apple-system,system-ui,"Segoe UI","Roboto","Oxygen-Sans","Ubuntu","Cantarell","Helvetica Neue",sans-serif);font-size:var(--wpds-typography-font-size-sm,12px);line-height:1.4;padding:var(--wpds-dimension-padding-xs,4px) var(--wpds-dimension-padding-sm,8px);@media (forced-colors:active){border-bottom-color:CanvasText;border-bottom-style:solid;border-bottom-width:1px;border-left-color:CanvasText;border-left-style:solid;border-left-width:1px;border-right-color:CanvasText;border-right-style:solid;border-right-width:1px;border-top-color:CanvasText;border-top-style:solid;border-top-width:1px}}}}');var qg={positioner:"_480b748dd3510e64__positioner",popup:"_50096b232db7709d__popup"},Fd=(0,Hd.forwardRef)(function({align:t="center",className:o,side:n="top",sideOffset:r=4,...i},s){return(0,jd.jsx)(Qe.Positioner,{ref:s,align:t,side:n,sideOffset:r,...i,className:$(Kg["box-sizing"],qg.positioner,o)})});var $o=h(Q(),1),is="data-wp-hash";function ss(){let e=globalThis;return e.__wpStyleRuntime||(e.__wpStyleRuntime={documents:new Map,styles:new Map,injectedStyles:new WeakMap},typeof document<"u"&&Qg(document)),e.__wpStyleRuntime}function Zg(e,t){if(!e.head)return!1;for(let o of e.head.querySelectorAll(`style[${is}]`))if(o.getAttribute(is)===t)return!0;return!1}function Wd(e,t,o){if(!e.head)return;let n=ss(),r=n.injectedStyles.get(e);if(r||(r=new Set,n.injectedStyles.set(e,r)),r.has(t))return;if(Zg(e,t)){r.add(t);return}let i=e.createElement("style");i.setAttribute(is,t),i.appendChild(e.createTextNode(o)),e.head.appendChild(i),r.add(t)}function Qg(e){let t=ss();t.documents.set(e,(t.documents.get(e)??0)+1);for(let[o,n]of t.styles)Wd(e,o,n);return()=>{let o=t.documents.get(e);if(o!==void 0){if(o<=1){t.documents.delete(e);return}t.documents.set(e,o-1)}}}function Jg(e,t){let o=ss();o.styles.set(e,t);for(let n of o.documents.keys())Wd(n,e,t)}typeof process>"u",Jg("19fcc06039",'@layer wp-ui{@layer utilities, components, compositions, overrides;@layer components{._480b748dd3510e64__positioner{z-index:var(--wp-ui-tooltip-z-index,initial)}._50096b232db7709d__popup{--_wp-ui-elevation-sm:0 1px 2px rgba(0,0,0,.05),0 2px 3px rgba(0,0,0,.04),0 6px 6px rgba(0,0,0,.03),0 8px 8px rgba(0,0,0,.02);background-color:var(--wpds-color-background-surface-neutral-strong,#fff);border-radius:var(--wpds-border-radius-md,4px);box-shadow:var(--_wp-ui-elevation-sm);color:var(--wpds-color-foreground-content-neutral,#1e1e1e);font-family:var(--wpds-typography-font-family-body,-apple-system,system-ui,"Segoe UI","Roboto","Oxygen-Sans","Ubuntu","Cantarell","Helvetica Neue",sans-serif);font-size:var(--wpds-typography-font-size-sm,12px);line-height:1.4;padding:var(--wpds-dimension-padding-xs,4px) var(--wpds-dimension-padding-sm,8px);@media (forced-colors:active){border-bottom-color:CanvasText;border-bottom-style:solid;border-bottom-width:1px;border-left-color:CanvasText;border-left-style:solid;border-left-width:1px;border-right-color:CanvasText;border-right-style:solid;border-right-width:1px;border-top-color:CanvasText;border-top-style:solid;border-top-width:1px}}}}');var $g={positioner:"_480b748dd3510e64__positioner",popup:"_50096b232db7709d__popup"},eb={background:"#1e1e1e"},as=(0,Vd.forwardRef)(function({portal:t,positioner:o,children:n,className:r,...i},s){let a=(0,$o.jsx)(kd,{color:eb,children:(0,$o.jsx)(Qe.Popup,{ref:s,className:$($g.popup,r),...i,children:n})}),d=Zi(o,(0,$o.jsx)(Fd,{}),a);return Zi(t,(0,$o.jsx)(Bd,{}),d)});var Yd=h(de(),1),Ud=h(Q(),1),cs=(0,Yd.forwardRef)(function(t,o){return(0,Ud.jsx)(Qe.Trigger,{ref:o,...t})});var Gd=h(Q(),1);function ls(e){return(0,Gd.jsx)(Qe.Root,{...e})}var lt=h(Q(),1),ds="data-wp-hash";function us(){let e=globalThis;return e.__wpStyleRuntime||(e.__wpStyleRuntime={documents:new Map,styles:new Map,injectedStyles:new WeakMap},typeof document<"u"&&nb(document)),e.__wpStyleRuntime}function ob(e,t){if(!e.head)return!1;for(let o of e.head.querySelectorAll(`style[${ds}]`))if(o.getAttribute(ds)===t)return!0;return!1}function qd(e,t,o){if(!e.head)return;let n=us(),r=n.injectedStyles.get(e);if(r||(r=new Set,n.injectedStyles.set(e,r)),r.has(t))return;if(ob(e,t)){r.add(t);return}let i=e.createElement("style");i.setAttribute(ds,t),i.appendChild(e.createTextNode(o)),e.head.appendChild(i),r.add(t)}function nb(e){let t=us();t.documents.set(e,(t.documents.get(e)??0)+1);for(let[o,n]of t.styles)qd(e,o,n);return()=>{let o=t.documents.get(e);if(o!==void 0){if(o<=1){t.documents.delete(e);return}t.documents.set(e,o-1)}}}function rb(e,t){let o=us();o.styles.set(e,t);for(let n of o.documents.keys())qd(n,e,t)}typeof process>"u",rb("c5cdafb1bc","@layer wp-ui{@layer utilities, components, compositions, overrides;@layer compositions{._28cfdc260e755391__icon-button{--wp-ui-button-aspect-ratio:1;--wp-ui-button-padding-inline:0px;--wp-ui-button-min-width:unset}.f1c70d719989a85a__icon{margin:-1px}}}");var Xd={"icon-button":"_28cfdc260e755391__icon-button",icon:"f1c70d719989a85a__icon"},fs=(0,Kd.forwardRef)(function({label:t,className:o,children:n,disabled:r,focusableWhenDisabled:i=!0,icon:s,size:a,shortcut:d,positioner:c,...l},f){let p=$(Xd["icon-button"],o);return(0,lt.jsxs)(ls,{children:[(0,lt.jsx)(cs,{ref:f,disabled:r&&!i,render:(0,lt.jsx)(sr,{...l,size:a,"aria-label":t,"aria-keyshortcuts":d?.ariaKeyShortcut,disabled:r,focusableWhenDisabled:i}),className:p,children:(0,lt.jsx)(eo,{icon:s,size:24,className:Xd.icon})}),(0,lt.jsxs)(as,{positioner:c,children:[t,d&&(0,lt.jsxs)(lt.Fragment,{children:[" ",(0,lt.jsx)("span",{"aria-hidden":"true",children:d.displayShortcut})]})]})]})});var Zd=h(de(),1),Qd=h(Ot(),1),Co=h(Q(),1),ps="data-wp-hash";function ms(){let e=globalThis;return e.__wpStyleRuntime||(e.__wpStyleRuntime={documents:new Map,styles:new Map,injectedStyles:new WeakMap},typeof document<"u"&&sb(document)),e.__wpStyleRuntime}function ib(e,t){if(!e.head)return!1;for(let o of e.head.querySelectorAll(`style[${ps}]`))if(o.getAttribute(ps)===t)return!0;return!1}function Jd(e,t,o){if(!e.head)return;let n=ms(),r=n.injectedStyles.get(e);if(r||(r=new Set,n.injectedStyles.set(e,r)),r.has(t))return;if(ib(e,t)){r.add(t);return}let i=e.createElement("style");i.setAttribute(ps,t),i.appendChild(e.createTextNode(o)),e.head.appendChild(i),r.add(t)}function sb(e){let t=ms();t.documents.set(e,(t.documents.get(e)??0)+1);for(let[o,n]of t.styles)Jd(e,o,n);return()=>{let o=t.documents.get(e);if(o!==void 0){if(o<=1){t.documents.delete(e);return}t.documents.set(e,o-1)}}}function pr(e,t){let o=ms();o.styles.set(e,t);for(let n of o.documents.keys())Jd(n,e,t)}typeof process>"u",pr("10f3806643","@layer wp-ui{@layer utilities, components, compositions, overrides;@layer utilities{._336cd3e4e743482f__box-sizing{box-sizing:border-box;*,:after,:before{box-sizing:inherit}}}}");var ab={"box-sizing":"_336cd3e4e743482f__box-sizing"};typeof process>"u",pr("5f8e7aa0bc","@layer wp-ui{@layer utilities, components, compositions, overrides;@layer utilities{._08e8a2e44959f892__outset-ring--focus:focus,._970d04df7376df67__outset-ring--focus-within-except-active:focus-within:not(:has(:active)),.c5cb3ee4bddaa8e4__outset-ring--focus-within-visible:focus-within:has(:focus-visible),.cd83dfc2126a0846__outset-ring--focus-within:focus-within,.d0541bc9dd9dc7b6__outset-ring--focus-visible:focus-visible,.e25b2bdd7aa21721__outset-ring--focus-except-active:focus:not(:active),:focus-visible .ecadb9e080e2dfa5__outset-ring--focus-parent-visible{--_gcd-a-outline:var(--wpds-border-width-focus,var(--wp-admin-border-width-focus,2px)) solid var(--wpds-color-stroke-focus,var(--wp-admin-theme-color,#3858e9));--_gcd-div-outline:var(--wpds-border-width-focus,var(--wp-admin-border-width-focus,2px)) solid var(--wpds-color-stroke-focus,var(--wp-admin-theme-color,#3858e9));outline:var(--wpds-border-width-focus,var(--wp-admin-border-width-focus,2px)) solid var(--wpds-color-stroke-focus,var(--wp-admin-theme-color,#3858e9));outline-offset:var(--wpds-border-width-focus,var(--wp-admin-border-width-focus,2px))}}}");var cb={"outset-ring--focus":"_08e8a2e44959f892__outset-ring--focus","outset-ring--focus-except-active":"e25b2bdd7aa21721__outset-ring--focus-except-active","outset-ring--focus-visible":"d0541bc9dd9dc7b6__outset-ring--focus-visible","outset-ring--focus-within":"cd83dfc2126a0846__outset-ring--focus-within","outset-ring--focus-within-except-active":"_970d04df7376df67__outset-ring--focus-within-except-active","outset-ring--focus-within-visible":"c5cb3ee4bddaa8e4__outset-ring--focus-within-visible","outset-ring--focus-parent-visible":"ecadb9e080e2dfa5__outset-ring--focus-parent-visible"};typeof process>"u",pr("e8e6a9be37",'@layer wp-ui{@layer utilities, components, compositions, overrides;@layer components{.d4250949359b05ce__link{text-decoration-thickness:from-font;text-underline-offset:.2em}.c6055659b8e2cd2c__is-brand,.c6055659b8e2cd2c__is-brand:visited{--_gcd-a-color:var(--wpds-color-foreground-interactive-brand,var(--wp-admin-theme-color,#3858e9));color:var(--wpds-color-foreground-interactive-brand,var(--wp-admin-theme-color,#3858e9))}.c6055659b8e2cd2c__is-brand:active,.c6055659b8e2cd2c__is-brand:hover{--_gcd-a-color:var(--wpds-color-foreground-interactive-brand-active,color-mix(in oklch,var(--wp-admin-theme-color,#3858e9) 52%,#000));color:var(--wpds-color-foreground-interactive-brand-active,color-mix(in oklch,var(--wp-admin-theme-color,#3858e9) 52%,#000))}._92e0dfcaeee15b88__is-neutral,._92e0dfcaeee15b88__is-neutral:visited{--_gcd-a-color:var(--wpds-color-foreground-interactive-neutral,#1e1e1e);color:var(--wpds-color-foreground-interactive-neutral,#1e1e1e);text-decoration-color:var(--wpds-color-stroke-interactive-neutral,#8d8d8d)}._92e0dfcaeee15b88__is-neutral:active,._92e0dfcaeee15b88__is-neutral:hover{--_gcd-a-color:var(--wpds-color-foreground-interactive-neutral-active,#1e1e1e);color:var(--wpds-color-foreground-interactive-neutral-active,#1e1e1e)}.cf122a9bf1035d42__is-unstyled{--_gcd-a-color:inherit;color:inherit;text-decoration:none}._0cb411afac4c86c7__link-icon{display:inline-block;font-weight:var(--wpds-typography-font-weight-default,400);line-height:1;margin-inline-start:var(--wpds-dimension-padding-xs,4px);text-decoration:none}._0cb411afac4c86c7__link-icon:after{content:"\\2197"}._0cb411afac4c86c7__link-icon:dir(rtl):after{content:"\\2196"}}}');var fr={link:"d4250949359b05ce__link","is-brand":"c6055659b8e2cd2c__is-brand","is-neutral":"_92e0dfcaeee15b88__is-neutral","is-unstyled":"cf122a9bf1035d42__is-unstyled","link-icon":"_0cb411afac4c86c7__link-icon"};typeof process>"u",pr("af6d9984a6","._6defc79820e382c6__button{box-sizing:var(--_gcd-button-box-sizing,border-box);font-family:var(--_gcd-button-font-family,inherit);font-size:var(--_gcd-button-font-size,inherit);font-weight:var(--_gcd-button-font-weight,inherit)}.d2cff2e5dea83bd1__input{box-sizing:var(--_gcd-input-box-sizing,border-box);font-family:var(--_gcd-input-font-family,inherit);font-size:var(--_gcd-input-font-size,inherit);font-weight:var(--_gcd-input-font-weight,inherit);margin:var(--_gcd-input-margin,0);&:is(textarea,[type=text],[type=password],[type=color],[type=date],[type=datetime],[type=datetime-local],[type=email],[type=month],[type=number],[type=search],[type=tel],[type=time],[type=url],[type=week]){background-color:var(--_gcd-input-background-color,transparent);border:var(--_gcd-input-border,none);border-radius:var(--_gcd-input-border-radius,0);box-shadow:var(--_gcd-input-box-shadow,0 0 0 transparent);color:var(--_gcd-input-color,var(--wpds-color-foreground-interactive-neutral,#1e1e1e));&:focus{border-color:var(--_gcd-input-border-color-focus,var(--wp-admin-theme-color));box-shadow:var(--_gcd-input-box-shadow-focus,none);outline:var(--_gcd-input-outline-focus,none)}&:disabled{background:var(--_gcd-input-background-disabled,transparent);border-color:var(--_gcd-input-border-color-disabled,transparent);box-shadow:var(--_gcd-input-box-shadow-disabled,none);color:var(--_gcd-input-color-disabled,var(--wpds-color-foreground-interactive-neutral-disabled,#8d8d8d))}&::placeholder{color:var(--_gcd-input-placeholder-color,var(--wpds-color-foreground-interactive-neutral-disabled,#8d8d8d))}}&:is(textarea,[type=text],[type=password],[type=date],[type=datetime],[type=datetime-local],[type=email],[type=month],[type=number],[type=search],[type=tel],[type=time],[type=url],[type=week]){line-height:var(--_gcd-input-line-height,inherit);min-height:var(--_gcd-input-min-height,auto);padding:var(--_gcd-input-padding,0)}}._547d86373d02e108__textarea{box-sizing:var(--_gcd-textarea-box-sizing,border-box);overflow:var(--_gcd-textarea-overflow,auto);resize:var(--_gcd-textarea-resize,block)}._8c15fd0ed9f28ba4__div{outline:var(--_gcd-div-outline,0 solid transparent)}p._43cec3e1eec1066d__p{font-size:var(--_gcd-p-font-size,13px);line-height:var(--_gcd-p-line-height,1.5);margin:var(--_gcd-p-margin,0)}:is(h1,h2,h3,h4,h5,h6).e97669c6d9a38497__heading{color:var(--_gcd-heading-color,var(--wpds-color-foreground-content-neutral,#1e1e1e));font-size:var(--_gcd-heading-font-size,inherit);font-weight:var(--_gcd-heading-font-weight,var(--wpds-typography-font-weight-emphasis,600));margin:var(--_gcd-heading-margin,0)}._2c0831b0499dbd6e__a,._2c0831b0499dbd6e__a:is(:hover,:focus,:active){border-radius:var(--_gcd-a-border-radius,0);box-shadow:var(--_gcd-a-box-shadow,none);color:var(--_gcd-a-color,inherit);outline:var(--_gcd-a-outline,0 solid transparent);transition:var(--_gcd-a-transition,none)}");var lb={button:"_6defc79820e382c6__button",input:"d2cff2e5dea83bd1__input",textarea:"_547d86373d02e108__textarea",div:"_8c15fd0ed9f28ba4__div",p:"_43cec3e1eec1066d__p",heading:"e97669c6d9a38497__heading",a:"_2c0831b0499dbd6e__a"},en=(0,Zd.forwardRef)(function({children:t,variant:o="default",tone:n="brand",openInNewTab:r=!1,render:i,className:s,...a},d){return bt({render:i,defaultTagName:"a",ref:d,props:ye(a,{className:$(lb.a,ab["box-sizing"],cb["outset-ring--focus-except-active"],o!=="unstyled"&&fr.link,o!=="unstyled"&&fr[`is-${n}`],o==="unstyled"&&fr["is-unstyled"],s),target:r?"_blank":void 0,children:(0,Co.jsxs)(Co.Fragment,{children:[t,r&&(0,Co.jsx)("span",{className:fr["link-icon"],role:"img","aria-label":(0,Qd.__)("(opens in a new tab)")})]})})})});var tn={};At(tn,{ActionButton:()=>xu,ActionLink:()=>Eu,Actions:()=>fu,CloseIcon:()=>hu,Description:()=>lu,Root:()=>tu,Title:()=>iu});var Ao=h(de(),1);import{speak as db}from"@wordpress/a11y";var Oo=h(Q(),1),bs="data-wp-hash";function hs(){let e=globalThis;return e.__wpStyleRuntime||(e.__wpStyleRuntime={documents:new Map,styles:new Map,injectedStyles:new WeakMap},typeof document<"u"&&fb(document)),e.__wpStyleRuntime}function ub(e,t){if(!e.head)return!1;for(let o of e.head.querySelectorAll(`style[${bs}]`))if(o.getAttribute(bs)===t)return!0;return!1}function $d(e,t,o){if(!e.head)return;let n=hs(),r=n.injectedStyles.get(e);if(r||(r=new Set,n.injectedStyles.set(e,r)),r.has(t))return;if(ub(e,t)){r.add(t);return}let i=e.createElement("style");i.setAttribute(bs,t),i.appendChild(e.createTextNode(o)),e.head.appendChild(i),r.add(t)}function fb(e){let t=hs();t.documents.set(e,(t.documents.get(e)??0)+1);for(let[o,n]of t.styles)$d(e,o,n);return()=>{let o=t.documents.get(e);if(o!==void 0){if(o<=1){t.documents.delete(e);return}t.documents.set(e,o-1)}}}function eu(e,t){let o=hs();o.styles.set(e,t);for(let n of o.documents.keys())$d(n,e,t)}typeof process>"u",eu("10f3806643","@layer wp-ui{@layer utilities, components, compositions, overrides;@layer utilities{._336cd3e4e743482f__box-sizing{box-sizing:border-box;*,:after,:before{box-sizing:inherit}}}}");var pb={"box-sizing":"_336cd3e4e743482f__box-sizing"};typeof process>"u",eu("726c480820","@layer wp-ui{@layer utilities, components, compositions, overrides;@layer components{._4145abab73d17514__notice{--icon-height:var(--wpds-dimension-size-sm,24px);--text-vertical-padding:calc((var(--icon-height) - var(--wpds-typography-line-height-sm, 20px))/2);--wp-ui-notice-background-color:var(--wpds-color-background-surface-neutral-weak,#f4f4f4);--wp-ui-notice-border-color:var(--wpds-color-stroke-surface-neutral,#dbdbdb);--wp-ui-notice-text-color:var(--wpds-color-foreground-content-neutral,#1e1e1e);--wp-ui-notice-decorative-icon-color:var(--wpds-color-foreground-content-neutral,#1e1e1e);align-items:start;background-color:var(--wp-ui-notice-background-color);border:1px solid var(--wp-ui-notice-border-color);border-radius:var(--wpds-border-radius-lg,8px);container-type:inline-size;display:grid;grid-template-columns:auto 1fr auto;padding:var(--wpds-dimension-padding-md,12px)}.d0a25570cb528528__icon{color:var(--wp-ui-notice-decorative-icon-color);grid-column:1;grid-row:1;margin-inline-end:var(--wpds-dimension-gap-xs,4px)}._1904b570a89bb815__description,.b5397fb9d05389e3__title{color:var(--wp-ui-notice-text-color);grid-column:2;padding-block:var(--text-vertical-padding)}._1904b570a89bb815__description{text-wrap:pretty}._0a1270dcdd79c031__actions{display:flex;flex-wrap:wrap;gap:var(--wpds-dimension-gap-md,12px);grid-column:2}._4145abab73d17514__notice:has(._1904b570a89bb815__description) ._0a1270dcdd79c031__actions,._4145abab73d17514__notice:has(.b5397fb9d05389e3__title) ._0a1270dcdd79c031__actions{margin-block-start:var(--wpds-dimension-gap-sm,8px)}._983740ab855c4e09__action-button{flex-shrink:0}.d329e7416d368d31__action-link{flex-shrink:0;&:not(:first-child){margin-inline-start:var(--wpds-dimension-gap-xs,4px)}&:not(:last-child){margin-inline-end:var(--wpds-dimension-gap-xs,4px)}}._487e6a5c1375f7dc__close-icon{grid-column:3;grid-row:1;margin-inline-start:var(--wpds-dimension-gap-xs,4px)}._531c140826094795__is-info{--wp-ui-notice-background-color:var(--wpds-color-background-surface-info-weak,#f3f9ff);--wp-ui-notice-border-color:var(--wpds-color-stroke-surface-info,#a9c6e7);--wp-ui-notice-text-color:var(--wpds-color-foreground-content-info,#001b4f);--wp-ui-notice-decorative-icon-color:var(--wpds-color-foreground-content-info-weak,#006bd7)}.ae2e1004697cce95__is-warning{--wp-ui-notice-background-color:var(--wpds-color-background-surface-warning-weak,#fff7e1);--wp-ui-notice-border-color:var(--wpds-color-stroke-surface-warning,#e1bc7c);--wp-ui-notice-text-color:var(--wpds-color-foreground-content-warning,#2e1900);--wp-ui-notice-decorative-icon-color:var(--wpds-color-foreground-content-warning-weak,#926300)}._2e614a76af494837__is-success{--wp-ui-notice-background-color:var(--wpds-color-background-surface-success-weak,#ebffed);--wp-ui-notice-border-color:var(--wpds-color-stroke-surface-success,#94d29e);--wp-ui-notice-text-color:var(--wpds-color-foreground-content-success,#002900);--wp-ui-notice-decorative-icon-color:var(--wpds-color-foreground-content-success-weak,#008030)}.af00331ae17a0065__is-error{--wp-ui-notice-background-color:var(--wpds-color-background-surface-error-weak,#fff6f5);--wp-ui-notice-border-color:var(--wpds-color-stroke-surface-error,#dab1aa);--wp-ui-notice-text-color:var(--wpds-color-foreground-content-error,#470000);--wp-ui-notice-decorative-icon-color:var(--wpds-color-foreground-content-error-weak,#cc1818)}@container (max-width: 320px){._4145abab73d17514__notice:has(.b5397fb9d05389e3__title) ._0a1270dcdd79c031__actions,._4145abab73d17514__notice:has(.b5397fb9d05389e3__title) ._1904b570a89bb815__description{grid-column:1/3}}}@layer compositions{.d329e7416d368d31__action-link{margin-block:auto}._487e6a5c1375f7dc__close-icon,._983740ab855c4e09__action-button:is(._8ddb8fb33fbf3d38__is-action-button-outline,._77bbde495a8a0af3__is-action-button-minimal){--wp-ui-button-background-color-active:color-mix(in srgb,transparent 50%,var(--wpds-color-background-interactive-neutral-weak-active,#ededed))}}}");var gs={notice:"_4145abab73d17514__notice",icon:"d0a25570cb528528__icon",title:"b5397fb9d05389e3__title",description:"_1904b570a89bb815__description",actions:"_0a1270dcdd79c031__actions","action-button":"_983740ab855c4e09__action-button","action-link":"d329e7416d368d31__action-link","close-icon":"_487e6a5c1375f7dc__close-icon","is-info":"_531c140826094795__is-info","is-warning":"ae2e1004697cce95__is-warning","is-success":"_2e614a76af494837__is-success","is-error":"af00331ae17a0065__is-error","is-action-button-outline":"_8ddb8fb33fbf3d38__is-action-button-outline","is-action-button-minimal":"_77bbde495a8a0af3__is-action-button-minimal"},mb={neutral:null,info:Xi,warning:Fi,success:qi,error:Ui};function gb(e){return e==="error"?"assertive":"polite"}function bb(e){if(e){if(typeof e=="string")return e;try{return(0,Ao.renderToString)(e)}catch{return}}}function hb(e,t){let o=bb(e);(0,Ao.useEffect)(()=>{o&&db(o,t)},[o,t])}var tu=(0,Ao.forwardRef)(function({intent:t="neutral",children:o,icon:n,spokenMessage:r=o,politeness:i=gb(t),render:s,...a},d){hb(r,i);let c=n===null?null:n??mb[t],l=$(gs.notice,gs[`is-${t}`],pb["box-sizing"]);return bt({defaultTagName:"div",render:s,ref:d,props:ye({className:l,children:(0,Oo.jsxs)(Oo.Fragment,{children:[o,c&&(0,Oo.jsx)(eo,{className:gs.icon,icon:c})]})},a)})});var ou=h(de(),1);var ru=h(Q(),1),ws="data-wp-hash";function vs(){let e=globalThis;return e.__wpStyleRuntime||(e.__wpStyleRuntime={documents:new Map,styles:new Map,injectedStyles:new WeakMap},typeof document<"u"&&vb(document)),e.__wpStyleRuntime}function wb(e,t){if(!e.head)return!1;for(let o of e.head.querySelectorAll(`style[${ws}]`))if(o.getAttribute(ws)===t)return!0;return!1}function nu(e,t,o){if(!e.head)return;let n=vs(),r=n.injectedStyles.get(e);if(r||(r=new Set,n.injectedStyles.set(e,r)),r.has(t))return;if(wb(e,t)){r.add(t);return}let i=e.createElement("style");i.setAttribute(ws,t),i.appendChild(e.createTextNode(o)),e.head.appendChild(i),r.add(t)}function vb(e){let t=vs();t.documents.set(e,(t.documents.get(e)??0)+1);for(let[o,n]of t.styles)nu(e,o,n);return()=>{let o=t.documents.get(e);if(o!==void 0){if(o<=1){t.documents.delete(e);return}t.documents.set(e,o-1)}}}function _b(e,t){let o=vs();o.styles.set(e,t);for(let n of o.documents.keys())nu(n,e,t)}typeof process>"u",_b("726c480820","@layer wp-ui{@layer utilities, components, compositions, overrides;@layer components{._4145abab73d17514__notice{--icon-height:var(--wpds-dimension-size-sm,24px);--text-vertical-padding:calc((var(--icon-height) - var(--wpds-typography-line-height-sm, 20px))/2);--wp-ui-notice-background-color:var(--wpds-color-background-surface-neutral-weak,#f4f4f4);--wp-ui-notice-border-color:var(--wpds-color-stroke-surface-neutral,#dbdbdb);--wp-ui-notice-text-color:var(--wpds-color-foreground-content-neutral,#1e1e1e);--wp-ui-notice-decorative-icon-color:var(--wpds-color-foreground-content-neutral,#1e1e1e);align-items:start;background-color:var(--wp-ui-notice-background-color);border:1px solid var(--wp-ui-notice-border-color);border-radius:var(--wpds-border-radius-lg,8px);container-type:inline-size;display:grid;grid-template-columns:auto 1fr auto;padding:var(--wpds-dimension-padding-md,12px)}.d0a25570cb528528__icon{color:var(--wp-ui-notice-decorative-icon-color);grid-column:1;grid-row:1;margin-inline-end:var(--wpds-dimension-gap-xs,4px)}._1904b570a89bb815__description,.b5397fb9d05389e3__title{color:var(--wp-ui-notice-text-color);grid-column:2;padding-block:var(--text-vertical-padding)}._1904b570a89bb815__description{text-wrap:pretty}._0a1270dcdd79c031__actions{display:flex;flex-wrap:wrap;gap:var(--wpds-dimension-gap-md,12px);grid-column:2}._4145abab73d17514__notice:has(._1904b570a89bb815__description) ._0a1270dcdd79c031__actions,._4145abab73d17514__notice:has(.b5397fb9d05389e3__title) ._0a1270dcdd79c031__actions{margin-block-start:var(--wpds-dimension-gap-sm,8px)}._983740ab855c4e09__action-button{flex-shrink:0}.d329e7416d368d31__action-link{flex-shrink:0;&:not(:first-child){margin-inline-start:var(--wpds-dimension-gap-xs,4px)}&:not(:last-child){margin-inline-end:var(--wpds-dimension-gap-xs,4px)}}._487e6a5c1375f7dc__close-icon{grid-column:3;grid-row:1;margin-inline-start:var(--wpds-dimension-gap-xs,4px)}._531c140826094795__is-info{--wp-ui-notice-background-color:var(--wpds-color-background-surface-info-weak,#f3f9ff);--wp-ui-notice-border-color:var(--wpds-color-stroke-surface-info,#a9c6e7);--wp-ui-notice-text-color:var(--wpds-color-foreground-content-info,#001b4f);--wp-ui-notice-decorative-icon-color:var(--wpds-color-foreground-content-info-weak,#006bd7)}.ae2e1004697cce95__is-warning{--wp-ui-notice-background-color:var(--wpds-color-background-surface-warning-weak,#fff7e1);--wp-ui-notice-border-color:var(--wpds-color-stroke-surface-warning,#e1bc7c);--wp-ui-notice-text-color:var(--wpds-color-foreground-content-warning,#2e1900);--wp-ui-notice-decorative-icon-color:var(--wpds-color-foreground-content-warning-weak,#926300)}._2e614a76af494837__is-success{--wp-ui-notice-background-color:var(--wpds-color-background-surface-success-weak,#ebffed);--wp-ui-notice-border-color:var(--wpds-color-stroke-surface-success,#94d29e);--wp-ui-notice-text-color:var(--wpds-color-foreground-content-success,#002900);--wp-ui-notice-decorative-icon-color:var(--wpds-color-foreground-content-success-weak,#008030)}.af00331ae17a0065__is-error{--wp-ui-notice-background-color:var(--wpds-color-background-surface-error-weak,#fff6f5);--wp-ui-notice-border-color:var(--wpds-color-stroke-surface-error,#dab1aa);--wp-ui-notice-text-color:var(--wpds-color-foreground-content-error,#470000);--wp-ui-notice-decorative-icon-color:var(--wpds-color-foreground-content-error-weak,#cc1818)}@container (max-width: 320px){._4145abab73d17514__notice:has(.b5397fb9d05389e3__title) ._0a1270dcdd79c031__actions,._4145abab73d17514__notice:has(.b5397fb9d05389e3__title) ._1904b570a89bb815__description{grid-column:1/3}}}@layer compositions{.d329e7416d368d31__action-link{margin-block:auto}._487e6a5c1375f7dc__close-icon,._983740ab855c4e09__action-button:is(._8ddb8fb33fbf3d38__is-action-button-outline,._77bbde495a8a0af3__is-action-button-minimal){--wp-ui-button-background-color-active:color-mix(in srgb,transparent 50%,var(--wpds-color-background-interactive-neutral-weak-active,#ededed))}}}");var yb={notice:"_4145abab73d17514__notice",icon:"d0a25570cb528528__icon",title:"b5397fb9d05389e3__title",description:"_1904b570a89bb815__description",actions:"_0a1270dcdd79c031__actions","action-button":"_983740ab855c4e09__action-button","action-link":"d329e7416d368d31__action-link","close-icon":"_487e6a5c1375f7dc__close-icon","is-info":"_531c140826094795__is-info","is-warning":"ae2e1004697cce95__is-warning","is-success":"_2e614a76af494837__is-success","is-error":"af00331ae17a0065__is-error","is-action-button-outline":"_8ddb8fb33fbf3d38__is-action-button-outline","is-action-button-minimal":"_77bbde495a8a0af3__is-action-button-minimal"},iu=(0,ou.forwardRef)(function({className:t,...o},n){return(0,ru.jsx)(Je,{ref:n,variant:"heading-md",className:$(yb.title,t),...o})});var su=h(de(),1);var cu=h(Q(),1),_s="data-wp-hash";function ys(){let e=globalThis;return e.__wpStyleRuntime||(e.__wpStyleRuntime={documents:new Map,styles:new Map,injectedStyles:new WeakMap},typeof document<"u"&&Rb(document)),e.__wpStyleRuntime}function xb(e,t){if(!e.head)return!1;for(let o of e.head.querySelectorAll(`style[${_s}]`))if(o.getAttribute(_s)===t)return!0;return!1}function au(e,t,o){if(!e.head)return;let n=ys(),r=n.injectedStyles.get(e);if(r||(r=new Set,n.injectedStyles.set(e,r)),r.has(t))return;if(xb(e,t)){r.add(t);return}let i=e.createElement("style");i.setAttribute(_s,t),i.appendChild(e.createTextNode(o)),e.head.appendChild(i),r.add(t)}function Rb(e){let t=ys();t.documents.set(e,(t.documents.get(e)??0)+1);for(let[o,n]of t.styles)au(e,o,n);return()=>{let o=t.documents.get(e);if(o!==void 0){if(o<=1){t.documents.delete(e);return}t.documents.set(e,o-1)}}}function Sb(e,t){let o=ys();o.styles.set(e,t);for(let n of o.documents.keys())au(n,e,t)}typeof process>"u",Sb("726c480820","@layer wp-ui{@layer utilities, components, compositions, overrides;@layer components{._4145abab73d17514__notice{--icon-height:var(--wpds-dimension-size-sm,24px);--text-vertical-padding:calc((var(--icon-height) - var(--wpds-typography-line-height-sm, 20px))/2);--wp-ui-notice-background-color:var(--wpds-color-background-surface-neutral-weak,#f4f4f4);--wp-ui-notice-border-color:var(--wpds-color-stroke-surface-neutral,#dbdbdb);--wp-ui-notice-text-color:var(--wpds-color-foreground-content-neutral,#1e1e1e);--wp-ui-notice-decorative-icon-color:var(--wpds-color-foreground-content-neutral,#1e1e1e);align-items:start;background-color:var(--wp-ui-notice-background-color);border:1px solid var(--wp-ui-notice-border-color);border-radius:var(--wpds-border-radius-lg,8px);container-type:inline-size;display:grid;grid-template-columns:auto 1fr auto;padding:var(--wpds-dimension-padding-md,12px)}.d0a25570cb528528__icon{color:var(--wp-ui-notice-decorative-icon-color);grid-column:1;grid-row:1;margin-inline-end:var(--wpds-dimension-gap-xs,4px)}._1904b570a89bb815__description,.b5397fb9d05389e3__title{color:var(--wp-ui-notice-text-color);grid-column:2;padding-block:var(--text-vertical-padding)}._1904b570a89bb815__description{text-wrap:pretty}._0a1270dcdd79c031__actions{display:flex;flex-wrap:wrap;gap:var(--wpds-dimension-gap-md,12px);grid-column:2}._4145abab73d17514__notice:has(._1904b570a89bb815__description) ._0a1270dcdd79c031__actions,._4145abab73d17514__notice:has(.b5397fb9d05389e3__title) ._0a1270dcdd79c031__actions{margin-block-start:var(--wpds-dimension-gap-sm,8px)}._983740ab855c4e09__action-button{flex-shrink:0}.d329e7416d368d31__action-link{flex-shrink:0;&:not(:first-child){margin-inline-start:var(--wpds-dimension-gap-xs,4px)}&:not(:last-child){margin-inline-end:var(--wpds-dimension-gap-xs,4px)}}._487e6a5c1375f7dc__close-icon{grid-column:3;grid-row:1;margin-inline-start:var(--wpds-dimension-gap-xs,4px)}._531c140826094795__is-info{--wp-ui-notice-background-color:var(--wpds-color-background-surface-info-weak,#f3f9ff);--wp-ui-notice-border-color:var(--wpds-color-stroke-surface-info,#a9c6e7);--wp-ui-notice-text-color:var(--wpds-color-foreground-content-info,#001b4f);--wp-ui-notice-decorative-icon-color:var(--wpds-color-foreground-content-info-weak,#006bd7)}.ae2e1004697cce95__is-warning{--wp-ui-notice-background-color:var(--wpds-color-background-surface-warning-weak,#fff7e1);--wp-ui-notice-border-color:var(--wpds-color-stroke-surface-warning,#e1bc7c);--wp-ui-notice-text-color:var(--wpds-color-foreground-content-warning,#2e1900);--wp-ui-notice-decorative-icon-color:var(--wpds-color-foreground-content-warning-weak,#926300)}._2e614a76af494837__is-success{--wp-ui-notice-background-color:var(--wpds-color-background-surface-success-weak,#ebffed);--wp-ui-notice-border-color:var(--wpds-color-stroke-surface-success,#94d29e);--wp-ui-notice-text-color:var(--wpds-color-foreground-content-success,#002900);--wp-ui-notice-decorative-icon-color:var(--wpds-color-foreground-content-success-weak,#008030)}.af00331ae17a0065__is-error{--wp-ui-notice-background-color:var(--wpds-color-background-surface-error-weak,#fff6f5);--wp-ui-notice-border-color:var(--wpds-color-stroke-surface-error,#dab1aa);--wp-ui-notice-text-color:var(--wpds-color-foreground-content-error,#470000);--wp-ui-notice-decorative-icon-color:var(--wpds-color-foreground-content-error-weak,#cc1818)}@container (max-width: 320px){._4145abab73d17514__notice:has(.b5397fb9d05389e3__title) ._0a1270dcdd79c031__actions,._4145abab73d17514__notice:has(.b5397fb9d05389e3__title) ._1904b570a89bb815__description{grid-column:1/3}}}@layer compositions{.d329e7416d368d31__action-link{margin-block:auto}._487e6a5c1375f7dc__close-icon,._983740ab855c4e09__action-button:is(._8ddb8fb33fbf3d38__is-action-button-outline,._77bbde495a8a0af3__is-action-button-minimal){--wp-ui-button-background-color-active:color-mix(in srgb,transparent 50%,var(--wpds-color-background-interactive-neutral-weak-active,#ededed))}}}");var Eb={notice:"_4145abab73d17514__notice",icon:"d0a25570cb528528__icon",title:"b5397fb9d05389e3__title",description:"_1904b570a89bb815__description",actions:"_0a1270dcdd79c031__actions","action-button":"_983740ab855c4e09__action-button","action-link":"d329e7416d368d31__action-link","close-icon":"_487e6a5c1375f7dc__close-icon","is-info":"_531c140826094795__is-info","is-warning":"ae2e1004697cce95__is-warning","is-success":"_2e614a76af494837__is-success","is-error":"af00331ae17a0065__is-error","is-action-button-outline":"_8ddb8fb33fbf3d38__is-action-button-outline","is-action-button-minimal":"_77bbde495a8a0af3__is-action-button-minimal"},lu=(0,su.forwardRef)(function({className:t,...o},n){return(0,cu.jsx)(Je,{ref:n,variant:"body-md",className:$(Eb.description,t),...o})});var du=h(de(),1);var xs="data-wp-hash";function Rs(){let e=globalThis;return e.__wpStyleRuntime||(e.__wpStyleRuntime={documents:new Map,styles:new Map,injectedStyles:new WeakMap},typeof document<"u"&&kb(document)),e.__wpStyleRuntime}function Tb(e,t){if(!e.head)return!1;for(let o of e.head.querySelectorAll(`style[${xs}]`))if(o.getAttribute(xs)===t)return!0;return!1}function uu(e,t,o){if(!e.head)return;let n=Rs(),r=n.injectedStyles.get(e);if(r||(r=new Set,n.injectedStyles.set(e,r)),r.has(t))return;if(Tb(e,t)){r.add(t);return}let i=e.createElement("style");i.setAttribute(xs,t),i.appendChild(e.createTextNode(o)),e.head.appendChild(i),r.add(t)}function kb(e){let t=Rs();t.documents.set(e,(t.documents.get(e)??0)+1);for(let[o,n]of t.styles)uu(e,o,n);return()=>{let o=t.documents.get(e);if(o!==void 0){if(o<=1){t.documents.delete(e);return}t.documents.set(e,o-1)}}}function Pb(e,t){let o=Rs();o.styles.set(e,t);for(let n of o.documents.keys())uu(n,e,t)}typeof process>"u",Pb("726c480820","@layer wp-ui{@layer utilities, components, compositions, overrides;@layer components{._4145abab73d17514__notice{--icon-height:var(--wpds-dimension-size-sm,24px);--text-vertical-padding:calc((var(--icon-height) - var(--wpds-typography-line-height-sm, 20px))/2);--wp-ui-notice-background-color:var(--wpds-color-background-surface-neutral-weak,#f4f4f4);--wp-ui-notice-border-color:var(--wpds-color-stroke-surface-neutral,#dbdbdb);--wp-ui-notice-text-color:var(--wpds-color-foreground-content-neutral,#1e1e1e);--wp-ui-notice-decorative-icon-color:var(--wpds-color-foreground-content-neutral,#1e1e1e);align-items:start;background-color:var(--wp-ui-notice-background-color);border:1px solid var(--wp-ui-notice-border-color);border-radius:var(--wpds-border-radius-lg,8px);container-type:inline-size;display:grid;grid-template-columns:auto 1fr auto;padding:var(--wpds-dimension-padding-md,12px)}.d0a25570cb528528__icon{color:var(--wp-ui-notice-decorative-icon-color);grid-column:1;grid-row:1;margin-inline-end:var(--wpds-dimension-gap-xs,4px)}._1904b570a89bb815__description,.b5397fb9d05389e3__title{color:var(--wp-ui-notice-text-color);grid-column:2;padding-block:var(--text-vertical-padding)}._1904b570a89bb815__description{text-wrap:pretty}._0a1270dcdd79c031__actions{display:flex;flex-wrap:wrap;gap:var(--wpds-dimension-gap-md,12px);grid-column:2}._4145abab73d17514__notice:has(._1904b570a89bb815__description) ._0a1270dcdd79c031__actions,._4145abab73d17514__notice:has(.b5397fb9d05389e3__title) ._0a1270dcdd79c031__actions{margin-block-start:var(--wpds-dimension-gap-sm,8px)}._983740ab855c4e09__action-button{flex-shrink:0}.d329e7416d368d31__action-link{flex-shrink:0;&:not(:first-child){margin-inline-start:var(--wpds-dimension-gap-xs,4px)}&:not(:last-child){margin-inline-end:var(--wpds-dimension-gap-xs,4px)}}._487e6a5c1375f7dc__close-icon{grid-column:3;grid-row:1;margin-inline-start:var(--wpds-dimension-gap-xs,4px)}._531c140826094795__is-info{--wp-ui-notice-background-color:var(--wpds-color-background-surface-info-weak,#f3f9ff);--wp-ui-notice-border-color:var(--wpds-color-stroke-surface-info,#a9c6e7);--wp-ui-notice-text-color:var(--wpds-color-foreground-content-info,#001b4f);--wp-ui-notice-decorative-icon-color:var(--wpds-color-foreground-content-info-weak,#006bd7)}.ae2e1004697cce95__is-warning{--wp-ui-notice-background-color:var(--wpds-color-background-surface-warning-weak,#fff7e1);--wp-ui-notice-border-color:var(--wpds-color-stroke-surface-warning,#e1bc7c);--wp-ui-notice-text-color:var(--wpds-color-foreground-content-warning,#2e1900);--wp-ui-notice-decorative-icon-color:var(--wpds-color-foreground-content-warning-weak,#926300)}._2e614a76af494837__is-success{--wp-ui-notice-background-color:var(--wpds-color-background-surface-success-weak,#ebffed);--wp-ui-notice-border-color:var(--wpds-color-stroke-surface-success,#94d29e);--wp-ui-notice-text-color:var(--wpds-color-foreground-content-success,#002900);--wp-ui-notice-decorative-icon-color:var(--wpds-color-foreground-content-success-weak,#008030)}.af00331ae17a0065__is-error{--wp-ui-notice-background-color:var(--wpds-color-background-surface-error-weak,#fff6f5);--wp-ui-notice-border-color:var(--wpds-color-stroke-surface-error,#dab1aa);--wp-ui-notice-text-color:var(--wpds-color-foreground-content-error,#470000);--wp-ui-notice-decorative-icon-color:var(--wpds-color-foreground-content-error-weak,#cc1818)}@container (max-width: 320px){._4145abab73d17514__notice:has(.b5397fb9d05389e3__title) ._0a1270dcdd79c031__actions,._4145abab73d17514__notice:has(.b5397fb9d05389e3__title) ._1904b570a89bb815__description{grid-column:1/3}}}@layer compositions{.d329e7416d368d31__action-link{margin-block:auto}._487e6a5c1375f7dc__close-icon,._983740ab855c4e09__action-button:is(._8ddb8fb33fbf3d38__is-action-button-outline,._77bbde495a8a0af3__is-action-button-minimal){--wp-ui-button-background-color-active:color-mix(in srgb,transparent 50%,var(--wpds-color-background-interactive-neutral-weak-active,#ededed))}}}");var Cb={notice:"_4145abab73d17514__notice",icon:"d0a25570cb528528__icon",title:"b5397fb9d05389e3__title",description:"_1904b570a89bb815__description",actions:"_0a1270dcdd79c031__actions","action-button":"_983740ab855c4e09__action-button","action-link":"d329e7416d368d31__action-link","close-icon":"_487e6a5c1375f7dc__close-icon","is-info":"_531c140826094795__is-info","is-warning":"ae2e1004697cce95__is-warning","is-success":"_2e614a76af494837__is-success","is-error":"af00331ae17a0065__is-error","is-action-button-outline":"_8ddb8fb33fbf3d38__is-action-button-outline","is-action-button-minimal":"_77bbde495a8a0af3__is-action-button-minimal"},fu=(0,du.forwardRef)(function({render:t,...o},n){return bt({defaultTagName:"div",render:t,ref:n,props:ye({className:Cb.actions},o)})});var pu=h(de(),1),mu=h(Ot(),1);var bu=h(Q(),1),Ss="data-wp-hash";function Es(){let e=globalThis;return e.__wpStyleRuntime||(e.__wpStyleRuntime={documents:new Map,styles:new Map,injectedStyles:new WeakMap},typeof document<"u"&&Ob(document)),e.__wpStyleRuntime}function Ab(e,t){if(!e.head)return!1;for(let o of e.head.querySelectorAll(`style[${Ss}]`))if(o.getAttribute(Ss)===t)return!0;return!1}function gu(e,t,o){if(!e.head)return;let n=Es(),r=n.injectedStyles.get(e);if(r||(r=new Set,n.injectedStyles.set(e,r)),r.has(t))return;if(Ab(e,t)){r.add(t);return}let i=e.createElement("style");i.setAttribute(Ss,t),i.appendChild(e.createTextNode(o)),e.head.appendChild(i),r.add(t)}function Ob(e){let t=Es();t.documents.set(e,(t.documents.get(e)??0)+1);for(let[o,n]of t.styles)gu(e,o,n);return()=>{let o=t.documents.get(e);if(o!==void 0){if(o<=1){t.documents.delete(e);return}t.documents.set(e,o-1)}}}function Nb(e,t){let o=Es();o.styles.set(e,t);for(let n of o.documents.keys())gu(n,e,t)}typeof process>"u",Nb("726c480820","@layer wp-ui{@layer utilities, components, compositions, overrides;@layer components{._4145abab73d17514__notice{--icon-height:var(--wpds-dimension-size-sm,24px);--text-vertical-padding:calc((var(--icon-height) - var(--wpds-typography-line-height-sm, 20px))/2);--wp-ui-notice-background-color:var(--wpds-color-background-surface-neutral-weak,#f4f4f4);--wp-ui-notice-border-color:var(--wpds-color-stroke-surface-neutral,#dbdbdb);--wp-ui-notice-text-color:var(--wpds-color-foreground-content-neutral,#1e1e1e);--wp-ui-notice-decorative-icon-color:var(--wpds-color-foreground-content-neutral,#1e1e1e);align-items:start;background-color:var(--wp-ui-notice-background-color);border:1px solid var(--wp-ui-notice-border-color);border-radius:var(--wpds-border-radius-lg,8px);container-type:inline-size;display:grid;grid-template-columns:auto 1fr auto;padding:var(--wpds-dimension-padding-md,12px)}.d0a25570cb528528__icon{color:var(--wp-ui-notice-decorative-icon-color);grid-column:1;grid-row:1;margin-inline-end:var(--wpds-dimension-gap-xs,4px)}._1904b570a89bb815__description,.b5397fb9d05389e3__title{color:var(--wp-ui-notice-text-color);grid-column:2;padding-block:var(--text-vertical-padding)}._1904b570a89bb815__description{text-wrap:pretty}._0a1270dcdd79c031__actions{display:flex;flex-wrap:wrap;gap:var(--wpds-dimension-gap-md,12px);grid-column:2}._4145abab73d17514__notice:has(._1904b570a89bb815__description) ._0a1270dcdd79c031__actions,._4145abab73d17514__notice:has(.b5397fb9d05389e3__title) ._0a1270dcdd79c031__actions{margin-block-start:var(--wpds-dimension-gap-sm,8px)}._983740ab855c4e09__action-button{flex-shrink:0}.d329e7416d368d31__action-link{flex-shrink:0;&:not(:first-child){margin-inline-start:var(--wpds-dimension-gap-xs,4px)}&:not(:last-child){margin-inline-end:var(--wpds-dimension-gap-xs,4px)}}._487e6a5c1375f7dc__close-icon{grid-column:3;grid-row:1;margin-inline-start:var(--wpds-dimension-gap-xs,4px)}._531c140826094795__is-info{--wp-ui-notice-background-color:var(--wpds-color-background-surface-info-weak,#f3f9ff);--wp-ui-notice-border-color:var(--wpds-color-stroke-surface-info,#a9c6e7);--wp-ui-notice-text-color:var(--wpds-color-foreground-content-info,#001b4f);--wp-ui-notice-decorative-icon-color:var(--wpds-color-foreground-content-info-weak,#006bd7)}.ae2e1004697cce95__is-warning{--wp-ui-notice-background-color:var(--wpds-color-background-surface-warning-weak,#fff7e1);--wp-ui-notice-border-color:var(--wpds-color-stroke-surface-warning,#e1bc7c);--wp-ui-notice-text-color:var(--wpds-color-foreground-content-warning,#2e1900);--wp-ui-notice-decorative-icon-color:var(--wpds-color-foreground-content-warning-weak,#926300)}._2e614a76af494837__is-success{--wp-ui-notice-background-color:var(--wpds-color-background-surface-success-weak,#ebffed);--wp-ui-notice-border-color:var(--wpds-color-stroke-surface-success,#94d29e);--wp-ui-notice-text-color:var(--wpds-color-foreground-content-success,#002900);--wp-ui-notice-decorative-icon-color:var(--wpds-color-foreground-content-success-weak,#008030)}.af00331ae17a0065__is-error{--wp-ui-notice-background-color:var(--wpds-color-background-surface-error-weak,#fff6f5);--wp-ui-notice-border-color:var(--wpds-color-stroke-surface-error,#dab1aa);--wp-ui-notice-text-color:var(--wpds-color-foreground-content-error,#470000);--wp-ui-notice-decorative-icon-color:var(--wpds-color-foreground-content-error-weak,#cc1818)}@container (max-width: 320px){._4145abab73d17514__notice:has(.b5397fb9d05389e3__title) ._0a1270dcdd79c031__actions,._4145abab73d17514__notice:has(.b5397fb9d05389e3__title) ._1904b570a89bb815__description{grid-column:1/3}}}@layer compositions{.d329e7416d368d31__action-link{margin-block:auto}._487e6a5c1375f7dc__close-icon,._983740ab855c4e09__action-button:is(._8ddb8fb33fbf3d38__is-action-button-outline,._77bbde495a8a0af3__is-action-button-minimal){--wp-ui-button-background-color-active:color-mix(in srgb,transparent 50%,var(--wpds-color-background-interactive-neutral-weak-active,#ededed))}}}");var Lb={notice:"_4145abab73d17514__notice",icon:"d0a25570cb528528__icon",title:"b5397fb9d05389e3__title",description:"_1904b570a89bb815__description",actions:"_0a1270dcdd79c031__actions","action-button":"_983740ab855c4e09__action-button","action-link":"d329e7416d368d31__action-link","close-icon":"_487e6a5c1375f7dc__close-icon","is-info":"_531c140826094795__is-info","is-warning":"ae2e1004697cce95__is-warning","is-success":"_2e614a76af494837__is-success","is-error":"af00331ae17a0065__is-error","is-action-button-outline":"_8ddb8fb33fbf3d38__is-action-button-outline","is-action-button-minimal":"_77bbde495a8a0af3__is-action-button-minimal"},hu=(0,pu.forwardRef)(function({className:t,icon:o=Wi,label:n=(0,mu.__)("Dismiss"),...r},i){return(0,bu.jsx)(fs,{...r,ref:i,className:$(Lb["close-icon"],t),variant:"minimal",size:"small",tone:"neutral",icon:o,label:n})});var vu=h(de(),1);var yu=h(Q(),1),Ts="data-wp-hash";function ks(){let e=globalThis;return e.__wpStyleRuntime||(e.__wpStyleRuntime={documents:new Map,styles:new Map,injectedStyles:new WeakMap},typeof document<"u"&&Mb(document)),e.__wpStyleRuntime}function Ib(e,t){if(!e.head)return!1;for(let o of e.head.querySelectorAll(`style[${Ts}]`))if(o.getAttribute(Ts)===t)return!0;return!1}function _u(e,t,o){if(!e.head)return;let n=ks(),r=n.injectedStyles.get(e);if(r||(r=new Set,n.injectedStyles.set(e,r)),r.has(t))return;if(Ib(e,t)){r.add(t);return}let i=e.createElement("style");i.setAttribute(Ts,t),i.appendChild(e.createTextNode(o)),e.head.appendChild(i),r.add(t)}function Mb(e){let t=ks();t.documents.set(e,(t.documents.get(e)??0)+1);for(let[o,n]of t.styles)_u(e,o,n);return()=>{let o=t.documents.get(e);if(o!==void 0){if(o<=1){t.documents.delete(e);return}t.documents.set(e,o-1)}}}function Bb(e,t){let o=ks();o.styles.set(e,t);for(let n of o.documents.keys())_u(n,e,t)}typeof process>"u",Bb("726c480820","@layer wp-ui{@layer utilities, components, compositions, overrides;@layer components{._4145abab73d17514__notice{--icon-height:var(--wpds-dimension-size-sm,24px);--text-vertical-padding:calc((var(--icon-height) - var(--wpds-typography-line-height-sm, 20px))/2);--wp-ui-notice-background-color:var(--wpds-color-background-surface-neutral-weak,#f4f4f4);--wp-ui-notice-border-color:var(--wpds-color-stroke-surface-neutral,#dbdbdb);--wp-ui-notice-text-color:var(--wpds-color-foreground-content-neutral,#1e1e1e);--wp-ui-notice-decorative-icon-color:var(--wpds-color-foreground-content-neutral,#1e1e1e);align-items:start;background-color:var(--wp-ui-notice-background-color);border:1px solid var(--wp-ui-notice-border-color);border-radius:var(--wpds-border-radius-lg,8px);container-type:inline-size;display:grid;grid-template-columns:auto 1fr auto;padding:var(--wpds-dimension-padding-md,12px)}.d0a25570cb528528__icon{color:var(--wp-ui-notice-decorative-icon-color);grid-column:1;grid-row:1;margin-inline-end:var(--wpds-dimension-gap-xs,4px)}._1904b570a89bb815__description,.b5397fb9d05389e3__title{color:var(--wp-ui-notice-text-color);grid-column:2;padding-block:var(--text-vertical-padding)}._1904b570a89bb815__description{text-wrap:pretty}._0a1270dcdd79c031__actions{display:flex;flex-wrap:wrap;gap:var(--wpds-dimension-gap-md,12px);grid-column:2}._4145abab73d17514__notice:has(._1904b570a89bb815__description) ._0a1270dcdd79c031__actions,._4145abab73d17514__notice:has(.b5397fb9d05389e3__title) ._0a1270dcdd79c031__actions{margin-block-start:var(--wpds-dimension-gap-sm,8px)}._983740ab855c4e09__action-button{flex-shrink:0}.d329e7416d368d31__action-link{flex-shrink:0;&:not(:first-child){margin-inline-start:var(--wpds-dimension-gap-xs,4px)}&:not(:last-child){margin-inline-end:var(--wpds-dimension-gap-xs,4px)}}._487e6a5c1375f7dc__close-icon{grid-column:3;grid-row:1;margin-inline-start:var(--wpds-dimension-gap-xs,4px)}._531c140826094795__is-info{--wp-ui-notice-background-color:var(--wpds-color-background-surface-info-weak,#f3f9ff);--wp-ui-notice-border-color:var(--wpds-color-stroke-surface-info,#a9c6e7);--wp-ui-notice-text-color:var(--wpds-color-foreground-content-info,#001b4f);--wp-ui-notice-decorative-icon-color:var(--wpds-color-foreground-content-info-weak,#006bd7)}.ae2e1004697cce95__is-warning{--wp-ui-notice-background-color:var(--wpds-color-background-surface-warning-weak,#fff7e1);--wp-ui-notice-border-color:var(--wpds-color-stroke-surface-warning,#e1bc7c);--wp-ui-notice-text-color:var(--wpds-color-foreground-content-warning,#2e1900);--wp-ui-notice-decorative-icon-color:var(--wpds-color-foreground-content-warning-weak,#926300)}._2e614a76af494837__is-success{--wp-ui-notice-background-color:var(--wpds-color-background-surface-success-weak,#ebffed);--wp-ui-notice-border-color:var(--wpds-color-stroke-surface-success,#94d29e);--wp-ui-notice-text-color:var(--wpds-color-foreground-content-success,#002900);--wp-ui-notice-decorative-icon-color:var(--wpds-color-foreground-content-success-weak,#008030)}.af00331ae17a0065__is-error{--wp-ui-notice-background-color:var(--wpds-color-background-surface-error-weak,#fff6f5);--wp-ui-notice-border-color:var(--wpds-color-stroke-surface-error,#dab1aa);--wp-ui-notice-text-color:var(--wpds-color-foreground-content-error,#470000);--wp-ui-notice-decorative-icon-color:var(--wpds-color-foreground-content-error-weak,#cc1818)}@container (max-width: 320px){._4145abab73d17514__notice:has(.b5397fb9d05389e3__title) ._0a1270dcdd79c031__actions,._4145abab73d17514__notice:has(.b5397fb9d05389e3__title) ._1904b570a89bb815__description{grid-column:1/3}}}@layer compositions{.d329e7416d368d31__action-link{margin-block:auto}._487e6a5c1375f7dc__close-icon,._983740ab855c4e09__action-button:is(._8ddb8fb33fbf3d38__is-action-button-outline,._77bbde495a8a0af3__is-action-button-minimal){--wp-ui-button-background-color-active:color-mix(in srgb,transparent 50%,var(--wpds-color-background-interactive-neutral-weak-active,#ededed))}}}");var wu={notice:"_4145abab73d17514__notice",icon:"d0a25570cb528528__icon",title:"b5397fb9d05389e3__title",description:"_1904b570a89bb815__description",actions:"_0a1270dcdd79c031__actions","action-button":"_983740ab855c4e09__action-button","action-link":"d329e7416d368d31__action-link","close-icon":"_487e6a5c1375f7dc__close-icon","is-info":"_531c140826094795__is-info","is-warning":"ae2e1004697cce95__is-warning","is-success":"_2e614a76af494837__is-success","is-error":"af00331ae17a0065__is-error","is-action-button-outline":"_8ddb8fb33fbf3d38__is-action-button-outline","is-action-button-minimal":"_77bbde495a8a0af3__is-action-button-minimal"},xu=(0,vu.forwardRef)(function({className:t,loading:o,loadingAnnouncement:n,variant:r,...i},s){return(0,yu.jsx)(sr,{...i,...o!==void 0?{loading:o,loadingAnnouncement:n??""}:{},ref:s,size:"compact",tone:"neutral",variant:r,className:$(wu["action-button"],wu[`is-action-button-${r}`],t)})});var Ru=h(de(),1);var Cs=h(Q(),1),Ps="data-wp-hash";function As(){let e=globalThis;return e.__wpStyleRuntime||(e.__wpStyleRuntime={documents:new Map,styles:new Map,injectedStyles:new WeakMap},typeof document<"u"&&zb(document)),e.__wpStyleRuntime}function Hb(e,t){if(!e.head)return!1;for(let o of e.head.querySelectorAll(`style[${Ps}]`))if(o.getAttribute(Ps)===t)return!0;return!1}function Su(e,t,o){if(!e.head)return;let n=As(),r=n.injectedStyles.get(e);if(r||(r=new Set,n.injectedStyles.set(e,r)),r.has(t))return;if(Hb(e,t)){r.add(t);return}let i=e.createElement("style");i.setAttribute(Ps,t),i.appendChild(e.createTextNode(o)),e.head.appendChild(i),r.add(t)}function zb(e){let t=As();t.documents.set(e,(t.documents.get(e)??0)+1);for(let[o,n]of t.styles)Su(e,o,n);return()=>{let o=t.documents.get(e);if(o!==void 0){if(o<=1){t.documents.delete(e);return}t.documents.set(e,o-1)}}}function Db(e,t){let o=As();o.styles.set(e,t);for(let n of o.documents.keys())Su(n,e,t)}typeof process>"u",Db("726c480820","@layer wp-ui{@layer utilities, components, compositions, overrides;@layer components{._4145abab73d17514__notice{--icon-height:var(--wpds-dimension-size-sm,24px);--text-vertical-padding:calc((var(--icon-height) - var(--wpds-typography-line-height-sm, 20px))/2);--wp-ui-notice-background-color:var(--wpds-color-background-surface-neutral-weak,#f4f4f4);--wp-ui-notice-border-color:var(--wpds-color-stroke-surface-neutral,#dbdbdb);--wp-ui-notice-text-color:var(--wpds-color-foreground-content-neutral,#1e1e1e);--wp-ui-notice-decorative-icon-color:var(--wpds-color-foreground-content-neutral,#1e1e1e);align-items:start;background-color:var(--wp-ui-notice-background-color);border:1px solid var(--wp-ui-notice-border-color);border-radius:var(--wpds-border-radius-lg,8px);container-type:inline-size;display:grid;grid-template-columns:auto 1fr auto;padding:var(--wpds-dimension-padding-md,12px)}.d0a25570cb528528__icon{color:var(--wp-ui-notice-decorative-icon-color);grid-column:1;grid-row:1;margin-inline-end:var(--wpds-dimension-gap-xs,4px)}._1904b570a89bb815__description,.b5397fb9d05389e3__title{color:var(--wp-ui-notice-text-color);grid-column:2;padding-block:var(--text-vertical-padding)}._1904b570a89bb815__description{text-wrap:pretty}._0a1270dcdd79c031__actions{display:flex;flex-wrap:wrap;gap:var(--wpds-dimension-gap-md,12px);grid-column:2}._4145abab73d17514__notice:has(._1904b570a89bb815__description) ._0a1270dcdd79c031__actions,._4145abab73d17514__notice:has(.b5397fb9d05389e3__title) ._0a1270dcdd79c031__actions{margin-block-start:var(--wpds-dimension-gap-sm,8px)}._983740ab855c4e09__action-button{flex-shrink:0}.d329e7416d368d31__action-link{flex-shrink:0;&:not(:first-child){margin-inline-start:var(--wpds-dimension-gap-xs,4px)}&:not(:last-child){margin-inline-end:var(--wpds-dimension-gap-xs,4px)}}._487e6a5c1375f7dc__close-icon{grid-column:3;grid-row:1;margin-inline-start:var(--wpds-dimension-gap-xs,4px)}._531c140826094795__is-info{--wp-ui-notice-background-color:var(--wpds-color-background-surface-info-weak,#f3f9ff);--wp-ui-notice-border-color:var(--wpds-color-stroke-surface-info,#a9c6e7);--wp-ui-notice-text-color:var(--wpds-color-foreground-content-info,#001b4f);--wp-ui-notice-decorative-icon-color:var(--wpds-color-foreground-content-info-weak,#006bd7)}.ae2e1004697cce95__is-warning{--wp-ui-notice-background-color:var(--wpds-color-background-surface-warning-weak,#fff7e1);--wp-ui-notice-border-color:var(--wpds-color-stroke-surface-warning,#e1bc7c);--wp-ui-notice-text-color:var(--wpds-color-foreground-content-warning,#2e1900);--wp-ui-notice-decorative-icon-color:var(--wpds-color-foreground-content-warning-weak,#926300)}._2e614a76af494837__is-success{--wp-ui-notice-background-color:var(--wpds-color-background-surface-success-weak,#ebffed);--wp-ui-notice-border-color:var(--wpds-color-stroke-surface-success,#94d29e);--wp-ui-notice-text-color:var(--wpds-color-foreground-content-success,#002900);--wp-ui-notice-decorative-icon-color:var(--wpds-color-foreground-content-success-weak,#008030)}.af00331ae17a0065__is-error{--wp-ui-notice-background-color:var(--wpds-color-background-surface-error-weak,#fff6f5);--wp-ui-notice-border-color:var(--wpds-color-stroke-surface-error,#dab1aa);--wp-ui-notice-text-color:var(--wpds-color-foreground-content-error,#470000);--wp-ui-notice-decorative-icon-color:var(--wpds-color-foreground-content-error-weak,#cc1818)}@container (max-width: 320px){._4145abab73d17514__notice:has(.b5397fb9d05389e3__title) ._0a1270dcdd79c031__actions,._4145abab73d17514__notice:has(.b5397fb9d05389e3__title) ._1904b570a89bb815__description{grid-column:1/3}}}@layer compositions{.d329e7416d368d31__action-link{margin-block:auto}._487e6a5c1375f7dc__close-icon,._983740ab855c4e09__action-button:is(._8ddb8fb33fbf3d38__is-action-button-outline,._77bbde495a8a0af3__is-action-button-minimal){--wp-ui-button-background-color-active:color-mix(in srgb,transparent 50%,var(--wpds-color-background-interactive-neutral-weak-active,#ededed))}}}");var jb={notice:"_4145abab73d17514__notice",icon:"d0a25570cb528528__icon",title:"b5397fb9d05389e3__title",description:"_1904b570a89bb815__description",actions:"_0a1270dcdd79c031__actions","action-button":"_983740ab855c4e09__action-button","action-link":"d329e7416d368d31__action-link","close-icon":"_487e6a5c1375f7dc__close-icon","is-info":"_531c140826094795__is-info","is-warning":"ae2e1004697cce95__is-warning","is-success":"_2e614a76af494837__is-success","is-error":"af00331ae17a0065__is-error","is-action-button-outline":"_8ddb8fb33fbf3d38__is-action-button-outline","is-action-button-minimal":"_77bbde495a8a0af3__is-action-button-minimal"},Eu=(0,Ru.forwardRef)(function({className:t,render:o,...n},r){return(0,Cs.jsx)(Je,{ref:r,className:$(jb["action-link"],t),...n,variant:"body-md",render:(0,Cs.jsx)(en,{tone:"neutral",variant:"default",render:o})})});var Tu=h(de(),1),ku=h(Q(),1),Pu=(0,Tu.forwardRef)(({children:e,className:t,ariaLabel:o,as:n="div",...r},i)=>(0,ku.jsx)(n,{ref:i,className:$("admin-ui-navigable-region",t),"aria-label":o,role:"region",tabIndex:"-1",...r,children:e}));Pu.displayName="NavigableRegion";var Cu=Pu;var Ou=h(on(),1),{Fill:Nu,Slot:Lu}=(0,Ou.createSlotFill)("SidebarToggle");var $e=h(Q(),1),Os="data-wp-hash";function Ns(){let e=globalThis;return e.__wpStyleRuntime||(e.__wpStyleRuntime={documents:new Map,styles:new Map,injectedStyles:new WeakMap},typeof document<"u"&&Vb(document)),e.__wpStyleRuntime}function Fb(e,t){if(!e.head)return!1;for(let o of e.head.querySelectorAll(`style[${Os}]`))if(o.getAttribute(Os)===t)return!0;return!1}function Iu(e,t,o){if(!e.head)return;let n=Ns(),r=n.injectedStyles.get(e);if(r||(r=new Set,n.injectedStyles.set(e,r)),r.has(t))return;if(Fb(e,t)){r.add(t);return}let i=e.createElement("style");i.setAttribute(Os,t),i.appendChild(e.createTextNode(o)),e.head.appendChild(i),r.add(t)}function Vb(e){let t=Ns();t.documents.set(e,(t.documents.get(e)??0)+1);for(let[o,n]of t.styles)Iu(e,o,n);return()=>{let o=t.documents.get(e);if(o!==void 0){if(o<=1){t.documents.delete(e);return}t.documents.set(e,o-1)}}}function Wb(e,t){let o=Ns();o.styles.set(e,t);for(let n of o.documents.keys())Iu(n,e,t)}typeof process>"u",Wb("ddd9aab364","._956b6df0898efed0__page{text-wrap:pretty;background-color:var(--wpds-color-background-surface-neutral,#fcfcfc);color:var(--wpds-color-foreground-content-neutral,#1e1e1e);display:flex;flex-flow:column;height:100%;position:relative;z-index:1}._0625b55e82a0d93d__header{background:var(--wpds-color-background-surface-neutral-strong,#fff);border-block-end:var(--wpds-border-width-xs,1px) solid var(--wpds-color-stroke-surface-neutral-weak,#f0f0f0);inset-block-start:0;padding:var(--wpds-dimension-padding-lg,16px) var(--wpds-dimension-padding-2xl,24px);position:sticky;z-index:1}.a43c44d5ae28b2e8__header-content{min-height:var(--wpds-dimension-size-md,32px)}.b7cb5b9daf3a3b25__header-actions{flex-shrink:0}._8113be94e7caf73c__header-title{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}._9a776c7f70996f61__header-visual{display:grid;flex-shrink:0;grid-template-columns:1fr;grid-template-rows:1fr;height:var(--wpds-dimension-size-sm,24px);width:var(--wpds-dimension-size-sm,24px);>*{grid-column:1/-1;grid-row:1/-1;max-height:100%;max-width:100%}}.d5e0920cd15d35bc__sidebar-toggle-slot:empty{display:none}._60fea2f6bf5319cd__header-subtitle{color:var(--wpds-color-foreground-content-neutral-weak,#707070);padding-block-end:var(--wpds-dimension-padding-xs,4px)}.be5e57d029ec4036__content{display:flex;flex-direction:column;flex-grow:1;overflow:auto;&._128806d0b26e3a50__has-padding{padding:var(--wpds-dimension-padding-lg,16px) var(--wpds-dimension-padding-2xl,24px)}}");var to={page:"_956b6df0898efed0__page",header:"_0625b55e82a0d93d__header","header-content":"a43c44d5ae28b2e8__header-content","header-actions":"b7cb5b9daf3a3b25__header-actions","header-title":"_8113be94e7caf73c__header-title","header-visual":"_9a776c7f70996f61__header-visual","sidebar-toggle-slot":"d5e0920cd15d35bc__sidebar-toggle-slot","header-subtitle":"_60fea2f6bf5319cd__header-subtitle",content:"be5e57d029ec4036__content","has-padding":"_128806d0b26e3a50__has-padding"};function Mu({headingLevel:e=1,breadcrumbs:t,badges:o,visual:n,title:r,subTitle:i,actions:s,showSidebarToggle:a=!0}){let d=`h${e}`;return(0,$e.jsxs)(Po,{direction:"column",className:to.header,children:[(0,$e.jsxs)(Po,{className:to["header-content"],direction:"row",gap:"sm",justify:"space-between",children:[(0,$e.jsxs)(Po,{direction:"row",gap:"sm",align:"center",justify:"start",children:[a&&(0,$e.jsx)(Lu,{bubblesVirtually:!0,className:to["sidebar-toggle-slot"]}),n&&(0,$e.jsx)("div",{className:to["header-visual"],"aria-hidden":"true",children:n}),r&&(0,$e.jsx)(Je,{className:to["header-title"],render:(0,$e.jsx)(d,{}),variant:"heading-lg",children:r}),t,o]}),s&&(0,$e.jsx)(Po,{align:"center",className:to["header-actions"],direction:"row",gap:"sm",children:s})]}),i&&(0,$e.jsx)(Je,{render:(0,$e.jsx)("p",{}),variant:"body-md",className:to["header-subtitle"],children:i})]})}var nn=h(Q(),1),Is="data-wp-hash";function Ms(){let e=globalThis;return e.__wpStyleRuntime||(e.__wpStyleRuntime={documents:new Map,styles:new Map,injectedStyles:new WeakMap},typeof document<"u"&&Ub(document)),e.__wpStyleRuntime}function Yb(e,t){if(!e.head)return!1;for(let o of e.head.querySelectorAll(`style[${Is}]`))if(o.getAttribute(Is)===t)return!0;return!1}function Bu(e,t,o){if(!e.head)return;let n=Ms(),r=n.injectedStyles.get(e);if(r||(r=new Set,n.injectedStyles.set(e,r)),r.has(t))return;if(Yb(e,t)){r.add(t);return}let i=e.createElement("style");i.setAttribute(Is,t),i.appendChild(e.createTextNode(o)),e.head.appendChild(i),r.add(t)}function Ub(e){let t=Ms();t.documents.set(e,(t.documents.get(e)??0)+1);for(let[o,n]of t.styles)Bu(e,o,n);return()=>{let o=t.documents.get(e);if(o!==void 0){if(o<=1){t.documents.delete(e);return}t.documents.set(e,o-1)}}}function Gb(e,t){let o=Ms();o.styles.set(e,t);for(let n of o.documents.keys())Bu(n,e,t)}typeof process>"u",Gb("ddd9aab364","._956b6df0898efed0__page{text-wrap:pretty;background-color:var(--wpds-color-background-surface-neutral,#fcfcfc);color:var(--wpds-color-foreground-content-neutral,#1e1e1e);display:flex;flex-flow:column;height:100%;position:relative;z-index:1}._0625b55e82a0d93d__header{background:var(--wpds-color-background-surface-neutral-strong,#fff);border-block-end:var(--wpds-border-width-xs,1px) solid var(--wpds-color-stroke-surface-neutral-weak,#f0f0f0);inset-block-start:0;padding:var(--wpds-dimension-padding-lg,16px) var(--wpds-dimension-padding-2xl,24px);position:sticky;z-index:1}.a43c44d5ae28b2e8__header-content{min-height:var(--wpds-dimension-size-md,32px)}.b7cb5b9daf3a3b25__header-actions{flex-shrink:0}._8113be94e7caf73c__header-title{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}._9a776c7f70996f61__header-visual{display:grid;flex-shrink:0;grid-template-columns:1fr;grid-template-rows:1fr;height:var(--wpds-dimension-size-sm,24px);width:var(--wpds-dimension-size-sm,24px);>*{grid-column:1/-1;grid-row:1/-1;max-height:100%;max-width:100%}}.d5e0920cd15d35bc__sidebar-toggle-slot:empty{display:none}._60fea2f6bf5319cd__header-subtitle{color:var(--wpds-color-foreground-content-neutral-weak,#707070);padding-block-end:var(--wpds-dimension-padding-xs,4px)}.be5e57d029ec4036__content{display:flex;flex-direction:column;flex-grow:1;overflow:auto;&._128806d0b26e3a50__has-padding{padding:var(--wpds-dimension-padding-lg,16px) var(--wpds-dimension-padding-2xl,24px)}}");var Ls={page:"_956b6df0898efed0__page",header:"_0625b55e82a0d93d__header","header-content":"a43c44d5ae28b2e8__header-content","header-actions":"b7cb5b9daf3a3b25__header-actions","header-title":"_8113be94e7caf73c__header-title","header-visual":"_9a776c7f70996f61__header-visual","sidebar-toggle-slot":"d5e0920cd15d35bc__sidebar-toggle-slot","header-subtitle":"_60fea2f6bf5319cd__header-subtitle",content:"be5e57d029ec4036__content","has-padding":"_128806d0b26e3a50__has-padding"};function Hu({headingLevel:e,breadcrumbs:t,badges:o,visual:n,title:r,subTitle:i,children:s,className:a,actions:d,ariaLabel:c,hasPadding:l=!1,showSidebarToggle:f=!0}){let p=$(Ls.page,a);return(0,nn.jsxs)(Cu,{className:p,ariaLabel:c??(typeof r=="string"?r:""),children:[(r||t||o||d||n)&&(0,nn.jsx)(Mu,{headingLevel:e,breadcrumbs:t,badges:o,visual:n,title:r,subTitle:i,actions:d,showSidebarToggle:f}),l?(0,nn.jsx)("div",{className:$(Ls.content,Ls["has-padding"]),children:s}):s]})}Hu.SidebarToggleFill=Nu;var Bs=Hu;var dt=h(on()),lf=h(rn()),df=h(de()),Tt=h(Ot()),uf=h(mr());import{privateApis as l0}from"@wordpress/connectors";var ju=h(Qi()),{lock:l3,unlock:No}=(0,ju.__dangerousOptInToUnstableAPIsOnlyForCoreModules)("I acknowledge private features are not for use in themes or plugins and doing so will break in the next version of WordPress.","@wordpress/routes");if(typeof document<"u"&&!document.head.querySelector("style[data-wp-hash='09e9b056ea']")){let e=document.createElement("style");e.setAttribute("data-wp-hash","09e9b056ea"),e.appendChild(document.createTextNode(".connectors-page{box-sizing:border-box;margin:0 auto;max-width:680px;padding:24px;width:100%}.connectors-page .components-item{background:#fff;border:1px solid #ddd;border-radius:8px;overflow:hidden;padding:20px;scroll-margin-top:120px}.connectors-page .connector-settings__error{color:#cc1818}.connectors-page .connector-settings .components-text-control__input{font-family:monospace;scroll-margin-top:120px}.connectors-page__file-mods-notice{margin-bottom:16px}.connectors-page--empty{align-items:center;display:flex;flex-direction:column;flex-grow:1;gap:32px;justify-content:center;text-align:center}.connectors-page .ai-plugin-callout{background-color:#e7d4e4;background-image:radial-gradient(ellipse 70% 120% at 18% 115%,rgba(202,158,198,.75) 0,rgba(202,158,198,0) 60%),radial-gradient(ellipse 55% 110% at 92% -15%,rgba(208,175,217,.7) 0,rgba(208,175,217,0) 65%),radial-gradient(ellipse 40% 85% at 58% -10%,rgba(170,130,184,.45) 0,rgba(170,130,184,0) 70%);border-radius:8px;overflow:hidden;padding:24px;padding-inline-end:150px;position:relative}[dir=rtl] .connectors-page .ai-plugin-callout{background-image:radial-gradient(ellipse 70% 120% at 82% 115%,rgba(202,158,198,.75) 0,rgba(202,158,198,0) 60%),radial-gradient(ellipse 55% 110% at 8% -15%,rgba(208,175,217,.7) 0,rgba(208,175,217,0) 65%),radial-gradient(ellipse 40% 85% at 42% -10%,rgba(170,130,184,.45) 0,rgba(170,130,184,0) 70%)}.connectors-page .ai-plugin-callout__content{align-items:flex-start;display:flex;flex-direction:column;gap:12px;padding-top:2px}.connectors-page .ai-plugin-callout__content p{font-size:13px;line-height:20px;margin:0}.connectors-page .ai-plugin-callout__decoration{height:110px;inset-inline-end:16px;position:absolute;top:12px;width:110px}.connectors-page>p{color:#949494}@media (max-width:680px){.connectors-page .ai-plugin-callout{padding:12px;padding-inline-end:100px}.connectors-page .ai-plugin-callout__decoration{height:75px;inset-inline-end:8px;top:8px;width:75px}}@media (max-width:480px){.connectors-page{padding:8px}.connectors-page .ai-plugin-callout{padding-inline-end:130px}.connectors-page .components-item{padding:12px}.connectors-page .components-item>.components-v-stack>.components-h-stack:first-child svg{height:32px;width:32px}.connectors-page .components-item>.components-v-stack>.components-h-stack:first-child>.components-h-stack:last-child{align-items:flex-end;flex-direction:column}}")),document.head.appendChild(e)}var cn=h(on()),Ws=h(mr()),ln=h(rn()),wt=h(de()),Xe=h(Ot()),rf=h(Hs()),sf=h(Wu());var gr=h(on()),js=h(de()),Qu=h(rn()),oo=h(Ot());import{__experimentalRegisterConnector as Xb,__experimentalConnectorItem as Zu,__experimentalDefaultConnectorSettings as Kb,__experimentalApplicationPasswordConnectorSettings as qb,privateApis as Zb}from"@wordpress/connectors";var zs=h(mr()),an=h(rn()),sn=h(de()),fe=h(Ot()),Yu=h(Hs());function Ds({file:e,settingName:t,connectorName:o,isInstalled:n,isActivated:r,keySource:i="none",initialIsConnected:s=!1}){let[a,d]=(0,sn.useState)(!1),[c,l]=(0,sn.useState)(!1),[f,p]=(0,sn.useState)(s),[m,u]=(0,sn.useState)(null),g=e?.replace(/\.php$/,""),v=g?.includes("/")?g.split("/")[0]:g,{derivedPluginStatus:_,canManagePlugins:w,currentApiKey:y,currentUsername:b,hasStoredCredentials:S,hasResolvedSettings:x,canInstallPlugins:E}=(0,an.useSelect)(K=>{let J=K(zs.store),me=J.getEntityRecord("root","site")?.[t],le=typeof me=="string"?me:"",X=typeof me=="object"&&me!==null?me:void 0,pe=X!==void 0?!!X.username&&!!X.password:!!le,ue=J.hasFinishedResolution("getEntityRecord",["root","site"]),vt=!!J.canUser("create",{kind:"root",name:"plugin"}),Te={currentApiKey:le,currentUsername:X?.username??"",hasStoredCredentials:pe,hasResolvedSettings:ue,canInstallPlugins:vt};if(!e)return{...Te,derivedPluginStatus:ue?"active":"checking",canManagePlugins:void 0};let Ve=J.getEntityRecord("root","plugin",g);if(!J.hasFinishedResolution("getEntityRecord",["root","plugin",g]))return{...Te,derivedPluginStatus:"checking",canManagePlugins:void 0};if(Ve){let no=Ve.status==="active"||Ve.status==="network-active";return{...Te,derivedPluginStatus:no?"active":"inactive",canManagePlugins:!0}}let He="not-installed";return r?He="active":n&&(He="inactive"),{...Te,derivedPluginStatus:He,canManagePlugins:!1}},[e,g,t,n,r]),T=m??_,k=w,C=T==="active"&&f||m==="active"&&S,{saveEntityRecord:j,invalidateResolution:A}=(0,an.useDispatch)(zs.store),{createSuccessNotice:L,createErrorNotice:I}=(0,an.useDispatch)(Yu.store),R=K=>j("root","site",{[t]:K},{throwOnError:!0}),N=()=>{L((0,fe.sprintf)((0,fe.__)("%s connected successfully."),o),{id:"connector-connect-success",type:"snackbar"})},H=()=>{L((0,fe.sprintf)((0,fe.__)("%s disconnected."),o),{id:"connector-disconnect-success",type:"snackbar"})},P=()=>{I((0,fe.sprintf)((0,fe.__)("Failed to disconnect %s."),o),{id:"connector-disconnect-error",type:"snackbar"})},O=async()=>{if(v){l(!0);try{await j("root","plugin",{slug:v,status:"active"},{throwOnError:!0}),u("active"),A("getEntityRecord",["root","site"]),d(!0),L((0,fe.sprintf)((0,fe.__)("Plugin for %s installed and activated successfully."),o),{id:"connector-plugin-install-success",type:"snackbar"})}catch{I((0,fe.sprintf)((0,fe.__)("Failed to install plugin for %s."),o),{id:"connector-plugin-install-error",type:"snackbar"})}finally{l(!1)}}},M=async()=>{if(e){l(!0);try{await j("root","plugin",{plugin:g,status:"active"},{throwOnError:!0}),u("active"),A("getEntityRecord",["root","site"]),d(!0),L((0,fe.sprintf)((0,fe.__)("Plugin for %s activated successfully."),o),{id:"connector-plugin-activate-success",type:"snackbar"})}catch{I((0,fe.sprintf)((0,fe.__)("Failed to activate plugin for %s."),o),{id:"connector-plugin-activate-error",type:"snackbar"})}finally{l(!1)}}};return{pluginStatus:T,canInstallPlugins:E,canActivatePlugins:k,isExpanded:a,setIsExpanded:d,isBusy:c,isConnected:C,currentApiKey:y,currentUsername:b,hasResolvedSettings:x,keySource:i,handleButtonClick:()=>{if(T==="not-installed"){if(E===!1)return;O()}else if(T==="inactive"){if(k===!1)return;M()}else d(!a)},getButtonLabel:()=>{if(c)return T==="not-installed"?(0,fe.__)("Installing\u2026"):(0,fe.__)("Activating\u2026");if(a)return(0,fe.__)("Cancel");if(C)return(0,fe.__)("Edit");switch(T){case"checking":return(0,fe.__)("Checking\u2026");case"not-installed":return(0,fe.__)("Install");case"inactive":return(0,fe.__)("Activate");case"active":return(0,fe.__)("Set up")}},saveApiKey:async K=>{let J=y;try{let le=(await R(K))?.[t];if(K&&(le===J||!le))throw new Error("It was not possible to connect to the provider using this key.");p(!0),N()}catch(ne){throw console.error("Failed to save API key:",ne),ne}},removeApiKey:async()=>{try{await R(""),p(!1),H()}catch(K){console.error("Failed to remove API key:",K),P()}},saveCredentials:async({username:K,applicationPassword:J})=>{try{let le=(await R({username:K,password:J}))?.[t];if(!le?.username||!le?.password)throw new Error((0,fe.__)("It was not possible to save these credentials."));p(!0),N()}catch(ne){throw console.error("Failed to save credentials:",ne),ne}},removeCredentials:async()=>{try{await R({username:"",password:""}),p(!1),H()}catch(K){console.error("Failed to remove credentials:",K),P()}}}}var Uu=()=>React.createElement("svg",{width:"40",height:"40",viewBox:"0 0 24 24",fill:"none",xmlns:"http://www.w3.org/2000/svg","aria-hidden":"true"},React.createElement("path",{d:"M22.2819 9.8211a5.9847 5.9847 0 0 0-.5157-4.9108 6.0462 6.0462 0 0 0-6.5098-2.9A6.0651 6.0651 0 0 0 4.9807 4.1818a5.9847 5.9847 0 0 0-3.9977 2.9 6.0462 6.0462 0 0 0 .7427 7.0966 5.98 5.98 0 0 0 .511 4.9107 6.051 6.051 0 0 0 6.5146 2.9001A5.9847 5.9847 0 0 0 13.2599 24a6.0557 6.0557 0 0 0 5.7718-4.2058 5.9894 5.9894 0 0 0 3.9977-2.9001 6.0557 6.0557 0 0 0-.7475-7.0729zm-9.022 12.6081a4.4755 4.4755 0 0 1-2.8764-1.0408l.1419-.0804 4.7783-2.7582a.7948.7948 0 0 0 .3927-.6813v-6.7369l2.02 1.1686a.071.071 0 0 1 .038.052v5.5826a4.504 4.504 0 0 1-4.4945 4.4944zm-9.6607-4.1254a4.4708 4.4708 0 0 1-.5346-3.0137l.142.0852 4.783 2.7582a.7712.7712 0 0 0 .7806 0l5.8428-3.3685v2.3324a.0804.0804 0 0 1-.0332.0615L9.74 19.9502a4.4992 4.4992 0 0 1-6.1408-1.6464zM2.3408 7.8956a4.485 4.485 0 0 1 2.3655-1.9728V11.6a.7664.7664 0 0 0 .3879.6765l5.8144 3.3543-2.0201 1.1685a.0757.0757 0 0 1-.071 0l-4.8303-2.7865A4.504 4.504 0 0 1 2.3408 7.872zm16.5963 3.8558L13.1038 8.364l2.0201-1.1685a.0757.0757 0 0 1 .071 0l4.8303 2.7913a4.4944 4.4944 0 0 1-.6765 8.1042v-5.6772a.79.79 0 0 0-.4043-.6813zm2.0107-3.0231l-.142-.0852-4.7735-2.7818a.7759.7759 0 0 0-.7854 0L9.409 9.2297V6.8974a.0662.0662 0 0 1 .0284-.0615l4.8303-2.7866a4.4992 4.4992 0 0 1 6.6802 4.66zM8.3065 12.863l-2.02-1.1638a.0804.0804 0 0 1-.038-.0567V6.0742a4.4992 4.4992 0 0 1 7.3757-3.4537l-.142.0805L8.704 5.459a.7948.7948 0 0 0-.3927.6813zm1.0976-2.3654l2.602-1.4998 2.6069 1.4998v2.9994l-2.5974 1.4997-2.6067-1.4997Z",fill:"currentColor"})),Gu=()=>React.createElement("svg",{width:"40",height:"40",viewBox:"0 0 32 32",fill:"none",xmlns:"http://www.w3.org/2000/svg","aria-hidden":"true"},React.createElement("path",{d:"M6.2 21.024L12.416 17.536L12.52 17.232L12.416 17.064H12.112L11.072 17L7.52 16.904L4.44 16.776L1.456 16.616L0.704 16.456L0 15.528L0.072 15.064L0.704 14.64L1.608 14.72L3.608 14.856L6.608 15.064L8.784 15.192L12.008 15.528H12.52L12.592 15.32L12.416 15.192L12.28 15.064L9.176 12.96L5.816 10.736L4.056 9.456L3.104 8.808L2.624 8.2L2.416 6.872L3.28 5.92L4.44 6L4.736 6.08L5.912 6.984L8.424 8.928L11.704 11.344L12.184 11.744L12.376 11.608L12.4 11.512L12.184 11.152L10.4 7.928L8.496 4.648L7.648 3.288L7.424 2.472C7.344 2.136 7.288 1.856 7.288 1.512L8.272 0.176L8.816 0L10.128 0.176L10.68 0.656L11.496 2.52L12.816 5.456L14.864 9.448L15.464 10.632L15.784 11.728L15.904 12.064H16.112V11.872L16.28 9.624L16.592 6.864L16.896 3.312L17 2.312L17.496 1.112L18.48 0.464L19.248 0.832L19.88 1.736L19.792 2.32L19.416 4.76L18.68 8.584L18.2 11.144H18.48L18.8 10.824L20.096 9.104L22.272 6.384L23.232 5.304L24.352 4.112L25.072 3.544H26.432L27.432 5.032L26.984 6.568L25.584 8.344L24.424 9.848L22.76 12.088L21.72 13.88L21.816 14.024L22.064 14L25.824 13.2L27.856 12.832L30.28 12.416L31.376 12.928L31.496 13.448L31.064 14.512L28.472 15.152L25.432 15.76L20.904 16.832L20.848 16.872L20.912 16.952L22.952 17.144L23.824 17.192H25.96L29.936 17.488L30.976 18.176L31.6 19.016L31.496 19.656L29.896 20.472L27.736 19.96L22.696 18.76L20.968 18.328H20.728V18.472L22.168 19.88L24.808 22.264L28.112 25.336L28.28 26.096L27.856 26.696L27.408 26.632L24.504 24.448L23.384 23.464L20.848 21.328H20.68V21.552L21.264 22.408L24.352 27.048L24.512 28.472L24.288 28.936L23.488 29.216L22.608 29.056L20.8 26.52L18.936 23.664L17.432 21.104L17.248 21.208L16.36 30.768L15.944 31.256L14.984 31.624L14.184 31.016L13.76 30.032L14.184 28.088L14.696 25.552L15.112 23.536L15.488 21.032L15.712 20.2L15.696 20.144L15.512 20.168L13.624 22.76L10.752 26.64L8.48 29.072L7.936 29.288L6.992 28.8L7.08 27.928L7.608 27.152L10.752 23.152L12.648 20.672L13.872 19.24L13.864 19.032H13.792L5.44 24.456L3.952 24.648L3.312 24.048L3.392 23.064L3.696 22.744L6.208 21.016L6.2 21.024Z",fill:"#D97757"})),Xu=()=>React.createElement("svg",{width:"40",height:"40",viewBox:"0 0 32 32",fill:"none",xmlns:"http://www.w3.org/2000/svg","aria-hidden":"true"},React.createElement("path",{d:"M0 4C0 1.79086 1.79086 0 4 0H28C30.2091 0 32 1.79086 32 4V28C32 30.2091 30.2091 32 28 32H4C1.79086 32 0 30.2091 0 28V4Z",fill:"#F0F0F0"}),React.createElement("path",{d:"M14.5 8V12H17.5V8H19V12H20.5C20.7652 12 21.0196 12.1054 21.2071 12.2929C21.3946 12.4804 21.5 12.7348 21.5 13V17L18.5 21V23C18.5 23.2652 18.3946 23.5196 18.2071 23.7071C18.0196 23.8946 17.7652 24 17.5 24H14.5C14.2348 24 13.9804 23.8946 13.7929 23.7071C13.6054 23.5196 13.5 23.2652 13.5 23V21L10.5 17V13C10.5 12.7348 10.6054 12.4804 10.7929 12.2929C10.9804 12.1054 11.2348 12 11.5 12H13V8H14.5ZM15 20.5V22.5H17V20.5L20 16.5V13.5H12V16.5L15 20.5Z",fill:"#949494"})),Ku=()=>React.createElement("svg",{width:"40",height:"40",viewBox:"0 0 44 44",fill:"none",xmlns:"http://www.w3.org/2000/svg","aria-hidden":"true"},React.createElement("rect",{width:"44",height:"44",fill:"#357B49",rx:"6"}),React.createElement("path",{fill:"#fff",fillRule:"evenodd",d:"m29.746 28.31-6.392-16.797c-.152-.397-.305-.672-.789-.675-.673 0-1.408.611-1.746 1.316l-7.378 16.154c-.072.16-.143.311-.214.454-.5.995-1.045 1.546-2.357 1.626a.399.399 0 0 0-.16.033l-.01.004a.399.399 0 0 0-.23.392v.01c0 .054.01.106.03.155l.004.01a.416.416 0 0 0 .394.252h6.212a.417.417 0 0 0 .307-.12.416.416 0 0 0 .124-.305.398.398 0 0 0-.105-.302.399.399 0 0 0-.294-.127c-.757 0-2.197-.062-2.197-1.164.02-.318.103-.63.245-.916l1.399-3.152c.52-1.163 1.654-1.163 2.572-1.163h5.843c.023 0 .044 0 .062.003.13.014.16.081.214.242l1.534 4.07a2.857 2.857 0 0 1 .216 1.04c0 .054-.003.104-.01.153-.09.726-.831.887-1.49.887a.4.4 0 0 0-.294.127l-.007.008-.007.008a.401.401 0 0 0-.092.286v.01c0 .054.01.106.03.155l.005.01a.42.42 0 0 0 .395.252h7.011a.413.413 0 0 0 .279-.13.412.412 0 0 0 .11-.297.387.387 0 0 0-.09-.294.388.388 0 0 0-.277-.135c-1.448-.122-2.295-.643-2.847-2.08Zm-11.985-5.844 2.847-6.304c.361-.728.659-1.486.889-2.265 0-.06.03-.092.06-.092s.061.032.061.091c.02.122.045.247.073.374.197.888.584 1.878.914 2.723l.176.453 1.684 4.529a.927.927 0 0 1 .092.4.473.473 0 0 1-.009.094c-.041.202-.228.272-.602.272h-6.063c-.122 0-.184-.03-.184-.092a.36.36 0 0 1 .062-.183Zm17.107-.721c0 .786-.446 1.231-1.25 1.231-.806 0-1.125-.409-1.125-1.034 0-.786.465-1.231 1.25-1.231.785 0 1.125.427 1.125 1.034ZM9.629 23.002c.803 0 1.25-.447 1.25-1.231 0-.607-.343-1.036-1.128-1.036-.785 0-1.25.447-1.25 1.231 0 .625.325 1.036 1.128 1.036Z",clipRule:"evenodd"})),qu=()=>React.createElement("svg",{width:"40",height:"40",style:{flex:"none",lineHeight:1},viewBox:"0 0 24 24",xmlns:"http://www.w3.org/2000/svg","aria-hidden":"true"},React.createElement("path",{d:"M20.616 10.835a14.147 14.147 0 01-4.45-3.001 14.111 14.111 0 01-3.678-6.452.503.503 0 00-.975 0 14.134 14.134 0 01-3.679 6.452 14.155 14.155 0 01-4.45 3.001c-.65.28-1.318.505-2.002.678a.502.502 0 000 .975c.684.172 1.35.397 2.002.677a14.147 14.147 0 014.45 3.001 14.112 14.112 0 013.679 6.453.502.502 0 00.975 0c.172-.685.397-1.351.677-2.003a14.145 14.145 0 013.001-4.45 14.113 14.113 0 016.453-3.678.503.503 0 000-.975 13.245 13.245 0 01-2.003-.678z",fill:"#3186FF"}),React.createElement("path",{d:"M20.616 10.835a14.147 14.147 0 01-4.45-3.001 14.111 14.111 0 01-3.678-6.452.503.503 0 00-.975 0 14.134 14.134 0 01-3.679 6.452 14.155 14.155 0 01-4.45 3.001c-.65.28-1.318.505-2.002.678a.502.502 0 000 .975c.684.172 1.35.397 2.002.677a14.147 14.147 0 014.45 3.001 14.112 14.112 0 013.679 6.453.502.502 0 00.975 0c.172-.685.397-1.351.677-2.003a14.145 14.145 0 013.001-4.45 14.113 14.113 0 016.453-3.678.503.503 0 000-.975 13.245 13.245 0 01-2.003-.678z",fill:"url(#lobe-icons-gemini-fill-0)"}),React.createElement("path",{d:"M20.616 10.835a14.147 14.147 0 01-4.45-3.001 14.111 14.111 0 01-3.678-6.452.503.503 0 00-.975 0 14.134 14.134 0 01-3.679 6.452 14.155 14.155 0 01-4.45 3.001c-.65.28-1.318.505-2.002.678a.502.502 0 000 .975c.684.172 1.35.397 2.002.677a14.147 14.147 0 014.45 3.001 14.112 14.112 0 013.679 6.453.502.502 0 00.975 0c.172-.685.397-1.351.677-2.003a14.145 14.145 0 013.001-4.45 14.113 14.113 0 016.453-3.678.503.503 0 000-.975 13.245 13.245 0 01-2.003-.678z",fill:"url(#lobe-icons-gemini-fill-1)"}),React.createElement("path",{d:"M20.616 10.835a14.147 14.147 0 01-4.45-3.001 14.111 14.111 0 01-3.678-6.452.503.503 0 00-.975 0 14.134 14.134 0 01-3.679 6.452 14.155 14.155 0 01-4.45 3.001c-.65.28-1.318.505-2.002.678a.502.502 0 000 .975c.684.172 1.35.397 2.002.677a14.147 14.147 0 014.45 3.001 14.112 14.112 0 013.679 6.453.502.502 0 00.975 0c.172-.685.397-1.351.677-2.003a14.145 14.145 0 013.001-4.45 14.113 14.113 0 016.453-3.678.503.503 0 000-.975 13.245 13.245 0 01-2.003-.678z",fill:"url(#lobe-icons-gemini-fill-2)"}),React.createElement("defs",null,React.createElement("linearGradient",{gradientUnits:"userSpaceOnUse",id:"lobe-icons-gemini-fill-0",x1:"7",x2:"11",y1:"15.5",y2:"12"},React.createElement("stop",{stopColor:"#08B962"}),React.createElement("stop",{offset:"1",stopColor:"#08B962",stopOpacity:"0"})),React.createElement("linearGradient",{gradientUnits:"userSpaceOnUse",id:"lobe-icons-gemini-fill-1",x1:"8",x2:"11.5",y1:"5.5",y2:"11"},React.createElement("stop",{stopColor:"#F94543"}),React.createElement("stop",{offset:"1",stopColor:"#F94543",stopOpacity:"0"})),React.createElement("linearGradient",{gradientUnits:"userSpaceOnUse",id:"lobe-icons-gemini-fill-2",x1:"3.5",x2:"17.5",y1:"13.5",y2:"12"},React.createElement("stop",{stopColor:"#FABC12"}),React.createElement("stop",{offset:".46",stopColor:"#FABC12",stopOpacity:"0"}))));var{store:Qb}=No(Zb);function Ju(){try{return JSON.parse(document.getElementById("wp-script-module-data-options-connectors-wp-admin")?.textContent??"{}")}catch{return{}}}function Fs(){return Ju().connectors??{}}function $u(){return!!Ju().isFileModDisabled}var Jb={google:qu,openai:Uu,anthropic:Gu,akismet:Ku};function $b(e,t){if(t)return React.createElement("img",{src:t,alt:"",width:40,height:40});let o=Jb[e];return React.createElement(o||Xu,null)}var e0=()=>React.createElement("span",{style:{color:"#345b37",backgroundColor:"#eff8f0",padding:"4px 12px",borderRadius:"2px",fontSize:"13px",fontWeight:"var(--wpds-typography-font-weight-emphasis)",whiteSpace:"nowrap"}},(0,oo.__)("Connected")),t0=({slug:e})=>React.createElement(en,{href:(0,oo.sprintf)((0,oo.__)("https://wordpress.org/plugins/%s/"),e),openInNewTab:!0},(0,oo.__)("Learn more")),o0=()=>React.createElement(Ii,null,(0,oo.__)("Not available"));function ef({isConnected:e,showUnavailableBadge:t,pluginSlug:o,isExpanded:n,isBusy:r,pluginStatus:i,actionButtonRef:s,handleButtonClick:a,getButtonLabel:d}){return React.createElement(gr.__experimentalHStack,{spacing:3,expanded:!1},e&&React.createElement(e0,null),t&&(o?React.createElement(t0,{slug:o}):React.createElement(o0,null)),!t&&React.createElement(gr.Button,{ref:s,variant:n||e?"tertiary":"secondary",size:"compact",onClick:a,disabled:i==="checking"||r,isBusy:r,accessibleWhenDisabled:!0},d()))}function tf(e){let t=e?.replace(/\.php$/,"");return t?.includes("/")?t.split("/")[0]:t}function n0({name:e,description:t,logo:o,authentication:n,plugin:r}){let i=n?.method==="api_key"?n:void 0,s=i?.settingName??"",a=i?.credentialsUrl??void 0,d=tf(r?.file),{pluginStatus:c,canInstallPlugins:l,canActivatePlugins:f,isExpanded:p,setIsExpanded:m,isBusy:u,isConnected:g,currentApiKey:v,hasResolvedSettings:_,keySource:w,handleButtonClick:y,getButtonLabel:b,saveApiKey:S,removeApiKey:x}=Ds({file:r?.file,settingName:s,connectorName:e,isInstalled:r?.isInstalled,isActivated:r?.isActivated,keySource:i?.keySource,initialIsConnected:i?.isConnected}),E=w==="env"||w==="constant",T=c==="not-installed"&&l===!1||c==="inactive"&&f===!1,k=(0,js.useRef)(null);return React.createElement(Zu,{className:d?`connector-item--${d}`:void 0,logo:o,name:e,description:t,actionArea:React.createElement(ef,{isConnected:g,showUnavailableBadge:T,pluginSlug:d,isExpanded:p,isBusy:u,pluginStatus:c,actionButtonRef:k,handleButtonClick:y,getButtonLabel:b})},p&&c==="active"&&_&&React.createElement(Kb,{key:g?"connected":"setup",initialValue:E?"\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022":v,helpUrl:a,readOnly:g||E,keySource:w,onRemove:E?void 0:async()=>{await x(),k.current?.focus()},onSave:async C=>{await S(C),m(!1),k.current?.focus()}}))}function r0({name:e,description:t,logo:o,authentication:n,plugin:r}){let i=n?.method==="application_password"?n:void 0,s=i?.settingName??"",a=i?.credentialsUrl??void 0,d=tf(r?.file),{pluginStatus:c,canInstallPlugins:l,canActivatePlugins:f,isExpanded:p,setIsExpanded:m,isBusy:u,isConnected:g,currentUsername:v,hasResolvedSettings:_,keySource:w,handleButtonClick:y,getButtonLabel:b,saveCredentials:S,removeCredentials:x}=Ds({file:r?.file,settingName:s,connectorName:e,isInstalled:r?.isInstalled,isActivated:r?.isActivated,keySource:i?.keySource,initialIsConnected:i?.isConnected}),E=w==="env"||w==="constant",T=(0,js.useRef)(null),k=c==="not-installed"&&l===!1||c==="inactive"&&f===!1;return React.createElement(Zu,{className:d?`connector-item--${d}`:void 0,logo:o,name:e,description:t,actionArea:React.createElement(ef,{isConnected:g,showUnavailableBadge:k,pluginSlug:d,isExpanded:p,isBusy:u,pluginStatus:c,actionButtonRef:T,handleButtonClick:y,getButtonLabel:b})},p&&c==="active"&&_&&React.createElement(qb,{key:g?"connected":"setup",initialUsername:E?"\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022":v,helpUrl:a,readOnly:g||E,keySource:w,onRemove:E?void 0:async()=>{await x(),T.current?.focus()},onSave:async C=>{await S(C),m(!1),T.current?.focus()}}))}function of(){let e=Fs(),t=o=>o.replace(/[^a-z0-9-_]/gi,"-");for(let[o,n]of Object.entries(e)){if(o==="akismet"&&!n.plugin?.isInstalled)continue;let{authentication:r}=n,i=t(o),s={name:n.name,description:n.description,type:n.type,logo:$b(o,n.logoUrl),authentication:r,plugin:n.plugin},a=No((0,Qu.select)(Qb)).getConnector(i);r.method==="api_key"&&!a?.render?s.render=n0:r.method==="application_password"&&!a?.render&&(s.render=r0),Xb(i,s)}}function nf(){return React.createElement("div",{className:"ai-plugin-callout__decoration","aria-hidden":"true"},React.createElement("svg",{viewBox:"0 0 248 248",xmlns:"http://www.w3.org/2000/svg",xmlnsXlink:"http://www.w3.org/1999/xlink",focusable:"false",style:{width:"100%",height:"100%"}},React.createElement("image",{href:"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAASwAAAEsCAYAAAB5fY51AAAQAElEQVR4AezdC3ojWW5tYflOzPbIbI/M9sh8+WdrdZ+KpiiKL5FB5KedwN7AeSFIpHRYmfX/PubXVGAqMBV4kQpMw3qRBzXbnApMBT4+pmHNq2AqMBV4mQpMw3qZR3X9RmeGqcCrV2Aa1qs/wdn/VOCNKjAN640e9hx1KvDqFZiG9epPcPY/FThWgZ1q07B2+mDnWFOBPVZgGtYen+qcaSqw0wpMw9rpg51jTQX2WIFpWMee6mhTganAU1ZgGtZTPpbZ1FRgKnCsAtOwjlVltKnAVOApKzAN6ykfy2zqcRWYlV6pAtOwXulpzV6nAm9egWlYb/4CmONPBV6pAtOwXulpve9e//Nw9P/7xL8d7Hy9aQWubFhvWrU59qMr8D+HBcPBna93rcA0rHd98q91bs3q3w9bBv7Bna93rMA0rHd86nPmqcCLVmAa1os+uF/Y9m8u6Q7rvw8bgLnDOhTiXb+mYb3rk3+tc//rYbsaVTjQP18amct4+h9hftt3BaZh7fv57v107rNg7+ec831WYBrWZyHGPHUF/vewu//6xNqg+HMRfyjMrb+edb5pWM/6ZGZfawX86Bc0qTU2/htVYBrWGz3sOepU4NUrMA3r1Z/g7H8q8EYVmIZ1h4c9U04FpgL3qcA0rPvUdWadCkwF7lCBaVh3KOpMORWYCtynAtOw7lPXmfVdKjDnfGgFpmE9tNyz2FRgKnBNBaZhXVO9GTsVmAo8tALTsB5a7llsKjAVuKYCv9uwrtn5jJ0KTAXergLTsN7ukc+BpwKvW4FpWK/77GbnU4G3q8A0rLd75L914Fl3KnB9BaZhXV/DmWEqMBV4UAWmYT2o0LPMVGAqcH0FpmFdX8OZYSowFfhrBe7GpmHdrbQz8VRgKnDrCkzDunVFZ76pwFTgbhWYhnW30s7EU4GpwK0rMA3r1hW9fr6ZYSowFfiiAtOwvijMyFOBqcDzVWAa1vM9k9nRVGAq8EUFpmF9UZiRpwKPqMCs8bMKTMP6Wb0meyowFfjFCkzD+sXiz9JTganAzyowDetn9ZrsqcBU4Bcr8NIN6xfrNktPBaYCv1CBaVi/UPRZciowFbisAtOwLqvbjJoKTAV+oQLTsH6h6LPkBRWYIVOBQwWmYR2KMF9TganAa1RgGtZrPKfZ5VRgKnCowDSsQxHmayowFXimCny9l2lYX9dmIlOBqcCTVWAa1pM9kNnOVGAq8HUFpmF9XZuJTAWmAk9WgWlYT/ZArt/OzDAV2G8FpmHt99nOyaYCu6vANKzdPdK7HOjfDrP+9yf4B/fP138efoeDma+pwP0rMA3r/jXewwqaVFjP8x8HAmIHd74eXIG3W24a1ts98pse+H8Os8HBzNdU4P4VmIZ1/xrvYQU/9v3L4SCwNqh/P2iwagdpvqYC96nANKz71HVmnQpMBe5QgXduWHco526n9B3W9tJ91fi7Pfwc7HkqMA3reZ7FM+/kXw+bc7EeDvTPV1z8jzC/TQXuWYFpWPes7sw9FZgK3LQC07BuWs7dTva/h5P91ye6YGfT2EP4eb9mZ/uowDSsfTzHe5/CHVXQqKzHbjX6YCpwtwpMw7pbaX808f8dsoN7oQOdr6nAVGBbgWlY24o8hvvEzXcnp1YTl3cq51ExTdRe7GldE6ev2vhTgbtV4KyGdbfV33diDeC7T9bE5T1LlezFntb94PRVG38qcLcKTMO6W2lPTuy/GPdfiJfED+6G6Lg8/m/Dnuxne7lOe5Y9/naNZv0HVGAa1gOKfMYSGkI4I/1XUp59f79SlFn0sRWYhvXYereaex/3PyunQT9iiePlPMbOKlOBJ67ANKzfeTiakvufVsdDmjgtPnYq8PYVmIb1Oy8B9z7uf06tLi7vVM7EpgJvVYFpWM/xuF1mB3dFz7Gr2cXOK/B6x5uG9RzPzH1VeI4dzS6mAk9YgWlYT/hQZktTganA8QpMwzpel1GnAlOBJ6zANKyLH8oMnApMBR5dgWlYj674rDcVmApcXIFpWBeXbgZOBaYCj67ANKxHV3zWe8UKzJ6fpALTsJ7kQcw2pgJTge8rMA3r+xpNxlRgKvAkFZiG9SQPYrYxFZgKfF+BRzSs73cxGVOBqcBU4IwKTMM6o0iTMhWYCjxHBaZhPcdzmF1MBaYCZ1RgGtYZRXrSFP9Wln/gD/htEwd/mTqNT4Nyj2ny+7/3sDgYh4NxNBYHcdrH4Tc8HOifL/E044hsmjgN0tj2Ko6DcfLE8EADcfmAg1w64IMXrcA0rNd5cN6Y3njrjnE4pvkHANP58iCNxQO+B3x11v84HA6c9+DO1ytWYBrW6zw1/6Df/FtZlz8vtYPLZ5iRv16BaVi//gjO3sD2zYb7F0mB30Q4aHBpfBqU68cjHPjl4iHNPy5oDljH4yBeLh7SxNN+Mr7cY+PFmpNtLf52/2suv9yx11TgF8ZOw/qFor/gkt7kKzrCJZox9xzf3GN3WIFpWK/xUN27hHbsuyIXyyBGZ/FAgzgrh7aO59PE5AQaiKfJ2WriNCiPPZabxsqBa8ebw9rAD9agmX+r0QcvVoFpWK/xwLzxvOFcGq87pof0OHtKE5MDLqpxwAMO4mksDfggjgMecBBPY2lsEKdBGouDOB5oEGdx4Ac8pLFpY1+sAtOwfuuBXb+uN/H1s9x2hm0zWH/8u+1KM9tbVmAa1ms8dj/SuEh2odyOXUSHtTGksadyjZETjuWm/e/BKc+4A/1g09iPw69j2kH+OHe8XHMF89GOjaeXx+LAD41nt5rcwYtVYBrWiz2wZbvehBoZ8IVYPNAgzuKw5vK32prLD8dy08xRHosDP5TLbrVtLg7lscbRWDzQIM7isObyaYMXrMA0rBd8aN9s2Y9lodQ4m/ZK1r7Duu80Np0f0n7ZzvK3qsA0rFtV8vHzeFP6r9+Bbwesy/lAgzgrh8YaC+t3I3SclRdoEGflbDU6TYwfaBBn5Ww1fN2nPB820ECcZiweaLDmyqGBcSunDV6sAtOwXuyBPWC73tTe9NulaNuLfhqsuY1nV13eOeONMRb4t4Q93HK+mevBFZiG9eCC33i5ay+S3efAui3fibjc3+o06625OH3VjKOZZ9V9aCB/1XC5q2YczTyrfq7vgt5YWMc076qN/2IVeIGG9WIVfdx2vSG9CaFVaRpDSI+zcuisxgDrHMXYFfLhO018m0eDrY6D2AoapNmjvUN7FcdDueLyQU762B1UYBrWDh7iHGEq8C4VmIa1vyfdJbTvNDqdi/Ww3g3JgfKe1TpT+2+/zpHGtne6HEgbu5MKTMPayYNcjuENC9sL7iXl765LaPi78MvOLZZ3dmc65/y3WG/meGAFpmE9sNhPuJQ7HhffT7i1i7fkTODy/eJJZuBzVmAa1nM+l2t21SW0S+fm4Qdv5lV/hR+dNNXt/p0jje1M6a9wrvY89swKTMM6s1AvnuZNHF7xKO092xnibNrYF6nAJduchnVJ1Z57jO8sQjt1aR3c8aS/inWe7f6dI43tLPSQNnYnFZiGtZMHuRzDhTOsl869gdkl9cOna7Bqz+g7i72H9hhnV00DU4O0sTupwDSsnTzIOcZU4B0qMA3rRZ/yiW27gAYX1aXxw3rf0wV9ec9qfeJ3bP9pbHv346NzqUHa2J1UYBrWTh7kcgwNKSR7E4e0V7LtnXU2e2fxQBvsvALTsHb+gD+P544nfEovZdo7u24cD6s+/k4rMA1rfw/WJTq4eO50/OANvtXjz2pdoLd/31HZp3OksTQQx1n89TEn+HsFpmH9vRRv6XjTw94O70w+Wdzbud7+PNOw9vcScLcDLqq/O52Lafgu75Xizu5M60X8K+1/9nqiAtOwThTnRUPerLD+SORTs+AN3dH4EH9W6zztv3PZdxrb3ukhbexOKrD/hrWTBzXHmApMBT4+pmHt71Xgwhn6TsQJXcIH9zs04AP/meE87b9z2Xca2/7pciBt7E4qMA1rJw9yOYY3LJxz6awRwDL85V1n96niOed/+cO+2wGmYb3bE//reXd21/PncJ3pnA8d/gyY316nAtOwXudZnbvTfzkkgovqg/vnix+8of+Ih9/SDu5Tf/nEr722fzaN7QDp8yNhFdmRnYa1o4d54ijexOFE2tOG2nu2jcbZtLE7rsA0rP09XN9ZhE7nniq440l/Fes82/07Rxr7KmeZfV5RgaVhXTHLDH2mCrhwhvXS2Zs7rHutEazaM/rO0v7Z9sgPac7kU8NpYlVkR3Ya1o4e5hzlLxWYS/e/lGMfZBrWPp7jeoouqNl0fljve3w3sl5Yl/9sVvM5tv80tj07kw8d2LSxO6nANKydPMjlGN6omhIk0wLt1dDe2c7F4uHVzjT7vaAC07AuKNoLDumeh33B7X/Yd/hYfqWxizzuXiswDWt/T9aFM6yXzvywvrnTnr0KPkRor76jsl/nSGNpII6z+GBHFZiGtaOHecFRvOnhgqFPPcSZfLL41Jt81Ob2tM40rD09zb+dxd0OuKj+m/L17y7c4euM14v4zsqZ1ov41zvF7PhoBaZhHS3LS4verOCN20F8ahY0s3Q+xJ/VOk/771z2ncauexeDVRt/BxWYhrWDhzhHmAq8SwWmYX3zpF8w7MIZ+k7EEVzCB/c7NOAD/5nhPO2/c9l3Gtv+6XIgbexOKjANaycPcjmGNyycc+msEcAy/OVdZ/ep4jnnf/nDvtsBpmG92xP/63nd88Bf1ddmzgPnfOjw2id9w91Pw9rfQ3cBDS6qOx0/eDNv9fizWp/4bffvHGlse0//+Y+EzTD2aSswDetpH81NN+ZNHG468YMma+/Zlo2zaWN3XIFpWPt7uL6zCJ0uzrrjSX+Utaa1gW9dFg+0r7Dm8strLJs2dscVmIa1v4frwhnWS2c8rCf26Rqs2j381mZrOCwO617pPghg24uckM6mseVqXs5kjrSxO6nA7RrWTgoyx3iaCmhIT7OZ2chzVGAa1nM8h1vuwgV1aN44u973uJyH8u5lfWJnbWh9Fg+tTXeJ7jultGPjxRrL4mCcM5kDH+yoAtOwdvQwP4/iDQve+J/SBx4+fuFXa7Pti8WBf2pbckK5bBp7avzEdlKBaVg7eZDfHMOPV+Gb1HPCP85pbbbB/BXpx+xXeV/px+YYbQcVmIa1g4e4OYILZ1gvnfnBm7wh8mDVil1jfcezzulSvPXFzC2eJk4DcTqLgzgN0tfxdHkgjrP4YEcVmIa1o4f5w6N4w/9wyFnp5tVgzkr+Iskc6yeHX6R9KV87/suJJ/C7FZiG9bv1v8fq7nbARfWp+eW4rIZTeT+NmdeFN/vTsfKNsyfAf4prx/90vb3nP9X5pmE91eO4yWY0C1h/JPKpWfCGbiE5sGrFrrHb+eyn9a1nbjZNnAbGirE4iJcrRhNPY2lAl8Pigx1VYBrWjh7mHGUqsPcKTMPa3xN24Qy+y+h0LtaD+x06Kwfwe8Ia9gTWtRaLgzjtK8hpoPtu7gAAEABJREFU/+Uan8Y2li4H0sbupALTsO77IH9jdm9YOOfS2uU4yL/3Xq0B6zo4nLPXddwp33zOdMs5T603sQdWYBrWA4s9Sz2kAu6u4LsPHR6ymVnkthWYhnXbej7DbC6gwUV1++EHb2Y6Kw/4tHvBj2fWgdZi29N3nwiKl2ucfbJpLA3SrYkPdlSBaVg7epgnjuJNHE6kPTzUnthTi4uvKPeYVuzhdha8fwWmYd2/xo9ewR0OrN9h8INYe+JD/FmtPR7bfxr7rHuffd2wAtOwbljMJ5nKJ2qwXjq7hA5tUxOQB/z0Z7TtnW2vLB7at+blU0PnShu7kwpMw9rJg5xjTAXeoQJP07DeodgPOqML6tCSx+56aC6rgV/uM1qf+NkjrPvDQ7rvsJxJDdLG7qQC07B28iCXY3jDgjdysjdwWHU+lPes1nnaP98+7TuNpQUxiI/dSQWmYe3kQX5zDPc94ZvUpwy3d3bdIB5WffydVmAa1v4erAtnWC+d+cEb3KlZecCnPQY/X8XFevvvOyx7TmObVRxn08bupALTsHbyIOcYf6mAZrZ+SvqX4JDXrcA0rNd9dl/t3N0NuKj+Kocux8U04HvBXs+1l+dz1TmmYV1Vvqcc7AIa1h+J/JWY4A3dxuXAqhV7Jus87d9+7c2e01ga0OWw+OBXK3Dbxadh3baeM9tUYCpwxwpMw7pjcR8wtbsaaCm+S/SQHmflpL+K9R2Ti3Ro/6zzhM4iJ8hJH7uDCkzDeu2H2Bvz0lNoBHDp+EeO03zCqXXLYU/lTewFKzAN66kf2rebc08D3yZ+kaBZXTP+i2lHngrcpwLTsO5T10fN6jIaWk/zwUN6nJWTzm457dmgsdo7tF8WD+05zspJH7uDCkzD2sFD3BzBmzQUirNpr2btPbT3OHtKKzb2xSswDet1H6A7Gt95AL+T4CFNPKTtwXZOtvPwgzNv9VUr9gx29nBGBaZhnVGkJ03xxvNXVoBvmyweaEC/9oLePM8EZ+qc63/Vnsa2Xw0MB+PSx75YBaZhvdgDm+1OBd65AtOwXvfpu7cJ6ynS2HTfYbiEhrQ9WH+tKHQe515Bx8vj0wYvWIG9NKwXLP3VW/bG04BAQzLhqtFpQQzir26dxbmB33mcO6SJywN++tgXq8A0rBd7YMt23cWERf5IYz/u+Mv8K1rqmFbsEfbY+se0R+xl1rhxBaZh3bigd5rOhfn2r6B4E9LBdw6WXjU67RZo3tYxZ5p1XGbTAA84GNf+jaMBH/iXwFhzs41vbTadxUF+uXScTRv7xBWYhvXED+fJtuZNvX4ad4vtmVMT+dFcS7Lxa7NcQme5144/a5FJul0FpmHdrpb3nMm/beXuBe65zqm5u7Q+lXMqZu+w5vjuZv2nYdbYOb753Fex5+Rvc6x/zfjtfMPvXIFpWHcu8I2m743lzdWUNG924NO9cfFAuwXMaw22+fDWWfeVxq65cmCdo/il1lywjrduKLbulb/ml7Nq4z9pBaZhPemDmW1NBaYCHx8fmyJMw9oU5AmoexXfBbBtpwtrNl2O+x9IK/8aay5Y57CGtcG6YiwO4jTAAw7iacbRrEGHNPq1MJc5oblop9Zfcxsz9gkrMA3r+R6KS2Twhv5ud3JCubg3aPyn1nhvYPanY3+abw245WW+ucwJ3+1HTvgud+JPUIFpWE/wEDZb6IJ9I59N3cnA2QM2ica6YGc3oaFTgd+twDSs363/sdV9d+Rymi2OhxqJ+FYr/285sZ9ZY829jtLAtmvJSRMvP41NE8fB/HQWB3HaLWAuc0LzrXu1Lp2VE2iDJ6/ANKwnf0Cf2/PmCp/SH5PG/hHu9Jv5V7TMJZoxp8YXu9SaP6xzpLHp/JA29okrMA3r+R6O7wZCu4uz7lzS+RC/hbVGaD5rpPHpbBpLA37AYc3lbzX5tHvBmtYAfuvgIU08jZ8+9gkqMA3rdx+CNwS0C74L95AeZ9PkuhwHfvq11hqhueJsa7E4uOg+lntMM47OGgvrePqtz2RO6wB/uz6dBvyAvwjeY5vTsH73OXvzwLFd+FHlmH5P7au93HPNY3PbBxyLjfbGFZiG9bsPvx891l1oVODTwnQ8rFoXxmLp11qX1qG57MUakMYP4sf0NPFy0+Ks+Kr7L9XVJu1aa43OxG8+fkizl2O5xcf+YgWmYf1i8Y8s7c1TE1rfsGmsnIbyIX6tNZd1Q/Ph1gY+XS4OaXQ84CCexqd9NV7s1rCWdYFvfrY9sTSQE/DBE1VgGtafh/FUv/lRKLSxOJt2L2uN0Bpx9pj2lX4q15gV5T7SHlv/mPbIPc1aJyowDetEce4c8qf4uX9dxCV08IZqa3yIX2vN1Tps87mExsG+6WuuOA3kBBzE04yjsWniNDA/ncVvAWtVa745WesEGsRZOTTg33JP5hz8sALTsH5YsCdL9yaCJ9vWVdvxiaEzsVdNdOPBmirceNqZ7icVmIb1k2rdNtcdSrh0Zn/iw6Xjn3GcS291YW+5P3PCpXO6iF/vui6dZ8ZdUYFpWFcU78qh3jzeAFDTofmELLREnJWTfmtrbmuE5rfHtPbKpomXm8amieNgHJ3FQZwGdJzFbwHnMifwzclaO9AgzsqhAR/4g1+qwDSsXyr8LDsVmAr8vALTsH5es0tGuJPxHQM0nu9iF8TpLB5oEGfl0G453lzmNLc1Ag3E0+RsNXEalMfiII7DT8Yfy01jzResA3FWDu3Y+mJygjyIs3Jo63g+bfALFfhpw/qFLe5iSZe1sF4k870hQgeNs6c0MTkBhziLAz/gIc1etppYmjge0uPiW00sTRwP6XHxrSaWJo6H9Dh7ShOTE3CIszjwAx7S7CVt7IMrMA3rwQWf5aYCU4HLKzAN6/La/WSky16XuGzj+tSJ1mUui4dy46wcuh9NcEhj8SAP4qwc2jreXmhicgINxNPk0I6Np5fH4nDp+NY6Nl7MGsE6EGfl0Na9prFygjyIs3Jo63h7oQ1+oQLTsH6h6J9LejOs+JQ/ztU+Dr/OzT2Wdxj+cUz/u/bxj1+rxi/CD+dq8s/NPZZ3yXjzGBdwiLM48AMe0ti0sQ+uwDSsxxTcn9DBXYhV2a1GT2Nx4AfjaOxWo6exOPCDcTQ2jaUBP+Cw5vK3mnwa8AMOxqTxaZDG4sAP5bJbbZuLQ3mscTQWBz4N8IBDnC2XxYM8iLP44M4VmIZ15wJ/Tu/CPXxKH3HWG4LO4oEGcRaHNZe/1dZcfpAHxqR1kbxqYvKAH+TQ2LTG09NYOV9p3uRy4KvxxoKc0JxsGisP+AGHNZe/1eTTgB9wMCbtu73KH9ypAtOw7lTYN53WG/tNj/7ix36R7U/DesyDcu+xwqr+6kkaHtLYn2ryjQs4xFkc+MFeaJDG4iCOAw78IL7VitHFV06Ls+I0wAMO4luNnsbiwA84xFk84OFczV7KdQEf0sbesQLTsO5Y3GVqnziFZD8SpfHp3jxpLA34QQ6NTbv3ePOfWkvcnqA8FgdxHPi0Y/unywlyaMak8WliaSwN+EEOjU271XjzmivggztXYBrWnQv8Ob0flcKn9BHPfnz+irOf0gc/fCy/0thkfjiliZXH4sAPOMSzNIizOPADDvEsDeIsDvyAQzxLgziLAz/gIY39qSbfuIBDnMUHd67ANKxbFPgfc3jh9u8u+ZO3iL/mEeTQXeJuNbE0Vh7wgxya+beaWBorD/hBDm0dby80sfJYGojjIId2bDxdTsDh0vGtdWy8WOuw1gF+kENb95rGlsfKA36QQ1vH2wtNrDyWFsTyx96wAtOwbljMmWoq8FkBDUyT+6RjblWBaVi3quQ/5nFfEv6hjvdOFfC3GrwG3unMDznrNKzbltmLtMtdfrN7AYd0eWn9aSyWxp4ab4wc4Mu9dLy9nBovbh1oLRYHceMBDziIpxlHY9PEaZDGOg9NHAfjaGJ4oEGclUMzBgc+TQwPNIizcmjG4P/y8fFhLzSxNJa2Qnzl49+gAtOwblDEmWIqMBV4TAWmYV1XZ5er0Cz+JHZ/Afx0PJQvvtXE0thHjbcXa321vrj9gBy5q8angZyAg3jaT8Yfy01jm5O1DvCDHNqx9cXKY+UBP8ihreP5NLHyWBqI4yCHNrhRBaZhXV5IL0Yvyj41aiY6rH+FAw/liaex6fxwShMrj8WBH3CIs3jAwV62Gj1NHA/pcfGtJpYmjof0uPhWE0sTx0N6nD2lickJOMRZHPgBD2n2stXE0sRxSBt7owo8uGHdaNfPM417ivW/fH6enc1OpgI7rMA0rMsfqmblAtaPAM1C669qsOn8IIeu0W01epq5cUhjG8/iwJcHOPxkvHxjgR9wOLZXa5bHygN+wOHS8db4ajy9dVgc+KHx7Fbb5uJQHmscjcUDDeIsDny1B+NogxtVYBrWjQr5OY0XqAYG/E/5Aw8fn7/ibLksDvzP1A88fHz+Ek/jk9mtRk9jcTiWu2prLj/I+Wo8vTwWB364x3hzWwf4AQdrpvG3mhgN+AEHY9L4W02MBuIBH9ywAtOwLi+mOwovVOCbicUDDeKsHBqLA58GeMAhzpbL4sCXB3jAIc6Wy+JBHsRZHNZc/lZbc/kA8sAYHPhbjU4DfjiWm7bNxaGxbLksDnx5gAcc4my5LB7kQZzFYc3lb7U1V2zwgwpMw/pBsTapLtvD+sJMc/nakDT2mHZs/Fe5jTdGDvDpLB5oEGdxWHPb66qtufwg56vx9PJYHPjhHuPNbR3gBxysmcbfamI04AccjEm7Za3MPfhBBaZh/aBYkzoVmAr8bgWmYV1efxfJ27uKOCve7HhIE99qYmksDvyAQ5zFAx7O1ezlVK74qTnFHz3eeu2JxYEfcIizeMDDudotz9qaL28fdYBpWJdX2l2ET4KAbyYvfhzS6HiQQxNP49PE0lga8IMcGpt27/HmP7WWuD1BeSwO4jjwacf2T5cT5NCMSePTxNJYGvCDHBqbdu/x5j+1lrg9DS6owDSsC4r2OcS9xopP+eMSzZiPz1/88Cl9xNmP5RcekuPsKU1MTsAhzuLAX0GDSzRjjAV+wCHO4sBfQYNztZ/kHpvz0vHmMhb4AR9cUIFpWOcVzZ+K/qt2thEuYWnghUgXx0GcBngoV3yriaWxxgI/yKGta6Wx5bHygB/k0Nbx9kITK4+lgTgOcmjHxtPlBBwuHd9ax8aLtQ5rHeAHObR1r2lseaw84Ac5tHW8vdDEymNpII6DHNqx8fTBDyswDeu8gvlkyIuPPW/ED7Im9a0r4B98fOsC/OTw07DOq5aLVvcg7HkjJmsqcF4Fjv3TNOeNfMOsaVjnPXTf0rtIZRuBe7FBOouDeLl40Pjo4mnG0cTSWBrwgxyaMWl8mlgaSwN+kEMzJs1eaGJpLA3EcTCOxuIgTgM84CCeZhyNTROnQRprPzRxHIyjieGBBnFWDs0YHPg0MTzQIM7KoRmDg73QxPBAA/E042hsmjhtcEEFpmFdUFjYef8AAA5ZSURBVLQZMhW4uAIz8KoKTMP65/K5q4I14gI1FPOn5ilNvDnKY0+NF5MTHjW+vX61vnh7kmNfq8anQXksDuI4/GT8sdw01nzBOhBn5dCOrS8mJ8iDOCuHto7n08TkBBqIp8nZauI0KI/FB99UYBrWPxfIiwd6scngBxxcwKexNOCDOA54wEE8jaUBP+AQZ/GAh59q8htrLziksTiI44EGcXEc0lgcxPFAg7g4DmksDuJ4oEGcxYEf8JDG/lSTbxzYCw54wEE8jaUBH8RxwAM++KYC07C+KdCEpwJTgeepwGs1rMfUrctRl6qt6N84Cuk+MdxqYmnsT8fLNw7Wy1k8WEMeu9XoaSwO/GAcjU1jacAPOFx6VmPh0vH2+NV4evtkceCD+jWepQFfHuABhzhbLosHeRBncfjJWe0xGDv4pgLTsL4p0GfYvUP4lD7irBf0x+EXiwP/IP35wsMf4fBbnC2XxYF/SPvzhYc/wuE38TT+Qfpg09iPz1/88Cl9rLn8j8Mvtjz2IP354gc5RHar0dNYHPjBOBq71ehpLA78YByNTWNpwAdxHPg04G81Og34AQdj0vhbTYwG/HAsN00uP+CDbyowDeuvBXKf0IuNLcoPcujsVqOnsTjwg3E0dqvR01gc+ME4GrvV6GksDvxgHI1NY2nADzisufytJp8G/ICDMWl8GqSxOPBDuexW2+biUB5rHI3FgU8DPOAgnsbfamI04AccjEnjbzUxGvADDsYEfLBUYBrWUoyD64Xir1bAejmKh0Pan684axzRiw+HS8abx9hgToizOKy5/K225vKDPDAmrb2umpg84Ac5NDat8fQ0Fgd+MI72Ta0+5MsDfmg8m8bKA37AYc3lbzX5NOAHHIxJ66yrJiYP+EEOjU1rPD2NxUGuD30AHywVmIa1FONM17fwZ6ZO2gtWQMN4wW2/x5anYf31OWtGweVp0TQ2TRyHNH4QP6aniZebxqaxOPADDnEWD3g4V7OXU7nip+YUf/R467UnFgd+wCHO4gEP52r3Pqv9uMSH9jT2swLTsD4L8Wm8WPrUxo8sn/Kf/z15epp4Gp9+zng5co05NV5MHvBD49k0c8lbNTEa8IMcGpv23XjxU7ni5oTyWBzEceDTjq1PlxPk0IxJ49PE0lga8IMcGpt27/HmP7WWuD1BeSwO9ioH8HfA2WechvXPpfIjQSgaZ49pX+nHco9pl4w3j3EBhziLAz/gIY09V/tJ7rE5f2O8fVg34BBnceAHPKSx52o/yT025zq++Fvbd29Y/hSDXgR8l52wXoTioVzxtF5Y54wv99h4seZkW4sf5NDXtdLY8lh5wA9yaOt4e6GJlcfSQBwHObRj4+lyAg6Xjm+tY+PFWoe1DvCDHNq61zS2PFYeHFtrHS8u79rx5rBuwMH8adagDT4r8M4Ny4vBi2P91OazLGOmAlOBZ6zAOzcsz8PFJvBfFbPv21bAH2S3nXFmu1kF3rlhdbnJVlDf+vdXc9aL0DS2XHEcjKOzOIjTAA+tJ55mnDyxNJYG/CCHZkwanyaWxtKAH+TQjEmzF5pYGksDcRyMo7E4iNMADziIpxlHY9PEaZDG2g9NHAfjaGJ4oEGclUMzBgc+TQwPNIizcmjG4GAvNDE80EA8zTgamyZOgzQWB3EcjKMNPivwzg3rswRjpgJTgVepwDs3LH96bS83V43fcyyPTRPHoR8jVo1frpxwLDeNLY991Pj2+tX64vYDcuxr1fg0kBNwEE/7yfhjuWlsc7LWAX6QQzu2vlh5rDw4lrtqfHnXjjeHdQMO5k+zBg3o/3lwVu1A3+vrnRuWy3YPP/Tk4+JbTSxNHA/pcfGtJpYmjof0OHtKE5MTcIizOPADHtLsZauJpYnjIT0uvtXE0sTxkB4X32piaeJ4SI+zpzQxOQGHOIvDuhYe5ID4VqOnieMhPS6+1cTSxPGQ7gMioKe9nX3nhvV2D3sOfFYF3rohnFWhX0x654blr1j4hBBcoHoMLB5oEGdxuHS8Nb4aT7dGwCHONp7FgS8P8IBDnC2XxYM8iLM4XHpWY+HS8fb41Xi6PQYc4i6vG8+m8+VBGosDP5TLprHygB9wuPSsxsKx8XSX8OBHQ/wt8VYNa/OEPfjgBSnMbjV6GosDPxhHY7caPY3FgR+Mo7FpLA34AYc1l7/V5NOAH3AwJo2/1cRowA/HctO2uTg0li2XxYEvD/CAQ5wtl8WDPIiL48Df6qsmJg/4AYc1l7/V5NOAH47lpm1zcWgsu+aKvT3epWH5Nj/00HEvCuDTWTzQIM7KobE48LcanQb8cCw3bZuLQ2PZclkc+PIADzjE2XJZPMiDOIvDmsvfamsuP8gDY9L4W02MBvxwLDdtm4tDY9lyWRz48gAPOIin8beaGA34AQdj0vhbTYwG/ICDMWn8rSZGe0u8S8PqUxe2B+0CM6wvjDSXn8dyj2n3GG8fx9ZKs6Yc4NNZPNAgzuKw5nbWVVtz+UHOV+Pp5bE48MM9xpvbOsAPOFgzjb/VxGjgNYIDDsbg8Ey1sre3wrs0rLd6qHPYj4+PKcIuK/AuDauLUbYH6XLTHQGk8YP4MT1NvNy0OCt+TE8TlwdpLB5wiLM48AMe0thzNXs5lStuPjiWJ35MTxM3FtL4QfyYniZebhqbxuLADzjEWTzg4VzNXk7lip+aU/xW45vnbey7NCw/94ceLu6TJODTvdBwSKPjQQ5NPI1PE9tq9DRWDs0YHPg0MTzQIM7KobE43Hu8+a0D/K/Wp8sJOBiTxqcd2z+9PFYOzRgc+DQxPNAgzsqhsTjce7z5rQP8r9anywk4GJPGpx3bP/3t8C4N6+0e7Bx4KrDHChxvWPs7qYtU6E8sJ8T/7+BAOouD+CH85wsPLmCJ4mnG0dg0cRqksafGi8kJxkKclUNb1+LTxOQEGsRZOTRjcGivYniQB+JpxtHYNHEapLE4iONgHI3FQZwGeLAfmniacTSxNJYG/CCHZkwanyaWxtKAH+TQjEmzF5pYGksDcRyMo7E4iNMADziIpxlHY9PEaW+Jd2lYXlzQJzxv+bDn0FOBV6/AuzQsdwCwXni++rOb/U8F3q4C79KwtpeYHjTNX3UA33LTWBzEaYAHjY8mnmYcjU0Tp0Eae2q8mJxgLMRZObR1LT5NTE6gQZyVQzPmXz4+PmjtVQwP8kA8zTgamyZOgzQWB3EcjKOxOIjTAA/2QxNPM44mlsbSgB/k0IxJ49PE0lga8IMcmjFp9kITS2NpII6DcTQWB3Ea4AEH8TTjaGyaOO0t8S4N6y0f7hx6KrC3Crxzw/KnlgtMcL/l2a4anwZywrHcNLa8a8ebx9rAD9agmX+riaWx8oAf5NDW8XyaWHksDcRxkLPVxGkgJ+AgnvaT8cdy09jmZK0D/CCHdmx9sfJYecAPcmjreD5NrDyWBuI4yNlq4jSQE3AQT2s8nQ/8t8U7NywX8F4AsL4AcBBPx0OaeBpLZ4M4DdJYHMTxQIM4iwM/4CGN/akm3ziwFxzwgIN4GksDPojjgAccxNNYGvBBHAc84CCextKAH3CIs3jAw081+Y21FxzSWBzE8UCDuDgOaSwO4niggb8WpJHR8Uvw8mPeuWG9/MObA7xVBXxg5N7srQ69Pew7Nyx/TccFJvRCYOmhesVZOXQvIBzSWDzIgzgrh3ZsPF1OwCHONp7FgS8PcHAuHPBQLpvGygN+wOHYXh8x3hpfrU9vnywO/NB4dqttc3EojzWOxuKBBnEWh3vUyrx+VPRM7QV/S7xzw/LgQw8f98IAfjoejmnlsuXxj+Ue08plG88ey01bc/l01jjg0wAPOIin8beaGA344Vhu2jYXh8ay5bI48OUBHnCIs+WyeJAHcRaHNZe/1dZcfpAHxqTxt5oYDfjhWG7aNheHxrJrrtjb450b1rGH735gRTlePEGczp7SxOQBPxhHY7caPY3FgR+Mo7FbjZ7G4sAPxtHYNJYG/IDDmsvfavJpwA84GJPG32piNOCHY7lp21wcGsuWy+LAlwd4wCHOlsviQR7EWRzWXP5WW3P5QR4YE/DBUoFpWEsxDq4XiotNcMl5kP588cMf4fBbnDXuIH2wOLg8/fj8hYdP6SPOGkdn8UCDOIvDmsvfamsuP8gDY9La66qJyQN+kENj0xpPT2Nx4AfjaGzatePNY07gBxzWtfhbTT4N+AEHY9La66qJyQN+kENj0xpPT2NxkOv1B3za4LMC07A+CzFmKjAVeP4KTMP66zNyZxBcnhZNY9PEcUjjB/Fjepp4uWlsGosDP+AQZ/GAh3M1ezmVK35qTvFHj7dee2Jx4Acc4iwe8PAX7UDoB/Pnix/ufVbruMQH/p8NzG9/q8A0rL/Vod+9QHwSA+4W0vEghy6exqeJbTV6GiuHZgwOfJoYHmgQZ+XQWBzuPd781gH+V+vT5QQcjEnj047tn14eK4dmDA58mhgeaBBn5dBYHO493vzWAf5X69PlBBzs1TjAB0sFpmEtxRh3KjAVeO4KTMN67ufzTLv7yQXwT3Kf6YyzlyevwF0a1pOfebZ3ugKajX8sbpvlkyyxVceP/ejiE641b/ypwE0qMA3rJmXc1STuUPxTJttDuWsRW3X8WMM6Nn4dN/5U4KIKTMO6qGwzaCowFfiNCkzD+o2q72nNOctU4IEVmIb1wGLPUlOBqcB1FZiGdV39ZvRUYCrwwApMw3pgsWepqcBrV+D3dz8N6/efwexgKjAVOLMC07DOLNSkTQWmAr9fgWlYv/8MZgdTganAmRWYhnVmoa5PmxmmAlOBayswDevaCs74qcBU4GEVmIb1sFLPQlOBqcC1FZiGdW0FZ/xU4J8rMMqdKjAN606FnWmnAlOB21dgGtbtazozTgWmAneqwDSsOxV2pp0KTAVuX4H/DwAA//9sB2hHAAAABklEQVQDAB9QlitZA9bLAAAAAElFTkSuQmCC",width:"248",height:"248",style:{mixBlendMode:"multiply"}})))}var i0="ai",s0="ai-wp-admin",Vs="ai/ai",a0="https://wordpress.org/plugins/ai/",Ys=Object.values(Fs()),c0=Ys.some(e=>e.type==="ai_provider"),af=[];for(let e of Ys)e.type==="ai_provider"&&e.authentication.method==="api_key"&&af.push(e.authentication.settingName);function cf(){let[e,t]=(0,wt.useState)(!1),[o,n]=(0,wt.useState)(!1),r=(0,wt.useRef)(null);(0,wt.useEffect)(()=>{o&&r.current?.focus()},[o]);let i=(0,wt.useRef)(Ys.some(S=>S.type==="ai_provider"&&S.authentication.method==="api_key"&&S.authentication.isConnected)).current,{pluginStatus:s,canInstallPlugins:a,canManagePlugins:d,hasConnectedProvider:c}=(0,ln.useSelect)(S=>{let x=S(Ws.store),E=!!x.canUser("create",{kind:"root",name:"plugin"}),T=x.getEntityRecord("root","site"),k=i||af.some(A=>!!T?.[A]),C=x.getEntityRecord("root","plugin",Vs);return x.hasFinishedResolution("getEntityRecord",["root","plugin",Vs])?C?{pluginStatus:C.status==="active"?"active":"inactive",canInstallPlugins:E,canManagePlugins:!0,hasConnectedProvider:k}:{pluginStatus:"not-installed",canInstallPlugins:E,canManagePlugins:E,hasConnectedProvider:k}:{pluginStatus:"checking",canInstallPlugins:E,canManagePlugins:void 0,hasConnectedProvider:k}},[]),{saveEntityRecord:l}=(0,ln.useDispatch)(Ws.store),{createSuccessNotice:f,createErrorNotice:p}=(0,ln.useDispatch)(rf.store),m=async()=>{t(!0);try{await l("root","plugin",{slug:i0,status:"active"},{throwOnError:!0}),n(!0),f((0,Xe.__)("AI plugin installed and activated successfully."),{id:"ai-plugin-install-success",type:"snackbar"})}catch{p((0,Xe.__)("Failed to install the AI plugin."),{id:"ai-plugin-install-error",type:"snackbar"})}finally{t(!1)}},u=async()=>{t(!0);try{await l("root","plugin",{plugin:Vs,status:"active"},{throwOnError:!0}),n(!0),f((0,Xe.__)("AI plugin activated successfully."),{id:"ai-plugin-activate-success",type:"snackbar"})}catch{p((0,Xe.__)("Failed to activate the AI plugin."),{id:"ai-plugin-activate-error",type:"snackbar"})}finally{t(!1)}};if(!c0||s==="checking"||s==="active"&&i&&!o||s==="inactive"&&d===!1)return null;let g=s==="active"&&!c,v=s==="active"&&c&&(!i||o),_=s==="not-installed"||s==="inactive",w=s==="not-installed"&&a===!1,y=()=>v?(0,Xe.__)("The AI plugin is ready to use. You can use it to generate featured images, alt text, titles, excerpts and more. Learn more"):g?(0,Xe.__)("The AI plugin is installed. Connect an AI provider below to generate featured images, alt text, titles, excerpts, and more. Learn more"):(0,Xe.__)("The AI plugin can use your AI connectors to generate featured images, alt text, titles, excerpts and more. Learn more"),b=()=>s==="not-installed"?{label:e?(0,Xe.__)("Installing\u2026"):(0,Xe.__)("Install the AI plugin"),disabled:e,onClick:e?void 0:m}:{label:e?(0,Xe.__)("Activating\u2026"):(0,Xe.__)("Activate the AI plugin"),disabled:e,onClick:e?void 0:u};return React.createElement("div",{className:"ai-plugin-callout"},React.createElement("div",{className:"ai-plugin-callout__content"},React.createElement("p",null,(0,wt.createInterpolateElement)(y(),{strong:React.createElement("strong",null),a:React.createElement(cn.ExternalLink,{href:a0})})),!w&&(_?React.createElement(cn.Button,{variant:"primary",size:"compact",isBusy:e,disabled:b().disabled,accessibleWhenDisabled:!0,onClick:b().onClick},b().label):React.createElement(cn.Button,{ref:r,variant:"secondary",size:"compact",href:(0,sf.addQueryArgs)("options-general.php",{page:s0})},(0,Xe.__)("Control features in the AI plugin")))),React.createElement(nf,null))}var{store:d0}=No(l0);of();function u0(){let e=$u(),{connectors:t,canInstallPlugins:o,isAiPluginInstalled:n}=(0,lf.useSelect)(c=>{let l=c(uf.store),f=l.getEntityRecord("root","plugin","ai/ai");return{connectors:No(c(d0)).getConnectors(),canInstallPlugins:l.canUser("create",{kind:"root",name:"plugin"}),isAiPluginInstalled:!!f}},[]),r=t.filter(c=>c.render),i=Array.from(new Set(t.filter(c=>c.type==="ai_provider").map(c=>c.plugin?.file?.split("/")[0]).filter(c=>!!c))).sort(),s=new Set(t.filter(c=>c.plugin?.isInstalled).map(c=>c.plugin?.file?.split("/")[0]).filter(c=>!!c));n&&s.add("ai");let a=["ai",...i].filter(c=>!s.has(c)),d=r.length===0;return React.createElement(Bs,{title:(0,Tt.__)("Connectors"),subTitle:(0,Tt.__)("All of your API keys and credentials are stored here and shared across plugins. Configure once and use everywhere.")},React.createElement("div",{className:`connectors-page${d?" connectors-page--empty":""}`},a.length>0&&(e||!o)&&React.createElement(tn.Root,{intent:"info",className:"connectors-page__file-mods-notice"},React.createElement(tn.Description,null,e?(0,Tt.__)("Plugins cannot be installed here due to your site configuration. Install them manually using your normal deployment workflow."):(0,Tt.__)("You do not have permission to install plugins. Please ask a site administrator to install them for you."))),d?React.createElement(dt.__experimentalVStack,{alignment:"center",spacing:3,style:{maxWidth:480}},React.createElement(dt.__experimentalVStack,{alignment:"center",spacing:2},React.createElement(dt.__experimentalHeading,{level:2,size:15},(0,Tt.__)("No connectors yet")),React.createElement(dt.__experimentalText,{size:12},(0,Tt.__)("Connectors appear here when you install plugins that use external services. Each plugin registers the API keys it needs, and you manage them all in one place."))),React.createElement(dt.Button,{variant:"secondary",href:"plugin-install.php",__next40pxDefaultSize:!0},(0,Tt.__)("Learn more"))):React.createElement(dt.__experimentalVStack,{spacing:3},React.createElement(cf,null),React.createElement(dt.__experimentalVStack,{spacing:3,role:"list"},t.map(c=>c.render?React.createElement(c.render,{key:c.slug,slug:c.slug,name:c.name,description:c.description,type:c.type,logo:c.logo,authentication:c.authentication,plugin:c.plugin}):null))),o&&!e&&React.createElement("p",null,(0,df.createInterpolateElement)((0,Tt.__)("If the connector you need is not listed, search the plugin directory to see if a connector is available."),{a:React.createElement("a",{href:"plugin-install.php?s=connector&tab=search&type=tag"})}))))}function f0(){return React.createElement(u0,null)}var p0=f0;export{p0 as stage}; +var wf=Object.create;var _r=Object.defineProperty;var vf=Object.getOwnPropertyDescriptor;var _f=Object.getOwnPropertyNames;var yf=Object.getPrototypeOf,xf=Object.prototype.hasOwnProperty;var Re=(e,t)=>()=>(t||e((t={exports:{}}).exports,t),t.exports),At=(e,t)=>{for(var o in t)_r(e,o,{get:t[o],enumerable:!0})},Rf=(e,t,o,n)=>{if(t&&typeof t=="object"||typeof t=="function")for(let r of _f(t))!xf.call(e,r)&&r!==o&&_r(e,r,{get:()=>t[r],enumerable:!(n=vf(t,r))||n.enumerable});return e};var h=(e,t,o)=>(o=e!=null?wf(yf(e)):{},Rf(t||!e||!e.__esModule?_r(o,"default",{value:e,enumerable:!0}):o,e));var Ot=Re((g0,Gs)=>{Gs.exports=window.wp.i18n});var de=Re((h0,Ks)=>{Ks.exports=window.wp.element});var z=Re((w0,qs)=>{qs.exports=window.React});var Q=Re((E0,$s)=>{$s.exports=window.ReactJSXRuntime});var Mt=Re((Ah,Ta)=>{Ta.exports=window.ReactDOM});var Mc=Re(Ic=>{"use strict";var wo=z();function Tm(e,t){return e===t&&(e!==0||1/e===1/t)||e!==e&&t!==t}var km=typeof Object.is=="function"?Object.is:Tm,Pm=wo.useState,Cm=wo.useEffect,Am=wo.useLayoutEffect,Om=wo.useDebugValue;function Nm(e,t){var o=t(),n=Pm({inst:{value:o,getSnapshot:t}}),r=n[0].inst,i=n[1];return Am(function(){r.value=o,r.getSnapshot=t,ri(r)&&i({inst:r})},[e,o,t]),Cm(function(){return ri(r)&&i({inst:r}),e(function(){ri(r)&&i({inst:r})})},[e]),Om(o),o}function ri(e){var t=e.getSnapshot;e=e.value;try{var o=t();return!km(e,o)}catch{return!0}}function Lm(e,t){return t()}var Im=typeof window>"u"||typeof window.document>"u"||typeof window.document.createElement>"u"?Lm:Nm;Ic.useSyncExternalStore=wo.useSyncExternalStore!==void 0?wo.useSyncExternalStore:Im});var ii=Re((w1,Bc)=>{"use strict";Bc.exports=Mc()});var zc=Re(Hc=>{"use strict";var Dn=z(),Mm=ii();function Bm(e,t){return e===t&&(e!==0||1/e===1/t)||e!==e&&t!==t}var Hm=typeof Object.is=="function"?Object.is:Bm,zm=Mm.useSyncExternalStore,Dm=Dn.useRef,jm=Dn.useEffect,Fm=Dn.useMemo,Vm=Dn.useDebugValue;Hc.useSyncExternalStoreWithSelector=function(e,t,o,n,r){var i=Dm(null);if(i.current===null){var s={hasValue:!1,value:null};i.current=s}else s=i.current;i=Fm(function(){function d(m){if(!c){if(c=!0,l=m,m=n(m),r!==void 0&&s.hasValue){var u=s.value;if(r(u,m))return f=u}return f=m}if(u=f,Hm(l,m))return u;var g=n(m);return r!==void 0&&r(u,g)?(l=m,u):(l=m,f=g)}var c=!1,l,f,p=o===void 0?null:o;return[function(){return d(t())},p===null?void 0:function(){return d(p())}]},[t,o,n,r]);var a=zm(e,i[0],i[1]);return jm(function(){s.hasValue=!0,s.value=a},[a]),Vm(a),a}});var jc=Re((_1,Dc)=>{"use strict";Dc.exports=zc()});var $t=Re((X2,md)=>{md.exports=window.wp.primitives});var Rd=Re((g4,xd)=>{xd.exports=window.wp.theme});var Qi=Re((b4,Sd)=>{Sd.exports=window.wp.privateApis});var on=Re((q5,Au)=>{Au.exports=window.wp.components});var rn=Re((a3,zu)=>{zu.exports=window.wp.data});var mr=Re((c3,Du)=>{Du.exports=window.wp.coreData});var Hs=Re((u3,Fu)=>{Fu.exports=window.wp.notices});var Wu=Re((f3,Vu)=>{Vu.exports=window.wp.url});function Xs(e){var t,o,n="";if(typeof e=="string"||typeof e=="number")n+=e;else if(typeof e=="object")if(Array.isArray(e)){var r=e.length;for(t=0;te();function Y(e){let t=Se(kf).current;return t.next=e,Tf(t.effect),t.trampoline}function kf(){let e={next:void 0,callback:Pf,trampoline:(...t)=>e.callback?.(...t),effect:()=>{e.callback=e.next}};return e}function Pf(){}var Js=h(z(),1),Cf=()=>{},D=typeof document<"u"?Js.useLayoutEffect:Cf;var hn=h(z(),1),Af=hn.createContext(void 0);function so(){return hn.useContext(Af)?.direction??"ltr"}function Of(e,t){return function(n,...r){let i=new URL(e);return i.searchParams.set("code",n.toString()),r.forEach(s=>i.searchParams.append("args[]",s)),`${t} error #${n}; visit ${i} for the full message.`}}var Nf=Of("https://base-ui.com/production-error","Base UI"),Pe=Nf;var Wt=h(z(),1);function xr(e,t,o,n){let r=Se(ta).current;return Lf(r,e,t,o,n)&&oa(r,[e,t,o,n]),r.callback}function ea(e){let t=Se(ta).current;return If(t,e)&&oa(t,e),t.callback}function ta(){return{callback:null,cleanup:null,refs:[]}}function Lf(e,t,o,n,r){return e.refs[0]!==t||e.refs[1]!==o||e.refs[2]!==n||e.refs[3]!==r}function If(e,t){return e.refs.length!==t.length||e.refs.some((o,n)=>o!==t[n])}function oa(e,t){if(e.refs=t,t.every(o=>o==null)){e.callback=null;return}e.callback=o=>{if(e.cleanup&&(e.cleanup(),e.cleanup=null),o!=null){let n=Array(t.length).fill(null);for(let r=0;r{for(let r=0;r=e}function Rr(e){if(!ra.isValidElement(e))return null;let t=e,o=t.props;return(ao(19)?o?.ref:t.ref)??null}function Bo(e,t){if(e&&!t)return e;if(!e&&t)return t;if(e||t)return{...e,...t}}function Nt(){}var I0=Object.freeze([]),be=Object.freeze({});function ia(e,t){let o={};for(let n in e){let r=e[n];if(t?.hasOwnProperty(n)){let i=t[n](r);i!=null&&Object.assign(o,i);continue}r===!0?o[`data-${n.toLowerCase()}`]="":r&&(o[`data-${n.toLowerCase()}`]=r.toString())}return o}function sa(e,t){return typeof e=="function"?e(t):e}function aa(e,t){return typeof e=="function"?e(t):e}var Sr={};function ye(e,t,o,n,r){if(!o&&!n&&!r&&!e)return wn(t);let i=wn(e);return t&&(i=Ho(i,t)),o&&(i=Ho(i,o)),n&&(i=Ho(i,n)),r&&(i=Ho(i,r)),i}function ca(e){if(e.length===0)return Sr;if(e.length===1)return wn(e[0]);let t=wn(e[0]);for(let o=1;o=65&&r<=90&&(typeof t=="function"||typeof t>"u")}function Er(e){return typeof e=="function"}function da(e,t){return Er(e)?e(t):e??Sr}function zf(e,t){return t?e?(...o)=>{let n=o[0];if(fa(n)){let i=n;zo(i);let s=t(...o);return i.baseUIHandlerPrevented||e?.(...o),s}let r=t(...o);return e?.(...o),r}:ua(t):e}function ua(e){return e&&((...t)=>{let o=t[0];return fa(o)&&zo(o),e(...t)})}function zo(e){return e.preventBaseUIHandler=()=>{e.baseUIHandlerPrevented=!0},e}function Tr(e,t){return t?e?t+" "+e:t:e}function fa(e){return e!=null&&typeof e=="object"&&"nativeEvent"in e}var kr=h(z(),1);function Ce(e,t,o={}){let n=t.render,r=Df(t,o);if(o.enabled===!1)return null;let i=o.state??be;return Vf(e,n,r,i)}function Df(e,t={}){let{className:o,style:n,render:r}=e,{state:i=be,ref:s,props:a,stateAttributesMapping:d,enabled:c=!0}=t,l=c?sa(o,i):void 0,f=c?aa(n,i):void 0,p=c?ia(i,d):be,m=c&&a?jf(a):void 0,u=c?Bo(p,m)??{}:be;return typeof document<"u"&&(c?Array.isArray(s)?u.ref=ea([u.ref,Rr(r),...s]):u.ref=xr(u.ref,Rr(r),s):xr(null,null)),c?(l!==void 0&&(u.className=Tr(u.className,l)),f!==void 0&&(u.style=Bo(u.style,f)),u):be}function jf(e){return Array.isArray(e)?ca(e):ye(void 0,e)}var Ff=Symbol.for("react.lazy");function Vf(e,t,o,n){if(t){if(typeof t=="function")return t(o,n);let r=ye(o,t.props);r.ref=o.ref;let i=t;return i?.$$typeof===Ff&&(i=Wt.Children.toArray(t)[0]),Wt.cloneElement(i,r)}if(e&&typeof e=="string")return Wf(e,o);throw new Error(Pe(8))}function Wf(e,t){return e==="button"?(0,kr.createElement)("button",{type:"button",...t,key:t.key}):e==="img"?(0,kr.createElement)("img",{alt:"",...t,key:t.key}):Wt.createElement(e,t)}var vn=h(z(),1);var pa=0;function Yf(e,t="mui"){let[o,n]=vn.useState(e),r=e||o;return vn.useEffect(()=>{o==null&&(pa+=1,n(`${t}-${pa}`))},[o,t]),r}var ma=Mo.useId;function Lt(e,t){if(ma!==void 0){let o=ma();return e??(t?`${t}-${o}`:o)}return Yf(e,t)}function ga(e){return Lt(e,"base-ui")}var U={};At(U,{cancelOpen:()=>wp,chipRemovePress:()=>ep,clearPress:()=>$f,closePress:()=>Qf,closeWatcher:()=>up,decrementPress:()=>np,disabled:()=>_p,drag:()=>gp,escapeKey:()=>dp,focusOut:()=>lp,imperativeAction:()=>Rp,incrementPress:()=>op,initial:()=>xp,inputBlur:()=>sp,inputChange:()=>rp,inputClear:()=>ip,inputPaste:()=>ap,inputPress:()=>cp,itemPress:()=>Zf,keyboard:()=>pp,linkPress:()=>Jf,listNavigation:()=>fp,missing:()=>yp,none:()=>Uf,outsidePress:()=>qf,pointer:()=>mp,scrub:()=>hp,siblingOpen:()=>vp,swipe:()=>Sp,trackPress:()=>tp,triggerFocus:()=>Kf,triggerHover:()=>Xf,triggerPress:()=>Gf,wheel:()=>bp,windowResize:()=>Ep});var Uf="none",Gf="trigger-press",Xf="trigger-hover",Kf="trigger-focus",qf="outside-press",Zf="item-press",Qf="close-press",Jf="link-press",$f="clear-press",ep="chip-remove-press",tp="track-press",op="increment-press",np="decrement-press",rp="input-change",ip="input-clear",sp="input-blur",ap="input-paste",cp="input-press",lp="focus-out",dp="escape-key",up="close-watcher",fp="list-navigation",pp="keyboard",mp="pointer",gp="drag",bp="wheel",hp="scrub",wp="cancel-open",vp="sibling-open",_p="disabled",yp="missing",xp="initial",Rp="imperative-action",Sp="swipe",Ep="window-resize";function ee(e,t,o,n){let r=!1,i=!1,s=n??be;return{reason:e,event:t??new Event("base-ui"),cancel(){r=!0},allowPropagation(){i=!0},get isCanceled(){return r},get isPropagationAllowed(){return i},trigger:o,...s}}var Cr=h(z(),1);var ba=h(z(),1),Tp=[];function co(e){ba.useEffect(e,Tp)}var _n=null,ah=globalThis.requestAnimationFrame,Pr=class{callbacks=[];callbacksCount=0;nextId=1;startId=1;isScheduled=!1;tick=t=>{this.isScheduled=!1;let o=this.callbacks,n=this.callbacksCount;if(this.callbacks=[],this.callbacksCount=0,this.startId=this.nextId,n>0)for(let r=0;r=this.callbacks.length||(this.callbacks[o]=null,this.callbacksCount-=1)}},yn=new Pr,ft=class e{static create(){return new e}static request(t){return yn.request(t)}static cancel(t){return yn.cancel(t)}currentId=_n;request(t){this.cancel(),this.currentId=yn.request(()=>{this.currentId=_n,t()})}cancel=()=>{this.currentId!==_n&&(yn.cancel(this.currentId),this.currentId=_n)};disposeEffect=()=>this.cancel};function lo(){let e=Se(ft.create).current;return co(e.disposeEffect),e}function ha(e,t=!1,o=!1){let[n,r]=Cr.useState(e&&t?"idle":void 0),[i,s]=Cr.useState(e);return e&&!i&&(s(!0),r("starting")),!e&&i&&n!=="ending"&&!o&&r("ending"),!e&&!i&&n==="ending"&&r(void 0),D(()=>{if(!e&&i&&n!=="ending"&&o){let a=ft.request(()=>{r("ending")});return()=>{ft.cancel(a)}}},[e,i,n,o]),D(()=>{if(!e||t)return;let a=ft.request(()=>{r(void 0)});return()=>{ft.cancel(a)}},[t,e]),D(()=>{if(!e||!t)return;e&&i&&n!=="idle"&&r("starting");let a=ft.request(()=>{r("idle")});return()=>{ft.cancel(a)}},[t,e,i,n]),{mounted:i,setMounted:s,transitionStatus:n}}var Yt=(function(e){return e.startingStyle="data-starting-style",e.endingStyle="data-ending-style",e})({}),kp={[Yt.startingStyle]:""},Pp={[Yt.endingStyle]:""},wa={transitionStatus(e){return e==="starting"?kp:e==="ending"?Pp:null}};var po=h(z(),1);function xn(){return typeof window<"u"}function Gt(e){return Rn(e)?(e.nodeName||"").toLowerCase():"#document"}function ge(e){var t;return(e==null||(t=e.ownerDocument)==null?void 0:t.defaultView)||window}function ot(e){var t;return(t=(Rn(e)?e.ownerDocument:e.document)||window.document)==null?void 0:t.documentElement}function Rn(e){return xn()?e instanceof Node||e instanceof ge(e).Node:!1}function V(e){return xn()?e instanceof Element||e instanceof ge(e).Element:!1}function we(e){return xn()?e instanceof HTMLElement||e instanceof ge(e).HTMLElement:!1}function uo(e){return!xn()||typeof ShadowRoot>"u"?!1:e instanceof ShadowRoot||e instanceof ge(e).ShadowRoot}function fo(e){let{overflow:t,overflowX:o,overflowY:n,display:r}=Ae(e);return/auto|scroll|overlay|hidden|clip/.test(t+n+o)&&r!=="inline"&&r!=="contents"}function va(e){return/^(table|td|th)$/.test(Gt(e))}function Do(e){try{if(e.matches(":popover-open"))return!0}catch{}try{return e.matches(":modal")}catch{return!1}}var Cp=/transform|translate|scale|rotate|perspective|filter/,Ap=/paint|layout|strict|content/,Ut=e=>!!e&&e!=="none",Ar;function Sn(e){let t=V(e)?Ae(e):e;return Ut(t.transform)||Ut(t.translate)||Ut(t.scale)||Ut(t.rotate)||Ut(t.perspective)||!En()&&(Ut(t.backdropFilter)||Ut(t.filter))||Cp.test(t.willChange||"")||Ap.test(t.contain||"")}function _a(e){let t=tt(e);for(;we(t)&&!nt(t);){if(Sn(t))return t;if(Do(t))return null;t=tt(t)}return null}function En(){return Ar==null&&(Ar=typeof CSS<"u"&&CSS.supports&&CSS.supports("-webkit-backdrop-filter","none")),Ar}function nt(e){return/^(html|body|#document)$/.test(Gt(e))}function Ae(e){return ge(e).getComputedStyle(e)}function jo(e){return V(e)?{scrollLeft:e.scrollLeft,scrollTop:e.scrollTop}:{scrollLeft:e.scrollX,scrollTop:e.scrollY}}function tt(e){if(Gt(e)==="html")return e;let t=e.assignedSlot||e.parentNode||uo(e)&&e.host||ot(e);return uo(t)?t.host:t}function ya(e){let t=tt(e);return nt(t)?e.ownerDocument?e.ownerDocument.body:e.body:we(t)&&fo(t)?t:ya(t)}function It(e,t,o){var n;t===void 0&&(t=[]),o===void 0&&(o=!0);let r=ya(e),i=r===((n=e.ownerDocument)==null?void 0:n.body),s=ge(r);if(i){let a=Tn(s);return t.concat(s,s.visualViewport||[],fo(r)?r:[],a&&o?It(a):[])}else return t.concat(r,It(r,[],o))}function Tn(e){return e.parent&&Object.getPrototypeOf(e.parent)?e.frameElement:null}var kn=h(z(),1),Op=kn.createContext(void 0);function xa(e=!1){let t=kn.useContext(Op);if(t===void 0&&!e)throw new Error(Pe(16));return t}var Ra=h(z(),1);function Sa(e){let{focusableWhenDisabled:t,disabled:o,composite:n=!1,tabIndex:r=0,isNativeButton:i}=e,s=n&&t!==!1,a=n&&t===!1;return{props:Ra.useMemo(()=>{let c={onKeyDown(l){o&&t&&l.key!=="Tab"&&l.preventDefault()}};return n||(c.tabIndex=r,!i&&o&&(c.tabIndex=t?r:-1)),(i&&(t||s)||!i&&o)&&(c["aria-disabled"]=o),i&&(!t||a)&&(c.disabled=o),c},[n,o,t,s,a,i,r])}}function Ea(e={}){let{disabled:t=!1,focusableWhenDisabled:o,tabIndex:n=0,native:r=!0,composite:i}=e,s=po.useRef(null),a=xa(!0),d=i??a!==void 0,{props:c}=Sa({focusableWhenDisabled:o,disabled:t,composite:d,tabIndex:n,isNativeButton:r}),l=po.useCallback(()=>{let m=s.current;Or(m)&&d&&t&&c.disabled===void 0&&m.disabled&&(m.disabled=!1)},[t,c.disabled,d]);D(l,[l]);let f=po.useCallback((m={})=>{let{onClick:u,onMouseDown:g,onKeyUp:v,onKeyDown:_,onPointerDown:w,...y}=m;return ye({onClick(b){if(t){b.preventDefault();return}u?.(b)},onMouseDown(b){t||g?.(b)},onKeyDown(b){if(t||(zo(b),_?.(b),b.baseUIHandlerPrevented))return;let S=b.target===b.currentTarget,x=b.currentTarget,E=Or(x),T=!r&&Np(x),k=S&&(r?E:!T),C=b.key==="Enter",j=b.key===" ",A=x.getAttribute("role"),L=A?.startsWith("menuitem")||A==="option"||A==="gridcell";if(S&&d&&j){if(b.defaultPrevented&&L)return;b.preventDefault(),T||r&&E?(x.click(),b.preventBaseUIHandler()):k&&(u?.(b),b.preventBaseUIHandler());return}k&&(!r&&(j||C)&&b.preventDefault(),!r&&C&&u?.(b))},onKeyUp(b){if(!t){if(zo(b),v?.(b),b.target===b.currentTarget&&r&&d&&Or(b.currentTarget)&&b.key===" "){b.preventDefault();return}b.baseUIHandlerPrevented||b.target===b.currentTarget&&!r&&!d&&b.key===" "&&u?.(b)}},onPointerDown(b){if(t){b.preventDefault();return}w?.(b)}},r?{type:"button"}:{role:"button"},c,y)},[t,c,d,r]),p=Y(m=>{s.current=m,l()});return{getButtonProps:f,buttonRef:p}}function Or(e){return we(e)&&e.tagName==="BUTTON"}function Np(e){return!!(e?.tagName==="A"&&e?.href)}function re(e,t,o,n){return e.addEventListener(t,o,n),()=>{e.removeEventListener(t,o,n)}}function ze(e){let t=Se(Lp,e).current;return t.next=e,D(t.effect),t}function Lp(e){let t={current:e,next:e,effect:()=>{t.current=t.next}};return t}function xe(e){return e?.ownerDocument||document}var Ca=h(z(),1);var Pa=h(Mt(),1);function ka(e){return e==null?e:"current"in e?e.current:e}function mo(e,t=!1,o=!0){let n=lo();return Y((r,i=null)=>{n.cancel();let s=ka(e);if(s==null)return;let a=s,d=()=>{Pa.flushSync(r)};if(typeof a.getAnimations!="function"||globalThis.BASE_UI_ANIMATIONS_DISABLED){r();return}function c(){Promise.all(a.getAnimations().map(l=>l.finished)).then(()=>{i?.aborted||d()}).catch(()=>{if(o){i?.aborted||d();return}let l=a.getAnimations();!i?.aborted&&l.length>0&&l.some(f=>f.pending||f.playState!=="finished")&&c()})}if(t){let l=Yt.startingStyle;if(!a.hasAttribute(l)){n.request(c);return}let f=new MutationObserver(()=>{a.hasAttribute(l)||(f.disconnect(),c())});f.observe(a,{attributes:!0,attributeFilter:[l]}),i?.addEventListener("abort",()=>f.disconnect(),{once:!0});return}n.request(c)})}function Pn(e){let{enabled:t=!0,open:o,ref:n,onComplete:r}=e,i=Y(r),s=mo(n,o,!1);Ca.useEffect(()=>{if(!t)return;let a=new AbortController;return s(i,a.signal),()=>{a.abort()}},[t,o,i,s])}var Aa=h(z(),1);function Oa(e){let t=Aa.useRef(!0);t.current&&(t.current=!1,e())}var xt={};At(xt,{engine:()=>Br,env:()=>zr,os:()=>Ir,screenReader:()=>Hr});var Ir={};At(Ir,{android:()=>Ia,apple:()=>Lr,ios:()=>Nr,linux:()=>zp,mac:()=>Ma,windows:()=>Hp});function Ip(){return typeof navigator>"u"?{userAgent:"",platform:"",maxTouchPoints:0}:{userAgent:navigator.userAgent,platform:navigator.platform??"",maxTouchPoints:navigator.maxTouchPoints??0}}var{userAgent:Mp,platform:Bp,maxTouchPoints:Na}=Ip(),Xt=Mp.toLowerCase(),Kt=Bp.toLowerCase();var Nr=/^i(os$|p)/.test(Kt)||Kt==="macintel"&&Na>1,La="android",Ia=Kt===La||Xt.includes(La),Ma=!Nr&&Kt.startsWith("mac"),Hp=Kt.startsWith("win"),zp=!Ia&&/^(linux|chrome os)/.test(Kt),Lr=Ma||Nr;var Br={};At(Br,{blink:()=>jp,gecko:()=>Dp,webkit:()=>Mr});var Mr=typeof CSS<"u"&&!!CSS.supports?.("-webkit-backdrop-filter:none"),Dp=!Mr&&Xt.includes("firefox"),jp=!Mr&&Xt.includes("chrom");var Hr={};At(Hr,{voiceOver:()=>Fp});var Fp=Lr;var zr={};At(zr,{jsdom:()=>Vp});var Vp=/jsdom|happydom/.test(Xt);var Fo=0,Ye=class e{static create(){return new e}currentId=Fo;start(t,o){this.clear(),this.currentId=setTimeout(()=>{this.currentId=Fo,o()},t)}isStarted(){return this.currentId!==Fo}clear=()=>{this.currentId!==Fo&&(clearTimeout(this.currentId),this.currentId=Fo)};disposeEffect=()=>this.clear};function rt(){let e=Se(Ye.create).current;return co(e.disposeEffect),e}var Oe=h(z(),1);function Ba(e){return"nativeEvent"in e}function Rt(e,t){let o=["mouse","pen"];return t||o.push("",void 0),o.includes(e)}function Ha(e){let t=e.type;return t==="click"||t==="mousedown"||t==="keydown"||t==="keyup"}var Dr="data-base-ui-focusable";var jr="input:not([type='hidden']):not([disabled]),[contenteditable]:not([contenteditable='false']),textarea:not([disabled])";function Cn(e){let t=e.activeElement;for(;t?.shadowRoot?.activeElement!=null;)t=t.shadowRoot.activeElement;return t}function ie(e,t){if(!e||!t)return!1;let o=t.getRootNode?.();if(e.contains(t))return!0;if(o&&uo(o)){let n=t;for(;n;){if(e===n)return!0;n=n.parentNode||n.host}}return!1}function Me(e){return"composedPath"in e?e.composedPath()[0]:e.target}function Bt(e,t){if(!V(e))return!1;let o=e;if(t.hasElement(o))return!o.hasAttribute("data-trigger-disabled");for(let[,n]of t.entries())if(ie(n,o))return!n.hasAttribute("data-trigger-disabled");return!1}function An(e,t){if(t==null)return!1;if("composedPath"in e)return e.composedPath().includes(t);let o=e;return o.target!=null&&t.contains(o.target)}function za(e){return e.matches("html,body")}function Da(e){return we(e)&&e.matches(jr)}function Fr(e){return e?.closest(`button,a[href],[role="button"],select,[tabindex]:not([tabindex="-1"]),${jr}`)!=null}function ja(e){if(!e||xt.env.jsdom)return!0;try{return e.matches(":focus-visible")}catch{return!0}}function Wp(e,t){return t!=null&&!Rt(t)?0:typeof e=="function"?e():e}function St(e,t,o){let n=Wp(e,o);return typeof n=="number"?n:n?.[t]}function Vr(e){return typeof e=="function"?e():e}function On(e,t){return t||e==="click"||e==="mousedown"}function Fa(e){return e?.includes("mouse")&&e!=="mousedown"}var Va=h(Q(),1),Wa=Oe.createContext({hasProvider:!1,timeoutMs:0,delayRef:{current:0},initialDelayRef:{current:0},timeout:new Ye,currentIdRef:{current:null},currentContextRef:{current:null}});function Yp(e,t){e.current=t.current}function Wr(e){let{children:t,delay:o,timeoutMs:n=0}=e,r=Oe.useRef(o),i=Oe.useRef(o),s=Oe.useRef(null),a=Oe.useRef(null),d=rt();return D(()=>{if(i.current=o,!s.current){r.current=o;return}r.current={open:St(r.current,"open"),close:St(o,"close")}},[o,s,r,i]),(0,Va.jsx)(Wa.Provider,{value:Oe.useMemo(()=>({hasProvider:!0,delayRef:r,initialDelayRef:i,currentIdRef:s,timeoutMs:n,currentContextRef:a,timeout:d}),[n,d]),children:t})}function Yr(e,t={open:!1}){let{open:o}=t,n="rootStore"in e?e.rootStore:e,r=n.useState("floatingId"),i=Oe.useContext(Wa),{currentIdRef:s,delayRef:a,timeoutMs:d,initialDelayRef:c,currentContextRef:l,hasProvider:f,timeout:p}=i,[m,u]=Oe.useState(!1),g=Oe.useRef(o),v=Oe.useRef(!1);return D(()=>{g.current=o},[o]),D(()=>()=>{v.current=!0},[]),D(()=>{function _(){v.current||u(!1),l.current?.setIsInstantPhase(!1),s.current=null,l.current=null,a.current=c.current,p.clear()}if(s.current&&!o&&s.current===r){if(u(!1),d){let w=r;return p.start(d,()=>{n.select("open")||s.current&&s.current!==w||_()}),()=>{(g.current||s.current!==w)&&p.clear()}}_()}},[o,r,s,a,d,c,l,p,n]),D(()=>{if(!o)return;let _=l.current,w=s.current;p.clear(),l.current={onOpenChange:n.setOpen,setIsInstantPhase:u},s.current=r,a.current={open:0,close:St(c.current,"close")},w!==null&&w!==r?(u(!0),_?.setIsInstantPhase(!0),_?.onOpenChange(!1,ee(U.none))):(u(!1),_?.setIsInstantPhase(!1))},[o,r,n,s,a,c,l,p]),D(()=>()=>{if(s.current===r){if(l.current=null,!g.current)return;s.current=null,Yp(a,c),p.clear()}},[l,s,a,r,c,p]),Oe.useMemo(()=>({hasProvider:f,delayRef:a,isInstantPhase:m}),[f,a,m])}function it(...e){return()=>{for(let t=0;t({x:e,y:e}),Up={left:"right",right:"left",bottom:"top",top:"bottom"};function Yo(e,t,o){return Be(e,Ht(t,o))}function at(e,t){return typeof e=="function"?e(t):e}function Ee(e){return e.split("-")[0]}function ct(e){return e.split("-")[1]}function Ln(e){return e==="x"?"y":"x"}function Uo(e){return e==="y"?"height":"width"}function De(e){let t=e[0];return t==="t"||t==="b"?"y":"x"}function Go(e){return Ln(De(e))}function Xa(e,t,o){o===void 0&&(o=!1);let n=ct(e),r=Go(e),i=Uo(r),s=r==="x"?n===(o?"end":"start")?"right":"left":n==="start"?"bottom":"top";return t.reference[i]>t.floating[i]&&(s=Vo(s)),[s,Vo(s)]}function Ka(e){let t=Vo(e);return[Nn(e),t,Nn(t)]}function Nn(e){return e.includes("start")?e.replace("start","end"):e.replace("end","start")}var Ya=["left","right"],Ua=["right","left"],Gp=["top","bottom"],Xp=["bottom","top"];function Kp(e,t,o){switch(e){case"top":case"bottom":return o?t?Ua:Ya:t?Ya:Ua;case"left":case"right":return t?Gp:Xp;default:return[]}}function qa(e,t,o,n){let r=ct(e),i=Kp(Ee(e),o==="start",n);return r&&(i=i.map(s=>s+"-"+r),t&&(i=i.concat(i.map(Nn)))),i}function Vo(e){let t=Ee(e);return Up[t]+e.slice(t.length)}function qp(e){return{top:0,right:0,bottom:0,left:0,...e}}function In(e){return typeof e!="number"?qp(e):{top:e,right:e,bottom:e,left:e}}function qt(e){let{x:t,y:o,width:n,height:r}=e;return{width:n,height:r,top:o,left:t,right:t+n,bottom:o+r,x:t,y:o}}function Et(e,t,o=!0){return e.filter(r=>r.parentId===t).flatMap(r=>[...!o||r.context?.open?[r]:[],...Et(e,r.id,o)])}function go(e){return`data-base-ui-${e}`}var Ue=h(z(),1),Ja=h(Mt(),1);var Za={style:{transition:"none"}};var Zp="data-base-ui-swipe-ignore",Qp="data-swipe-ignore",ww=`[${Zp}]`,vw=`[${Qp}]`;var Qa={fallbackAxisSide:"end"};var $a=h(Q(),1),Jp=Ue.createContext(null),$p=()=>Ue.useContext(Jp),em=go("portal");function Ur(e={}){let{ref:t,container:o,componentProps:n=be,elementProps:r}=e,i=Lt(),a=$p()?.portalNode,[d,c]=Ue.useState(null),[l,f]=Ue.useState(null),p=Y(v=>{v!==null&&f(v)}),m=Ue.useRef(null);D(()=>{if(o===null){m.current&&(m.current=null,f(null),c(null));return}if(i==null)return;let v=(o&&(Rn(o)?o:o.current))??a??document.body;if(v==null){m.current&&(m.current=null,f(null),c(null));return}m.current!==v&&(m.current=v,f(null),c(v))},[o,a,i]);let u=Ce("div",n,{ref:[t,p],props:[{id:i,[em]:""},r]});return{portalNode:l,portalSubtree:d&&u?Ja.createPortal(u,d):null}}var Zt=h(z(),1);function ec(){let e=new Map;return{emit(t,o){e.get(t)?.forEach(n=>n(o))},on(t,o){e.has(t)||e.set(t,new Set),e.get(t).add(o)},off(t,o){e.get(t)?.delete(o)}}}var tm=h(Q(),1),om=Zt.createContext(null),nm=Zt.createContext(null),bo=()=>Zt.useContext(om)?.id||null,Dt=e=>{let t=Zt.useContext(nm);return e??t};var je=h(z(),1);function rm(e,t){let o=null,n=null,r=!1;return{contextElement:e||void 0,getBoundingClientRect(){let i=e?.getBoundingClientRect()||{width:0,height:0,x:0,y:0},s=t.axis==="x"||t.axis==="both",a=t.axis==="y"||t.axis==="both",d=["mouseenter","mousemove"].includes(t.dataRef.current.openEvent?.type||"")&&t.pointerType!=="touch",c=i.width,l=i.height,f=i.x,p=i.y;return o==null&&t.x&&s&&(o=i.x-t.x),n==null&&t.y&&a&&(n=i.y-t.y),f-=o||0,p-=n||0,c=0,l=0,!r||d?(c=t.axis==="y"?i.width:0,l=t.axis==="x"?i.height:0,f=s&&t.x!=null?t.x:f,p=a&&t.y!=null?t.y:p):r&&!d&&(l=t.axis==="x"?i.height:l,c=t.axis==="y"?i.width:c),r=!0,{width:c,height:l,x:f,y:p,top:p,right:f+c,bottom:p+l,left:f}}}}function tc(e){return e!=null&&e.clientX!=null}function Gr(e,t={}){let{enabled:o=!0,axis:n="both"}=t,r="rootStore"in e?e.rootStore:e,i=r.useState("open"),s=r.useState("floatingElement"),a=r.useState("domReferenceElement"),d=r.context.dataRef,c=je.useRef(!1),l=je.useRef(null),[f,p]=je.useState(),[m,u]=je.useState([]),g=Y(b=>{r.set("positionReference",b)}),v=Y((b,S,x)=>{c.current||d.current.openEvent&&!tc(d.current.openEvent)||r.set("positionReference",rm(x??a,{x:b,y:S,axis:n,dataRef:d,pointerType:f}))}),_=Y(b=>{i?l.current||(v(b.clientX,b.clientY,b.currentTarget),u([])):v(b.clientX,b.clientY,b.currentTarget)}),w=Rt(f)?s:i;je.useEffect(()=>{if(!o){g(a);return}if(!w)return;function b(){l.current?.(),l.current=null}let S=ge(s);function x(E){let T=Me(E);ie(s,T)?b():v(E.clientX,E.clientY)}return!d.current.openEvent||tc(d.current.openEvent)?l.current=re(S,"mousemove",x):g(a),b},[w,o,s,d,a,r,v,g,m]),je.useEffect(()=>()=>{r.set("positionReference",null)},[r]),je.useEffect(()=>{o&&!s&&(c.current=!1)},[o,s]),je.useEffect(()=>{!o&&i&&(c.current=!0)},[o,i]);let y=je.useMemo(()=>{function b(S){p(S.pointerType)}return{onPointerDown:b,onPointerEnter:b,onMouseMove:_,onMouseEnter:_}},[_]);return je.useMemo(()=>o?{reference:y,trigger:y}:{},[o,y])}var Fe=h(z(),1);function im(){return!1}function sm(e){return{escapeKey:typeof e=="boolean"?e:e?.escapeKey??!1,outsidePress:typeof e=="boolean"?e:e?.outsidePress??!0}}function Xr(e,t={}){let{enabled:o=!0,escapeKey:n=!0,outsidePress:r=!0,outsidePressEvent:i="sloppy",referencePress:s=im,bubbles:a,externalTree:d}=t,c="rootStore"in e?e.rootStore:e,l=c.useState("open"),f=c.useState("floatingElement"),{dataRef:p}=c.context,m=Dt(d),u=Y(typeof r=="function"?r:()=>!1),g=typeof r=="function"?u:r,v=g!==!1,_=Y(()=>i),{escapeKey:w,outsidePress:y}=sm(a),b=Fe.useRef(!1),S=Fe.useRef(!1),x=Fe.useRef(!1),E=Fe.useRef(!1),T=Fe.useRef(""),k=Fe.useRef(null),C=rt(),j=rt(),A=Y(()=>{j.clear(),p.current.insideReactTree=!1}),L=Y(W=>{let oe=p.current.floatingContext?.nodeId;return(m?Et(m.nodesRef.current,oe):[]).some(se=>se.context?.open&&!se.context.dataRef.current[W])}),I=Y(W=>An(W,c.select("floatingElement"))||An(W,c.select("domReferenceElement"))),R=Y(W=>{s()&&c.setOpen(!1,ee(U.triggerPress,W.nativeEvent))}),N=Y(W=>{if(!l||!o||!n||W.key!=="Escape"||E.current||!w&&L("__escapeKeyBubbles"))return;let oe=Ba(W)?W.nativeEvent:W,te=ee(U.escapeKey,oe);c.setOpen(!1,te),te.isCanceled||W.preventDefault(),!w&&!te.isPropagationAllowed&&W.stopPropagation()}),H=Y(()=>{p.current.insideReactTree=!0,j.start(0,A)}),P=Y(W=>{if(!l||!o||W.button!==0)return;let oe=Me(W.nativeEvent);ie(c.select("floatingElement"),oe)&&(b.current||(b.current=!0,S.current=!1))}),O=Y(W=>{!l||!o||(W.defaultPrevented||W.nativeEvent.defaultPrevented)&&b.current&&(S.current=!0)});Fe.useEffect(()=>{if(!l||!o)return;p.current.__escapeKeyBubbles=w,p.current.__outsidePressBubbles=y;let W=new Ye,oe=new Ye;function te(){W.clear(),E.current=!0}function se(){W.start(xt.engine.webkit?5:0,()=>{E.current=!1})}function G(){x.current=!0,oe.start(0,()=>{x.current=!1})}function K(){b.current=!1,S.current=!1}function J(){let B=T.current,F=B==="pen"||!B?"mouse":B,he=_(),ke=typeof he=="function"?he():he;return typeof ke=="string"?ke:ke[F]}function ne(B){let F=J();return F==="intentional"&&B.type!=="click"||F==="sloppy"&&B.type==="click"}function me(B){let F=p.current.floatingContext?.nodeId,he=m&&Et(m.nodesRef.current,F).some(ke=>An(B,ke.context?.elements.floating));return I(B)||he}function le(B){if(ne(B)){B.type!=="click"&&!I(B)&&(oe.clear(),x.current=!1),A();return}if(p.current.insideReactTree){A();return}let F=Me(B),he=`[${go("inert")}]`,ke=V(F)?F.getRootNode():null,kt=Array.from((uo(ke)?ke:xe(c.select("floatingElement"))).querySelectorAll(he)),Lo=c.context.triggerElements;if(F&&(Lo.hasElement(F)||Lo.hasMatchingElement(We=>ie(We,F))))return;let _t=V(F)?F:null;for(;_t&&!nt(_t);){let We=tt(_t);if(nt(We)||!V(We))break;_t=We}if(!(kt.length&&V(F)&&!za(F)&&!ie(F,c.select("floatingElement"))&&kt.every(We=>!ie(_t,We)))){if(we(F)&&!("touches"in B)){let We=nt(F),Pt=Ae(F),Ct=/auto|scroll/,un=We||Ct.test(Pt.overflowX),fn=We||Ct.test(Pt.overflowY),pn=un&&F.clientWidth>0&&F.scrollWidth>F.clientWidth,mn=fn&&F.clientHeight>0&&F.scrollHeight>F.clientHeight,gn=Pt.direction==="rtl",ae=mn&&(gn?B.offsetX<=F.offsetWidth-F.clientWidth:B.offsetX>F.clientWidth),Ie=pn&&B.offsetY>F.clientHeight;if(ae||Ie)return}if(!me(B)){if(J()==="intentional"&&x.current){oe.clear(),x.current=!1;return}typeof g=="function"&&!g(B)||L("__outsidePressBubbles")||(c.setOpen(!1,ee(U.outsidePress,B)),A())}}}function X(B){J()!=="sloppy"||B.pointerType==="touch"||!c.select("open")||!o||I(B)||le(B)}function pe(B){if(J()!=="sloppy"||!c.select("open")||!o||I(B))return;let F=B.touches[0];F&&(k.current={startTime:Date.now(),startX:F.clientX,startY:F.clientY,dismissOnTouchEnd:!1,dismissOnMouseDown:!0},C.start(1e3,()=>{k.current&&(k.current.dismissOnTouchEnd=!1,k.current.dismissOnMouseDown=!1)}))}function ue(B,F){let he=Me(B);if(!he)return;let ke=re(he,B.type,()=>{F(B),ke()})}function vt(B){T.current="touch",ue(B,pe)}function Te(B){C.clear(),B.type==="pointerdown"&&(T.current=B.pointerType),!(B.type==="mousedown"&&k.current&&!k.current.dismissOnMouseDown)&&ue(B,F=>{F.type==="pointerdown"?X(F):le(F)})}function Ve(B){if(!b.current)return;let F=S.current;if(K(),J()==="intentional"){if(B.type==="pointercancel"){F&&G();return}if(!me(B)){if(F){G();return}typeof g=="function"&&!g(B)||(oe.clear(),x.current=!0,A())}}}function Ke(B){if(J()!=="sloppy"||!k.current||I(B))return;let F=B.touches[0];if(!F)return;let he=Math.abs(F.clientX-k.current.startX),ke=Math.abs(F.clientY-k.current.startY),kt=Math.sqrt(he*he+ke*ke);kt>5&&(k.current.dismissOnTouchEnd=!0),kt>10&&(le(B),C.clear(),k.current=null)}function He(B){ue(B,Ke)}function no(B){J()!=="sloppy"||!k.current||I(B)||(k.current.dismissOnTouchEnd&&le(B),C.clear(),k.current=null)}function dn(B){ue(B,no)}let _e=xe(f),ro=it(n&&it(re(_e,"keydown",N),re(_e,"compositionstart",te),re(_e,"compositionend",se)),v&&it(re(_e,"click",Te,!0),re(_e,"pointerdown",Te,!0),re(_e,"pointerup",Ve,!0),re(_e,"pointercancel",Ve,!0),re(_e,"mousedown",Te,!0),re(_e,"mouseup",Ve,!0),re(_e,"touchstart",vt,!0),re(_e,"touchmove",He,!0),re(_e,"touchend",dn,!0)));return()=>{ro(),W.clear(),oe.clear(),K(),x.current=!1}},[p,f,n,v,g,l,o,w,y,N,A,_,L,I,m,c,C]),Fe.useEffect(A,[g,A]);let M=Fe.useMemo(()=>({onKeyDown:N,onPointerDown:R,onClick:R}),[N,R]),Z=Fe.useMemo(()=>({onKeyDown:N,onPointerDown:O,onMouseDown:O,onClickCapture:H,onMouseDownCapture(W){H(),P(W)},onPointerDownCapture(W){H(),P(W)},onMouseUpCapture:H,onTouchEndCapture:H,onTouchMoveCapture:H}),[N,H,P,O]);return Fe.useMemo(()=>o?{reference:M,floating:Z,trigger:M}:{},[o,M,Z])}var Ne=h(z(),1);function oc(e,t,o){let{reference:n,floating:r}=e,i=De(t),s=Go(t),a=Uo(s),d=Ee(t),c=i==="y",l=n.x+n.width/2-r.width/2,f=n.y+n.height/2-r.height/2,p=n[a]/2-r[a]/2,m;switch(d){case"top":m={x:l,y:n.y-r.height};break;case"bottom":m={x:l,y:n.y+n.height};break;case"right":m={x:n.x+n.width,y:f};break;case"left":m={x:n.x-r.width,y:f};break;default:m={x:n.x,y:n.y}}switch(ct(t)){case"start":m[s]-=p*(o&&c?-1:1);break;case"end":m[s]+=p*(o&&c?-1:1);break}return m}async function ic(e,t){var o;t===void 0&&(t={});let{x:n,y:r,platform:i,rects:s,elements:a,strategy:d}=e,{boundary:c="clippingAncestors",rootBoundary:l="viewport",elementContext:f="floating",altBoundary:p=!1,padding:m=0}=at(t,e),u=In(m),v=a[p?f==="floating"?"reference":"floating":f],_=qt(await i.getClippingRect({element:(o=await(i.isElement==null?void 0:i.isElement(v)))==null||o?v:v.contextElement||await(i.getDocumentElement==null?void 0:i.getDocumentElement(a.floating)),boundary:c,rootBoundary:l,strategy:d})),w=f==="floating"?{x:n,y:r,width:s.floating.width,height:s.floating.height}:s.reference,y=await(i.getOffsetParent==null?void 0:i.getOffsetParent(a.floating)),b=await(i.isElement==null?void 0:i.isElement(y))?await(i.getScale==null?void 0:i.getScale(y))||{x:1,y:1}:{x:1,y:1},S=qt(i.convertOffsetParentRelativeRectToViewportRelativeRect?await i.convertOffsetParentRelativeRectToViewportRelativeRect({elements:a,rect:w,offsetParent:y,strategy:d}):w);return{top:(_.top-S.top+u.top)/b.y,bottom:(S.bottom-_.bottom+u.bottom)/b.y,left:(_.left-S.left+u.left)/b.x,right:(S.right-_.right+u.right)/b.x}}var am=50,sc=async(e,t,o)=>{let{placement:n="bottom",strategy:r="absolute",middleware:i=[],platform:s}=o,a=s.detectOverflow?s:{...s,detectOverflow:ic},d=await(s.isRTL==null?void 0:s.isRTL(t)),c=await s.getElementRects({reference:e,floating:t,strategy:r}),{x:l,y:f}=oc(c,n,d),p=n,m=0,u={};for(let g=0;gI<=0)){var j,A;let I=(((j=i.flip)==null?void 0:j.index)||0)+1,R=E[I];if(R&&(!(f==="alignment"?w!==De(R):!1)||C.every(P=>De(P.placement)===w?P.overflows[0]>0:!0)))return{data:{index:I,overflows:C},reset:{placement:R}};let N=(A=C.filter(H=>H.overflows[0]<=0).sort((H,P)=>H.overflows[1]-P.overflows[1])[0])==null?void 0:A.placement;if(!N)switch(m){case"bestFit":{var L;let H=(L=C.filter(P=>{if(x){let O=De(P.placement);return O===w||O==="y"}return!0}).map(P=>[P.placement,P.overflows.filter(O=>O>0).reduce((O,M)=>O+M,0)]).sort((P,O)=>P[1]-O[1])[0])==null?void 0:L[0];H&&(N=H);break}case"initialPlacement":N=a;break}if(r!==N)return{reset:{placement:N}}}return{}}}};function nc(e,t){return{top:e.top-t.height,right:e.right-t.width,bottom:e.bottom-t.height,left:e.left-t.width}}function rc(e){return Ga.some(t=>e[t]>=0)}var cc=function(e){return e===void 0&&(e={}),{name:"hide",options:e,async fn(t){let{rects:o,platform:n}=t,{strategy:r="referenceHidden",...i}=at(e,t);switch(r){case"referenceHidden":{let s=await n.detectOverflow(t,{...i,elementContext:"reference"}),a=nc(s,o.reference);return{data:{referenceHiddenOffsets:a,referenceHidden:rc(a)}}}case"escaped":{let s=await n.detectOverflow(t,{...i,altBoundary:!0}),a=nc(s,o.floating);return{data:{escapedOffsets:a,escaped:rc(a)}}}default:return{}}}}};var lc=new Set(["left","top"]);async function cm(e,t){let{placement:o,platform:n,elements:r}=e,i=await(n.isRTL==null?void 0:n.isRTL(r.floating)),s=Ee(o),a=ct(o),d=De(o)==="y",c=lc.has(s)?-1:1,l=i&&d?-1:1,f=at(t,e),{mainAxis:p,crossAxis:m,alignmentAxis:u}=typeof f=="number"?{mainAxis:f,crossAxis:0,alignmentAxis:null}:{mainAxis:f.mainAxis||0,crossAxis:f.crossAxis||0,alignmentAxis:f.alignmentAxis};return a&&typeof u=="number"&&(m=a==="end"?u*-1:u),d?{x:m*l,y:p*c}:{x:p*c,y:m*l}}var dc=function(e){return e===void 0&&(e=0),{name:"offset",options:e,async fn(t){var o,n;let{x:r,y:i,placement:s,middlewareData:a}=t,d=await cm(t,e);return s===((o=a.offset)==null?void 0:o.placement)&&(n=a.arrow)!=null&&n.alignmentOffset?{}:{x:r+d.x,y:i+d.y,data:{...d,placement:s}}}}},uc=function(e){return e===void 0&&(e={}),{name:"shift",options:e,async fn(t){let{x:o,y:n,placement:r,platform:i}=t,{mainAxis:s=!0,crossAxis:a=!1,limiter:d={fn:_=>{let{x:w,y}=_;return{x:w,y}}},...c}=at(e,t),l={x:o,y:n},f=await i.detectOverflow(t,c),p=De(Ee(r)),m=Ln(p),u=l[m],g=l[p];if(s){let _=m==="y"?"top":"left",w=m==="y"?"bottom":"right",y=u+f[_],b=u-f[w];u=Yo(y,u,b)}if(a){let _=p==="y"?"top":"left",w=p==="y"?"bottom":"right",y=g+f[_],b=g-f[w];g=Yo(y,g,b)}let v=d.fn({...t,[m]:u,[p]:g});return{...v,data:{x:v.x-o,y:v.y-n,enabled:{[m]:s,[p]:a}}}}}},fc=function(e){return e===void 0&&(e={}),{options:e,fn(t){let{x:o,y:n,placement:r,rects:i,middlewareData:s}=t,{offset:a=0,mainAxis:d=!0,crossAxis:c=!0}=at(e,t),l={x:o,y:n},f=De(r),p=Ln(f),m=l[p],u=l[f],g=at(a,t),v=typeof g=="number"?{mainAxis:g,crossAxis:0}:{mainAxis:0,crossAxis:0,...g};if(d){let y=p==="y"?"height":"width",b=i.reference[p]-i.floating[y]+v.mainAxis,S=i.reference[p]+i.reference[y]-v.mainAxis;mS&&(m=S)}if(c){var _,w;let y=p==="y"?"width":"height",b=lc.has(Ee(r)),S=i.reference[f]-i.floating[y]+(b&&((_=s.offset)==null?void 0:_[f])||0)+(b?0:v.crossAxis),x=i.reference[f]+i.reference[y]+(b?0:((w=s.offset)==null?void 0:w[f])||0)-(b?v.crossAxis:0);ux&&(u=x)}return{[p]:m,[f]:u}}}},pc=function(e){return e===void 0&&(e={}),{name:"size",options:e,async fn(t){var o,n;let{placement:r,rects:i,platform:s,elements:a}=t,{apply:d=()=>{},...c}=at(e,t),l=await s.detectOverflow(t,c),f=Ee(r),p=ct(r),m=De(r)==="y",{width:u,height:g}=i.floating,v,_;f==="top"||f==="bottom"?(v=f,_=p===(await(s.isRTL==null?void 0:s.isRTL(a.floating))?"start":"end")?"left":"right"):(_=f,v=p==="end"?"top":"bottom");let w=g-l.top-l.bottom,y=u-l.left-l.right,b=Ht(g-l[v],w),S=Ht(u-l[_],y),x=!t.middlewareData.shift,E=b,T=S;if((o=t.middlewareData.shift)!=null&&o.enabled.x&&(T=y),(n=t.middlewareData.shift)!=null&&n.enabled.y&&(E=w),x&&!p){let C=Be(l.left,0),j=Be(l.right,0),A=Be(l.top,0),L=Be(l.bottom,0);m?T=u-2*(C!==0||j!==0?C+j:Be(l.left,l.right)):E=g-2*(A!==0||L!==0?A+L:Be(l.top,l.bottom))}await d({...t,availableWidth:T,availableHeight:E});let k=await s.getDimensions(a.floating);return u!==k.width||g!==k.height?{reset:{rects:!0}}:{}}}};function hc(e){let t=Ae(e),o=parseFloat(t.width)||0,n=parseFloat(t.height)||0,r=we(e),i=r?e.offsetWidth:o,s=r?e.offsetHeight:n,a=zt(o)!==i||zt(n)!==s;return a&&(o=i,n=s),{width:o,height:n,$:a}}function qr(e){return V(e)?e:e.contextElement}function ho(e){let t=qr(e);if(!we(t))return st(1);let o=t.getBoundingClientRect(),{width:n,height:r,$:i}=hc(t),s=(i?zt(o.width):o.width)/n,a=(i?zt(o.height):o.height)/r;return(!s||!Number.isFinite(s))&&(s=1),(!a||!Number.isFinite(a))&&(a=1),{x:s,y:a}}var lm=st(0);function wc(e){let t=ge(e);return!En()||!t.visualViewport?lm:{x:t.visualViewport.offsetLeft,y:t.visualViewport.offsetTop}}function dm(e,t,o){return t===void 0&&(t=!1),!o||t&&o!==ge(e)?!1:t}function Qt(e,t,o,n){t===void 0&&(t=!1),o===void 0&&(o=!1);let r=e.getBoundingClientRect(),i=qr(e),s=st(1);t&&(n?V(n)&&(s=ho(n)):s=ho(e));let a=dm(i,o,n)?wc(i):st(0),d=(r.left+a.x)/s.x,c=(r.top+a.y)/s.y,l=r.width/s.x,f=r.height/s.y;if(i){let p=ge(i),m=n&&V(n)?ge(n):n,u=p,g=Tn(u);for(;g&&n&&m!==u;){let v=ho(g),_=g.getBoundingClientRect(),w=Ae(g),y=_.left+(g.clientLeft+parseFloat(w.paddingLeft))*v.x,b=_.top+(g.clientTop+parseFloat(w.paddingTop))*v.y;d*=v.x,c*=v.y,l*=v.x,f*=v.y,d+=y,c+=b,u=ge(g),g=Tn(u)}}return qt({width:l,height:f,x:d,y:c})}function Mn(e,t){let o=jo(e).scrollLeft;return t?t.left+o:Qt(ot(e)).left+o}function vc(e,t){let o=e.getBoundingClientRect(),n=o.left+t.scrollLeft-Mn(e,o),r=o.top+t.scrollTop;return{x:n,y:r}}function um(e){let{elements:t,rect:o,offsetParent:n,strategy:r}=e,i=r==="fixed",s=ot(n),a=t?Do(t.floating):!1;if(n===s||a&&i)return o;let d={scrollLeft:0,scrollTop:0},c=st(1),l=st(0),f=we(n);if((f||!f&&!i)&&((Gt(n)!=="body"||fo(s))&&(d=jo(n)),f)){let m=Qt(n);c=ho(n),l.x=m.x+n.clientLeft,l.y=m.y+n.clientTop}let p=s&&!f&&!i?vc(s,d):st(0);return{width:o.width*c.x,height:o.height*c.y,x:o.x*c.x-d.scrollLeft*c.x+l.x+p.x,y:o.y*c.y-d.scrollTop*c.y+l.y+p.y}}function fm(e){return Array.from(e.getClientRects())}function pm(e){let t=ot(e),o=jo(e),n=e.ownerDocument.body,r=Be(t.scrollWidth,t.clientWidth,n.scrollWidth,n.clientWidth),i=Be(t.scrollHeight,t.clientHeight,n.scrollHeight,n.clientHeight),s=-o.scrollLeft+Mn(e),a=-o.scrollTop;return Ae(n).direction==="rtl"&&(s+=Be(t.clientWidth,n.clientWidth)-r),{width:r,height:i,x:s,y:a}}var mc=25;function mm(e,t){let o=ge(e),n=ot(e),r=o.visualViewport,i=n.clientWidth,s=n.clientHeight,a=0,d=0;if(r){i=r.width,s=r.height;let l=En();(!l||l&&t==="fixed")&&(a=r.offsetLeft,d=r.offsetTop)}let c=Mn(n);if(c<=0){let l=n.ownerDocument,f=l.body,p=getComputedStyle(f),m=l.compatMode==="CSS1Compat"&&parseFloat(p.marginLeft)+parseFloat(p.marginRight)||0,u=Math.abs(n.clientWidth-f.clientWidth-m);u<=mc&&(i-=u)}else c<=mc&&(i+=c);return{width:i,height:s,x:a,y:d}}function gm(e,t){let o=Qt(e,!0,t==="fixed"),n=o.top+e.clientTop,r=o.left+e.clientLeft,i=we(e)?ho(e):st(1),s=e.clientWidth*i.x,a=e.clientHeight*i.y,d=r*i.x,c=n*i.y;return{width:s,height:a,x:d,y:c}}function gc(e,t,o){let n;if(t==="viewport")n=mm(e,o);else if(t==="document")n=pm(ot(e));else if(V(t))n=gm(t,o);else{let r=wc(e);n={x:t.x-r.x,y:t.y-r.y,width:t.width,height:t.height}}return qt(n)}function _c(e,t){let o=tt(e);return o===t||!V(o)||nt(o)?!1:Ae(o).position==="fixed"||_c(o,t)}function bm(e,t){let o=t.get(e);if(o)return o;let n=It(e,[],!1).filter(a=>V(a)&&Gt(a)!=="body"),r=null,i=Ae(e).position==="fixed",s=i?tt(e):e;for(;V(s)&&!nt(s);){let a=Ae(s),d=Sn(s);!d&&a.position==="fixed"&&(r=null),(i?!d&&!r:!d&&a.position==="static"&&!!r&&(r.position==="absolute"||r.position==="fixed")||fo(s)&&!d&&_c(e,s))?n=n.filter(l=>l!==s):r=a,s=tt(s)}return t.set(e,n),n}function hm(e){let{element:t,boundary:o,rootBoundary:n,strategy:r}=e,s=[...o==="clippingAncestors"?Do(t)?[]:bm(t,this._c):[].concat(o),n],a=gc(t,s[0],r),d=a.top,c=a.right,l=a.bottom,f=a.left;for(let p=1;p{s(!1,1e-7)},1e3)}E===1&&!xc(c,e.getBoundingClientRect())&&s(),b=!1}try{o=new IntersectionObserver(S,{...y,root:r.ownerDocument})}catch{o=new IntersectionObserver(S,y)}o.observe(e)}return s(!0),i}function Xo(e,t,o,n){n===void 0&&(n={});let{ancestorScroll:r=!0,ancestorResize:i=!0,elementResize:s=typeof ResizeObserver=="function",layoutShift:a=typeof IntersectionObserver=="function",animationFrame:d=!1}=n,c=qr(e),l=r||i?[...c?It(c):[],...t?It(t):[]]:[];l.forEach(_=>{r&&_.addEventListener("scroll",o,{passive:!0}),i&&_.addEventListener("resize",o)});let f=c&&a?xm(c,o):null,p=-1,m=null;s&&(m=new ResizeObserver(_=>{let[w]=_;w&&w.target===c&&m&&t&&(m.unobserve(t),cancelAnimationFrame(p),p=requestAnimationFrame(()=>{var y;(y=m)==null||y.observe(t)})),o()}),c&&!d&&m.observe(c),t&&m.observe(t));let u,g=d?Qt(e):null;d&&v();function v(){let _=Qt(e);g&&!xc(g,_)&&o(),g=_,u=requestAnimationFrame(v)}return o(),()=>{var _;l.forEach(w=>{r&&w.removeEventListener("scroll",o),i&&w.removeEventListener("resize",o)}),f?.(),(_=m)==null||_.disconnect(),m=null,d&&cancelAnimationFrame(u)}}var Rc=dc;var Sc=uc,Ec=ac,Tc=pc,kc=cc;var Pc=fc,Bn=(e,t,o)=>{let n=new Map,r={platform:Zr,...o},i={...r.platform,_c:n};return sc(e,t,{...r,platform:i})};var ve=h(z(),1),Ac=h(z(),1),Oc=h(Mt(),1),Sm=typeof document<"u",Em=function(){},Hn=Sm?Ac.useLayoutEffect:Em;function zn(e,t){if(e===t)return!0;if(typeof e!=typeof t)return!1;if(typeof e=="function"&&e.toString()===t.toString())return!0;let o,n,r;if(e&&t&&typeof e=="object"){if(Array.isArray(e)){if(o=e.length,o!==t.length)return!1;for(n=o;n--!==0;)if(!zn(e[n],t[n]))return!1;return!0}if(r=Object.keys(e),o=r.length,o!==Object.keys(t).length)return!1;for(n=o;n--!==0;)if(!{}.hasOwnProperty.call(t,r[n]))return!1;for(n=o;n--!==0;){let i=r[n];if(!(i==="_owner"&&e.$$typeof)&&!zn(e[i],t[i]))return!1}return!0}return e!==e&&t!==t}function Nc(e){return typeof window>"u"?1:(e.ownerDocument.defaultView||window).devicePixelRatio||1}function Cc(e,t){let o=Nc(e);return Math.round(t*o)/o}function Qr(e){let t=ve.useRef(e);return Hn(()=>{t.current=e}),t}function Lc(e){e===void 0&&(e={});let{placement:t="bottom",strategy:o="absolute",middleware:n=[],platform:r,elements:{reference:i,floating:s}={},transform:a=!0,whileElementsMounted:d,open:c}=e,[l,f]=ve.useState({x:0,y:0,strategy:o,placement:t,middlewareData:{},isPositioned:!1}),[p,m]=ve.useState(n);zn(p,n)||m(n);let[u,g]=ve.useState(null),[v,_]=ve.useState(null),w=ve.useCallback(P=>{P!==x.current&&(x.current=P,g(P))},[]),y=ve.useCallback(P=>{P!==E.current&&(E.current=P,_(P))},[]),b=i||u,S=s||v,x=ve.useRef(null),E=ve.useRef(null),T=ve.useRef(l),k=d!=null,C=Qr(d),j=Qr(r),A=Qr(c),L=ve.useCallback(()=>{if(!x.current||!E.current)return;let P={placement:t,strategy:o,middleware:p};j.current&&(P.platform=j.current),Bn(x.current,E.current,P).then(O=>{let M={...O,isPositioned:A.current!==!1};I.current&&!zn(T.current,M)&&(T.current=M,Oc.flushSync(()=>{f(M)}))})},[p,t,o,j,A]);Hn(()=>{c===!1&&T.current.isPositioned&&(T.current.isPositioned=!1,f(P=>({...P,isPositioned:!1})))},[c]);let I=ve.useRef(!1);Hn(()=>(I.current=!0,()=>{I.current=!1}),[]),Hn(()=>{if(b&&(x.current=b),S&&(E.current=S),b&&S){if(C.current)return C.current(b,S,L);L()}},[b,S,L,C,k]);let R=ve.useMemo(()=>({reference:x,floating:E,setReference:w,setFloating:y}),[w,y]),N=ve.useMemo(()=>({reference:b,floating:S}),[b,S]),H=ve.useMemo(()=>{let P={position:o,left:0,top:0};if(!N.floating)return P;let O=Cc(N.floating,l.x),M=Cc(N.floating,l.y);return a?{...P,transform:"translate("+O+"px, "+M+"px)",...Nc(N.floating)>=1.5&&{willChange:"transform"}}:{position:o,left:O,top:M}},[o,a,N.floating,l.x,l.y]);return ve.useMemo(()=>({...l,update:L,refs:R,elements:N,floatingStyles:H}),[l,L,R,N,H])}var Jr=(e,t)=>{let o=Rc(e);return{name:o.name,fn:o.fn,options:[e,t]}},$r=(e,t)=>{let o=Sc(e);return{name:o.name,fn:o.fn,options:[e,t]}},ei=(e,t)=>({fn:Pc(e).fn,options:[e,t]}),ti=(e,t)=>{let o=Ec(e);return{name:o.name,fn:o.fn,options:[e,t]}},oi=(e,t)=>{let o=Tc(e);return{name:o.name,fn:o.fn,options:[e,t]}};var ni=(e,t)=>{let o=kc(e);return{name:o.name,fn:o.fn,options:[e,t]}};var _o=h(z(),1),qc=h(Mt(),1);var Xc=h(z(),1);var q=(e,t,o,n,r,i,...s)=>{if(s.length>0)throw new Error(Pe(1));let a;if(e&&t&&o&&n&&r&&i)a=(d,c,l,f)=>{let p=e(d,c,l,f),m=t(d,c,l,f),u=o(d,c,l,f),g=n(d,c,l,f),v=r(d,c,l,f);return i(p,m,u,g,v,c,l,f)};else if(e&&t&&o&&n&&r)a=(d,c,l,f)=>{let p=e(d,c,l,f),m=t(d,c,l,f),u=o(d,c,l,f),g=n(d,c,l,f);return r(p,m,u,g,c,l,f)};else if(e&&t&&o&&n)a=(d,c,l,f)=>{let p=e(d,c,l,f),m=t(d,c,l,f),u=o(d,c,l,f);return n(p,m,u,c,l,f)};else if(e&&t&&o)a=(d,c,l,f)=>{let p=e(d,c,l,f),m=t(d,c,l,f);return o(p,m,c,l,f)};else if(e&&t)a=(d,c,l,f)=>{let p=e(d,c,l,f);return t(p,c,l,f)};else if(e)a=e;else throw new Error("Missing arguments");return a};var Uc=h(z(),1),li=h(ii(),1),Gc=h(jc(),1);var Fc=h(z(),1);var si=[],ai;function Vc(){return ai}function Wc(e){si.push(e)}function ci(e){let t=(o,n)=>{let r=Se(Wm).current,i;try{ai=r;for(let s of si)s.before(r);i=e(o,n);for(let s of si)s.after(r);r.didInitialize=!0}finally{ai=void 0}return i};return t.displayName=e.displayName||e.name,t}function Yc(e){return Fc.forwardRef(ci(e))}function Wm(){return{didInitialize:!1}}var Ym=ao(19),Um=Ym?Xm:Km;function jn(e,t,o,n,r){return Um(e,t,o,n,r)}function Gm(e,t,o,n,r){let i=Uc.useCallback(()=>t(e.getSnapshot(),o,n,r),[e,t,o,n,r]);return(0,li.useSyncExternalStore)(e.subscribe,i,i)}Wc({before(e){e.syncIndex=0,e.didInitialize||(e.syncTick=1,e.syncHooks=[],e.didChangeStore=!0,e.getSnapshot=()=>{let t=!1;for(let o=0;o0&&(e.didChangeStore&&(e.didChangeStore=!1,e.subscribe=t=>{let o=new Set;for(let r of e.syncHooks)o.add(r.store);let n=[];for(let r of o)n.push(r.subscribe(t));return()=>{for(let r of n)r()}}),(0,li.useSyncExternalStore)(e.subscribe,e.getSnapshot,e.getSnapshot))}});function Xm(e,t,o,n,r){let i=Vc();if(!i)return Gm(e,t,o,n,r);let s=i.syncIndex;i.syncIndex+=1;let a;return i.didInitialize?(a=i.syncHooks[s],(a.store!==e||a.selector!==t||!Object.is(a.a1,o)||!Object.is(a.a2,n)||!Object.is(a.a3,r))&&(a.store!==e&&(i.didChangeStore=!0),a.store=e,a.selector=t,a.a1=o,a.a2=n,a.a3=r,a.value=t(e.getSnapshot(),o,n,r))):(a={store:e,selector:t,a1:o,a2:n,a3:r,value:t(e.getSnapshot(),o,n,r)},i.syncHooks.push(a)),a.value}function Km(e,t,o,n,r){return(0,Gc.useSyncExternalStoreWithSelector)(e.subscribe,e.getSnapshot,e.getSnapshot,i=>t(i,o,n,r))}var Fn=class{constructor(t){this.state=t,this.listeners=new Set,this.updateTick=0}subscribe=t=>(this.listeners.add(t),()=>{this.listeners.delete(t)});getSnapshot=()=>this.state;setState(t){if(this.state===t)return;this.state=t,this.updateTick+=1;let o=this.updateTick;for(let n of this.listeners){if(o!==this.updateTick)return;n(t)}}update(t){for(let o in t)if(!Object.is(this.state[o],t[o])){this.setState({...this.state,...t});return}}set(t,o){Object.is(this.state[t],o)||this.setState({...this.state,[t]:o})}notifyAll(){let t={...this.state};this.setState(t)}use(t,o,n,r){return jn(this,t,o,n,r)}};var Jt=h(z(),1);var vo=class extends Fn{constructor(t,o={},n){super(t),this.context=o,this.selectors=n}useSyncedValue(t,o){Jt.useDebugValue(t);let n=this;D(()=>{n.state[t]!==o&&n.set(t,o)},[n,t,o])}useSyncedValueWithCleanup(t,o){let n=this;D(()=>(n.state[t]!==o&&n.set(t,o),()=>{n.set(t,void 0)}),[n,t,o])}useSyncedValues(t){let o=this,n=Object.values(t);D(()=>{o.update(t)},[o,...n])}useControlledProp(t,o){Jt.useDebugValue(t);let n=this,r=o!==void 0;D(()=>{r&&!Object.is(n.state[t],o)&&n.setState({...n.state,[t]:o})},[n,t,o,r])}select(t,o,n,r){let i=this.selectors[t];return i(this.state,o,n,r)}useState(t,o,n,r){return Jt.useDebugValue(t),jn(this,this.selectors[t],o,n,r)}useContextCallback(t,o){Jt.useDebugValue(t);let n=Y(o??Nt);this.context[t]=n}useStateSetter(t){let o=Jt.useRef(void 0);return o.current===void 0&&(o.current=n=>{this.set(t,n)}),o.current}observe(t,o){let n;typeof t=="function"?n=t:n=this.selectors[t];let r=n(this.state);return o(r,r,this),this.subscribe(i=>{let s=n(i);if(!Object.is(r,s)){let a=r;r=s,o(s,a,this)}})}};var qm={open:q(e=>e.open),transitionStatus:q(e=>e.transitionStatus),domReferenceElement:q(e=>e.domReferenceElement),referenceElement:q(e=>e.positionReference??e.referenceElement),floatingElement:q(e=>e.floatingElement),floatingId:q(e=>e.floatingId)},pt=class extends vo{constructor(t){let{syncOnly:o,nested:n,onOpenChange:r,triggerElements:i,...s}=t;super({...s,positionReference:s.referenceElement,domReferenceElement:s.referenceElement},{onOpenChange:r,dataRef:{current:{}},events:ec(),nested:n,triggerElements:i},qm),this.syncOnly=o}syncOpenEvent=(t,o)=>{(!t||!this.state.open||o!=null&&Ha(o))&&(this.context.dataRef.current.openEvent=t?o:void 0)};dispatchOpenChange=(t,o)=>{this.syncOpenEvent(t,o.event);let n={open:t,reason:o.reason,nativeEvent:o.event,nested:this.context.nested,triggerElement:o.trigger};this.context.events.emit("openchange",n)};setOpen=(t,o)=>{if(this.syncOnly){this.context.onOpenChange?.(t,o);return}this.dispatchOpenChange(t,o),this.context.onOpenChange?.(t,o)}};function Kc(e){let{popupStore:t,treatPopupAsFloatingElement:o=!1,floatingRootContext:n,floatingId:r,nested:i,onOpenChange:s}=e,a=t.useState("open"),d=t.useState("activeTriggerElement"),c=t.useState(o?"popupElement":"positionerElement"),l=t.context.triggerElements,f=s,p=Xc.useRef(null);n===void 0&&p.current===null&&(p.current=new pt({open:a,transitionStatus:void 0,referenceElement:d,floatingElement:c,triggerElements:l,onOpenChange:f,floatingId:r,syncOnly:!0,nested:i}));let m=n??p.current;return t.useSyncedValue("floatingId",r),D(()=>{let u={open:a,floatingId:r,referenceElement:d,floatingElement:c};V(d)&&(u.domReferenceElement=d),m.state.positionReference===m.state.referenceElement&&(u.positionReference=d),m.update(u)},[a,r,d,c,m]),m.context.onOpenChange=f,m.context.nested=i,m}var Zc={tabIndex:-1,[Dr]:""};function Qc(e,t,o=!1){let n=Lt(),r=bo()!=null,i=_o.useRef(null);e===void 0&&i.current===null&&(i.current=t(n,r));let s=e??i.current;return Kc({popupStore:s,treatPopupAsFloatingElement:o,floatingRootContext:s.state.floatingRootContext,floatingId:n,nested:r,onOpenChange:s.setOpen}),{store:s,internalStore:i.current}}function Zm(e,t){let o=_o.useRef(null),n=_o.useRef(null);return _o.useCallback(r=>{if(e===void 0)return;let i=!1;if(o.current!==null){let s=o.current,a=n.current,d=t.context.triggerElements.getById(s);a&&d===a&&(t.context.triggerElements.delete(s),i=!0),o.current=null,n.current=null}if(r!==null&&(o.current=e,n.current=r,t.context.triggerElements.add(e,r),i=!0),i){let s=t.context.triggerElements.size;t.select("open")&&t.state.triggerCount!==s&&t.set("triggerCount",s)}},[t,e])}function Qm(e,t,o,n=!1){t?e.preventUnmountingOnClose=!1:n&&(e.preventUnmountingOnClose=!0);let r=o?.id??null;(r||t)&&(e.activeTriggerId=r,e.activeTriggerElement=o??null)}function Jm(e){let t=!1;return e.preventUnmountOnClose=()=>{t=!0},()=>t}function Jc(e,t,o,n={}){let r=o.reason,i=r===U.triggerHover,s=t&&r===U.triggerFocus,a=!t&&(r===U.triggerPress||r===U.escapeKey),d=Jm(o);if(e.context.onOpenChange?.(t,o),o.isCanceled)return;n.onBeforeDispatch?.(),e.state.floatingRootContext.dispatchOpenChange(t,o);let c=()=>{let l={...n.extraState,open:t};s?l.instantType="focus":a?l.instantType="dismiss":i&&(l.instantType=void 0),Qm(l,t,o.trigger,d()),e.update(l)};i?qc.flushSync(c):c()}function $c(e,t,o,n){Oa(()=>{t===void 0&&e.state.open===!1&&o&&(e.state={...e.state,open:!0,activeTriggerId:n,preventUnmountingOnClose:!1})})}function el(e,t,o,n){let r=o.useState("isMountedByTrigger",e),i=Zm(e,o),s=Y(a=>{if(i(a),!a)return;let d=o.select("open"),c=o.select("activeTriggerId");if(c===e){o.update({activeTriggerElement:a,...d?n:null});return}c==null&&d&&o.update({activeTriggerId:e,activeTriggerElement:a,...n})});return D(()=>{r&&o.update({activeTriggerElement:t.current,...n})},[r,o,t,...Object.values(n)]),{registerTrigger:s,isMountedByThisTrigger:r}}function tl(e,t={}){let{closeOnActiveTriggerUnmount:o=!1}=t,n=e.useState("open"),r=e.useState("triggerCount");D(()=>{if(!n){e.state.triggerCount!==0&&e.set("triggerCount",0);return}let i=e.context.triggerElements.size,s={};e.state.triggerCount!==i&&(s.triggerCount=i);let a=e.select("activeTriggerId"),d=null;if(a){let c=e.context.triggerElements.getById(a);c?c!==e.state.activeTriggerElement&&(s.activeTriggerElement=c):d=a}if(!d&&!a&&i===1){let c=e.context.triggerElements.entries().next();if(!c.done){let[l,f]=c.value;s.activeTriggerId=l,s.activeTriggerElement=f}}(s.triggerCount!==void 0||s.activeTriggerId!==void 0||s.activeTriggerElement!==void 0)&&e.update(s),d&&o&&queueMicrotask(()=>{if(e.select("open")&&e.select("activeTriggerId")===d&&!e.context.triggerElements.getById(d)){let c=ee(U.none);e.setOpen(!1,c),c.isCanceled||e.update({activeTriggerId:null,activeTriggerElement:null})}})},[n,e,r,o])}function ol(e,t,o){let{mounted:n,setMounted:r,transitionStatus:i}=ha(e),s=t.useState("preventUnmountingOnClose"),a=e?!1:s;t.useSyncedValues({mounted:n,transitionStatus:i,preventUnmountingOnClose:a});let d=Y(()=>{r(!1),t.update({activeTriggerId:null,activeTriggerElement:null,mounted:!1,preventUnmountingOnClose:!1}),o?.(),t.context.onOpenChangeComplete?.(!1)});return Pn({enabled:n&&!e&&!a,open:e,ref:t.context.popupRef,onComplete(){e||d()}}),{forceUnmount:d,transitionStatus:i}}function nl(e,t){e.useSyncedValues(t),D(()=>()=>{e.update({activeTriggerProps:be,inactiveTriggerProps:be,popupProps:be})},[e])}var jt=class{constructor(){this.elementsSet=new Set,this.idMap=new Map}add(t,o){let n=this.idMap.get(t);n!==o&&(n!==void 0&&this.elementsSet.delete(n),this.elementsSet.add(o),this.idMap.set(t,o))}delete(t){let o=this.idMap.get(t);o&&(this.elementsSet.delete(o),this.idMap.delete(t))}hasElement(t){return this.elementsSet.has(t)}hasMatchingElement(t){for(let o of this.elementsSet)if(t(o))return!0;return!1}getById(t){return this.idMap.get(t)}entries(){return this.idMap.entries()}elements(){return this.elementsSet.values()}get size(){return this.idMap.size}};function rl(){return new pt({open:!1,transitionStatus:void 0,floatingElement:null,referenceElement:null,triggerElements:new jt,floatingId:void 0,syncOnly:!1,nested:!1,onOpenChange:void 0})}function sl(){return{open:!1,openProp:void 0,mounted:!1,transitionStatus:void 0,floatingRootContext:rl(),floatingId:void 0,triggerCount:0,preventUnmountingOnClose:!1,payload:void 0,activeTriggerId:null,activeTriggerElement:null,triggerIdProp:void 0,popupElement:null,positionerElement:null,activeTriggerProps:be,inactiveTriggerProps:be,popupProps:be}}function al(e,t,o=!1){return new pt({open:!1,transitionStatus:void 0,floatingElement:null,referenceElement:null,triggerElements:e,floatingId:t,syncOnly:!0,nested:o,onOpenChange:void 0})}var Ko=q(e=>e.triggerIdProp??e.activeTriggerId),di=q(e=>e.openProp??e.open),il=q(e=>(e.popupElement?.id??e.floatingId)||void 0);function cl(e,t){return t!==void 0&&di(e)&&Ko(e)===t}function $m(e,t){return cl(e,t)?!0:t!==void 0&&di(e)&&Ko(e)==null&&e.triggerCount===1}var ll={open:di,mounted:q(e=>e.mounted),transitionStatus:q(e=>e.transitionStatus),floatingRootContext:q(e=>e.floatingRootContext),triggerCount:q(e=>e.triggerCount),preventUnmountingOnClose:q(e=>e.preventUnmountingOnClose),payload:q(e=>e.payload),activeTriggerId:Ko,activeTriggerElement:q(e=>e.mounted?e.activeTriggerElement:null),popupId:il,isTriggerActive:q((e,t)=>t!==void 0&&Ko(e)===t),isOpenedByTrigger:q((e,t)=>cl(e,t)),isMountedByTrigger:q((e,t)=>t!==void 0&&Ko(e)===t&&e.mounted),triggerProps:q((e,t)=>t?e.activeTriggerProps:e.inactiveTriggerProps),triggerPopupId:q((e,t)=>$m(e,t)?il(e):void 0),popupProps:q(e=>e.popupProps),popupElement:q(e=>e.popupElement),positionerElement:q(e=>e.positionerElement)};function dl(e){let{open:t=!1,onOpenChange:o,elements:n={}}=e,r=Lt(),i=bo()!=null,s=Se(()=>new pt({open:t,transitionStatus:void 0,onOpenChange:o,referenceElement:n.reference??null,floatingElement:n.floating??null,triggerElements:new jt,floatingId:r,syncOnly:!1,nested:i})).current;return D(()=>{let a={open:t,floatingId:r};n.reference!==void 0&&(a.referenceElement=n.reference,a.domReferenceElement=V(n.reference)?n.reference:null),n.floating!==void 0&&(a.floatingElement=n.floating),s.update(a)},[t,r,n.reference,n.floating,s]),s.context.onOpenChange=o,s.context.nested=i,s}function ui(e={}){let{nodeId:t,externalTree:o}=e,n=dl(e),r=e.rootContext||n,i=r.useState("referenceElement"),s=r.useState("floatingElement"),a=r.useState("domReferenceElement"),d=r.useState("open"),c=r.useState("floatingId"),[l,f]=Ne.useState(null),[p,m]=Ne.useState(void 0),[u,g]=Ne.useState(void 0),v=Ne.useRef(null),_=Dt(o),w=Ne.useMemo(()=>({reference:i,floating:s,domReference:a}),[i,s,a]),y=Lc({...e,elements:{...w,...l&&{reference:l}}}),b=V(p)?p:null,S=u===void 0?r.state.floatingElement:u;r.useSyncedValue("referenceElement",p??null),r.useSyncedValue("domReferenceElement",p===void 0?a:b),r.useSyncedValue("floatingElement",S);let x=Ne.useCallback(A=>{let L=V(A)?{getBoundingClientRect:()=>A.getBoundingClientRect(),getClientRects:()=>A.getClientRects(),contextElement:A}:A;f(L),y.refs.setReference(L)},[y.refs]),E=Ne.useCallback(A=>{(V(A)||A===null)&&(v.current=A,m(A)),(V(y.refs.reference.current)||y.refs.reference.current===null||A!==null&&!V(A))&&y.refs.setReference(A)},[y.refs,m]),T=Ne.useCallback(A=>{g(A),y.refs.setFloating(A)},[y.refs]),k=Ne.useMemo(()=>({...y.refs,setReference:E,setFloating:T,setPositionReference:x,domReference:v}),[y.refs,E,T,x]),C=Ne.useMemo(()=>({...y.elements,domReference:a}),[y.elements,a]),j=Ne.useMemo(()=>({...y,dataRef:r.context.dataRef,open:d,onOpenChange:r.setOpen,events:r.context.events,floatingId:c,refs:k,elements:C,nodeId:t,rootStore:r}),[y,k,C,t,r,d,c]);return D(()=>{a&&(v.current=a)},[a]),D(()=>{r.context.dataRef.current.floatingContext=j;let A=_?.nodesRef.current.find(L=>L.id===t);A&&(A.context=j)}),Ne.useMemo(()=>({...y,context:j,refs:k,elements:C,rootStore:r}),[y,k,C,j,r])}var mt=h(z(),1);var fi=xt.os.mac&&xt.engine.webkit;function pi(e,t={}){let{enabled:o=!0,delay:n}=t,r="rootStore"in e?e.rootStore:e,{events:i,dataRef:s}=r.context,a=mt.useRef(!1),d=mt.useRef(null),c=mt.useRef(!0),l=rt();mt.useEffect(()=>{let p=r.select("domReferenceElement");if(!o)return;let m=ge(p);function u(){let _=r.select("domReferenceElement");!r.select("open")&&we(_)&&_===Cn(xe(_))&&(a.current=!0)}function g(){c.current=!0}function v(){c.current=!1}return it(re(m,"blur",u),fi&&re(m,"keydown",g,!0),fi&&re(m,"pointerdown",v,!0))},[r,o]),mt.useEffect(()=>{if(!o)return;function p(m){if(m.reason===U.triggerPress||m.reason===U.escapeKey){let u=r.select("domReferenceElement");V(u)&&(d.current=u,a.current=!0)}}return i.on("openchange",p),()=>{i.off("openchange",p)}},[i,o,r]);let f=mt.useMemo(()=>{function p(){a.current=!1,d.current=null}return{onMouseLeave(){p()},onFocus(m){let u=m.currentTarget;if(a.current){if(d.current===u)return;p()}let g=Me(m.nativeEvent);if(V(g)){if(fi&&!m.relatedTarget){if(!c.current&&!Da(g))return}else if(!ja(g))return}let v=Bt(m.relatedTarget,r.context.triggerElements),{nativeEvent:_,currentTarget:w}=m,y=typeof n=="function"?n():n;if(r.select("open")&&v||y===0||y===void 0){r.setOpen(!0,ee(U.triggerFocus,_,w));return}l.start(y,()=>{a.current||r.setOpen(!0,ee(U.triggerFocus,_,w))})},onBlur(m){p();let u=m.relatedTarget,g=m.nativeEvent,v=V(u)&&u.hasAttribute(go("focus-guard"))&&u.getAttribute("data-type")==="outside";l.start(0,()=>{let _=r.select("domReferenceElement"),w=Cn(xe(_));!u&&w===_||ie(s.current.floatingContext?.refs.floating.current,w)||ie(_,w)||v||Bt(u??w,r.context.triggerElements)||r.setOpen(!1,ee(U.triggerFocus,g))})}}},[s,n,r,l]);return mt.useMemo(()=>o?{reference:f,trigger:f}:{},[o,f])}var gi=h(z(),1);var mi=class e{constructor(){this.pointerType=void 0,this.interactedInside=!1,this.handler=void 0,this.blockMouseMove=!0,this.performedPointerEventsMutation=!1,this.pointerEventsScopeElement=null,this.pointerEventsReferenceElement=null,this.pointerEventsFloatingElement=null,this.restTimeoutPending=!1,this.openChangeTimeout=new Ye,this.restTimeout=new Ye,this.handleCloseOptions=void 0}static create(){return new e}dispose=()=>{this.openChangeTimeout.clear(),this.restTimeout.clear()};disposeEffect=()=>this.dispose},Vn=new WeakMap;function yo(e){if(!e.performedPointerEventsMutation)return;let t=e.pointerEventsScopeElement;t&&Vn.get(t)===e&&(e.pointerEventsScopeElement?.style.removeProperty("pointer-events"),e.pointerEventsReferenceElement?.style.removeProperty("pointer-events"),e.pointerEventsFloatingElement?.style.removeProperty("pointer-events"),Vn.delete(t)),e.performedPointerEventsMutation=!1,e.pointerEventsScopeElement=null,e.pointerEventsReferenceElement=null,e.pointerEventsFloatingElement=null}function Wn(e,t){let{scopeElement:o,referenceElement:n,floatingElement:r}=t,i=Vn.get(o);i&&i!==e&&yo(i),yo(e),e.performedPointerEventsMutation=!0,e.pointerEventsScopeElement=o,e.pointerEventsReferenceElement=n,e.pointerEventsFloatingElement=r,Vn.set(o,e),o.style.pointerEvents="none",n.style.pointerEvents="auto",r.style.pointerEvents="auto"}function xo(e){let t=e.context.dataRef.current,o=Se(()=>t.hoverInteractionState??mi.create()).current;return t.hoverInteractionState||(t.hoverInteractionState=o),co(t.hoverInteractionState.disposeEffect),t.hoverInteractionState}function bi(e,t={}){let{enabled:o=!0,closeDelay:n=0,nodeId:r}=t,i="rootStore"in e?e.rootStore:e,s=i.useState("open"),a=i.useState("floatingElement"),d=i.useState("domReferenceElement"),{dataRef:c}=i.context,l=Dt(),f=bo(),p=xo(i),m=rt(),u=Y(()=>On(c.current.openEvent?.type,p.interactedInside)),g=Y(()=>Fa(c.current.openEvent?.type)),v=Y(()=>{yo(p)});D(()=>{s||(p.pointerType=void 0,p.restTimeoutPending=!1,p.interactedInside=!1,v())},[s,p,v]),gi.useEffect(()=>v,[v]),D(()=>{if(o&&s&&p.handleCloseOptions?.blockPointerEvents&&g()&&V(d)&&a){let _=d,w=a,y=xe(a),b=l?.nodesRef.current.find(T=>T.id===f)?.context?.elements.floating;b&&(b.style.pointerEvents="");let S=p.pointerEventsScopeElement!==w?p.pointerEventsScopeElement:null,x=b!==w?b:null,E=p.handleCloseOptions?.getScope?.()??S??x??_.closest("[data-rootownerid]")??y.body;return Wn(p,{scopeElement:E,referenceElement:_,floatingElement:w}),()=>{v()}}},[o,s,d,a,p,g,l,f,v]),gi.useEffect(()=>{if(!o)return;function _(){return!!(l&&f&&Et(l.nodesRef.current,f).length>0)}function w(T){let k=St(n,"close",p.pointerType),C=()=>{i.setOpen(!1,ee(U.triggerHover,T)),l?.events.emit("floating.closed",T)};k?p.openChangeTimeout.start(k,C):(p.openChangeTimeout.clear(),C())}function y(T){let k=Me(T);if(!Fr(k)){p.interactedInside=!1;return}p.interactedInside=k?.closest("[aria-haspopup]")!=null}function b(){p.openChangeTimeout.clear(),m.clear(),l?.events.off("floating.closed",x),v()}function S(T){if(_()&&l){l.events.on("floating.closed",x);return}if(Bt(T.relatedTarget,i.context.triggerElements))return;let k=c.current.floatingContext?.nodeId??r,C=T.relatedTarget;if(!(l&&k&&V(C)&&Et(l.nodesRef.current,k,!1).some(A=>ie(A.context?.elements.floating,C)))){if(p.handler){p.handler(T);return}v(),g()&&!u()&&w(T)}}function x(T){!l||!f||_()||m.start(0,()=>{l.events.off("floating.closed",x),i.setOpen(!1,ee(U.triggerHover,T)),l.events.emit("floating.closed",T)})}let E=a;return it(E&&re(E,"mouseenter",b),E&&re(E,"mouseleave",S),E&&re(E,"pointerdown",y,!0),()=>{l?.events.off("floating.closed",x)})},[o,a,i,c,n,r,g,u,v,p,l,f,m])}var Ft=h(z(),1),ul=h(Mt(),1);var eg={current:null};function hi(e,t={}){let{enabled:o=!0,delay:n=0,handleClose:r=null,mouseOnly:i=!1,restMs:s=0,move:a=!0,triggerElementRef:d=eg,externalTree:c,isActiveTrigger:l=!0,getHandleCloseContext:f,isClosing:p,shouldOpen:m}=t,u="rootStore"in e?e.rootStore:e,{dataRef:g,events:v}=u.context,_=Dt(c),w=xo(u),y=Ft.useRef(!1),b=ze(r),S=ze(n),x=ze(s),E=ze(o),T=ze(m),k=ze(p),C=Y(()=>On(g.current.openEvent?.type,w.interactedInside)),j=Y(()=>T.current?.()!==!1),A=Y((R,N,H)=>{let P=u.context.triggerElements;if(P.hasElement(N))return!R||!ie(R,N);if(!V(H))return!1;let O=H;return P.hasMatchingElement(M=>ie(M,O))&&(!R||!ie(R,O))}),L=Y(()=>{if(!w.handler)return;xe(u.select("domReferenceElement")).removeEventListener("mousemove",w.handler),w.handler=void 0}),I=Y(()=>{yo(w)});return l&&(w.handleCloseOptions=b.current?.__options),Ft.useEffect(()=>L,[L]),Ft.useEffect(()=>{if(!o)return;function R(N){N.open?y.current=!1:(y.current=N.reason===U.triggerHover,L(),w.openChangeTimeout.clear(),w.restTimeout.clear(),w.blockMouseMove=!0,w.restTimeoutPending=!1)}return v.on("openchange",R),()=>{v.off("openchange",R)}},[o,v,w,L]),Ft.useEffect(()=>{if(!o)return;function R(O,M=!0){let Z=St(S.current,"close",w.pointerType);Z?w.openChangeTimeout.start(Z,()=>{u.setOpen(!1,ee(U.triggerHover,O)),_?.events.emit("floating.closed",O)}):M&&(w.openChangeTimeout.clear(),u.setOpen(!1,ee(U.triggerHover,O)),_?.events.emit("floating.closed",O))}let N=d.current??(l?u.select("domReferenceElement"):null);if(!V(N))return;function H(O){if(w.openChangeTimeout.clear(),w.blockMouseMove=!1,i&&!Rt(w.pointerType))return;let M=Vr(x.current),Z=St(S.current,"open",w.pointerType),W=Me(O),oe=O.currentTarget??null,te=u.select("domReferenceElement"),se=oe;if(V(W)&&!u.context.triggerElements.hasElement(W)){for(let ue of u.context.triggerElements.elements())if(ie(ue,W)){se=ue;break}}V(oe)&&V(te)&&!u.context.triggerElements.hasElement(oe)&&ie(oe,te)&&(se=te);let G=se==null?!1:A(te,se,W),K=u.select("open"),J=k.current?.()??u.select("transitionStatus")==="ending",ne=!K&&J&&y.current,me=!G&&V(se)&&V(te)&&ie(te,se)&&ne,le=M>0&&!Z,X=G&&(K||ne)||me,pe=!K||G;if(X){j()&&u.setOpen(!0,ee(U.triggerHover,O,se));return}le||(Z?w.openChangeTimeout.start(Z,()=>{pe&&j()&&u.setOpen(!0,ee(U.triggerHover,O,se))}):pe&&j()&&u.setOpen(!0,ee(U.triggerHover,O,se)))}function P(O){if(C()){I();return}L();let M=u.select("domReferenceElement"),Z=xe(M);w.restTimeout.clear(),w.restTimeoutPending=!1;let W=g.current.floatingContext??f?.();if(Bt(O.relatedTarget,u.context.triggerElements))return;if(b.current&&W){u.select("open")||w.openChangeTimeout.clear();let te=d.current;w.handler=b.current({...W,tree:_,x:O.clientX,y:O.clientY,onClose(){I(),L(),E.current&&!C()&&te===u.select("domReferenceElement")&&R(O,!0)}}),Z.addEventListener("mousemove",w.handler),w.handler(O);return}(w.pointerType!=="touch"||!ie(u.select("floatingElement"),O.relatedTarget))&&R(O)}return a?it(re(N,"mousemove",H,{once:!0}),re(N,"mouseenter",H),re(N,"mouseleave",P)):it(re(N,"mouseenter",H),re(N,"mouseleave",P))},[L,I,g,S,u,o,b,w,l,A,C,i,a,x,d,_,E,f,k,j]),Ft.useMemo(()=>{if(!o)return;function R(N){w.pointerType=N.pointerType}return{onPointerDown:R,onPointerEnter:R,onMouseMove(N){let{nativeEvent:H}=N,P=N.currentTarget,O=u.select("domReferenceElement"),M=u.select("open"),Z=A(O,P,N.target);if(i&&!Rt(w.pointerType))return;if(M&&Z&&w.handleCloseOptions?.blockPointerEvents){let te=u.select("floatingElement");if(te){let se=w.handleCloseOptions?.getScope?.()??P.ownerDocument.body;Wn(w,{scopeElement:se,referenceElement:P,floatingElement:te})}}let W=Vr(x.current);if(M&&!Z||W===0||!Z&&w.restTimeoutPending&&N.movementX**2+N.movementY**2<2)return;w.restTimeout.clear();function oe(){if(w.restTimeoutPending=!1,C())return;let te=u.select("open");!w.blockMouseMove&&(!te||Z)&&j()&&u.setOpen(!0,ee(U.triggerHover,H,P))}w.pointerType==="touch"?ul.flushSync(()=>{oe()}):Z&&M?oe():(w.restTimeoutPending=!0,w.restTimeout.start(W,oe))}}},[o,w,C,A,i,u,x,j])}var fl=.1,tg=fl*fl,ce=.5;function Yn(e,t,o,n,r,i){return n>=t!=i>=t&&e<=(r-o)*(t-n)/(i-n)+o}function Un(e,t,o,n,r,i,s,a,d,c){let l=!1;return Yn(e,t,o,n,r,i)&&(l=!l),Yn(e,t,r,i,s,a)&&(l=!l),Yn(e,t,s,a,d,c)&&(l=!l),Yn(e,t,d,c,o,n)&&(l=!l),l}function og(e,t,o){return e>=o.x&&e<=o.x+o.width&&t>=o.y&&t<=o.y+o.height}function Gn(e,t,o,n,r,i){let s=Math.min(o,r),a=Math.max(o,r),d=Math.min(n,i),c=Math.max(n,i);return e>=s&&e<=a&&t>=d&&t<=c}function wi(e={}){let{blockPointerEvents:t=!1}=e,o=new Ye,n=({x:r,y:i,placement:s,elements:a,onClose:d,nodeId:c,tree:l})=>{let f=s?.split("-")[0],p=!1,m=null,u=null,g=typeof performance<"u"?performance.now():0;function v(w,y){let b=performance.now(),S=b-g;if(m===null||u===null||S===0)return m=w,u=y,g=b,!1;let x=w-m,E=y-u,T=x*x+E*E,k=S*S*tg;return m=w,u=y,g=b,T0)}function L(){A()||_()}if(A())return;let I=b.getBoundingClientRect(),R=S.getBoundingClientRect(),N=r>R.right-R.width/2,H=i>R.bottom-R.height/2,P=R.width>I.width,O=R.height>I.height,M=(P?I:R).left,Z=(P?I:R).right,W=(O?I:R).top,oe=(O?I:R).bottom;if(f==="top"&&i>=I.bottom-1||f==="bottom"&&i<=I.top+1||f==="left"&&r>=I.right-1||f==="right"&&r<=I.left+1){L();return}let te=!1;switch(f){case"top":te=Gn(x,E,M,I.top+1,Z,R.bottom-1);break;case"bottom":te=Gn(x,E,M,R.top+1,Z,I.bottom-1);break;case"left":te=Gn(x,E,R.right-1,oe,I.left+1,W);break;case"right":te=Gn(x,E,I.right-1,oe,R.left+1,W);break;default:}if(te)return;if(p&&!og(x,E,I)){L();return}if(!k&&v(x,E)){L();return}let se=!1;switch(f){case"top":{let G=P?ce/2:ce*4,K=P||N?r+G:r-G,J=P?r-G:N?r+G:r-G,ne=i+ce+1,me=N||P?R.bottom-ce:R.top,le=N?P?R.bottom-ce:R.top:R.bottom-ce;se=Un(x,E,K,ne,J,ne,R.left,me,R.right,le);break}case"bottom":{let G=P?ce/2:ce*4,K=P||N?r+G:r-G,J=P?r-G:N?r+G:r-G,ne=i-ce,me=N||P?R.top+ce:R.bottom,le=N?P?R.top+ce:R.bottom:R.top+ce;se=Un(x,E,K,ne,J,ne,R.left,me,R.right,le);break}case"left":{let G=O?ce/2:ce*4,K=O||H?i+G:i-G,J=O?i-G:H?i+G:i-G,ne=r+ce+1,me=H||O?R.right-ce:R.left,le=H?O?R.right-ce:R.left:R.right-ce;se=Un(x,E,me,R.top,le,R.bottom,ne,K,ne,J);break}case"right":{let G=O?ce/2:ce*4,K=O||H?i+G:i-G,J=O?i-G:H?i+G:i-G,ne=r-ce,me=H||O?R.left+ce:R.right,le=H?O?R.left+ce:R.right:R.left+ce;se=Un(x,E,ne,K,ne,J,me,R.top,le,R.bottom);break}default:}se?p||o.start(40,L):L()}};return n.__options={...e,blockPointerEvents:t},n}var vi=(function(e){return e.open="data-open",e.closed="data-closed",e[e.startingStyle=Yt.startingStyle]="startingStyle",e[e.endingStyle=Yt.endingStyle]="endingStyle",e.anchorHidden="data-anchor-hidden",e.side="data-side",e.align="data-align",e})({}),qo=(function(e){return e.popupOpen="data-popup-open",e.pressed="data-pressed",e})({}),ng={[qo.popupOpen]:""},B_={[qo.popupOpen]:"",[qo.pressed]:""},rg={[vi.open]:""},ig={[vi.closed]:""},sg={[vi.anchorHidden]:""},pl={open(e){return e?ng:null}};var Ro={open(e){return e?rg:ig},anchorHidden(e){return e?sg:null}};function ml(e){return ao(19)?e:e?"true":void 0}var Ge=h(z(),1);var ag=e=>({name:"arrow",options:e,async fn(t){let{x:o,y:n,placement:r,rects:i,platform:s,elements:a,middlewareData:d}=t,{element:c,padding:l=0,offsetParent:f="real"}=at(e,t)||{};if(c==null)return{};let p=In(l),m={x:o,y:n},u=Go(r),g=Uo(u),v=await s.getDimensions(c),_=u==="y",w=_?"top":"left",y=_?"bottom":"right",b=_?"clientHeight":"clientWidth",S=i.reference[g]+i.reference[u]-m[u]-i.floating[g],x=m[u]-i.reference[u],E=f==="real"?await s.getOffsetParent?.(c):a.floating,T=a.floating[b]||i.floating[g];(!T||!await s.isElement?.(E))&&(T=a.floating[b]||i.floating[g]);let k=S/2-x/2,C=T/2-v[g]/2-1,j=Math.min(p[w],C),A=Math.min(p[y],C),L=j,I=T-v[g]-A,R=T/2-v[g]/2+k,N=Yo(L,R,I),H=!d.arrow&&ct(r)!=null&&R!==N&&i.reference[g]/2-(R({...ag(e),options:[e,t]});var cg=ni().fn,bl={name:"hide",async fn(e){let{width:t,height:o,x:n,y:r}=e.rects.reference,i=t===0&&o===0&&n===0&&r===0;return{data:{referenceHidden:(await cg(e)).data?.referenceHidden||i}}}};var Zo={sideX:"left",sideY:"top"},hl={name:"adaptiveOrigin",async fn(e){let{x:t,y:o,rects:{floating:n},elements:{floating:r},platform:i,strategy:s,placement:a}=e,d=ge(r),c=d.getComputedStyle(r);if(!(c.transitionDuration!=="0s"&&c.transitionDuration!==""))return{x:t,y:o,data:Zo};let f=await i.getOffsetParent?.(r),p={width:0,height:0};if(s==="fixed"&&d?.visualViewport)p={width:d.visualViewport.width,height:d.visualViewport.height};else if(f===d){let w=xe(r);p={width:w.documentElement.clientWidth,height:w.documentElement.clientHeight}}else await i.isElement?.(f)&&(p=await i.getDimensions(f));let m=Ee(a),u=t,g=o;m==="left"&&(u=p.width-(t+n.width)),m==="top"&&(g=p.height-(o+n.height));let v=m==="left"?"right":Zo.sideX,_=m==="top"?"bottom":Zo.sideY;return{x:u,y:g,data:{sideX:v,sideY:_}}}};function _l(e,t,o){let n=e==="inline-start"||e==="inline-end";return{top:"top",right:n?o?"inline-start":"inline-end":"right",bottom:"bottom",left:n?o?"inline-end":"inline-start":"left"}[t]}function wl(e,t,o){let{rects:n,placement:r}=e;return{side:_l(t,Ee(r),o),align:ct(r)||"center",anchor:{width:n.reference.width,height:n.reference.height},positioner:{width:n.floating.width,height:n.floating.height}}}function yl(e){let{anchor:t,positionMethod:o="absolute",side:n="bottom",sideOffset:r=0,align:i="center",alignOffset:s=0,collisionBoundary:a,collisionPadding:d=5,sticky:c=!1,arrowPadding:l=5,disableAnchorTracking:f=!1,inline:p,keepMounted:m=!1,floatingRootContext:u,mounted:g,collisionAvoidance:v,shiftCrossAxis:_=!1,nodeId:w,adaptiveOrigin:y,lazyFlip:b=!1,externalTree:S}=e,[x,E]=Ge.useState(null);!g&&x!==null&&E(null);let T=v.side||"flip",k=v.align||"flip",C=v.fallbackAxisSide||"end",j=typeof t=="function"?t:void 0,A=Y(j),L=j?A:t,I=ze(t),R=ze(g),H=so()==="rtl",P=x||{top:"top",right:"right",bottom:"bottom",left:"left","inline-end":H?"left":"right","inline-start":H?"right":"left"}[n],O=i==="center"?P:`${P}-${i}`,M=d,Z=1,W=n==="bottom"?Z:0,oe=n==="top"?Z:0,te=n==="right"?Z:0,se=n==="left"?Z:0;typeof M=="number"?M={top:M+W,right:M+se,bottom:M+oe,left:M+te}:M&&(M={top:(M.top||0)+W,right:(M.right||0)+se,bottom:(M.bottom||0)+oe,left:(M.left||0)+te});let G={boundary:a==="clipping-ancestors"?"clippingAncestors":a,padding:M},K=Ge.useRef(null),J=ze(r),ne=ze(s),me=typeof r!="function"?r:0,le=typeof s!="function"?s:0,X=[];p&&X.push(p),X.push(Jr(ae=>{let Ie=wl(ae,n,H),ut=typeof J.current=="function"?J.current(Ie):J.current,qe=typeof ne.current=="function"?ne.current(Ie):ne.current;return{mainAxis:ut,crossAxis:qe,alignmentAxis:qe}},[me,le,H,n]));let pe=k==="none"&&T!=="shift",ue=!pe&&(c||_||T==="shift"),vt=T==="none"?null:ti({...G,padding:{top:M.top+Z,right:M.right+Z,bottom:M.bottom+Z,left:M.left+Z},mainAxis:!_&&T==="flip",crossAxis:k==="flip"?"alignment":!1,fallbackAxisSideDirection:C}),Te=pe?null:$r(ae=>{let Ie=xe(ae.elements.floating).documentElement;return{...G,rootBoundary:_?{x:0,y:0,width:Ie.clientWidth,height:Ie.clientHeight}:void 0,mainAxis:k!=="none",crossAxis:ue,limiter:c||_?void 0:ei(ut=>{if(!K.current)return{};let{width:qe,height:yt}=K.current.getBoundingClientRect(),et=De(Ee(ut.placement)),Vt=et==="y"?qe:yt,io=et==="y"?M.left+M.right:M.top+M.bottom;return{offset:Vt/2+io/2}})}},[G,c,_,M,k]);T==="shift"||k==="shift"||i==="center"?X.push(Te,vt):X.push(vt,Te),X.push(oi({...G,apply({elements:{floating:ae},availableWidth:Ie,availableHeight:ut,rects:qe}){if(!R.current)return;let yt=ae.style;yt.setProperty("--available-width",`${Ie}px`),yt.setProperty("--available-height",`${ut}px`);let et=ge(ae).devicePixelRatio||1,{x:Vt,y:io,width:bn,height:br}=qe.reference,hr=(Math.round((Vt+bn)*et)-Math.round(Vt*et))/et,wr=(Math.round((io+br)*et)-Math.round(io*et))/et;yt.setProperty("--anchor-width",`${hr}px`),yt.setProperty("--anchor-height",`${wr}px`)}}),gl(ae=>({element:K.current||xe(ae.elements.floating).createElement("div"),padding:l,offsetParent:"floating"}),[l]),{name:"transformOrigin",fn(ae){let{elements:Ie,middlewareData:ut,placement:qe,rects:yt,y:et}=ae,Vt=Ee(qe),io=De(Vt),bn=K.current,br=ut.arrow?.x||0,hr=ut.arrow?.y||0,wr=bn?.clientWidth||0,ff=bn?.clientHeight||0,vr=br+wr/2,Us=hr+ff/2,pf=Math.abs(ut.shift?.y||0),mf=yt.reference.height/2,Io=typeof r=="function"?r(wl(ae,n,H)):r,gf=pf>Io,bf={top:`${vr}px calc(100% + ${Io}px)`,bottom:`${vr}px ${-Io}px`,left:`calc(100% + ${Io}px) ${Us}px`,right:`${-Io}px ${Us}px`}[Vt],hf=`${vr}px ${yt.reference.y+mf-et}px`;return Ie.floating.style.setProperty("--transform-origin",ue&&io==="y"&&gf?hf:bf),{}}},bl,y),D(()=>{!g&&u&&u.update({referenceElement:null,floatingElement:null,domReferenceElement:null,positionReference:null})},[g,u]);let Ve=Ge.useMemo(()=>({elementResize:!f&&typeof ResizeObserver<"u",layoutShift:!f&&typeof IntersectionObserver<"u"}),[f]),{refs:Ke,elements:He,x:no,y:dn,middlewareData:_e,update:ro,placement:B,context:F,isPositioned:he,floatingStyles:ke}=ui({rootContext:u,open:m?g:void 0,placement:O,middleware:X,strategy:o,whileElementsMounted:m?void 0:(...ae)=>Xo(...ae,Ve),nodeId:w,externalTree:S}),{sideX:kt,sideY:Lo}=_e.adaptiveOrigin||Zo,_t=he?o:"fixed",We=Ge.useMemo(()=>{let ae=y?{position:_t,[kt]:no,[Lo]:dn}:{position:_t,...ke};return he||(ae.opacity=0),ae},[y,_t,kt,no,Lo,dn,ke,he]),Pt=Ge.useRef(null);D(()=>{if(!g)return;let ae=I.current,Ie=typeof ae=="function"?ae():ae,qe=(vl(Ie)?Ie.current:Ie)||null||null;qe!==Pt.current&&(Ke.setPositionReference(qe),Pt.current=qe)},[g,Ke,L,I]),Ge.useEffect(()=>{if(!g)return;let ae=I.current;typeof ae!="function"&&vl(ae)&&ae.current!==Pt.current&&(Ke.setPositionReference(ae.current),Pt.current=ae.current)},[g,Ke,L,I]),Ge.useEffect(()=>{if(m&&g&&He.reference&&He.floating)return Xo(He.reference,He.floating,ro,Ve)},[m,g,He,ro,Ve]);let Ct=Ee(B),un=_l(n,Ct,H),fn=ct(B)||"center",pn=!!_e.hide?.referenceHidden;D(()=>{b&&g&&he&&E(Ct)},[b,g,he,Ct]);let mn=Ge.useMemo(()=>({position:"absolute",top:_e.arrow?.y,left:_e.arrow?.x}),[_e.arrow]),gn=_e.arrow?.centerOffset!==0;return Ge.useMemo(()=>({positionerStyles:We,arrowStyles:mn,arrowRef:K,arrowUncentered:gn,side:un,align:fn,physicalSide:Ct,anchorHidden:pn,refs:Ke,context:F,isPositioned:he,update:ro}),[We,mn,K,gn,un,fn,Ct,pn,Ke,F,he,ro])}function vl(e){return e!=null&&"current"in e}function Xn(e){return e==="starting"?Za:be}function xl(e,t,{styles:o,transitionStatus:n,props:r,refs:i,hidden:s,inert:a=!1}){let d={...o};return a&&(d.pointerEvents="none"),Ce("div",e,{state:t,ref:i,props:[{role:"presentation",hidden:s,style:d},Xn(n),r],stateAttributesMapping:Ro})}var Rl=h(z(),1);var _i=Rl.forwardRef(function(t,o){let{render:n,className:r,disabled:i=!1,focusableWhenDisabled:s=!1,nativeButton:a=!0,style:d,...c}=t,{getButtonProps:l,buttonRef:f}=Ea({disabled:i,focusableWhenDisabled:s,native:a});return Ce("button",t,{state:{disabled:i},ref:[o,f],props:[c,l]})});var Le=h(z(),1),Cl=h(Mt(),1);var Sl=h(z(),1);function El(e){let[t,o]=Sl.useState({current:e,previous:null});return e!==t.current&&o({current:e,previous:t.current}),t.previous}var So=h(z(),1);function yi(e){let t=Ae(e),o=parseFloat(t.width)||0,n=parseFloat(t.height)||0,r=we(e),i=r?e.offsetWidth:o,s=r?e.offsetHeight:n;return(zt(o)!==i||zt(n)!==s)&&(o=i,n=s),{width:o,height:n}}function kl(e){let{popupElement:t,positionerElement:o,content:n,mounted:r,onMeasureLayout:i,onMeasureLayoutComplete:s,side:a,direction:d}=e,c=mo(t,!0,!1),l=lo(),f=So.useRef(null),p=So.useRef(!0),m=So.useRef(Nt),u=Y(i),g=Y(s),v=So.useMemo(()=>{let _=a==="top",w=a==="left";return d==="rtl"?(_=_||a==="inline-end",w=w||a==="inline-end"):(_=_||a==="inline-start",w=w||a==="inline-start"),_?{position:"absolute",[a==="top"?"bottom":"top"]:"0",[w?"right":"left"]:"0"}:be},[a,d]);D(()=>{if(!r){m.current=Nt,p.current=!0,f.current=null;return}if(!t||!o)return;m.current=Tl(t,v),xi(t,"auto");let _=qn(t,"position","static"),w=qn(t,"transform","none"),y=qn(t,"scale","1"),b=Tl(o,{"--available-width":"max-content","--available-height":"max-content"});function S(){_(),w(),b()}function x(){S(),y()}if(u?.(),p.current||f.current===null){Kn(o,"max-content");let C=yi(t);return f.current=C,Kn(o,C),x(),g?.(null,C),p.current=!1,()=>{m.current(),m.current=Nt}}Kn(o,"max-content");let E=f.current,T=yi(t);f.current=T,xi(t,E),x(),g?.(E,T),Kn(o,T);let k=new AbortController;return l.request(()=>{xi(t,T),c(()=>{t.style.setProperty("--popup-width","auto"),t.style.setProperty("--popup-height","auto")},k.signal)}),()=>{k.abort(),l.cancel(),m.current(),m.current=Nt}},[n,t,o,c,l,r,u,g,v])}function qn(e,t,o){let n=e.style.getPropertyValue(t);return e.style.setProperty(t,o),()=>{e.style.setProperty(t,n)}}function Tl(e,t){let o=[];for(let[n,r]of Object.entries(t))o.push(qn(e,n,r));return o.length?()=>{o.forEach(n=>n())}:Nt}function xi(e,t){let o=t==="auto"?"auto":`${t.width}px`,n=t==="auto"?"auto":`${t.height}px`;e.style.setProperty("--popup-width",o),e.style.setProperty("--popup-height",n)}function Kn(e,t){let o=t==="max-content"?"max-content":`${t.width}px`,n=t==="max-content"?"max-content":`${t.height}px`;e.style.setProperty("--positioner-width",o),e.style.setProperty("--positioner-height",n)}var Eo=h(Q(),1);function Al(e){let{store:t,side:o,cssVars:n,children:r}=e,i=so(),s=t.useState("activeTriggerElement"),a=t.useState("activeTriggerId"),d=t.useState("open"),c=t.useState("payload"),l=t.useState("mounted"),f=t.useState("popupElement"),p=t.useState("positionerElement"),m=El(d?s:null),u=ug(a,c),g=Le.useRef(null),[v,_]=Le.useState(null),[w,y]=Le.useState(null),b=Le.useRef(null),S=Le.useRef(null),x=mo(b,!0,!1),E=lo(),[T,k]=Le.useState(null),[C,j]=Le.useState(!1);D(()=>(t.set("hasViewport",!0),()=>{t.set("hasViewport",!1)}),[t]);let A=Y(()=>{b.current?.style.setProperty("animation","none"),b.current?.style.setProperty("transition","none"),S.current?.style.setProperty("display","none")}),L=Y(P=>{b.current?.style.removeProperty("animation"),b.current?.style.removeProperty("transition"),S.current?.style.removeProperty("display"),P&&k(P)}),I=Le.useRef(null);D(()=>{(!d||!l)&&(I.current=null)},[d,l]),D(()=>{if(s&&m&&s!==m&&I.current!==s&&g.current){_(g.current),j(!0);let P=dg(m,s);y(P),E.request(()=>{Cl.flushSync(()=>{j(!1)}),x(()=>{_(null),k(null),g.current=null})}),I.current=s}},[s,m,v,x,E]),D(()=>{let P=b.current;if(!P)return;let O=xe(P).createElement("div");for(let M of Array.from(P.childNodes))O.appendChild(M.cloneNode(!0));g.current=O});let R=v!=null,N;R?N=(0,Eo.jsxs)(Le.Fragment,{children:[(0,Eo.jsx)("div",{"data-previous":!0,inert:ml(!0),ref:S,style:{...T?{[n.popupWidth]:`${T.width}px`,[n.popupHeight]:`${T.height}px`}:null,position:"absolute"},"data-ending-style":C?void 0:""},"previous"),(0,Eo.jsx)("div",{"data-current":!0,ref:b,"data-starting-style":C?"":void 0,children:r},u)]}):N=(0,Eo.jsx)("div",{"data-current":!0,ref:b,children:r},u),D(()=>{let P=S.current;!P||!v||P.replaceChildren(...Array.from(v.childNodes))},[v]),kl({popupElement:f,positionerElement:p,mounted:l,content:c,onMeasureLayout:A,onMeasureLayoutComplete:L,side:o,direction:i});let H={activationDirection:lg(w),transitioning:R};return{children:N,state:H}}function lg(e){if(e)return`${Pl(e.horizontal,5,"right","left")} ${Pl(e.vertical,5,"down","up")}`}function Pl(e,t,o,n){return e>t?o:e<-t?n:""}function dg(e,t){let o=e.getBoundingClientRect(),n=t.getBoundingClientRect(),r={x:o.left+o.width/2,y:o.top+o.height/2},i={x:n.left+n.width/2,y:n.top+n.height/2};return{horizontal:i.x-r.x,vertical:i.y-r.y}}function ug(e,t){let[o,n]=Le.useState(0),r=Le.useRef(e),i=Le.useRef(t),s=Le.useRef(!1);return D(()=>{let a=r.current,d=i.current,c=e!==a,l=t!==d;c?(n(f=>f+1),s.current=!l):s.current&&l&&(n(f=>f+1),s.current=!1),r.current=e,i.current=t},[e,t]),`${e??"current"}-${o}`}var Zn=h(z(),1),Ol=h(Mt(),1);var Nl=h(Q(),1),Ll=Zn.forwardRef(function(t,o){let{children:n,container:r,className:i,render:s,style:a,...d}=t,{portalNode:c,portalSubtree:l}=Ur({container:r,ref:o,componentProps:t,elementProps:d});return!l&&!c?null:(0,Nl.jsxs)(Zn.Fragment,{children:[l,c&&Ol.createPortal(n,c)]})});var Qe={};At(Qe,{Arrow:()=>ql,Handle:()=>Qo,Popup:()=>Xl,Portal:()=>Wl,Positioner:()=>Ul,Provider:()=>Zl,Root:()=>Ml,Trigger:()=>jl,Viewport:()=>$l,createHandle:()=>ed});var gt=h(z(),1);var Qn=h(z(),1),Ri=Qn.createContext(void 0);function Ze(e){let t=Qn.useContext(Ri);if(t===void 0&&!e)throw new Error(Pe(72));return t}var Il=h(z(),1);var fg={...ll,disabled:q(e=>e.disabled),instantType:q(e=>e.instantType),isInstantPhase:q(e=>e.isInstantPhase),trackCursorAxis:q(e=>e.trackCursorAxis),disableHoverablePopup:q(e=>e.disableHoverablePopup),lastOpenChangeReason:q(e=>e.openChangeReason),closeOnClick:q(e=>e.closeOnClick),closeDelay:q(e=>e.closeDelay),hasViewport:q(e=>e.hasViewport)},To=class e extends vo{constructor(t,o,n=!1){let r=new jt,i={...pg(),...t};i.floatingRootContext=al(r,o,n),super(i,{popupRef:Il.createRef(),onOpenChange:void 0,onOpenChangeComplete:void 0,triggerElements:r},fg)}setOpen=(t,o)=>{Jc(this,t,o,{extraState:{openChangeReason:o.reason}})};cancelPendingOpen(t){this.state.floatingRootContext.dispatchOpenChange(!1,ee(U.triggerPress,t))}static useStore(t,o){return Qc(t,(r,i)=>new e(o,r,i)).store}};function pg(){return{...sl(),disabled:!1,instantType:void 0,isInstantPhase:!1,trackCursorAxis:"none",disableHoverablePopup:!1,openChangeReason:null,closeOnClick:!0,closeDelay:0,hasViewport:!1}}var Jn=h(Q(),1),Ml=ci(function(t){let{disabled:o=!1,defaultOpen:n=!1,open:r,disableHoverablePopup:i=!1,trackCursorAxis:s="none",actionsRef:a,onOpenChange:d,onOpenChangeComplete:c,handle:l,triggerId:f,defaultTriggerId:p=null,children:m}=t,u=To.useStore(l?.store,{open:n,openProp:r,activeTriggerId:p,triggerIdProp:f});$c(u,r,n,p),u.useControlledProp("openProp",r),u.useControlledProp("triggerIdProp",f),u.useContextCallback("onOpenChange",d),u.useContextCallback("onOpenChangeComplete",c);let g=u.useState("open"),v=!o&&g,_=u.useState("activeTriggerId"),w=u.useState("mounted"),y=u.useState("payload");u.useSyncedValues({trackCursorAxis:s,disableHoverablePopup:i}),u.useSyncedValue("disabled",o),tl(u,{closeOnActiveTriggerUnmount:!0});let{forceUnmount:b,transitionStatus:S}=ol(v,u),x=u.useState("isInstantPhase"),E=u.useState("instantType"),T=u.useState("lastOpenChangeReason"),k=gt.useRef(null);D(()=>{g&&o&&u.setOpen(!1,ee(U.disabled))},[g,o,u]),D(()=>{S==="ending"&&T===U.none||S!=="ending"&&x?(E!=="delay"&&(k.current=E),u.set("instantType","delay")):k.current!==null&&(u.set("instantType",k.current),k.current=null)},[S,x,T,E,u]),D(()=>{v&&_==null&&u.set("payload",void 0)},[u,_,v]);let C=gt.useCallback(()=>{u.setOpen(!1,ee(U.imperativeAction))},[u]);gt.useImperativeHandle(a,()=>({unmount:b,close:C}),[b,C]);let j=v||w||!o&&s!=="none";return(0,Jn.jsxs)(Ri.Provider,{value:u,children:[j&&(0,Jn.jsx)(mg,{store:u,disabled:o,trackCursorAxis:s}),typeof m=="function"?m({payload:y}):m]})});function mg({store:e,disabled:t,trackCursorAxis:o}){let n=e.useState("floatingRootContext"),r=Xr(n,{enabled:!t,referencePress:()=>e.select("closeOnClick")}),i=Gr(n,{enabled:!t&&o!=="none",axis:o==="none"?void 0:o}),s=gt.useMemo(()=>ye(i.reference,r.reference),[i.reference,r.reference]),a=gt.useMemo(()=>ye(i.trigger,r.trigger),[i.trigger,r.trigger]),d=gt.useMemo(()=>ye(Zc,i.floating,r.floating),[i.floating,r.floating]);return nl(e,{activeTriggerProps:s,inactiveTriggerProps:a,popupProps:d}),null}var er=h(z(),1);var $n=h(z(),1),Si=$n.createContext(void 0);function Bl(){return $n.useContext(Si)}var Hl=(function(e){return e[e.popupOpen=qo.popupOpen]="popupOpen",e.triggerDisabled="data-trigger-disabled",e})({});var Dl="data-base-ui-tooltip-trigger";function zl(e){if("composedPath"in e){let o=e.composedPath();for(let n=0;ng.select("transitionStatus")==="ending",shouldOpen(){return!O.current}}),G=pi(y,{enabled:!R}).reference,K=X=>{let pe=O.current,ue=zl(X),vt=te(ue),Te=b.current,Ve=Te&&ue&&ie(Te,ue);if(vt&&g.select("open")&&g.select("lastOpenChangeReason")===U.triggerHover){g.setOpen(!1,ee(U.triggerHover,X));return}if(pe&&!vt&&Ve&&!N.current&&!g.select("open")&&Te&&Rt(Z.current)){let Ke=()=>{!O.current&&!N.current&&!g.select("open")&&g.setOpen(!0,ee(U.triggerHover,X,Te))},He=W();He===0?(M.clear(),Ke()):M.start(He,Ke)}},J=g.useState("triggerProps",T);return Ce("button",t,{state:{open:w},ref:[o,E,b],props:[se,G,T||H!=="none"?J:void 0,{onMouseOver(X){K(X.nativeEvent)},onFocus(X){oe(zl(X.nativeEvent))&&X.preventBaseUIHandler()},onMouseLeave(){O.current=!1,M.clear(),Z.current=void 0},onPointerEnter(X){Z.current=X.pointerType},onPointerDown(X){Z.current=X.pointerType,g.set("closeOnClick",l),l&&!g.select("open")&&g.cancelPendingOpen(X.nativeEvent)},onClick(X){l&&!g.select("open")&&g.cancelPendingOpen(X.nativeEvent)},id:v,[Hl.triggerDisabled]:R?"":void 0,[Dl]:R?void 0:""},m],stateAttributesMapping:pl})});var Vl=h(z(),1);var tr=h(z(),1),Ei=tr.createContext(void 0);function Fl(){let e=tr.useContext(Ei);if(e===void 0)throw new Error(Pe(70));return e}var Ti=h(Q(),1),Wl=Vl.forwardRef(function(t,o){let{keepMounted:n=!1,...r}=t;return Ze().useState("mounted")||n?(0,Ti.jsx)(Ei.Provider,{value:n,children:(0,Ti.jsx)(Ll,{ref:o,...r})}):null});var nr=h(z(),1);var or=h(z(),1),ki=or.createContext(void 0);function ko(){let e=or.useContext(ki);if(e===void 0)throw new Error(Pe(71));return e}var Yl=h(Q(),1),Ul=nr.forwardRef(function(t,o){let{render:n,className:r,anchor:i,positionMethod:s="absolute",side:a="top",align:d="center",sideOffset:c=0,alignOffset:l=0,collisionBoundary:f="clipping-ancestors",collisionPadding:p=5,arrowPadding:m=5,sticky:u=!1,disableAnchorTracking:g=!1,collisionAvoidance:v=Qa,style:_,...w}=t,y=Ze(),b=Fl(),S=y.useState("open"),x=y.useState("mounted"),E=y.useState("trackCursorAxis"),T=y.useState("disableHoverablePopup"),k=y.useState("floatingRootContext"),C=y.useState("instantType"),j=y.useState("transitionStatus"),A=y.useState("hasViewport"),L=yl({anchor:i,positionMethod:s,floatingRootContext:k,mounted:x,side:a,sideOffset:c,align:d,alignOffset:l,collisionBoundary:f,collisionPadding:p,sticky:u,arrowPadding:m,disableAnchorTracking:g,keepMounted:b,collisionAvoidance:v,adaptiveOrigin:A?hl:void 0}),I=nr.useMemo(()=>({open:S,side:L.side,align:L.align,anchorHidden:L.anchorHidden,instant:E!=="none"?"tracking-cursor":C}),[S,L.side,L.align,L.anchorHidden,E,C]),R=xl(t,I,{styles:L.positionerStyles,transitionStatus:j,props:w,refs:[o,y.useStateSetter("positionerElement")],hidden:!x,inert:!S||E==="both"||T});return(0,Yl.jsx)(ki.Provider,{value:L,children:R})});var Gl=h(z(),1);var bg={...Ro,...wa},Xl=Gl.forwardRef(function(t,o){let{render:n,className:r,style:i,...s}=t,a=Ze(),{side:d,align:c}=ko(),l=a.useState("open"),f=a.useState("instantType"),p=a.useState("transitionStatus"),m=a.useState("popupProps"),u=a.useState("floatingRootContext"),g=a.useState("disabled"),v=a.useState("closeDelay");Pn({open:l,ref:a.context.popupRef,onComplete(){l&&a.context.onOpenChangeComplete?.(!0)}}),bi(u,{enabled:!g,closeDelay:v});let _=a.useStateSetter("popupElement");return Ce("div",t,{state:{open:l,side:d,align:c,instant:f,transitionStatus:p},ref:[o,a.context.popupRef,_],props:[m,Xn(p),s],stateAttributesMapping:bg})});var Kl=h(z(),1);var ql=Kl.forwardRef(function(t,o){let{render:n,className:r,style:i,...s}=t,a=Ze(),{arrowRef:d,side:c,align:l,arrowUncentered:f,arrowStyles:p}=ko(),m=a.useState("open"),u=a.useState("instantType");return Ce("div",t,{state:{open:m,side:c,align:l,uncentered:f,instant:u},ref:[o,d],props:[{style:p,"aria-hidden":!0},s],stateAttributesMapping:Ro})});var Pi=h(z(),1);var Ci=h(Q(),1),Zl=function(t){let{delay:o,closeDelay:n,timeout:r=400}=t,i=Pi.useMemo(()=>({delay:o,closeDelay:n}),[o,n]),s=Pi.useMemo(()=>({open:o,close:n}),[o,n]);return(0,Ci.jsx)(Si.Provider,{value:i,children:(0,Ci.jsx)(Wr,{delay:s,timeoutMs:r,children:t.children})})};var Jl=h(z(),1);var Ql=(function(e){return e.popupWidth="--popup-width",e.popupHeight="--popup-height",e})({});var hg={activationDirection:e=>e?{"data-activation-direction":e}:null},$l=Jl.forwardRef(function(t,o){let{render:n,className:r,style:i,children:s,...a}=t,d=Ze(),c=ko(),l=d.useState("instantType"),{children:f,state:p}=Al({store:d,side:c.side,cssVars:Ql,children:s}),m={activationDirection:p.activationDirection,transitioning:p.transitioning,instant:l};return Ce("div",t,{state:m,ref:o,props:[a,{children:f}],stateAttributesMapping:hg})});var Qo=class{constructor(){this.store=new To}open(t){let o=t?this.store.context.triggerElements.getById(t):void 0;if(t&&!o)throw new Error(Pe(81,t));this.store.setOpen(!0,ee(U.imperativeAction,void 0,o))}close(){this.store.setOpen(!1,ee(U.imperativeAction,void 0,void 0))}get isOpen(){return this.store.select("open")}};function ed(){return new Qo}function bt(e){return Ce(e.defaultTagName??"div",e,e)}var nd=h(de(),1),Ai="data-wp-hash";function Oi(){let e=globalThis;return e.__wpStyleRuntime||(e.__wpStyleRuntime={documents:new Map,styles:new Map,injectedStyles:new WeakMap},typeof document<"u"&&vg(document)),e.__wpStyleRuntime}function wg(e,t){if(!e.head)return!1;for(let o of e.head.querySelectorAll(`style[${Ai}]`))if(o.getAttribute(Ai)===t)return!0;return!1}function rd(e,t,o){if(!e.head)return;let n=Oi(),r=n.injectedStyles.get(e);if(r||(r=new Set,n.injectedStyles.set(e,r)),r.has(t))return;if(wg(e,t)){r.add(t);return}let i=e.createElement("style");i.setAttribute(Ai,t),i.appendChild(e.createTextNode(o)),e.head.appendChild(i),r.add(t)}function vg(e){let t=Oi();t.documents.set(e,(t.documents.get(e)??0)+1);for(let[o,n]of t.styles)rd(e,o,n);return()=>{let o=t.documents.get(e);if(o!==void 0){if(o<=1){t.documents.delete(e);return}t.documents.set(e,o-1)}}}function id(e,t){let o=Oi();o.styles.set(e,t);for(let n of o.documents.keys())rd(n,e,t)}typeof process>"u",id("a495f9d138",'@layer wp-ui{@layer utilities, components, compositions, overrides;@layer components{._83ed8a8da5dd50ea__text{margin:0}._14437cfb77831647__heading-2xl{--_gcd-heading-font-size:var(--wpds-typography-font-size-2xl,32px);--_gcd-heading-font-weight:var(--wpds-typography-font-weight-emphasis,600);--_gcd-p-font-size:var(--wpds-typography-font-size-2xl,32px);--_gcd-p-line-height:var(--wpds-typography-line-height-2xl,40px);font-size:var(--wpds-typography-font-size-2xl,32px);line-height:var(--wpds-typography-line-height-2xl,40px)}._14437cfb77831647__heading-2xl,._3c78b7fa9b4072dd__heading-xl{font-family:var(--wpds-typography-font-family-heading,-apple-system,system-ui,"Segoe UI","Roboto","Oxygen-Sans","Ubuntu","Cantarell","Helvetica Neue",sans-serif);font-weight:var(--wpds-typography-font-weight-emphasis,600)}._3c78b7fa9b4072dd__heading-xl{--_gcd-heading-font-size:var(--wpds-typography-font-size-xl,20px);--_gcd-heading-font-weight:var(--wpds-typography-font-weight-emphasis,600);--_gcd-p-font-size:var(--wpds-typography-font-size-xl,20px);--_gcd-p-line-height:var(--wpds-typography-line-height-md,24px);font-size:var(--wpds-typography-font-size-xl,20px);line-height:var(--wpds-typography-line-height-md,24px)}.aa58f227716bcde2__heading-lg{--_gcd-heading-font-size:var(--wpds-typography-font-size-lg,15px);--_gcd-heading-font-weight:var(--wpds-typography-font-weight-emphasis,600);--_gcd-p-font-size:var(--wpds-typography-font-size-lg,15px);--_gcd-p-line-height:var(--wpds-typography-line-height-sm,20px);font-size:var(--wpds-typography-font-size-lg,15px)}.aa58f227716bcde2__heading-lg,.fc4da56d8dfe52c4__heading-md{font-family:var(--wpds-typography-font-family-heading,-apple-system,system-ui,"Segoe UI","Roboto","Oxygen-Sans","Ubuntu","Cantarell","Helvetica Neue",sans-serif);font-weight:var(--wpds-typography-font-weight-emphasis,600);line-height:var(--wpds-typography-line-height-sm,20px)}.fc4da56d8dfe52c4__heading-md{--_gcd-heading-font-size:var(--wpds-typography-font-size-md,13px);--_gcd-heading-font-weight:var(--wpds-typography-font-weight-emphasis,600);--_gcd-p-font-size:var(--wpds-typography-font-size-md,13px);--_gcd-p-line-height:var(--wpds-typography-line-height-sm,20px);font-size:var(--wpds-typography-font-size-md,13px)}.a9b78c7c82e8dff7__heading-sm{--_gcd-heading-font-size:var(--wpds-typography-font-size-xs,11px);--_gcd-heading-font-weight:var(--wpds-typography-font-weight-emphasis,600);--_gcd-p-font-size:var(--wpds-typography-font-size-xs,11px);--_gcd-p-line-height:var(--wpds-typography-line-height-xs,16px);font-family:var(--wpds-typography-font-family-heading,-apple-system,system-ui,"Segoe UI","Roboto","Oxygen-Sans","Ubuntu","Cantarell","Helvetica Neue",sans-serif);font-size:var(--wpds-typography-font-size-xs,11px);font-weight:var(--wpds-typography-font-weight-emphasis,600);line-height:var(--wpds-typography-line-height-xs,16px);text-transform:uppercase}._305ff559e52180d5__body-xl{--_gcd-heading-font-size:var(--wpds-typography-font-size-xl,20px);--_gcd-heading-font-weight:var(--wpds-typography-font-weight-default,400);--_gcd-p-font-size:var(--wpds-typography-font-size-xl,20px);--_gcd-p-line-height:var(--wpds-typography-line-height-xl,32px);font-size:var(--wpds-typography-font-size-xl,20px);line-height:var(--wpds-typography-line-height-xl,32px)}._305ff559e52180d5__body-xl,.ca1aa3fc2029e958__body-lg{font-family:var(--wpds-typography-font-family-body,-apple-system,system-ui,"Segoe UI","Roboto","Oxygen-Sans","Ubuntu","Cantarell","Helvetica Neue",sans-serif);font-weight:var(--wpds-typography-font-weight-default,400)}.ca1aa3fc2029e958__body-lg{--_gcd-heading-font-size:var(--wpds-typography-font-size-lg,15px);--_gcd-heading-font-weight:var(--wpds-typography-font-weight-default,400);--_gcd-p-font-size:var(--wpds-typography-font-size-lg,15px);--_gcd-p-line-height:var(--wpds-typography-line-height-md,24px);font-size:var(--wpds-typography-font-size-lg,15px);line-height:var(--wpds-typography-line-height-md,24px)}._131101940be12424__body-md{--_gcd-heading-font-size:var(--wpds-typography-font-size-md,13px);--_gcd-heading-font-weight:var(--wpds-typography-font-weight-default,400);--_gcd-p-font-size:var(--wpds-typography-font-size-md,13px);--_gcd-p-line-height:var(--wpds-typography-line-height-sm,20px);font-size:var(--wpds-typography-font-size-md,13px);line-height:var(--wpds-typography-line-height-sm,20px)}._0e8d87a42c1f75fa__body-sm,._131101940be12424__body-md{font-family:var(--wpds-typography-font-family-body,-apple-system,system-ui,"Segoe UI","Roboto","Oxygen-Sans","Ubuntu","Cantarell","Helvetica Neue",sans-serif);font-weight:var(--wpds-typography-font-weight-default,400)}._0e8d87a42c1f75fa__body-sm{--_gcd-heading-font-size:var(--wpds-typography-font-size-sm,12px);--_gcd-heading-font-weight:var(--wpds-typography-font-weight-default,400);--_gcd-p-font-size:var(--wpds-typography-font-size-sm,12px);--_gcd-p-line-height:var(--wpds-typography-line-height-xs,16px);font-size:var(--wpds-typography-font-size-sm,12px);line-height:var(--wpds-typography-line-height-xs,16px)}}}');var td={text:"_83ed8a8da5dd50ea__text","heading-2xl":"_14437cfb77831647__heading-2xl","heading-xl":"_3c78b7fa9b4072dd__heading-xl","heading-lg":"aa58f227716bcde2__heading-lg","heading-md":"fc4da56d8dfe52c4__heading-md","heading-sm":"a9b78c7c82e8dff7__heading-sm","body-xl":"_305ff559e52180d5__body-xl","body-lg":"ca1aa3fc2029e958__body-lg","body-md":"_131101940be12424__body-md","body-sm":"_0e8d87a42c1f75fa__body-sm"};typeof process>"u",id("af6d9984a6","._6defc79820e382c6__button{box-sizing:var(--_gcd-button-box-sizing,border-box);font-family:var(--_gcd-button-font-family,inherit);font-size:var(--_gcd-button-font-size,inherit);font-weight:var(--_gcd-button-font-weight,inherit)}.d2cff2e5dea83bd1__input{box-sizing:var(--_gcd-input-box-sizing,border-box);font-family:var(--_gcd-input-font-family,inherit);font-size:var(--_gcd-input-font-size,inherit);font-weight:var(--_gcd-input-font-weight,inherit);margin:var(--_gcd-input-margin,0);&:is(textarea,[type=text],[type=password],[type=color],[type=date],[type=datetime],[type=datetime-local],[type=email],[type=month],[type=number],[type=search],[type=tel],[type=time],[type=url],[type=week]){background-color:var(--_gcd-input-background-color,transparent);border:var(--_gcd-input-border,none);border-radius:var(--_gcd-input-border-radius,0);box-shadow:var(--_gcd-input-box-shadow,0 0 0 transparent);color:var(--_gcd-input-color,var(--wpds-color-foreground-interactive-neutral,#1e1e1e));&:focus{border-color:var(--_gcd-input-border-color-focus,var(--wp-admin-theme-color));box-shadow:var(--_gcd-input-box-shadow-focus,none);outline:var(--_gcd-input-outline-focus,none)}&:disabled{background:var(--_gcd-input-background-disabled,transparent);border-color:var(--_gcd-input-border-color-disabled,transparent);box-shadow:var(--_gcd-input-box-shadow-disabled,none);color:var(--_gcd-input-color-disabled,var(--wpds-color-foreground-interactive-neutral-disabled,#8d8d8d))}&::placeholder{color:var(--_gcd-input-placeholder-color,var(--wpds-color-foreground-interactive-neutral-disabled,#8d8d8d))}}&:is(textarea,[type=text],[type=password],[type=date],[type=datetime],[type=datetime-local],[type=email],[type=month],[type=number],[type=search],[type=tel],[type=time],[type=url],[type=week]){line-height:var(--_gcd-input-line-height,inherit);min-height:var(--_gcd-input-min-height,auto);padding:var(--_gcd-input-padding,0)}}._547d86373d02e108__textarea{box-sizing:var(--_gcd-textarea-box-sizing,border-box);overflow:var(--_gcd-textarea-overflow,auto);resize:var(--_gcd-textarea-resize,block)}._8c15fd0ed9f28ba4__div{outline:var(--_gcd-div-outline,0 solid transparent)}p._43cec3e1eec1066d__p{font-size:var(--_gcd-p-font-size,13px);line-height:var(--_gcd-p-line-height,1.5);margin:var(--_gcd-p-margin,0)}:is(h1,h2,h3,h4,h5,h6).e97669c6d9a38497__heading{color:var(--_gcd-heading-color,var(--wpds-color-foreground-content-neutral,#1e1e1e));font-size:var(--_gcd-heading-font-size,inherit);font-weight:var(--_gcd-heading-font-weight,var(--wpds-typography-font-weight-emphasis,600));margin:var(--_gcd-heading-margin,0)}._2c0831b0499dbd6e__a,._2c0831b0499dbd6e__a:is(:hover,:focus,:active){border-radius:var(--_gcd-a-border-radius,0);box-shadow:var(--_gcd-a-box-shadow,none);color:var(--_gcd-a-color,inherit);outline:var(--_gcd-a-outline,0 solid transparent);transition:var(--_gcd-a-transition,none)}");var od={button:"_6defc79820e382c6__button",input:"d2cff2e5dea83bd1__input",textarea:"_547d86373d02e108__textarea",div:"_8c15fd0ed9f28ba4__div",p:"_43cec3e1eec1066d__p",heading:"e97669c6d9a38497__heading",a:"_2c0831b0499dbd6e__a"},Je=(0,nd.forwardRef)(function({variant:t="body-md",render:o,className:n,...r},i){return bt({render:o,defaultTagName:"span",ref:i,props:ye(r,{className:$(td.text,od.heading,od.p,td[t],n)})})});var ld=h(Q(),1),Ni="data-wp-hash";function Li(){let e=globalThis;return e.__wpStyleRuntime||(e.__wpStyleRuntime={documents:new Map,styles:new Map,injectedStyles:new WeakMap},typeof document<"u"&&yg(document)),e.__wpStyleRuntime}function _g(e,t){if(!e.head)return!1;for(let o of e.head.querySelectorAll(`style[${Ni}]`))if(o.getAttribute(Ni)===t)return!0;return!1}function cd(e,t,o){if(!e.head)return;let n=Li(),r=n.injectedStyles.get(e);if(r||(r=new Set,n.injectedStyles.set(e,r)),r.has(t))return;if(_g(e,t)){r.add(t);return}let i=e.createElement("style");i.setAttribute(Ni,t),i.appendChild(e.createTextNode(o)),e.head.appendChild(i),r.add(t)}function yg(e){let t=Li();t.documents.set(e,(t.documents.get(e)??0)+1);for(let[o,n]of t.styles)cd(e,o,n);return()=>{let o=t.documents.get(e);if(o!==void 0){if(o<=1){t.documents.delete(e);return}t.documents.set(e,o-1)}}}function xg(e,t){let o=Li();o.styles.set(e,t);for(let n of o.documents.keys())cd(n,e,t)}typeof process>"u",xg("9db2873e7f","@layer wp-ui{@layer utilities, components, compositions, overrides;@layer components{._96e6251aad1a6136__badge{border-radius:var(--wpds-border-radius-lg,8px);padding-block:var(--wpds-dimension-padding-xs,4px);padding-inline:var(--wpds-dimension-padding-sm,8px)}._99f7158cb520f750__is-high-intent{background-color:var(--wpds-color-background-surface-error,#f6e6e3);color:var(--wpds-color-foreground-content-error,#470000)}.c20ebef2365bc8b7__is-medium-intent{background-color:var(--wpds-color-background-surface-warning,#fde6be);color:var(--wpds-color-foreground-content-warning,#2e1900)}._365e1626c6202e52__is-low-intent{background-color:var(--wpds-color-background-surface-caution,#fee995);color:var(--wpds-color-foreground-content-caution,#281d00)}._33f8198127ddf4ef__is-stable-intent{background-color:var(--wpds-color-background-surface-success,#c6f7cd);color:var(--wpds-color-foreground-content-success,#002900)}._04c1aca8fc449412__is-informational-intent{background-color:var(--wpds-color-background-surface-info,#deebfa);color:var(--wpds-color-foreground-content-info,#001b4f)}._90726e69d495ec19__is-draft-intent{background-color:var(--wpds-color-background-surface-neutral-weak,#f4f4f4);color:var(--wpds-color-foreground-content-neutral,#1e1e1e)}._898f4a544993bd39__is-none-intent{background-color:var(--wpds-color-background-surface-neutral-strong,#fff);border:var(--wpds-border-width-xs,1px) solid var(--wpds-color-stroke-surface-neutral,#dbdbdb);color:var(--wpds-color-foreground-content-neutral,#1e1e1e);padding-block:calc(var(--wpds-dimension-padding-xs, 4px) - var(--wpds-border-width-xs, 1px));padding-inline:calc(var(--wpds-dimension-padding-sm, 8px) - var(--wpds-border-width-xs, 1px))}}}");var sd={badge:"_96e6251aad1a6136__badge","is-high-intent":"_99f7158cb520f750__is-high-intent","is-medium-intent":"c20ebef2365bc8b7__is-medium-intent","is-low-intent":"_365e1626c6202e52__is-low-intent","is-stable-intent":"_33f8198127ddf4ef__is-stable-intent","is-informational-intent":"_04c1aca8fc449412__is-informational-intent","is-draft-intent":"_90726e69d495ec19__is-draft-intent","is-none-intent":"_898f4a544993bd39__is-none-intent"},Ii=(0,ad.forwardRef)(function({intent:t="none",className:o,...n},r){return(0,ld.jsx)(Je,{ref:r,className:$(sd.badge,sd[`is-${t}-intent`],o),...n,variant:"body-sm"})});var rr=h(de(),1),dd=h(Ot(),1),fd=h(Q(),1);import{speak as Rg}from"@wordpress/a11y";var Mi="data-wp-hash";function Bi(){let e=globalThis;return e.__wpStyleRuntime||(e.__wpStyleRuntime={documents:new Map,styles:new Map,injectedStyles:new WeakMap},typeof document<"u"&&Eg(document)),e.__wpStyleRuntime}function Sg(e,t){if(!e.head)return!1;for(let o of e.head.querySelectorAll(`style[${Mi}]`))if(o.getAttribute(Mi)===t)return!0;return!1}function ud(e,t,o){if(!e.head)return;let n=Bi(),r=n.injectedStyles.get(e);if(r||(r=new Set,n.injectedStyles.set(e,r)),r.has(t))return;if(Sg(e,t)){r.add(t);return}let i=e.createElement("style");i.setAttribute(Mi,t),i.appendChild(e.createTextNode(o)),e.head.appendChild(i),r.add(t)}function Eg(e){let t=Bi();t.documents.set(e,(t.documents.get(e)??0)+1);for(let[o,n]of t.styles)ud(e,o,n);return()=>{let o=t.documents.get(e);if(o!==void 0){if(o<=1){t.documents.delete(e);return}t.documents.set(e,o-1)}}}function ir(e,t){let o=Bi();o.styles.set(e,t);for(let n of o.documents.keys())ud(n,e,t)}typeof process>"u",ir("b74f1ac304",'@layer wp-ui{@layer utilities, components, compositions, overrides;@layer components{._97b0fc33c028be1a__button,.abbb272e2ce49bd6__is-unstyled{appearance:none;padding:0}._97b0fc33c028be1a__button{--wp-ui-button-font-weight:var(--wpds-typography-font-weight-emphasis,600);--wp-ui-button-background-color:var(--wpds-color-background-interactive-brand-strong,var(--wp-admin-theme-color,#3858e9));--wp-ui-button-background-color-active:var(--wpds-color-background-interactive-brand-strong-active,color-mix(in oklch,var(--wp-admin-theme-color,#3858e9) 93%,#000));--wp-ui-button-background-color-disabled:var(--wpds-color-background-interactive-brand-strong-disabled,#e6e6e6);--wp-ui-button-foreground-color:var(--wpds-color-foreground-interactive-brand-strong,#fff);--wp-ui-button-foreground-color-active:var(--wpds-color-foreground-interactive-brand-strong-active,#fff);--wp-ui-button-foreground-color-disabled:var(--wpds-color-foreground-interactive-brand-strong-disabled,#8d8d8d);--wp-ui-button-padding-block:var(--wpds-dimension-padding-xs,4px);--wp-ui-button-padding-inline:var(--wpds-dimension-padding-md,12px);--wp-ui-button-height:var(--wpds-dimension-size-lg,40px);--wp-ui-button-aspect-ratio:auto;--wp-ui-button-font-size:var(--wpds-typography-font-size-md,13px);--wp-ui-button-min-width:calc(4ch + var(--wp-ui-button-padding-inline)*2);--wp-ui-button-icon-margin:calc((var(--wpds-dimension-size-2xs, 16px) - var(--wpds-dimension-size-sm, 24px))/2);--wp-ui-button-border-color:var(--wp-ui-button-background-color);--wp-ui-button-border-color-active:var(--wp-ui-button-background-color-active);--wp-ui-button-border-color-disabled:var(--wp-ui-button-background-color-disabled);--_gcd-button-font-family:var(--wpds-typography-font-family-body,-apple-system,system-ui,"Segoe UI","Roboto","Oxygen-Sans","Ubuntu","Cantarell","Helvetica Neue",sans-serif);--_gcd-button-font-size:var(--wp-ui-button-font-size);--_gcd-button-font-weight:var(--wp-ui-button-font-weight);align-items:center;aspect-ratio:var(--wp-ui-button-aspect-ratio);background-clip:border-box;background-color:var(--wp-ui-button-background-color);border-color:var(--wp-ui-button-border-color);border-radius:var(--wpds-border-radius-sm,2px);border-style:solid;border-width:1px;color:var(--wp-ui-button-foreground-color);display:inline-flex;font-family:var(--wpds-typography-font-family-body,-apple-system,system-ui,"Segoe UI","Roboto","Oxygen-Sans","Ubuntu","Cantarell","Helvetica Neue",sans-serif);font-size:var(--wp-ui-button-font-size);font-weight:var(--wp-ui-button-font-weight);gap:var(--wpds-dimension-gap-sm,8px);justify-content:center;line-height:var(--wpds-typography-line-height-sm,20px);max-width:100%;min-height:var(--wp-ui-button-height);min-width:var(--wp-ui-button-min-width);overflow-wrap:anywhere;padding-block:var(--wp-ui-button-padding-block);padding-inline:var(--wp-ui-button-padding-inline);position:relative;text-align:center;text-decoration:none;&:not([data-disabled]){cursor:var(--wpds-cursor-control,pointer)}@media not (prefers-reduced-motion){transition:color .1s ease-out;*{transition:opacity .1s ease-out}}&[href]{cursor:pointer}[href]{color:inherit;text-decoration:inherit}&:not([data-disabled]):is(:hover,:active,:focus){background-color:var(--wp-ui-button-background-color-active);border-color:var(--wp-ui-button-border-color-active);color:var(--wp-ui-button-foreground-color-active)}&[data-disabled]:not(._914b42f315c0e580__is-loading){background-color:var(--wp-ui-button-background-color-disabled);border-color:var(--wp-ui-button-border-color-disabled);color:var(--wp-ui-button-foreground-color-disabled);@media (forced-colors:active){border-bottom-color:GrayText;border-left-color:GrayText;border-right-color:GrayText;border-top-color:GrayText;color:GrayText}}&:before{aspect-ratio:1;border:var(--wpds-border-width-focus,var(--wp-admin-border-width-focus,2px)) solid;border-block-end-color:transparent;border-block-start-color:var(--wp-ui-button-foreground-color);border-inline-end-color:var(--wp-ui-button-foreground-color);border-inline-start-color:transparent;border-radius:50%;box-sizing:border-box;content:"";display:block;height:var(--wp-ui-button-font-size);inset-inline-start:50%;opacity:0;pointer-events:none;position:absolute;top:50%;transform:translate(-50%,-50%);@media not (prefers-reduced-motion){transition:opacity .1s ease-out}@media (forced-colors:active){border-block-end-style:none;border-bottom-color:ButtonText;border-inline-start-style:none;border-left-color:ButtonText;border-right-color:ButtonText;border-top-color:ButtonText}}}._908205475f9f2a92__is-small{--wp-ui-button-padding-block:0px;--wp-ui-button-padding-inline:var(--wpds-dimension-padding-sm,8px);--wp-ui-button-height:var(--wpds-dimension-size-sm,24px)}._9f6fc6553aeb36fe__icon{margin:var(--wp-ui-button-icon-margin)}.dd460c965226cc77__is-brand{&._62d5a778b7b258ee__is-outline,&.ad0619a3217c6a5b__is-minimal{--wp-ui-button-foreground-color:var(--wpds-color-foreground-interactive-brand,var(--wp-admin-theme-color,#3858e9));--wp-ui-button-foreground-color-active:var(--wpds-color-foreground-interactive-brand-active,color-mix(in oklch,var(--wp-admin-theme-color,#3858e9) 52%,#000));--wp-ui-button-foreground-color-disabled:var(--wpds-color-foreground-interactive-brand-disabled,#8d8d8d)}&._62d5a778b7b258ee__is-outline{--wp-ui-button-background-color:var(--wpds-color-background-interactive-brand-weak,#0000);--wp-ui-button-background-color-active:var(--wpds-color-background-interactive-brand-weak-active,color-mix(in oklch,var(--wp-admin-theme-color,#3858e9) 12%,#fff));--wp-ui-button-background-color-disabled:var(--wpds-color-background-interactive-brand-weak-disabled,#0000);--wp-ui-button-border-color:var(--wpds-color-stroke-interactive-brand,var(--wp-admin-theme-color,#3858e9));--wp-ui-button-border-color-active:var(--wpds-color-stroke-interactive-brand-active,color-mix(in oklch,var(--wp-admin-theme-color,#3858e9) 85%,#000));--wp-ui-button-border-color-disabled:var(--wpds-color-stroke-interactive-brand-disabled,#dbdbdb)}&.ad0619a3217c6a5b__is-minimal{--wp-ui-button-background-color:var(--wpds-color-background-interactive-brand-weak,#0000);--wp-ui-button-background-color-active:var(--wpds-color-background-interactive-brand-weak-active,color-mix(in oklch,var(--wp-admin-theme-color,#3858e9) 12%,#fff));--wp-ui-button-background-color-disabled:var(--wpds-color-background-interactive-brand-weak-disabled,#0000)}}.e722a8f96726aa99__is-neutral{&.ad0619a3217c6a5b__is-minimal[aria-pressed=true],&.b50b3358c5fb4d0b__is-solid{--wp-ui-button-background-color:var(--wpds-color-background-interactive-neutral-strong,#2d2d2d);--wp-ui-button-background-color-active:var(--wpds-color-background-interactive-neutral-strong-active,#1e1e1e);--wp-ui-button-background-color-disabled:var(--wpds-color-background-interactive-neutral-strong-disabled,#e6e6e6);--wp-ui-button-foreground-color:var(--wpds-color-foreground-interactive-neutral-strong,#f0f0f0);--wp-ui-button-foreground-color-active:var(--wpds-color-foreground-interactive-neutral-strong-active,#f0f0f0);--wp-ui-button-foreground-color-disabled:var(--wpds-color-foreground-interactive-neutral-strong-disabled,#8d8d8d)}&._62d5a778b7b258ee__is-outline,&.ad0619a3217c6a5b__is-minimal:not([aria-pressed=true]){--wp-ui-button-foreground-color:var(--wpds-color-foreground-interactive-neutral,#1e1e1e);--wp-ui-button-foreground-color-active:var(--wpds-color-foreground-interactive-neutral-active,#1e1e1e);--wp-ui-button-foreground-color-disabled:var(--wpds-color-foreground-interactive-neutral-disabled,#8d8d8d)}&._62d5a778b7b258ee__is-outline{--wp-ui-button-background-color:var(--wpds-color-background-interactive-neutral-weak,#0000);--wp-ui-button-background-color-active:var(--wpds-color-background-interactive-neutral-weak-active,#ededed);--wp-ui-button-background-color-disabled:var(--wpds-color-background-interactive-neutral-weak-disabled,#0000);--wp-ui-button-border-color:var(--wpds-color-stroke-interactive-neutral,#8d8d8d);--wp-ui-button-border-color-active:var(--wpds-color-stroke-interactive-neutral-active,#6e6e6e);--wp-ui-button-border-color-disabled:var(--wpds-color-stroke-interactive-neutral-disabled,#dbdbdb)}&.ad0619a3217c6a5b__is-minimal:not([aria-pressed=true]){--wp-ui-button-background-color:var(--wpds-color-background-interactive-neutral-weak,#0000);--wp-ui-button-background-color-active:var(--wpds-color-background-interactive-neutral-weak-active,#ededed);--wp-ui-button-background-color-disabled:var(--wpds-color-background-interactive-neutral-weak-disabled,#0000)}}.abbb272e2ce49bd6__is-unstyled{background:none;border:none;min-width:unset}.cf59cf1b69629838__is-compact{--wp-ui-button-height:var(--wpds-dimension-size-md,32px)}._914b42f315c0e580__is-loading:not(.abbb272e2ce49bd6__is-unstyled){color:transparent;&:not([data-disabled]):is(:hover,:active,:focus){color:transparent}@media (forced-colors:active){color:ButtonFace}*{opacity:0}&:before{opacity:1;transition-delay:.05s;@media not (prefers-reduced-motion){animation:_5a1d53da6f830c8d__loading-animation 1s linear infinite}}}}@keyframes _5a1d53da6f830c8d__loading-animation{0%{transform:translate(-50%,-50%) rotate(0deg)}to{transform:translate(-50%,-50%) rotate(1turn)}}}');var Jo={button:"_97b0fc33c028be1a__button","is-unstyled":"abbb272e2ce49bd6__is-unstyled","is-loading":"_914b42f315c0e580__is-loading","is-small":"_908205475f9f2a92__is-small",icon:"_9f6fc6553aeb36fe__icon","is-brand":"dd460c965226cc77__is-brand","is-outline":"_62d5a778b7b258ee__is-outline","is-minimal":"ad0619a3217c6a5b__is-minimal","is-neutral":"e722a8f96726aa99__is-neutral","is-solid":"b50b3358c5fb4d0b__is-solid","is-compact":"cf59cf1b69629838__is-compact","loading-animation":"_5a1d53da6f830c8d__loading-animation"};typeof process>"u",ir("10f3806643","@layer wp-ui{@layer utilities, components, compositions, overrides;@layer utilities{._336cd3e4e743482f__box-sizing{box-sizing:border-box;*,:after,:before{box-sizing:inherit}}}}");var Tg={"box-sizing":"_336cd3e4e743482f__box-sizing"};typeof process>"u",ir("da99a163ac","@layer wp-ui{@layer utilities, components, compositions, overrides;@layer utilities{._08e8a2e44959f892__outset-ring--focus:focus,.c5cb3ee4bddaa8e4__outset-ring--focus-within-visible:focus-within:has(:focus-visible),.cd83dfc2126a0846__outset-ring--focus-within:focus-within,.d0541bc9dd9dc7b6__outset-ring--focus-visible:focus-visible,:focus-visible .ecadb9e080e2dfa5__outset-ring--focus-parent-visible{--_gcd-a-outline:var(--wpds-border-width-focus,var(--wp-admin-border-width-focus,2px)) solid var(--wpds-color-stroke-focus,var(--wp-admin-theme-color,#3858e9));--_gcd-div-outline:var(--wpds-border-width-focus,var(--wp-admin-border-width-focus,2px)) solid var(--wpds-color-stroke-focus,var(--wp-admin-theme-color,#3858e9));outline:var(--wpds-border-width-focus,var(--wp-admin-border-width-focus,2px)) solid var(--wpds-color-stroke-focus,var(--wp-admin-theme-color,#3858e9));outline-offset:var(--wpds-border-width-focus,var(--wp-admin-border-width-focus,2px))}._970d04df7376df67__outset-ring--focus-within-except-active:focus-within,.e25b2bdd7aa21721__outset-ring--focus-except-active:focus{outline:none}._970d04df7376df67__outset-ring--focus-within-except-active:focus-within:not(:has(:active)),.e25b2bdd7aa21721__outset-ring--focus-except-active:focus:not(:active){@include mixins.focus-ring()}}}");var kg={"outset-ring--focus":"_08e8a2e44959f892__outset-ring--focus","outset-ring--focus-visible":"d0541bc9dd9dc7b6__outset-ring--focus-visible","outset-ring--focus-within":"cd83dfc2126a0846__outset-ring--focus-within","outset-ring--focus-within-visible":"c5cb3ee4bddaa8e4__outset-ring--focus-within-visible","outset-ring--focus-parent-visible":"ecadb9e080e2dfa5__outset-ring--focus-parent-visible","outset-ring--focus-except-active":"e25b2bdd7aa21721__outset-ring--focus-except-active","outset-ring--focus-within-except-active":"_970d04df7376df67__outset-ring--focus-within-except-active"};typeof process>"u",ir("af6d9984a6","._6defc79820e382c6__button{box-sizing:var(--_gcd-button-box-sizing,border-box);font-family:var(--_gcd-button-font-family,inherit);font-size:var(--_gcd-button-font-size,inherit);font-weight:var(--_gcd-button-font-weight,inherit)}.d2cff2e5dea83bd1__input{box-sizing:var(--_gcd-input-box-sizing,border-box);font-family:var(--_gcd-input-font-family,inherit);font-size:var(--_gcd-input-font-size,inherit);font-weight:var(--_gcd-input-font-weight,inherit);margin:var(--_gcd-input-margin,0);&:is(textarea,[type=text],[type=password],[type=color],[type=date],[type=datetime],[type=datetime-local],[type=email],[type=month],[type=number],[type=search],[type=tel],[type=time],[type=url],[type=week]){background-color:var(--_gcd-input-background-color,transparent);border:var(--_gcd-input-border,none);border-radius:var(--_gcd-input-border-radius,0);box-shadow:var(--_gcd-input-box-shadow,0 0 0 transparent);color:var(--_gcd-input-color,var(--wpds-color-foreground-interactive-neutral,#1e1e1e));&:focus{border-color:var(--_gcd-input-border-color-focus,var(--wp-admin-theme-color));box-shadow:var(--_gcd-input-box-shadow-focus,none);outline:var(--_gcd-input-outline-focus,none)}&:disabled{background:var(--_gcd-input-background-disabled,transparent);border-color:var(--_gcd-input-border-color-disabled,transparent);box-shadow:var(--_gcd-input-box-shadow-disabled,none);color:var(--_gcd-input-color-disabled,var(--wpds-color-foreground-interactive-neutral-disabled,#8d8d8d))}&::placeholder{color:var(--_gcd-input-placeholder-color,var(--wpds-color-foreground-interactive-neutral-disabled,#8d8d8d))}}&:is(textarea,[type=text],[type=password],[type=date],[type=datetime],[type=datetime-local],[type=email],[type=month],[type=number],[type=search],[type=tel],[type=time],[type=url],[type=week]){line-height:var(--_gcd-input-line-height,inherit);min-height:var(--_gcd-input-min-height,auto);padding:var(--_gcd-input-padding,0)}}._547d86373d02e108__textarea{box-sizing:var(--_gcd-textarea-box-sizing,border-box);overflow:var(--_gcd-textarea-overflow,auto);resize:var(--_gcd-textarea-resize,block)}._8c15fd0ed9f28ba4__div{outline:var(--_gcd-div-outline,0 solid transparent)}p._43cec3e1eec1066d__p{font-size:var(--_gcd-p-font-size,13px);line-height:var(--_gcd-p-line-height,1.5);margin:var(--_gcd-p-margin,0)}:is(h1,h2,h3,h4,h5,h6).e97669c6d9a38497__heading{color:var(--_gcd-heading-color,var(--wpds-color-foreground-content-neutral,#1e1e1e));font-size:var(--_gcd-heading-font-size,inherit);font-weight:var(--_gcd-heading-font-weight,var(--wpds-typography-font-weight-emphasis,600));margin:var(--_gcd-heading-margin,0)}._2c0831b0499dbd6e__a,._2c0831b0499dbd6e__a:is(:hover,:focus,:active){border-radius:var(--_gcd-a-border-radius,0);box-shadow:var(--_gcd-a-box-shadow,none);color:var(--_gcd-a-color,inherit);outline:var(--_gcd-a-outline,0 solid transparent);transition:var(--_gcd-a-transition,none)}");var Pg={button:"_6defc79820e382c6__button",input:"d2cff2e5dea83bd1__input",textarea:"_547d86373d02e108__textarea",div:"_8c15fd0ed9f28ba4__div",p:"_43cec3e1eec1066d__p",heading:"e97669c6d9a38497__heading",a:"_2c0831b0499dbd6e__a"},pd=(0,rr.forwardRef)(function({tone:t="brand",variant:o="solid",size:n="default",className:r,focusableWhenDisabled:i=!0,disabled:s,loading:a,loadingAnnouncement:d=(0,dd.__)("Loading"),children:c,...l},f){let p=$(Pg.button,Tg["box-sizing"],kg["outset-ring--focus-except-active"],o!=="unstyled"&&Jo.button,Jo[`is-${t}`],Jo[`is-${o}`],Jo[`is-${n}`],a&&Jo["is-loading"],r);return(0,rr.useEffect)(()=>{a&&d&&Rg(d)},[a,d]),(0,fd.jsx)(_i,{ref:f,className:p,focusableWhenDisabled:i,disabled:s??a,...l,children:c})});var wd=h(de(),1);var gd=h(de(),1),bd=h($t(),1),hd=h(Q(),1),eo=(0,gd.forwardRef)(function({icon:t,size:o=24,...n},r){return(0,hd.jsx)(bd.SVG,{ref:r,...t.props,...n,width:o,height:o})});var _d=h(Q(),1),Hi="data-wp-hash";function zi(){let e=globalThis;return e.__wpStyleRuntime||(e.__wpStyleRuntime={documents:new Map,styles:new Map,injectedStyles:new WeakMap},typeof document<"u"&&Ag(document)),e.__wpStyleRuntime}function Cg(e,t){if(!e.head)return!1;for(let o of e.head.querySelectorAll(`style[${Hi}]`))if(o.getAttribute(Hi)===t)return!0;return!1}function vd(e,t,o){if(!e.head)return;let n=zi(),r=n.injectedStyles.get(e);if(r||(r=new Set,n.injectedStyles.set(e,r)),r.has(t))return;if(Cg(e,t)){r.add(t);return}let i=e.createElement("style");i.setAttribute(Hi,t),i.appendChild(e.createTextNode(o)),e.head.appendChild(i),r.add(t)}function Ag(e){let t=zi();t.documents.set(e,(t.documents.get(e)??0)+1);for(let[o,n]of t.styles)vd(e,o,n);return()=>{let o=t.documents.get(e);if(o!==void 0){if(o<=1){t.documents.delete(e);return}t.documents.set(e,o-1)}}}function Og(e,t){let o=zi();o.styles.set(e,t);for(let n of o.documents.keys())vd(n,e,t)}typeof process>"u",Og("b74f1ac304",'@layer wp-ui{@layer utilities, components, compositions, overrides;@layer components{._97b0fc33c028be1a__button,.abbb272e2ce49bd6__is-unstyled{appearance:none;padding:0}._97b0fc33c028be1a__button{--wp-ui-button-font-weight:var(--wpds-typography-font-weight-emphasis,600);--wp-ui-button-background-color:var(--wpds-color-background-interactive-brand-strong,var(--wp-admin-theme-color,#3858e9));--wp-ui-button-background-color-active:var(--wpds-color-background-interactive-brand-strong-active,color-mix(in oklch,var(--wp-admin-theme-color,#3858e9) 93%,#000));--wp-ui-button-background-color-disabled:var(--wpds-color-background-interactive-brand-strong-disabled,#e6e6e6);--wp-ui-button-foreground-color:var(--wpds-color-foreground-interactive-brand-strong,#fff);--wp-ui-button-foreground-color-active:var(--wpds-color-foreground-interactive-brand-strong-active,#fff);--wp-ui-button-foreground-color-disabled:var(--wpds-color-foreground-interactive-brand-strong-disabled,#8d8d8d);--wp-ui-button-padding-block:var(--wpds-dimension-padding-xs,4px);--wp-ui-button-padding-inline:var(--wpds-dimension-padding-md,12px);--wp-ui-button-height:var(--wpds-dimension-size-lg,40px);--wp-ui-button-aspect-ratio:auto;--wp-ui-button-font-size:var(--wpds-typography-font-size-md,13px);--wp-ui-button-min-width:calc(4ch + var(--wp-ui-button-padding-inline)*2);--wp-ui-button-icon-margin:calc((var(--wpds-dimension-size-2xs, 16px) - var(--wpds-dimension-size-sm, 24px))/2);--wp-ui-button-border-color:var(--wp-ui-button-background-color);--wp-ui-button-border-color-active:var(--wp-ui-button-background-color-active);--wp-ui-button-border-color-disabled:var(--wp-ui-button-background-color-disabled);--_gcd-button-font-family:var(--wpds-typography-font-family-body,-apple-system,system-ui,"Segoe UI","Roboto","Oxygen-Sans","Ubuntu","Cantarell","Helvetica Neue",sans-serif);--_gcd-button-font-size:var(--wp-ui-button-font-size);--_gcd-button-font-weight:var(--wp-ui-button-font-weight);align-items:center;aspect-ratio:var(--wp-ui-button-aspect-ratio);background-clip:border-box;background-color:var(--wp-ui-button-background-color);border-color:var(--wp-ui-button-border-color);border-radius:var(--wpds-border-radius-sm,2px);border-style:solid;border-width:1px;color:var(--wp-ui-button-foreground-color);display:inline-flex;font-family:var(--wpds-typography-font-family-body,-apple-system,system-ui,"Segoe UI","Roboto","Oxygen-Sans","Ubuntu","Cantarell","Helvetica Neue",sans-serif);font-size:var(--wp-ui-button-font-size);font-weight:var(--wp-ui-button-font-weight);gap:var(--wpds-dimension-gap-sm,8px);justify-content:center;line-height:var(--wpds-typography-line-height-sm,20px);max-width:100%;min-height:var(--wp-ui-button-height);min-width:var(--wp-ui-button-min-width);overflow-wrap:anywhere;padding-block:var(--wp-ui-button-padding-block);padding-inline:var(--wp-ui-button-padding-inline);position:relative;text-align:center;text-decoration:none;&:not([data-disabled]){cursor:var(--wpds-cursor-control,pointer)}@media not (prefers-reduced-motion){transition:color .1s ease-out;*{transition:opacity .1s ease-out}}&[href]{cursor:pointer}[href]{color:inherit;text-decoration:inherit}&:not([data-disabled]):is(:hover,:active,:focus){background-color:var(--wp-ui-button-background-color-active);border-color:var(--wp-ui-button-border-color-active);color:var(--wp-ui-button-foreground-color-active)}&[data-disabled]:not(._914b42f315c0e580__is-loading){background-color:var(--wp-ui-button-background-color-disabled);border-color:var(--wp-ui-button-border-color-disabled);color:var(--wp-ui-button-foreground-color-disabled);@media (forced-colors:active){border-bottom-color:GrayText;border-left-color:GrayText;border-right-color:GrayText;border-top-color:GrayText;color:GrayText}}&:before{aspect-ratio:1;border:var(--wpds-border-width-focus,var(--wp-admin-border-width-focus,2px)) solid;border-block-end-color:transparent;border-block-start-color:var(--wp-ui-button-foreground-color);border-inline-end-color:var(--wp-ui-button-foreground-color);border-inline-start-color:transparent;border-radius:50%;box-sizing:border-box;content:"";display:block;height:var(--wp-ui-button-font-size);inset-inline-start:50%;opacity:0;pointer-events:none;position:absolute;top:50%;transform:translate(-50%,-50%);@media not (prefers-reduced-motion){transition:opacity .1s ease-out}@media (forced-colors:active){border-block-end-style:none;border-bottom-color:ButtonText;border-inline-start-style:none;border-left-color:ButtonText;border-right-color:ButtonText;border-top-color:ButtonText}}}._908205475f9f2a92__is-small{--wp-ui-button-padding-block:0px;--wp-ui-button-padding-inline:var(--wpds-dimension-padding-sm,8px);--wp-ui-button-height:var(--wpds-dimension-size-sm,24px)}._9f6fc6553aeb36fe__icon{margin:var(--wp-ui-button-icon-margin)}.dd460c965226cc77__is-brand{&._62d5a778b7b258ee__is-outline,&.ad0619a3217c6a5b__is-minimal{--wp-ui-button-foreground-color:var(--wpds-color-foreground-interactive-brand,var(--wp-admin-theme-color,#3858e9));--wp-ui-button-foreground-color-active:var(--wpds-color-foreground-interactive-brand-active,color-mix(in oklch,var(--wp-admin-theme-color,#3858e9) 52%,#000));--wp-ui-button-foreground-color-disabled:var(--wpds-color-foreground-interactive-brand-disabled,#8d8d8d)}&._62d5a778b7b258ee__is-outline{--wp-ui-button-background-color:var(--wpds-color-background-interactive-brand-weak,#0000);--wp-ui-button-background-color-active:var(--wpds-color-background-interactive-brand-weak-active,color-mix(in oklch,var(--wp-admin-theme-color,#3858e9) 12%,#fff));--wp-ui-button-background-color-disabled:var(--wpds-color-background-interactive-brand-weak-disabled,#0000);--wp-ui-button-border-color:var(--wpds-color-stroke-interactive-brand,var(--wp-admin-theme-color,#3858e9));--wp-ui-button-border-color-active:var(--wpds-color-stroke-interactive-brand-active,color-mix(in oklch,var(--wp-admin-theme-color,#3858e9) 85%,#000));--wp-ui-button-border-color-disabled:var(--wpds-color-stroke-interactive-brand-disabled,#dbdbdb)}&.ad0619a3217c6a5b__is-minimal{--wp-ui-button-background-color:var(--wpds-color-background-interactive-brand-weak,#0000);--wp-ui-button-background-color-active:var(--wpds-color-background-interactive-brand-weak-active,color-mix(in oklch,var(--wp-admin-theme-color,#3858e9) 12%,#fff));--wp-ui-button-background-color-disabled:var(--wpds-color-background-interactive-brand-weak-disabled,#0000)}}.e722a8f96726aa99__is-neutral{&.ad0619a3217c6a5b__is-minimal[aria-pressed=true],&.b50b3358c5fb4d0b__is-solid{--wp-ui-button-background-color:var(--wpds-color-background-interactive-neutral-strong,#2d2d2d);--wp-ui-button-background-color-active:var(--wpds-color-background-interactive-neutral-strong-active,#1e1e1e);--wp-ui-button-background-color-disabled:var(--wpds-color-background-interactive-neutral-strong-disabled,#e6e6e6);--wp-ui-button-foreground-color:var(--wpds-color-foreground-interactive-neutral-strong,#f0f0f0);--wp-ui-button-foreground-color-active:var(--wpds-color-foreground-interactive-neutral-strong-active,#f0f0f0);--wp-ui-button-foreground-color-disabled:var(--wpds-color-foreground-interactive-neutral-strong-disabled,#8d8d8d)}&._62d5a778b7b258ee__is-outline,&.ad0619a3217c6a5b__is-minimal:not([aria-pressed=true]){--wp-ui-button-foreground-color:var(--wpds-color-foreground-interactive-neutral,#1e1e1e);--wp-ui-button-foreground-color-active:var(--wpds-color-foreground-interactive-neutral-active,#1e1e1e);--wp-ui-button-foreground-color-disabled:var(--wpds-color-foreground-interactive-neutral-disabled,#8d8d8d)}&._62d5a778b7b258ee__is-outline{--wp-ui-button-background-color:var(--wpds-color-background-interactive-neutral-weak,#0000);--wp-ui-button-background-color-active:var(--wpds-color-background-interactive-neutral-weak-active,#ededed);--wp-ui-button-background-color-disabled:var(--wpds-color-background-interactive-neutral-weak-disabled,#0000);--wp-ui-button-border-color:var(--wpds-color-stroke-interactive-neutral,#8d8d8d);--wp-ui-button-border-color-active:var(--wpds-color-stroke-interactive-neutral-active,#6e6e6e);--wp-ui-button-border-color-disabled:var(--wpds-color-stroke-interactive-neutral-disabled,#dbdbdb)}&.ad0619a3217c6a5b__is-minimal:not([aria-pressed=true]){--wp-ui-button-background-color:var(--wpds-color-background-interactive-neutral-weak,#0000);--wp-ui-button-background-color-active:var(--wpds-color-background-interactive-neutral-weak-active,#ededed);--wp-ui-button-background-color-disabled:var(--wpds-color-background-interactive-neutral-weak-disabled,#0000)}}.abbb272e2ce49bd6__is-unstyled{background:none;border:none;min-width:unset}.cf59cf1b69629838__is-compact{--wp-ui-button-height:var(--wpds-dimension-size-md,32px)}._914b42f315c0e580__is-loading:not(.abbb272e2ce49bd6__is-unstyled){color:transparent;&:not([data-disabled]):is(:hover,:active,:focus){color:transparent}@media (forced-colors:active){color:ButtonFace}*{opacity:0}&:before{opacity:1;transition-delay:.05s;@media not (prefers-reduced-motion){animation:_5a1d53da6f830c8d__loading-animation 1s linear infinite}}}}@keyframes _5a1d53da6f830c8d__loading-animation{0%{transform:translate(-50%,-50%) rotate(0deg)}to{transform:translate(-50%,-50%) rotate(1turn)}}}');var Ng={button:"_97b0fc33c028be1a__button","is-unstyled":"abbb272e2ce49bd6__is-unstyled","is-loading":"_914b42f315c0e580__is-loading","is-small":"_908205475f9f2a92__is-small",icon:"_9f6fc6553aeb36fe__icon","is-brand":"dd460c965226cc77__is-brand","is-outline":"_62d5a778b7b258ee__is-outline","is-minimal":"ad0619a3217c6a5b__is-minimal","is-neutral":"e722a8f96726aa99__is-neutral","is-solid":"b50b3358c5fb4d0b__is-solid","is-compact":"cf59cf1b69629838__is-compact","loading-animation":"_5a1d53da6f830c8d__loading-animation"},Di=(0,wd.forwardRef)(function({className:t,icon:o,...n},r){return(0,_d.jsx)(eo,{ref:r,icon:o,className:$(Ng.icon,t),size:24,...n})});Di.displayName="Button.Icon";var sr=Object.assign(pd,{Icon:Di});var ar=h($t(),1),ji=h(Q(),1),Fi=(0,ji.jsx)(ar.SVG,{xmlns:"http://www.w3.org/2000/svg",viewBox:"0 0 24 24",fill:"currentColor",children:(0,ji.jsx)(ar.Path,{fillRule:"evenodd",clipRule:"evenodd",d:"M5.5 12a6.5 6.5 0 1 0 13 0 6.5 6.5 0 0 0-13 0ZM12 4a8 8 0 1 0 0 16 8 8 0 0 0 0-16Zm-.75 12v-1.5h1.5V16h-1.5Zm0-8v5h1.5V8h-1.5Z"})});var cr=h($t(),1),Vi=h(Q(),1),Wi=(0,Vi.jsx)(cr.SVG,{xmlns:"http://www.w3.org/2000/svg",viewBox:"0 0 24 24",fill:"currentColor",children:(0,Vi.jsx)(cr.Path,{d:"M12 13.06l3.712 3.713 1.061-1.06L13.061 12l3.712-3.712-1.06-1.06L12 10.938 8.288 7.227l-1.061 1.06L10.939 12l-3.712 3.712 1.06 1.061L12 13.061z"})});var lr=h($t(),1),Yi=h(Q(),1),Ui=(0,Yi.jsx)(lr.SVG,{xmlns:"http://www.w3.org/2000/svg",viewBox:"0 0 24 24",fill:"currentColor",children:(0,Yi.jsx)(lr.Path,{fillRule:"evenodd",clipRule:"evenodd",d:"M12.218 5.377a.25.25 0 0 0-.436 0l-7.29 12.96a.25.25 0 0 0 .218.373h14.58a.25.25 0 0 0 .218-.372l-7.29-12.96Zm-1.743-.735c.669-1.19 2.381-1.19 3.05 0l7.29 12.96a1.75 1.75 0 0 1-1.525 2.608H4.71a1.75 1.75 0 0 1-1.525-2.608l7.29-12.96ZM12.75 17.46h-1.5v-1.5h1.5v1.5Zm-1.5-3h1.5v-5h-1.5v5Z"})});var dr=h($t(),1),Gi=h(Q(),1),Xi=(0,Gi.jsx)(dr.SVG,{xmlns:"http://www.w3.org/2000/svg",viewBox:"0 0 24 24",fill:"currentColor",children:(0,Gi.jsx)(dr.Path,{fillRule:"evenodd",clipRule:"evenodd",d:"M5.5 12a6.5 6.5 0 1 0 13 0 6.5 6.5 0 0 0-13 0ZM12 4a8 8 0 1 0 0 16 8 8 0 0 0 0-16Zm.75 4v1.5h-1.5V8h1.5Zm0 8v-5h-1.5v5h1.5Z"})});var ur=h($t(),1),Ki=h(Q(),1),qi=(0,Ki.jsx)(ur.SVG,{xmlns:"http://www.w3.org/2000/svg",viewBox:"0 0 24 24",fill:"currentColor",children:(0,Ki.jsx)(ur.Path,{fillRule:"evenodd",clipRule:"evenodd",d:"M12 18.5a6.5 6.5 0 1 1 0-13 6.5 6.5 0 0 1 0 13ZM4 12a8 8 0 1 1 16 0 8 8 0 0 1-16 0Zm11.53-1.47-1.06-1.06L11 12.94l-1.47-1.47-1.06 1.06L11 15.06l4.53-4.53Z"})});var yd=h(de(),1);function Zi(e,t,o){return(0,yd.cloneElement)(e??t,{children:o})}var Lg=h(Rd(),1);var Ed=h(Qi(),1),{lock:h4,unlock:Td}=(0,Ed.__dangerousOptInToUnstableAPIsOnlyForCoreModules)("I acknowledge private features are not for use in themes or plugins and doing so will break in the next version of WordPress.","@wordpress/ui");function Ig(){let e=Lg;if(e.ThemeProvider)return e.ThemeProvider;if(!e.privateApis)throw new Error("@wordpress/ui: @wordpress/theme must expose `ThemeProvider` or `privateApis.ThemeProvider`.");return Td(e.privateApis).ThemeProvider}var kd=Ig();var Pd=h(de(),1),Ji="data-wp-hash";function $i(){let e=globalThis;return e.__wpStyleRuntime||(e.__wpStyleRuntime={documents:new Map,styles:new Map,injectedStyles:new WeakMap},typeof document<"u"&&Bg(document)),e.__wpStyleRuntime}function Mg(e,t){if(!e.head)return!1;for(let o of e.head.querySelectorAll(`style[${Ji}]`))if(o.getAttribute(Ji)===t)return!0;return!1}function Cd(e,t,o){if(!e.head)return;let n=$i(),r=n.injectedStyles.get(e);if(r||(r=new Set,n.injectedStyles.set(e,r)),r.has(t))return;if(Mg(e,t)){r.add(t);return}let i=e.createElement("style");i.setAttribute(Ji,t),i.appendChild(e.createTextNode(o)),e.head.appendChild(i),r.add(t)}function Bg(e){let t=$i();t.documents.set(e,(t.documents.get(e)??0)+1);for(let[o,n]of t.styles)Cd(e,o,n);return()=>{let o=t.documents.get(e);if(o!==void 0){if(o<=1){t.documents.delete(e);return}t.documents.set(e,o-1)}}}function Hg(e,t){let o=$i();o.styles.set(e,t);for(let n of o.documents.keys())Cd(n,e,t)}typeof process>"u",Hg("32aba35fe1","@layer wp-ui{@layer utilities, components, compositions, overrides;@layer components{._19ce0419607e1896__stack{display:flex}}}");var zg={stack:"_19ce0419607e1896__stack"},Dg={xs:"var(--wpds-dimension-gap-xs, 4px)",sm:"var(--wpds-dimension-gap-sm, 8px)",md:"var(--wpds-dimension-gap-md, 12px)",lg:"var(--wpds-dimension-gap-lg, 16px)",xl:"var(--wpds-dimension-gap-xl, 24px)","2xl":"var(--wpds-dimension-gap-2xl, 32px)","3xl":"var(--wpds-dimension-gap-3xl, 40px)"},Po=(0,Pd.forwardRef)(function({direction:t,gap:o,align:n,justify:r,wrap:i,render:s,...a},d){let c={gap:o&&Dg[o],alignItems:n,justifyContent:r,flexDirection:t,flexWrap:i};return bt({render:s,ref:d,props:ye(a,{style:c,className:zg.stack})})});var Kd=h(de(),1);var Vd=h(de(),1);var Id=h(de(),1);var ts="data-wp-hash";function os(){let e=globalThis;return e.__wpStyleRuntime||(e.__wpStyleRuntime={documents:new Map,styles:new Map,injectedStyles:new WeakMap},typeof document<"u"&&Fg(document)),e.__wpStyleRuntime}function jg(e,t){if(!e.head)return!1;for(let o of e.head.querySelectorAll(`style[${ts}]`))if(o.getAttribute(ts)===t)return!0;return!1}function Od(e,t,o){if(!e.head)return;let n=os(),r=n.injectedStyles.get(e);if(r||(r=new Set,n.injectedStyles.set(e,r)),r.has(t))return;if(jg(e,t)){r.add(t);return}let i=e.createElement("style");i.setAttribute(ts,t),i.appendChild(e.createTextNode(o)),e.head.appendChild(i),r.add(t)}function Fg(e){let t=os();t.documents.set(e,(t.documents.get(e)??0)+1);for(let[o,n]of t.styles)Od(e,o,n);return()=>{let o=t.documents.get(e);if(o!==void 0){if(o<=1){t.documents.delete(e);return}t.documents.set(e,o-1)}}}function Vg(e,t){let o=os();o.styles.set(e,t);for(let n of o.documents.keys())Od(n,e,t)}typeof process>"u",Vg("be37f31c1e","._11fc52b637ff8a7e__slot{inset:0;isolation:isolate;pointer-events:none;position:fixed;z-index:1000000003}@layer wp-ui{@layer utilities, components, compositions, overrides;@layer utilities{._11fc52b637ff8a7e__slot>*{pointer-events:auto}}}");var Ad={slot:"_11fc52b637ff8a7e__slot"},Nd="data-wp-compat-overlay-slot";function Wg(){return typeof document>"u"?null:document}function Yg(){let e;try{e=window.top?.wp}catch{}let t=e??window.wp;return typeof t?.components=="object"&&t.components!==null}var ht=null;function es(e){return e.setAttribute("aria-hidden","false"),e}function Ug(e){let t=e.createElement("div");return t.setAttribute(Nd,""),Ad.slot&&t.classList.add(Ad.slot),e.body.appendChild(t),t}function Ld(){if(typeof window>"u"||!Yg()&&window.__wpUiCompatOverlaySlotEnabled!==!0)return;let e=Wg();if(!e||!e.body)return;if(ht&&ht.ownerDocument===e&&ht.isConnected)return es(ht);let t=e.querySelector(`[${Nd}]`);return t instanceof HTMLDivElement?(ht=es(t),ht):(ht?.isConnected&&ht.remove(),ht=es(Ug(e)),ht)}var Md=h(Q(),1),Bd=(0,Id.forwardRef)(function({container:t,...o},n){return(0,Md.jsx)(Qe.Portal,{container:t??Ld(),...o,ref:n})});var Hd=h(de(),1),jd=h(Q(),1),ns="data-wp-hash";function rs(){let e=globalThis;return e.__wpStyleRuntime||(e.__wpStyleRuntime={documents:new Map,styles:new Map,injectedStyles:new WeakMap},typeof document<"u"&&Xg(document)),e.__wpStyleRuntime}function Gg(e,t){if(!e.head)return!1;for(let o of e.head.querySelectorAll(`style[${ns}]`))if(o.getAttribute(ns)===t)return!0;return!1}function zd(e,t,o){if(!e.head)return;let n=rs(),r=n.injectedStyles.get(e);if(r||(r=new Set,n.injectedStyles.set(e,r)),r.has(t))return;if(Gg(e,t)){r.add(t);return}let i=e.createElement("style");i.setAttribute(ns,t),i.appendChild(e.createTextNode(o)),e.head.appendChild(i),r.add(t)}function Xg(e){let t=rs();t.documents.set(e,(t.documents.get(e)??0)+1);for(let[o,n]of t.styles)zd(e,o,n);return()=>{let o=t.documents.get(e);if(o!==void 0){if(o<=1){t.documents.delete(e);return}t.documents.set(e,o-1)}}}function Dd(e,t){let o=rs();o.styles.set(e,t);for(let n of o.documents.keys())zd(n,e,t)}typeof process>"u",Dd("10f3806643","@layer wp-ui{@layer utilities, components, compositions, overrides;@layer utilities{._336cd3e4e743482f__box-sizing{box-sizing:border-box;*,:after,:before{box-sizing:inherit}}}}");var Kg={"box-sizing":"_336cd3e4e743482f__box-sizing"};typeof process>"u",Dd("19fcc06039",'@layer wp-ui{@layer utilities, components, compositions, overrides;@layer components{._480b748dd3510e64__positioner{z-index:var(--wp-ui-tooltip-z-index,initial)}._50096b232db7709d__popup{--_wp-ui-elevation-sm:0 1px 2px rgba(0,0,0,.05),0 2px 3px rgba(0,0,0,.04),0 6px 6px rgba(0,0,0,.03),0 8px 8px rgba(0,0,0,.02);background-color:var(--wpds-color-background-surface-neutral-strong,#fff);border-radius:var(--wpds-border-radius-md,4px);box-shadow:var(--_wp-ui-elevation-sm);color:var(--wpds-color-foreground-content-neutral,#1e1e1e);font-family:var(--wpds-typography-font-family-body,-apple-system,system-ui,"Segoe UI","Roboto","Oxygen-Sans","Ubuntu","Cantarell","Helvetica Neue",sans-serif);font-size:var(--wpds-typography-font-size-sm,12px);line-height:1.4;padding:var(--wpds-dimension-padding-xs,4px) var(--wpds-dimension-padding-sm,8px);@media (forced-colors:active){border-bottom-color:CanvasText;border-bottom-style:solid;border-bottom-width:1px;border-left-color:CanvasText;border-left-style:solid;border-left-width:1px;border-right-color:CanvasText;border-right-style:solid;border-right-width:1px;border-top-color:CanvasText;border-top-style:solid;border-top-width:1px}}}}');var qg={positioner:"_480b748dd3510e64__positioner",popup:"_50096b232db7709d__popup"},Fd=(0,Hd.forwardRef)(function({align:t="center",className:o,side:n="top",sideOffset:r=4,...i},s){return(0,jd.jsx)(Qe.Positioner,{ref:s,align:t,side:n,sideOffset:r,...i,className:$(Kg["box-sizing"],qg.positioner,o)})});var $o=h(Q(),1),is="data-wp-hash";function ss(){let e=globalThis;return e.__wpStyleRuntime||(e.__wpStyleRuntime={documents:new Map,styles:new Map,injectedStyles:new WeakMap},typeof document<"u"&&Qg(document)),e.__wpStyleRuntime}function Zg(e,t){if(!e.head)return!1;for(let o of e.head.querySelectorAll(`style[${is}]`))if(o.getAttribute(is)===t)return!0;return!1}function Wd(e,t,o){if(!e.head)return;let n=ss(),r=n.injectedStyles.get(e);if(r||(r=new Set,n.injectedStyles.set(e,r)),r.has(t))return;if(Zg(e,t)){r.add(t);return}let i=e.createElement("style");i.setAttribute(is,t),i.appendChild(e.createTextNode(o)),e.head.appendChild(i),r.add(t)}function Qg(e){let t=ss();t.documents.set(e,(t.documents.get(e)??0)+1);for(let[o,n]of t.styles)Wd(e,o,n);return()=>{let o=t.documents.get(e);if(o!==void 0){if(o<=1){t.documents.delete(e);return}t.documents.set(e,o-1)}}}function Jg(e,t){let o=ss();o.styles.set(e,t);for(let n of o.documents.keys())Wd(n,e,t)}typeof process>"u",Jg("19fcc06039",'@layer wp-ui{@layer utilities, components, compositions, overrides;@layer components{._480b748dd3510e64__positioner{z-index:var(--wp-ui-tooltip-z-index,initial)}._50096b232db7709d__popup{--_wp-ui-elevation-sm:0 1px 2px rgba(0,0,0,.05),0 2px 3px rgba(0,0,0,.04),0 6px 6px rgba(0,0,0,.03),0 8px 8px rgba(0,0,0,.02);background-color:var(--wpds-color-background-surface-neutral-strong,#fff);border-radius:var(--wpds-border-radius-md,4px);box-shadow:var(--_wp-ui-elevation-sm);color:var(--wpds-color-foreground-content-neutral,#1e1e1e);font-family:var(--wpds-typography-font-family-body,-apple-system,system-ui,"Segoe UI","Roboto","Oxygen-Sans","Ubuntu","Cantarell","Helvetica Neue",sans-serif);font-size:var(--wpds-typography-font-size-sm,12px);line-height:1.4;padding:var(--wpds-dimension-padding-xs,4px) var(--wpds-dimension-padding-sm,8px);@media (forced-colors:active){border-bottom-color:CanvasText;border-bottom-style:solid;border-bottom-width:1px;border-left-color:CanvasText;border-left-style:solid;border-left-width:1px;border-right-color:CanvasText;border-right-style:solid;border-right-width:1px;border-top-color:CanvasText;border-top-style:solid;border-top-width:1px}}}}');var $g={positioner:"_480b748dd3510e64__positioner",popup:"_50096b232db7709d__popup"},eb={background:"#1e1e1e"},as=(0,Vd.forwardRef)(function({portal:t,positioner:o,children:n,className:r,...i},s){let a=(0,$o.jsx)(kd,{color:eb,children:(0,$o.jsx)(Qe.Popup,{ref:s,className:$($g.popup,r),...i,children:n})}),d=Zi(o,(0,$o.jsx)(Fd,{}),a);return Zi(t,(0,$o.jsx)(Bd,{}),d)});var Yd=h(de(),1),Ud=h(Q(),1),cs=(0,Yd.forwardRef)(function(t,o){return(0,Ud.jsx)(Qe.Trigger,{ref:o,...t})});var Gd=h(Q(),1);function ls(e){return(0,Gd.jsx)(Qe.Root,{...e})}var lt=h(Q(),1),ds="data-wp-hash";function us(){let e=globalThis;return e.__wpStyleRuntime||(e.__wpStyleRuntime={documents:new Map,styles:new Map,injectedStyles:new WeakMap},typeof document<"u"&&nb(document)),e.__wpStyleRuntime}function ob(e,t){if(!e.head)return!1;for(let o of e.head.querySelectorAll(`style[${ds}]`))if(o.getAttribute(ds)===t)return!0;return!1}function qd(e,t,o){if(!e.head)return;let n=us(),r=n.injectedStyles.get(e);if(r||(r=new Set,n.injectedStyles.set(e,r)),r.has(t))return;if(ob(e,t)){r.add(t);return}let i=e.createElement("style");i.setAttribute(ds,t),i.appendChild(e.createTextNode(o)),e.head.appendChild(i),r.add(t)}function nb(e){let t=us();t.documents.set(e,(t.documents.get(e)??0)+1);for(let[o,n]of t.styles)qd(e,o,n);return()=>{let o=t.documents.get(e);if(o!==void 0){if(o<=1){t.documents.delete(e);return}t.documents.set(e,o-1)}}}function rb(e,t){let o=us();o.styles.set(e,t);for(let n of o.documents.keys())qd(n,e,t)}typeof process>"u",rb("c5cdafb1bc","@layer wp-ui{@layer utilities, components, compositions, overrides;@layer compositions{._28cfdc260e755391__icon-button{--wp-ui-button-aspect-ratio:1;--wp-ui-button-padding-inline:0px;--wp-ui-button-min-width:unset}.f1c70d719989a85a__icon{margin:-1px}}}");var Xd={"icon-button":"_28cfdc260e755391__icon-button",icon:"f1c70d719989a85a__icon"},fs=(0,Kd.forwardRef)(function({label:t,className:o,children:n,disabled:r,focusableWhenDisabled:i=!0,icon:s,size:a,shortcut:d,positioner:c,...l},f){let p=$(Xd["icon-button"],o);return(0,lt.jsxs)(ls,{children:[(0,lt.jsx)(cs,{ref:f,disabled:r&&!i,render:(0,lt.jsx)(sr,{...l,size:a,"aria-label":t,"aria-keyshortcuts":d?.ariaKeyShortcut,disabled:r,focusableWhenDisabled:i}),className:p,children:(0,lt.jsx)(eo,{icon:s,size:24,className:Xd.icon})}),(0,lt.jsxs)(as,{positioner:c,children:[t,d&&(0,lt.jsxs)(lt.Fragment,{children:[" ",(0,lt.jsx)("span",{"aria-hidden":"true",children:d.displayShortcut})]})]})]})});var Zd=h(de(),1),Qd=h(Ot(),1),Co=h(Q(),1),ps="data-wp-hash";function ms(){let e=globalThis;return e.__wpStyleRuntime||(e.__wpStyleRuntime={documents:new Map,styles:new Map,injectedStyles:new WeakMap},typeof document<"u"&&sb(document)),e.__wpStyleRuntime}function ib(e,t){if(!e.head)return!1;for(let o of e.head.querySelectorAll(`style[${ps}]`))if(o.getAttribute(ps)===t)return!0;return!1}function Jd(e,t,o){if(!e.head)return;let n=ms(),r=n.injectedStyles.get(e);if(r||(r=new Set,n.injectedStyles.set(e,r)),r.has(t))return;if(ib(e,t)){r.add(t);return}let i=e.createElement("style");i.setAttribute(ps,t),i.appendChild(e.createTextNode(o)),e.head.appendChild(i),r.add(t)}function sb(e){let t=ms();t.documents.set(e,(t.documents.get(e)??0)+1);for(let[o,n]of t.styles)Jd(e,o,n);return()=>{let o=t.documents.get(e);if(o!==void 0){if(o<=1){t.documents.delete(e);return}t.documents.set(e,o-1)}}}function pr(e,t){let o=ms();o.styles.set(e,t);for(let n of o.documents.keys())Jd(n,e,t)}typeof process>"u",pr("10f3806643","@layer wp-ui{@layer utilities, components, compositions, overrides;@layer utilities{._336cd3e4e743482f__box-sizing{box-sizing:border-box;*,:after,:before{box-sizing:inherit}}}}");var ab={"box-sizing":"_336cd3e4e743482f__box-sizing"};typeof process>"u",pr("da99a163ac","@layer wp-ui{@layer utilities, components, compositions, overrides;@layer utilities{._08e8a2e44959f892__outset-ring--focus:focus,.c5cb3ee4bddaa8e4__outset-ring--focus-within-visible:focus-within:has(:focus-visible),.cd83dfc2126a0846__outset-ring--focus-within:focus-within,.d0541bc9dd9dc7b6__outset-ring--focus-visible:focus-visible,:focus-visible .ecadb9e080e2dfa5__outset-ring--focus-parent-visible{--_gcd-a-outline:var(--wpds-border-width-focus,var(--wp-admin-border-width-focus,2px)) solid var(--wpds-color-stroke-focus,var(--wp-admin-theme-color,#3858e9));--_gcd-div-outline:var(--wpds-border-width-focus,var(--wp-admin-border-width-focus,2px)) solid var(--wpds-color-stroke-focus,var(--wp-admin-theme-color,#3858e9));outline:var(--wpds-border-width-focus,var(--wp-admin-border-width-focus,2px)) solid var(--wpds-color-stroke-focus,var(--wp-admin-theme-color,#3858e9));outline-offset:var(--wpds-border-width-focus,var(--wp-admin-border-width-focus,2px))}._970d04df7376df67__outset-ring--focus-within-except-active:focus-within,.e25b2bdd7aa21721__outset-ring--focus-except-active:focus{outline:none}._970d04df7376df67__outset-ring--focus-within-except-active:focus-within:not(:has(:active)),.e25b2bdd7aa21721__outset-ring--focus-except-active:focus:not(:active){@include mixins.focus-ring()}}}");var cb={"outset-ring--focus":"_08e8a2e44959f892__outset-ring--focus","outset-ring--focus-visible":"d0541bc9dd9dc7b6__outset-ring--focus-visible","outset-ring--focus-within":"cd83dfc2126a0846__outset-ring--focus-within","outset-ring--focus-within-visible":"c5cb3ee4bddaa8e4__outset-ring--focus-within-visible","outset-ring--focus-parent-visible":"ecadb9e080e2dfa5__outset-ring--focus-parent-visible","outset-ring--focus-except-active":"e25b2bdd7aa21721__outset-ring--focus-except-active","outset-ring--focus-within-except-active":"_970d04df7376df67__outset-ring--focus-within-except-active"};typeof process>"u",pr("e8e6a9be37",'@layer wp-ui{@layer utilities, components, compositions, overrides;@layer components{.d4250949359b05ce__link{text-decoration-thickness:from-font;text-underline-offset:.2em}.c6055659b8e2cd2c__is-brand,.c6055659b8e2cd2c__is-brand:visited{--_gcd-a-color:var(--wpds-color-foreground-interactive-brand,var(--wp-admin-theme-color,#3858e9));color:var(--wpds-color-foreground-interactive-brand,var(--wp-admin-theme-color,#3858e9))}.c6055659b8e2cd2c__is-brand:active,.c6055659b8e2cd2c__is-brand:hover{--_gcd-a-color:var(--wpds-color-foreground-interactive-brand-active,color-mix(in oklch,var(--wp-admin-theme-color,#3858e9) 52%,#000));color:var(--wpds-color-foreground-interactive-brand-active,color-mix(in oklch,var(--wp-admin-theme-color,#3858e9) 52%,#000))}._92e0dfcaeee15b88__is-neutral,._92e0dfcaeee15b88__is-neutral:visited{--_gcd-a-color:var(--wpds-color-foreground-interactive-neutral,#1e1e1e);color:var(--wpds-color-foreground-interactive-neutral,#1e1e1e);text-decoration-color:var(--wpds-color-stroke-interactive-neutral,#8d8d8d)}._92e0dfcaeee15b88__is-neutral:active,._92e0dfcaeee15b88__is-neutral:hover{--_gcd-a-color:var(--wpds-color-foreground-interactive-neutral-active,#1e1e1e);color:var(--wpds-color-foreground-interactive-neutral-active,#1e1e1e)}.cf122a9bf1035d42__is-unstyled{--_gcd-a-color:inherit;color:inherit;text-decoration:none}._0cb411afac4c86c7__link-icon{display:inline-block;font-weight:var(--wpds-typography-font-weight-default,400);line-height:1;margin-inline-start:var(--wpds-dimension-padding-xs,4px);text-decoration:none}._0cb411afac4c86c7__link-icon:after{content:"\\2197"}._0cb411afac4c86c7__link-icon:dir(rtl):after{content:"\\2196"}}}');var fr={link:"d4250949359b05ce__link","is-brand":"c6055659b8e2cd2c__is-brand","is-neutral":"_92e0dfcaeee15b88__is-neutral","is-unstyled":"cf122a9bf1035d42__is-unstyled","link-icon":"_0cb411afac4c86c7__link-icon"};typeof process>"u",pr("af6d9984a6","._6defc79820e382c6__button{box-sizing:var(--_gcd-button-box-sizing,border-box);font-family:var(--_gcd-button-font-family,inherit);font-size:var(--_gcd-button-font-size,inherit);font-weight:var(--_gcd-button-font-weight,inherit)}.d2cff2e5dea83bd1__input{box-sizing:var(--_gcd-input-box-sizing,border-box);font-family:var(--_gcd-input-font-family,inherit);font-size:var(--_gcd-input-font-size,inherit);font-weight:var(--_gcd-input-font-weight,inherit);margin:var(--_gcd-input-margin,0);&:is(textarea,[type=text],[type=password],[type=color],[type=date],[type=datetime],[type=datetime-local],[type=email],[type=month],[type=number],[type=search],[type=tel],[type=time],[type=url],[type=week]){background-color:var(--_gcd-input-background-color,transparent);border:var(--_gcd-input-border,none);border-radius:var(--_gcd-input-border-radius,0);box-shadow:var(--_gcd-input-box-shadow,0 0 0 transparent);color:var(--_gcd-input-color,var(--wpds-color-foreground-interactive-neutral,#1e1e1e));&:focus{border-color:var(--_gcd-input-border-color-focus,var(--wp-admin-theme-color));box-shadow:var(--_gcd-input-box-shadow-focus,none);outline:var(--_gcd-input-outline-focus,none)}&:disabled{background:var(--_gcd-input-background-disabled,transparent);border-color:var(--_gcd-input-border-color-disabled,transparent);box-shadow:var(--_gcd-input-box-shadow-disabled,none);color:var(--_gcd-input-color-disabled,var(--wpds-color-foreground-interactive-neutral-disabled,#8d8d8d))}&::placeholder{color:var(--_gcd-input-placeholder-color,var(--wpds-color-foreground-interactive-neutral-disabled,#8d8d8d))}}&:is(textarea,[type=text],[type=password],[type=date],[type=datetime],[type=datetime-local],[type=email],[type=month],[type=number],[type=search],[type=tel],[type=time],[type=url],[type=week]){line-height:var(--_gcd-input-line-height,inherit);min-height:var(--_gcd-input-min-height,auto);padding:var(--_gcd-input-padding,0)}}._547d86373d02e108__textarea{box-sizing:var(--_gcd-textarea-box-sizing,border-box);overflow:var(--_gcd-textarea-overflow,auto);resize:var(--_gcd-textarea-resize,block)}._8c15fd0ed9f28ba4__div{outline:var(--_gcd-div-outline,0 solid transparent)}p._43cec3e1eec1066d__p{font-size:var(--_gcd-p-font-size,13px);line-height:var(--_gcd-p-line-height,1.5);margin:var(--_gcd-p-margin,0)}:is(h1,h2,h3,h4,h5,h6).e97669c6d9a38497__heading{color:var(--_gcd-heading-color,var(--wpds-color-foreground-content-neutral,#1e1e1e));font-size:var(--_gcd-heading-font-size,inherit);font-weight:var(--_gcd-heading-font-weight,var(--wpds-typography-font-weight-emphasis,600));margin:var(--_gcd-heading-margin,0)}._2c0831b0499dbd6e__a,._2c0831b0499dbd6e__a:is(:hover,:focus,:active){border-radius:var(--_gcd-a-border-radius,0);box-shadow:var(--_gcd-a-box-shadow,none);color:var(--_gcd-a-color,inherit);outline:var(--_gcd-a-outline,0 solid transparent);transition:var(--_gcd-a-transition,none)}");var lb={button:"_6defc79820e382c6__button",input:"d2cff2e5dea83bd1__input",textarea:"_547d86373d02e108__textarea",div:"_8c15fd0ed9f28ba4__div",p:"_43cec3e1eec1066d__p",heading:"e97669c6d9a38497__heading",a:"_2c0831b0499dbd6e__a"},en=(0,Zd.forwardRef)(function({children:t,variant:o="default",tone:n="brand",openInNewTab:r=!1,render:i,className:s,...a},d){return bt({render:i,defaultTagName:"a",ref:d,props:ye(a,{className:$(lb.a,ab["box-sizing"],cb["outset-ring--focus-except-active"],o!=="unstyled"&&fr.link,o!=="unstyled"&&fr[`is-${n}`],o==="unstyled"&&fr["is-unstyled"],s),target:r?"_blank":void 0,children:(0,Co.jsxs)(Co.Fragment,{children:[t,r&&(0,Co.jsx)("span",{className:fr["link-icon"],role:"img","aria-label":(0,Qd.__)("(opens in a new tab)")})]})})})});var tn={};At(tn,{ActionButton:()=>xu,ActionLink:()=>Eu,Actions:()=>fu,CloseIcon:()=>hu,Description:()=>lu,Root:()=>tu,Title:()=>iu});var Ao=h(de(),1);import{speak as db}from"@wordpress/a11y";var Oo=h(Q(),1),bs="data-wp-hash";function hs(){let e=globalThis;return e.__wpStyleRuntime||(e.__wpStyleRuntime={documents:new Map,styles:new Map,injectedStyles:new WeakMap},typeof document<"u"&&fb(document)),e.__wpStyleRuntime}function ub(e,t){if(!e.head)return!1;for(let o of e.head.querySelectorAll(`style[${bs}]`))if(o.getAttribute(bs)===t)return!0;return!1}function $d(e,t,o){if(!e.head)return;let n=hs(),r=n.injectedStyles.get(e);if(r||(r=new Set,n.injectedStyles.set(e,r)),r.has(t))return;if(ub(e,t)){r.add(t);return}let i=e.createElement("style");i.setAttribute(bs,t),i.appendChild(e.createTextNode(o)),e.head.appendChild(i),r.add(t)}function fb(e){let t=hs();t.documents.set(e,(t.documents.get(e)??0)+1);for(let[o,n]of t.styles)$d(e,o,n);return()=>{let o=t.documents.get(e);if(o!==void 0){if(o<=1){t.documents.delete(e);return}t.documents.set(e,o-1)}}}function eu(e,t){let o=hs();o.styles.set(e,t);for(let n of o.documents.keys())$d(n,e,t)}typeof process>"u",eu("10f3806643","@layer wp-ui{@layer utilities, components, compositions, overrides;@layer utilities{._336cd3e4e743482f__box-sizing{box-sizing:border-box;*,:after,:before{box-sizing:inherit}}}}");var pb={"box-sizing":"_336cd3e4e743482f__box-sizing"};typeof process>"u",eu("726c480820","@layer wp-ui{@layer utilities, components, compositions, overrides;@layer components{._4145abab73d17514__notice{--icon-height:var(--wpds-dimension-size-sm,24px);--text-vertical-padding:calc((var(--icon-height) - var(--wpds-typography-line-height-sm, 20px))/2);--wp-ui-notice-background-color:var(--wpds-color-background-surface-neutral-weak,#f4f4f4);--wp-ui-notice-border-color:var(--wpds-color-stroke-surface-neutral,#dbdbdb);--wp-ui-notice-text-color:var(--wpds-color-foreground-content-neutral,#1e1e1e);--wp-ui-notice-decorative-icon-color:var(--wpds-color-foreground-content-neutral,#1e1e1e);align-items:start;background-color:var(--wp-ui-notice-background-color);border:1px solid var(--wp-ui-notice-border-color);border-radius:var(--wpds-border-radius-lg,8px);container-type:inline-size;display:grid;grid-template-columns:auto 1fr auto;padding:var(--wpds-dimension-padding-md,12px)}.d0a25570cb528528__icon{color:var(--wp-ui-notice-decorative-icon-color);grid-column:1;grid-row:1;margin-inline-end:var(--wpds-dimension-gap-xs,4px)}._1904b570a89bb815__description,.b5397fb9d05389e3__title{color:var(--wp-ui-notice-text-color);grid-column:2;padding-block:var(--text-vertical-padding)}._1904b570a89bb815__description{text-wrap:pretty}._0a1270dcdd79c031__actions{display:flex;flex-wrap:wrap;gap:var(--wpds-dimension-gap-md,12px);grid-column:2}._4145abab73d17514__notice:has(._1904b570a89bb815__description) ._0a1270dcdd79c031__actions,._4145abab73d17514__notice:has(.b5397fb9d05389e3__title) ._0a1270dcdd79c031__actions{margin-block-start:var(--wpds-dimension-gap-sm,8px)}._983740ab855c4e09__action-button{flex-shrink:0}.d329e7416d368d31__action-link{flex-shrink:0;&:not(:first-child){margin-inline-start:var(--wpds-dimension-gap-xs,4px)}&:not(:last-child){margin-inline-end:var(--wpds-dimension-gap-xs,4px)}}._487e6a5c1375f7dc__close-icon{grid-column:3;grid-row:1;margin-inline-start:var(--wpds-dimension-gap-xs,4px)}._531c140826094795__is-info{--wp-ui-notice-background-color:var(--wpds-color-background-surface-info-weak,#f3f9ff);--wp-ui-notice-border-color:var(--wpds-color-stroke-surface-info,#a9c6e7);--wp-ui-notice-text-color:var(--wpds-color-foreground-content-info,#001b4f);--wp-ui-notice-decorative-icon-color:var(--wpds-color-foreground-content-info-weak,#006bd7)}.ae2e1004697cce95__is-warning{--wp-ui-notice-background-color:var(--wpds-color-background-surface-warning-weak,#fff7e1);--wp-ui-notice-border-color:var(--wpds-color-stroke-surface-warning,#e1bc7c);--wp-ui-notice-text-color:var(--wpds-color-foreground-content-warning,#2e1900);--wp-ui-notice-decorative-icon-color:var(--wpds-color-foreground-content-warning-weak,#926300)}._2e614a76af494837__is-success{--wp-ui-notice-background-color:var(--wpds-color-background-surface-success-weak,#ebffed);--wp-ui-notice-border-color:var(--wpds-color-stroke-surface-success,#94d29e);--wp-ui-notice-text-color:var(--wpds-color-foreground-content-success,#002900);--wp-ui-notice-decorative-icon-color:var(--wpds-color-foreground-content-success-weak,#008030)}.af00331ae17a0065__is-error{--wp-ui-notice-background-color:var(--wpds-color-background-surface-error-weak,#fff6f5);--wp-ui-notice-border-color:var(--wpds-color-stroke-surface-error,#dab1aa);--wp-ui-notice-text-color:var(--wpds-color-foreground-content-error,#470000);--wp-ui-notice-decorative-icon-color:var(--wpds-color-foreground-content-error-weak,#cc1818)}@container (max-width: 320px){._4145abab73d17514__notice:has(.b5397fb9d05389e3__title) ._0a1270dcdd79c031__actions,._4145abab73d17514__notice:has(.b5397fb9d05389e3__title) ._1904b570a89bb815__description{grid-column:1/3}}}@layer compositions{.d329e7416d368d31__action-link{margin-block:auto}._487e6a5c1375f7dc__close-icon,._983740ab855c4e09__action-button:is(._8ddb8fb33fbf3d38__is-action-button-outline,._77bbde495a8a0af3__is-action-button-minimal){--wp-ui-button-background-color-active:color-mix(in srgb,transparent 50%,var(--wpds-color-background-interactive-neutral-weak-active,#ededed))}}}");var gs={notice:"_4145abab73d17514__notice",icon:"d0a25570cb528528__icon",title:"b5397fb9d05389e3__title",description:"_1904b570a89bb815__description",actions:"_0a1270dcdd79c031__actions","action-button":"_983740ab855c4e09__action-button","action-link":"d329e7416d368d31__action-link","close-icon":"_487e6a5c1375f7dc__close-icon","is-info":"_531c140826094795__is-info","is-warning":"ae2e1004697cce95__is-warning","is-success":"_2e614a76af494837__is-success","is-error":"af00331ae17a0065__is-error","is-action-button-outline":"_8ddb8fb33fbf3d38__is-action-button-outline","is-action-button-minimal":"_77bbde495a8a0af3__is-action-button-minimal"},mb={neutral:null,info:Xi,warning:Fi,success:qi,error:Ui};function gb(e){return e==="error"?"assertive":"polite"}function bb(e){if(e){if(typeof e=="string")return e;try{return(0,Ao.renderToString)(e)}catch{return}}}function hb(e,t){let o=bb(e);(0,Ao.useEffect)(()=>{o&&db(o,t)},[o,t])}var tu=(0,Ao.forwardRef)(function({intent:t="neutral",children:o,icon:n,spokenMessage:r=o,politeness:i=gb(t),render:s,...a},d){hb(r,i);let c=n===null?null:n??mb[t],l=$(gs.notice,gs[`is-${t}`],pb["box-sizing"]);return bt({defaultTagName:"div",render:s,ref:d,props:ye({className:l,children:(0,Oo.jsxs)(Oo.Fragment,{children:[o,c&&(0,Oo.jsx)(eo,{className:gs.icon,icon:c})]})},a)})});var ou=h(de(),1);var ru=h(Q(),1),ws="data-wp-hash";function vs(){let e=globalThis;return e.__wpStyleRuntime||(e.__wpStyleRuntime={documents:new Map,styles:new Map,injectedStyles:new WeakMap},typeof document<"u"&&vb(document)),e.__wpStyleRuntime}function wb(e,t){if(!e.head)return!1;for(let o of e.head.querySelectorAll(`style[${ws}]`))if(o.getAttribute(ws)===t)return!0;return!1}function nu(e,t,o){if(!e.head)return;let n=vs(),r=n.injectedStyles.get(e);if(r||(r=new Set,n.injectedStyles.set(e,r)),r.has(t))return;if(wb(e,t)){r.add(t);return}let i=e.createElement("style");i.setAttribute(ws,t),i.appendChild(e.createTextNode(o)),e.head.appendChild(i),r.add(t)}function vb(e){let t=vs();t.documents.set(e,(t.documents.get(e)??0)+1);for(let[o,n]of t.styles)nu(e,o,n);return()=>{let o=t.documents.get(e);if(o!==void 0){if(o<=1){t.documents.delete(e);return}t.documents.set(e,o-1)}}}function _b(e,t){let o=vs();o.styles.set(e,t);for(let n of o.documents.keys())nu(n,e,t)}typeof process>"u",_b("726c480820","@layer wp-ui{@layer utilities, components, compositions, overrides;@layer components{._4145abab73d17514__notice{--icon-height:var(--wpds-dimension-size-sm,24px);--text-vertical-padding:calc((var(--icon-height) - var(--wpds-typography-line-height-sm, 20px))/2);--wp-ui-notice-background-color:var(--wpds-color-background-surface-neutral-weak,#f4f4f4);--wp-ui-notice-border-color:var(--wpds-color-stroke-surface-neutral,#dbdbdb);--wp-ui-notice-text-color:var(--wpds-color-foreground-content-neutral,#1e1e1e);--wp-ui-notice-decorative-icon-color:var(--wpds-color-foreground-content-neutral,#1e1e1e);align-items:start;background-color:var(--wp-ui-notice-background-color);border:1px solid var(--wp-ui-notice-border-color);border-radius:var(--wpds-border-radius-lg,8px);container-type:inline-size;display:grid;grid-template-columns:auto 1fr auto;padding:var(--wpds-dimension-padding-md,12px)}.d0a25570cb528528__icon{color:var(--wp-ui-notice-decorative-icon-color);grid-column:1;grid-row:1;margin-inline-end:var(--wpds-dimension-gap-xs,4px)}._1904b570a89bb815__description,.b5397fb9d05389e3__title{color:var(--wp-ui-notice-text-color);grid-column:2;padding-block:var(--text-vertical-padding)}._1904b570a89bb815__description{text-wrap:pretty}._0a1270dcdd79c031__actions{display:flex;flex-wrap:wrap;gap:var(--wpds-dimension-gap-md,12px);grid-column:2}._4145abab73d17514__notice:has(._1904b570a89bb815__description) ._0a1270dcdd79c031__actions,._4145abab73d17514__notice:has(.b5397fb9d05389e3__title) ._0a1270dcdd79c031__actions{margin-block-start:var(--wpds-dimension-gap-sm,8px)}._983740ab855c4e09__action-button{flex-shrink:0}.d329e7416d368d31__action-link{flex-shrink:0;&:not(:first-child){margin-inline-start:var(--wpds-dimension-gap-xs,4px)}&:not(:last-child){margin-inline-end:var(--wpds-dimension-gap-xs,4px)}}._487e6a5c1375f7dc__close-icon{grid-column:3;grid-row:1;margin-inline-start:var(--wpds-dimension-gap-xs,4px)}._531c140826094795__is-info{--wp-ui-notice-background-color:var(--wpds-color-background-surface-info-weak,#f3f9ff);--wp-ui-notice-border-color:var(--wpds-color-stroke-surface-info,#a9c6e7);--wp-ui-notice-text-color:var(--wpds-color-foreground-content-info,#001b4f);--wp-ui-notice-decorative-icon-color:var(--wpds-color-foreground-content-info-weak,#006bd7)}.ae2e1004697cce95__is-warning{--wp-ui-notice-background-color:var(--wpds-color-background-surface-warning-weak,#fff7e1);--wp-ui-notice-border-color:var(--wpds-color-stroke-surface-warning,#e1bc7c);--wp-ui-notice-text-color:var(--wpds-color-foreground-content-warning,#2e1900);--wp-ui-notice-decorative-icon-color:var(--wpds-color-foreground-content-warning-weak,#926300)}._2e614a76af494837__is-success{--wp-ui-notice-background-color:var(--wpds-color-background-surface-success-weak,#ebffed);--wp-ui-notice-border-color:var(--wpds-color-stroke-surface-success,#94d29e);--wp-ui-notice-text-color:var(--wpds-color-foreground-content-success,#002900);--wp-ui-notice-decorative-icon-color:var(--wpds-color-foreground-content-success-weak,#008030)}.af00331ae17a0065__is-error{--wp-ui-notice-background-color:var(--wpds-color-background-surface-error-weak,#fff6f5);--wp-ui-notice-border-color:var(--wpds-color-stroke-surface-error,#dab1aa);--wp-ui-notice-text-color:var(--wpds-color-foreground-content-error,#470000);--wp-ui-notice-decorative-icon-color:var(--wpds-color-foreground-content-error-weak,#cc1818)}@container (max-width: 320px){._4145abab73d17514__notice:has(.b5397fb9d05389e3__title) ._0a1270dcdd79c031__actions,._4145abab73d17514__notice:has(.b5397fb9d05389e3__title) ._1904b570a89bb815__description{grid-column:1/3}}}@layer compositions{.d329e7416d368d31__action-link{margin-block:auto}._487e6a5c1375f7dc__close-icon,._983740ab855c4e09__action-button:is(._8ddb8fb33fbf3d38__is-action-button-outline,._77bbde495a8a0af3__is-action-button-minimal){--wp-ui-button-background-color-active:color-mix(in srgb,transparent 50%,var(--wpds-color-background-interactive-neutral-weak-active,#ededed))}}}");var yb={notice:"_4145abab73d17514__notice",icon:"d0a25570cb528528__icon",title:"b5397fb9d05389e3__title",description:"_1904b570a89bb815__description",actions:"_0a1270dcdd79c031__actions","action-button":"_983740ab855c4e09__action-button","action-link":"d329e7416d368d31__action-link","close-icon":"_487e6a5c1375f7dc__close-icon","is-info":"_531c140826094795__is-info","is-warning":"ae2e1004697cce95__is-warning","is-success":"_2e614a76af494837__is-success","is-error":"af00331ae17a0065__is-error","is-action-button-outline":"_8ddb8fb33fbf3d38__is-action-button-outline","is-action-button-minimal":"_77bbde495a8a0af3__is-action-button-minimal"},iu=(0,ou.forwardRef)(function({className:t,...o},n){return(0,ru.jsx)(Je,{ref:n,variant:"heading-md",className:$(yb.title,t),...o})});var su=h(de(),1);var cu=h(Q(),1),_s="data-wp-hash";function ys(){let e=globalThis;return e.__wpStyleRuntime||(e.__wpStyleRuntime={documents:new Map,styles:new Map,injectedStyles:new WeakMap},typeof document<"u"&&Rb(document)),e.__wpStyleRuntime}function xb(e,t){if(!e.head)return!1;for(let o of e.head.querySelectorAll(`style[${_s}]`))if(o.getAttribute(_s)===t)return!0;return!1}function au(e,t,o){if(!e.head)return;let n=ys(),r=n.injectedStyles.get(e);if(r||(r=new Set,n.injectedStyles.set(e,r)),r.has(t))return;if(xb(e,t)){r.add(t);return}let i=e.createElement("style");i.setAttribute(_s,t),i.appendChild(e.createTextNode(o)),e.head.appendChild(i),r.add(t)}function Rb(e){let t=ys();t.documents.set(e,(t.documents.get(e)??0)+1);for(let[o,n]of t.styles)au(e,o,n);return()=>{let o=t.documents.get(e);if(o!==void 0){if(o<=1){t.documents.delete(e);return}t.documents.set(e,o-1)}}}function Sb(e,t){let o=ys();o.styles.set(e,t);for(let n of o.documents.keys())au(n,e,t)}typeof process>"u",Sb("726c480820","@layer wp-ui{@layer utilities, components, compositions, overrides;@layer components{._4145abab73d17514__notice{--icon-height:var(--wpds-dimension-size-sm,24px);--text-vertical-padding:calc((var(--icon-height) - var(--wpds-typography-line-height-sm, 20px))/2);--wp-ui-notice-background-color:var(--wpds-color-background-surface-neutral-weak,#f4f4f4);--wp-ui-notice-border-color:var(--wpds-color-stroke-surface-neutral,#dbdbdb);--wp-ui-notice-text-color:var(--wpds-color-foreground-content-neutral,#1e1e1e);--wp-ui-notice-decorative-icon-color:var(--wpds-color-foreground-content-neutral,#1e1e1e);align-items:start;background-color:var(--wp-ui-notice-background-color);border:1px solid var(--wp-ui-notice-border-color);border-radius:var(--wpds-border-radius-lg,8px);container-type:inline-size;display:grid;grid-template-columns:auto 1fr auto;padding:var(--wpds-dimension-padding-md,12px)}.d0a25570cb528528__icon{color:var(--wp-ui-notice-decorative-icon-color);grid-column:1;grid-row:1;margin-inline-end:var(--wpds-dimension-gap-xs,4px)}._1904b570a89bb815__description,.b5397fb9d05389e3__title{color:var(--wp-ui-notice-text-color);grid-column:2;padding-block:var(--text-vertical-padding)}._1904b570a89bb815__description{text-wrap:pretty}._0a1270dcdd79c031__actions{display:flex;flex-wrap:wrap;gap:var(--wpds-dimension-gap-md,12px);grid-column:2}._4145abab73d17514__notice:has(._1904b570a89bb815__description) ._0a1270dcdd79c031__actions,._4145abab73d17514__notice:has(.b5397fb9d05389e3__title) ._0a1270dcdd79c031__actions{margin-block-start:var(--wpds-dimension-gap-sm,8px)}._983740ab855c4e09__action-button{flex-shrink:0}.d329e7416d368d31__action-link{flex-shrink:0;&:not(:first-child){margin-inline-start:var(--wpds-dimension-gap-xs,4px)}&:not(:last-child){margin-inline-end:var(--wpds-dimension-gap-xs,4px)}}._487e6a5c1375f7dc__close-icon{grid-column:3;grid-row:1;margin-inline-start:var(--wpds-dimension-gap-xs,4px)}._531c140826094795__is-info{--wp-ui-notice-background-color:var(--wpds-color-background-surface-info-weak,#f3f9ff);--wp-ui-notice-border-color:var(--wpds-color-stroke-surface-info,#a9c6e7);--wp-ui-notice-text-color:var(--wpds-color-foreground-content-info,#001b4f);--wp-ui-notice-decorative-icon-color:var(--wpds-color-foreground-content-info-weak,#006bd7)}.ae2e1004697cce95__is-warning{--wp-ui-notice-background-color:var(--wpds-color-background-surface-warning-weak,#fff7e1);--wp-ui-notice-border-color:var(--wpds-color-stroke-surface-warning,#e1bc7c);--wp-ui-notice-text-color:var(--wpds-color-foreground-content-warning,#2e1900);--wp-ui-notice-decorative-icon-color:var(--wpds-color-foreground-content-warning-weak,#926300)}._2e614a76af494837__is-success{--wp-ui-notice-background-color:var(--wpds-color-background-surface-success-weak,#ebffed);--wp-ui-notice-border-color:var(--wpds-color-stroke-surface-success,#94d29e);--wp-ui-notice-text-color:var(--wpds-color-foreground-content-success,#002900);--wp-ui-notice-decorative-icon-color:var(--wpds-color-foreground-content-success-weak,#008030)}.af00331ae17a0065__is-error{--wp-ui-notice-background-color:var(--wpds-color-background-surface-error-weak,#fff6f5);--wp-ui-notice-border-color:var(--wpds-color-stroke-surface-error,#dab1aa);--wp-ui-notice-text-color:var(--wpds-color-foreground-content-error,#470000);--wp-ui-notice-decorative-icon-color:var(--wpds-color-foreground-content-error-weak,#cc1818)}@container (max-width: 320px){._4145abab73d17514__notice:has(.b5397fb9d05389e3__title) ._0a1270dcdd79c031__actions,._4145abab73d17514__notice:has(.b5397fb9d05389e3__title) ._1904b570a89bb815__description{grid-column:1/3}}}@layer compositions{.d329e7416d368d31__action-link{margin-block:auto}._487e6a5c1375f7dc__close-icon,._983740ab855c4e09__action-button:is(._8ddb8fb33fbf3d38__is-action-button-outline,._77bbde495a8a0af3__is-action-button-minimal){--wp-ui-button-background-color-active:color-mix(in srgb,transparent 50%,var(--wpds-color-background-interactive-neutral-weak-active,#ededed))}}}");var Eb={notice:"_4145abab73d17514__notice",icon:"d0a25570cb528528__icon",title:"b5397fb9d05389e3__title",description:"_1904b570a89bb815__description",actions:"_0a1270dcdd79c031__actions","action-button":"_983740ab855c4e09__action-button","action-link":"d329e7416d368d31__action-link","close-icon":"_487e6a5c1375f7dc__close-icon","is-info":"_531c140826094795__is-info","is-warning":"ae2e1004697cce95__is-warning","is-success":"_2e614a76af494837__is-success","is-error":"af00331ae17a0065__is-error","is-action-button-outline":"_8ddb8fb33fbf3d38__is-action-button-outline","is-action-button-minimal":"_77bbde495a8a0af3__is-action-button-minimal"},lu=(0,su.forwardRef)(function({className:t,...o},n){return(0,cu.jsx)(Je,{ref:n,variant:"body-md",className:$(Eb.description,t),...o})});var du=h(de(),1);var xs="data-wp-hash";function Rs(){let e=globalThis;return e.__wpStyleRuntime||(e.__wpStyleRuntime={documents:new Map,styles:new Map,injectedStyles:new WeakMap},typeof document<"u"&&kb(document)),e.__wpStyleRuntime}function Tb(e,t){if(!e.head)return!1;for(let o of e.head.querySelectorAll(`style[${xs}]`))if(o.getAttribute(xs)===t)return!0;return!1}function uu(e,t,o){if(!e.head)return;let n=Rs(),r=n.injectedStyles.get(e);if(r||(r=new Set,n.injectedStyles.set(e,r)),r.has(t))return;if(Tb(e,t)){r.add(t);return}let i=e.createElement("style");i.setAttribute(xs,t),i.appendChild(e.createTextNode(o)),e.head.appendChild(i),r.add(t)}function kb(e){let t=Rs();t.documents.set(e,(t.documents.get(e)??0)+1);for(let[o,n]of t.styles)uu(e,o,n);return()=>{let o=t.documents.get(e);if(o!==void 0){if(o<=1){t.documents.delete(e);return}t.documents.set(e,o-1)}}}function Pb(e,t){let o=Rs();o.styles.set(e,t);for(let n of o.documents.keys())uu(n,e,t)}typeof process>"u",Pb("726c480820","@layer wp-ui{@layer utilities, components, compositions, overrides;@layer components{._4145abab73d17514__notice{--icon-height:var(--wpds-dimension-size-sm,24px);--text-vertical-padding:calc((var(--icon-height) - var(--wpds-typography-line-height-sm, 20px))/2);--wp-ui-notice-background-color:var(--wpds-color-background-surface-neutral-weak,#f4f4f4);--wp-ui-notice-border-color:var(--wpds-color-stroke-surface-neutral,#dbdbdb);--wp-ui-notice-text-color:var(--wpds-color-foreground-content-neutral,#1e1e1e);--wp-ui-notice-decorative-icon-color:var(--wpds-color-foreground-content-neutral,#1e1e1e);align-items:start;background-color:var(--wp-ui-notice-background-color);border:1px solid var(--wp-ui-notice-border-color);border-radius:var(--wpds-border-radius-lg,8px);container-type:inline-size;display:grid;grid-template-columns:auto 1fr auto;padding:var(--wpds-dimension-padding-md,12px)}.d0a25570cb528528__icon{color:var(--wp-ui-notice-decorative-icon-color);grid-column:1;grid-row:1;margin-inline-end:var(--wpds-dimension-gap-xs,4px)}._1904b570a89bb815__description,.b5397fb9d05389e3__title{color:var(--wp-ui-notice-text-color);grid-column:2;padding-block:var(--text-vertical-padding)}._1904b570a89bb815__description{text-wrap:pretty}._0a1270dcdd79c031__actions{display:flex;flex-wrap:wrap;gap:var(--wpds-dimension-gap-md,12px);grid-column:2}._4145abab73d17514__notice:has(._1904b570a89bb815__description) ._0a1270dcdd79c031__actions,._4145abab73d17514__notice:has(.b5397fb9d05389e3__title) ._0a1270dcdd79c031__actions{margin-block-start:var(--wpds-dimension-gap-sm,8px)}._983740ab855c4e09__action-button{flex-shrink:0}.d329e7416d368d31__action-link{flex-shrink:0;&:not(:first-child){margin-inline-start:var(--wpds-dimension-gap-xs,4px)}&:not(:last-child){margin-inline-end:var(--wpds-dimension-gap-xs,4px)}}._487e6a5c1375f7dc__close-icon{grid-column:3;grid-row:1;margin-inline-start:var(--wpds-dimension-gap-xs,4px)}._531c140826094795__is-info{--wp-ui-notice-background-color:var(--wpds-color-background-surface-info-weak,#f3f9ff);--wp-ui-notice-border-color:var(--wpds-color-stroke-surface-info,#a9c6e7);--wp-ui-notice-text-color:var(--wpds-color-foreground-content-info,#001b4f);--wp-ui-notice-decorative-icon-color:var(--wpds-color-foreground-content-info-weak,#006bd7)}.ae2e1004697cce95__is-warning{--wp-ui-notice-background-color:var(--wpds-color-background-surface-warning-weak,#fff7e1);--wp-ui-notice-border-color:var(--wpds-color-stroke-surface-warning,#e1bc7c);--wp-ui-notice-text-color:var(--wpds-color-foreground-content-warning,#2e1900);--wp-ui-notice-decorative-icon-color:var(--wpds-color-foreground-content-warning-weak,#926300)}._2e614a76af494837__is-success{--wp-ui-notice-background-color:var(--wpds-color-background-surface-success-weak,#ebffed);--wp-ui-notice-border-color:var(--wpds-color-stroke-surface-success,#94d29e);--wp-ui-notice-text-color:var(--wpds-color-foreground-content-success,#002900);--wp-ui-notice-decorative-icon-color:var(--wpds-color-foreground-content-success-weak,#008030)}.af00331ae17a0065__is-error{--wp-ui-notice-background-color:var(--wpds-color-background-surface-error-weak,#fff6f5);--wp-ui-notice-border-color:var(--wpds-color-stroke-surface-error,#dab1aa);--wp-ui-notice-text-color:var(--wpds-color-foreground-content-error,#470000);--wp-ui-notice-decorative-icon-color:var(--wpds-color-foreground-content-error-weak,#cc1818)}@container (max-width: 320px){._4145abab73d17514__notice:has(.b5397fb9d05389e3__title) ._0a1270dcdd79c031__actions,._4145abab73d17514__notice:has(.b5397fb9d05389e3__title) ._1904b570a89bb815__description{grid-column:1/3}}}@layer compositions{.d329e7416d368d31__action-link{margin-block:auto}._487e6a5c1375f7dc__close-icon,._983740ab855c4e09__action-button:is(._8ddb8fb33fbf3d38__is-action-button-outline,._77bbde495a8a0af3__is-action-button-minimal){--wp-ui-button-background-color-active:color-mix(in srgb,transparent 50%,var(--wpds-color-background-interactive-neutral-weak-active,#ededed))}}}");var Cb={notice:"_4145abab73d17514__notice",icon:"d0a25570cb528528__icon",title:"b5397fb9d05389e3__title",description:"_1904b570a89bb815__description",actions:"_0a1270dcdd79c031__actions","action-button":"_983740ab855c4e09__action-button","action-link":"d329e7416d368d31__action-link","close-icon":"_487e6a5c1375f7dc__close-icon","is-info":"_531c140826094795__is-info","is-warning":"ae2e1004697cce95__is-warning","is-success":"_2e614a76af494837__is-success","is-error":"af00331ae17a0065__is-error","is-action-button-outline":"_8ddb8fb33fbf3d38__is-action-button-outline","is-action-button-minimal":"_77bbde495a8a0af3__is-action-button-minimal"},fu=(0,du.forwardRef)(function({render:t,...o},n){return bt({defaultTagName:"div",render:t,ref:n,props:ye({className:Cb.actions},o)})});var pu=h(de(),1),mu=h(Ot(),1);var bu=h(Q(),1),Ss="data-wp-hash";function Es(){let e=globalThis;return e.__wpStyleRuntime||(e.__wpStyleRuntime={documents:new Map,styles:new Map,injectedStyles:new WeakMap},typeof document<"u"&&Ob(document)),e.__wpStyleRuntime}function Ab(e,t){if(!e.head)return!1;for(let o of e.head.querySelectorAll(`style[${Ss}]`))if(o.getAttribute(Ss)===t)return!0;return!1}function gu(e,t,o){if(!e.head)return;let n=Es(),r=n.injectedStyles.get(e);if(r||(r=new Set,n.injectedStyles.set(e,r)),r.has(t))return;if(Ab(e,t)){r.add(t);return}let i=e.createElement("style");i.setAttribute(Ss,t),i.appendChild(e.createTextNode(o)),e.head.appendChild(i),r.add(t)}function Ob(e){let t=Es();t.documents.set(e,(t.documents.get(e)??0)+1);for(let[o,n]of t.styles)gu(e,o,n);return()=>{let o=t.documents.get(e);if(o!==void 0){if(o<=1){t.documents.delete(e);return}t.documents.set(e,o-1)}}}function Nb(e,t){let o=Es();o.styles.set(e,t);for(let n of o.documents.keys())gu(n,e,t)}typeof process>"u",Nb("726c480820","@layer wp-ui{@layer utilities, components, compositions, overrides;@layer components{._4145abab73d17514__notice{--icon-height:var(--wpds-dimension-size-sm,24px);--text-vertical-padding:calc((var(--icon-height) - var(--wpds-typography-line-height-sm, 20px))/2);--wp-ui-notice-background-color:var(--wpds-color-background-surface-neutral-weak,#f4f4f4);--wp-ui-notice-border-color:var(--wpds-color-stroke-surface-neutral,#dbdbdb);--wp-ui-notice-text-color:var(--wpds-color-foreground-content-neutral,#1e1e1e);--wp-ui-notice-decorative-icon-color:var(--wpds-color-foreground-content-neutral,#1e1e1e);align-items:start;background-color:var(--wp-ui-notice-background-color);border:1px solid var(--wp-ui-notice-border-color);border-radius:var(--wpds-border-radius-lg,8px);container-type:inline-size;display:grid;grid-template-columns:auto 1fr auto;padding:var(--wpds-dimension-padding-md,12px)}.d0a25570cb528528__icon{color:var(--wp-ui-notice-decorative-icon-color);grid-column:1;grid-row:1;margin-inline-end:var(--wpds-dimension-gap-xs,4px)}._1904b570a89bb815__description,.b5397fb9d05389e3__title{color:var(--wp-ui-notice-text-color);grid-column:2;padding-block:var(--text-vertical-padding)}._1904b570a89bb815__description{text-wrap:pretty}._0a1270dcdd79c031__actions{display:flex;flex-wrap:wrap;gap:var(--wpds-dimension-gap-md,12px);grid-column:2}._4145abab73d17514__notice:has(._1904b570a89bb815__description) ._0a1270dcdd79c031__actions,._4145abab73d17514__notice:has(.b5397fb9d05389e3__title) ._0a1270dcdd79c031__actions{margin-block-start:var(--wpds-dimension-gap-sm,8px)}._983740ab855c4e09__action-button{flex-shrink:0}.d329e7416d368d31__action-link{flex-shrink:0;&:not(:first-child){margin-inline-start:var(--wpds-dimension-gap-xs,4px)}&:not(:last-child){margin-inline-end:var(--wpds-dimension-gap-xs,4px)}}._487e6a5c1375f7dc__close-icon{grid-column:3;grid-row:1;margin-inline-start:var(--wpds-dimension-gap-xs,4px)}._531c140826094795__is-info{--wp-ui-notice-background-color:var(--wpds-color-background-surface-info-weak,#f3f9ff);--wp-ui-notice-border-color:var(--wpds-color-stroke-surface-info,#a9c6e7);--wp-ui-notice-text-color:var(--wpds-color-foreground-content-info,#001b4f);--wp-ui-notice-decorative-icon-color:var(--wpds-color-foreground-content-info-weak,#006bd7)}.ae2e1004697cce95__is-warning{--wp-ui-notice-background-color:var(--wpds-color-background-surface-warning-weak,#fff7e1);--wp-ui-notice-border-color:var(--wpds-color-stroke-surface-warning,#e1bc7c);--wp-ui-notice-text-color:var(--wpds-color-foreground-content-warning,#2e1900);--wp-ui-notice-decorative-icon-color:var(--wpds-color-foreground-content-warning-weak,#926300)}._2e614a76af494837__is-success{--wp-ui-notice-background-color:var(--wpds-color-background-surface-success-weak,#ebffed);--wp-ui-notice-border-color:var(--wpds-color-stroke-surface-success,#94d29e);--wp-ui-notice-text-color:var(--wpds-color-foreground-content-success,#002900);--wp-ui-notice-decorative-icon-color:var(--wpds-color-foreground-content-success-weak,#008030)}.af00331ae17a0065__is-error{--wp-ui-notice-background-color:var(--wpds-color-background-surface-error-weak,#fff6f5);--wp-ui-notice-border-color:var(--wpds-color-stroke-surface-error,#dab1aa);--wp-ui-notice-text-color:var(--wpds-color-foreground-content-error,#470000);--wp-ui-notice-decorative-icon-color:var(--wpds-color-foreground-content-error-weak,#cc1818)}@container (max-width: 320px){._4145abab73d17514__notice:has(.b5397fb9d05389e3__title) ._0a1270dcdd79c031__actions,._4145abab73d17514__notice:has(.b5397fb9d05389e3__title) ._1904b570a89bb815__description{grid-column:1/3}}}@layer compositions{.d329e7416d368d31__action-link{margin-block:auto}._487e6a5c1375f7dc__close-icon,._983740ab855c4e09__action-button:is(._8ddb8fb33fbf3d38__is-action-button-outline,._77bbde495a8a0af3__is-action-button-minimal){--wp-ui-button-background-color-active:color-mix(in srgb,transparent 50%,var(--wpds-color-background-interactive-neutral-weak-active,#ededed))}}}");var Lb={notice:"_4145abab73d17514__notice",icon:"d0a25570cb528528__icon",title:"b5397fb9d05389e3__title",description:"_1904b570a89bb815__description",actions:"_0a1270dcdd79c031__actions","action-button":"_983740ab855c4e09__action-button","action-link":"d329e7416d368d31__action-link","close-icon":"_487e6a5c1375f7dc__close-icon","is-info":"_531c140826094795__is-info","is-warning":"ae2e1004697cce95__is-warning","is-success":"_2e614a76af494837__is-success","is-error":"af00331ae17a0065__is-error","is-action-button-outline":"_8ddb8fb33fbf3d38__is-action-button-outline","is-action-button-minimal":"_77bbde495a8a0af3__is-action-button-minimal"},hu=(0,pu.forwardRef)(function({className:t,icon:o=Wi,label:n=(0,mu.__)("Dismiss"),...r},i){return(0,bu.jsx)(fs,{...r,ref:i,className:$(Lb["close-icon"],t),variant:"minimal",size:"small",tone:"neutral",icon:o,label:n})});var vu=h(de(),1);var yu=h(Q(),1),Ts="data-wp-hash";function ks(){let e=globalThis;return e.__wpStyleRuntime||(e.__wpStyleRuntime={documents:new Map,styles:new Map,injectedStyles:new WeakMap},typeof document<"u"&&Mb(document)),e.__wpStyleRuntime}function Ib(e,t){if(!e.head)return!1;for(let o of e.head.querySelectorAll(`style[${Ts}]`))if(o.getAttribute(Ts)===t)return!0;return!1}function _u(e,t,o){if(!e.head)return;let n=ks(),r=n.injectedStyles.get(e);if(r||(r=new Set,n.injectedStyles.set(e,r)),r.has(t))return;if(Ib(e,t)){r.add(t);return}let i=e.createElement("style");i.setAttribute(Ts,t),i.appendChild(e.createTextNode(o)),e.head.appendChild(i),r.add(t)}function Mb(e){let t=ks();t.documents.set(e,(t.documents.get(e)??0)+1);for(let[o,n]of t.styles)_u(e,o,n);return()=>{let o=t.documents.get(e);if(o!==void 0){if(o<=1){t.documents.delete(e);return}t.documents.set(e,o-1)}}}function Bb(e,t){let o=ks();o.styles.set(e,t);for(let n of o.documents.keys())_u(n,e,t)}typeof process>"u",Bb("726c480820","@layer wp-ui{@layer utilities, components, compositions, overrides;@layer components{._4145abab73d17514__notice{--icon-height:var(--wpds-dimension-size-sm,24px);--text-vertical-padding:calc((var(--icon-height) - var(--wpds-typography-line-height-sm, 20px))/2);--wp-ui-notice-background-color:var(--wpds-color-background-surface-neutral-weak,#f4f4f4);--wp-ui-notice-border-color:var(--wpds-color-stroke-surface-neutral,#dbdbdb);--wp-ui-notice-text-color:var(--wpds-color-foreground-content-neutral,#1e1e1e);--wp-ui-notice-decorative-icon-color:var(--wpds-color-foreground-content-neutral,#1e1e1e);align-items:start;background-color:var(--wp-ui-notice-background-color);border:1px solid var(--wp-ui-notice-border-color);border-radius:var(--wpds-border-radius-lg,8px);container-type:inline-size;display:grid;grid-template-columns:auto 1fr auto;padding:var(--wpds-dimension-padding-md,12px)}.d0a25570cb528528__icon{color:var(--wp-ui-notice-decorative-icon-color);grid-column:1;grid-row:1;margin-inline-end:var(--wpds-dimension-gap-xs,4px)}._1904b570a89bb815__description,.b5397fb9d05389e3__title{color:var(--wp-ui-notice-text-color);grid-column:2;padding-block:var(--text-vertical-padding)}._1904b570a89bb815__description{text-wrap:pretty}._0a1270dcdd79c031__actions{display:flex;flex-wrap:wrap;gap:var(--wpds-dimension-gap-md,12px);grid-column:2}._4145abab73d17514__notice:has(._1904b570a89bb815__description) ._0a1270dcdd79c031__actions,._4145abab73d17514__notice:has(.b5397fb9d05389e3__title) ._0a1270dcdd79c031__actions{margin-block-start:var(--wpds-dimension-gap-sm,8px)}._983740ab855c4e09__action-button{flex-shrink:0}.d329e7416d368d31__action-link{flex-shrink:0;&:not(:first-child){margin-inline-start:var(--wpds-dimension-gap-xs,4px)}&:not(:last-child){margin-inline-end:var(--wpds-dimension-gap-xs,4px)}}._487e6a5c1375f7dc__close-icon{grid-column:3;grid-row:1;margin-inline-start:var(--wpds-dimension-gap-xs,4px)}._531c140826094795__is-info{--wp-ui-notice-background-color:var(--wpds-color-background-surface-info-weak,#f3f9ff);--wp-ui-notice-border-color:var(--wpds-color-stroke-surface-info,#a9c6e7);--wp-ui-notice-text-color:var(--wpds-color-foreground-content-info,#001b4f);--wp-ui-notice-decorative-icon-color:var(--wpds-color-foreground-content-info-weak,#006bd7)}.ae2e1004697cce95__is-warning{--wp-ui-notice-background-color:var(--wpds-color-background-surface-warning-weak,#fff7e1);--wp-ui-notice-border-color:var(--wpds-color-stroke-surface-warning,#e1bc7c);--wp-ui-notice-text-color:var(--wpds-color-foreground-content-warning,#2e1900);--wp-ui-notice-decorative-icon-color:var(--wpds-color-foreground-content-warning-weak,#926300)}._2e614a76af494837__is-success{--wp-ui-notice-background-color:var(--wpds-color-background-surface-success-weak,#ebffed);--wp-ui-notice-border-color:var(--wpds-color-stroke-surface-success,#94d29e);--wp-ui-notice-text-color:var(--wpds-color-foreground-content-success,#002900);--wp-ui-notice-decorative-icon-color:var(--wpds-color-foreground-content-success-weak,#008030)}.af00331ae17a0065__is-error{--wp-ui-notice-background-color:var(--wpds-color-background-surface-error-weak,#fff6f5);--wp-ui-notice-border-color:var(--wpds-color-stroke-surface-error,#dab1aa);--wp-ui-notice-text-color:var(--wpds-color-foreground-content-error,#470000);--wp-ui-notice-decorative-icon-color:var(--wpds-color-foreground-content-error-weak,#cc1818)}@container (max-width: 320px){._4145abab73d17514__notice:has(.b5397fb9d05389e3__title) ._0a1270dcdd79c031__actions,._4145abab73d17514__notice:has(.b5397fb9d05389e3__title) ._1904b570a89bb815__description{grid-column:1/3}}}@layer compositions{.d329e7416d368d31__action-link{margin-block:auto}._487e6a5c1375f7dc__close-icon,._983740ab855c4e09__action-button:is(._8ddb8fb33fbf3d38__is-action-button-outline,._77bbde495a8a0af3__is-action-button-minimal){--wp-ui-button-background-color-active:color-mix(in srgb,transparent 50%,var(--wpds-color-background-interactive-neutral-weak-active,#ededed))}}}");var wu={notice:"_4145abab73d17514__notice",icon:"d0a25570cb528528__icon",title:"b5397fb9d05389e3__title",description:"_1904b570a89bb815__description",actions:"_0a1270dcdd79c031__actions","action-button":"_983740ab855c4e09__action-button","action-link":"d329e7416d368d31__action-link","close-icon":"_487e6a5c1375f7dc__close-icon","is-info":"_531c140826094795__is-info","is-warning":"ae2e1004697cce95__is-warning","is-success":"_2e614a76af494837__is-success","is-error":"af00331ae17a0065__is-error","is-action-button-outline":"_8ddb8fb33fbf3d38__is-action-button-outline","is-action-button-minimal":"_77bbde495a8a0af3__is-action-button-minimal"},xu=(0,vu.forwardRef)(function({className:t,loading:o,loadingAnnouncement:n,variant:r,...i},s){return(0,yu.jsx)(sr,{...i,...o!==void 0?{loading:o,loadingAnnouncement:n??""}:{},ref:s,size:"compact",tone:"neutral",variant:r,className:$(wu["action-button"],wu[`is-action-button-${r}`],t)})});var Ru=h(de(),1);var Cs=h(Q(),1),Ps="data-wp-hash";function As(){let e=globalThis;return e.__wpStyleRuntime||(e.__wpStyleRuntime={documents:new Map,styles:new Map,injectedStyles:new WeakMap},typeof document<"u"&&zb(document)),e.__wpStyleRuntime}function Hb(e,t){if(!e.head)return!1;for(let o of e.head.querySelectorAll(`style[${Ps}]`))if(o.getAttribute(Ps)===t)return!0;return!1}function Su(e,t,o){if(!e.head)return;let n=As(),r=n.injectedStyles.get(e);if(r||(r=new Set,n.injectedStyles.set(e,r)),r.has(t))return;if(Hb(e,t)){r.add(t);return}let i=e.createElement("style");i.setAttribute(Ps,t),i.appendChild(e.createTextNode(o)),e.head.appendChild(i),r.add(t)}function zb(e){let t=As();t.documents.set(e,(t.documents.get(e)??0)+1);for(let[o,n]of t.styles)Su(e,o,n);return()=>{let o=t.documents.get(e);if(o!==void 0){if(o<=1){t.documents.delete(e);return}t.documents.set(e,o-1)}}}function Db(e,t){let o=As();o.styles.set(e,t);for(let n of o.documents.keys())Su(n,e,t)}typeof process>"u",Db("726c480820","@layer wp-ui{@layer utilities, components, compositions, overrides;@layer components{._4145abab73d17514__notice{--icon-height:var(--wpds-dimension-size-sm,24px);--text-vertical-padding:calc((var(--icon-height) - var(--wpds-typography-line-height-sm, 20px))/2);--wp-ui-notice-background-color:var(--wpds-color-background-surface-neutral-weak,#f4f4f4);--wp-ui-notice-border-color:var(--wpds-color-stroke-surface-neutral,#dbdbdb);--wp-ui-notice-text-color:var(--wpds-color-foreground-content-neutral,#1e1e1e);--wp-ui-notice-decorative-icon-color:var(--wpds-color-foreground-content-neutral,#1e1e1e);align-items:start;background-color:var(--wp-ui-notice-background-color);border:1px solid var(--wp-ui-notice-border-color);border-radius:var(--wpds-border-radius-lg,8px);container-type:inline-size;display:grid;grid-template-columns:auto 1fr auto;padding:var(--wpds-dimension-padding-md,12px)}.d0a25570cb528528__icon{color:var(--wp-ui-notice-decorative-icon-color);grid-column:1;grid-row:1;margin-inline-end:var(--wpds-dimension-gap-xs,4px)}._1904b570a89bb815__description,.b5397fb9d05389e3__title{color:var(--wp-ui-notice-text-color);grid-column:2;padding-block:var(--text-vertical-padding)}._1904b570a89bb815__description{text-wrap:pretty}._0a1270dcdd79c031__actions{display:flex;flex-wrap:wrap;gap:var(--wpds-dimension-gap-md,12px);grid-column:2}._4145abab73d17514__notice:has(._1904b570a89bb815__description) ._0a1270dcdd79c031__actions,._4145abab73d17514__notice:has(.b5397fb9d05389e3__title) ._0a1270dcdd79c031__actions{margin-block-start:var(--wpds-dimension-gap-sm,8px)}._983740ab855c4e09__action-button{flex-shrink:0}.d329e7416d368d31__action-link{flex-shrink:0;&:not(:first-child){margin-inline-start:var(--wpds-dimension-gap-xs,4px)}&:not(:last-child){margin-inline-end:var(--wpds-dimension-gap-xs,4px)}}._487e6a5c1375f7dc__close-icon{grid-column:3;grid-row:1;margin-inline-start:var(--wpds-dimension-gap-xs,4px)}._531c140826094795__is-info{--wp-ui-notice-background-color:var(--wpds-color-background-surface-info-weak,#f3f9ff);--wp-ui-notice-border-color:var(--wpds-color-stroke-surface-info,#a9c6e7);--wp-ui-notice-text-color:var(--wpds-color-foreground-content-info,#001b4f);--wp-ui-notice-decorative-icon-color:var(--wpds-color-foreground-content-info-weak,#006bd7)}.ae2e1004697cce95__is-warning{--wp-ui-notice-background-color:var(--wpds-color-background-surface-warning-weak,#fff7e1);--wp-ui-notice-border-color:var(--wpds-color-stroke-surface-warning,#e1bc7c);--wp-ui-notice-text-color:var(--wpds-color-foreground-content-warning,#2e1900);--wp-ui-notice-decorative-icon-color:var(--wpds-color-foreground-content-warning-weak,#926300)}._2e614a76af494837__is-success{--wp-ui-notice-background-color:var(--wpds-color-background-surface-success-weak,#ebffed);--wp-ui-notice-border-color:var(--wpds-color-stroke-surface-success,#94d29e);--wp-ui-notice-text-color:var(--wpds-color-foreground-content-success,#002900);--wp-ui-notice-decorative-icon-color:var(--wpds-color-foreground-content-success-weak,#008030)}.af00331ae17a0065__is-error{--wp-ui-notice-background-color:var(--wpds-color-background-surface-error-weak,#fff6f5);--wp-ui-notice-border-color:var(--wpds-color-stroke-surface-error,#dab1aa);--wp-ui-notice-text-color:var(--wpds-color-foreground-content-error,#470000);--wp-ui-notice-decorative-icon-color:var(--wpds-color-foreground-content-error-weak,#cc1818)}@container (max-width: 320px){._4145abab73d17514__notice:has(.b5397fb9d05389e3__title) ._0a1270dcdd79c031__actions,._4145abab73d17514__notice:has(.b5397fb9d05389e3__title) ._1904b570a89bb815__description{grid-column:1/3}}}@layer compositions{.d329e7416d368d31__action-link{margin-block:auto}._487e6a5c1375f7dc__close-icon,._983740ab855c4e09__action-button:is(._8ddb8fb33fbf3d38__is-action-button-outline,._77bbde495a8a0af3__is-action-button-minimal){--wp-ui-button-background-color-active:color-mix(in srgb,transparent 50%,var(--wpds-color-background-interactive-neutral-weak-active,#ededed))}}}");var jb={notice:"_4145abab73d17514__notice",icon:"d0a25570cb528528__icon",title:"b5397fb9d05389e3__title",description:"_1904b570a89bb815__description",actions:"_0a1270dcdd79c031__actions","action-button":"_983740ab855c4e09__action-button","action-link":"d329e7416d368d31__action-link","close-icon":"_487e6a5c1375f7dc__close-icon","is-info":"_531c140826094795__is-info","is-warning":"ae2e1004697cce95__is-warning","is-success":"_2e614a76af494837__is-success","is-error":"af00331ae17a0065__is-error","is-action-button-outline":"_8ddb8fb33fbf3d38__is-action-button-outline","is-action-button-minimal":"_77bbde495a8a0af3__is-action-button-minimal"},Eu=(0,Ru.forwardRef)(function({className:t,render:o,...n},r){return(0,Cs.jsx)(Je,{ref:r,className:$(jb["action-link"],t),...n,variant:"body-md",render:(0,Cs.jsx)(en,{tone:"neutral",variant:"default",render:o})})});var Tu=h(de(),1),ku=h(Q(),1),Pu=(0,Tu.forwardRef)(({children:e,className:t,ariaLabel:o,as:n="div",...r},i)=>(0,ku.jsx)(n,{ref:i,className:$("admin-ui-navigable-region",t),"aria-label":o,role:"region",tabIndex:"-1",...r,children:e}));Pu.displayName="NavigableRegion";var Cu=Pu;var Ou=h(on(),1),{Fill:Nu,Slot:Lu}=(0,Ou.createSlotFill)("SidebarToggle");var $e=h(Q(),1),Os="data-wp-hash";function Ns(){let e=globalThis;return e.__wpStyleRuntime||(e.__wpStyleRuntime={documents:new Map,styles:new Map,injectedStyles:new WeakMap},typeof document<"u"&&Vb(document)),e.__wpStyleRuntime}function Fb(e,t){if(!e.head)return!1;for(let o of e.head.querySelectorAll(`style[${Os}]`))if(o.getAttribute(Os)===t)return!0;return!1}function Iu(e,t,o){if(!e.head)return;let n=Ns(),r=n.injectedStyles.get(e);if(r||(r=new Set,n.injectedStyles.set(e,r)),r.has(t))return;if(Fb(e,t)){r.add(t);return}let i=e.createElement("style");i.setAttribute(Os,t),i.appendChild(e.createTextNode(o)),e.head.appendChild(i),r.add(t)}function Vb(e){let t=Ns();t.documents.set(e,(t.documents.get(e)??0)+1);for(let[o,n]of t.styles)Iu(e,o,n);return()=>{let o=t.documents.get(e);if(o!==void 0){if(o<=1){t.documents.delete(e);return}t.documents.set(e,o-1)}}}function Wb(e,t){let o=Ns();o.styles.set(e,t);for(let n of o.documents.keys())Iu(n,e,t)}typeof process>"u",Wb("ddd9aab364","._956b6df0898efed0__page{text-wrap:pretty;background-color:var(--wpds-color-background-surface-neutral,#fcfcfc);color:var(--wpds-color-foreground-content-neutral,#1e1e1e);display:flex;flex-flow:column;height:100%;position:relative;z-index:1}._0625b55e82a0d93d__header{background:var(--wpds-color-background-surface-neutral-strong,#fff);border-block-end:var(--wpds-border-width-xs,1px) solid var(--wpds-color-stroke-surface-neutral-weak,#f0f0f0);inset-block-start:0;padding:var(--wpds-dimension-padding-lg,16px) var(--wpds-dimension-padding-2xl,24px);position:sticky;z-index:1}.a43c44d5ae28b2e8__header-content{min-height:var(--wpds-dimension-size-md,32px)}.b7cb5b9daf3a3b25__header-actions{flex-shrink:0}._8113be94e7caf73c__header-title{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}._9a776c7f70996f61__header-visual{display:grid;flex-shrink:0;grid-template-columns:1fr;grid-template-rows:1fr;height:var(--wpds-dimension-size-sm,24px);width:var(--wpds-dimension-size-sm,24px);>*{grid-column:1/-1;grid-row:1/-1;max-height:100%;max-width:100%}}.d5e0920cd15d35bc__sidebar-toggle-slot:empty{display:none}._60fea2f6bf5319cd__header-subtitle{color:var(--wpds-color-foreground-content-neutral-weak,#707070);padding-block-end:var(--wpds-dimension-padding-xs,4px)}.be5e57d029ec4036__content{display:flex;flex-direction:column;flex-grow:1;overflow:auto;&._128806d0b26e3a50__has-padding{padding:var(--wpds-dimension-padding-lg,16px) var(--wpds-dimension-padding-2xl,24px)}}");var to={page:"_956b6df0898efed0__page",header:"_0625b55e82a0d93d__header","header-content":"a43c44d5ae28b2e8__header-content","header-actions":"b7cb5b9daf3a3b25__header-actions","header-title":"_8113be94e7caf73c__header-title","header-visual":"_9a776c7f70996f61__header-visual","sidebar-toggle-slot":"d5e0920cd15d35bc__sidebar-toggle-slot","header-subtitle":"_60fea2f6bf5319cd__header-subtitle",content:"be5e57d029ec4036__content","has-padding":"_128806d0b26e3a50__has-padding"};function Mu({headingLevel:e=1,breadcrumbs:t,badges:o,visual:n,title:r,subTitle:i,actions:s,showSidebarToggle:a=!0}){let d=`h${e}`;return(0,$e.jsxs)(Po,{direction:"column",className:to.header,children:[(0,$e.jsxs)(Po,{className:to["header-content"],direction:"row",gap:"sm",justify:"space-between",children:[(0,$e.jsxs)(Po,{direction:"row",gap:"sm",align:"center",justify:"start",children:[a&&(0,$e.jsx)(Lu,{bubblesVirtually:!0,className:to["sidebar-toggle-slot"]}),n&&(0,$e.jsx)("div",{className:to["header-visual"],"aria-hidden":"true",children:n}),r&&(0,$e.jsx)(Je,{className:to["header-title"],render:(0,$e.jsx)(d,{}),variant:"heading-lg",children:r}),t,o]}),s&&(0,$e.jsx)(Po,{align:"center",className:to["header-actions"],direction:"row",gap:"sm",children:s})]}),i&&(0,$e.jsx)(Je,{render:(0,$e.jsx)("p",{}),variant:"body-md",className:to["header-subtitle"],children:i})]})}var nn=h(Q(),1),Is="data-wp-hash";function Ms(){let e=globalThis;return e.__wpStyleRuntime||(e.__wpStyleRuntime={documents:new Map,styles:new Map,injectedStyles:new WeakMap},typeof document<"u"&&Ub(document)),e.__wpStyleRuntime}function Yb(e,t){if(!e.head)return!1;for(let o of e.head.querySelectorAll(`style[${Is}]`))if(o.getAttribute(Is)===t)return!0;return!1}function Bu(e,t,o){if(!e.head)return;let n=Ms(),r=n.injectedStyles.get(e);if(r||(r=new Set,n.injectedStyles.set(e,r)),r.has(t))return;if(Yb(e,t)){r.add(t);return}let i=e.createElement("style");i.setAttribute(Is,t),i.appendChild(e.createTextNode(o)),e.head.appendChild(i),r.add(t)}function Ub(e){let t=Ms();t.documents.set(e,(t.documents.get(e)??0)+1);for(let[o,n]of t.styles)Bu(e,o,n);return()=>{let o=t.documents.get(e);if(o!==void 0){if(o<=1){t.documents.delete(e);return}t.documents.set(e,o-1)}}}function Gb(e,t){let o=Ms();o.styles.set(e,t);for(let n of o.documents.keys())Bu(n,e,t)}typeof process>"u",Gb("ddd9aab364","._956b6df0898efed0__page{text-wrap:pretty;background-color:var(--wpds-color-background-surface-neutral,#fcfcfc);color:var(--wpds-color-foreground-content-neutral,#1e1e1e);display:flex;flex-flow:column;height:100%;position:relative;z-index:1}._0625b55e82a0d93d__header{background:var(--wpds-color-background-surface-neutral-strong,#fff);border-block-end:var(--wpds-border-width-xs,1px) solid var(--wpds-color-stroke-surface-neutral-weak,#f0f0f0);inset-block-start:0;padding:var(--wpds-dimension-padding-lg,16px) var(--wpds-dimension-padding-2xl,24px);position:sticky;z-index:1}.a43c44d5ae28b2e8__header-content{min-height:var(--wpds-dimension-size-md,32px)}.b7cb5b9daf3a3b25__header-actions{flex-shrink:0}._8113be94e7caf73c__header-title{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}._9a776c7f70996f61__header-visual{display:grid;flex-shrink:0;grid-template-columns:1fr;grid-template-rows:1fr;height:var(--wpds-dimension-size-sm,24px);width:var(--wpds-dimension-size-sm,24px);>*{grid-column:1/-1;grid-row:1/-1;max-height:100%;max-width:100%}}.d5e0920cd15d35bc__sidebar-toggle-slot:empty{display:none}._60fea2f6bf5319cd__header-subtitle{color:var(--wpds-color-foreground-content-neutral-weak,#707070);padding-block-end:var(--wpds-dimension-padding-xs,4px)}.be5e57d029ec4036__content{display:flex;flex-direction:column;flex-grow:1;overflow:auto;&._128806d0b26e3a50__has-padding{padding:var(--wpds-dimension-padding-lg,16px) var(--wpds-dimension-padding-2xl,24px)}}");var Ls={page:"_956b6df0898efed0__page",header:"_0625b55e82a0d93d__header","header-content":"a43c44d5ae28b2e8__header-content","header-actions":"b7cb5b9daf3a3b25__header-actions","header-title":"_8113be94e7caf73c__header-title","header-visual":"_9a776c7f70996f61__header-visual","sidebar-toggle-slot":"d5e0920cd15d35bc__sidebar-toggle-slot","header-subtitle":"_60fea2f6bf5319cd__header-subtitle",content:"be5e57d029ec4036__content","has-padding":"_128806d0b26e3a50__has-padding"};function Hu({headingLevel:e,breadcrumbs:t,badges:o,visual:n,title:r,subTitle:i,children:s,className:a,actions:d,ariaLabel:c,hasPadding:l=!1,showSidebarToggle:f=!0}){let p=$(Ls.page,a);return(0,nn.jsxs)(Cu,{className:p,ariaLabel:c??(typeof r=="string"?r:""),children:[(r||t||o||d||n)&&(0,nn.jsx)(Mu,{headingLevel:e,breadcrumbs:t,badges:o,visual:n,title:r,subTitle:i,actions:d,showSidebarToggle:f}),l?(0,nn.jsx)("div",{className:$(Ls.content,Ls["has-padding"]),children:s}):s]})}Hu.SidebarToggleFill=Nu;var Bs=Hu;var dt=h(on()),lf=h(rn()),df=h(de()),Tt=h(Ot()),uf=h(mr());import{privateApis as l0}from"@wordpress/connectors";var ju=h(Qi()),{lock:l3,unlock:No}=(0,ju.__dangerousOptInToUnstableAPIsOnlyForCoreModules)("I acknowledge private features are not for use in themes or plugins and doing so will break in the next version of WordPress.","@wordpress/routes");if(typeof document<"u"&&!document.head.querySelector("style[data-wp-hash='09e9b056ea']")){let e=document.createElement("style");e.setAttribute("data-wp-hash","09e9b056ea"),e.appendChild(document.createTextNode(".connectors-page{box-sizing:border-box;margin:0 auto;max-width:680px;padding:24px;width:100%}.connectors-page .components-item{background:#fff;border:1px solid #ddd;border-radius:8px;overflow:hidden;padding:20px;scroll-margin-top:120px}.connectors-page .connector-settings__error{color:#cc1818}.connectors-page .connector-settings .components-text-control__input{font-family:monospace;scroll-margin-top:120px}.connectors-page__file-mods-notice{margin-bottom:16px}.connectors-page--empty{align-items:center;display:flex;flex-direction:column;flex-grow:1;gap:32px;justify-content:center;text-align:center}.connectors-page .ai-plugin-callout{background-color:#e7d4e4;background-image:radial-gradient(ellipse 70% 120% at 18% 115%,rgba(202,158,198,.75) 0,rgba(202,158,198,0) 60%),radial-gradient(ellipse 55% 110% at 92% -15%,rgba(208,175,217,.7) 0,rgba(208,175,217,0) 65%),radial-gradient(ellipse 40% 85% at 58% -10%,rgba(170,130,184,.45) 0,rgba(170,130,184,0) 70%);border-radius:8px;overflow:hidden;padding:24px;padding-inline-end:150px;position:relative}[dir=rtl] .connectors-page .ai-plugin-callout{background-image:radial-gradient(ellipse 70% 120% at 82% 115%,rgba(202,158,198,.75) 0,rgba(202,158,198,0) 60%),radial-gradient(ellipse 55% 110% at 8% -15%,rgba(208,175,217,.7) 0,rgba(208,175,217,0) 65%),radial-gradient(ellipse 40% 85% at 42% -10%,rgba(170,130,184,.45) 0,rgba(170,130,184,0) 70%)}.connectors-page .ai-plugin-callout__content{align-items:flex-start;display:flex;flex-direction:column;gap:12px;padding-top:2px}.connectors-page .ai-plugin-callout__content p{font-size:13px;line-height:20px;margin:0}.connectors-page .ai-plugin-callout__decoration{height:110px;inset-inline-end:16px;position:absolute;top:12px;width:110px}.connectors-page>p{color:#949494}@media (max-width:680px){.connectors-page .ai-plugin-callout{padding:12px;padding-inline-end:100px}.connectors-page .ai-plugin-callout__decoration{height:75px;inset-inline-end:8px;top:8px;width:75px}}@media (max-width:480px){.connectors-page{padding:8px}.connectors-page .ai-plugin-callout{padding-inline-end:130px}.connectors-page .components-item{padding:12px}.connectors-page .components-item>.components-v-stack>.components-h-stack:first-child svg{height:32px;width:32px}.connectors-page .components-item>.components-v-stack>.components-h-stack:first-child>.components-h-stack:last-child{align-items:flex-end;flex-direction:column}}")),document.head.appendChild(e)}var cn=h(on()),Ws=h(mr()),ln=h(rn()),wt=h(de()),Xe=h(Ot()),rf=h(Hs()),sf=h(Wu());var gr=h(on()),js=h(de()),Qu=h(rn()),oo=h(Ot());import{__experimentalRegisterConnector as Xb,__experimentalConnectorItem as Zu,__experimentalDefaultConnectorSettings as Kb,__experimentalApplicationPasswordConnectorSettings as qb,privateApis as Zb}from"@wordpress/connectors";var zs=h(mr()),an=h(rn()),sn=h(de()),fe=h(Ot()),Yu=h(Hs());function Ds({file:e,settingName:t,connectorName:o,isInstalled:n,isActivated:r,keySource:i="none",initialIsConnected:s=!1}){let[a,d]=(0,sn.useState)(!1),[c,l]=(0,sn.useState)(!1),[f,p]=(0,sn.useState)(s),[m,u]=(0,sn.useState)(null),g=e?.replace(/\.php$/,""),v=g?.includes("/")?g.split("/")[0]:g,{derivedPluginStatus:_,canManagePlugins:w,currentApiKey:y,currentUsername:b,hasStoredCredentials:S,hasResolvedSettings:x,canInstallPlugins:E}=(0,an.useSelect)(K=>{let J=K(zs.store),me=J.getEntityRecord("root","site")?.[t],le=typeof me=="string"?me:"",X=typeof me=="object"&&me!==null?me:void 0,pe=X!==void 0?!!X.username&&!!X.password:!!le,ue=J.hasFinishedResolution("getEntityRecord",["root","site"]),vt=!!J.canUser("create",{kind:"root",name:"plugin"}),Te={currentApiKey:le,currentUsername:X?.username??"",hasStoredCredentials:pe,hasResolvedSettings:ue,canInstallPlugins:vt};if(!e)return{...Te,derivedPluginStatus:ue?"active":"checking",canManagePlugins:void 0};let Ve=J.getEntityRecord("root","plugin",g);if(!J.hasFinishedResolution("getEntityRecord",["root","plugin",g]))return{...Te,derivedPluginStatus:"checking",canManagePlugins:void 0};if(Ve){let no=Ve.status==="active"||Ve.status==="network-active";return{...Te,derivedPluginStatus:no?"active":"inactive",canManagePlugins:!0}}let He="not-installed";return r?He="active":n&&(He="inactive"),{...Te,derivedPluginStatus:He,canManagePlugins:!1}},[e,g,t,n,r]),T=m??_,k=w,C=T==="active"&&f||m==="active"&&S,{saveEntityRecord:j,invalidateResolution:A}=(0,an.useDispatch)(zs.store),{createSuccessNotice:L,createErrorNotice:I}=(0,an.useDispatch)(Yu.store),R=K=>j("root","site",{[t]:K},{throwOnError:!0}),N=()=>{L((0,fe.sprintf)((0,fe.__)("%s connected successfully."),o),{id:"connector-connect-success",type:"snackbar"})},H=()=>{L((0,fe.sprintf)((0,fe.__)("%s disconnected."),o),{id:"connector-disconnect-success",type:"snackbar"})},P=()=>{I((0,fe.sprintf)((0,fe.__)("Failed to disconnect %s."),o),{id:"connector-disconnect-error",type:"snackbar"})},O=async()=>{if(v){l(!0);try{await j("root","plugin",{slug:v,status:"active"},{throwOnError:!0}),u("active"),A("getEntityRecord",["root","site"]),d(!0),L((0,fe.sprintf)((0,fe.__)("Plugin for %s installed and activated successfully."),o),{id:"connector-plugin-install-success",type:"snackbar"})}catch{I((0,fe.sprintf)((0,fe.__)("Failed to install plugin for %s."),o),{id:"connector-plugin-install-error",type:"snackbar"})}finally{l(!1)}}},M=async()=>{if(e){l(!0);try{await j("root","plugin",{plugin:g,status:"active"},{throwOnError:!0}),u("active"),A("getEntityRecord",["root","site"]),d(!0),L((0,fe.sprintf)((0,fe.__)("Plugin for %s activated successfully."),o),{id:"connector-plugin-activate-success",type:"snackbar"})}catch{I((0,fe.sprintf)((0,fe.__)("Failed to activate plugin for %s."),o),{id:"connector-plugin-activate-error",type:"snackbar"})}finally{l(!1)}}};return{pluginStatus:T,canInstallPlugins:E,canActivatePlugins:k,isExpanded:a,setIsExpanded:d,isBusy:c,isConnected:C,currentApiKey:y,currentUsername:b,hasResolvedSettings:x,keySource:i,handleButtonClick:()=>{if(T==="not-installed"){if(E===!1)return;O()}else if(T==="inactive"){if(k===!1)return;M()}else d(!a)},getButtonLabel:()=>{if(c)return T==="not-installed"?(0,fe.__)("Installing\u2026"):(0,fe.__)("Activating\u2026");if(a)return(0,fe.__)("Cancel");if(C)return(0,fe.__)("Edit");switch(T){case"checking":return(0,fe.__)("Checking\u2026");case"not-installed":return(0,fe.__)("Install");case"inactive":return(0,fe.__)("Activate");case"active":return(0,fe.__)("Set up")}},saveApiKey:async K=>{let J=y;try{let le=(await R(K))?.[t];if(K&&(le===J||!le))throw new Error("It was not possible to connect to the provider using this key.");p(!0),N()}catch(ne){throw console.error("Failed to save API key:",ne),ne}},removeApiKey:async()=>{try{await R(""),p(!1),H()}catch(K){console.error("Failed to remove API key:",K),P()}},saveCredentials:async({username:K,applicationPassword:J})=>{try{let le=(await R({username:K,password:J}))?.[t];if(!le?.username||!le?.password)throw new Error((0,fe.__)("It was not possible to save these credentials."));p(!0),N()}catch(ne){throw console.error("Failed to save credentials:",ne),ne}},removeCredentials:async()=>{try{await R({username:"",password:""}),p(!1),H()}catch(K){console.error("Failed to remove credentials:",K),P()}}}}var Uu=()=>React.createElement("svg",{width:"40",height:"40",viewBox:"0 0 24 24",fill:"none",xmlns:"http://www.w3.org/2000/svg","aria-hidden":"true"},React.createElement("path",{d:"M22.2819 9.8211a5.9847 5.9847 0 0 0-.5157-4.9108 6.0462 6.0462 0 0 0-6.5098-2.9A6.0651 6.0651 0 0 0 4.9807 4.1818a5.9847 5.9847 0 0 0-3.9977 2.9 6.0462 6.0462 0 0 0 .7427 7.0966 5.98 5.98 0 0 0 .511 4.9107 6.051 6.051 0 0 0 6.5146 2.9001A5.9847 5.9847 0 0 0 13.2599 24a6.0557 6.0557 0 0 0 5.7718-4.2058 5.9894 5.9894 0 0 0 3.9977-2.9001 6.0557 6.0557 0 0 0-.7475-7.0729zm-9.022 12.6081a4.4755 4.4755 0 0 1-2.8764-1.0408l.1419-.0804 4.7783-2.7582a.7948.7948 0 0 0 .3927-.6813v-6.7369l2.02 1.1686a.071.071 0 0 1 .038.052v5.5826a4.504 4.504 0 0 1-4.4945 4.4944zm-9.6607-4.1254a4.4708 4.4708 0 0 1-.5346-3.0137l.142.0852 4.783 2.7582a.7712.7712 0 0 0 .7806 0l5.8428-3.3685v2.3324a.0804.0804 0 0 1-.0332.0615L9.74 19.9502a4.4992 4.4992 0 0 1-6.1408-1.6464zM2.3408 7.8956a4.485 4.485 0 0 1 2.3655-1.9728V11.6a.7664.7664 0 0 0 .3879.6765l5.8144 3.3543-2.0201 1.1685a.0757.0757 0 0 1-.071 0l-4.8303-2.7865A4.504 4.504 0 0 1 2.3408 7.872zm16.5963 3.8558L13.1038 8.364l2.0201-1.1685a.0757.0757 0 0 1 .071 0l4.8303 2.7913a4.4944 4.4944 0 0 1-.6765 8.1042v-5.6772a.79.79 0 0 0-.4043-.6813zm2.0107-3.0231l-.142-.0852-4.7735-2.7818a.7759.7759 0 0 0-.7854 0L9.409 9.2297V6.8974a.0662.0662 0 0 1 .0284-.0615l4.8303-2.7866a4.4992 4.4992 0 0 1 6.6802 4.66zM8.3065 12.863l-2.02-1.1638a.0804.0804 0 0 1-.038-.0567V6.0742a4.4992 4.4992 0 0 1 7.3757-3.4537l-.142.0805L8.704 5.459a.7948.7948 0 0 0-.3927.6813zm1.0976-2.3654l2.602-1.4998 2.6069 1.4998v2.9994l-2.5974 1.4997-2.6067-1.4997Z",fill:"currentColor"})),Gu=()=>React.createElement("svg",{width:"40",height:"40",viewBox:"0 0 32 32",fill:"none",xmlns:"http://www.w3.org/2000/svg","aria-hidden":"true"},React.createElement("path",{d:"M6.2 21.024L12.416 17.536L12.52 17.232L12.416 17.064H12.112L11.072 17L7.52 16.904L4.44 16.776L1.456 16.616L0.704 16.456L0 15.528L0.072 15.064L0.704 14.64L1.608 14.72L3.608 14.856L6.608 15.064L8.784 15.192L12.008 15.528H12.52L12.592 15.32L12.416 15.192L12.28 15.064L9.176 12.96L5.816 10.736L4.056 9.456L3.104 8.808L2.624 8.2L2.416 6.872L3.28 5.92L4.44 6L4.736 6.08L5.912 6.984L8.424 8.928L11.704 11.344L12.184 11.744L12.376 11.608L12.4 11.512L12.184 11.152L10.4 7.928L8.496 4.648L7.648 3.288L7.424 2.472C7.344 2.136 7.288 1.856 7.288 1.512L8.272 0.176L8.816 0L10.128 0.176L10.68 0.656L11.496 2.52L12.816 5.456L14.864 9.448L15.464 10.632L15.784 11.728L15.904 12.064H16.112V11.872L16.28 9.624L16.592 6.864L16.896 3.312L17 2.312L17.496 1.112L18.48 0.464L19.248 0.832L19.88 1.736L19.792 2.32L19.416 4.76L18.68 8.584L18.2 11.144H18.48L18.8 10.824L20.096 9.104L22.272 6.384L23.232 5.304L24.352 4.112L25.072 3.544H26.432L27.432 5.032L26.984 6.568L25.584 8.344L24.424 9.848L22.76 12.088L21.72 13.88L21.816 14.024L22.064 14L25.824 13.2L27.856 12.832L30.28 12.416L31.376 12.928L31.496 13.448L31.064 14.512L28.472 15.152L25.432 15.76L20.904 16.832L20.848 16.872L20.912 16.952L22.952 17.144L23.824 17.192H25.96L29.936 17.488L30.976 18.176L31.6 19.016L31.496 19.656L29.896 20.472L27.736 19.96L22.696 18.76L20.968 18.328H20.728V18.472L22.168 19.88L24.808 22.264L28.112 25.336L28.28 26.096L27.856 26.696L27.408 26.632L24.504 24.448L23.384 23.464L20.848 21.328H20.68V21.552L21.264 22.408L24.352 27.048L24.512 28.472L24.288 28.936L23.488 29.216L22.608 29.056L20.8 26.52L18.936 23.664L17.432 21.104L17.248 21.208L16.36 30.768L15.944 31.256L14.984 31.624L14.184 31.016L13.76 30.032L14.184 28.088L14.696 25.552L15.112 23.536L15.488 21.032L15.712 20.2L15.696 20.144L15.512 20.168L13.624 22.76L10.752 26.64L8.48 29.072L7.936 29.288L6.992 28.8L7.08 27.928L7.608 27.152L10.752 23.152L12.648 20.672L13.872 19.24L13.864 19.032H13.792L5.44 24.456L3.952 24.648L3.312 24.048L3.392 23.064L3.696 22.744L6.208 21.016L6.2 21.024Z",fill:"#D97757"})),Xu=()=>React.createElement("svg",{width:"40",height:"40",viewBox:"0 0 32 32",fill:"none",xmlns:"http://www.w3.org/2000/svg","aria-hidden":"true"},React.createElement("path",{d:"M0 4C0 1.79086 1.79086 0 4 0H28C30.2091 0 32 1.79086 32 4V28C32 30.2091 30.2091 32 28 32H4C1.79086 32 0 30.2091 0 28V4Z",fill:"#F0F0F0"}),React.createElement("path",{d:"M14.5 8V12H17.5V8H19V12H20.5C20.7652 12 21.0196 12.1054 21.2071 12.2929C21.3946 12.4804 21.5 12.7348 21.5 13V17L18.5 21V23C18.5 23.2652 18.3946 23.5196 18.2071 23.7071C18.0196 23.8946 17.7652 24 17.5 24H14.5C14.2348 24 13.9804 23.8946 13.7929 23.7071C13.6054 23.5196 13.5 23.2652 13.5 23V21L10.5 17V13C10.5 12.7348 10.6054 12.4804 10.7929 12.2929C10.9804 12.1054 11.2348 12 11.5 12H13V8H14.5ZM15 20.5V22.5H17V20.5L20 16.5V13.5H12V16.5L15 20.5Z",fill:"#949494"})),Ku=()=>React.createElement("svg",{width:"40",height:"40",viewBox:"0 0 44 44",fill:"none",xmlns:"http://www.w3.org/2000/svg","aria-hidden":"true"},React.createElement("rect",{width:"44",height:"44",fill:"#357B49",rx:"6"}),React.createElement("path",{fill:"#fff",fillRule:"evenodd",d:"m29.746 28.31-6.392-16.797c-.152-.397-.305-.672-.789-.675-.673 0-1.408.611-1.746 1.316l-7.378 16.154c-.072.16-.143.311-.214.454-.5.995-1.045 1.546-2.357 1.626a.399.399 0 0 0-.16.033l-.01.004a.399.399 0 0 0-.23.392v.01c0 .054.01.106.03.155l.004.01a.416.416 0 0 0 .394.252h6.212a.417.417 0 0 0 .307-.12.416.416 0 0 0 .124-.305.398.398 0 0 0-.105-.302.399.399 0 0 0-.294-.127c-.757 0-2.197-.062-2.197-1.164.02-.318.103-.63.245-.916l1.399-3.152c.52-1.163 1.654-1.163 2.572-1.163h5.843c.023 0 .044 0 .062.003.13.014.16.081.214.242l1.534 4.07a2.857 2.857 0 0 1 .216 1.04c0 .054-.003.104-.01.153-.09.726-.831.887-1.49.887a.4.4 0 0 0-.294.127l-.007.008-.007.008a.401.401 0 0 0-.092.286v.01c0 .054.01.106.03.155l.005.01a.42.42 0 0 0 .395.252h7.011a.413.413 0 0 0 .279-.13.412.412 0 0 0 .11-.297.387.387 0 0 0-.09-.294.388.388 0 0 0-.277-.135c-1.448-.122-2.295-.643-2.847-2.08Zm-11.985-5.844 2.847-6.304c.361-.728.659-1.486.889-2.265 0-.06.03-.092.06-.092s.061.032.061.091c.02.122.045.247.073.374.197.888.584 1.878.914 2.723l.176.453 1.684 4.529a.927.927 0 0 1 .092.4.473.473 0 0 1-.009.094c-.041.202-.228.272-.602.272h-6.063c-.122 0-.184-.03-.184-.092a.36.36 0 0 1 .062-.183Zm17.107-.721c0 .786-.446 1.231-1.25 1.231-.806 0-1.125-.409-1.125-1.034 0-.786.465-1.231 1.25-1.231.785 0 1.125.427 1.125 1.034ZM9.629 23.002c.803 0 1.25-.447 1.25-1.231 0-.607-.343-1.036-1.128-1.036-.785 0-1.25.447-1.25 1.231 0 .625.325 1.036 1.128 1.036Z",clipRule:"evenodd"})),qu=()=>React.createElement("svg",{width:"40",height:"40",style:{flex:"none",lineHeight:1},viewBox:"0 0 24 24",xmlns:"http://www.w3.org/2000/svg","aria-hidden":"true"},React.createElement("path",{d:"M20.616 10.835a14.147 14.147 0 01-4.45-3.001 14.111 14.111 0 01-3.678-6.452.503.503 0 00-.975 0 14.134 14.134 0 01-3.679 6.452 14.155 14.155 0 01-4.45 3.001c-.65.28-1.318.505-2.002.678a.502.502 0 000 .975c.684.172 1.35.397 2.002.677a14.147 14.147 0 014.45 3.001 14.112 14.112 0 013.679 6.453.502.502 0 00.975 0c.172-.685.397-1.351.677-2.003a14.145 14.145 0 013.001-4.45 14.113 14.113 0 016.453-3.678.503.503 0 000-.975 13.245 13.245 0 01-2.003-.678z",fill:"#3186FF"}),React.createElement("path",{d:"M20.616 10.835a14.147 14.147 0 01-4.45-3.001 14.111 14.111 0 01-3.678-6.452.503.503 0 00-.975 0 14.134 14.134 0 01-3.679 6.452 14.155 14.155 0 01-4.45 3.001c-.65.28-1.318.505-2.002.678a.502.502 0 000 .975c.684.172 1.35.397 2.002.677a14.147 14.147 0 014.45 3.001 14.112 14.112 0 013.679 6.453.502.502 0 00.975 0c.172-.685.397-1.351.677-2.003a14.145 14.145 0 013.001-4.45 14.113 14.113 0 016.453-3.678.503.503 0 000-.975 13.245 13.245 0 01-2.003-.678z",fill:"url(#lobe-icons-gemini-fill-0)"}),React.createElement("path",{d:"M20.616 10.835a14.147 14.147 0 01-4.45-3.001 14.111 14.111 0 01-3.678-6.452.503.503 0 00-.975 0 14.134 14.134 0 01-3.679 6.452 14.155 14.155 0 01-4.45 3.001c-.65.28-1.318.505-2.002.678a.502.502 0 000 .975c.684.172 1.35.397 2.002.677a14.147 14.147 0 014.45 3.001 14.112 14.112 0 013.679 6.453.502.502 0 00.975 0c.172-.685.397-1.351.677-2.003a14.145 14.145 0 013.001-4.45 14.113 14.113 0 016.453-3.678.503.503 0 000-.975 13.245 13.245 0 01-2.003-.678z",fill:"url(#lobe-icons-gemini-fill-1)"}),React.createElement("path",{d:"M20.616 10.835a14.147 14.147 0 01-4.45-3.001 14.111 14.111 0 01-3.678-6.452.503.503 0 00-.975 0 14.134 14.134 0 01-3.679 6.452 14.155 14.155 0 01-4.45 3.001c-.65.28-1.318.505-2.002.678a.502.502 0 000 .975c.684.172 1.35.397 2.002.677a14.147 14.147 0 014.45 3.001 14.112 14.112 0 013.679 6.453.502.502 0 00.975 0c.172-.685.397-1.351.677-2.003a14.145 14.145 0 013.001-4.45 14.113 14.113 0 016.453-3.678.503.503 0 000-.975 13.245 13.245 0 01-2.003-.678z",fill:"url(#lobe-icons-gemini-fill-2)"}),React.createElement("defs",null,React.createElement("linearGradient",{gradientUnits:"userSpaceOnUse",id:"lobe-icons-gemini-fill-0",x1:"7",x2:"11",y1:"15.5",y2:"12"},React.createElement("stop",{stopColor:"#08B962"}),React.createElement("stop",{offset:"1",stopColor:"#08B962",stopOpacity:"0"})),React.createElement("linearGradient",{gradientUnits:"userSpaceOnUse",id:"lobe-icons-gemini-fill-1",x1:"8",x2:"11.5",y1:"5.5",y2:"11"},React.createElement("stop",{stopColor:"#F94543"}),React.createElement("stop",{offset:"1",stopColor:"#F94543",stopOpacity:"0"})),React.createElement("linearGradient",{gradientUnits:"userSpaceOnUse",id:"lobe-icons-gemini-fill-2",x1:"3.5",x2:"17.5",y1:"13.5",y2:"12"},React.createElement("stop",{stopColor:"#FABC12"}),React.createElement("stop",{offset:".46",stopColor:"#FABC12",stopOpacity:"0"}))));var{store:Qb}=No(Zb);function Ju(){try{return JSON.parse(document.getElementById("wp-script-module-data-options-connectors-wp-admin")?.textContent??"{}")}catch{return{}}}function Fs(){return Ju().connectors??{}}function $u(){return!!Ju().isFileModDisabled}var Jb={google:qu,openai:Uu,anthropic:Gu,akismet:Ku};function $b(e,t){if(t)return React.createElement("img",{src:t,alt:"",width:40,height:40});let o=Jb[e];return React.createElement(o||Xu,null)}var e0=()=>React.createElement("span",{style:{color:"#345b37",backgroundColor:"#eff8f0",padding:"4px 12px",borderRadius:"2px",fontSize:"13px",fontWeight:"var(--wpds-typography-font-weight-emphasis)",whiteSpace:"nowrap"}},(0,oo.__)("Connected")),t0=({slug:e})=>React.createElement(en,{href:(0,oo.sprintf)((0,oo.__)("https://wordpress.org/plugins/%s/"),e),openInNewTab:!0},(0,oo.__)("Learn more")),o0=()=>React.createElement(Ii,null,(0,oo.__)("Not available"));function ef({isConnected:e,showUnavailableBadge:t,pluginSlug:o,isExpanded:n,isBusy:r,pluginStatus:i,actionButtonRef:s,handleButtonClick:a,getButtonLabel:d}){return React.createElement(gr.__experimentalHStack,{spacing:3,expanded:!1},e&&React.createElement(e0,null),t&&(o?React.createElement(t0,{slug:o}):React.createElement(o0,null)),!t&&React.createElement(gr.Button,{ref:s,variant:n||e?"tertiary":"secondary",size:"compact",onClick:a,disabled:i==="checking"||r,isBusy:r,accessibleWhenDisabled:!0},d()))}function tf(e){let t=e?.replace(/\.php$/,"");return t?.includes("/")?t.split("/")[0]:t}function n0({name:e,description:t,logo:o,authentication:n,plugin:r}){let i=n?.method==="api_key"?n:void 0,s=i?.settingName??"",a=i?.credentialsUrl??void 0,d=tf(r?.file),{pluginStatus:c,canInstallPlugins:l,canActivatePlugins:f,isExpanded:p,setIsExpanded:m,isBusy:u,isConnected:g,currentApiKey:v,hasResolvedSettings:_,keySource:w,handleButtonClick:y,getButtonLabel:b,saveApiKey:S,removeApiKey:x}=Ds({file:r?.file,settingName:s,connectorName:e,isInstalled:r?.isInstalled,isActivated:r?.isActivated,keySource:i?.keySource,initialIsConnected:i?.isConnected}),E=w==="env"||w==="constant",T=c==="not-installed"&&l===!1||c==="inactive"&&f===!1,k=(0,js.useRef)(null);return React.createElement(Zu,{className:d?`connector-item--${d}`:void 0,logo:o,name:e,description:t,actionArea:React.createElement(ef,{isConnected:g,showUnavailableBadge:T,pluginSlug:d,isExpanded:p,isBusy:u,pluginStatus:c,actionButtonRef:k,handleButtonClick:y,getButtonLabel:b})},p&&c==="active"&&_&&React.createElement(Kb,{key:g?"connected":"setup",initialValue:E?"\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022":v,helpUrl:a,readOnly:g||E,keySource:w,onRemove:E?void 0:async()=>{await x(),k.current?.focus()},onSave:async C=>{await S(C),m(!1),k.current?.focus()}}))}function r0({name:e,description:t,logo:o,authentication:n,plugin:r}){let i=n?.method==="application_password"?n:void 0,s=i?.settingName??"",a=i?.credentialsUrl??void 0,d=tf(r?.file),{pluginStatus:c,canInstallPlugins:l,canActivatePlugins:f,isExpanded:p,setIsExpanded:m,isBusy:u,isConnected:g,currentUsername:v,hasResolvedSettings:_,keySource:w,handleButtonClick:y,getButtonLabel:b,saveCredentials:S,removeCredentials:x}=Ds({file:r?.file,settingName:s,connectorName:e,isInstalled:r?.isInstalled,isActivated:r?.isActivated,keySource:i?.keySource,initialIsConnected:i?.isConnected}),E=w==="env"||w==="constant",T=(0,js.useRef)(null),k=c==="not-installed"&&l===!1||c==="inactive"&&f===!1;return React.createElement(Zu,{className:d?`connector-item--${d}`:void 0,logo:o,name:e,description:t,actionArea:React.createElement(ef,{isConnected:g,showUnavailableBadge:k,pluginSlug:d,isExpanded:p,isBusy:u,pluginStatus:c,actionButtonRef:T,handleButtonClick:y,getButtonLabel:b})},p&&c==="active"&&_&&React.createElement(qb,{key:g?"connected":"setup",initialUsername:E?"\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022":v,helpUrl:a,readOnly:g||E,keySource:w,onRemove:E?void 0:async()=>{await x(),T.current?.focus()},onSave:async C=>{await S(C),m(!1),T.current?.focus()}}))}function of(){let e=Fs(),t=o=>o.replace(/[^a-z0-9-_]/gi,"-");for(let[o,n]of Object.entries(e)){if(o==="akismet"&&!n.plugin?.isInstalled)continue;let{authentication:r}=n,i=t(o),s={name:n.name,description:n.description,type:n.type,logo:$b(o,n.logoUrl),authentication:r,plugin:n.plugin},a=No((0,Qu.select)(Qb)).getConnector(i);r.method==="api_key"&&!a?.render?s.render=n0:r.method==="application_password"&&!a?.render&&(s.render=r0),Xb(i,s)}}function nf(){return React.createElement("div",{className:"ai-plugin-callout__decoration","aria-hidden":"true"},React.createElement("svg",{viewBox:"0 0 248 248",xmlns:"http://www.w3.org/2000/svg",xmlnsXlink:"http://www.w3.org/1999/xlink",focusable:"false",style:{width:"100%",height:"100%"}},React.createElement("image",{href:"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAASwAAAEsCAYAAAB5fY51AAAQAElEQVR4AezdC3ojWW5tYflOzPbIbI/M9sh8+WdrdZ+KpiiKL5FB5KedwN7AeSFIpHRYmfX/PubXVGAqMBV4kQpMw3qRBzXbnApMBT4+pmHNq2AqMBV4mQpMw3qZR3X9RmeGqcCrV2Aa1qs/wdn/VOCNKjAN640e9hx1KvDqFZiG9epPcPY/FThWgZ1q07B2+mDnWFOBPVZgGtYen+qcaSqw0wpMw9rpg51jTQX2WIFpWMee6mhTganAU1ZgGtZTPpbZ1FRgKnCsAtOwjlVltKnAVOApKzAN6ykfy2zqcRWYlV6pAtOwXulpzV6nAm9egWlYb/4CmONPBV6pAtOwXulpve9e//Nw9P/7xL8d7Hy9aQWubFhvWrU59qMr8D+HBcPBna93rcA0rHd98q91bs3q3w9bBv7Bna93rMA0rHd86nPmqcCLVmAa1os+uF/Y9m8u6Q7rvw8bgLnDOhTiXb+mYb3rk3+tc//rYbsaVTjQP18amct4+h9hftt3BaZh7fv57v107rNg7+ec831WYBrWZyHGPHUF/vewu//6xNqg+HMRfyjMrb+edb5pWM/6ZGZfawX86Bc0qTU2/htVYBrWGz3sOepU4NUrMA3r1Z/g7H8q8EYVmIZ1h4c9U04FpgL3qcA0rPvUdWadCkwF7lCBaVh3KOpMORWYCtynAtOw7lPXmfVdKjDnfGgFpmE9tNyz2FRgKnBNBaZhXVO9GTsVmAo8tALTsB5a7llsKjAVuKYCv9uwrtn5jJ0KTAXergLTsN7ukc+BpwKvW4FpWK/77GbnU4G3q8A0rLd75L914Fl3KnB9BaZhXV/DmWEqMBV4UAWmYT2o0LPMVGAqcH0FpmFdX8OZYSowFfhrBe7GpmHdrbQz8VRgKnDrCkzDunVFZ76pwFTgbhWYhnW30s7EU4GpwK0rMA3r1hW9fr6ZYSowFfiiAtOwvijMyFOBqcDzVWAa1vM9k9nRVGAq8EUFpmF9UZiRpwKPqMCs8bMKTMP6Wb0meyowFfjFCkzD+sXiz9JTganAzyowDetn9ZrsqcBU4Bcr8NIN6xfrNktPBaYCv1CBaVi/UPRZciowFbisAtOwLqvbjJoKTAV+oQLTsH6h6LPkBRWYIVOBQwWmYR2KMF9TganAa1RgGtZrPKfZ5VRgKnCowDSsQxHmayowFXimCny9l2lYX9dmIlOBqcCTVWAa1pM9kNnOVGAq8HUFpmF9XZuJTAWmAk9WgWlYT/ZArt/OzDAV2G8FpmHt99nOyaYCu6vANKzdPdK7HOjfDrP+9yf4B/fP138efoeDma+pwP0rMA3r/jXewwqaVFjP8x8HAmIHd74eXIG3W24a1ts98pse+H8Os8HBzNdU4P4VmIZ1/xrvYQU/9v3L4SCwNqh/P2iwagdpvqYC96nANKz71HVmnQpMBe5QgXduWHco526n9B3W9tJ91fi7Pfwc7HkqMA3reZ7FM+/kXw+bc7EeDvTPV1z8jzC/TQXuWYFpWPes7sw9FZgK3LQC07BuWs7dTva/h5P91ye6YGfT2EP4eb9mZ/uowDSsfTzHe5/CHVXQqKzHbjX6YCpwtwpMw7pbaX808f8dsoN7oQOdr6nAVGBbgWlY24o8hvvEzXcnp1YTl3cq51ExTdRe7GldE6ev2vhTgbtV4KyGdbfV33diDeC7T9bE5T1LlezFntb94PRVG38qcLcKTMO6W2lPTuy/GPdfiJfED+6G6Lg8/m/Dnuxne7lOe5Y9/naNZv0HVGAa1gOKfMYSGkI4I/1XUp59f79SlFn0sRWYhvXYereaex/3PyunQT9iiePlPMbOKlOBJ67ANKzfeTiakvufVsdDmjgtPnYq8PYVmIb1Oy8B9z7uf06tLi7vVM7EpgJvVYFpWM/xuF1mB3dFz7Gr2cXOK/B6x5uG9RzPzH1VeI4dzS6mAk9YgWlYT/hQZktTganA8QpMwzpel1GnAlOBJ6zANKyLH8oMnApMBR5dgWlYj674rDcVmApcXIFpWBeXbgZOBaYCj67ANKxHV3zWe8UKzJ6fpALTsJ7kQcw2pgJTge8rMA3r+xpNxlRgKvAkFZiG9SQPYrYxFZgKfF+BRzSs73cxGVOBqcBU4IwKTMM6o0iTMhWYCjxHBaZhPcdzmF1MBaYCZ1RgGtYZRXrSFP9Wln/gD/htEwd/mTqNT4Nyj2ny+7/3sDgYh4NxNBYHcdrH4Tc8HOifL/E044hsmjgN0tj2Ko6DcfLE8EADcfmAg1w64IMXrcA0rNd5cN6Y3njrjnE4pvkHANP58iCNxQO+B3x11v84HA6c9+DO1ytWYBrW6zw1/6Df/FtZlz8vtYPLZ5iRv16BaVi//gjO3sD2zYb7F0mB30Q4aHBpfBqU68cjHPjl4iHNPy5oDljH4yBeLh7SxNN+Mr7cY+PFmpNtLf52/2suv9yx11TgF8ZOw/qFor/gkt7kKzrCJZox9xzf3GN3WIFpWK/xUN27hHbsuyIXyyBGZ/FAgzgrh7aO59PE5AQaiKfJ2WriNCiPPZabxsqBa8ebw9rAD9agmX+r0QcvVoFpWK/xwLzxvOFcGq87pof0OHtKE5MDLqpxwAMO4mksDfggjgMecBBPY2lsEKdBGouDOB5oEGdx4Ac8pLFpY1+sAtOwfuuBXb+uN/H1s9x2hm0zWH/8u+1KM9tbVmAa1ms8dj/SuEh2odyOXUSHtTGksadyjZETjuWm/e/BKc+4A/1g09iPw69j2kH+OHe8XHMF89GOjaeXx+LAD41nt5rcwYtVYBrWiz2wZbvehBoZ8IVYPNAgzuKw5vK32prLD8dy08xRHosDP5TLbrVtLg7lscbRWDzQIM7isObyaYMXrMA0rBd8aN9s2Y9lodQ4m/ZK1r7Duu80Np0f0n7ZzvK3qsA0rFtV8vHzeFP6r9+Bbwesy/lAgzgrh8YaC+t3I3SclRdoEGflbDU6TYwfaBBn5Ww1fN2nPB820ECcZiweaLDmyqGBcSunDV6sAtOwXuyBPWC73tTe9NulaNuLfhqsuY1nV13eOeONMRb4t4Q93HK+mevBFZiG9eCC33i5ay+S3efAui3fibjc3+o06625OH3VjKOZZ9V9aCB/1XC5q2YczTyrfq7vgt5YWMc076qN/2IVeIGG9WIVfdx2vSG9CaFVaRpDSI+zcuisxgDrHMXYFfLhO018m0eDrY6D2AoapNmjvUN7FcdDueLyQU762B1UYBrWDh7iHGEq8C4VmIa1vyfdJbTvNDqdi/Ww3g3JgfKe1TpT+2+/zpHGtne6HEgbu5MKTMPayYNcjuENC9sL7iXl765LaPi78MvOLZZ3dmc65/y3WG/meGAFpmE9sNhPuJQ7HhffT7i1i7fkTODy/eJJZuBzVmAa1nM+l2t21SW0S+fm4Qdv5lV/hR+dNNXt/p0jje1M6a9wrvY89swKTMM6s1AvnuZNHF7xKO092xnibNrYF6nAJduchnVJ1Z57jO8sQjt1aR3c8aS/inWe7f6dI43tLPSQNnYnFZiGtZMHuRzDhTOsl869gdkl9cOna7Bqz+g7i72H9hhnV00DU4O0sTupwDSsnTzIOcZU4B0qMA3rRZ/yiW27gAYX1aXxw3rf0wV9ec9qfeJ3bP9pbHv346NzqUHa2J1UYBrWTh7kcgwNKSR7E4e0V7LtnXU2e2fxQBvsvALTsHb+gD+P544nfEovZdo7u24cD6s+/k4rMA1rfw/WJTq4eO50/OANvtXjz2pdoLd/31HZp3OksTQQx1n89TEn+HsFpmH9vRRv6XjTw94O70w+Wdzbud7+PNOw9vcScLcDLqq/O52Lafgu75Xizu5M60X8K+1/9nqiAtOwThTnRUPerLD+SORTs+AN3dH4EH9W6zztv3PZdxrb3ukhbexOKrD/hrWTBzXHmApMBT4+pmHt71Xgwhn6TsQJXcIH9zs04AP/meE87b9z2Xca2/7pciBt7E4qMA1rJw9yOYY3LJxz6awRwDL85V1n96niOed/+cO+2wGmYb3bE//reXd21/PncJ3pnA8d/gyY316nAtOwXudZnbvTfzkkgovqg/vnix+8of+Ih9/SDu5Tf/nEr722fzaN7QDp8yNhFdmRnYa1o4d54ijexOFE2tOG2nu2jcbZtLE7rsA0rP09XN9ZhE7nniq440l/Fes82/07Rxr7KmeZfV5RgaVhXTHLDH2mCrhwhvXS2Zs7rHutEazaM/rO0v7Z9sgPac7kU8NpYlVkR3Ya1o4e5hzlLxWYS/e/lGMfZBrWPp7jeoouqNl0fljve3w3sl5Yl/9sVvM5tv80tj07kw8d2LSxO6nANKydPMjlGN6omhIk0wLt1dDe2c7F4uHVzjT7vaAC07AuKNoLDumeh33B7X/Yd/hYfqWxizzuXiswDWt/T9aFM6yXzvywvrnTnr0KPkRor76jsl/nSGNpII6z+GBHFZiGtaOHecFRvOnhgqFPPcSZfLL41Jt81Ob2tM40rD09zb+dxd0OuKj+m/L17y7c4euM14v4zsqZ1ov41zvF7PhoBaZhHS3LS4verOCN20F8ahY0s3Q+xJ/VOk/771z2ncauexeDVRt/BxWYhrWDhzhHmAq8SwWmYX3zpF8w7MIZ+k7EEVzCB/c7NOAD/5nhPO2/c9l3Gtv+6XIgbexOKjANaycPcjmGNyycc+msEcAy/OVdZ/ep4jnnf/nDvtsBpmG92xP/63nd88Bf1ddmzgPnfOjw2id9w91Pw9rfQ3cBDS6qOx0/eDNv9fizWp/4bffvHGlse0//+Y+EzTD2aSswDetpH81NN+ZNHG468YMma+/Zlo2zaWN3XIFpWPt7uL6zCJ0uzrrjSX+Utaa1gW9dFg+0r7Dm8strLJs2dscVmIa1v4frwhnWS2c8rCf26Rqs2j381mZrOCwO617pPghg24uckM6mseVqXs5kjrSxO6nA7RrWTgoyx3iaCmhIT7OZ2chzVGAa1nM8h1vuwgV1aN44u973uJyH8u5lfWJnbWh9Fg+tTXeJ7jultGPjxRrL4mCcM5kDH+yoAtOwdvQwP4/iDQve+J/SBx4+fuFXa7Pti8WBf2pbckK5bBp7avzEdlKBaVg7eZDfHMOPV+Gb1HPCP85pbbbB/BXpx+xXeV/px+YYbQcVmIa1g4e4OYILZ1gvnfnBm7wh8mDVil1jfcezzulSvPXFzC2eJk4DcTqLgzgN0tfxdHkgjrP4YEcVmIa1o4f5w6N4w/9wyFnp5tVgzkr+Iskc6yeHX6R9KV87/suJJ/C7FZiG9bv1v8fq7nbARfWp+eW4rIZTeT+NmdeFN/vTsfKNsyfAf4prx/90vb3nP9X5pmE91eO4yWY0C1h/JPKpWfCGbiE5sGrFrrHb+eyn9a1nbjZNnAbGirE4iJcrRhNPY2lAl8Pigx1VYBrWjh7mHGUqsPcKTMPa3xN24Qy+y+h0LtaD+x06Kwfwe8Ia9gTWtRaLgzjtK8hpoPtu7gAAEABJREFU/+Uan8Y2li4H0sbupALTsO77IH9jdm9YOOfS2uU4yL/3Xq0B6zo4nLPXddwp33zOdMs5T603sQdWYBrWA4s9Sz2kAu6u4LsPHR6ymVnkthWYhnXbej7DbC6gwUV1++EHb2Y6Kw/4tHvBj2fWgdZi29N3nwiKl2ucfbJpLA3SrYkPdlSBaVg7epgnjuJNHE6kPTzUnthTi4uvKPeYVuzhdha8fwWmYd2/xo9ewR0OrN9h8INYe+JD/FmtPR7bfxr7rHuffd2wAtOwbljMJ5nKJ2qwXjq7hA5tUxOQB/z0Z7TtnW2vLB7at+blU0PnShu7kwpMw9rJg5xjTAXeoQJP07DeodgPOqML6tCSx+56aC6rgV/uM1qf+NkjrPvDQ7rvsJxJDdLG7qQC07B28iCXY3jDgjdysjdwWHU+lPes1nnaP98+7TuNpQUxiI/dSQWmYe3kQX5zDPc94ZvUpwy3d3bdIB5WffydVmAa1v4erAtnWC+d+cEb3KlZecCnPQY/X8XFevvvOyx7TmObVRxn08bupALTsHbyIOcYf6mAZrZ+SvqX4JDXrcA0rNd9dl/t3N0NuKj+Kocux8U04HvBXs+1l+dz1TmmYV1Vvqcc7AIa1h+J/JWY4A3dxuXAqhV7Jus87d9+7c2e01ga0OWw+OBXK3Dbxadh3baeM9tUYCpwxwpMw7pjcR8wtbsaaCm+S/SQHmflpL+K9R2Ti3Ro/6zzhM4iJ8hJH7uDCkzDeu2H2Bvz0lNoBHDp+EeO03zCqXXLYU/lTewFKzAN66kf2rebc08D3yZ+kaBZXTP+i2lHngrcpwLTsO5T10fN6jIaWk/zwUN6nJWTzm457dmgsdo7tF8WD+05zspJH7uDCkzD2sFD3BzBmzQUirNpr2btPbT3OHtKKzb2xSswDet1H6A7Gt95AL+T4CFNPKTtwXZOtvPwgzNv9VUr9gx29nBGBaZhnVGkJ03xxvNXVoBvmyweaEC/9oLePM8EZ+qc63/Vnsa2Xw0MB+PSx75YBaZhvdgDm+1OBd65AtOwXvfpu7cJ6ynS2HTfYbiEhrQ9WH+tKHQe515Bx8vj0wYvWIG9NKwXLP3VW/bG04BAQzLhqtFpQQzir26dxbmB33mcO6SJywN++tgXq8A0rBd7YMt23cWERf5IYz/u+Mv8K1rqmFbsEfbY+se0R+xl1rhxBaZh3bigd5rOhfn2r6B4E9LBdw6WXjU67RZo3tYxZ5p1XGbTAA84GNf+jaMBH/iXwFhzs41vbTadxUF+uXScTRv7xBWYhvXED+fJtuZNvX4ad4vtmVMT+dFcS7Lxa7NcQme5144/a5FJul0FpmHdrpb3nMm/beXuBe65zqm5u7Q+lXMqZu+w5vjuZv2nYdbYOb753Fex5+Rvc6x/zfjtfMPvXIFpWHcu8I2m743lzdWUNG924NO9cfFAuwXMaw22+fDWWfeVxq65cmCdo/il1lywjrduKLbulb/ml7Nq4z9pBaZhPemDmW1NBaYCHx8fmyJMw9oU5AmoexXfBbBtpwtrNl2O+x9IK/8aay5Y57CGtcG6YiwO4jTAAw7iacbRrEGHNPq1MJc5oblop9Zfcxsz9gkrMA3r+R6KS2Twhv5ud3JCubg3aPyn1nhvYPanY3+abw245WW+ucwJ3+1HTvgud+JPUIFpWE/wEDZb6IJ9I59N3cnA2QM2ica6YGc3oaFTgd+twDSs363/sdV9d+Rymi2OhxqJ+FYr/285sZ9ZY829jtLAtmvJSRMvP41NE8fB/HQWB3HaLWAuc0LzrXu1Lp2VE2iDJ6/ANKwnf0Cf2/PmCp/SH5PG/hHu9Jv5V7TMJZoxp8YXu9SaP6xzpLHp/JA29okrMA3r+R6O7wZCu4uz7lzS+RC/hbVGaD5rpPHpbBpLA37AYc3lbzX5tHvBmtYAfuvgIU08jZ8+9gkqMA3rdx+CNwS0C74L95AeZ9PkuhwHfvq11hqhueJsa7E4uOg+lntMM47OGgvrePqtz2RO6wB/uz6dBvyAvwjeY5vTsH73OXvzwLFd+FHlmH5P7au93HPNY3PbBxyLjfbGFZiG9bsPvx891l1oVODTwnQ8rFoXxmLp11qX1qG57MUakMYP4sf0NPFy0+Ks+Kr7L9XVJu1aa43OxG8+fkizl2O5xcf+YgWmYf1i8Y8s7c1TE1rfsGmsnIbyIX6tNZd1Q/Ph1gY+XS4OaXQ84CCexqd9NV7s1rCWdYFvfrY9sTSQE/DBE1VgGtafh/FUv/lRKLSxOJt2L2uN0Bpx9pj2lX4q15gV5T7SHlv/mPbIPc1aJyowDetEce4c8qf4uX9dxCV08IZqa3yIX2vN1Tps87mExsG+6WuuOA3kBBzE04yjsWniNDA/ncVvAWtVa745WesEGsRZOTTg33JP5hz8sALTsH5YsCdL9yaCJ9vWVdvxiaEzsVdNdOPBmirceNqZ7icVmIb1k2rdNtcdSrh0Zn/iw6Xjn3GcS291YW+5P3PCpXO6iF/vui6dZ8ZdUYFpWFcU78qh3jzeAFDTofmELLREnJWTfmtrbmuE5rfHtPbKpomXm8amieNgHJ3FQZwGdJzFbwHnMifwzclaO9AgzsqhAR/4g1+qwDSsXyr8LDsVmAr8vALTsH5es0tGuJPxHQM0nu9iF8TpLB5oEGfl0G453lzmNLc1Ag3E0+RsNXEalMfiII7DT8Yfy01jzResA3FWDu3Y+mJygjyIs3Jo63g+bfALFfhpw/qFLe5iSZe1sF4k870hQgeNs6c0MTkBhziLAz/gIc1etppYmjge0uPiW00sTRwP6XHxrSaWJo6H9Dh7ShOTE3CIszjwAx7S7CVt7IMrMA3rwQWf5aYCU4HLKzAN6/La/WSky16XuGzj+tSJ1mUui4dy46wcuh9NcEhj8SAP4qwc2jreXmhicgINxNPk0I6Np5fH4nDp+NY6Nl7MGsE6EGfl0Na9prFygjyIs3Jo63h7oQ1+oQLTsH6h6J9LejOs+JQ/ztU+Dr/OzT2Wdxj+cUz/u/bxj1+rxi/CD+dq8s/NPZZ3yXjzGBdwiLM48AMe0ti0sQ+uwDSsxxTcn9DBXYhV2a1GT2Nx4AfjaOxWo6exOPCDcTQ2jaUBP+Cw5vK3mnwa8AMOxqTxaZDG4sAP5bJbbZuLQ3mscTQWBz4N8IBDnC2XxYM8iLP44M4VmIZ15wJ/Tu/CPXxKH3HWG4LO4oEGcRaHNZe/1dZcfpAHxqR1kbxqYvKAH+TQ2LTG09NYOV9p3uRy4KvxxoKc0JxsGisP+AGHNZe/1eTTgB9wMCbtu73KH9ypAtOw7lTYN53WG/tNj/7ix36R7U/DesyDcu+xwqr+6kkaHtLYn2ryjQs4xFkc+MFeaJDG4iCOAw78IL7VitHFV06Ls+I0wAMO4luNnsbiwA84xFk84OFczV7KdQEf0sbesQLTsO5Y3GVqnziFZD8SpfHp3jxpLA34QQ6NTbv3ePOfWkvcnqA8FgdxHPi0Y/unywlyaMak8WliaSwN+EEOjU271XjzmivggztXYBrWnQv8Ob0flcKn9BHPfnz+irOf0gc/fCy/0thkfjiliZXH4sAPOMSzNIizOPADDvEsDeIsDvyAQzxLgziLAz/gIY39qSbfuIBDnMUHd67ANKxbFPgfc3jh9u8u+ZO3iL/mEeTQXeJuNbE0Vh7wgxya+beaWBorD/hBDm0dby80sfJYGojjIId2bDxdTsDh0vGtdWy8WOuw1gF+kENb95rGlsfKA36QQ1vH2wtNrDyWFsTyx96wAtOwbljMmWoq8FkBDUyT+6RjblWBaVi3quQ/5nFfEv6hjvdOFfC3GrwG3unMDznrNKzbltmLtMtdfrN7AYd0eWn9aSyWxp4ab4wc4Mu9dLy9nBovbh1oLRYHceMBDziIpxlHY9PEaZDGOg9NHAfjaGJ4oEGclUMzBgc+TQwPNIizcmjG4P/y8fFhLzSxNJa2Qnzl49+gAtOwblDEmWIqMBV4TAWmYV1XZ5er0Cz+JHZ/Afx0PJQvvtXE0thHjbcXa321vrj9gBy5q8angZyAg3jaT8Yfy01jm5O1DvCDHNqx9cXKY+UBP8ihreP5NLHyWBqI4yCHNrhRBaZhXV5IL0Yvyj41aiY6rH+FAw/liaex6fxwShMrj8WBH3CIs3jAwV62Gj1NHA/pcfGtJpYmjof0uPhWE0sTx0N6nD2lickJOMRZHPgBD2n2stXE0sRxSBt7owo8uGHdaNfPM417ivW/fH6enc1OpgI7rMA0rMsfqmblAtaPAM1C669qsOn8IIeu0W01epq5cUhjG8/iwJcHOPxkvHxjgR9wOLZXa5bHygN+wOHS8db4ajy9dVgc+KHx7Fbb5uJQHmscjcUDDeIsDny1B+NogxtVYBrWjQr5OY0XqAYG/E/5Aw8fn7/ibLksDvzP1A88fHz+Ek/jk9mtRk9jcTiWu2prLj/I+Wo8vTwWB364x3hzWwf4AQdrpvG3mhgN+AEHY9L4W02MBuIBH9ywAtOwLi+mOwovVOCbicUDDeKsHBqLA58GeMAhzpbL4sCXB3jAIc6Wy+JBHsRZHNZc/lZbc/kA8sAYHPhbjU4DfjiWm7bNxaGxbLksDnx5gAcc4my5LB7kQZzFYc3lb7U1V2zwgwpMw/pBsTapLtvD+sJMc/nakDT2mHZs/Fe5jTdGDvDpLB5oEGdxWHPb66qtufwg56vx9PJYHPjhHuPNbR3gBxysmcbfamI04AccjEm7Za3MPfhBBaZh/aBYkzoVmAr8bgWmYV1efxfJ27uKOCve7HhIE99qYmksDvyAQ5zFAx7O1ezlVK74qTnFHz3eeu2JxYEfcIizeMDDudotz9qaL28fdYBpWJdX2l2ET4KAbyYvfhzS6HiQQxNP49PE0lga8IMcGpt27/HmP7WWuD1BeSwO4jjwacf2T5cT5NCMSePTxNJYGvCDHBqbdu/x5j+1lrg9DS6owDSsC4r2OcS9xopP+eMSzZiPz1/88Cl9xNmP5RcekuPsKU1MTsAhzuLAX0GDSzRjjAV+wCHO4sBfQYNztZ/kHpvz0vHmMhb4AR9cUIFpWOcVzZ+K/qt2thEuYWnghUgXx0GcBngoV3yriaWxxgI/yKGta6Wx5bHygB/k0Nbx9kITK4+lgTgOcmjHxtPlBBwuHd9ax8aLtQ5rHeAHObR1r2lseaw84Ac5tHW8vdDEymNpII6DHNqx8fTBDyswDeu8gvlkyIuPPW/ED7Im9a0r4B98fOsC/OTw07DOq5aLVvcg7HkjJmsqcF4Fjv3TNOeNfMOsaVjnPXTf0rtIZRuBe7FBOouDeLl40Pjo4mnG0cTSWBrwgxyaMWl8mlgaSwN+kEMzJs1eaGJpLA3EcTCOxuIgTgM84CCeZhyNTROnQRprPzRxHIyjieGBBnFWDs0YHPg0MTzQIM7KoRmDg73QxPBAA/E042hsmjhtcEEFpmFdUFjYef8AAA5ZSURBVLQZMhW4uAIz8KoKTMP65/K5q4I14gI1FPOn5ilNvDnKY0+NF5MTHjW+vX61vnh7kmNfq8anQXksDuI4/GT8sdw01nzBOhBn5dCOrS8mJ8iDOCuHto7n08TkBBqIp8nZauI0KI/FB99UYBrWPxfIiwd6scngBxxcwKexNOCDOA54wEE8jaUBP+AQZ/GAh59q8htrLziksTiI44EGcXEc0lgcxPFAg7g4DmksDuJ4oEGcxYEf8JDG/lSTbxzYCw54wEE8jaUBH8RxwAM++KYC07C+KdCEpwJTgeepwGs1rMfUrctRl6qt6N84Cuk+MdxqYmnsT8fLNw7Wy1k8WEMeu9XoaSwO/GAcjU1jacAPOFx6VmPh0vH2+NV4evtkceCD+jWepQFfHuABhzhbLosHeRBncfjJWe0xGDv4pgLTsL4p0GfYvUP4lD7irBf0x+EXiwP/IP35wsMf4fBbnC2XxYF/SPvzhYc/wuE38TT+Qfpg09iPz1/88Cl9rLn8j8Mvtjz2IP354gc5RHar0dNYHPjBOBq71ehpLA78YByNTWNpwAdxHPg04G81Og34AQdj0vhbTYwG/HAsN00uP+CDbyowDeuvBXKf0IuNLcoPcujsVqOnsTjwg3E0dqvR01gc+ME4GrvV6GksDvxgHI1NY2nADzisufytJp8G/ICDMWl8GqSxOPBDuexW2+biUB5rHI3FgU8DPOAgnsbfamI04AccjEnjbzUxGvADDsYEfLBUYBrWUoyD64Xir1bAejmKh0Pan684axzRiw+HS8abx9hgToizOKy5/K225vKDPDAmrb2umpg84Ac5NDat8fQ0Fgd+MI72Ta0+5MsDfmg8m8bKA37AYc3lbzX5NOAHHIxJ66yrJiYP+EEOjU1rPD2NxUGuD30AHywVmIa1FONM17fwZ6ZO2gtWQMN4wW2/x5anYf31OWtGweVp0TQ2TRyHNH4QP6aniZebxqaxOPADDnEWD3g4V7OXU7nip+YUf/R467UnFgd+wCHO4gEP52r3Pqv9uMSH9jT2swLTsD4L8Wm8WPrUxo8sn/Kf/z15epp4Gp9+zng5co05NV5MHvBD49k0c8lbNTEa8IMcGpv23XjxU7ni5oTyWBzEceDTjq1PlxPk0IxJ49PE0lga8IMcGpt27/HmP7WWuD1BeSwO9ioH8HfA2WechvXPpfIjQSgaZ49pX+nHco9pl4w3j3EBhziLAz/gIY09V/tJ7rE5f2O8fVg34BBnceAHPKSx52o/yT025zq++Fvbd29Y/hSDXgR8l52wXoTioVzxtF5Y54wv99h4seZkW4sf5NDXtdLY8lh5wA9yaOt4e6GJlcfSQBwHObRj4+lyAg6Xjm+tY+PFWoe1DvCDHNq61zS2PFYeHFtrHS8u79rx5rBuwMH8adagDT4r8M4Ny4vBi2P91OazLGOmAlOBZ6zAOzcsz8PFJvBfFbPv21bAH2S3nXFmu1kF3rlhdbnJVlDf+vdXc9aL0DS2XHEcjKOzOIjTAA+tJ55mnDyxNJYG/CCHZkwanyaWxtKAH+TQjEmzF5pYGksDcRyMo7E4iNMADziIpxlHY9PEaZDG2g9NHAfjaGJ4oEGclUMzBgc+TQwPNIizcmjG4GAvNDE80EA8zTgamyZOgzQWB3EcjKMNPivwzg3rswRjpgJTgVepwDs3LH96bS83V43fcyyPTRPHoR8jVo1frpxwLDeNLY991Pj2+tX64vYDcuxr1fg0kBNwEE/7yfhjuWlsc7LWAX6QQzu2vlh5rDw4lrtqfHnXjjeHdQMO5k+zBg3o/3lwVu1A3+vrnRuWy3YPP/Tk4+JbTSxNHA/pcfGtJpYmjof0OHtKE5MTcIizOPADHtLsZauJpYnjIT0uvtXE0sTxkB4X32piaeJ4SI+zpzQxOQGHOIvDuhYe5ID4VqOnieMhPS6+1cTSxPGQ7gMioKe9nX3nhvV2D3sOfFYF3rohnFWhX0x654blr1j4hBBcoHoMLB5oEGdxuHS8Nb4aT7dGwCHONp7FgS8P8IBDnC2XxYM8iLM4XHpWY+HS8fb41Xi6PQYc4i6vG8+m8+VBGosDP5TLprHygB9wuPSsxsKx8XSX8OBHQ/wt8VYNa/OEPfjgBSnMbjV6GosDPxhHY7caPY3FgR+Mo7FpLA34AYc1l7/V5NOAH3AwJo2/1cRowA/HctO2uTg0li2XxYEvD/CAQ5wtl8WDPIiL48Df6qsmJg/4AYc1l7/V5NOAH47lpm1zcWgsu+aKvT3epWH5Nj/00HEvCuDTWTzQIM7KobE48LcanQb8cCw3bZuLQ2PZclkc+PIADzjE2XJZPMiDOIvDmsvfamsuP8gDY9L4W02MBvxwLDdtm4tDY9lyWRz48gAPOIin8beaGA34AQdj0vhbTYwG/ICDMWn8rSZGe0u8S8PqUxe2B+0CM6wvjDSXn8dyj2n3GG8fx9ZKs6Yc4NNZPNAgzuKw5nbWVVtz+UHOV+Pp5bE48MM9xpvbOsAPOFgzjb/VxGjgNYIDDsbg8Ey1sre3wrs0rLd6qHPYj4+PKcIuK/AuDauLUbYH6XLTHQGk8YP4MT1NvNy0OCt+TE8TlwdpLB5wiLM48AMe0thzNXs5lStuPjiWJ35MTxM3FtL4QfyYniZebhqbxuLADzjEWTzg4VzNXk7lip+aU/xW45vnbey7NCw/94ceLu6TJODTvdBwSKPjQQ5NPI1PE9tq9DRWDs0YHPg0MTzQIM7KobE43Hu8+a0D/K/Wp8sJOBiTxqcd2z+9PFYOzRgc+DQxPNAgzsqhsTjce7z5rQP8r9anywk4GJPGpx3bP/3t8C4N6+0e7Bx4KrDHChxvWPs7qYtU6E8sJ8T/7+BAOouD+CH85wsPLmCJ4mnG0dg0cRqksafGi8kJxkKclUNb1+LTxOQEGsRZOTRjcGivYniQB+JpxtHYNHEapLE4iONgHI3FQZwGeLAfmniacTSxNJYG/CCHZkwanyaWxtKAH+TQjEmzF5pYGksDcRyMo7E4iNMADziIpxlHY9PEaW+Jd2lYXlzQJzxv+bDn0FOBV6/AuzQsdwCwXni++rOb/U8F3q4C79KwtpeYHjTNX3UA33LTWBzEaYAHjY8mnmYcjU0Tp0Eae2q8mJxgLMRZObR1LT5NTE6gQZyVQzPmXz4+PmjtVQwP8kA8zTgamyZOgzQWB3EcjKOxOIjTAA/2QxNPM44mlsbSgB/k0IxJ49PE0lga8IMcmjFp9kITS2NpII6DcTQWB3Ea4AEH8TTjaGyaOO0t8S4N6y0f7hx6KrC3Crxzw/KnlgtMcL/l2a4anwZywrHcNLa8a8ebx9rAD9agmX+riaWx8oAf5NDW8XyaWHksDcRxkLPVxGkgJ+AgnvaT8cdy09jmZK0D/CCHdmx9sfJYecAPcmjreD5NrDyWBuI4yNlq4jSQE3AQT2s8nQ/8t8U7NywX8F4AsL4AcBBPx0OaeBpLZ4M4DdJYHMTxQIM4iwM/4CGN/akm3ziwFxzwgIN4GksDPojjgAccxNNYGvBBHAc84CCextKAH3CIs3jAw081+Y21FxzSWBzE8UCDuDgOaSwO4niggb8WpJHR8Uvw8mPeuWG9/MObA7xVBXxg5N7srQ69Pew7Nyx/TccFJvRCYOmhesVZOXQvIBzSWDzIgzgrh3ZsPF1OwCHONp7FgS8PcHAuHPBQLpvGygN+wOHYXh8x3hpfrU9vnywO/NB4dqttc3EojzWOxuKBBnEWh3vUyrx+VPRM7QV/S7xzw/LgQw8f98IAfjoejmnlsuXxj+Ue08plG88ey01bc/l01jjg0wAPOIin8beaGA344Vhu2jYXh8ay5bI48OUBHnCIs+WyeJAHcRaHNZe/1dZcfpAHxqTxt5oYDfjhWG7aNheHxrJrrtjb450b1rGH735gRTlePEGczp7SxOQBPxhHY7caPY3FgR+Mo7FbjZ7G4sAPxtHYNJYG/IDDmsvfavJpwA84GJPG32piNOCHY7lp21wcGsuWy+LAlwd4wCHOlsviQR7EWRzWXP5WW3P5QR4YE/DBUoFpWEsxDq4XiotNcMl5kP588cMf4fBbnDXuIH2wOLg8/fj8hYdP6SPOGkdn8UCDOIvDmsvfamsuP8gDY9La66qJyQN+kENj0xpPT2Nx4AfjaGzatePNY07gBxzWtfhbTT4N+AEHY9La66qJyQN+kENj0xpPT2NxkOv1B3za4LMC07A+CzFmKjAVeP4KTMP66zNyZxBcnhZNY9PEcUjjB/Fjepp4uWlsGosDP+AQZ/GAh3M1ezmVK35qTvFHj7dee2Jx4Acc4iwe8PAX7UDoB/Pnix/ufVbruMQH/p8NzG9/q8A0rL/Vod+9QHwSA+4W0vEghy6exqeJbTV6GiuHZgwOfJoYHmgQZ+XQWBzuPd781gH+V+vT5QQcjEnj047tn14eK4dmDA58mhgeaBBn5dBYHO493vzWAf5X69PlBBzs1TjAB0sFpmEtxRh3KjAVeO4KTMN67ufzTLv7yQXwT3Kf6YyzlyevwF0a1pOfebZ3ugKajX8sbpvlkyyxVceP/ejiE641b/ypwE0qMA3rJmXc1STuUPxTJttDuWsRW3X8WMM6Nn4dN/5U4KIKTMO6qGwzaCowFfiNCkzD+o2q72nNOctU4IEVmIb1wGLPUlOBqcB1FZiGdV39ZvRUYCrwwApMw3pgsWepqcBrV+D3dz8N6/efwexgKjAVOLMC07DOLNSkTQWmAr9fgWlYv/8MZgdTganAmRWYhnVmoa5PmxmmAlOBayswDevaCs74qcBU4GEVmIb1sFLPQlOBqcC1FZiGdW0FZ/xU4J8rMMqdKjAN606FnWmnAlOB21dgGtbtazozTgWmAneqwDSsOxV2pp0KTAVuX4H/DwAA//9sB2hHAAAABklEQVQDAB9QlitZA9bLAAAAAElFTkSuQmCC",width:"248",height:"248",style:{mixBlendMode:"multiply"}})))}var i0="ai",s0="ai-wp-admin",Vs="ai/ai",a0="https://wordpress.org/plugins/ai/",Ys=Object.values(Fs()),c0=Ys.some(e=>e.type==="ai_provider"),af=[];for(let e of Ys)e.type==="ai_provider"&&e.authentication.method==="api_key"&&af.push(e.authentication.settingName);function cf(){let[e,t]=(0,wt.useState)(!1),[o,n]=(0,wt.useState)(!1),r=(0,wt.useRef)(null);(0,wt.useEffect)(()=>{o&&r.current?.focus()},[o]);let i=(0,wt.useRef)(Ys.some(S=>S.type==="ai_provider"&&S.authentication.method==="api_key"&&S.authentication.isConnected)).current,{pluginStatus:s,canInstallPlugins:a,canManagePlugins:d,hasConnectedProvider:c}=(0,ln.useSelect)(S=>{let x=S(Ws.store),E=!!x.canUser("create",{kind:"root",name:"plugin"}),T=x.getEntityRecord("root","site"),k=i||af.some(A=>!!T?.[A]),C=x.getEntityRecord("root","plugin",Vs);return x.hasFinishedResolution("getEntityRecord",["root","plugin",Vs])?C?{pluginStatus:C.status==="active"?"active":"inactive",canInstallPlugins:E,canManagePlugins:!0,hasConnectedProvider:k}:{pluginStatus:"not-installed",canInstallPlugins:E,canManagePlugins:E,hasConnectedProvider:k}:{pluginStatus:"checking",canInstallPlugins:E,canManagePlugins:void 0,hasConnectedProvider:k}},[]),{saveEntityRecord:l}=(0,ln.useDispatch)(Ws.store),{createSuccessNotice:f,createErrorNotice:p}=(0,ln.useDispatch)(rf.store),m=async()=>{t(!0);try{await l("root","plugin",{slug:i0,status:"active"},{throwOnError:!0}),n(!0),f((0,Xe.__)("AI plugin installed and activated successfully."),{id:"ai-plugin-install-success",type:"snackbar"})}catch{p((0,Xe.__)("Failed to install the AI plugin."),{id:"ai-plugin-install-error",type:"snackbar"})}finally{t(!1)}},u=async()=>{t(!0);try{await l("root","plugin",{plugin:Vs,status:"active"},{throwOnError:!0}),n(!0),f((0,Xe.__)("AI plugin activated successfully."),{id:"ai-plugin-activate-success",type:"snackbar"})}catch{p((0,Xe.__)("Failed to activate the AI plugin."),{id:"ai-plugin-activate-error",type:"snackbar"})}finally{t(!1)}};if(!c0||s==="checking"||s==="active"&&i&&!o||s==="inactive"&&d===!1)return null;let g=s==="active"&&!c,v=s==="active"&&c&&(!i||o),_=s==="not-installed"||s==="inactive",w=s==="not-installed"&&a===!1,y=()=>v?(0,Xe.__)("The AI plugin is ready to use. You can use it to generate featured images, alt text, titles, excerpts and more. Learn more"):g?(0,Xe.__)("The AI plugin is installed. Connect an AI provider below to generate featured images, alt text, titles, excerpts, and more. Learn more"):(0,Xe.__)("The AI plugin can use your AI connectors to generate featured images, alt text, titles, excerpts and more. Learn more"),b=()=>s==="not-installed"?{label:e?(0,Xe.__)("Installing\u2026"):(0,Xe.__)("Install the AI plugin"),disabled:e,onClick:e?void 0:m}:{label:e?(0,Xe.__)("Activating\u2026"):(0,Xe.__)("Activate the AI plugin"),disabled:e,onClick:e?void 0:u};return React.createElement("div",{className:"ai-plugin-callout"},React.createElement("div",{className:"ai-plugin-callout__content"},React.createElement("p",null,(0,wt.createInterpolateElement)(y(),{strong:React.createElement("strong",null),a:React.createElement(cn.ExternalLink,{href:a0})})),!w&&(_?React.createElement(cn.Button,{variant:"primary",size:"compact",isBusy:e,disabled:b().disabled,accessibleWhenDisabled:!0,onClick:b().onClick},b().label):React.createElement(cn.Button,{ref:r,variant:"secondary",size:"compact",href:(0,sf.addQueryArgs)("options-general.php",{page:s0})},(0,Xe.__)("Control features in the AI plugin")))),React.createElement(nf,null))}var{store:d0}=No(l0);of();function u0(){let e=$u(),{connectors:t,canInstallPlugins:o,isAiPluginInstalled:n}=(0,lf.useSelect)(c=>{let l=c(uf.store),f=l.getEntityRecord("root","plugin","ai/ai");return{connectors:No(c(d0)).getConnectors(),canInstallPlugins:l.canUser("create",{kind:"root",name:"plugin"}),isAiPluginInstalled:!!f}},[]),r=t.filter(c=>c.render),i=Array.from(new Set(t.filter(c=>c.type==="ai_provider").map(c=>c.plugin?.file?.split("/")[0]).filter(c=>!!c))).sort(),s=new Set(t.filter(c=>c.plugin?.isInstalled).map(c=>c.plugin?.file?.split("/")[0]).filter(c=>!!c));n&&s.add("ai");let a=["ai",...i].filter(c=>!s.has(c)),d=r.length===0;return React.createElement(Bs,{title:(0,Tt.__)("Connectors"),subTitle:(0,Tt.__)("All of your API keys and credentials are stored here and shared across plugins. Configure once and use everywhere.")},React.createElement("div",{className:`connectors-page${d?" connectors-page--empty":""}`},a.length>0&&(e||!o)&&React.createElement(tn.Root,{intent:"info",className:"connectors-page__file-mods-notice"},React.createElement(tn.Description,null,e?(0,Tt.__)("Plugins cannot be installed here due to your site configuration. Install them manually using your normal deployment workflow."):(0,Tt.__)("You do not have permission to install plugins. Please ask a site administrator to install them for you."))),d?React.createElement(dt.__experimentalVStack,{alignment:"center",spacing:3,style:{maxWidth:480}},React.createElement(dt.__experimentalVStack,{alignment:"center",spacing:2},React.createElement(dt.__experimentalHeading,{level:2,size:15},(0,Tt.__)("No connectors yet")),React.createElement(dt.__experimentalText,{size:12},(0,Tt.__)("Connectors appear here when you install plugins that use external services. Each plugin registers the API keys it needs, and you manage them all in one place."))),React.createElement(dt.Button,{variant:"secondary",href:"plugin-install.php",__next40pxDefaultSize:!0},(0,Tt.__)("Learn more"))):React.createElement(dt.__experimentalVStack,{spacing:3},React.createElement(cf,null),React.createElement(dt.__experimentalVStack,{spacing:3,role:"list"},t.map(c=>c.render?React.createElement(c.render,{key:c.slug,slug:c.slug,name:c.name,description:c.description,type:c.type,logo:c.logo,authentication:c.authentication,plugin:c.plugin}):null))),o&&!e&&React.createElement("p",null,(0,df.createInterpolateElement)((0,Tt.__)("If the connector you need is not listed, search the plugin directory to see if a connector is available."),{a:React.createElement("a",{href:"plugin-install.php?s=connector&tab=search&type=tag"})}))))}function f0(){return React.createElement(u0,null)}var p0=f0;export{p0 as stage}; /*! Bundled license information: use-sync-external-store/cjs/use-sync-external-store-shim.production.js: diff --git a/src/wp-includes/capabilities.php b/src/wp-includes/capabilities.php index 028e61ec414a8..645ab5588ee5e 100644 --- a/src/wp-includes/capabilities.php +++ b/src/wp-includes/capabilities.php @@ -1163,11 +1163,7 @@ function remove_role( $role ) { function get_super_admins() { global $super_admins; - if ( isset( $super_admins ) ) { - return $super_admins; - } else { - return get_site_option( 'site_admins', array( 'admin' ) ); - } + return $super_admins ?? get_site_option( 'site_admins', array( 'admin' ) ); } /** diff --git a/src/wp-includes/category-template.php b/src/wp-includes/category-template.php index cd8304f24fdc0..76409d0832f2e 100644 --- a/src/wp-includes/category-template.php +++ b/src/wp-includes/category-template.php @@ -1230,7 +1230,7 @@ function get_the_tag_list( $before = '', $sep = '', $after = '', $post_id = 0 ) */ function the_tags( $before = null, $sep = ', ', $after = '' ) { if ( null === $before ) { - $before = __( 'Tags: ' ); + $before = __( 'Tags:' ) . ' '; } $the_tags = get_the_tag_list( $before, $sep, $after ); diff --git a/src/wp-includes/certificates/ca-bundle.crt b/src/wp-includes/certificates/ca-bundle.crt index 3e158b864a6e4..26ec7f7baabf9 100644 --- a/src/wp-includes/certificates/ca-bundle.crt +++ b/src/wp-includes/certificates/ca-bundle.crt @@ -1,7 +1,7 @@ ## ## Bundle of CA Root Certificates ## -## Certificate data from Mozilla as of: Thu May 14 03:12:02 2026 GMT +## Certificate data from Mozilla as of: Thu Jul 16 03:12:01 2026 GMT ## ## Find updated versions here: https://curl.se/docs/caextract.html ## @@ -13,39 +13,13 @@ ## It contains the certificates in PEM format and therefore ## can be directly used with curl / libcurl / php_curl, or with ## an Apache+mod_ssl webserver for SSL client authentication. -## Just configure this file as the SSLCACertificateFile. +## Configure this file as the SSLCACertificateFile. ## ## Conversion done with mk-ca-bundle.pl version 1.33. -## SHA256: 77130ef91213772844561fbd3aa31d413b25c2ac7f576fea3bc3bbff7ef93489 +## SHA256: e57912808daef7b2b0fa4df2ccf17e47aeaf26c839a38f85c76003ebafd866bd ## -Entrust Root Certification Authority -==================================== ------BEGIN CERTIFICATE----- -MIIEkTCCA3mgAwIBAgIERWtQVDANBgkqhkiG9w0BAQUFADCBsDELMAkGA1UEBhMCVVMxFjAUBgNV -BAoTDUVudHJ1c3QsIEluYy4xOTA3BgNVBAsTMHd3dy5lbnRydXN0Lm5ldC9DUFMgaXMgaW5jb3Jw -b3JhdGVkIGJ5IHJlZmVyZW5jZTEfMB0GA1UECxMWKGMpIDIwMDYgRW50cnVzdCwgSW5jLjEtMCsG -A1UEAxMkRW50cnVzdCBSb290IENlcnRpZmljYXRpb24gQXV0aG9yaXR5MB4XDTA2MTEyNzIwMjM0 -MloXDTI2MTEyNzIwNTM0MlowgbAxCzAJBgNVBAYTAlVTMRYwFAYDVQQKEw1FbnRydXN0LCBJbmMu -MTkwNwYDVQQLEzB3d3cuZW50cnVzdC5uZXQvQ1BTIGlzIGluY29ycG9yYXRlZCBieSByZWZlcmVu -Y2UxHzAdBgNVBAsTFihjKSAyMDA2IEVudHJ1c3QsIEluYy4xLTArBgNVBAMTJEVudHJ1c3QgUm9v -dCBDZXJ0aWZpY2F0aW9uIEF1dGhvcml0eTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEB -ALaVtkNC+sZtKm9I35RMOVcF7sN5EUFoNu3s/poBj6E4KPz3EEZmLk0eGrEaTsbRwJWIsMn/MYsz -A9u3g3s+IIRe7bJWKKf44LlAcTfFy0cOlypowCKVYhXbR9n10Cv/gkvJrT7eTNuQgFA/CYqEAOww -Cj0Yzfv9KlmaI5UXLEWeH25DeW0MXJj+SKfFI0dcXv1u5x609mhF0YaDW6KKjbHjKYD+JXGIrb68 -j6xSlkuqUY3kEzEZ6E5Nn9uss2rVvDlUccp6en+Q3X0dgNmBu1kmwhH+5pPi94DkZfs0Nw4pgHBN -rziGLp5/V6+eF67rHMsoIV+2HNjnogQi+dPa2MsCAwEAAaOBsDCBrTAOBgNVHQ8BAf8EBAMCAQYw -DwYDVR0TAQH/BAUwAwEB/zArBgNVHRAEJDAigA8yMDA2MTEyNzIwMjM0MlqBDzIwMjYxMTI3MjA1 -MzQyWjAfBgNVHSMEGDAWgBRokORnpKZTgMeGZqTx90tD+4S9bTAdBgNVHQ4EFgQUaJDkZ6SmU4DH -hmak8fdLQ/uEvW0wHQYJKoZIhvZ9B0EABBAwDhsIVjcuMTo0LjADAgSQMA0GCSqGSIb3DQEBBQUA -A4IBAQCT1DCw1wMgKtD5Y+iRDAUgqV8ZyntyTtSx29CW+1RaGSwMCPeyvIWonX9tO1KzKtvn1ISM -Y/YPyyYBkVBs9F8U4pN0wBOeMDpQ47RgxRzwIkSNcUesyBrJ6ZuaAGAT/3B+XxFNSRuzFVJ7yVTa -v52Vr2ua2J7p8eRDjeIRRDq/r72DQnNSi6q7pynP9WQcCk3RvKqsnyrQ/39/2n3qse0wJcGE2jTS -W3iDVuycNsMm4hH2Z0kdkquM++v/eu6FSqdQgPCnXEqULl8FmTxSQeDNtGPPAUO6nIPcj2A781q0 -tHuu2guQOHXvgR1m0vdXcDazv/wor3ElhVsT/h5/WrQ8 ------END CERTIFICATE----- - COMODO ECC Certification Authority ================================== -----BEGIN CERTIFICATE----- @@ -2689,27 +2663,6 @@ It6IKTtM1/r+t+FHvpw+PoP7UV31aPcuIYXcv/Fa4nzXxeSDwWrruoBa3lwtcHb4yOWHh8qgnaHl IhInD0Q9HWzq1MKLL295q39QpsQZp6F6t5b5wR9iWqJDB0BeJsas7a5wFsWqynKKTbDPAYsDP27X -----END CERTIFICATE----- -SecureSign Root CA12 -==================== ------BEGIN CERTIFICATE----- -MIIDcjCCAlqgAwIBAgIUZvnHwa/swlG07VOX5uaCwysckBYwDQYJKoZIhvcNAQELBQAwUTELMAkG -A1UEBhMCSlAxIzAhBgNVBAoTGkN5YmVydHJ1c3QgSmFwYW4gQ28uLCBMdGQuMR0wGwYDVQQDExRT -ZWN1cmVTaWduIFJvb3QgQ0ExMjAeFw0yMDA0MDgwNTM2NDZaFw00MDA0MDgwNTM2NDZaMFExCzAJ -BgNVBAYTAkpQMSMwIQYDVQQKExpDeWJlcnRydXN0IEphcGFuIENvLiwgTHRkLjEdMBsGA1UEAxMU -U2VjdXJlU2lnbiBSb290IENBMTIwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQC6OcE3 -emhFKxS06+QT61d1I02PJC0W6K6OyX2kVzsqdiUzg2zqMoqUm048luT9Ub+ZyZN+v/mtp7JIKwcc -J/VMvHASd6SFVLX9kHrko+RRWAPNEHl57muTH2SOa2SroxPjcf59q5zdJ1M3s6oYwlkm7Fsf0uZl -fO+TvdhYXAvA42VvPMfKWeP+bl+sg779XSVOKik71gurFzJ4pOE+lEa+Ym6b3kaosRbnhW70CEBF -EaCeVESE99g2zvVQR9wsMJvuwPWW0v4JhscGWa5Pro4RmHvzC1KqYiaqId+OJTN5lxZJjfU+1Uef -NzFJM3IFTQy2VYzxV4+Kh9GtxRESOaCtAgMBAAGjQjBAMA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0P -AQH/BAQDAgEGMB0GA1UdDgQWBBRXNPN0zwRL1SXm8UC2LEzZLemgrTANBgkqhkiG9w0BAQsFAAOC -AQEAPrvbFxbS8hQBICw4g0utvsqFepq2m2um4fylOqyttCg6r9cBg0krY6LdmmQOmFxv3Y67ilQi -LUoT865AQ9tPkbeGGuwAtEGBpE/6aouIs3YIcipJQMPTw4WJmBClnW8Zt7vPemVV2zfrPIpyMpce -mik+rY3moxtt9XUa5rBouVui7mlHJzWhhpmA8zNL4WukJsPvdFlseqJkth5Ew1DgDzk9qTPxpfPS -vWKErI4cqc1avTc7bgoitPQV55FYxTpE05Uo2cBl6XLK0A+9H7MV2anjpEcJnuDLN/v9vZfVvhga -aaI5gdka9at/yOPiZwud9AzqVN/Ssq+xIvEg37xEHA== ------END CERTIFICATE----- - SecureSign Root CA14 ==================== -----BEGIN CERTIFICATE----- diff --git a/src/wp-includes/class-wp-block-metadata-registry.php b/src/wp-includes/class-wp-block-metadata-registry.php index b6d0416d5fc5e..bdce6ae17a94c 100644 --- a/src/wp-includes/class-wp-block-metadata-registry.php +++ b/src/wp-includes/class-wp-block-metadata-registry.php @@ -82,7 +82,7 @@ class WP_Block_Metadata_Registry { * @return bool True if the collection was registered successfully, false otherwise. */ public static function register_collection( $path, $manifest ) { - $path = rtrim( wp_normalize_path( $path ), '/' ); + $path = self::normalize_collection_path( $path ); $collection_roots = self::get_default_collection_roots(); @@ -112,7 +112,7 @@ public static function register_collection( $path, $manifest ) { $collection_roots = array_unique( array_map( static function ( $allowed_root ) { - return rtrim( wp_normalize_path( $allowed_root ), '/' ); + return self::normalize_collection_path( $allowed_root ); }, $collection_roots ) @@ -161,7 +161,7 @@ static function ( $allowed_root ) { * @return array|null The block metadata for the block, or null if not found. */ public static function get_metadata( $file_or_folder ) { - $file_or_folder = wp_normalize_path( $file_or_folder ); + $file_or_folder = self::normalize_collection_path( $file_or_folder ); $path = self::find_collection_path( $file_or_folder ); if ( ! $path ) { @@ -196,7 +196,7 @@ public static function get_metadata( $file_or_folder ) { * @return string[] List of block metadata file paths, or an empty array if the given `$path` is invalid. */ public static function get_collection_block_metadata_files( $path ) { - $path = rtrim( wp_normalize_path( $path ), '/' ); + $path = self::normalize_collection_path( $path ); if ( ! isset( self::$collections[ $path ] ) ) { _doing_it_wrong( @@ -223,6 +223,56 @@ static function ( $block_name ) use ( $path ) { ); } + /** + * Normalizes a collection path and collapses dot segments. + * + * @since 7.1.0 + * + * @param string $path File or directory path. + * @return string Normalized path without trailing slashes. + */ + private static function normalize_collection_path( $path ) { + $path = wp_normalize_path( $path ); + + $prefix = ''; + if ( preg_match( '#^([A-Za-z][A-Za-z0-9+.-]*://)(.*)$#', $path, $matches ) ) { + $prefix = $matches[1]; + $path = $matches[2]; + } elseif ( str_starts_with( $path, '//' ) ) { + $prefix = '//'; + $path = substr( $path, 2 ); + } + + $drive = ''; + if ( preg_match( '#^[A-Za-z]:#', $path, $matches ) ) { + $drive = $matches[0]; + $path = substr( $path, 2 ); + } + + $is_absolute = str_starts_with( $path, '/' ); + $parts = array(); + + foreach ( explode( '/', $path ) as $part ) { + if ( '' === $part || '.' === $part ) { + continue; + } + if ( '..' === $part ) { + if ( array() !== $parts && '..' !== end( $parts ) ) { + array_pop( $parts ); + continue; + } + if ( ! $is_absolute ) { + $parts[] = $part; + } + continue; + } + $parts[] = $part; + } + + $normalized = ( $is_absolute ? '/' : '' ) . implode( '/', $parts ); + return $prefix . rtrim( $drive . $normalized, '/' ); + } + /** * Finds the collection path for a given file or folder. * @@ -238,13 +288,13 @@ private static function find_collection_path( $file_or_folder ) { // Check the last matched collection first, since block registration usually happens in batches per plugin or theme. $path = rtrim( $file_or_folder, '/' ); - if ( self::$last_matched_collection && str_starts_with( $path, self::$last_matched_collection ) ) { + if ( self::$last_matched_collection && self::is_same_or_child_path( $path, self::$last_matched_collection ) ) { return self::$last_matched_collection; } $collection_paths = array_keys( self::$collections ); foreach ( $collection_paths as $collection_path ) { - if ( str_starts_with( $path, $collection_path ) ) { + if ( self::is_same_or_child_path( $path, $collection_path ) ) { self::$last_matched_collection = $collection_path; return $collection_path; } @@ -252,6 +302,19 @@ private static function find_collection_path( $file_or_folder ) { return null; } + /** + * Checks whether a path is equal to or inside another path. + * + * @since 7.1.0 + * + * @param string $path Normalized path. + * @param string $base_path Normalized base path. + * @return bool True if the path is the base path or a child path, false otherwise. + */ + private static function is_same_or_child_path( $path, $base_path ) { + return $path === $base_path || str_starts_with( $path, $base_path . '/' ); + } + /** * Checks if metadata exists for a given block name in a specific collection. * @@ -317,7 +380,7 @@ private static function is_valid_collection_path( $path, $collection_roots ) { } // If the path is a parent path of any of the roots, it is invalid. - if ( str_starts_with( $allowed_root, $path ) ) { + if ( self::is_same_or_child_path( $allowed_root, $path ) ) { return false; } } diff --git a/src/wp-includes/class-wp-block-parser.php b/src/wp-includes/class-wp-block-parser.php index 8c619a7b47f2c..ea66e3b51d38d 100644 --- a/src/wp-includes/class-wp-block-parser.php +++ b/src/wp-includes/class-wp-block-parser.php @@ -342,7 +342,7 @@ public function add_freeform( $length = null ) { * @param int|null $last_offset Last byte offset into document if continuing form earlier output. */ public function add_inner_block( WP_Block_Parser_Block $block, $token_start, $token_length, $last_offset = null ) { - $parent = $this->stack[ count( $this->stack ) - 1 ]; + $parent = $this->stack[ array_key_last( $this->stack ) ]; $parent->block->innerBlocks[] = (array) $block; $html = substr( $this->document, $parent->prev_offset, $token_start - $parent->prev_offset ); diff --git a/src/wp-includes/class-wp-customize-manager.php b/src/wp-includes/class-wp-customize-manager.php index c2198acf20f66..e298b04efcf90 100644 --- a/src/wp-includes/class-wp-customize-manager.php +++ b/src/wp-includes/class-wp-customize-manager.php @@ -4959,7 +4959,7 @@ public function customize_pane_settings() { /* translators: %d: Number of themes being displayed, which cannot currently consider singular vs. plural forms. */ 'announceThemeCount' => __( 'Displaying %d themes' ), /* translators: %s: Theme name. */ - 'announceThemeDetails' => __( 'Showing details for theme: %s' ), + 'announceThemeDetails' => __( 'Theme details: %s' ), ), ); diff --git a/src/wp-includes/class-wp-duotone.php b/src/wp-includes/class-wp-duotone.php index b75b01619fbee..0e12e7ca7f306 100644 --- a/src/wp-includes/class-wp-duotone.php +++ b/src/wp-includes/class-wp-duotone.php @@ -569,8 +569,8 @@ private static function get_slug_from_attribute( $duotone_attr ) { * * @since 6.3.0 * - * @param string $duotone_attr The duotone attribute from a block. * @param string|string[] $duotone_attr The duotone attribute from a block. + * @return bool True if the duotone preset present and valid. */ private static function is_preset( $duotone_attr ) { if ( ! is_string( $duotone_attr ) ) { diff --git a/src/wp-includes/class-wp-query.php b/src/wp-includes/class-wp-query.php index 244cf84e9b810..228691d26d12b 100644 --- a/src/wp-includes/class-wp-query.php +++ b/src/wp-includes/class-wp-query.php @@ -2430,11 +2430,13 @@ public function get_posts() { if ( '' !== $query_vars['author_name'] ) { if ( str_contains( $query_vars['author_name'], '/' ) ) { - $query_vars['author_name'] = explode( '/', $query_vars['author_name'] ); - if ( $query_vars['author_name'][ count( $query_vars['author_name'] ) - 1 ] ) { - $query_vars['author_name'] = $query_vars['author_name'][ count( $query_vars['author_name'] ) - 1 ]; // No trailing slash. + $author_name_parts = explode( '/', $query_vars['author_name'] ); + $last_part = array_last( $author_name_parts ); + + if ( $last_part ) { + $query_vars['author_name'] = $last_part; // No trailing slash. } else { - $query_vars['author_name'] = $query_vars['author_name'][ count( $query_vars['author_name'] ) - 2 ]; // There was a trailing slash. + $query_vars['author_name'] = $author_name_parts[ count( $author_name_parts ) - 2 ]; // There was a trailing slash. } } $query_vars['author_name'] = sanitize_title_for_query( $query_vars['author_name'] ); diff --git a/src/wp-includes/class-wp-theme-json.php b/src/wp-includes/class-wp-theme-json.php index 212c52023a621..7e2cb54731b1f 100644 --- a/src/wp-includes/class-wp-theme-json.php +++ b/src/wp-includes/class-wp-theme-json.php @@ -2432,7 +2432,7 @@ protected function get_layout_styles( $block_metadata, $options = array() ) { * background: value; * } * - * p.has-value-gradient-background { + * :where(p).has-value-gradient-background { * background: value; * } * @@ -2613,6 +2613,7 @@ static function ( $carry, $element ) { * @since 5.8.0 * @since 5.9.0 Added the `$origins` parameter. * @since 6.6.0 Added check for root CSS properties selector. + * @since 7.1.0 Wraps block-level preset classes in `:where()` to match root-level specificity. * * @param array $settings Settings to process. * @param string $selector Selector wrapping the classes. @@ -2639,8 +2640,16 @@ protected static function compute_preset_classes( $settings, $selector, $origins $css_var = static::replace_slug_in_string( $preset_metadata['css_vars'], $slug ); $class_name = static::replace_slug_in_string( $class, $slug ); - // $selector is often empty, so we can save ourselves the `append_to_selector()` call then. - $new_selector = '' === $selector ? $class_name : static::append_to_selector( $selector, $class_name ); + /* + * $selector is often empty (root-level presets), in which case the + * bare class is used. For block-level presets the block selector is + * wrapped in `:where()` so the class keeps the same 0-1-0 specificity + * as a root-level preset. Without this, block-level palette rules + * (e.g. `p.has-x-color`) out-rank equally-important rules that also + * target the same property at 0-1-0, such as per-instance responsive + * state styles. + */ + $new_selector = '' === $selector ? $class_name : ':where(' . $selector . ')' . $class_name; $stylesheet .= static::to_ruleset( $new_selector, array( @@ -3985,7 +3994,7 @@ public function get_styles_for_block( $block_metadata ) { */ $is_processing_element = in_array( 'elements', $block_metadata['path'], true ); - $current_element = $is_processing_element ? $block_metadata['path'][ count( $block_metadata['path'] ) - 1 ] : null; + $current_element = $is_processing_element ? array_last( $block_metadata['path'] ) : null; $element_pseudo_allowed = array(); @@ -4679,7 +4688,7 @@ public static function remove_insecure_properties( $theme_json, $origin = 'theme * Get a reference to element name from path. * $metadata['path'] = array( 'styles', 'elements', 'link' ); */ - $current_element = $metadata['path'][ count( $metadata['path'] ) - 1 ]; + $current_element = array_last( $metadata['path'] ); /* * $output is stripped of pseudo selectors. Re-add and process them diff --git a/src/wp-includes/class-wp-token-map.php b/src/wp-includes/class-wp-token-map.php index fc223b187f8c5..108f28475241a 100644 --- a/src/wp-includes/class-wp-token-map.php +++ b/src/wp-includes/class-wp-token-map.php @@ -662,7 +662,7 @@ public function to_array(): array { } foreach ( $this->large_words as $index => $group ) { - $prefix = substr( $this->groups, $index * ( $this->key_length + 1 ), 2 ); + $prefix = substr( $this->groups, $index * ( $this->key_length + 1 ), $this->key_length ); $group_length = strlen( $group ); $at = 0; while ( $at < $group_length ) { diff --git a/src/wp-includes/class-wp-user.php b/src/wp-includes/class-wp-user.php index d921a83de7f1f..e6691d489c9fe 100644 --- a/src/wp-includes/class-wp-user.php +++ b/src/wp-includes/class-wp-user.php @@ -254,8 +254,27 @@ public static function get_data_by( $field, $value ) { } } + if ( 'email' === $field ) { + $users = $wpdb->get_results( + $wpdb->prepare( + "SELECT * FROM $wpdb->users WHERE user_email = %s", + $value + ) + ); + + foreach ( $users as $user ) { + if ( 0 === strcasecmp( $user->user_email, $value ) ) { + update_user_caches( $user ); + return $user; + } + } + + return false; + } + $user = $wpdb->get_row( $wpdb->prepare( + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- The column name is selected by the hardcoded switch above. "SELECT * FROM $wpdb->users WHERE $db_field = %s LIMIT 1", $value ) diff --git a/src/wp-includes/class-wp-view-config-data.php b/src/wp-includes/class-wp-view-config-data.php index 99ea816aee96e..be85e9dc10d60 100644 --- a/src/wp-includes/class-wp-view-config-data.php +++ b/src/wp-includes/class-wp-view-config-data.php @@ -40,7 +40,10 @@ * key by key (an associative array merges member by member, a nested `null` * deletes just that leaf, a scalar replaces just that value), while `set()` * swaps the whole value. A nested `null` deletes just the leaf it names in - * every case. Each patch also declares the configuration schema + * every case. A patch value whose shape does not match the current value — + * an associative array where a list lives, or the reverse — is rejected with + * a notice rather than merged, and an empty array under `merge()` is a + * no-op. Each patch also declares the configuration schema * version it was written against (currently 1), so a future WordPress release * that changes the configuration shape can migrate existing patches forward * instead of breaking them. @@ -118,9 +121,9 @@ private function get_data() { * Applies the entity view configuration filter and returns the result. * * Exposes the container through the dynamic - * `get_entity_view_config_{$kind}_{$name}` filter so that core and third - * parties can provide the configuration for a specific entity, then - * reconciles the filtered container back into a plain configuration array, + * `get_entity_view_config_{$kind}_{$name}` filter (with the dynamic portions + * lowercased), so that core and third parties can provide the configuration for a specific entity, + * then reconciles the filtered container back into a plain configuration array, * limited to the documented configuration keys. * * @since 7.1.0 @@ -134,7 +137,9 @@ public function apply_filters( $kind, $name ) { * Filters the view configuration for a given entity. * * The dynamic portions of the hook name, `$kind` and `$name`, refer to the - * entity kind (e.g. `postType`) and the entity name (e.g. `page`). + * entity kind (e.g. `postType`) and the entity name (e.g. `page`), + * lowercased — so the `postType`/`page` entity maps to the + * `get_entity_view_config_posttype_page` hook. * * Callbacks receive a WP_View_Config_Data object and change the * configuration through its methods. Each write method takes the schema @@ -159,10 +164,12 @@ public function apply_filters( $kind, $name ) { * individual list members. * * A change that declares an unsupported schema version is rejected and does - * not alter anything. Callbacks mutate the container in place, so there is no - * need to return it; any returned value is ignored. Callbacks must not replace - * the container with a different value, as later callbacks receive whatever the - * the previous one returned. + * not alter anything. As with any filter, each callback's return value is + * passed to the next callback as `$data`, so callbacks must return the + * container they received: a callback that returns nothing, or any other + * value, hands that result to every callback hooked at a later priority + * instead of the container. Since the write methods return the container, + * a callback can end with `return $data->merge( $patch, $version );`. * * @since 7.1.0 * @@ -178,7 +185,7 @@ public function apply_filters( $kind, $name ) { * } */ apply_filters( - "get_entity_view_config_{$kind}_{$name}", + wp_get_entity_view_config_hook_name( $kind, $name ), $this, array( 'kind' => $kind, @@ -306,6 +313,12 @@ public function remove( array $spec, int $version ) { * stops inheriting core's future additions to it — but it's useful when a * contributor needs to pin a list to an exact set of members. * + * The shape rule applies here too: a patch value whose shape does not match + * the current value — an associative array where a list lives, or a + * non-empty list where an associative value lives — is rejected with a + * notice and leaves the current value unchanged. An empty array is exempt, + * so replacing a list with an empty list still clears it. + * * A patch that declares an unsupported schema version is rejected and does * not change anything. * @@ -342,16 +355,23 @@ public function replace( array $patch, int $version ) { * * ```php * array( - * 'default_view' => array( 'search' => 'new search', 'fields' => array( 'newField' ) ), + * 'default_view' => array( 'titleField' => 'newTitleField', 'fields' => array( 'newField' ) ), * 'default_layouts' => array( 'grid' => array( 'layout' => array( 'badgeFields' => array( 'newField' ) ) ) ), * 'view_list' => array( array( 'slug' => 'table', 'title' => 'New title' ) ), * ) * ``` * - * - default_view will be updated so the search string is 'new search' and the newField is appended to the list of fields. + * - default_view will be updated so the titleField is 'newTitleField' and the newField is appended to the list of fields. * - default_layouts will be updated so that newField is appended to the badgeFields. * - view_list will be updated so that the view with slug 'table' has its title changed to 'New title'. * + * A patch value only merges into a current value of the same shape: an + * associative array where a list lives, or a non-empty list where an + * associative value lives, is rejected with a notice and leaves the current + * value unchanged. An empty array merges nothing and is a no-op — clear a + * list with replace() and an empty list, or reset a key to its default with + * a top-level `null`. + * * A patch that declares an unsupported schema version is rejected and does * not change anything. * @@ -475,6 +495,15 @@ private function strip_nulls( $value ) { * $replace_lists flag is carried down through associative nesting so that, * under replace(), every list reached along the way is swapped wholesale. * + * An array in $incoming only merges into a current value of the same shape. + * A non-empty mismatch — an associative array where a list lives, or a + * non-empty list where an associative value lives — is reported with + * _doing_it_wrong() and leaves the current value unchanged, so a malformed + * patch cannot silently destroy configuration. An empty array is + * shape-ambiguous and merges nothing, so it is a no-op: clearing a list is + * spelled replace() with an empty list, and resetting a key is spelled + * `null`. + * * @since 7.1.0 * * @param mixed $current The current value. @@ -491,6 +520,18 @@ private function merge_properties( $current, $incoming, $replace_lists ) { // Numerical indexed arrays are expected to be lists (sequential integer keys starting at 0). if ( array_is_list( $incoming ) ) { + // A non-empty list only lands where a list (or nothing) lives, under + // merge() and replace() alike. An empty array is shape-ambiguous and + // exempt, so replace() with an empty list can still clear a list. + if ( array() !== $incoming && is_array( $current ) && ! array_is_list( $current ) && array() !== $current ) { + _doing_it_wrong( + __METHOD__, + esc_html__( 'A view configuration patch value must match the shape of the value it patches: a list merges into a list, and an associative array into an associative array.' ), + '7.1.0' + ); + return $current; + } + // replace() takes an incoming list as-is; merge() merges it by member identity. if ( $replace_lists ) { // As-is except for nulls: a list swapped in wholesale has no @@ -498,6 +539,13 @@ private function merge_properties( $current, $incoming, $replace_lists ) { // set()), so a null member is dropped rather than stored. return $this->strip_nulls( $incoming ); } + + // An empty list has no members to merge, and an empty array is + // shape-ambiguous, so merging one is a no-op rather than a reset. + if ( array() === $incoming ) { + return $current; + } + return $this->merge_list_by_identity( is_array( $current ) && array_is_list( $current ) ? $current : array(), $incoming @@ -505,6 +553,15 @@ private function merge_properties( $current, $incoming, $replace_lists ) { } // Consider any other array as associative (keys are strings). + if ( is_array( $current ) && array_is_list( $current ) && array() !== $current ) { + _doing_it_wrong( + __METHOD__, + esc_html__( 'A view configuration patch value must match the shape of the value it patches: a list merges into a list, and an associative array into an associative array.' ), + '7.1.0' + ); + return $current; + } + $result = is_array( $current ) && ! array_is_list( $current ) ? $current : array(); foreach ( $incoming as $key => $value ) { // A null patch value deletes the property. @@ -601,7 +658,9 @@ private function remove_list_member( array $members, $identity ) { * A member of the incoming list whose identity matches one already present * merges into it in place, keeping its position; an unmatched member is * appended to the end, except a literal `null`, which carries no identity - * and holds nothing to merge and so is dropped. A matched member's contents + * and holds nothing to merge and so is dropped. An appended member has no + * existing leaf for a nested `null` to delete (the same rationale as set()), + * so its nulls are stripped rather than stored. A matched member's contents * merge recursively with the same rules (merge_properties), so the * identity-aware merge applies at * any nesting level: each key named by the patch is substituted while the @@ -637,7 +696,9 @@ private function merge_list_by_identity( array $current, array $incoming ) { } } if ( null === $index ) { - $result[] = $item; + // An appended member has no existing leaf for a nested null to + // delete, so nulls are dropped rather than stored. + $result[] = $this->strip_nulls( $item ); continue; } diff --git a/src/wp-includes/class-wp-xmlrpc-server.php b/src/wp-includes/class-wp-xmlrpc-server.php index b20c79b4c9201..1061dbd1831d2 100644 --- a/src/wp-includes/class-wp-xmlrpc-server.php +++ b/src/wp-includes/class-wp-xmlrpc-server.php @@ -191,9 +191,11 @@ private function set_is_enabled() { * Respect old get_option() filters left for back-compat when the 'enable_xmlrpc' * option was deprecated in 3.5.0. Use the {@see 'xmlrpc_enabled'} hook instead. */ - $is_enabled = apply_filters( 'pre_option_enable_xmlrpc', false ); + /** This filter is documented in wp-includes/option.php */ + $is_enabled = apply_filters( 'pre_option_enable_xmlrpc', false, 'enable_xmlrpc', false ); if ( false === $is_enabled ) { - $is_enabled = apply_filters( 'option_enable_xmlrpc', true ); + /** This filter is documented in wp-includes/option.php */ + $is_enabled = apply_filters( 'option_enable_xmlrpc', true, 'enable_xmlrpc' ); } /** @@ -6438,24 +6440,33 @@ public function mw_getCategories( $args ) { * @since 1.5.0 * * @param array $args { - * Method arguments. Note: arguments must be ordered as documented. + * Method arguments. Note: top-level arguments must be ordered as documented. * * @type int $0 Blog ID (unused). * @type string $1 Username. * @type string $2 Password. - * @type array $3 Data. + * @type array $3 { + * Data for the file to upload. + * + * @type string $name File name. Sanitized with sanitize_file_name(). + * @type string $type Optional. File MIME type, stored as the attachment's + * post MIME type. Default empty string. + * @type string $bits Optional. File contents. Default empty string. + * @type int $post_id Optional. ID of the post to attach the file to. + * Default 0. + * } * } * @return array|IXR_Error */ public function mw_newMediaObject( $args ) { + if ( ! $this->minimum_args( $args, 4 ) ) { + return $this->error; + } + $username = $this->escape( $args[1] ); $password = $this->escape( $args[2] ); $data = $args[3]; - $name = sanitize_file_name( $data['name'] ); - $type = $data['type']; - $bits = $data['bits']; - $user = $this->login( $username, $password ); if ( ! $user ) { return $this->error; @@ -6469,6 +6480,25 @@ public function mw_newMediaObject( $args ) { return $this->error; } + if ( + ! is_array( $data ) || + ! is_string( $data['name'] ?? null ) || + ! is_string( $data['type'] ?? '' ) || + ! is_string( $data['bits'] ?? '' ) + ) { + return new IXR_Error( 400, __( 'Invalid attachment data.' ) ); + } + + $name = sanitize_file_name( $data['name'] ); + + // A name consisting only of characters the sanitizer strips leaves nothing to write to. + if ( '' === $name ) { + return new IXR_Error( 400, __( 'Invalid attachment data.' ) ); + } + + $type = $data['type'] ?? ''; + $bits = $data['bits'] ?? ''; + if ( is_multisite() && upload_is_user_over_quota( false ) ) { $this->error = new IXR_Error( 401, diff --git a/src/wp-includes/comment.php b/src/wp-includes/comment.php index b3738c24ec9df..96a26fcf558a1 100644 --- a/src/wp-includes/comment.php +++ b/src/wp-includes/comment.php @@ -2556,6 +2556,193 @@ function wp_new_comment_via_rest_notify_postauthor( $comment ) { } } +/** + * Extracts the mentioned user IDs from note content. + * + * Mentions are stored as chips carrying the `wp-note-mention` class plus a + * `user-N` class token holding the mentioned user's ID: + * `@Name`. Only elements that + * carry both classes are treated as mentions. + * + * @since 7.1.0 + * + * @param string $content Note (comment) content, as stored. + * @return int[] Unique, positive mentioned user IDs. + * @phpstan-return list + */ +function wp_get_note_mentioned_user_ids( string $content ): array { + if ( ! str_contains( $content, 'wp-note-mention' ) ) { + return array(); + } + + $user_ids = array(); + $processor = new WP_HTML_Tag_Processor( $content ); + while ( + $processor->next_tag( + array( + 'tag_name' => 'SPAN', + 'class_name' => 'wp-note-mention', + ) + ) + ) { + foreach ( $processor->class_list() as $class_name ) { + if ( 1 === preg_match( '/^user-(\d+)$/', $class_name, $matches ) ) { + $user_id = (int) $matches[1]; + if ( $user_id > 0 ) { + $user_ids[] = $user_id; + } + break; + } + } + } + + return array_values( array_unique( $user_ids, SORT_NUMERIC ) ); +} + +/** + * Notifies mentioned users about a new note. + * + * Runs on {@see 'rest_insert_comment'} alongside the post author notification. + * The recipient set is the users mentioned in this note, minus the note's own + * author (a user is not notified about their own note) and the post author, + * who is already notified about every note by + * {@see wp_new_comment_via_rest_notify_postauthor()}. + * + * Only fires when a note is created, not when an existing one is edited, so + * correcting a note does not re-notify everyone who already received it. + * + * @since 7.1.0 + * + * @param WP_Comment|null $comment The note that was just inserted. (May only be null as an edge case.) + * @param mixed $request The REST request. Unused. + * @param bool $creating Whether this is a create (true) or update (false). + */ +function wp_notify_note_mentions( ?WP_Comment $comment, $request = null, bool $creating = true ): void { + if ( ! $creating || ! $comment ) { + return; + } + + if ( 'note' !== $comment->comment_type ) { + return; + } + + // Share the single user-facing notes notification preference. + if ( ! get_option( 'wp_notes_notify', 1 ) ) { + return; + } + + $mentioned = wp_get_note_mentioned_user_ids( $comment->comment_content ); + + $author_id = (int) $comment->user_id; + $comment_post_id = (int) $comment->comment_post_ID; + $post = $comment_post_id ? get_post( $comment_post_id ) : null; + $post_author_id = $post ? (int) $post->post_author : 0; + + /* + * The recipient set is bounded and small (one note's mentions), so emails + * are sent synchronously here. If notification volume ever warrants it, + * the right fix is to offload delivery to a background queue rather than + * throttle within the request. + */ + foreach ( $mentioned as $user_id ) { + // Never notify the author about their own note. + if ( $user_id === $author_id ) { + continue; + } + + // The post author is already notified of every note. + if ( $user_id === $post_author_id ) { + continue; + } + + $user = get_userdata( $user_id ); + if ( ! $user || empty( $user->user_email ) ) { + continue; + } + + /* + * Only notify users who can actually read the note. Notes are + * internal: WP_REST_Comments_Controller::check_read_permission() + * only exposes a note to its author or to users who can edit it, so + * the email audience is held to the same bar. A plain read_post + * check would leak note content to, for example, subscribers on a + * public post, who cannot see the note in the editor. + */ + if ( ! user_can( $user_id, 'edit_comment', $comment->comment_ID ) ) { + continue; + } + + wp_send_note_notification( $user, $comment, $post ); + } +} + +/** + * Sends a single note mention notification email. + * + * The email is composed in the recipient's locale, matching how other + * user-directed notifications are composed, and links to the post editor the + * same way the post author's note notification does. + * + * @since 7.1.0 + * + * @param WP_User $user The recipient. + * @param WP_Comment $comment The note that triggered the notification. + * @param WP_Post|null $post The post the note belongs to. + * @return bool Whether the email was accepted for delivery by {@see wp_mail()}. + */ +function wp_send_note_notification( WP_User $user, WP_Comment $comment, ?WP_Post $post ): bool { + $switched_locale = switch_to_user_locale( $user->ID ); + + /* + * The site title and the post title are escaped on the way into the database, + * and note content is stored as HTML. Both are reversed once here for the + * plain text arena of emails. Decoding a second time would go too far and + * resolve entities the author meant to be read literally. + */ + $blogname = wp_specialchars_decode( get_bloginfo( 'name', 'display' ), ENT_QUOTES ); + $post_title = $post ? wp_specialchars_decode( get_the_title( $post ), ENT_QUOTES ) : ''; + $author_name = $comment->comment_author ? $comment->comment_author : __( 'Someone' ); + $content = wp_specialchars_decode( wp_strip_all_tags( $comment->comment_content ) ); + + /* + * The rest of the message is composed for the recipient, and so is the editor + * link: get_edit_post_link() answers for whoever is current, which here is the + * note's author over REST and nobody at all under WP-Cron. + */ + $edit_link = ''; + if ( $post ) { + $previous_user_id = get_current_user_id(); + wp_set_current_user( $user->ID ); + $edit_link = (string) get_edit_post_link( $post->ID, 'url' ); + wp_set_current_user( $previous_user_id ); + } + + /* translators: 1: Note author's name, 2: Post title. */ + $message = sprintf( __( '%1$s mentioned you in a note on "%2$s".' ), $author_name, $post_title ); + /* translators: Note mention notification email subject. 1: Site title, 2: Post title. */ + $subject = sprintf( __( '[%1$s] You were mentioned in a note on "%2$s"' ), $blogname, $post_title ); + + $lines = array( $message, '' ); + if ( '' !== $content ) { + $lines[] = $content; + } + if ( $edit_link ) { + $lines[] = ''; + $lines[] = __( 'Edit This' ) . ': ' . $edit_link; + } + + // Declared explicitly so a filtered default cannot turn the message into HTML. + $headers = 'Content-Type: text/plain; charset="' . get_option( 'blog_charset' ) . '"'; + + $sent = wp_mail( $user->user_email, $subject, implode( "\n", $lines ), $headers ); + + if ( $switched_locale ) { + restore_previous_locale(); + } + + return $sent; +} + /** * Sets the status of a comment. * @@ -3149,7 +3336,7 @@ function do_trackbacks( $post ) { if ( empty( $post->post_excerpt ) ) { /** This filter is documented in wp-includes/post-template.php */ - $excerpt = apply_filters( 'the_content', $post->post_content, $post->ID ); + $excerpt = apply_filters( 'the_content', $post->post_content ); } else { /** This filter is documented in wp-includes/post-template.php */ $excerpt = apply_filters( 'the_excerpt', $post->post_excerpt ); diff --git a/src/wp-includes/cron.php b/src/wp-includes/cron.php index 88ca63f148e12..3fb6a29cb8dc7 100644 --- a/src/wp-includes/cron.php +++ b/src/wp-includes/cron.php @@ -958,6 +958,8 @@ function spawn_cron( $gmt_time = 0 ) { $doing_wp_cron = sprintf( '%.22F', $gmt_time ); set_transient( 'doing_cron', $doing_wp_cron ); + $cron_url = add_query_arg( 'doing_wp_cron', $doing_wp_cron, site_url( 'wp-cron.php' ) ); + /** * Filters the cron request arguments. * @@ -982,13 +984,13 @@ function spawn_cron( $gmt_time = 0 ) { $cron_request = apply_filters( 'cron_request', array( - 'url' => add_query_arg( 'doing_wp_cron', $doing_wp_cron, site_url( 'wp-cron.php' ) ), + 'url' => $cron_url, 'key' => $doing_wp_cron, 'args' => array( 'timeout' => 0.01, 'blocking' => false, /** This filter is documented in wp-includes/class-wp-http-streams.php */ - 'sslverify' => apply_filters( 'https_local_ssl_verify', false ), + 'sslverify' => apply_filters( 'https_local_ssl_verify', false, $cron_url ), ), ), $doing_wp_cron diff --git a/src/wp-includes/css/admin-bar.css b/src/wp-includes/css/admin-bar.css index 77e196525657a..f50c67dd71689 100644 --- a/src/wp-includes/css/admin-bar.css +++ b/src/wp-includes/css/admin-bar.css @@ -77,10 +77,18 @@ html:lang(he-il) .rtl #wpadminbar * { box-shadow: none; } -#wpadminbar a:focus { - outline-offset: -1px; +#wpadminbar a:focus, +#wpadminbar .ab-item[tabindex="0"]:focus { + outline-offset: -2px; /* Only visible in Windows High Contrast mode */ outline: 2px solid transparent; + box-shadow: inset 0 -4px 0 0 currentColor; + transition: box-shadow 0.1s linear; +} + +#wpadminbar .ab-submenu a:focus, +#wpadminbar .ab-submenu .ab-item[tabindex="0"]:focus { + box-shadow: inset 4px 0 0 0 currentColor; } #wpadminbar { @@ -225,7 +233,9 @@ html:lang(he-il) .rtl #wpadminbar * { #wpadminbar:not(.mobile) > #wp-toolbar li:hover span.ab-label, #wpadminbar > #wp-toolbar li.hover span.ab-label, -#wpadminbar:not(.mobile) > #wp-toolbar a:focus span.ab-label { +/* The adminbar menu may output either links or focusable div elements. */ +/* As such, we target the focus state without specifying the element type. */ +#wpadminbar > #wp-toolbar :focus span.ab-label { color: #72aee6; } @@ -294,7 +304,8 @@ html:lang(he-il) .rtl #wpadminbar * { #wpadminbar li.hover .ab-icon:before, #wpadminbar li.hover .ab-item:before, #wpadminbar li:hover #adminbarsearch:before, -#wpadminbar li #adminbarsearch.adminbar-focused:before { +#wpadminbar li #adminbarsearch.adminbar-focused:before, +#wpadminbar.mobile .quicklinks .ab-item:focus:before { color: #72aee6; } @@ -379,11 +390,6 @@ html:lang(he-il) .rtl #wpadminbar * { float: right; } -#wpadminbar ul li:last-child, -#wpadminbar ul li:last-child .ab-item { - box-shadow: none; -} - /** * Recovery Mode */ @@ -452,7 +458,7 @@ html:lang(he-il) .rtl #wpadminbar * { #wp-admin-bar-user-info .avatar { position: absolute; left: -72px; - top: 4px; + top: 0; width: 64px; height: 64px; border-radius: 50%; @@ -466,7 +472,7 @@ html:lang(he-il) .rtl #wpadminbar * { #wpadminbar #wp-admin-bar-user-info span { background: none; padding: 0; - height: 18px; + line-height: 1.38461538; } #wpadminbar #wp-admin-bar-user-info .display-name, @@ -475,7 +481,6 @@ html:lang(he-il) .rtl #wpadminbar * { } #wpadminbar #wp-admin-bar-user-info .username { - color: #a7aaad; font-size: 11px; } @@ -911,7 +916,6 @@ html:lang(he-il) .rtl #wpadminbar * { overflow: hidden; width: 52px; padding: 0; - color: #a7aaad; /* @todo not needed? this text is hidden */ position: relative; } @@ -1031,16 +1035,6 @@ html:lang(he-il) .rtl #wpadminbar * { height: auto; font-size: 16px; line-height: 1.5; - color: #f0f0f1; - } - - #wpadminbar #wp-admin-bar-user-info a { - padding-top: 4px; - } - - #wpadminbar #wp-admin-bar-user-info .username { - line-height: 0.8 !important; - margin-bottom: -2px; } /* Show only default top level items */ diff --git a/src/wp-includes/css/buttons.css b/src/wp-includes/css/buttons.css index 967970a9ba461..57d5754483199 100644 --- a/src/wp-includes/css/buttons.css +++ b/src/wp-includes/css/buttons.css @@ -223,6 +223,7 @@ TABLE OF CONTENTS: .wp-core-ui .button[aria-disabled="true"], .wp-core-ui .button-secondary[aria-disabled="true"] { + color: #8a8a8a !important; cursor: default; } diff --git a/src/wp-includes/css/media-views.css b/src/wp-includes/css/media-views.css index 835b04a8bf6bf..6748a50f00c57 100644 --- a/src/wp-includes/css/media-views.css +++ b/src/wp-includes/css/media-views.css @@ -136,8 +136,7 @@ .media-frame textarea:focus, .media-frame select:focus { border-color: var(--wp-admin-theme-color, #3858e9); - /* Expand border by 0.5px for total 1.5px effect */ - box-shadow: 0 0 0 0.5px var(--wp-admin-theme-color, #3858e9); + box-shadow: 0 0 0 var(--wp-admin-border-width-focus, 1.5px) var(--wp-admin-theme-color, #3858e9); outline: 2px solid transparent; } @@ -333,7 +332,6 @@ .media-toolbar-secondary { float: left; height: 100%; - position: relative; display: grid; grid-template-columns: repeat( 2, 1fr ); grid-template-rows: repeat( 2, 1fr ); @@ -351,16 +349,24 @@ select#media-attachment-filters { } label[for="media-attachment-date-filters"] { - grid-area: 1 / 2 / 2 / 3; + grid-area: 1 / 1 / 2 / 2; } select#media-attachment-date-filters { + grid-area: 2 / 1 / 3 / 2; +} + +select#media-attachment-filters ~ label[for="media-attachment-date-filters"] { + grid-area: 1 / 2 / 2 / 3; +} + +select#media-attachment-filters ~ select#media-attachment-date-filters { grid-area: 2 / 2 / 3 / 3; } -.media-toolbar-secondary > .spinner { +.media-modal .media-toolbar-secondary > .spinner { position: absolute; - left: calc( 100% + 2px ); + right: -30px; top: 50%; margin: 0; } @@ -1031,9 +1037,8 @@ select#media-attachment-date-filters { .wp-core-ui .attachment:focus, .wp-core-ui .selected.attachment:focus, .wp-core-ui .attachment.details:focus { - box-shadow: - inset 0 0 2px 3px #fff, - inset 0 0 0 7px var(--wp-admin-theme-color, #3858e9); + /* Keep this box-shadow consistent between media.css and media-views.css. */ + box-shadow: inset 0 0 0 var(--wp-admin-border-width-focus, 1.5px) var(--wp-admin-theme-color, #3858e9); /* Only visible in Windows High Contrast mode */ outline: 2px solid transparent; outline-offset: -6px; @@ -1303,6 +1308,7 @@ select#media-attachment-date-filters { .attachments-browser .media-toolbar-secondary { max-width: 66%; + position: relative; } .uploader-inline .close { @@ -1399,7 +1405,11 @@ select#media-attachment-date-filters { } .attachment.new-media { - outline: 2px dotted #c3c4c7; + /* Dotted outline for the first added item when Infinite scrolling is disabled. */ + /* Contrast ratio must be at least 3:1 against the #dcdcde background for added items when Infinite scrolling is disabled. */ + outline: 2px dotted #757575; + /* Prevent the border from being obscured by adjacent items. */ + outline-offset: -2px; } /** @@ -1845,6 +1855,7 @@ select#media-attachment-date-filters { text-align: right; text-transform: none; font-weight: 400; + display: flex; } .attachment-details .settings-save-status .spinner { @@ -2625,6 +2636,7 @@ select#media-attachment-date-filters { } .attachments-browser .attachments, + .attachments-browser:not(.has-load-more) .attachments, .attachments-browser .uploader-inline, .attachments-browser .media-toolbar, .attachments-browser .attachments-wrapper, @@ -2633,13 +2645,14 @@ select#media-attachment-date-filters { } .attachments-browser .media-toolbar { - height: 74px; + height: 117px; } .attachments-browser .attachments, + .attachments-browser:not(.has-load-more) .attachments, .attachments-browser .uploader-inline, .media-frame-content .attachments-browser .attachments-wrapper { - top: 90px; + top: 131px; } .media-sidebar .setting, @@ -2830,7 +2843,7 @@ select#media-attachment-date-filters { float: right; } - .media-frame .media-toolbar-secondary .spinner { + .media-modal .media-frame .media-toolbar-secondary .spinner { top: calc( 50% - 8px ); } @@ -2865,7 +2878,7 @@ select#media-attachment-date-filters { bottom: -60px; } - .media-frame .media-toolbar-secondary .spinner { + .media-modal .media-frame .media-toolbar-secondary .spinner { top: 0; } @@ -2888,13 +2901,9 @@ select#media-attachment-date-filters { position: unset; } - .media-frame .media-toolbar-secondary .spinner { - position: absolute; - top: 0; + .media-modal .media-frame .media-toolbar-secondary .spinner { bottom: 0; margin: auto; - left: calc( 100% + 2px ); - right: 0; z-index: 9; } @@ -2946,6 +2955,7 @@ select#media-attachment-date-filters { } .attachments-browser .attachments, + .attachments-browser:not(.has-load-more) .attachments, .attachments-browser .uploader-inline, .attachments-browser .media-toolbar, .media-frame-content .attachments-browser .attachments-wrapper { diff --git a/src/wp-includes/customize/class-wp-customize-selective-refresh.php b/src/wp-includes/customize/class-wp-customize-selective-refresh.php index f51b1e6dc7627..3077d09dcf75e 100644 --- a/src/wp-includes/customize/class-wp-customize-selective-refresh.php +++ b/src/wp-includes/customize/class-wp-customize-selective-refresh.php @@ -120,11 +120,7 @@ public function add_partial( $id, $args = array() ) { * @return WP_Customize_Partial|null The partial, if set. Otherwise null. */ public function get_partial( $id ) { - if ( isset( $this->partials[ $id ] ) ) { - return $this->partials[ $id ]; - } else { - return null; - } + return $this->partials[ $id ] ?? null; } /** diff --git a/src/wp-includes/default-filters.php b/src/wp-includes/default-filters.php index d9a05c829646a..12ca0045b98b4 100644 --- a/src/wp-includes/default-filters.php +++ b/src/wp-includes/default-filters.php @@ -310,6 +310,11 @@ add_action( 'check_comment_flood', 'check_comment_flood_db', 10, 4 ); add_filter( 'comment_flood_filter', 'wp_throttle_comment_flood', 10, 3 ); add_filter( 'pre_comment_content', 'wp_rel_ugc', 15 ); + +// Note mention chips in comment content: allow `span` through comment kses, +// then reduce its classes to the mention tokens right after `wp_filter_kses`. +add_filter( 'wp_kses_allowed_html', '_wp_kses_allow_note_mention_span', 10, 2 ); +add_filter( 'pre_comment_content', '_wp_kses_sanitize_note_mention_classes', 11 ); add_filter( 'comment_email', 'antispambot' ); add_filter( 'option_tag_base', '_wp_filter_taxonomy_base' ); add_filter( 'option_category_base', '_wp_filter_taxonomy_base' ); @@ -531,6 +536,7 @@ add_action( 'comment_post', 'wp_new_comment_notify_moderator' ); add_action( 'comment_post', 'wp_new_comment_notify_postauthor' ); add_action( 'rest_insert_comment', 'wp_new_comment_via_rest_notify_postauthor' ); +add_action( 'rest_insert_comment', 'wp_notify_note_mentions', 10, 3 ); add_action( 'after_password_reset', 'wp_password_change_notification' ); add_action( 'register_new_user', 'wp_send_new_user_notifications' ); add_action( 'edit_user_created_user', 'wp_send_new_user_notifications', 10, 2 ); @@ -585,6 +591,7 @@ add_action( 'init', 'create_initial_post_types', 0 ); // Highest priority. add_action( 'admin_menu', '_add_post_type_submenus' ); add_action( 'before_delete_post', '_reset_front_page_settings_for_post' ); +add_action( 'before_delete_post', '_reset_privacy_policy_page_for_post' ); add_action( 'wp_trash_post', '_reset_front_page_settings_for_post' ); add_action( 'change_locale', 'create_initial_post_types' ); @@ -821,8 +828,8 @@ // callbacks registered at the default compose on top of them // regardless of registration order. add_filter( - "get_entity_view_config_postType_{$post_type}", - "_wp_get_entity_view_config_post_type_{$post_type}", + "get_entity_view_config_posttype_{$post_type}", + "_wp_get_entity_view_config_posttype_{$post_type}", 5 ); } diff --git a/src/wp-includes/fonts/class-wp-font-face.php b/src/wp-includes/fonts/class-wp-font-face.php index 193a5d0951ddb..0aac64e792c58 100644 --- a/src/wp-includes/fonts/class-wp-font-face.php +++ b/src/wp-includes/fonts/class-wp-font-face.php @@ -311,13 +311,7 @@ private function build_font_face_css( array $font_face ) { * Wrap font-family in quotes if it contains spaces * and is not already wrapped in quotes. */ - if ( - str_contains( $font_face['font-family'], ' ' ) && - ! str_contains( $font_face['font-family'], '"' ) && - ! str_contains( $font_face['font-family'], "'" ) - ) { - $font_face['font-family'] = '"' . $font_face['font-family'] . '"'; - } + $font_face['font-family'] = $this->compile_font_family( $font_face['font-family'] ); foreach ( $font_face as $key => $value ) { // Compile the "src" parameter. @@ -338,6 +332,47 @@ private function build_font_face_css( array $font_face ) { return $css; } + /** + * Compiles the `font-family` into valid CSS. + * + * @since 6.4.0 + * + * @param string $value Value to process. + * @return string The CSS. + */ + private function compile_font_family( $value ) { + if ( $this->font_family_needs_quotes( $value ) ) { + return '"' . $this->escape_css_string( $value, '"' ) . '"'; + } + + return $value; + } + + /** + * Checks whether a font family value should be quoted. + * + * @since 6.4.0 + * + * @param string $value Value to process. + * @return bool Whether the font family value should be quoted. + */ + private function font_family_needs_quotes( $value ) { + if ( + str_contains( $value, ';' ) || + str_contains( $value, '{' ) || + str_contains( $value, '}' ) || + str_contains( $value, '/*' ) || + str_contains( $value, '*/' ) || + preg_match( '/[\x00-\x1F\x7F]/', $value ) + ) { + return true; + } + + return str_contains( $value, ' ' ) && + ! str_contains( $value, '"' ) && + ! str_contains( $value, "'" ); + } + /** * Compiles the `src` into valid CSS. * @@ -350,15 +385,39 @@ private function compile_src( array $value ) { $src = ''; foreach ( $value as $item ) { - $src .= ( 'data' === $item['format'] ) - ? ", url({$item['url']})" - : ", url('{$item['url']}') format('{$item['format']}')"; + $src .= ", url('" . $this->escape_css_string( $item['url'], "'" ) . "')"; + + if ( 'data' !== $item['format'] ) { + $src .= " format('" . $this->escape_css_string( $item['format'], "'" ) . "')"; + } } $src = ltrim( $src, ', ' ); return $src; } + /** + * Escapes a value for use in a quoted CSS string. + * + * @since 6.4.0 + * + * @param string $value Value to process. + * @param string $quote Quote character wrapping the CSS string. + * @return string Escaped CSS string content. + */ + private function escape_css_string( $value, $quote ) { + $value = str_replace( '\\', '\\\\', $value ); + $value = str_replace( $quote, '\\' . $quote, $value ); + + return preg_replace_callback( + '/[\x00-\x1F\x7F]/', + static function ( $matches ) { + return '\\' . strtoupper( dechex( ord( $matches[0] ) ) ) . ' '; + }, + $value + ); + } + /** * Compiles the font variation settings. * diff --git a/src/wp-includes/formatting.php b/src/wp-includes/formatting.php index 8f8af4a082196..66a61062a6e6b 100644 --- a/src/wp-includes/formatting.php +++ b/src/wp-includes/formatting.php @@ -2056,6 +2056,9 @@ function sanitize_file_name( $filename ) { * @see https://www.php.net/manual/en/regexp.reference.unicode.php */ $filename = preg_replace( '#\p{Zs}#siu', ' ', $filename ); + if ( null === $filename ) { + $filename = ''; + } } /** @@ -2186,6 +2189,7 @@ function sanitize_user( $username, $strict = false ) { * * @param string $key String key. * @return string Sanitized key. + * @phpstan-return lowercase-string */ function sanitize_key( $key ) { $sanitized_key = ''; @@ -3208,7 +3212,7 @@ function make_clickable( $text ) { $ret = preg_replace_callback( $url_clickable, '_make_url_clickable_cb', $ret ); $ret = preg_replace_callback( '#([\s>])((www|ftp)\.[\w\\x80-\\xff\#$%&~/.\-;:=,?@\[\]+]+)#is', '_make_web_ftp_clickable_cb', $ret ); - $ret = preg_replace_callback( '#([\s>])([.0-9a-z_+-]+)@(([0-9a-z-]+\.)+[0-9a-z]{2,})#i', '_make_email_clickable_cb', $ret ); + $ret = preg_replace_callback( '#([\s>])([.0-9a-z_+-]+)@(([0-9a-z-]+\.)+(?:xn--[0-9a-z-]*[0-9a-z]|[0-9a-z]{2,}))(?![0-9a-z-])#i', '_make_email_clickable_cb', $ret ); $ret = substr( $ret, 1, -1 ); // Remove our whitespace padding. $r .= $ret; diff --git a/src/wp-includes/functions.php b/src/wp-includes/functions.php index dca95d69b69fe..f5002a45de1e8 100644 --- a/src/wp-includes/functions.php +++ b/src/wp-includes/functions.php @@ -1445,11 +1445,7 @@ function get_status_header_desc( $code ) { ); } - if ( isset( $wp_header_to_desc[ $code ] ) ) { - return $wp_header_to_desc[ $code ]; - } else { - return ''; - } + return $wp_header_to_desc[ $code ] ?? ''; } /** @@ -5034,9 +5030,13 @@ function wp_parse_args( $args, $defaults = array() ) { * @since 5.1.0 * * @param mixed[]|string $input_list List of values. - * @return array Array of values. A string is split into a list, while an array + * @return array Array of scalar values. A string is split into a list, while an array * keeps its keys, so the result is not necessarily a list. - * @phpstan-return ( $input_list is string ? list : array ) + * @phpstan-return ( + * $input_list is string ? list : ( + * $input_list is array ? array : array + * ) + * ) */ function wp_parse_list( $input_list ): array { if ( ! is_array( $input_list ) ) { diff --git a/src/wp-includes/general-template.php b/src/wp-includes/general-template.php index 29c30137e173d..22a1d3e307d3e 100644 --- a/src/wp-includes/general-template.php +++ b/src/wp-includes/general-template.php @@ -1182,7 +1182,7 @@ function get_site_icon_url( $size = 512, $url = '', $blog_id = 0 ) { } $attachment_url = wp_get_attachment_image_url( $site_icon_id, $size_data ); if ( $attachment_url ) { - $url = $attachment_url; + $url = is_ssl() ? set_url_scheme( $attachment_url, 'https' ) : $attachment_url; } } diff --git a/src/wp-includes/html-api/class-wp-html-tag-processor.php b/src/wp-includes/html-api/class-wp-html-tag-processor.php index ace3e14bea565..7ca5191a0f162 100644 --- a/src/wp-includes/html-api/class-wp-html-tag-processor.php +++ b/src/wp-includes/html-api/class-wp-html-tag-processor.php @@ -718,6 +718,7 @@ class WP_HTML_Tag_Processor { * * @since 6.2.0 * @var WP_HTML_Attribute_Token[] + * @phpstan-var array */ private $attributes = array(); @@ -752,7 +753,7 @@ class WP_HTML_Tag_Processor { * ); * * @since 6.2.0 - * @var bool[] + * @var array */ private $classname_updates = array(); @@ -809,7 +810,7 @@ class WP_HTML_Tag_Processor { * ); * * @since 6.2.0 - * @var WP_HTML_Text_Replacement[] + * @var array */ protected $lexical_updates = array(); @@ -2072,7 +2073,7 @@ private function parse_next_tag(): bool { */ $is_valid_pi = ( 0 !== $target_length && - false !== strpos( " \t\f\r\n?>", $html[ $target_at + $target_length ] ) && + str_contains( " \t\f\r\n?>", $html[ $target_at + $target_length ] ) && ! ( 3 === $target_length && 0 === substr_compare( $html, 'xml', $target_at, 3, true ) ) && ! ( 14 === $target_length && 0 === substr_compare( $html, 'xml-stylesheet', $target_at, 14, true ) ) ); @@ -2957,6 +2958,7 @@ private function get_decoded_attribute_value( WP_HTML_Attribute_Token $attribute * * @param string $prefix Prefix of requested attribute names. * @return array|null List of attribute names, or `null` when no tag opener is matched. + * @phpstan-return list|null */ public function get_attribute_names_with_prefix( $prefix ): ?array { if ( @@ -2968,13 +2970,45 @@ public function get_attribute_names_with_prefix( $prefix ): ?array { $comparable = strtolower( $prefix ); + /* + * For the `class` attribute, ensure that enqueued class changes from + * `add_class` and `remove_class` are flushed into attribute updates. + */ + $has_class = isset( $this->attributes['class'] ); + if ( '' === $comparable || str_starts_with( 'class', $comparable ) ) { + foreach ( $this->classname_updates as $update ) { + if ( + ( $has_class && self::REMOVE_CLASS === $update ) || + ( ! $has_class && self::ADD_CLASS === $update ) + ) { + $this->class_name_updates_to_attributes_updates(); + break; + } + } + } + + $additions = array(); + $removals = array(); + foreach ( $this->lexical_updates as $update_name => $update ) { + if ( is_int( $update_name ) || 'modifiable text' === $update_name ) { + continue; + } + + if ( '' === $update->text ) { + $removals[ $update_name ] = true; + } elseif ( ! isset( $this->attributes[ $update_name ] ) && str_starts_with( $update_name, $comparable ) ) { + $additions[] = $update_name; + } + } + $matches = array(); foreach ( array_keys( $this->attributes ) as $attr_name ) { - if ( str_starts_with( $attr_name, $comparable ) ) { + if ( str_starts_with( $attr_name, $comparable ) && ! isset( $removals[ $attr_name ] ) ) { $matches[] = $attr_name; } } - return $matches; + + return empty( $additions ) ? $matches : array_merge( $additions, $matches ); } /** diff --git a/src/wp-includes/http.php b/src/wp-includes/http.php index 8280f424934dd..19aec80581a44 100644 --- a/src/wp-includes/http.php +++ b/src/wp-includes/http.php @@ -855,9 +855,5 @@ function _wp_translate_php_url_constant_to_key( $constant ) { PHP_URL_FRAGMENT => 'fragment', ); - if ( isset( $translation[ $constant ] ) ) { - return $translation[ $constant ]; - } else { - return false; - } + return $translation[ $constant ] ?? false; } diff --git a/src/wp-includes/interactivity-api/class-wp-interactivity-api.php b/src/wp-includes/interactivity-api/class-wp-interactivity-api.php index a04d62e54924c..62bfc29cfc03f 100644 --- a/src/wp-includes/interactivity-api/class-wp-interactivity-api.php +++ b/src/wp-includes/interactivity-api/class-wp-interactivity-api.php @@ -17,9 +17,19 @@ final class WP_Interactivity_API { * Holds the mapping of directive attribute names to their processor methods. * * @since 6.5.0 - * @var array + * @var array + * @phpstan-var array{ + * 'data-wp-interactive': 'data_wp_interactive_processor', + * 'data-wp-router-region': 'data_wp_router_region_processor', + * 'data-wp-context': 'data_wp_context_processor', + * 'data-wp-bind': 'data_wp_bind_processor', + * 'data-wp-class': 'data_wp_class_processor', + * 'data-wp-style': 'data_wp_style_processor', + * 'data-wp-text': 'data_wp_text_processor', + * 'data-wp-each': 'data_wp_each_processor', + * } */ - private static $directive_processors = array( + private static array $directive_processors = array( 'data-wp-interactive' => 'data_wp_interactive_processor', 'data-wp-router-region' => 'data_wp_router_region_processor', 'data-wp-context' => 'data_wp_context_processor', @@ -99,8 +109,17 @@ final class WP_Interactivity_API { * * This is only available during directive processing, otherwise it is `null`. * + * An entry is the namespace the directive defined. It is `false` instead when + * the directive did not define a usable one — the attribute was empty, or its + * JSON held no `namespace`, or the namespace did not match the accepted + * characters — and no enclosing `data-wp-interactive` was in effect to inherit + * from. An entry is pushed either way, because one is popped for every closing + * tag regardless of what the directive contained, so `false` is what stands in + * for "no namespace here" and keeps the stack balanced. + * * @since 6.6.0 - * @var array|null + * @var array|null + * @phpstan-var list|null */ private $namespace_stack = null; @@ -752,32 +771,54 @@ private function evaluate( $entry ) { /** * Parse the directive name to extract the following parts: - * - Prefix: The main directive name without "data-wp-". + * - Prefix: The main directive name without "data-wp-". It cannot begin with a hyphen. * - Suffix: An optional suffix used during directive processing, extracted after the first double hyphen "--". * - Unique ID: An optional unique identifier, extracted after the first triple hyphen "---". * * This function has an equivalent version for the client side. - * See `parseDirectiveName` in https://github.com/WordPress/gutenberg/blob/trunk/packages/interactivity/src/vdom.ts.: + * See `parseDirectiveName` in https://github.com/WordPress/gutenberg/blob/trunk/packages/interactivity/src/vdom.ts: + * + * An empty suffix or unique ID is normalized to null, but the string "0" is preserved. The + * client's `|| null` discards only the empty string, since every non-empty string is truthy in + * JavaScript. Do not use empty() for these checks: it would discard "0" and diverge from the + * client. * - * See examples in the function unit tests `test_parse_directive_name`. + * @see Tests_Interactivity_API_WpInteractivityAPI::test_parse_directive_name() for examples in the test inputs. * * @since 6.9.0 * * @param string $directive_name The directive attribute name. - * @return array An array containing the directive prefix, optional suffix, and optional unique ID. + * @return array|null An array containing the directive prefix, optional suffix, and optional unique ID, or null if the directive name cannot be parsed. + * @phpstan-return array{ + * prefix: non-empty-string, + * suffix: non-empty-string|null, + * unique_id: non-empty-string|null, + * }|null */ private function parse_directive_name( string $directive_name ): ?array { // Remove the first 8 characters (assumes "data-wp-" prefix) - $name = substr( $directive_name, 8 ); + $name = (string) substr( $directive_name, 8 ); - // Check for invalid characters (anything not a-z, 0-9, -, or _) - if ( preg_match( '/[^a-z0-9\-_]/i', $name ) ) { + // Ensure the name only contains valid characters (anything a-z, A-Z, 0-9, -, or _). + if ( 1 !== preg_match( '/^[a-zA-Z0-9\-_]+$/', $name ) ) { return null; } - // Find the first occurrence of '--' to separate the prefix + // Find the first occurrence of '--' to separate the prefix. $suffix_index = strpos( $name, '--' ); + /* + * A prefix cannot begin with a hyphen, so a name which does is not a directive at all. This + * covers both a lone leading hyphen, as in "data-wp--bind", and a leading double hyphen, as + * in "data-wp---foo", where treating the hyphens as a suffix separator would instead leave + * the prefix empty. It also covers "data-wp----unique-id", where only a unique ID is supplied + * without any prefix or suffix. + */ + if ( 0 === $suffix_index || '-' === $name[0] ) { + return null; + } + + // Without a '--' the whole name is the prefix. (This naturally also means there is no unique ID after '---'.) if ( false === $suffix_index ) { return array( 'prefix' => $name, @@ -790,33 +831,34 @@ private function parse_directive_name( string $directive_name ): ?array { $remaining = substr( $name, $suffix_index ); // If remaining starts with '---' but not '----', it's a unique_id - if ( '---' === substr( $remaining, 0, 3 ) && '-' !== ( $remaining[3] ?? '' ) ) { + if ( 3 === strspn( $remaining, '-' ) ) { + $unique_id = (string) substr( $remaining, 3 ); return array( 'prefix' => $prefix, 'suffix' => null, - 'unique_id' => '---' !== $remaining ? substr( $remaining, 3 ) : null, + 'unique_id' => '' === $unique_id ? null : $unique_id, ); } // Otherwise, remove the first two dashes for a potential suffix - $suffix = substr( $remaining, 2 ); + $suffix = (string) substr( $remaining, 2 ); // Look for '---' in the suffix for a unique_id $unique_id_index = strpos( $suffix, '---' ); if ( false !== $unique_id_index && '-' !== ( $suffix[ $unique_id_index + 3 ] ?? '' ) ) { - $unique_id = substr( $suffix, $unique_id_index + 3 ); - $suffix = substr( $suffix, 0, $unique_id_index ); + $unique_id = (string) substr( $suffix, $unique_id_index + 3 ); + $suffix = (string) substr( $suffix, 0, $unique_id_index ); return array( 'prefix' => $prefix, - 'suffix' => empty( $suffix ) ? null : $suffix, - 'unique_id' => empty( $unique_id ) ? null : $unique_id, + 'suffix' => '' === $suffix ? null : $suffix, + 'unique_id' => '' === $unique_id ? null : $unique_id, ); } return array( 'prefix' => $prefix, - 'suffix' => empty( $suffix ) ? null : $suffix, + 'suffix' => '' === $suffix ? null : $suffix, 'unique_id' => null, ); } @@ -846,6 +888,7 @@ private function parse_directive_name( string $directive_name ): ?array { * @param string|null $default_namespace Optional. The default namespace if none is explicitly defined. * @return array An array containing the namespace in the first item and the JSON, the reference path, or null on the * second item. + * @phpstan-return array{ 0: string|null, 1: mixed } */ private function extract_directive_value( $directive_value, $default_namespace = null ): array { if ( empty( $directive_value ) || is_bool( $directive_value ) ) { @@ -878,17 +921,46 @@ private function extract_directive_value( $directive_value, $default_namespace = * @param WP_Interactivity_API_Directives_Processor $p The directives processor instance. * @param string $prefix The directive prefix to filter by. * @return array An array of entries containing the directive namespace, value, suffix, and unique ID. + * @phpstan-return list */ - private function get_directive_entries( WP_Interactivity_API_Directives_Processor $p, string $prefix ) { + private function get_directive_entries( WP_Interactivity_API_Directives_Processor $p, string $prefix ): array { $directive_attributes = $p->get_attribute_names_with_prefix( 'data-wp-' . $prefix ); - $entries = array(); + if ( null === $directive_attributes ) { + return array(); + } + + $entries = array(); foreach ( $directive_attributes as $attribute_name ) { - [ 'prefix' => $attr_prefix, 'suffix' => $suffix, 'unique_id' => $unique_id] = $this->parse_directive_name( $attribute_name ); + $parsed_directive = $this->parse_directive_name( $attribute_name ); + if ( null === $parsed_directive ) { + continue; + } + + [ 'prefix' => $attr_prefix, 'suffix' => $suffix, 'unique_id' => $unique_id ] = $parsed_directive; // Ensure it is the desired directive. if ( $prefix !== $attr_prefix ) { continue; } - list( $namespace, $value ) = $this->extract_directive_value( $p->get_attribute( $attribute_name ), end( $this->namespace_stack ) ); + $attribute_value = $p->get_attribute( $attribute_name ); + if ( null === $attribute_value ) { + continue; + } + /* + * The namespace stack can hold false, which data_wp_interactive_processor() pushes for a + * `data-wp-interactive` whose namespace is invalid and which has no enclosing one to inherit. Only a + * string names a store, so anything else counts as no default namespace at all. + */ + $default_namespace = array_last( $this->namespace_stack ?? array() ); + if ( ! is_string( $default_namespace ) ) { + $default_namespace = null; + } + + list( $namespace, $value ) = $this->extract_directive_value( $attribute_value, $default_namespace ); $entries[] = array( 'namespace' => $namespace, 'value' => $value, @@ -1002,6 +1074,15 @@ private function data_wp_context_processor( WP_Interactivity_API_Directives_Proc continue; } + /* + * A context with no namespace has nothing to be stored under, so the inherited context is left as it + * is. Using the namespace as an array key regardless would coerce null to an empty string, which PHP + * 8.5 deprecates, and would store the context where no reference can address it anyway. + */ + if ( null === $entry['namespace'] ) { + continue; + } + $context = array_replace_recursive( $context, array( $entry['namespace'] => is_array( $entry['value'] ) ? $entry['value'] : array() ) @@ -1017,16 +1098,19 @@ private function data_wp_context_processor( WP_Interactivity_API_Directives_Proc * associated reference. * * @since 6.5.0 + * @since 7.1.0 An object is resolved to whatever it serializes to for the client, a number is formatted by the + * JSON encoder, and a value which cannot be sent to the client is rejected rather than passed to + * WP_HTML_Tag_Processor::set_attribute(). * - * @param WP_Interactivity_API_Directives_Processor $p The directives processor instance. - * @param string $mode Whether the processing is entering or exiting the tag. + * @param WP_Interactivity_API_Directives_Processor $p The directives processor instance. + * @param string $mode Whether the processing is entering or exiting the tag. */ - private function data_wp_bind_processor( WP_Interactivity_API_Directives_Processor $p, string $mode ) { + private function data_wp_bind_processor( WP_Interactivity_API_Directives_Processor $p, string $mode ): void { if ( 'enter' === $mode ) { $entries = $this->get_directive_entries( $p, 'bind' ); foreach ( $entries as $entry ) { if ( empty( $entry['suffix'] ) || null !== $entry['unique_id'] ) { - continue; + continue; } // Skip if the suffix is an event handler. @@ -1045,6 +1129,97 @@ private function data_wp_bind_processor( WP_Interactivity_API_Directives_Process $result = $this->evaluate( $entry ); + /* + * An object is resolved to whatever it serializes to. When the reference points to a value stored + * in state or context, that is the value the client receives for it when the store is hydrated. + * A derived state closure is never serialized, so there the client value comes from the derived + * state's client-side implementation instead; the resolution is still applied so that both origins + * behave the same. Round-tripping through the JSON encoder rather than calling + * JsonSerializable::jsonSerialize() directly keeps this resolution identical to the client's, + * including for an object which serializes to another serializable object. When the encoding fails + * the object is left in place, to be reported as a usage error below. Note that it rarely does + * fail: wp_json_encode() retries through _wp_json_sanity_check(), which rebuilds the object from + * its public properties and so ignores jsonSerialize() altogether. An object whose serialized form + * JSON cannot represent therefore resolves to whatever that rebuild encodes to, which is what the + * client is sent for it as well. + * + * A throwing JsonSerializable::jsonSerialize() is caught for the same reason the value is checked + * at all: a binding must not be able to abort the render. An exception escaping here would leave + * `$context_stack` and `$namespace_stack` unrestored for every later `process_directives()` call + * on this instance, so the object is treated as one which failed to encode. + */ + if ( is_object( $result ) ) { + try { + $encoded = wp_json_encode( $result ); + } catch ( Throwable $e ) { + $encoded = false; + } + if ( false !== $encoded ) { + $result = json_decode( $encoded ); + } + } + + /* + * Only a value which can be sent to the client may be stored in an attribute value. Strings and + * booleans are passed in as-is, numbers are formatted, and everything else is rejected as a usage + * error. + * + * An object which does not serialize to a scalar is rejected even when it defines `__toString()`, + * which PHP would otherwise coerce for the string parameters of the escaping functions. Its string + * representation is not what the client evaluates this reference to, whether that is the form + * serialized into the store or the return value of a derived state's client-side implementation, + * so the two could disagree once the directive is evaluated during hydration. + */ + if ( null !== $result ) { + if ( ! is_scalar( $result ) ) { + _doing_it_wrong( + __METHOD__, + sprintf( + /* translators: %s: The attribute name. */ + __( 'Attempted to bind a non-scalar value to the "%s" attribute. Ensure the state/context property or the derived state closure resolves to a string, number, or boolean.' ), + esc_html( $entry['suffix'] ) + ), + '7.1.0' + ); + $result = null; + } elseif ( is_int( $result ) || is_float( $result ) ) { + /* + * A number is formatted by the JSON encoder rather than cast to string, so that the + * attribute value matches the number the client receives for this same reference. Casting + * a float is locale-dependent before PHP 8.0, and rounds to `precision` rather than to the + * encoder's `serialize_precision`. + * + * This closes the cases which differ in practice, not every one. A float written in + * exponent notation still disagrees, since PHP encodes 1e25 as `1.0e+25` where JavaScript + * renders it as `1e+25`, as does negative zero, and an integer above the range JavaScript + * can represent exactly is rounded once it reaches the client. Casting diverged on all + * three as well, so none is a regression. + */ + $encoded = wp_json_encode( $result ); + if ( JSON_ERROR_INF_OR_NAN === json_last_error() ) { + /* + * The encoder only rejects INF and NAN, of which JSON can represent neither. When such + * a value is stored in state, the store itself also fails to encode in its entirety, + * and the client is sent an empty script tag in place of all of its state; only + * removing the value from the state resolves that. A derived state closure returning + * one never reaches the store, so there only the binding itself is affected. + */ + _doing_it_wrong( + __METHOD__, + sprintf( + /* translators: %s: The attribute name. */ + __( 'Attempted to bind a non-finite number to the "%s" attribute. Ensure the state/context property or the derived state closure resolves to a finite number or a string.' ), + esc_html( $entry['suffix'] ) + ), + '7.1.0' + ); + $result = null; + } else { + $result = $encoded; + } + } + } + if ( null !== $result && ( diff --git a/src/wp-includes/kses.php b/src/wp-includes/kses.php index 37d457a3e18a2..d68021c3a8b30 100644 --- a/src/wp-includes/kses.php +++ b/src/wp-includes/kses.php @@ -158,9 +158,10 @@ 'popover' => true, ), 'dialog' => array( - 'closedby' => true, - 'open' => true, - 'popover' => true, + 'closedby' => true, + 'open' => true, + 'popover' => true, + 'autofocus' => true, ), 'dl' => array(), 'dt' => array(), @@ -1131,6 +1132,89 @@ function wp_kses_allowed_html( $context = '' ) { } } +/** + * Allows the note mention chip markup in comment content. + * + * The notes `@` mention completer stores a mention as a chip carrying the + * mentioned user's ID in a class token: + * `@Name`. The default comment + * allowlist does not allow `span` at all, so for users without + * `unfiltered_html` the mention would be stripped on save. + * + * The allowance is deliberately narrow and always on: `span` is a + * semantics-free element and _wp_kses_sanitize_note_mention_classes() + * reduces its `class` to the two mention tokens right after kses runs, so + * regular (including anonymous) commenters gain nothing beyond the inert + * mention markup itself. + * + * @since 7.1.0 + * @access private + * + * @param array> $allowed The allowed tags structure for the context. + * @param string $context The kses context. + * @return array> Modified allowed tags structure. + */ +function _wp_kses_allow_note_mention_span( $allowed, $context ): array { + if ( ! is_array( $allowed ) ) { + $allowed = array(); + } + if ( 'pre_comment_content' !== $context ) { + return $allowed; + } + + if ( ! isset( $allowed['span'] ) || ! is_array( $allowed['span'] ) ) { + $allowed['span'] = array(); + } + + $allowed['span']['class'] = true; + + return $allowed; +} + +/** + * Reduces `span` classes in comment content to the note mention tokens. + * + * _wp_kses_allow_note_mention_span() lets `class` through kses on `span` so + * the mention chip survives, but `class` is an open-ended styling and + * scripting hook, so this companion pass - running right after + * `wp_filter_kses` at priority 10 - strips every class token except the two + * the mention markup uses: `wp-note-mention` and `user-N`. `span` is the only + * comment tag allowed to carry `class` at all, so walking `span` tags covers + * the entire allowance. + * + * The pass only applies while the restrictive comment allowlist is active: + * users with `unfiltered_html` are filtered through `wp_filter_post_kses` + * (or not at all), where arbitrary classes are already permitted, and + * narrowing their markup here would restrict what core allows them to post. + * + * @since 7.1.0 + * @access private + * + * @param string $content Slashed comment content, already filtered by kses. + * @return string Slashed comment content with span classes reduced. + */ +function _wp_kses_sanitize_note_mention_classes( $content ): string { + if ( ! is_string( $content ) ) { + $content = ''; + } + if ( false === has_filter( 'pre_comment_content', 'wp_filter_kses' ) ) { + return $content; + } + + $processor = new WP_HTML_Tag_Processor( wp_unslash( $content ) ); + + while ( $processor->next_tag( 'SPAN' ) ) { + foreach ( $processor->class_list() as $token ) { + if ( 'wp-note-mention' !== $token && ! preg_match( '/^user-[1-9][0-9]*$/', $token ) ) { + // Removing the last class also removes the attribute itself. + $processor->remove_class( $token ); + } + } + } + + return wp_slash( $processor->get_updated_html() ); +} + /** * You add any KSES hooks here. * diff --git a/src/wp-includes/media.php b/src/wp-includes/media.php index 456684d08e221..a31e77b00e26c 100644 --- a/src/wp-includes/media.php +++ b/src/wp-includes/media.php @@ -4716,9 +4716,9 @@ function wp_prepare_attachment_for_js( $attachment ) { $attached_file = get_attached_file( $attachment->ID ); - if ( isset( $meta['filesize'] ) ) { - $bytes = $meta['filesize']; - } elseif ( file_exists( $attached_file ) ) { + if ( isset( $meta['filesize'] ) && is_numeric( $meta['filesize'] ) && (int) $meta['filesize'] > 0 ) { + $bytes = (int) $meta['filesize']; + } elseif ( is_string( $attached_file ) && '' !== $attached_file && is_readable( $attached_file ) ) { $bytes = wp_filesize( $attached_file ); } else { $bytes = ''; @@ -4814,7 +4814,10 @@ function wp_prepare_attachment_for_js( $attachment ) { } $response = array_merge( $response, $sizes['full'] ); - } elseif ( $meta['sizes']['full']['file'] ) { + } elseif ( + ! empty( $meta['sizes']['full']['file'] ) && + isset( $meta['sizes']['full']['width'], $meta['sizes']['full']['height'] ) + ) { $sizes['full'] = array( 'url' => esc_url_raw( $base_url . $meta['sizes']['full']['file'] ), 'height' => $meta['sizes']['full']['height'], @@ -5189,6 +5192,8 @@ function wp_enqueue_media( $args = array() ) { 'mediaFound' => __( 'Number of media items found: %d' ), 'noMedia' => __( 'No media items found.' ), 'noMediaTryNewSearch' => __( 'No media items found. Try a different search.' ), + /* translators: %s: Media item title or file name. */ + 'mediaItemViewed' => __( 'Viewing media item: %s' ), // Library Details. 'attachmentDetails' => __( 'Attachment details' ), @@ -6532,7 +6537,7 @@ function wp_high_priority_element_flag( $value = null ): bool { * * @param string $filename Path to the image. * @param string $mime_type The source image mime type. - * @return string[] An array of mime type mappings. + * @return array An array of mime type mappings. */ function wp_get_image_editor_output_format( $filename, $mime_type ) { $output_format = array( @@ -6554,14 +6559,10 @@ function wp_get_image_editor_output_format( $filename, $mime_type ) { * @since 6.7.0 The default was changed from an empty array to an array * containing the HEIC/HEIF images mime types. * - * @param string[] $output_format { - * An array of mime type mappings. Maps a source mime type to a new - * destination mime type. By default maps HEIC/HEIF input to JPEG output. - * - * @type string ...$0 The new mime type. - * } - * @param string $filename Path to the image. - * @param string $mime_type The source image mime type. + * @param array $output_format An array of mime type mappings. Maps a source mime type to a new + * destination mime type. By default maps HEIC/HEIF input to JPEG output. + * @param string $filename Path to the image. + * @param string $mime_type The source image mime type. */ return apply_filters( 'image_editor_output_format', $output_format, $filename, $mime_type ); } diff --git a/src/wp-includes/php-ai-client/src/Providers/Http/HttpTransporter.php b/src/wp-includes/php-ai-client/src/Providers/Http/HttpTransporter.php index dd6cc3e9e4c4b..3113105734b8e 100644 --- a/src/wp-includes/php-ai-client/src/Providers/Http/HttpTransporter.php +++ b/src/wp-includes/php-ai-client/src/Providers/Http/HttpTransporter.php @@ -75,6 +75,8 @@ public function send(Request $request, ?RequestOptions $options = null): Respons } else { $psr7Response = $this->client->sendRequest($psr7Request); } + } catch (NetworkException $e) { + throw NetworkException::fromPsr18NetworkException($psr7Request, $e); } catch (\WordPress\AiClientDependencies\Psr\Http\Client\NetworkExceptionInterface $e) { throw NetworkException::fromPsr18NetworkException($psr7Request, $e); } catch (\WordPress\AiClientDependencies\Psr\Http\Client\ClientExceptionInterface $e) { diff --git a/src/wp-includes/pluggable.php b/src/wp-includes/pluggable.php index e1c43540c8cb8..7c7de80220da4 100644 --- a/src/wp-includes/pluggable.php +++ b/src/wp-includes/pluggable.php @@ -448,7 +448,7 @@ function wp_mail( $to, $subject, $message, $headers = '', $attachments = array() try { $phpmailer->setFrom( $from_email, $from_name, false ); } catch ( PHPMailer\PHPMailer\Exception $e ) { - $mail_error_data = compact( 'to', 'subject', 'message', 'headers', 'attachments' ); + $mail_error_data = compact( 'to', 'subject', 'message', 'headers', 'attachments', 'embeds' ); $mail_error_data['phpmailer_exception_code'] = $e->getCode(); /** This filter is documented in wp-includes/pluggable.php */ @@ -2090,7 +2090,7 @@ function wp_notify_moderator( $comment_id ) { $notify_message .= sprintf( __( 'Website: %1$s (IP address: %2$s, %3$s)' ), $comment->comment_author, $comment->comment_author_IP, $comment_author_domain ) . "\r\n"; /* translators: %s: Trackback/pingback/comment author URL. */ $notify_message .= sprintf( __( 'URL: %s' ), $comment->comment_author_url ) . "\r\n"; - $notify_message .= __( 'Trackback excerpt: ' ) . "\r\n" . $comment_content . "\r\n\r\n"; + $notify_message .= sprintf( __( 'Trackback excerpt: %s' ), "\r\n" . $comment_content ) . "\r\n\r\n"; break; case 'pingback': @@ -2101,7 +2101,7 @@ function wp_notify_moderator( $comment_id ) { $notify_message .= sprintf( __( 'Website: %1$s (IP address: %2$s, %3$s)' ), $comment->comment_author, $comment->comment_author_IP, $comment_author_domain ) . "\r\n"; /* translators: %s: Trackback/pingback/comment author URL. */ $notify_message .= sprintf( __( 'URL: %s' ), $comment->comment_author_url ) . "\r\n"; - $notify_message .= __( 'Pingback excerpt: ' ) . "\r\n" . $comment_content . "\r\n\r\n"; + $notify_message .= sprintf( __( 'Pingback excerpt: %s' ), "\r\n" . $comment_content ) . "\r\n\r\n"; break; default: // Comments. @@ -2376,7 +2376,7 @@ function wp_new_user_notification( $user_id, $deprecated = null, $notify = '' ) $switched_locale = switch_to_user_locale( $user_id ); - $message = __( 'To set your password, visit the following address:' ) . "\r\n\r\n"; + $message = __( 'To set your password, visit the following address:' ) . "\r\n\r\n"; /* * Since some user login names end in a period, this could produce ambiguous URLs that diff --git a/src/wp-includes/plugin.php b/src/wp-includes/plugin.php index 55459c0dd96c8..f64b584374c8e 100644 --- a/src/wp-includes/plugin.php +++ b/src/wp-includes/plugin.php @@ -1005,10 +1005,9 @@ function _wp_filter_build_unique_id( $hook_name, $callback, $priority ): ?string } if ( is_object( $callback ) ) { - return (string) spl_object_id( (object) $callback ); + return (string) spl_object_id( $callback ); } - $callback = (array) $callback; if ( ! isset( $callback[1] ) || ! is_string( $callback[1] ) ) { return null; } diff --git a/src/wp-includes/pomo/plural-forms.php b/src/wp-includes/pomo/plural-forms.php index a604334e88c20..cc31471a0b88d 100644 --- a/src/wp-includes/pomo/plural-forms.php +++ b/src/wp-includes/pomo/plural-forms.php @@ -128,7 +128,7 @@ protected function parse( $str ) { case ')': $found = false; while ( ! empty( $stack ) ) { - $o2 = $stack[ count( $stack ) - 1 ]; + $o2 = array_last( $stack ); if ( '(' !== $o2 ) { $output[] = array( 'op', array_pop( $stack ) ); continue; @@ -163,7 +163,7 @@ protected function parse( $str ) { } while ( ! empty( $stack ) ) { - $o2 = $stack[ count( $stack ) - 1 ]; + $o2 = array_last( $stack ); // Ternary is right-associative in C. if ( '?:' === $operator || '?' === $operator ) { diff --git a/src/wp-includes/post.php b/src/wp-includes/post.php index 3813176140bb4..8b13321d6a2cb 100644 --- a/src/wp-includes/post.php +++ b/src/wp-includes/post.php @@ -1869,6 +1869,15 @@ function register_post_type( $post_type, $args = array() ) { return new WP_Error( 'post_type_length_invalid', __( 'Post type names must be between 1 and 20 characters in length.' ) ); } + // Re-registration replaces the object; clean side effects from the old object first. + if ( isset( $wp_post_types[ $post_type ] ) && $wp_post_types[ $post_type ] instanceof WP_Post_Type && ! $wp_post_types[ $post_type ]->_builtin ) { + $wp_post_types[ $post_type ]->remove_supports(); + $wp_post_types[ $post_type ]->remove_rewrite_rules(); + $wp_post_types[ $post_type ]->unregister_meta_boxes(); + $wp_post_types[ $post_type ]->remove_hooks(); + $wp_post_types[ $post_type ]->unregister_taxonomies(); + } + $post_type_object = new WP_Post_Type( $post_type, $args ); $post_type_object->add_supports(); $post_type_object->add_rewrite_rules(); @@ -4052,6 +4061,22 @@ function _reset_front_page_settings_for_post( $post_id ) { unstick_post( $post->ID ); } +/** + * Resets the Privacy Policy page ID option when the Privacy Policy page + * is permanently deleted, to prevent uncached database queries for a + * non-existent page. + * + * @since 7.1.0 + * @access private + * + * @param int $post_id The ID of the post being deleted. + */ +function _reset_privacy_policy_page_for_post( int $post_id ): void { + if ( 'page' === get_post_type( $post_id ) && ( (int) get_option( 'wp_page_for_privacy_policy' ) === $post_id ) ) { + update_option( 'wp_page_for_privacy_policy', 0 ); + } +} + /** * Moves a post or page to the Trash * @@ -7032,6 +7057,8 @@ function wp_delete_attachment_files( $post_id, $meta, $backup_sizes, $file ) { * * @since 2.1.0 * @since 6.0.0 The `$filesize` value was added to the returned array. + * @since 7.1.0 `false` is now returned if the metadata is not an array, and when the result is + * filtered the `sizes` key is always an array when present. * * @param int $attachment_id Attachment post ID. Defaults to global $post. * @param bool $unfiltered Optional. If true, filters are not run. Default false. @@ -7095,7 +7122,7 @@ function wp_get_attachment_metadata( $attachment_id = 0, $unfiltered = false ) { $data = get_post_meta( $attachment_id, '_wp_attachment_metadata', true ); - if ( ! $data ) { + if ( ! is_array( $data ) || ! $data ) { return false; } @@ -7111,7 +7138,17 @@ function wp_get_attachment_metadata( $attachment_id = 0, $unfiltered = false ) { * @param array $data Array of meta data for the given attachment. * @param int $attachment_id Attachment post ID. */ - return apply_filters( 'wp_get_attachment_metadata', $data, $attachment_id ); + $data = apply_filters( 'wp_get_attachment_metadata', $data, $attachment_id ); + + if ( ! is_array( $data ) ) { + return false; + } + + if ( array_key_exists( 'sizes', $data ) && ! is_array( $data['sizes'] ) ) { + $data['sizes'] = array(); + } + + return $data; } /** diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php index 609b133ca4cfc..f336f321a9ea2 100644 --- a/src/wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php @@ -237,50 +237,54 @@ public function register_routes() { public function get_endpoint_args_for_item_schema( $method = WP_REST_Server::CREATABLE ) { $args = parent::get_endpoint_args_for_item_schema( $method ); - if ( WP_REST_Server::CREATABLE === $method && wp_is_client_side_media_processing_enabled() ) { - $args['generate_sub_sizes'] = array( - 'type' => 'boolean', - 'default' => true, - 'description' => __( 'Whether to generate image sub sizes.' ), - ); - $args['convert_format'] = array( - 'type' => 'boolean', - 'default' => true, - 'description' => __( 'Whether to convert image formats.' ), - ); - $args['url'] = array( - 'type' => 'string', - 'format' => 'uri', - 'description' => __( 'URL of an external image to sideload into the media library, instead of uploading a file.' ), - 'sanitize_callback' => 'sanitize_url', - 'validate_callback' => static function ( $url, $request, $param ) { - /* - * A custom validate_callback replaces the default - * rest_validate_request_arg(), so re-apply it first to keep - * the schema checks (string type, uri format) enforced. - */ - $valid = rest_validate_request_arg( $url, $request, $param ); - if ( is_wp_error( $valid ) ) { - return $valid; - } + if ( WP_REST_Server::CREATABLE !== $method ) { + return $args; + } - /* - * Reject URLs that are not safe to request server-side. wp_http_validate_url() - * enforces an HTTP(S) scheme and blocks private, local, and otherwise - * disallowed hosts, guarding the sideload against SSRF. - */ - if ( false === wp_http_validate_url( $url ) ) { - return new WP_Error( - 'rest_invalid_url', - __( 'Invalid URL. Provide a valid, publicly reachable HTTP or HTTPS image URL.' ), - array( 'status' => 400 ) - ); - } + $args['generate_sub_sizes'] = array( + 'type' => 'boolean', + 'default' => true, + 'description' => __( 'Whether to generate image sub sizes.' ), + ); - return true; - }, - ); - } + $args['convert_format'] = array( + 'type' => 'boolean', + 'default' => true, + 'description' => __( 'Whether to convert image formats.' ), + ); + + $args['url'] = array( + 'type' => 'string', + 'format' => 'uri', + 'description' => __( 'URL of an external image to sideload into the media library, instead of uploading a file.' ), + 'sanitize_callback' => 'sanitize_url', + 'validate_callback' => static function ( $url, $request, $param ) { + /* + * A custom validate_callback replaces the default + * rest_validate_request_arg(), so re-apply it first to keep + * the schema checks (string type, uri format) enforced. + */ + $valid = rest_validate_request_arg( $url, $request, $param ); + if ( is_wp_error( $valid ) ) { + return $valid; + } + + /* + * Reject URLs that are not safe to request server-side. wp_http_validate_url() + * enforces an HTTP(S) scheme and blocks private, local, and otherwise + * disallowed hosts, guarding the sideload against SSRF. + */ + if ( false === wp_http_validate_url( $url ) ) { + return new WP_Error( + 'rest_invalid_url', + __( 'Invalid URL. Provide a valid, publicly reachable HTTP or HTTPS image URL.' ), + array( 'status' => 400 ) + ); + } + + return true; + }, + ); return $args; } @@ -381,9 +385,15 @@ public function create_item_permissions_check( $request ) { */ $prevent_unsupported_uploads = apply_filters( 'wp_prevent_unsupported_mime_type_uploads', true, $files['file']['type'] ?? null ); - // When the client handles image processing (generate_sub_sizes is false), - // skip the server-side image editor support check. - if ( false === $request['generate_sub_sizes'] ) { + /* + * When the client handles image processing (generate_sub_sizes is false), + * skip the server-side image editor support check. This check exists + * because the server cannot process the image, so it is only relaxed when + * client side media processing is enabled and something else can. Asking + * to skip sub sizes on a site without it does not make an unsupported + * image type any more usable. + */ + if ( wp_is_client_side_media_processing_enabled() && false === $request['generate_sub_sizes'] ) { $prevent_unsupported_uploads = false; } @@ -459,6 +469,10 @@ public function create_item( $request ) { // Disable server-side EXIF rotation so the client can handle it. // This preserves the original orientation value in the metadata. add_filter( 'wp_image_maybe_exif_rotate', '__return_false', 100 ); + // Disable server-side "big image" downscaling; the client supplies its + // own scaled version via the sideload endpoint. Scaling here would + // create a conflicting "-scaled" file and orphan the full-size upload. + add_filter( 'big_image_size_threshold', '__return_false', 100 ); } // Handle convert_format parameter. @@ -626,12 +640,41 @@ protected function create_item_from_url( $request ) { ); } + /* + * Cap the download at the same size the site would accept as a direct + * upload. check_upload_size() only applies on multisite, so without a + * ceiling here a single site has no limit at all on this path: the + * `upload_max_filesize` and `post_max_size` directives bound a request + * body, not a fetch the server makes itself. + * + * When `wp_max_upload_size` returns 0, no ceiling is applied. + */ + $max_size = (int) wp_max_upload_size(); + /* * Download the remote file with WordPress's HTTP API, which validates * the host and blocks requests to private or local addresses. This is * the same primitive core's media_sideload_image() relies on. + * + * `limit_response_size` stops the transfer once the limit is passed, + * so an oversized remote file is never written to disk in full. One + * byte over the ceiling is enough to fail the size check below. */ + $limit_response_size = static function ( $args ) use ( $max_size ) { + $args['limit_response_size'] = $max_size + 1; + return $args; + }; + + if ( $max_size > 0 ) { + add_filter( 'http_request_args', $limit_response_size ); + } + $tmp_file = download_url( $url ); + + if ( $max_size > 0 ) { + remove_filter( 'http_request_args', $limit_response_size ); + } + if ( is_wp_error( $tmp_file ) ) { return $tmp_file; } @@ -641,6 +684,27 @@ protected function create_item_from_url( $request ) { 'tmp_name' => $tmp_file, ); + $size_check = self::check_upload_size( $file_array ); + if ( is_wp_error( $size_check ) ) { + if ( file_exists( $tmp_file ) ) { + wp_delete_file( $tmp_file ); + } + return $size_check; + } + + if ( $max_size > 0 && wp_filesize( $tmp_file ) > $max_size ) { + if ( file_exists( $tmp_file ) ) { + wp_delete_file( $tmp_file ); + } + + return new WP_Error( + 'rest_upload_file_too_big', + /* translators: %s: Maximum allowed file size in kilobytes. */ + sprintf( __( 'This file is too big. Files must be less than %s KB in size.' ), number_format( $max_size / KB_IN_BYTES ) ), + array( 'status' => 400 ) + ); + } + $attachment_id = media_handle_sideload( $file_array, $post_id ); if ( is_wp_error( $attachment_id ) ) { @@ -683,6 +747,7 @@ private function remove_client_side_media_processing_filters(): void { remove_filter( 'fallback_intermediate_image_sizes', '__return_empty_array', 100 ); remove_filter( 'wp_image_maybe_exif_rotate', '__return_false', 100 ); remove_filter( 'image_editor_output_format', '__return_empty_array', 100 ); + remove_filter( 'big_image_size_threshold', '__return_false', 100 ); } /** diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-autosaves-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-autosaves-controller.php index 65ca4e0018cb6..c0d58160467b2 100644 --- a/src/wp-includes/rest-api/endpoints/class-wp-rest-autosaves-controller.php +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-autosaves-controller.php @@ -425,7 +425,7 @@ public function create_post_autosave( $post_data, array $meta = array() ) { $new_autosave['post_author'] = $user_id; /** This action is documented in wp-admin/includes/post.php */ - do_action( 'wp_creating_autosave', $new_autosave ); + do_action( 'wp_creating_autosave', $new_autosave, true ); // wp_update_post() expects escaped array. $revision_id = wp_update_post( wp_slash( $new_autosave ) ); diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-menus-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-menus-controller.php index 3947bfd6107ce..706e36fb6cc66 100644 --- a/src/wp-includes/rest-api/endpoints/class-wp-rest-menus-controller.php +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-menus-controller.php @@ -453,7 +453,7 @@ protected function handle_auto_add( $menu_id, $request ) { $update = update_option( 'nav_menu_options', $nav_menu_option ); /** This action is documented in wp-includes/nav-menu.php */ - do_action( 'wp_update_nav_menu', $menu_id ); + do_action( 'wp_update_nav_menu', $menu_id, array() ); return $update; } diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-posts-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-posts-controller.php index ee3e6b4959869..ad04e9817cb1a 100644 --- a/src/wp-includes/rest-api/endpoints/class-wp-rest-posts-controller.php +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-posts-controller.php @@ -759,6 +759,8 @@ public function create_item( $request ) { && ! empty( $prepared_post->post_status ) && in_array( $prepared_post->post_status, array( 'draft', 'pending' ), true ) ) { + $post_parent = isset( $prepared_post->post_parent ) ? (int) $prepared_post->post_parent : 0; + /* * `wp_unique_post_slug()` returns the same slug for 'draft' or 'pending' posts. * @@ -766,10 +768,10 @@ public function create_item( $request ) { */ $prepared_post->post_name = wp_unique_post_slug( $prepared_post->post_name, - $prepared_post->id, + 0, 'publish', $prepared_post->post_type, - $prepared_post->post_parent + $post_parent ); } @@ -966,7 +968,7 @@ public function update_item( $request ) { * To ensure that a unique slug is generated, pass the post data with the 'publish' status. */ if ( ! empty( $post->post_name ) && in_array( $post_status, array( 'draft', 'pending' ), true ) ) { - $post_parent = ! empty( $post->post_parent ) ? $post->post_parent : 0; + $post_parent = property_exists( $post, 'post_parent' ) ? (int) $post->post_parent : (int) $post_before->post_parent; $post->post_name = wp_unique_post_slug( $post->post_name, $post->ID, diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-revisions-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-revisions-controller.php index 73a888d6eac48..caf0982c0b966 100644 --- a/src/wp-includes/rest-api/endpoints/class-wp-rest-revisions-controller.php +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-revisions-controller.php @@ -381,7 +381,7 @@ public function get_items( $request ) { $response->header( 'X-WP-TotalPages', (int) $max_pages ); $request_params = $request->get_query_params(); - $base_path = rest_url( sprintf( '%s/%s/%d/%s', $this->namespace, $this->parent_base, $request['parent'], $this->rest_base ) ); + $base_path = rest_url( sprintf( '%s/%s/%s/%s', $this->namespace, $this->parent_base, $request['parent'], $this->rest_base ) ); $base = add_query_arg( urlencode_deep( $request_params ), $base_path ); if ( $page > 1 ) { @@ -595,14 +595,29 @@ protected function prepare_items_query( $prepared_args = array(), $request = nul * * @since 4.7.0 * @since 5.9.0 Renamed `$post` to `$item` to match parent class for PHP 8 named parameter support. + * @since 7.1.0 The global post is now restored to its previous value before returning. * - * @global WP_Post $post Global post object. + * @global WP_Post|null $post Global post object. * * @param WP_Post $item Post revision object. * @param WP_REST_Request $request Request object. * @return WP_REST_Response Response object. */ public function prepare_item_for_response( $item, $request ) { + /* + * Save the previous global post so it can be restored before returning. + * Preparing the revision sets up the global post and post data, which + * must not leak into the rest of the request (e.g. the autosaves endpoint + * is preloaded in the block editor, where a leaked global post can cause + * the editor to be initialized with the wrong post). + * + * Note that $post is intentionally not declared as a global here. It must + * remain local to this method so that a filter which reassigns the global + * post while the response is being prepared (for example on 'the_content') + * cannot change which post the remaining fields are read from. + */ + $previous_post = isset( $GLOBALS['post'] ) && $GLOBALS['post'] instanceof WP_Post ? $GLOBALS['post'] : null; + // Restores the more descriptive, specific name for use within this method. $post = $item; @@ -613,7 +628,11 @@ public function prepare_item_for_response( $item, $request ) { // Don't prepare the response body for HEAD requests. if ( $request->is_method( 'HEAD' ) ) { /** This filter is documented in wp-includes/rest-api/endpoints/class-wp-rest-revisions-controller.php */ - return apply_filters( 'rest_prepare_revision', new WP_REST_Response( array() ), $post, $request ); + $response = apply_filters( 'rest_prepare_revision', new WP_REST_Response( array() ), $post, $request ); + + $this->restore_post_data( $previous_post ); + + return $response; } $fields = $this->get_fields_for_response( $request ); @@ -717,7 +736,55 @@ public function prepare_item_for_response( $item, $request ) { * @param WP_Post $post The original revision object. * @param WP_REST_Request $request Request used to generate the response. */ - return apply_filters( 'rest_prepare_revision', $response, $post, $request ); + $response = apply_filters( 'rest_prepare_revision', $response, $post, $request ); + + $this->restore_post_data( $previous_post ); + + return $response; + } + + /** + * Restores the global post to its previous value after preparing a revision. + * + * Preparing a revision overwrites the global post and post data via + * setup_postdata(). This restores the global post that was in place + * beforehand so the change does not leak into the rest of the request. + * + * Only the global post is guaranteed to be restored. When there was no + * previous global post and the main query has no post either, which is the + * usual state during a REST request, wp_reset_postdata() has nothing to + * restore from, so the remaining globals set by setup_postdata() (such as + * $id, $authordata and $pages) are left describing the revision. Clearing + * those would mean unsetting each one by hand, which is beyond what is + * needed to keep the global post from leaking. + * + * @since 7.1.0 + * + * @param WP_Post|null $previous_post The global post to restore, or null if there was none. + */ + private function restore_post_data( ?WP_Post $previous_post ): void { + if ( $previous_post ) { + $GLOBALS['post'] = $previous_post; + setup_postdata( $previous_post ); + return; + } + + /* + * There was no global post to restore, so clear the revision's post data. + * This runs before clearing the global post because wp_reset_postdata() + * repopulates it from the main query whenever that query has a post. Note + * that it is a no-op when the main query has no post, in which case only + * the global post below is cleared. + */ + wp_reset_postdata(); + + /* + * Assigned rather than unset so that any `global $post` binding made before + * this request keeps pointing at the global. Unsetting removes the entry from + * the symbol table, which detaches those bindings, and a later write through + * one of them would no longer be visible to get_post(). + */ + $GLOBALS['post'] = null; } /** @@ -911,7 +978,7 @@ public function get_collection_params() { protected function prepare_excerpt_response( $excerpt, $post ) { /** This filter is documented in wp-includes/post-template.php */ - $excerpt = apply_filters( 'the_excerpt', $excerpt, $post ); + $excerpt = apply_filters( 'the_excerpt', $excerpt ); if ( empty( $excerpt ) ) { return ''; diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-templates-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-templates-controller.php index b821ca09453e3..b6691c588ca7d 100644 --- a/src/wp-includes/rest-api/endpoints/class-wp-rest-templates-controller.php +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-templates-controller.php @@ -668,6 +668,8 @@ protected function prepare_item_for_database( $request ) { * @since 5.9.0 Renamed `$template` to `$item` to match parent class for PHP 8 named parameter support. * @since 6.3.0 Added `modified` property to the response. * @since 7.1.0 Added `date` property to the response. + * @since 7.1.0 The `modified` property is `null` for templates that have no + * modification date. * * @param WP_Block_Template $item Template instance. * @param WP_REST_Request $request Request object. @@ -776,11 +778,23 @@ public function prepare_item_for_response( $item, $request ) { } if ( rest_is_field_included( 'modified', $fields ) ) { - $data['modified'] = mysql_to_rfc3339( $template->modified ); + /* + * File-backed templates have no modification date, and `mysql_to_rfc3339()` + * returns `false` for an empty or malformed value, which the schema does + * not allow. Return `null` in that case. + */ + $modified = mysql_to_rfc3339( $template->modified ); + $data['modified'] = false !== $modified ? $modified : null; } if ( rest_is_field_included( 'date', $fields ) ) { - $data['date'] = mysql_to_rfc3339( $template->date ); + /* + * File-backed templates have no date, and `mysql_to_rfc3339()` returns + * `false` for an empty or malformed value, which the schema does not + * allow. Return `null` in that case. + */ + $date = mysql_to_rfc3339( $template->date ); + $data['date'] = false !== $date ? $date : null; } if ( rest_is_field_included( 'author_text', $fields ) ) { @@ -1154,7 +1168,7 @@ public function get_item_schema() { ), 'modified' => array( 'description' => __( "The date the template was last modified, in the site's timezone." ), - 'type' => 'string', + 'type' => array( 'string', 'null' ), 'format' => 'date-time', 'context' => array( 'view', 'edit' ), 'readonly' => true, @@ -1177,7 +1191,7 @@ public function get_item_schema() { 'user', ), ), - 'date' => array( + 'date' => array( 'description' => __( "The date the template was published, in the site's timezone." ), 'type' => array( 'string', 'null' ), 'format' => 'date-time', diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-view-config-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-view-config-controller.php index 34cd1572526c6..64c1ebe1ba921 100644 --- a/src/wp-includes/rest-api/endpoints/class-wp-rest-view-config-controller.php +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-view-config-controller.php @@ -193,10 +193,10 @@ protected function cast_empty_objects( $value, $schema ) { } if ( isset( $schema['oneOf'] ) || isset( $schema['anyOf'] ) ) { - $branches = isset( $schema['oneOf'] ) ? $schema['oneOf'] : $schema['anyOf']; + $branches = $schema['oneOf'] ?? $schema['anyOf']; if ( array() === $value ) { foreach ( $branches as $branch ) { - if ( is_array( $branch ) && in_array( 'object', (array) ( isset( $branch['type'] ) ? $branch['type'] : array() ), true ) ) { + if ( is_array( $branch ) && in_array( 'object', (array) ( $branch['type'] ?? array() ), true ) ) { return (object) array(); } } @@ -204,7 +204,7 @@ protected function cast_empty_objects( $value, $schema ) { return $value; } - $types = (array) ( isset( $schema['type'] ) ? $schema['type'] : array() ); + $types = (array) ( $schema['type'] ?? array() ); if ( in_array( 'array', $types, true ) && isset( $schema['items'] ) ) { foreach ( $value as $index => $item ) { @@ -391,15 +391,15 @@ public function get_item_schema() { /** * Returns the schema properties shared by all view types (ViewBase), excluding 'type'. * + * Note that `search` and `page` are not part of the schema: they are managed + * via the URL, which is their only source of truth. + * * @since 7.1.0 * * @return array Schema properties for the base view configuration. */ protected function get_view_base_schema() { return array( - 'search' => array( - 'type' => 'string', - ), 'filters' => array( 'type' => 'array', 'items' => array( @@ -444,9 +444,6 @@ protected function get_view_base_schema() { ), ), ), - 'page' => array( - 'type' => 'integer', - ), 'perPage' => array( 'type' => 'integer', ), diff --git a/src/wp-includes/script-loader.php b/src/wp-includes/script-loader.php index fd1f24a08968d..a364439f0abbb 100644 --- a/src/wp-includes/script-loader.php +++ b/src/wp-includes/script-loader.php @@ -1038,7 +1038,7 @@ function wp_default_scripts( $scripts ) { 'deleted' => __( 'moved to the Trash.' ), /* translators: %s: File name. */ 'error_uploading' => __( '“%s” has failed to upload.' ), - 'unsupported_image' => __( 'This image cannot be displayed in a web browser. For best results convert it to JPEG before uploading.' ), + 'unsupported_image' => __( 'The server cannot process HEIC images. Convert it to JPEG before uploading.' ), 'noneditable_image' => __( 'The web server cannot generate responsive image sizes for this image. Convert it to JPEG or PNG before uploading.' ), 'file_url_copied' => __( 'The file URL has been copied to your clipboard' ), ); @@ -1781,9 +1781,11 @@ function wp_default_styles( $styles ) { 'wp-reusable-blocks', 'wp-patterns', 'wp-preferences', + 'wp-media-utils', ), 'format-library' => array(), 'list-reusable-blocks' => array( 'wp-components' ), + 'media-utils' => array( 'wp-components' ), 'reusable-blocks' => array( 'wp-components' ), 'patterns' => array( 'wp-components' ), 'preferences' => array( 'wp-components' ), @@ -1892,6 +1894,7 @@ function wp_default_styles( $styles ) { 'wp-editor', 'wp-format-library', 'wp-list-reusable-blocks', + 'wp-media-utils', 'wp-reusable-blocks', 'wp-patterns', 'wp-nux', diff --git a/src/wp-includes/shortcodes.php b/src/wp-includes/shortcodes.php index 01fed7244e548..2260ffc230c97 100644 --- a/src/wp-includes/shortcodes.php +++ b/src/wp-includes/shortcodes.php @@ -263,7 +263,7 @@ function do_shortcode( $content, $ignore_html = false ) { $has_filter = has_filter( 'wp_get_attachment_image_context', '_filter_do_shortcode_context' ); $filter_added = false; - if ( ! $has_filter ) { + if ( false === $has_filter ) { $filter_added = add_filter( 'wp_get_attachment_image_context', '_filter_do_shortcode_context' ); } diff --git a/src/wp-includes/taxonomy.php b/src/wp-includes/taxonomy.php index 493d303068cf3..29317f0a8bf9b 100644 --- a/src/wp-includes/taxonomy.php +++ b/src/wp-includes/taxonomy.php @@ -2632,11 +2632,11 @@ function wp_insert_term( $term, $taxonomy, $args = array() ) { $slug = sanitize_title( $slug, $term_id ); /** This action is documented in wp-includes/taxonomy.php */ - do_action( 'edit_terms', $term_id, $taxonomy ); + do_action( 'edit_terms', $term_id, $taxonomy, $args ); $wpdb->update( $wpdb->terms, compact( 'slug' ), compact( 'term_id' ) ); /** This action is documented in wp-includes/taxonomy.php */ - do_action( 'edited_terms', $term_id, $taxonomy ); + do_action( 'edited_terms', $term_id, $taxonomy, $args ); } /** @var numeric-string|null $tt_id */ @@ -3494,7 +3494,7 @@ function wp_update_term( $term_id, $taxonomy, $args = array() ) { do_action( "edit_{$taxonomy}", $term_id, $tt_id, $args ); /** This filter is documented in wp-includes/taxonomy.php */ - $term_id = apply_filters( 'term_id_filter', $term_id, $tt_id ); + $term_id = apply_filters( 'term_id_filter', $term_id, $tt_id, $args ); clean_term_cache( $term_id, $taxonomy ); @@ -4249,11 +4249,11 @@ function _update_post_term_count( $terms, $taxonomy ) { do_action( 'update_term_count', $tt_id, $taxonomy->name, $count ); /** This action is documented in wp-includes/taxonomy.php */ - do_action( 'edit_term_taxonomy', $tt_id, $taxonomy->name ); + do_action( 'edit_term_taxonomy', $tt_id, $taxonomy->name, array() ); $wpdb->update( $wpdb->term_taxonomy, compact( 'count' ), array( 'term_taxonomy_id' => $tt_id ) ); /** This action is documented in wp-includes/taxonomy.php */ - do_action( 'edited_term_taxonomy', $tt_id, $taxonomy->name ); + do_action( 'edited_term_taxonomy', $tt_id, $taxonomy->name, array() ); } } @@ -4279,11 +4279,11 @@ function _update_generic_term_count( $terms, $taxonomy ) { do_action( 'update_term_count', $term, $taxonomy->name, $count ); /** This action is documented in wp-includes/taxonomy.php */ - do_action( 'edit_term_taxonomy', $term, $taxonomy->name ); + do_action( 'edit_term_taxonomy', $term, $taxonomy->name, array() ); $wpdb->update( $wpdb->term_taxonomy, compact( 'count' ), array( 'term_taxonomy_id' => $term ) ); /** This action is documented in wp-includes/taxonomy.php */ - do_action( 'edited_term_taxonomy', $term, $taxonomy->name ); + do_action( 'edited_term_taxonomy', $term, $taxonomy->name, array() ); } } diff --git a/src/wp-includes/theme.json b/src/wp-includes/theme.json index df48a061af01e..1cd9dfa120e89 100644 --- a/src/wp-includes/theme.json +++ b/src/wp-includes/theme.json @@ -319,6 +319,7 @@ "radius": true }, "dimensions": { + "width": true, "dimensionSizes": [ { "name": "25%", diff --git a/src/wp-includes/user.php b/src/wp-includes/user.php index d682762acb7e7..35c2667220491 100644 --- a/src/wp-includes/user.php +++ b/src/wp-includes/user.php @@ -5075,12 +5075,22 @@ function wp_validate_user_request_key( #[\SensitiveParameter] $key ) { - $request_id = absint( $request_id ); - $request = wp_get_user_request( $request_id ); + $request_id = absint( $request_id ); + + if ( ! $request_id ) { + return new WP_Error( 'invalid_request', __( 'Invalid personal data request.' ) ); + } + + $request = wp_get_user_request( $request_id ); + + if ( ! $request ) { + return new WP_Error( 'invalid_request', __( 'Invalid personal data request.' ) ); + } + $saved_key = $request->confirm_key; $key_request_time = $request->modified_timestamp; - if ( ! $request || ! $saved_key || ! $key_request_time ) { + if ( ! $saved_key || ! $key_request_time ) { return new WP_Error( 'invalid_request', __( 'Invalid personal data request.' ) ); } diff --git a/src/wp-includes/version.php b/src/wp-includes/version.php index 2246c53246e53..985bfaf0bf868 100644 --- a/src/wp-includes/version.php +++ b/src/wp-includes/version.php @@ -16,7 +16,7 @@ * * @global string $wp_version */ -$wp_version = '7.1-beta3-62828-src'; +$wp_version = '7.1-RC1-63034-src'; /** * Holds the WordPress DB revision, increments when changes are made to the WordPress DB schema. diff --git a/src/wp-includes/view-config.php b/src/wp-includes/view-config.php index c4d20979b846f..b97b221ec2a32 100644 --- a/src/wp-includes/view-config.php +++ b/src/wp-includes/view-config.php @@ -4,12 +4,33 @@ * * Builds the default view configuration for an entity and exposes it through * the dynamic `get_entity_view_config_{$kind}_{$name}` filter so core and third - * parties can provide the configuration for a specific entity. + * parties can provide the configuration for a specific entity. The dynamic + * portions of the hook name are lowercased, e.g. + * `get_entity_view_config_posttype_page` for the `page` post type. * * @package WordPress * @since 7.1.0 */ +/** + * Builds the name of the dynamic filter that provides the view configuration + * for an entity. + * + * The entity kind and name are embedded in the hook name lowercased, so the + * hook follows the WordPress convention of lowercase hook names regardless of + * how the entity identifiers are spelled: the `postType`/`page` entity maps to + * the `get_entity_view_config_posttype_page` hook. + * + * @since 7.1.0 + * + * @param string $kind The entity kind (e.g. `postType`). + * @param string $name The entity name (e.g. `page`). + * @return string The filter name. + */ +function wp_get_entity_view_config_hook_name( $kind, $name ) { + return strtolower( "get_entity_view_config_{$kind}_{$name}" ); +} + /** * Builds the default `form` configuration for post types that don't provide their own. * @@ -27,7 +48,7 @@ * * @return array The default form configuration. */ -function _wp_get_default_post_type_form() { +function _wp_get_default_posttype_form() { return array( 'layout' => array( 'type' => 'panel' ), 'fields' => array( @@ -97,8 +118,10 @@ function _wp_get_default_post_type_form() { * Returns the view configuration for the given entity. * * Builds the default configuration shared by all entities and then exposes it - * through the dynamic `get_entity_view_config_{$kind}_{$name}` filter so that core - * and third parties can provide the configuration for a specific entity. + * through the dynamic `get_entity_view_config_{$kind}_{$name}` filter — with the + * dynamic portions lowercased, see wp_get_entity_view_config_hook_name() + * — so that core and third parties can provide the configuration for a + * specific entity. * * @since 7.1.0 * @@ -148,7 +171,7 @@ function wp_get_entity_view_config( $kind, $name ) { 'default_view' => $default_view, 'default_layouts' => $default_layouts, 'view_list' => $view_list, - 'form' => 'postType' === $kind ? _wp_get_default_post_type_form() : array(), + 'form' => 'postType' === $kind ? _wp_get_default_posttype_form() : array(), ); $data = new WP_View_Config_Data( $config ); @@ -164,7 +187,7 @@ function wp_get_entity_view_config( $kind, $name ) { * @param WP_View_Config_Data $data The view configuration container for the entity. * @return WP_View_Config_Data The updated view configuration container. */ -function _wp_get_entity_view_config_post_type_page( $data ) { +function _wp_get_entity_view_config_posttype_page( $data ) { $default_layouts = array( 'table' => array( 'layout' => array( @@ -304,7 +327,7 @@ function _wp_get_entity_view_config_post_type_page( $data ) { * @param WP_View_Config_Data $data The view configuration container for the entity. * @return WP_View_Config_Data The updated view configuration container. */ -function _wp_get_entity_view_config_post_type_wp_block( $data ) { +function _wp_get_entity_view_config_posttype_wp_block( $data ) { $default_layouts = array( 'table' => array( 'layout' => array( @@ -422,7 +445,7 @@ function _wp_get_entity_view_config_post_type_wp_block( $data ) { * @param WP_View_Config_Data $data The view configuration container for the entity. * @return WP_View_Config_Data The updated view configuration container. */ -function _wp_get_entity_view_config_post_type_wp_template_part( $data ) { +function _wp_get_entity_view_config_posttype_wp_template_part( $data ) { $default_layouts = array( 'table' => array( 'layout' => array( @@ -524,7 +547,7 @@ function _wp_get_entity_view_config_post_type_wp_template_part( $data ) { * @param WP_View_Config_Data $data The view configuration container for the entity. * @return WP_View_Config_Data The updated view configuration container. */ -function _wp_get_entity_view_config_post_type_wp_template( $data ) { +function _wp_get_entity_view_config_posttype_wp_template( $data ) { $default_view = array( 'type' => 'grid', 'perPage' => 20, diff --git a/src/wp-trackback.php b/src/wp-trackback.php index 76c2a1d4a7285..c666c13e8b823 100644 --- a/src/wp-trackback.php +++ b/src/wp-trackback.php @@ -50,7 +50,7 @@ function trackback_response( $error = 0, $error_message = '' ) { if ( ! isset( $_GET['tb_id'] ) || ! $_GET['tb_id'] ) { $post_id = explode( '/', $_SERVER['REQUEST_URI'] ); - $post_id = (int) $post_id[ count( $post_id ) - 1 ]; + $post_id = (int) array_last( $post_id ); } $trackback_url = isset( $_POST['url'] ) ? sanitize_url( $_POST['url'] ) : ''; diff --git a/tests/e2e/specs/dashboard.test.js b/tests/e2e/specs/dashboard.test.js index 9d290e58a9d50..0481d306f2e0b 100644 --- a/tests/e2e/specs/dashboard.test.js +++ b/tests/e2e/specs/dashboard.test.js @@ -149,11 +149,9 @@ test.describe( 'Quick Draft', () => { await saveDraftButton.click(); // Check that the new draft title appears in the 'Your Recent Drafts' section. - // This test relies on Twenty Twenty-One being the active theme. - // Twenty Twenty-One alters the default post title from "(no title)" to "Untitled". await expect( page.locator( '.drafts .draft-title' ).first().getByRole( 'link' ) - ).toHaveText( 'Untitled' ); + ).toHaveText( '(no title)' ); await expect( page.locator( '.drafts .draft-content' ).first() @@ -164,6 +162,6 @@ test.describe( 'Quick Draft', () => { await expect( page.locator( '.type-post.status-draft .title' ).first() - ).toContainText( 'Untitled' ); + ).toContainText( '(no title)' ); } ); } ); diff --git a/tests/phpstan/ApplyFiltersDynamicFunctionReturnTypeExtension.php b/tests/phpstan/ApplyFiltersDynamicFunctionReturnTypeExtension.php new file mode 100644 index 0000000000000..4d708aa7757df --- /dev/null +++ b/tests/phpstan/ApplyFiltersDynamicFunctionReturnTypeExtension.php @@ -0,0 +1,95 @@ +hookDocBlock = $hook_doc_block; + } + + /** + * Determines whether this extension applies to the given function. + * + * @param FunctionReflection $functionReflection Function being analyzed. + * @return bool + */ + public function isFunctionSupported( FunctionReflection $functionReflection ): bool { + return in_array( $functionReflection->getName(), HookDocBlock::FILTER_FUNCTIONS, true ); + } + + /** + * Resolves the return type of the filter call from its preceding docblock. + * + * @link https://developer.wordpress.org/reference/functions/apply_filters/ + * @link https://developer.wordpress.org/reference/functions/apply_filters_deprecated/ + * @link https://developer.wordpress.org/reference/functions/apply_filters_ref_array/ + * + * @param FunctionReflection $functionReflection Function being analyzed. + * @param FuncCall $functionCall The function call node. + * @param Scope $scope Analysis scope. + * @return Type + * @throws ShouldNotHappenException + */ + public function getTypeFromFunctionCall( FunctionReflection $functionReflection, FuncCall $functionCall, Scope $scope ): Type { + $default = new MixedType(); + $resolved_php_doc = $this->hookDocBlock->getNullableHookDocBlock( $functionCall, $scope ); + + if ( null === $resolved_php_doc ) { + return $default; + } + + // The first `@param` describes the value being filtered. + $params = $resolved_php_doc->getParamTags(); + $param = reset( $params ); + + return false === $param ? $default : $param->getType(); + } +} diff --git a/tests/phpstan/HookDocBlock.php b/tests/phpstan/HookDocBlock.php new file mode 100644 index 0000000000000..6d7cca2f69f11 --- /dev/null +++ b/tests/phpstan/HookDocBlock.php @@ -0,0 +1,946 @@ +, + * patterns: list, + * } + * @phpstan-type HookNameMatcher array{ + * kind: 'literal'|'pattern', + * value: string, + * literal: string, + * } + * @phpstan-type HookDocumentationProblem array{ + * path: non-empty-string, + * hook: string, + * problem: self::PROBLEM_FILE_MISSING|self::PROBLEM_HOOK_MISSING, + * } + * @phpstan-type HookDocumentation array{ + * kind: 'inline'|'reference', + * resolved: ResolvedPhpDocBlock|null, + * paramCount: int<0, max>|null, + * problem: HookDocumentationProblem|null, + * } + */ +class HookDocBlock { + + /** + * Hook functions that carry a documenting docblock for their first argument. + */ + public const HOOK_FUNCTIONS = array( + 'apply_filters', + 'apply_filters_deprecated', + 'apply_filters_ref_array', + 'do_action', + 'do_action_deprecated', + 'do_action_ref_array', + ); + + /** + * Hook functions that filter a value, and so always pass at least one argument. + */ + public const FILTER_FUNCTIONS = array( + 'apply_filters', + 'apply_filters_deprecated', + 'apply_filters_ref_array', + ); + + /** + * Directories, relative to the WordPress root, scanned for reference comments + * when hashing the docblocks that call sites can inherit. + * + * @see HookDocBlock::getScannedFiles() + */ + private const SCANNED_DIRECTORIES = array( + 'wp-admin', + 'wp-includes', + 'wp-content/themes', + ); + + /** + * Problem code: the referenced file does not exist. + */ + public const PROBLEM_FILE_MISSING = 'fileMissing'; + + /** + * Problem code: the hook is not documented in the referenced file. + */ + public const PROBLEM_HOOK_MISSING = 'hookMissing'; + + /** + * Pattern matching WordPress core's "documented elsewhere" reference comment. + * Captures the referenced root-relative file path. + */ + private const REFERENCE_PATTERN = '#This (?:filter|action) is documented in (\S+)#'; + + /** + * File type mapper used to resolve docblocks in scope. + * + * @var FileTypeMapper + */ + protected FileTypeMapper $fileTypeMapper; + + /** + * In-memory cache of parsed hook documentation, keyed by absolute file path. + * + * @var array + */ + private array $fileHookDocs = array(); + + /** + * Absolute path to the WordPress root that reference comment paths resolve against. + * + * @var string + */ + private string $wordpressRoot; + + /** + * Canonical form of the WordPress root, with symlinks and dot segments resolved, + * against which a candidate path is tested for being inside the tree. + * + * @var string + */ + private string $canonicalWordpressRoot; + + /** + * Constructor. + * + * @param FileTypeMapper $file_type_mapper File type mapper. + * @param string|null $wordpress_root Absolute path to the WordPress root that + * "documented in " paths are relative to. + * Defaults to the `src` directory of this checkout. + */ + public function __construct( FileTypeMapper $file_type_mapper, ?string $wordpress_root = null ) { + $this->fileTypeMapper = $file_type_mapper; + $this->wordpressRoot = rtrim( $wordpress_root ?? dirname( __DIR__, 2 ) . '/src', '/' ); + + $canonical_root = realpath( $this->wordpressRoot ); + $this->canonicalWordpressRoot = false === $canonical_root ? $this->wordpressRoot : $canonical_root; + } + + /** + * Returns a hash of every hook docblock that a call site can inherit through a + * "documented elsewhere" reference comment. + * + * Those docblocks are read with plain file I/O, so PHPStan's dependency graph + * does not know that the referencing files depend on them: editing a canonical + * docblock re-analyzes only the file it lives in, leaving the cached results of + * every referencing file in place. Folding this hash into the result cache key + * invalidates the cache when an inheritable docblock changes, and only then. + * + * @see HookDocsResultCacheMetaExtension + * + * @return non-falsy-string + */ + public function getReferencedHookDocsHash(): string { + $docs = array(); + + foreach ( $this->getScannedFiles() as $file ) { + $code = file_get_contents( $file ); + + if ( false === $code || ! str_contains( $code, 'is documented in' ) ) { + continue; + } + + if ( ! preg_match_all( self::REFERENCE_PATTERN, $code, $matches ) ) { + continue; + } + + foreach ( $matches[1] as $reference_path ) { + $target = $this->resolveReferencePath( $file, $reference_path ); + + if ( null === $target ) { + continue; + } + + // Key on the path relative to the WordPress root so the hash does not + // depend on where the checkout lives. + $key = $this->getRootRelativePath( $target ); + + if ( ! isset( $docs[ $key ] ) ) { + $docs[ $key ] = $this->getHookDocs( $target ); + } + } + } + + ksort( $docs ); + + return md5( (string) json_encode( $docs ) ); + } + + /** + * Returns the files scanned for reference comments: the WordPress directories + * that can contain them, plus the PHP files at the root of the install. + * + * `wp-content/plugins` is deliberately not scanned. A checkout may have plugins + * carrying large `vendor` and `node_modules` trees, and core's reference comments + * only ever point within core. + * + * @return list Absolute file paths. + */ + private function getScannedFiles(): array { + $files = array(); + + foreach ( self::SCANNED_DIRECTORIES as $directory ) { + $path = $this->wordpressRoot . '/' . $directory; + + if ( ! is_dir( $path ) ) { + continue; + } + + $iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator( $path, FilesystemIterator::SKIP_DOTS ) + ); + + foreach ( $iterator as $file ) { + if ( $file instanceof SplFileInfo && $file->isFile() && 'php' === strtolower( $file->getExtension() ) ) { + $files[] = $file->getPathname(); + } + } + } + + $root_files = glob( $this->wordpressRoot . '/*.php' ); + + if ( is_array( $root_files ) ) { + $files = array_merge( $files, $root_files ); + } + + return $files; + } + + /** + * Expresses an absolute path relative to the WordPress root when it sits inside + * it, so that hashes do not depend on the checkout location. + * + * Paths reached through a reference comment are canonical, so the canonical root is + * what they are relative to. + * + * @param string $path Absolute path. + * @return string + */ + private function getRootRelativePath( string $path ): string { + $prefix = $this->canonicalWordpressRoot . '/'; + + return str_starts_with( $path, $prefix ) ? substr( $path, strlen( $prefix ) ) : $path; + } + + /** + * Resolves the documentation for a hook call: the docblock written above it, or + * the canonical docblock a "documented elsewhere" comment points at. + * + * This is the one entry point the rules and the return type extension share, so + * all three necessarily agree on what documents a given call. + * + * A reference that cannot be resolved to a canonical docblock leaves `resolved` + * and `paramCount` null rather than falling back to the reference comment itself, + * so an unresolved reference is never mistaken for a hook documented with no + * parameters. `problem` says why it could not be resolved, when the reason is one + * worth reporting. + * + * @param FuncCall $function_call Hook function call node. + * @param Scope $scope Analysis scope. + * @return HookDocumentation|null Null when no docblock precedes the call. + * @throws ShouldNotHappenException + */ + public function getHookDoc( FuncCall $function_call, Scope $scope ): ?array { + $comment = self::getNullableNodeComment( $function_call ); + + if ( null === $comment ) { + return null; + } + + $text = $comment->getText(); + + // A docblock written at the call site documents the hook in place. + if ( ! preg_match( self::REFERENCE_PATTERN, $text, $matches ) ) { + $resolved = $this->resolveInlineDocBlock( $text, $scope ); + + return array( + 'kind' => 'inline', + 'resolved' => $resolved, + 'paramCount' => self::countParamTags( $resolved ), + 'problem' => null, + ); + } + + $hook_doc = array( + 'kind' => 'reference', + 'resolved' => null, + 'paramCount' => null, + 'problem' => null, + ); + + // Without an identifiable hook name there is nothing to look up in the + // referenced file, and so nothing to report either. + $matcher = self::getHookNameMatcher( $function_call ); + if ( null === $matcher ) { + return $hook_doc; + } + + $reference_path = $matches[1]; + $target_file = $this->resolveReferencePath( $scope->getFile(), $reference_path ); + + // The referenced file could not be located up the directory tree. + if ( null === $target_file ) { + $hook_doc['problem'] = array( + 'path' => $reference_path, + 'hook' => self::getHookNameDisplay( $function_call ), + 'problem' => self::PROBLEM_FILE_MISSING, + ); + + return $hook_doc; + } + + $doc_text = $this->findHookDoc( $target_file, $matcher ); + + if ( null === $doc_text ) { + $hook_doc['problem'] = array( + 'path' => $reference_path, + 'hook' => self::getHookNameDisplay( $function_call ), + 'problem' => self::PROBLEM_HOOK_MISSING, + ); + + return $hook_doc; + } + + // Resolve the canonical docblock in the global namespace, with no file + // context. Hook docblocks describe global/plain types (e.g. string[], + // WP_REST_Response), so the referenced file's `use` imports are not needed. + // Passing the referenced file here would also re-enter PHPStan's name-scope + // builder while that file is itself being analyzed, which makes + // getResolvedPhpDoc return an empty docblock (NameScopeAlreadyBeingCreated). + $resolved = $this->fileTypeMapper->getResolvedPhpDoc( null, null, null, null, $doc_text ); + + $hook_doc['resolved'] = $resolved; + $hook_doc['paramCount'] = self::countParamTags( $resolved ); + + return $hook_doc; + } + + /** + * Resolves the docblock preceding the given function call, if any. + * + * @param FuncCall $function_call Hook function call node. + * @param Scope $scope Analysis scope. + * @return ResolvedPhpDocBlock|null Resolved docblock, or null when none precedes + * the call or a reference cannot be resolved. + * @throws ShouldNotHappenException + */ + public function getNullableHookDocBlock( FuncCall $function_call, Scope $scope ): ?ResolvedPhpDocBlock { + $hook_doc = $this->getHookDoc( $function_call, $scope ); + + return null === $hook_doc ? null : $hook_doc['resolved']; + } + + /** + * Resolves a docblock written at a call site, in the scope of that site. + * + * @param string $text Docblock text. + * @param Scope $scope Analysis scope. + * @return ResolvedPhpDocBlock + * @throws ShouldNotHappenException + */ + private function resolveInlineDocBlock( string $text, Scope $scope ): ResolvedPhpDocBlock { + $class_reflection = $scope->getClassReflection(); + $trait_reflection = $scope->getTraitReflection(); + + return $this->fileTypeMapper->getResolvedPhpDoc( + $scope->getFile(), + ( $scope->isInClass() && null !== $class_reflection ) ? $class_reflection->getName() : null, + ( $scope->isInTrait() && null !== $trait_reflection ) ? $trait_reflection->getName() : null, + $scope->getFunctionName(), + $text + ); + } + + /** + * Counts the `@param` tags a resolved docblock declares. + * + * ResolvedPhpDocBlock::getParamTags() is keyed by parameter name, so two tags + * documenting the same name — a copy-and-paste slip — collapse into a single + * entry. That undercounts, which both reports a hook passing the documented + * number of arguments as a mismatch and hides a hook that genuinely passes too + * few. The parsed docblock nodes list every tag, so they are counted instead. + * + * Tags PHPStan cannot parse as a `@param` — one missing its variable name, say — + * are still left out, so a malformed tag continues to surface rather than passing + * for documentation of a parameter. + * + * @param ResolvedPhpDocBlock $resolved_php_doc Resolved docblock. + * @return int<0, max> + */ + private static function countParamTags( ResolvedPhpDocBlock $resolved_php_doc ): int { + $count = 0; + + foreach ( $resolved_php_doc->getPhpDocNodes() as $php_doc_node ) { + $count += count( $php_doc_node->getParamTagValues() ); + } + + return $count; + } + + /** + * Determines whether a filter call resolves to a docblock that documents no + * parameters. + * + * A filter always passes at least the value being filtered, so such a docblock + * does not document the hook. It is either hook documentation with its `@param` + * tags missing, or an unrelated annotation — typically a `@var` block — that + * happens to sit immediately above the call. + * + * This holds wherever the docblock was found, so no argument count is worth + * comparing against it. HookDocumentationRule reports it only for a docblock + * written at the call itself: a hook documented elsewhere is fixed where its + * canonical docblock lives, rather than once per site inheriting it. + * + * @param FuncCall $function_call Hook function call node. + * @param HookDocumentation $hook_doc Documentation resolved for the call. + * @return bool + */ + public static function isFilterMissingParamDocs( FuncCall $function_call, array $hook_doc ): bool { + if ( 0 !== $hook_doc['paramCount'] ) { + return false; + } + + return $function_call->name instanceof Name + && in_array( $function_call->name->toString(), self::FILTER_FUNCTIONS, true ); + } + + /** + * Determines whether a hook call's name can be identified well enough to + * require or locate documentation. + * + * Calls whose hook name carries no literal text (e.g. the generic + * `apply_filters_ref_array( $hook_name, $args )` forwarders in plugin.php) + * cannot be meaningfully documented at the call site and are excluded. + * + * @param FuncCall $function_call Hook function call node. + * @return bool + */ + public static function hasIdentifiableHookName( FuncCall $function_call ): bool { + $args = $function_call->getArgs(); + if ( ! isset( $args[0] ) ) { + return false; + } + + $value = $args[0]->value; + if ( $value instanceof String_ ) { + return true; + } + + return null !== self::buildHookNamePattern( $value ); + } + + /** + * Returns the canonical docblock text for a hook documented in the given file. + * + * @param string $file Absolute path to the file declaring the hook. + * @param HookNameMatcher $matcher Hook name matcher from getHookNameMatcher(). + * @return string|null Docblock text, or null when no documented invocation is found. + */ + private function findHookDoc( string $file, array $matcher ): ?string { + $docs = $this->getHookDocs( $file ); + + if ( 'literal' === $matcher['kind'] ) { + $name = $matcher['value']; + + if ( isset( $docs['exact'][ $name ] ) ) { + return $docs['exact'][ $name ]; + } + + // A literal name may be an instance of a dynamic canonical hook + // (e.g. "index_template_hierarchy" matching "{$type}_template_hierarchy"). + // The most specifically anchored match wins, so a name is not attributed + // to a loosely anchored hook that merely happens to match it as well. + $best = null; + $anchor_len = -1; + foreach ( $docs['patterns'] as $pattern ) { + $literal_len = strlen( $pattern['literal'] ); + + if ( $literal_len <= $anchor_len || ! self::isAnchorableLiteral( $pattern['literal'] ) ) { + continue; + } + + if ( preg_match( $pattern['regex'], $name ) ) { + $best = $pattern['text']; + $anchor_len = $literal_len; + } + } + + return $best; + } + + // A dynamic referencing name matches the same dynamic canonical (identical + // regex), or a literal canonical the pattern covers. + $regex = $matcher['value']; + + foreach ( $docs['patterns'] as $pattern ) { + if ( $pattern['regex'] === $regex ) { + return $pattern['text']; + } + } + + // Covering a literal canonical is only meaningful for a pattern anchored + // specifically enough to identify a hook. + if ( ! self::isAnchorableLiteral( $matcher['literal'] ) ) { + return null; + } + + foreach ( $docs['exact'] as $name => $text ) { + if ( preg_match( $regex, $name ) ) { + return $text; + } + } + + return null; + } + + /** + * Returns the hook documentation declared by a file, parsing each file at most + * once per process. + * + * @param string $file Absolute path to the file. + * @return HookDocs + */ + private function getHookDocs( string $file ): array { + if ( ! isset( $this->fileHookDocs[ $file ] ) ) { + $this->fileHookDocs[ $file ] = self::loadHookDocs( $file ); + } + + return $this->fileHookDocs[ $file ]; + } + + /** + * Reads and parses the hook documentation declared by a file. + * + * @param string $file Absolute path to the file. + * @return HookDocs + */ + private static function loadHookDocs( string $file ): array { + $empty = array( + 'exact' => array(), + 'patterns' => array(), + ); + + if ( ! is_file( $file ) || ! is_readable( $file ) ) { + return $empty; + } + + $code = file_get_contents( $file ); + if ( false === $code ) { + return $empty; + } + + return self::parseHookDocs( $code ); + } + + /** + * Collects the canonical docblock text for each hook invocation documented in + * the given PHP source. + * + * A docblock is treated as canonical when it is not itself a "documented + * elsewhere" reference, so referencing call sites do not count as the source + * of documentation. Hooks with a literal name are indexed exactly; hooks with + * a dynamic name that contains literal text are indexed as a regex, alongside + * that literal text, which findHookDoc() uses to rank how specifically a pattern + * identifies a hook. + * + * @see HookDocBlock::findHookDoc() + * + * @param string $code PHP source code. + * @return HookDocs + */ + private static function parseHookDocs( string $code ): array { + $docs = array( + 'exact' => array(), + 'patterns' => array(), + ); + + // Source that cannot be parsed documents nothing this can read, and must not stop + // the analysis: a file is temporarily incomplete while it is being edited, and may + // use syntax the host PHP version does not know. Collecting the parse errors + // rather than throwing keeps the hooks documented ahead of the error wherever the + // parser can recover, and yields none where it cannot. + $parser = ( new ParserFactory() )->createForHostVersion(); + + try { + $stmts = $parser->parse( $code, new Collecting() ); + } catch ( PhpParserError $parse_error ) { + return $docs; + } + + if ( null === $stmts ) { + return $docs; + } + + // Propagate each docblock down to the nested hook-call node. + $traverser = new NodeTraverser(); + $traverser->addVisitor( new HookDocsVisitor() ); + $stmts = $traverser->traverse( $stmts ); + + $seen = array(); + $calls = ( new NodeFinder() )->findInstanceOf( $stmts, FuncCall::class ); + foreach ( $calls as $call ) { + if ( ! $call instanceof FuncCall || ! $call->name instanceof Name ) { + continue; + } + + if ( ! in_array( $call->name->toString(), self::HOOK_FUNCTIONS, true ) ) { + continue; + } + + $args = $call->getArgs(); + if ( ! isset( $args[0] ) ) { + continue; + } + + $doc = $call->getAttribute( 'latestDocComment' ); + + // Skip reference comments so only the canonical documentation counts. + if ( ! $doc instanceof Doc || preg_match( self::REFERENCE_PATTERN, $doc->getText() ) ) { + continue; + } + + $name_expr = $args[0]->value; + + if ( $name_expr instanceof String_ ) { + if ( ! isset( $docs['exact'][ $name_expr->value ] ) ) { + $docs['exact'][ $name_expr->value ] = $doc->getText(); + } + continue; + } + + $pattern = self::buildHookNamePattern( $name_expr ); + if ( null !== $pattern && ! isset( $seen[ $pattern['regex'] ] ) ) { + $seen[ $pattern['regex'] ] = true; + $docs['patterns'][] = array( + 'regex' => $pattern['regex'], + 'literal' => $pattern['literal'], + 'text' => $doc->getText(), + ); + } + } + + return $docs; + } + + /** + * Builds an anchored regex matching a dynamic hook name expression, together + * with the literal text it is anchored on, or null when the expression carries + * no literal text at all. + * + * @param Expr $expr Hook name expression. + * @return array{ + * regex: non-falsy-string, + * literal: non-empty-string, + * }|null + */ + private static function buildHookNamePattern( Expr $expr ): ?array { + $parts = self::hookNameRegexParts( $expr ); + if ( null === $parts || '' === $parts[1] ) { + return null; + } + + return array( + 'regex' => '#^' . $parts[0] . '$#', + 'literal' => $parts[1], + ); + } + + /** + * Determines whether the literal text of a dynamic hook name identifies a hook + * specifically enough to resolve documentation through it. + * + * A name whose literal text is nothing but separators — e.g. taxonomy.php's + * `"{$taxonomy}_{$field}"`, which becomes `#^.+_.+$#` — matches almost any hook + * name, so honoring it would attribute a call to an unrelated hook's + * documentation and hide a genuinely broken reference comment. The hook is still + * required to be documented at its own call site; only its use as the + * documentation *source* for a differently named hook is refused. + * + * @param string $literal Concatenated literal text of a hook name expression. + * @return bool + */ + private static function isAnchorableLiteral( string $literal ): bool { + return '' !== trim( $literal, "-_ \t\n\r\0\x0B" ); + } + + /** + * Recursively converts a hook name expression into a regex fragment and the + * literal text that fragment is anchored on. + * + * @param Expr $expr Hook name expression. + * @return array{ + * 0: string, + * 1: string, + * }|null Fragment and its literal text, or null if unsupported. + */ + private static function hookNameRegexParts( Expr $expr ): ?array { + if ( $expr instanceof String_ ) { + return array( preg_quote( $expr->value, '#' ), $expr->value ); + } + + if ( $expr instanceof Concat ) { + $left = self::hookNameRegexParts( $expr->left ); + $right = self::hookNameRegexParts( $expr->right ); + if ( null === $left || null === $right ) { + return null; + } + return array( $left[0] . $right[0], $left[1] . $right[1] ); + } + + if ( $expr instanceof InterpolatedString ) { + $fragment = ''; + $literal = ''; + foreach ( $expr->parts as $part ) { + if ( $part instanceof InterpolatedStringPart ) { + $fragment .= preg_quote( $part->value, '#' ); + $literal .= $part->value; + } else { + $fragment .= '.+'; + } + } + return array( $fragment, $literal ); + } + + // Variables, property fetches, etc.: a wildcard with no literal anchor. + return array( '.+', '' ); + } + + /** + * Resolves a WordPress-root-relative reference path against the file + * containing the reference comment. + * + * The reference comment names the exact file (e.g. "wp-includes/media.php"), so + * resolution proceeds in two steps: + * + * 1. Walk up from the current file's directory, as far as the WordPress root, + * until the relative path resolves to a real file inside the tree. This works + * regardless of where in the tree the referencing file lives (core, a bundled + * theme, the install root, ...), and also resolves the sibling references used + * by the bundled themes (e.g. "author.php"). The walk stops at the root because + * a path that only resolves above the tree under analysis is a coincidence + * rather than the file the comment names. + * 2. Fall back to the WordPress root. Step 1 assumes the analysed file sits in + * its real location, which does not hold when an IDE runs PHPStan against a + * temporary copy of the editor buffer. Without this fallback, every + * reference comment in such a copy is reported as naming a missing file. + * + * Only the single named file is ever tested; no directory is enumerated. + * + * @param string $current_file Absolute path to the file with the reference comment. + * @param string $reference_path Root-relative path (e.g. "wp-includes/media.php"). + * @return string|null Absolute path to the referenced file, or null when it cannot be located. + */ + private function resolveReferencePath( string $current_file, string $reference_path ): ?string { + $reference_path = ltrim( $reference_path, '/' ); + $dir = dirname( $current_file ); + + while ( $dir === $this->wordpressRoot || str_starts_with( $dir, $this->wordpressRoot . '/' ) ) { + $target = $this->resolveWithinRoot( $dir . '/' . $reference_path ); + if ( null !== $target ) { + return $target; + } + + // The root has just been tested, so the walk is done. + if ( $dir === $this->wordpressRoot ) { + return null; + } + + $dir = dirname( $dir ); + } + + // The file holding the comment is not in the tree, so resolve against the root. + return $this->resolveWithinRoot( $this->wordpressRoot . '/' . $reference_path ); + } + + /** + * Canonicalizes a candidate path, accepting it only when it is a file inside the + * WordPress tree. + * + * A reference path is usually a plain relative path, but the convention is also + * written with dot segments relative to the file holding the comment: WooCommerce + * references `../wc-user-functions.php` and MainWP `../widgets/…`, so those have to + * keep resolving. WordPress core has never used that form, which is exactly why + * rejecting dot segments outright would look harmless here and break those plugins. + * + * Canonicalizing the candidate and requiring the result to be inside the tree keeps + * them working while ensuring a reference cannot reach a file outside it. That + * matters because whatever resolution finds is then read and parsed. + * + * @param string $candidate Absolute candidate path, possibly containing dot segments. + * @return string|null Canonical path, or null when it is not a file inside the tree. + */ + private function resolveWithinRoot( string $candidate ): ?string { + if ( ! is_file( $candidate ) ) { + return null; + } + + $canonical = realpath( $candidate ); + + if ( false === $canonical ) { + return null; + } + + return str_starts_with( $canonical, $this->canonicalWordpressRoot . '/' ) ? $canonical : null; + } + + /** + * Returns a matcher describing a hook call's name: a literal string to look up + * exactly, or a regex for a dynamic name (e.g. "{$type}_template_hierarchy"). + * + * @param FuncCall $call Hook function call node. + * @return HookNameMatcher|null Null when the name carries no identifiable text + * (e.g. a bare variable). + */ + private static function getHookNameMatcher( FuncCall $call ): ?array { + $args = $call->getArgs(); + if ( ! isset( $args[0] ) ) { + return null; + } + + $expr = $args[0]->value; + + if ( $expr instanceof String_ ) { + return array( + 'kind' => 'literal', + 'value' => $expr->value, + 'literal' => $expr->value, + ); + } + + $pattern = self::buildHookNamePattern( $expr ); + if ( null !== $pattern ) { + return array( + 'kind' => 'pattern', + 'value' => $pattern['regex'], + 'literal' => $pattern['literal'], + ); + } + + return null; + } + + /** + * Renders a hook name expression to a readable string for diagnostics, e.g. + * "default_option_{$option}". + * + * @param FuncCall $call Hook function call node. + * @return string + */ + public static function getHookNameDisplay( FuncCall $call ): string { + $args = $call->getArgs(); + if ( ! isset( $args[0] ) ) { + return ''; + } + + return self::renderHookName( $args[0]->value ); + } + + /** + * Recursively renders a hook name expression to a readable string. + * + * @param Expr $expr Hook name expression. + * @return string + */ + private static function renderHookName( Expr $expr ): string { + if ( $expr instanceof String_ ) { + return $expr->value; + } + + if ( $expr instanceof Concat ) { + return self::renderHookName( $expr->left ) . self::renderHookName( $expr->right ); + } + + if ( $expr instanceof InterpolatedString ) { + $out = ''; + foreach ( $expr->parts as $part ) { + if ( $part instanceof InterpolatedStringPart ) { + $out .= $part->value; + } elseif ( $part instanceof Variable && is_string( $part->name ) ) { + $out .= '{$' . $part->name . '}'; + } else { + $out .= '{...}'; + } + } + return $out; + } + + if ( $expr instanceof Variable && is_string( $expr->name ) ) { + return '$' . $expr->name; + } + + return '...'; + } + + /** + * Returns the docblock attached to the node by HookDocsVisitor, if present. + * + * @param FuncCall $node Function call node. + * @return Doc|null + */ + private static function getNullableNodeComment( FuncCall $node ): ?Doc { + /** @var Doc|null $doc */ + $doc = $node->getAttribute( 'latestDocComment' ); + return $doc; + } +} diff --git a/tests/phpstan/HookDocsResultCacheMetaExtension.php b/tests/phpstan/HookDocsResultCacheMetaExtension.php new file mode 100644 index 0000000000000..ff7b3a9fa068b --- /dev/null +++ b/tests/phpstan/HookDocsResultCacheMetaExtension.php @@ -0,0 +1,99 @@ + *\/` convention and read with plain + * file I/O. A referencing file has no symbol dependency on its reference target, + * so editing a canonical docblock re-analyzes only the file that docblock lives + * in; every call site inheriting it keeps its cached result. + * 2. The tooling's own source files. PHPStan hashes its configuration, but changing + * a rule's logic does not invalidate results that rule already produced. + * + * Both are folded into the result cache key here, so cached results are discarded + * when — and only when — an inheritable docblock or the tooling itself changes. + * + * @see HookDocBlock::getReferencedHookDocsHash() + * + * @package WordPress + */ + +declare(strict_types=1); + +namespace WordPress\PHPStan; + +use PHPStan\Analyser\ResultCache\ResultCacheMetaExtension; + +/** + * Invalidates the result cache when hook documentation read from another file, or + * the tooling reading it, changes. + */ +final class HookDocsResultCacheMetaExtension implements ResultCacheMetaExtension { + + /** + * Hook docblock resolver. + * + * @var HookDocBlock + */ + private HookDocBlock $hookDocBlock; + + /** + * Constructor. + * + * @param HookDocBlock $hook_doc_block Hook docblock resolver. + */ + public function __construct( HookDocBlock $hook_doc_block ) { + $this->hookDocBlock = $hook_doc_block; + } + + /** + * Returns the key identifying this metadata source. + * + * @return non-empty-string + */ + public function getKey(): string { + return 'wordpressHookDocs'; + } + + /** + * Returns a hash of the inheritable hook documentation and of the tooling that + * reads it. + * + * @return non-falsy-string + */ + public function getHash(): string { + return md5( self::getToolingHash() . '|' . $this->hookDocBlock->getReferencedHookDocsHash() ); + } + + /** + * Hashes the PHPStan extension sources in this directory. + * + * @return non-falsy-string + */ + private static function getToolingHash(): string { + $files = glob( __DIR__ . '/*.php' ); + + if ( ! is_array( $files ) ) { + $files = array(); + } + + sort( $files ); + + $parts = array(); + + foreach ( $files as $file ) { + $contents = file_get_contents( $file ); + + if ( false === $contents ) { + continue; + } + + $parts[] = basename( $file ) . ':' . md5( $contents ); + } + + return md5( implode( '|', $parts ) ); + } +} diff --git a/tests/phpstan/HookDocsVisitor.php b/tests/phpstan/HookDocsVisitor.php new file mode 100644 index 0000000000000..a025c1127f402 --- /dev/null +++ b/tests/phpstan/HookDocsVisitor.php @@ -0,0 +1,122 @@ + + */ + private array $stack = array(); + + /** + * Resets state before traversing a new set of nodes. + * + * @param Node[] $nodes Nodes about to be traversed. + * @return Node[]|null + */ + public function beforeTraverse( array $nodes ): ?array { + $this->latestDocComment = null; + $this->stack = array(); + + return null; + } + + /** + * Tracks the applicable docblock and attaches it to function-call nodes. + * + * @param Node $node Node being entered. + * @return Node|null + */ + public function enterNode( Node $node ): ?Node { + $doc = $node->getDocComment(); + + if ( null !== $doc ) { + // A docblock here documents this node and everything nested within it. + $this->stack[] = array( $node, $this->latestDocComment ); + $this->latestDocComment = $doc; + } elseif ( $node instanceof Stmt ) { + // A new statement without its own docblock starts an undocumented scope + // for its subtree, so a preceding docblock does not carry into it. + $this->stack[] = array( $node, $this->latestDocComment ); + $this->latestDocComment = null; + } + + // Attributes are retained for as long as a parsed file is held in memory, so + // the docblock is recorded only where it can be read: on a function call, and + // only when there is one to record. Readers cannot tell an absent attribute + // from a null one, so skipping the write costs them nothing. + if ( null !== $this->latestDocComment && $node instanceof FuncCall ) { + $node->setAttribute( 'latestDocComment', $this->latestDocComment ); + } + + return null; + } + + /** + * Restores the docblock that applied before this node was entered, bounding a + * docblock's reach to the node that introduced it. + * + * @param Node $node Node being left. + * @return Node|null + */ + public function leaveNode( Node $node ): ?Node { + $top = end( $this->stack ); + + if ( false !== $top && $top[0] === $node ) { + $this->latestDocComment = $top[1]; + array_pop( $this->stack ); + } + + return null; + } +} diff --git a/tests/phpstan/HookDocumentationRule.php b/tests/phpstan/HookDocumentationRule.php new file mode 100644 index 0000000000000..7eead6a371321 --- /dev/null +++ b/tests/phpstan/HookDocumentationRule.php @@ -0,0 +1,166 @@ + *\/` reference comment. + * + * When a reference comment is used, the referenced file must exist and must + * actually document a hook of the same name; otherwise an error is reported. + * + * @package WordPress + */ + +declare(strict_types=1); + +namespace WordPress\PHPStan; + +use PhpParser\Node; +use PhpParser\Node\Expr\FuncCall; +use PhpParser\Node\Name; +use PHPStan\Analyser\Scope; +use PHPStan\Rules\IdentifierRuleError; +use PHPStan\Rules\Rule; +use PHPStan\Rules\RuleErrorBuilder; +use PHPStan\ShouldNotHappenException; + +/** + * Reports undocumented hooks and broken "documented elsewhere" references. + * + * @implements Rule + */ +class HookDocumentationRule implements Rule { + + /** + * Hook docblock resolver. + * + * @var HookDocBlock + */ + private HookDocBlock $hookDocBlock; + + /** + * Constructor. + * + * @param HookDocBlock $hook_doc_block Hook docblock resolver. + */ + public function __construct( HookDocBlock $hook_doc_block ) { + $this->hookDocBlock = $hook_doc_block; + } + + /** + * Returns the node type this rule processes. + * + * @return string + */ + public function getNodeType(): string { + return FuncCall::class; + } + + /** + * Processes a function call node. + * + * @param Node $node Function call node. + * @param Scope $scope Analysis scope. + * @return list + * @throws ShouldNotHappenException + */ + public function processNode( Node $node, Scope $scope ): array { + if ( ! $node instanceof FuncCall || ! $node->name instanceof Name ) { + return array(); + } + + if ( ! in_array( $node->name->toString(), HookDocBlock::HOOK_FUNCTIONS, true ) ) { + return array(); + } + + // Skip calls whose hook name carries no literal text, i.e. a bare variable + // such as the generic apply_filters_ref_array( $hook_name, $args ) + // re-dispatch in plugin.php. There is no concrete hook to document or look + // up. Calls naming a hook literally (e.g. apply_filters_ref_array( 'the_posts', + // ... )) or dynamically with literal text (e.g. "{$type}_template_hierarchy") + // remain subject to the documentation requirement. + if ( ! HookDocBlock::hasIdentifiableHookName( $node ) ) { + return array(); + } + + $function_name = $node->name->toString(); + $hook_doc = $this->hookDocBlock->getHookDoc( $node, $scope ); + + // No preceding docblock at all: the hook is undocumented. + if ( null === $hook_doc ) { + return array( + RuleErrorBuilder::message( + sprintf( + '%s() call for hook "%s" is not preceded by a docblock documenting the hook, nor by a "This filter/action is documented in " reference comment.', + $function_name, + HookDocBlock::getHookNameDisplay( $node ) + ) + ) + ->identifier( 'wordpress.hookDocMissing' ) + ->line( $node->getStartLine() ) + ->build(), + ); + } + + // An inline docblock documents the hook in place, provided it describes the + // value being filtered. + if ( 'reference' !== $hook_doc['kind'] ) { + if ( ! HookDocBlock::isFilterMissingParamDocs( $node, $hook_doc ) ) { + return array(); + } + + return array( + RuleErrorBuilder::message( + sprintf( + '%s() call for hook "%s" is preceded by a docblock that documents no parameters. A filter is documented with a `@param` tag for the value being filtered, plus one for each further argument passed.', + $function_name, + HookDocBlock::getHookNameDisplay( $node ) + ) + ) + ->identifier( 'wordpress.hookDocNoParams' ) + ->line( $node->getStartLine() ) + ->build(), + ); + } + + // A reference comment must point at a file that documents this hook. + $problem = $hook_doc['problem']; + if ( null === $problem ) { + return array(); + } + + if ( HookDocBlock::PROBLEM_FILE_MISSING === $problem['problem'] ) { + return array( + RuleErrorBuilder::message( + sprintf( + '%s() call for hook "%s" references documentation in "%s", but no such file exists in the tree being analyzed.', + $function_name, + $problem['hook'], + $problem['path'] + ) + ) + ->identifier( 'wordpress.hookDocReferenceFileMissing' ) + ->line( $node->getStartLine() ) + ->build(), + ); + } + + return array( + RuleErrorBuilder::message( + sprintf( + '%s() call for hook "%s" references documentation in "%s", but no documented "%s" hook is found there.', + $function_name, + $problem['hook'], + $problem['path'], + $problem['hook'] + ) + ) + ->identifier( 'wordpress.hookDocReferenceHookMissing' ) + ->line( $node->getStartLine() ) + ->build(), + ); + } +} diff --git a/tests/phpstan/HookParamCountRule.php b/tests/phpstan/HookParamCountRule.php new file mode 100644 index 0000000000000..4cebe197a7af4 --- /dev/null +++ b/tests/phpstan/HookParamCountRule.php @@ -0,0 +1,218 @@ +" + * reference is checked against its canonical docblock. + * + * @package WordPress + */ + +declare(strict_types=1); + +namespace WordPress\PHPStan; + +use PhpParser\Node; +use PhpParser\Node\Expr\FuncCall; +use PhpParser\Node\Name; +use PHPStan\Analyser\Scope; +use PHPStan\Rules\IdentifierRuleError; +use PHPStan\Rules\Rule; +use PHPStan\Rules\RuleErrorBuilder; +use PHPStan\ShouldNotHappenException; +use PHPStan\Type\Constant\ConstantIntegerType; + +/** + * Reports hook invocations whose argument count does not match the number of + * documented parameters. + * + * @implements Rule + */ +class HookParamCountRule implements Rule { + + /** + * Hook functions that receive the hook arguments as variadic parameters. + */ + private const VARIADIC_FUNCTIONS = array( + 'apply_filters', + 'do_action', + ); + + /** + * Hook functions that receive the hook arguments as an array in their second + * parameter. + */ + private const ARRAY_ARG_FUNCTIONS = array( + 'apply_filters_ref_array', + 'apply_filters_deprecated', + 'do_action_ref_array', + 'do_action_deprecated', + ); + + /** + * Hook docblock resolver. + * + * @var HookDocBlock + */ + private HookDocBlock $hookDocBlock; + + /** + * Constructor. + * + * @param HookDocBlock $hook_doc_block Hook docblock resolver. + */ + public function __construct( HookDocBlock $hook_doc_block ) { + $this->hookDocBlock = $hook_doc_block; + } + + /** + * Returns the node type this rule processes. + * + * @return string + */ + public function getNodeType(): string { + return FuncCall::class; + } + + /** + * Processes a function call node. + * + * @param Node $node Function call node. + * @param Scope $scope Analysis scope. + * @return list + * @throws ShouldNotHappenException + */ + public function processNode( Node $node, Scope $scope ): array { + if ( ! $node instanceof FuncCall || ! $node->name instanceof Name ) { + return array(); + } + + $function_name = $node->name->toString(); + $is_variadic = in_array( $function_name, self::VARIADIC_FUNCTIONS, true ); + if ( ! $is_variadic && ! in_array( $function_name, self::ARRAY_ARG_FUNCTIONS, true ) ) { + return array(); + } + + // Without an identifiable hook name there is nothing to document or look up. + if ( ! HookDocBlock::hasIdentifiableHookName( $node ) ) { + return array(); + } + + // Only compare against documentation that actually resolves. Missing docs and + // unresolvable/broken references (reported by HookDocumentationRule) leave the + // documented count unknown, and are skipped rather than compared against a + // bogus zero count. + $hook_doc = $this->hookDocBlock->getHookDoc( $node, $scope ); + if ( null === $hook_doc || null === $hook_doc['paramCount'] ) { + return array(); + } + + // A filter whose docblock documents no parameters is not documented at all, + // which HookDocumentationRule reports. Comparing counts as well would report + // one defect twice. + if ( HookDocBlock::isFilterMissingParamDocs( $node, $hook_doc ) ) { + return array(); + } + + $documented = $hook_doc['paramCount']; + + $provided = $is_variadic + ? self::countVariadicArguments( $node, $scope ) + : self::countArrayArguments( $node, $scope ); + + // The provided count could not be determined statically; skip rather than + // guess (e.g. arguments spread from a variable of unknown size). + if ( null === $provided || $provided === $documented ) { + return array(); + } + + $hook_name = HookDocBlock::getHookNameDisplay( $node ); + + // An action documented without any `@param` tag reads better as a statement + // about its docblock than as a count of zero. + $message = 0 === $documented + ? sprintf( + '%s() for hook "%s" provides %d argument%s, but its docblock documents no parameters.', + $function_name, + $hook_name, + $provided, + 1 === $provided ? '' : 's' + ) + : sprintf( + '%s() for hook "%s" provides %d argument%s, but the hook is documented with %d parameter%s.', + $function_name, + $hook_name, + $provided, + 1 === $provided ? '' : 's', + $documented, + 1 === $documented ? '' : 's' + ); + + return array( + RuleErrorBuilder::message( $message ) + ->identifier( 'wordpress.hookParamCountMismatch' ) + ->line( $node->getStartLine() ) + ->build(), + ); + } + + /** + * Counts the arguments a variadic hook call passes after the hook name. + * + * @param FuncCall $node Hook function call node. + * @param Scope $scope Analysis scope. + * @return int|null Argument count, or null when it cannot be determined statically. + */ + private static function countVariadicArguments( FuncCall $node, Scope $scope ): ?int { + $args = $node->getArgs(); + $count = 0; + + // Skip index 0, the hook name. + for ( $i = 1, $len = count( $args ); $i < $len; $i++ ) { + $arg = $args[ $i ]; + + if ( $arg->unpack ) { + $size = $scope->getType( $arg->value )->getArraySize(); + if ( ! $size instanceof ConstantIntegerType ) { + return null; + } + $count += $size->getValue(); + continue; + } + + ++$count; + } + + return $count; + } + + /** + * Counts the arguments a hook call passes via its array argument. + * + * @param FuncCall $node Hook function call node. + * @param Scope $scope Analysis scope. + * @return int|null Argument count, or null when it cannot be determined statically. + */ + private static function countArrayArguments( FuncCall $node, Scope $scope ): ?int { + $args = $node->getArgs(); + if ( ! isset( $args[1] ) ) { + return null; + } + + $size = $scope->getType( $args[1]->value )->getArraySize(); + if ( ! $size instanceof ConstantIntegerType ) { + return null; + } + + return $size->getValue(); + } +} diff --git a/tests/phpstan/README.md b/tests/phpstan/README.md index 967c4a9d0195d..edf96fefdc093 100644 --- a/tests/phpstan/README.md +++ b/tests/phpstan/README.md @@ -39,6 +39,8 @@ composer run phpstan -- src/wp-includes/template.php composer run phpstan -- -vvv --debug ``` +Note the `--` in each of those. Composer needs it in order to pass the flags on to PHPStan rather than reading them as its own, and without it they are discarded silently. The npm script supplies it, which is why only one is needed there. + For available flags, see https://phpstan.org/user-guide/command-line-usage. ## The PHPStan configuration @@ -49,6 +51,38 @@ You can create a local copy at `phpstan.neon` to override the default configurat For more information about configuring PHPStan, see the [PHPStan documentation's Config reference](https://phpstan.org/config-reference). +## WordPress-specific extensions + +This directory also contains extensions that teach PHPStan conventions specific to WordPress. They are registered in [`base.neon`](base.neon), so they apply to the default configuration and to any local override of it. + +### Global variables in function docblocks + +Core documents the globals a function uses with `@global Type $varname`. `GlobalDocBlockVisitor` bridges that convention to PHPStan's variable type resolution, so those globals are typed rather than `mixed` inside the function. + +### Hook documentation + +The remaining extensions read the docblock documenting a hook where the hook is fired, which is where WordPress documents its hooks. They cover `apply_filters()`, `do_action()` and their `_deprecated` and `_ref_array` variants. + +- **The value a filter returns is typed from its documentation.** `apply_filters()` returns the type of the first `@param` its docblock documents, rather than `mixed`. This assumes callbacks honor the documented type; one that returns something else is treated as the unusual case. +- **Hooks documented elsewhere are resolved.** Core's `/** This filter is documented in */` convention is followed, in its action form as well, so a hook documented in another file is analyzed against its canonical docblock. A dynamic canonical name such as `"{$type}_template_hierarchy"` is matched against the literal name used at the referencing site. +- **Two rules check the documentation itself**: that a hook is documented at all, and that it is fired with as many arguments as its documentation describes. + +Calls whose hook name contains no literal text, such as the `apply_filters_ref_array( $hook_name, $args )` re-dispatch in `plugin.php`, name no concrete hook and are skipped. + +One consequence worth knowing: because a hook's documentation may live in a different file than the call inheriting it, editing a hook docblock in a file that reference comments point at discards PHPStan's result cache. Every call site inheriting that docblock has to be analyzed again, and PHPStan cannot infer that dependency on its own. + +### Errors these rules report + +These identifiers are specific to WordPress, and can be ignored or baselined like any other error, as described [below](#ignoring-and-baselining-errors). + +| Identifier | What it means | +| --- | --- | +| `wordpress.hookDocMissing` | The hook is fired with neither a docblock documenting it nor a reference comment. Document it, or point at wherever it is documented. | +| `wordpress.hookDocNoParams` | A filter's docblock documents no parameters. A filter always passes at least the value being filtered, so document that value with `@param`, plus one for each further argument. This also fires when an unrelated docblock, such as a `@var` annotation, happens to sit immediately above the call. | +| `wordpress.hookDocReferenceFileMissing` | A reference comment names a file that does not exist in the tree being analyzed. The path is resolved relative to the file holding the comment and to the WordPress root; one that resolves outside the tree counts as missing, since the analysis does not read it. | +| `wordpress.hookDocReferenceHookMissing` | The referenced file exists, but documents no hook of that name. Either the reference is stale, or the canonical docblock has moved. | +| `wordpress.hookParamCountMismatch` | The call passes a different number of arguments than the docblock documents `@param` tags for. Passing fewer risks an `ArgumentCountError` in a callback registered for the documented count; passing more silently drops the extra argument and leaves the documentation misleading. | + ## Ignoring and baselining errors As we adopt PHPStan iteratively, you may be faced with false positives due to legacy code, or code that is not worth changing at this time. @@ -59,20 +93,70 @@ PHPStan errors can be ignored in the following ways: - Adding the error pattern to the `ignoreErrors` section of the `phpstan.neon.dist` configuration file. This should be used to handle conflicts with WordPress Coding Standards or similar project decisions, or to allowlist legacy code that is not worth refactoring solely to satisfy the tests. -- Adding an error to the "tech debt" baseline. This should be used for code that needs to be addressed eventually - by fixing, refactoring, or ignoring via one of the above methods - but is not worth addressing right now. +- Adding an error to a "tech debt" baseline. This should be used for code that needs to be addressed eventually - by fixing, refactoring, or ignoring via one of the above methods - but is not worth addressing right now. Baselines are a useful triage tool for handling PHPStan errors in legacy code, as they allow us to enforce stricter code quality checks on new code, while gradually chipping away at the existing issues over time. **Avoid adding PHPStan errors from new code whenever possible, and use baselines as a last resort.** - The baseline file is located at `tests/phpstan/baseline.php` and generated by running PHPStan with the `--generate-baseline` flag: +### How the baselines are organized + +The baselines live in [`baselines/`](baselines), one file per error identifier, such as `variable.undefined.neon`. Splitting them this way keeps each kind of error visible as a single file that should shrink to nothing and then be deleted, rather than as part of one large file in which every kind is mixed together. + +Every entry is scoped to the file the error occurs in and carries an exact occurrence count: + +```neon +- + message: '#^Variable \$wpdb might not be defined\.$#' + identifier: variable.undefined + count: 4 + path: ../../../src/wp-trackback.php +``` + +Both the path and the count matter. A new occurrence of an already baselined error does not match the entry, even in a file that is already listed, and is reported as a new error. That is the point of recording them this way: the baselines describe exactly what exists today, so nothing new slips in behind them. + +The consequence is that **fixing a baselined error means regenerating its baseline as part of the same change**, because the count no longer matches. A count that no longer matches is reported as an `ignore.count` error, which PHPStan does not allow to be ignored or baselined. + +### Regenerating the baselines + +The baselines are generated, and should not be edited by hand. Regenerate them with: + +```bash +npm run typecheck:php:baselines +``` + +which will run the generator in the Docker container. + +As with the analysis itself, flags are passed by adding `--` followed by the flags themselves: + +```bash +# a single identifier: +npm run typecheck:php:baselines -- --identifier=variable.undefined + +# several, either comma separated or by repeating the option: +npm run typecheck:php:baselines -- --identifier=variable.undefined,isset.variable +npm run typecheck:php:baselines -- --identifier=isset.variable --identifier=empty.variable + +# print every error as one baseline, writing nothing: +npm run typecheck:php:baselines -- --combined + +# the remaining options: +npm run typecheck:php:baselines -- --help +``` + +If you are not using the Docker environment, you can run the generator via Composer directly: + +```bash +composer phpstan:baselines + +composer phpstan:baselines -- --identifier=variable.undefined +composer phpstan:baselines -- --combined +composer phpstan:baselines -- --help +``` - ```bash - npm run typecheck:php -- --generate-baseline=tests/phpstan/baseline.php +Note the `--` in each of those. Composer needs it in order to pass the flags on to the script rather than reading them as its own, and without it they are discarded silently, so `composer phpstan:baselines --identifier=variable.undefined` regenerates every baseline rather than that one. The npm script supplies it, which is why only one is needed there. - # or, with Composer directly: - composer run phpstan -- --generate-baseline=tests/phpstan/baseline.php - ``` +A run also deletes any baseline whose identifier no longer reports anything, and rewrites the list of them between the `# phpstan:baselines` markers in the `includes` of [`phpstan.neon.dist`](../../phpstan.neon.dist) to match. Adding a newly split out baseline, and retiring one that has reached zero, therefore need no edit of the configuration. - This will regenerate the baseline file with any new errors added to the existing ones. You can then commit the updated baseline file. +PHPStan's own `--generate-baseline` is deliberately not used directly. It captures every error a run reports, with no way to restrict it to one identifier, so it cannot refresh a single baseline without sweeping every other kind of error into it. ## Performance and troubleshooting diff --git a/tests/phpstan/base.neon b/tests/phpstan/base.neon index 0210e0618d785..1c416cb3fe643 100644 --- a/tests/phpstan/base.neon +++ b/tests/phpstan/base.neon @@ -12,6 +12,48 @@ services: tags: - phpstan.parser.richParserNodeVisitor + # Attaches the docblock documenting a hook to the hook's call, so that the return + # type extension and the rules below can all read it. + - + class: WordPress\PHPStan\HookDocsVisitor + tags: + - phpstan.parser.richParserNodeVisitor + + # Resolves a hook call's documentation, whether written at the call or inherited + # through the "This filter is documented in " convention. + - + class: WordPress\PHPStan\HookDocBlock + + # Types the return value of apply_filters() (and variants) from the `@param` type + # documented for the value being filtered. Adapted from szepeviktor/phpstan-wordpress. + - + class: WordPress\PHPStan\ApplyFiltersDynamicFunctionReturnTypeExtension + tags: + - phpstan.broker.dynamicFunctionReturnTypeExtension + + # Enforces that every hook invocation is preceded by a documenting docblock or + # a valid "This filter is documented in " reference comment. + - + class: WordPress\PHPStan\HookDocumentationRule + tags: + - phpstan.rules.rule + + # Enforces that a hook invocation passes as many arguments as its documentation + # (inline or referenced) describes. + - + class: WordPress\PHPStan\HookParamCountRule + tags: + - phpstan.rules.rule + + # Docblocks inherited from another file, and the sources above that read them, + # are invisible to PHPStan's dependency graph, so both are folded into the result + # cache key. Without this, editing a canonical hook docblock leaves the cached + # results of every call site inheriting it in place. + - + class: WordPress\PHPStan\HookDocsResultCacheMetaExtension + tags: + - phpstan.resultCacheMetaExtension + parameters: # Cache is stored locally, so it's available for CI. tmpDir: ../../.cache @@ -33,6 +75,8 @@ parameters: - ALLOW_SUBDIRECTORY_INSTALL - AUTH_SALT - AUTOMATIC_UPDATER_DISABLED + - BACKGROUND_COLOR + - BACKGROUND_IMAGE - COOKIEPATH - CUSTOM_TAGS - DISALLOW_FILE_EDIT @@ -40,8 +84,13 @@ parameters: - EMPTY_TRASH_DAYS - ENFORCE_GZIP - FORCE_SSL_LOGIN + - HEADER_IMAGE + - HEADER_IMAGE_HEIGHT + - HEADER_IMAGE_WIDTH + - HEADER_TEXTCOLOR - MEDIA_TRASH - MULTISITE + - NO_HEADER_TEXT - NOBLOGREDIRECT - SAVEQUERIES - SCRIPT_DEBUG @@ -99,6 +148,12 @@ parameters: - ../../src/wp-trackback.php - ../../src/xmlrpc.php - GlobalDocBlockVisitor.php + - HookDocsVisitor.php + - HookDocBlock.php + - ApplyFiltersDynamicFunctionReturnTypeExtension.php + - HookDocumentationRule.php + - HookParamCountRule.php + - HookDocsResultCacheMetaExtension.php bootstrapFiles: - bootstrap.php scanFiles: @@ -119,6 +174,8 @@ parameters: - ../../src/wp-admin/load-styles.php # These files are autogenerated by tools/gutenberg/copy.js. - ../../src/wp-includes/blocks + # Generated output from the Gutenberg plugin's wp-build templates. + - ../../src/wp-includes/build # Third-party libraries. - ../../src/wp-admin/includes/class-ftp-pure.php - ../../src/wp-admin/includes/class-ftp-sockets.php diff --git a/tests/phpstan/baseline.php b/tests/phpstan/baseline.php deleted file mode 100644 index 646cbdbef630c..0000000000000 --- a/tests/phpstan/baseline.php +++ /dev/null @@ -1,3 +0,0 @@ -\|string, false given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-activate.php + - + message: '#^Parameter \#3 \$subject of function str_replace expects array\\|string, float given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-admin/admin-header.php + - + message: '#^Parameter \#1 \$post of function get_edit_post_link expects int\|WP_Post, string given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-admin/comment.php + - + message: '#^Parameter \#1 \$post of function get_post_status expects int\|WP_Post\|null, string given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-admin/comment.php + - + message: '#^Parameter \#1 \$post of function get_the_title expects int\|WP_Post, string given\.$#' + identifier: argument.type + count: 2 + path: ../../../src/wp-admin/comment.php + - + message: '#^Parameter \#1 \$text of function esc_attr expects string, bool given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-admin/customize.php + - + message: '#^Parameter \#1 \$position of function wp_comment_reply expects int, string given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-admin/edit-comments.php + - + message: '#^Parameter \#1 \$text of function esc_attr expects string, int given\.$#' + identifier: argument.type + count: 3 + path: ../../../src/wp-admin/edit-comments.php + - + message: '#^Parameter \#1 \$text of function esc_attr expects string, int\<0, max\> given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-admin/edit-comments.php + - + message: '#^Parameter \#1 \$text of function esc_attr expects string, int\\|int\<1, max\> given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-admin/edit-comments.php + - + message: '#^Parameter \#1 \$screen of function do_meta_boxes expects string\|WP_Screen, null given\.$#' + identifier: argument.type + count: 2 + path: ../../../src/wp-admin/edit-form-advanced.php + - + message: '#^Parameter \#1 \$text of function esc_attr expects string, int\<0, max\> given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-admin/edit-form-advanced.php + - + message: '#^Parameter \#1 \$post of function get_edit_post_link expects int\|WP_Post, string given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-admin/edit-form-comment.php + - + message: '#^Parameter \#1 \$post of function get_the_title expects int\|WP_Post, string given\.$#' + identifier: argument.type + count: 2 + path: ../../../src/wp-admin/edit-form-comment.php + - + message: '#^Parameter \#1 \$screen of function do_meta_boxes expects string\|WP_Screen, null given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-admin/edit-form-comment.php + - + message: '#^Parameter \#1 \$screen of function do_meta_boxes expects string\|WP_Screen, null given\.$#' + identifier: argument.type + count: 2 + path: ../../../src/wp-admin/edit-link-form.php + - + message: '#^Parameter \#3 \$name of function submit_button expects string, null given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-admin/edit-tag-form.php + - + message: '#^Parameter \#1 \$post of function get_edit_post_link expects int\|WP_Post, string given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-admin/edit.php + - + message: '#^Parameter \#1 \$post of function get_post_type expects int\|WP_Post\|null, string given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-admin/edit.php + - + message: '#^Parameter \#1 \$attachment of function wp_get_attachment_id3_keys expects WP_Post, stdClass given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-admin/includes/ajax-actions.php + - + message: '#^Parameter \#1 \$comment_id of function _wp_ajax_delete_comment_response expects int, string given\.$#' + identifier: argument.type + count: 2 + path: ../../../src/wp-admin/includes/ajax-actions.php + - + message: '#^Parameter \#1 \$text of function esc_attr expects string, int given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-admin/includes/ajax-actions.php + - + message: '#^Parameter \#1 \$text of function esc_attr expects string, int\<0, max\> given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-admin/includes/ajax-actions.php + - + message: '#^Parameter \#2 \$compare_from of function wp_get_revision_ui_diff expects int, string given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-admin/includes/ajax-actions.php + - + message: '#^Parameter \#3 \$compare_to of function wp_get_revision_ui_diff expects int, string given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-admin/includes/ajax-actions.php + - + message: '#^Parameter \#2 \$gmt of function current_time expects bool, int given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-admin/includes/bookmark.php + - + message: '#^Parameter \#2 \$allowed_html of function wp_kses expects array\\|string, array\\|true\> given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-admin/includes/class-automatic-upgrader-skin.php + - + message: '#^Parameter \#1 \$text of function esc_attr expects string, int given\.$#' + identifier: argument.type + count: 2 + path: ../../../src/wp-admin/includes/class-bulk-upgrader-skin.php + - + message: '#^Parameter \#1 \$text of function esc_js expects string, int given\.$#' + identifier: argument.type + count: 4 + path: ../../../src/wp-admin/includes/class-bulk-upgrader-skin.php + - + message: '#^Parameter \#1 \$text of function submit_button expects string, null given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-admin/includes/class-custom-background.php + - + message: '#^Parameter \#1 \$text of function esc_attr expects string, \(float\|int\) given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-admin/includes/class-custom-image-header.php + - + message: '#^Parameter \#1 \$text of function esc_attr expects string, float\|int given\.$#' + identifier: argument.type + count: 4 + path: ../../../src/wp-admin/includes/class-custom-image-header.php + - + message: '#^Parameter \#1 \$text of function submit_button expects string, null given\.$#' + identifier: argument.type + count: 2 + path: ../../../src/wp-admin/includes/class-custom-image-header.php + - + message: '#^Parameter \#1 \$language_updates of method Language_Pack_Upgrader\:\:bulk_upgrade\(\) expects array\, list\\|string\|false given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-admin/includes/class-language-pack-upgrader.php + - + message: '#^Parameter \#1 \$text of function esc_attr expects string, int given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-admin/includes/class-walker-nav-menu-edit.php + - + message: '#^Parameter \#1 \$text of function esc_attr expects string, int\<0, max\> given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-admin/includes/class-walker-nav-menu-edit.php + - + message: '#^Parameter \#1 \$str of function md5 expects string, int given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-admin/includes/class-wp-automatic-updater.php + - + message: '#^Parameter \#1 \$post of function post_password_required expects int\|WP_Post\|null, string given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-admin/includes/class-wp-comments-list-table.php + - + message: '#^Parameter \#3 \$post of function get_comment_class expects int\|WP_Post\|null, string given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-admin/includes/class-wp-comments-list-table.php + - + message: '#^Parameter \#1 \$text of function esc_attr expects string, int given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-admin/includes/class-wp-ms-users-list-table.php + - + message: '#^Parameter \#3 \$number of function _nx expects int, float given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-admin/includes/class-wp-plugin-install-list-table.php + - + message: '#^Parameter \#1 \$text of function esc_attr expects string, int given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-admin/includes/class-wp-plugins-list-table.php + - + message: '#^Parameter \#1 \$text of function esc_attr expects string, int given\.$#' + identifier: argument.type + count: 2 + path: ../../../src/wp-admin/includes/class-wp-privacy-data-export-requests-list-table.php + - + message: '#^Parameter \#1 \$text of function esc_attr expects string, int\<0, max\> given\.$#' + identifier: argument.type + count: 2 + path: ../../../src/wp-admin/includes/class-wp-privacy-data-export-requests-list-table.php + - + message: '#^Parameter \#1 \$text of function esc_attr expects string, int given\.$#' + identifier: argument.type + count: 2 + path: ../../../src/wp-admin/includes/class-wp-privacy-data-removal-requests-list-table.php + - + message: '#^Parameter \#1 \$text of function esc_attr expects string, int\<0, max\> given\.$#' + identifier: argument.type + count: 2 + path: ../../../src/wp-admin/includes/class-wp-privacy-data-removal-requests-list-table.php + - + message: '#^Parameter \#1 \$text of function esc_attr expects string, int given\.$#' + identifier: argument.type + count: 2 + path: ../../../src/wp-admin/includes/class-wp-privacy-requests-table.php + - + message: '#^Parameter \#1 \$text of function esc_attr expects string, int given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-admin/includes/class-wp-screen.php + - + message: '#^Parameter \#1 \$text of function esc_attr expects string, int\<1, max\> given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-admin/includes/class-wp-screen.php + - + message: '#^Parameter \#1 \$version of function get_core_checksums expects string, float given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-admin/includes/class-wp-site-health-auto-updates.php + - + message: '#^Parameter \#1 \$bytes of function size_format expects int\|string, float\|false given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-admin/includes/class-wp-site-health.php + - + message: '#^Parameter \#2 \$allowed_html of function wp_kses expects array\\|string, array\\|true\> given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-admin/includes/class-wp-site-health.php + - + message: '#^Parameter \#2 \$allowed_html of function wp_kses expects array\\|string, array\ given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-admin/includes/class-wp-site-health.php + - + message: '#^Parameter \#1 \$args of function WP_Filesystem expects array\|false, true given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-admin/includes/class-wp-upgrader.php + - + message: '#^Parameter \#1 \$post of function _draft_or_post_title expects int\|WP_Post, string given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-admin/includes/dashboard.php + - + message: '#^Parameter \#1 \$post of function get_the_permalink expects int\|WP_Post, string given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-admin/includes/dashboard.php + - + message: '#^Parameter \#1 \$post of function post_password_required expects int\|WP_Post\|null, string given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-admin/includes/dashboard.php + - + message: '#^Parameter \#3 \$name of function submit_button expects string, false given\.$#' + identifier: argument.type + count: 2 + path: ../../../src/wp-admin/includes/dashboard.php + - + message: '#^Parameter \#1 \$callback of function array_map expects \(callable\(stdClass\)\: mixed\)\|null, ''get_comment'' given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-admin/includes/export.php + - + message: '#^Parameter \#1 \$term of function get_term expects int\|object, string given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-admin/includes/export.php + - + message: '#^Parameter \#2 \$callback of function add_filter expects callable\(\)\: mixed, ''wxr_filter_postmeta'' given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-admin/includes/export.php + - + message: '#^Parameter \#1 \$str of function md5 expects string, int\<0, max\> given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-admin/includes/file.php + - + message: '#^Parameter \#1 \$image of function is_gd_image expects GdImage\|resource\|false, WP_Image_Editor given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-admin/includes/image-edit.php + - + message: '#^Parameter \#1 \$text of function esc_attr expects string, int given\.$#' + identifier: argument.type + count: 2 + path: ../../../src/wp-admin/includes/image-edit.php + - + message: '#^Parameter \#1 \$width of function wp_imagecreatetruecolor expects int, float given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-admin/includes/image-edit.php + - + message: '#^Parameter \#2 \$height of function wp_imagecreatetruecolor expects int, float given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-admin/includes/image-edit.php + - + message: '#^Parameter \#5 \$src_x of function imagecopy expects int, float given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-admin/includes/image-edit.php + - + message: '#^Parameter \#6 \$src_y of function imagecopy expects int, float given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-admin/includes/image-edit.php + - + message: '#^Parameter \#7 \$src_w of function imagecopy expects int, float given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-admin/includes/image-edit.php + - + message: '#^Parameter \#8 \$src_h of function imagecopy expects int, float given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-admin/includes/image-edit.php + - + message: '#^Parameter \#1 \$post_id of function wp_delete_attachment expects int, string given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-admin/includes/import.php + - + message: '#^Parameter \#1 \$number of function number_format_i18n expects float, string given\.$#' + identifier: argument.type + count: 3 + path: ../../../src/wp-admin/includes/media.php + - + message: '#^Parameter \#1 \$post_id of function get_media_items expects int, null given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-admin/includes/media.php + - + message: '#^Parameter \#1 \$text of function esc_attr expects string, int given\.$#' + identifier: argument.type + count: 2 + path: ../../../src/wp-admin/includes/media.php + - + message: '#^Parameter \#2 \$result of function wp_parse_str expects array, \(string\|false\) given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-admin/includes/menu.php + - + message: '#^Parameter \#1 \$text of function esc_attr expects string, int given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-admin/includes/meta-boxes.php + - + message: '#^Parameter \#1 \$text of function esc_attr expects string, int given\.$#' + identifier: argument.type + count: 8 + path: ../../../src/wp-admin/includes/misc.php + - + message: '#^Parameter \#1 \$text of function esc_attr expects string, int\<0, max\> given\.$#' + identifier: argument.type + count: 2 + path: ../../../src/wp-admin/includes/misc.php + - + message: '#^Parameter \#1 \$text of function esc_attr expects string, int\<1, max\> given\.$#' + identifier: argument.type + count: 2 + path: ../../../src/wp-admin/includes/misc.php + - + message: '#^Parameter \#1 \$link_id of function wp_delete_link expects int, string\|null given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-admin/includes/ms.php + - + message: '#^Parameter \#1 \$post_id of function wp_delete_post expects int, string\|null given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-admin/includes/ms.php + - + message: '#^Parameter \#2 \$meta_id of function delete_metadata_by_mid expects int, string\|null given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-admin/includes/ms.php + - + message: '#^Parameter \#3 \$value of function update_blog_status expects string, int given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-admin/includes/ms.php + - + message: '#^Parameter \#1 \$post_id of function wp_delete_post expects int, string\|null given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-admin/includes/nav-menu.php + - + message: '#^Parameter \#2 \$arr2 of function array_intersect expects an array of values castable to string, array\ given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-admin/includes/nav-menu.php + - + message: '#^Parameter \#7 \$callback_args of function add_meta_box expects array\|null, WP_Post_Type given\.$#' + identifier: argument.type + count: 2 + path: ../../../src/wp-admin/includes/nav-menu.php + - + message: '#^Parameter \#1 \$tags of function wp_generate_tag_cloud expects array\, array\ given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-admin/includes/plugin-install.php + - + message: '#^Parameter \#3 \$name of function submit_button expects string, false given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-admin/includes/plugin-install.php + - + message: '#^Parameter \#3 \$number of function _nx expects int, float given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-admin/includes/plugin-install.php + - + message: '#^Parameter \#2 \$allowed_html of function wp_kses expects array\\|string, array\\|true\> given\.$#' + identifier: argument.type + count: 4 + path: ../../../src/wp-admin/includes/plugin.php + - + message: '#^Parameter \#1 \$text of function esc_attr expects string, int\<0, max\> given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-admin/includes/post.php + - + message: '#^Parameter \#2 \$fallback_title of function sanitize_title expects string, int\<0, max\> given\.$#' + identifier: argument.type + count: 2 + path: ../../../src/wp-admin/includes/post.php + - + message: '#^Parameter \#1 \$user_id of function switch_to_user_locale expects int, string given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-admin/includes/privacy-tools.php + - + message: '#^Parameter \#2 \$user_id of function get_the_author_meta expects int\|false, ''''\|numeric\-string given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-admin/includes/revision.php + - + message: '#^Parameter \#1 \$str of function md5 expects string, int\<1, max\> given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-admin/includes/schema.php + - + message: '#^Parameter \#2 \$multiplier of function str_repeat expects int, float given\.$#' + identifier: argument.type + count: 3 + path: ../../../src/wp-admin/includes/template.php + - + message: '#^Parameter \#2 \$title of function add_meta_box expects string, null given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-admin/includes/template.php + - + message: '#^Parameter \#3 \$callback of function add_meta_box expects callable\(\)\: mixed, null given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-admin/includes/template.php + - + message: '#^Parameter \#1 \$update of method Language_Pack_Upgrader\:\:upgrade\(\) expects string\|false, stdClass given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-admin/includes/translation-install.php + - + message: '#^Parameter \#3 \$overwrite of method WP_Filesystem_Base\:\:copy\(\) expects bool, int given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-admin/includes/update-core.php + - + message: '#^Parameter \#1 \$text of function esc_attr expects string, int given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-admin/includes/update.php + - + message: '#^Parameter \#1 \$timestamp of function wp_schedule_event expects int, float given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-admin/includes/upgrade.php + - + message: '#^Parameter \#3 \$deprecated of function add_option expects string, null given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-admin/includes/upgrade.php + - + message: '#^Parameter \#1 \$bookmark_id of function clean_bookmark_cache expects int, string\|null given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-admin/includes/user.php + - + message: '#^Parameter \#1 \$link_id of function wp_delete_link expects int, string\|null given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-admin/includes/user.php + - + message: '#^Parameter \#1 \$post of function clean_post_cache expects int\|WP_Post, string\|null given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-admin/includes/user.php + - + message: '#^Parameter \#1 \$post_id of function wp_delete_post expects int, string\|null given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-admin/includes/user.php + - + message: '#^Parameter \#1 \$user of function wp_get_user_contact_methods expects WP_User\|null, stdClass given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-admin/includes/user.php + - + message: '#^Parameter \#2 \$meta_id of function delete_metadata_by_mid expects int, string\|null given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-admin/includes/user.php + - + message: '#^Parameter \#4 \$is_public of function wp_install expects bool, int given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-admin/install.php + - + message: '#^Parameter \#1 \$text of function esc_attr expects string, int given\.$#' + identifier: argument.type + count: 5 + path: ../../../src/wp-admin/nav-menus.php + - + message: '#^Parameter \#2 \$menu_data of function wp_save_nav_menu_items expects array\, int\<0, max\> given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-admin/nav-menus.php + - + message: '#^Parameter \#1 \$network_id of function can_edit_network expects int, string given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-admin/network/site-info.php + - + message: '#^Parameter \#1 \$text of function esc_attr expects string, int\\|int\<1, max\> given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-admin/network/site-info.php + - + message: '#^Parameter \#1 \$network_id of function can_edit_network expects int, string given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-admin/network/site-settings.php + - + message: '#^Parameter \#1 \$text of function esc_attr expects string, int\\|int\<1, max\> given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-admin/network/site-settings.php + - + message: '#^Parameter \#1 \$network_id of function can_edit_network expects int, string given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-admin/network/site-themes.php + - + message: '#^Parameter \#1 \$text of function esc_attr expects string, int\\|int\<1, max\> given\.$#' + identifier: argument.type + count: 2 + path: ../../../src/wp-admin/network/site-themes.php + - + message: '#^Parameter \#1 \$network_id of function can_edit_network expects int, string given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-admin/network/site-users.php + - + message: '#^Parameter \#1 \$text of function esc_attr expects string, int\\|int\<1, max\> given\.$#' + identifier: argument.type + count: 4 + path: ../../../src/wp-admin/network/site-users.php + - + message: '#^Parameter \#1 \$text of function esc_attr expects string, int given\.$#' + identifier: argument.type + count: 2 + path: ../../../src/wp-admin/network/sites.php + - + message: '#^Parameter \#1 \$text of function esc_attr expects string, int\<2, max\> given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-admin/options-discussion.php + - + message: '#^Parameter \#1 \$text of function esc_attr expects string, bool given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-admin/options-general.php + - + message: '#^Parameter \#1 \$text of function esc_attr expects string, int\<0, 6\> given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-admin/options-general.php + - + message: '#^Parameter \#2 \$callback of function array_filter expects \(callable\(mixed\)\: bool\)\|null, ''validate_file'' given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-admin/plugins.php + - + message: '#^Parameter \#2 \$newvalue of function ini_set expects string, true given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-admin/plugins.php + - + message: '#^Parameter \#2 \$newvalue of function ini_set expects string, true given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-admin/update.php + - + message: '#^Parameter \#1 \$text of function esc_attr expects string, int given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-admin/user-edit.php + - + message: '#^Parameter \#1 \$text of function esc_attr expects string, int given\.$#' + identifier: argument.type + count: 10 + path: ../../../src/wp-admin/users.php + - + message: '#^Parameter \#1 \$author_id of function get_author_posts_url expects int, string given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-content/themes/twentyeleven/author.php + - + message: '#^Parameter \#1 \$author_id of function get_author_posts_url expects int, string given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-content/themes/twentyeleven/content-image.php + - + message: '#^Parameter \#1 \$author_id of function get_author_posts_url expects int, string given\.$#' + identifier: argument.type + count: 2 + path: ../../../src/wp-content/themes/twentyeleven/content-single.php + - + message: '#^Parameter \#1 \$author_id of function get_author_posts_url expects int, string given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-content/themes/twentyeleven/functions.php + - + message: '#^Parameter \#1 \$comment of function get_comment_link expects int\|WP_Comment\|null, string given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-content/themes/twentyeleven/functions.php + - + message: '#^Parameter \#1 \$size of function next_image_link expects array\\|string, false given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-content/themes/twentyeleven/image.php + - + message: '#^Parameter \#1 \$size of function previous_image_link expects array\\|string, false given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-content/themes/twentyeleven/image.php + - + message: '#^Parameter \#1 \$screen of function add_contextual_help expects string, WP_Screen\|null given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-content/themes/twentyeleven/inc/theme-options.php + - + message: '#^Parameter \#3 \$args of function register_setting expects array, string given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-content/themes/twentyeleven/inc/theme-options.php + - + message: '#^Parameter \#3 \$deps of function wp_enqueue_style expects array\, false given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-content/themes/twentyeleven/inc/theme-options.php + - + message: '#^Parameter \#1 \$text of function esc_attr expects string, int\<0, max\> given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-content/themes/twentyeleven/inc/widgets.php + - + message: '#^Parameter \#1 \$text of function esc_attr expects string, int\<1, max\> given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-content/themes/twentyeleven/showcase.php + - + message: '#^Parameter \#2 \$instance of function the_widget expects array, string given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-content/themes/twentyeleven/showcase.php + - + message: '#^Parameter \#1 \$author_id of function get_author_posts_url expects int, string given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-content/themes/twentyfifteen/author-bio.php + - + message: '#^Parameter \#1 \$size of function next_image_link expects array\\|string, false given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-content/themes/twentyfifteen/image.php + - + message: '#^Parameter \#1 \$size of function previous_image_link expects array\\|string, false given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-content/themes/twentyfifteen/image.php + - + message: '#^Parameter \#1 \$author_id of function get_author_posts_url expects int, string given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-content/themes/twentyfifteen/inc/template-tags.php + - + message: '#^Parameter \#3 \$number of function _n expects int, string given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-content/themes/twentyfourteen/functions.php + - + message: '#^Parameter \#1 \$size of function next_image_link expects array\\|string, false given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-content/themes/twentyfourteen/image.php + - + message: '#^Parameter \#1 \$size of function previous_image_link expects array\\|string, false given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-content/themes/twentyfourteen/image.php + - + message: '#^Parameter \#1 \$text of function esc_html expects string, int\<1, max\> given\.$#' + identifier: argument.type + count: 2 + path: ../../../src/wp-content/themes/twentyfourteen/image.php + - + message: '#^Parameter \#1 \$author_id of function get_author_posts_url expects int, string given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-content/themes/twentyfourteen/inc/template-tags.php + - + message: '#^Parameter \#1 \$author_id of function get_author_posts_url expects int, string given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-content/themes/twentyfourteen/inc/widgets.php + - + message: '#^Parameter \#1 \$text of function esc_attr expects string, int\<0, max\> given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-content/themes/twentyfourteen/inc/widgets.php + - + message: '#^Parameter \#3 \$deps of function wp_enqueue_style expects array\, false given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-content/themes/twentynineteen/functions.php + - + message: '#^Parameter \#1 \$num of function dechex expects int, float given\.$#' + identifier: argument.type + count: 6 + path: ../../../src/wp-content/themes/twentynineteen/inc/helper-functions.php + - + message: '#^Parameter \#1 \$user_id of function get_userdata expects int, string given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-content/themes/twentynineteen/inc/helper-functions.php + - + message: '#^Parameter \#1 \$author_id of function get_author_posts_url expects int, string given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-content/themes/twentynineteen/inc/template-tags.php + - + message: '#^Parameter \#1 \$author_id of function get_author_posts_url expects int, string given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-content/themes/twentynineteen/template-parts/post/author-bio.php + - + message: '#^Parameter \#1 \$text of function esc_attr expects string, int\<0, max\> given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-content/themes/twentyseventeen/inc/color-patterns.php + - + message: '#^Parameter \#1 \$author_id of function get_author_posts_url expects int, string given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-content/themes/twentyseventeen/inc/template-tags.php + - + message: '#^Parameter \#1 \$text of function esc_attr expects string, \(float\|int\) given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-content/themes/twentyseventeen/template-parts/page/content-front-page-panels.php + - + message: '#^Parameter \#1 \$text of function esc_attr expects string, \(float\|int\) given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-content/themes/twentyseventeen/template-parts/page/content-front-page.php + - + message: '#^Parameter \#1 \$size of function next_image_link expects array\\|string, false given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-content/themes/twentysixteen/image.php + - + message: '#^Parameter \#1 \$size of function previous_image_link expects array\\|string, false given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-content/themes/twentysixteen/image.php + - + message: '#^Parameter \#1 \$author_id of function get_author_posts_url expects int, string given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-content/themes/twentysixteen/inc/template-tags.php + - + message: '#^Parameter \#1 \$author_id of function get_author_posts_url expects int, string given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-content/themes/twentysixteen/template-parts/biography.php + - + message: '#^Parameter \#1 \$author_id of function get_author_posts_url expects int, string given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-content/themes/twentyten/author.php + - + message: '#^Parameter \#1 \$author_id of function get_author_posts_url expects int, string given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-content/themes/twentyten/functions.php + - + message: '#^Parameter \#1 \$comment of function get_comment_link expects int\|WP_Comment\|null, string given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-content/themes/twentyten/functions.php + - + message: '#^Parameter \#1 \$wp_head_callback of function add_custom_image_header expects callable\(\)\: mixed, '''' given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-content/themes/twentyten/functions.php + - + message: '#^Parameter \#1 \$author_id of function get_author_posts_url expects int, string given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-content/themes/twentyten/loop-attachment.php + - + message: '#^Parameter \#1 \$size of function next_image_link expects array\\|string, false given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-content/themes/twentyten/loop-attachment.php + - + message: '#^Parameter \#1 \$size of function previous_image_link expects array\\|string, false given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-content/themes/twentyten/loop-attachment.php + - + message: '#^Parameter \#1 \$author_id of function get_author_posts_url expects int, string given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-content/themes/twentyten/loop-single.php + - + message: '#^Parameter \#1 \$author_id of function get_author_posts_url expects int, string given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-content/themes/twentythirteen/author-bio.php + - + message: '#^Parameter \#1 \$author_id of function get_author_posts_url expects int, string given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-content/themes/twentythirteen/author.php + - + message: '#^Parameter \#1 \$author_id of function get_author_posts_url expects int, string given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-content/themes/twentythirteen/functions.php + - + message: '#^Parameter \#1 \$size of function next_image_link expects array\\|string, false given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-content/themes/twentythirteen/image.php + - + message: '#^Parameter \#1 \$size of function previous_image_link expects array\\|string, false given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-content/themes/twentythirteen/image.php + - + message: '#^Parameter \#1 \$author_id of function get_author_posts_url expects int, string given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-content/themes/twentytwelve/author.php + - + message: '#^Parameter \#1 \$author_id of function get_author_posts_url expects int, string given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-content/themes/twentytwelve/content.php + - + message: '#^Parameter \#1 \$author_id of function get_author_posts_url expects int, string given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-content/themes/twentytwelve/functions.php + - + message: '#^Parameter \#1 \$comment of function get_comment_link expects int\|WP_Comment\|null, string given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-content/themes/twentytwelve/functions.php + - + message: '#^Parameter \#1 \$size of function next_image_link expects array\\|string, false given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-content/themes/twentytwelve/image.php + - + message: '#^Parameter \#1 \$size of function previous_image_link expects array\\|string, false given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-content/themes/twentytwelve/image.php + - + message: '#^Parameter \#4 \$prefix of function twentytwenty_generate_css expects string, null given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-content/themes/twentytwenty/classes/class-twentytwenty-non-latin-languages.php + - + message: '#^Parameter \#5 \$suffix of function twentytwenty_generate_css expects string, null given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-content/themes/twentytwenty/classes/class-twentytwenty-non-latin-languages.php + - + message: '#^Parameter \#1 \$text of function esc_attr expects string, int given\.$#' + identifier: argument.type + count: 2 + path: ../../../src/wp-content/themes/twentytwenty/functions.php + - + message: '#^Parameter \#3 \$deps of function wp_enqueue_style expects array\, null given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-content/themes/twentytwenty/functions.php + - + message: '#^Parameter \#1 \$author_id of function get_author_posts_url expects int, string given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-content/themes/twentytwenty/inc/template-tags.php + - + message: '#^Parameter \#1 \$author_id of function get_author_posts_url expects int, string given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-content/themes/twentytwenty/template-parts/entry-author-bio.php + - + message: '#^Parameter \#1 \$text of function esc_attr expects string, int\|false given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-content/themes/twentytwentyone/inc/template-functions.php + - + message: '#^Parameter \#1 \$author_id of function get_author_posts_url expects int, string given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-content/themes/twentytwentyone/inc/template-tags.php + - + message: '#^Parameter \#1 \$author_id of function get_author_posts_url expects int, string given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-content/themes/twentytwentyone/template-parts/post/author-bio.php + - + message: '#^Parameter \#2 \$size of function get_avatar expects int, string given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-content/themes/twentytwentyone/template-parts/post/author-bio.php + - + message: '#^Parameter \#1 \$userid of function count_user_posts expects int, string given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/author-template.php + - + message: '#^Parameter \#1 \$separator of function explode expects string, float given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/block-supports/layout.php + - + message: '#^Parameter \#1 \$block of function filter_block_kses expects WP_Block_Parser_Block, array given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/blocks.php + - + message: '#^Parameter \#4 \$block_context of function filter_block_kses_value expects array\|null, WP_Block_Parser_Block given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/blocks.php + - + message: '#^Parameter \#1 \$post of function get_permalink expects int\|WP_Post, string given\.$#' + identifier: argument.type + count: 2 + path: ../../../src/wp-includes/canonical.php + - + message: '#^Parameter \#1 \$post_id of function get_post_comments_feed_link expects int, string given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/canonical.php + - + message: '#^Parameter \#2 \$callback of function preg_replace_callback expects callable\(array\\)\: string, ''lowercase_octets'' given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/canonical.php + - + message: '#^Parameter \#1 \$user_id of function get_userdata expects int, string given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/capabilities.php + - + message: '#^Parameter \#1 \$name of class WP_Block_Parser_Block constructor expects string, null given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/class-wp-block-parser.php + - + message: '#^Parameter \#1 \$child_id of method WP_Comment\:\:get_child\(\) expects int, string given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/class-wp-comment-query.php + - + message: '#^Parameter \#1 \$ids of function _prime_post_caches expects array\, list\ given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/class-wp-comment-query.php + - + message: '#^Parameter \#1 \$text of function esc_attr expects string, int\<0, max\> given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/class-wp-customize-control.php + - + message: '#^Parameter \#1 \$ajax_message of method WP_Customize_Manager\:\:wp_die\(\) expects string\|WP_Error, int given\.$#' + identifier: argument.type + count: 6 + path: ../../../src/wp-includes/class-wp-customize-manager.php + - + message: '#^Parameter \#1 \$month of function wp_checkdate expects int, \(string\|false\) given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/class-wp-customize-manager.php + - + message: '#^Parameter \#2 \$day of function wp_checkdate expects int, \(string\|false\) given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/class-wp-customize-manager.php + - + message: '#^Parameter \#3 \$year of function wp_checkdate expects int, \(string\|false\) given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/class-wp-customize-manager.php + - + message: '#^Parameter \#1 \$text of function esc_html expects string, int given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/class-wp-date-query.php + - + message: '#^Parameter \#2 \$parent_query of method WP_Date_Query\:\:get_sql_for_clause\(\) expects array, string given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/class-wp-date-query.php + - + message: '#^Parameter \#2 \$timestamp of function gmdate expects int, false given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/class-wp-date-query.php + - + message: '#^Parameter \#2 \$value of method WP_Date_Query\:\:build_value\(\) expects array\|string, int given\.$#' + identifier: argument.type + count: 3 + path: ../../../src/wp-includes/class-wp-date-query.php + - + message: '#^Parameter \#2 \$value of method WP_Date_Query\:\:build_value\(\) expects array\|string, int\|null given\.$#' + identifier: argument.type + count: 3 + path: ../../../src/wp-includes/class-wp-date-query.php + - + message: '#^Parameter \#1 \$value of static method WP_Duotone\:\:colord_parse_hue\(\) expects float, string given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/class-wp-duotone.php + - + message: '#^Parameter \#3 \$priority of function _wp_filter_build_unique_id expects int, false given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/class-wp-hook.php + - + message: '#^Parameter \#3 \$value of function curl_setopt expects int, string given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/class-wp-http-curl.php + - + message: '#^Parameter \#2 \$mode of function stream_set_blocking expects bool, int given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/class-wp-http-streams.php + - + message: '#^Parameter \#2 \$callback of method WP_Image_Editor_GD\:\:make_image\(\) expects callable\(\)\: mixed, ''imageavif'' given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/class-wp-image-editor-gd.php + - + message: '#^Parameter \#2 \$interlace of function imageinterlace expects int, bool given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/class-wp-image-editor-gd.php + - + message: '#^Parameter \#2 \$limit of static method Imagick\:\:setResourceLimit\(\) expects int, float given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/class-wp-image-editor-imagick.php + - + message: '#^Parameter \#2 \$value of method Imagick\:\:setOption\(\) expects string, false given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/class-wp-image-editor-imagick.php + - + message: '#^Parameter \#2 \$value of method Imagick\:\:setOption\(\) expects string, int given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/class-wp-image-editor-imagick.php + - + message: '#^Parameter \#2 \$value of method Imagick\:\:setOption\(\) expects string, true given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/class-wp-image-editor-imagick.php + - + message: '#^Parameter \#1 \$ids of function _prime_post_caches expects array\, list\ given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/class-wp-query.php + - + message: '#^Parameter \#1 \$user_id of function get_userdata expects int, string given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/class-wp-query.php + - + message: '#^Parameter \#1 \$pages of function get_page_hierarchy expects array\, list\\|null given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/class-wp-rewrite.php + - + message: '#^Parameter \#1 \$new_blog_id of function switch_to_blog expects int, string given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/class-wp-site.php + - + message: '#^Parameter \#1 \$metadata of method WP_Theme_JSON\:\:get_feature_declarations_for_node\(\) expects object, array given\.$#' + identifier: argument.type + count: 4 + path: ../../../src/wp-includes/class-wp-theme-json.php + - + message: '#^Parameter \#1 \$node of method WP_Theme_JSON\:\:process_pseudo_selectors\(\) expects array, object given\.$#' + identifier: argument.type + count: 2 + path: ../../../src/wp-includes/class-wp-theme-json.php + - + message: '#^Parameter \#1 \$styles of static method WP_Theme_JSON\:\:compute_style_properties\(\) expects array, object given\.$#' + identifier: argument.type + count: 3 + path: ../../../src/wp-includes/class-wp-theme-json.php + - + message: '#^Parameter \#2 \$data of method WP_Theme\:\:cache_add\(\) expects array\|string, int given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/class-wp-theme.php + - + message: '#^Parameter \#1 \$str of function strtoupper expects string, bool given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/class-wp-token-map.php + - + message: '#^Parameter \#1 \$level of method WP_User\:\:translate_level_to_cap\(\) expects int, string given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/class-wp-user.php + - + message: '#^Parameter \#1 \$number of method WP_Widget\:\:_set\(\) expects int, string given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/class-wp-widget.php + - + message: '#^Parameter \#1 \$post of function get_the_title expects int\|WP_Post, string given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/class-wp-xmlrpc-server.php + - + message: '#^Parameter \#1 \$term_id of method wp_xmlrpc_server\:\:get_term_custom_fields\(\) expects int, string given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/class-wp-xmlrpc-server.php + - + message: '#^Parameter \#1 \$user_id of function get_userdata expects int, string given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/class-wp-xmlrpc-server.php + - + message: '#^Parameter \#1 \$comment_id of function get_page_of_comment expects int, string given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/comment-template.php + - + message: '#^Parameter \#1 \$post of function get_permalink expects int\|WP_Post, string given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/comment-template.php + - + message: '#^Parameter \#1 \$post of function post_password_required expects int\|WP_Post\|null, string given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/comment-template.php + - + message: '#^Parameter \#1 \$user_id of function get_userdata expects int, string given\.$#' + identifier: argument.type + count: 2 + path: ../../../src/wp-includes/comment-template.php + - + message: '#^Parameter \#1 \$comment of function get_comment_link expects int\|WP_Comment\|null, string given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/comment.php + - + message: '#^Parameter \#1 \$comment_id of function add_comment_meta expects int, string given\.$#' + identifier: argument.type + count: 5 + path: ../../../src/wp-includes/comment.php + - + message: '#^Parameter \#1 \$comment_id of function delete_comment_meta expects int, string given\.$#' + identifier: argument.type + count: 8 + path: ../../../src/wp-includes/comment.php + - + message: '#^Parameter \#1 \$comment_id of function get_comment_text expects int\|WP_Comment, string given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/comment.php + - + message: '#^Parameter \#1 \$comment_id of function get_page_of_comment expects int, string given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/comment.php + - + message: '#^Parameter \#1 \$comments of function update_comment_cache expects array\, list\\|null given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/comment.php + - + message: '#^Parameter \#1 \$content of function pingback expects string, null given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/comment.php + - + message: '#^Parameter \#1 \$ids of function clean_comment_cache expects array\|int, string given\.$#' + identifier: argument.type + count: 2 + path: ../../../src/wp-includes/comment.php + - + message: '#^Parameter \#1 \$post_id of function wp_update_comment_count expects int\|null, string given\.$#' + identifier: argument.type + count: 2 + path: ../../../src/wp-includes/comment.php + - + message: '#^Parameter \#2 \$meta_id of function delete_metadata_by_mid expects int, string\|null given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/comment.php + - + message: '#^Parameter \#2 \$object_ids of function update_meta_cache expects array\\|string, list\ given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/comment.php + - + message: '#^Parameter \#1 \$gmt_time of function spawn_cron expects int, float given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/cron.php + - + message: '#^Parameter \#1 \$container_context of method WP_Customize_Partial\:\:render\(\) expects array, null given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/customize/class-wp-customize-selective-refresh.php + - + message: '#^Parameter \#1 \$response of method WP_REST_Server\:\:response_to_data\(\) expects WP_REST_Response, WP_HTTP_Response given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/embed.php + - + message: '#^Parameter \#1 \$user_id of function get_userdata expects int, string given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/embed.php + - + message: '#^Parameter \#1 \$post_id of function get_post_comments_feed_link expects int, string given\.$#' + identifier: argument.type + count: 2 + path: ../../../src/wp-includes/feed-atom-comments.php + - + message: '#^Parameter \#1 \$post_id of function get_post_comments_feed_link expects int, null given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/feed-rss2.php + - + message: '#^Parameter \#1 \$post of function get_the_guid expects int\|WP_Post, string given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/feed.php + - + message: '#^Parameter \#2 \$message of class WP_Error constructor expects string, list\ given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/feed.php + - + message: '#^Parameter \#2 \$callback of function preg_replace_callback expects callable\(array\\)\: string, ''_links_add_base'' given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/formatting.php + - + message: '#^Parameter \#2 \$callback of function preg_replace_callback expects callable\(array\\)\: string, ''_links_add_target'' given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/formatting.php + - + message: '#^Parameter \#1 \$prefix of function uniqid expects string, int\<0, max\> given\.$#' + identifier: argument.type + count: 2 + path: ../../../src/wp-includes/functions.php + - + message: '#^Parameter \#1 \$weekday_number of method WP_Locale\:\:get_weekday\(\) expects int, string given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/functions.php + - + message: '#^Parameter \#2 \$fallback_url of function wp_validate_redirect expects string, false given\.$#' + identifier: argument.type + count: 2 + path: ../../../src/wp-includes/functions.php + - + message: '#^Parameter \#2 \$meta_id of function delete_metadata_by_mid expects int, string\|null given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/functions.php + - + message: '#^Parameter \#3 \$number of function _n expects int, string given\.$#' + identifier: argument.type + count: 3 + path: ../../../src/wp-includes/functions.php + - + message: '#^Parameter \#4 \$mon of function mktime expects int, \(string\|false\) given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/functions.php + - + message: '#^Parameter \#5 \$day of function mktime expects int, \(string\|false\) given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/functions.php + - + message: '#^Parameter \#6 \$year of function mktime expects int, \(string\|false\) given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/functions.php + - + message: '#^Parameter \#1 \$string of function strlen expects string, int\\|int\<1, max\> given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/general-template.php + - + message: '#^Parameter \#1 \$string of function substr expects string, int given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/general-template.php + - + message: '#^Parameter \#1 \$string of function substr expects string, int\\|int\<1, max\> given\.$#' + identifier: argument.type + count: 2 + path: ../../../src/wp-includes/general-template.php + - + message: '#^Parameter \#1 \$text of function esc_attr expects string, float given\.$#' + identifier: argument.type + count: 2 + path: ../../../src/wp-includes/general-template.php + - + message: '#^Parameter \#2 \$replace of function str_replace expects array\\|string, int\<1, max\> given\.$#' + identifier: argument.type + count: 2 + path: ../../../src/wp-includes/general-template.php + - + message: '#^Parameter \#2 \$replace of function str_replace expects array\\|string, int\\|int\<2, max\> given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/general-template.php + - + message: '#^Parameter \#3 \$url of function wp_admin_css_color expects string, false given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/general-template.php + - + message: '#^Parameter \#1 \$name of method WP_HTML_Tag_Processor\:\:set_bookmark\(\) expects string, int given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/html-api/class-wp-html-processor.php + - + message: '#^Parameter \#4 \$length of function substr_compare expects int, null given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/html-api/class-wp-html-tag-processor.php + - + message: '#^Parameter \#1 \$user_id of function get_userdata expects int, string given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/link-template.php + - + message: '#^Parameter \#2 \$replace of function str_replace expects array\\|string, array\ given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/link-template.php + - + message: '#^Parameter \#1 \$str of function md5 expects string, int given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/load.php + - + message: '#^Parameter \#2 \$newvalue of function ini_set expects string, int given\.$#' + identifier: argument.type + count: 4 + path: ../../../src/wp-includes/load.php + - + message: '#^Parameter \#3 \$subject of function str_replace expects array\\|string, array\ given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/load.php + - + message: '#^Parameter \#1 \$text of function esc_attr expects string, int\<1, 9\> given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/media-template.php + - + message: '#^Parameter \#1 \$text of function esc_html expects string, int\<1, 9\> given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/media-template.php + - + message: '#^Parameter \#5 \$text of function wp_get_attachment_link expects string\|false, bool given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/media.php + - + message: '#^Parameter \#2 \$callback of function array_walk expects callable\(non\-empty\-string\|null, int\<0, max\>\)\: mixed, ''clean_bookmark_cache'' given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/ms-functions.php + - + message: '#^Parameter \#2 \$callback of function array_walk expects callable\(non\-empty\-string\|null, int\<0, max\>\)\: mixed, ''clean_post_cache'' given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/ms-functions.php + - + message: '#^Parameter \#3 \$value of function update_blog_status expects string, int given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/ms-functions.php + - + message: '#^Parameter \#1 \$network_id of static method WP_Network\:\:get_instance\(\) expects int, string given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/ms-load.php + - + message: '#^Parameter \#1 \$month of function wp_checkdate expects int, \(string\|false\) given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/ms-site.php + - + message: '#^Parameter \#2 \$day of function wp_checkdate expects int, \(string\|false\) given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/ms-site.php + - + message: '#^Parameter \#2 \$meta_id of function delete_metadata_by_mid expects int, string\|null given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/ms-site.php + - + message: '#^Parameter \#2 \$object_id of function delete_metadata expects int, null given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/ms-site.php + - + message: '#^Parameter \#3 \$year of function wp_checkdate expects int, \(string\|false\) given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/ms-site.php + - + message: '#^Parameter \#1 \$post_id of function wp_delete_post expects int, string given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/nav-menu.php + - + message: '#^Parameter \#2 \$value of function setcookie expects string, int\<1, max\> given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/option.php + - + message: '#^Parameter \#1 \$engine of class Text_Diff constructor expects string, list\ given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/pluggable.php + - + message: '#^Parameter \#1 \$number of function number_format_i18n expects float, string\|null given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/pluggable.php + - + message: '#^Parameter \#1 \$post of function get_edit_post_link expects int\|WP_Post, string given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/pluggable.php + - + message: '#^Parameter \#1 \$post of function get_permalink expects int\|WP_Post, string given\.$#' + identifier: argument.type + count: 4 + path: ../../../src/wp-includes/pluggable.php + - + message: '#^Parameter \#1 \$user of function user_can expects int\|WP_User, string given\.$#' + identifier: argument.type + count: 3 + path: ../../../src/wp-includes/pluggable.php + - + message: '#^Parameter \#1 \$user_id of function get_userdata expects int, string given\.$#' + identifier: argument.type + count: 2 + path: ../../../src/wp-includes/pluggable.php + - + message: '#^Parameter \#3 \$number of function _n expects int, string\|null given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/pluggable.php + - + message: '#^Parameter \#1 \$attachment of function is_attachment expects array\\|int\|string, WP_Post given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/post-template.php + - + message: '#^Parameter \#1 \$url of function user_trailingslashit expects string, int\\|int\<2, max\> given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/post-template.php + - + message: '#^Parameter \#2 \$fallback of function sanitize_html_class expects string, int given\.$#' + identifier: argument.type + count: 2 + path: ../../../src/wp-includes/post-template.php + - + message: '#^Parameter \#2 \$replace of function str_replace expects array\\|string, int\<1, max\> given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/post-template.php + - + message: '#^Parameter \#2 \$user_id of function get_the_author_meta expects int\|false, ''''\|numeric\-string given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/post-template.php + - + message: '#^Parameter \#1 \$comment_id of function wp_delete_comment expects int\|WP_Comment, string\|null given\.$#' + identifier: argument.type + count: 2 + path: ../../../src/wp-includes/post.php + - + message: '#^Parameter \#1 \$month of function wp_checkdate expects int, string given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/post.php + - + message: '#^Parameter \#1 \$post of function clean_post_cache expects int\|WP_Post, stdClass given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/post.php + - + message: '#^Parameter \#1 \$post of function get_post expects int\|numeric\-string\|WP_Post\|null, stdClass given\.$#' + identifier: argument.type + count: 2 + path: ../../../src/wp-includes/post.php + - + message: '#^Parameter \#1 \$post_id of function wp_delete_post expects int, string\|null given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/post.php + - + message: '#^Parameter \#1 \$posts of function update_post_cache expects array\, list\ given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/post.php + - + message: '#^Parameter \#1 \$revision of function wp_delete_post_revision expects int\|WP_Post, string\|null given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/post.php + - + message: '#^Parameter \#1 \$string of function strlen expects string, int\<2, max\> given\.$#' + identifier: argument.type + count: 3 + path: ../../../src/wp-includes/post.php + - + message: '#^Parameter \#2 \$day of function wp_checkdate expects int, string given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/post.php + - + message: '#^Parameter \#2 \$meta_id of function delete_metadata_by_mid expects int, string\|null given\.$#' + identifier: argument.type + count: 2 + path: ../../../src/wp-includes/post.php + - + message: '#^Parameter \#2 \$object_id of function delete_metadata expects int, null given\.$#' + identifier: argument.type + count: 2 + path: ../../../src/wp-includes/post.php + - + message: '#^Parameter \#3 \$year of function wp_checkdate expects int, string given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/post.php + - + message: '#^Parameter \#1 \$response of method WP_REST_Server\:\:response_to_data\(\) expects WP_REST_Response, WP_HTTP_Response given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/rest-api.php + - + message: '#^Parameter \#1 \$haystack of function str_starts_with expects string, int given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/rest-api/class-wp-rest-server.php + - + message: '#^Parameter \#1 \$response of method WP_REST_Server\:\:envelope_response\(\) expects WP_REST_Response, WP_HTTP_Response given\.$#' + identifier: argument.type + count: 2 + path: ../../../src/wp-includes/rest-api/class-wp-rest-server.php + - + message: '#^Parameter \#1 \$response of method WP_REST_Server\:\:response_to_data\(\) expects WP_REST_Response, WP_HTTP_Response given\.$#' + identifier: argument.type + count: 2 + path: ../../../src/wp-includes/rest-api/class-wp-rest-server.php + - + message: '#^Parameter \#1 \$data_object of method WP_REST_Controller\:\:update_additional_fields_for_object\(\) expects object, array given\.$#' + identifier: argument.type + count: 2 + path: ../../../src/wp-includes/rest-api/endpoints/class-wp-rest-application-passwords-controller.php + - + message: '#^Parameter \#1 \$comment_id of function get_comment_type expects int\|WP_Comment, string given\.$#' + identifier: argument.type + count: 2 + path: ../../../src/wp-includes/rest-api/endpoints/class-wp-rest-comments-controller.php + - + message: '#^Parameter \#1 \$comment_id of function wp_delete_comment expects int\|WP_Comment, string given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/rest-api/endpoints/class-wp-rest-comments-controller.php + - + message: '#^Parameter \#1 \$comment_id of function wp_trash_comment expects int\|WP_Comment, string given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/rest-api/endpoints/class-wp-rest-comments-controller.php + - + message: '#^Parameter \#1 \$object_id of method WP_REST_Meta_Fields\:\:get_value\(\) expects int, string given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/rest-api/endpoints/class-wp-rest-comments-controller.php + - + message: '#^Parameter \#2 \$comment_id of method WP_REST_Comments_Controller\:\:handle_status_param\(\) expects int, string given\.$#' + identifier: argument.type + count: 2 + path: ../../../src/wp-includes/rest-api/endpoints/class-wp-rest-comments-controller.php + - + message: '#^Parameter \#2 \$object_id of method WP_REST_Meta_Fields\:\:update_value\(\) expects int, string given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/rest-api/endpoints/class-wp-rest-comments-controller.php + - + message: '#^Parameter \#2 \$value of method WP_HTTP_Response\:\:header\(\) expects string, int given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/rest-api/endpoints/class-wp-rest-font-collections-controller.php + - + message: '#^Parameter \#2 \$value of method WP_HTTP_Response\:\:header\(\) expects string, int\<0, max\> given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/rest-api/endpoints/class-wp-rest-font-collections-controller.php + - + message: '#^Parameter \#2 \$value of method WP_HTTP_Response\:\:header\(\) expects string, int given\.$#' + identifier: argument.type + count: 2 + path: ../../../src/wp-includes/rest-api/endpoints/class-wp-rest-global-styles-revisions-controller.php + - + message: '#^Parameter \#1 \$data_object of method WP_REST_Controller\:\:update_additional_fields_for_object\(\) expects object, array given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/rest-api/endpoints/class-wp-rest-plugins-controller.php + - + message: '#^Parameter \#2 \$value of method WP_HTTP_Response\:\:header\(\) expects string, int given\.$#' + identifier: argument.type + count: 2 + path: ../../../src/wp-includes/rest-api/endpoints/class-wp-rest-posts-controller.php + - + message: '#^Parameter \#2 \$value of method WP_HTTP_Response\:\:header\(\) expects string, int given\.$#' + identifier: argument.type + count: 2 + path: ../../../src/wp-includes/rest-api/endpoints/class-wp-rest-revisions-controller.php + - + message: '#^Parameter \#2 \$value of method WP_HTTP_Response\:\:header\(\) expects string, int given\.$#' + identifier: argument.type + count: 2 + path: ../../../src/wp-includes/rest-api/endpoints/class-wp-rest-search-controller.php + - + message: '#^Parameter \#1 \$args of function get_taxonomies expects array, string given\.$#' + identifier: argument.type + count: 2 + path: ../../../src/wp-includes/rest-api/endpoints/class-wp-rest-taxonomies-controller.php + - + message: '#^Parameter \#1 \$id of function get_block_template expects string, int given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/rest-api/endpoints/class-wp-rest-templates-controller.php + - + message: '#^Parameter \#1 \$id of method WP_REST_Templates_Controller\:\:prepare_links\(\) expects int, string given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/rest-api/endpoints/class-wp-rest-templates-controller.php + - + message: '#^Parameter \#2 \$value of method WP_HTTP_Response\:\:header\(\) expects string, int given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/rest-api/endpoints/class-wp-rest-terms-controller.php + - + message: '#^Parameter \#2 \$value of method WP_HTTP_Response\:\:header\(\) expects string, int given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/rest-api/endpoints/class-wp-rest-themes-controller.php + - + message: '#^Parameter \#2 \$value of method WP_HTTP_Response\:\:header\(\) expects string, int\<0, max\> given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/rest-api/endpoints/class-wp-rest-themes-controller.php + - + message: '#^Parameter \#2 \$value of method WP_HTTP_Response\:\:header\(\) expects string, int given\.$#' + identifier: argument.type + count: 2 + path: ../../../src/wp-includes/rest-api/endpoints/class-wp-rest-users-controller.php + - + message: '#^Parameter \#2 \$src of method WP_Dependencies\:\:add\(\) expects string\|false, true given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/script-loader.php + - + message: '#^Parameter \#1 \$object_id of function wp_remove_object_terms expects int, string\|null given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/taxonomy.php + - + message: '#^Parameter \#1 \$object_id of function wp_set_object_terms expects int, string\|null given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/taxonomy.php + - + message: '#^Parameter \#1 \$object_ids of function wp_get_object_terms expects array\\|int, string\|null given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/taxonomy.php + - + message: '#^Parameter \#1 \$post_id of function update_post_meta expects int, string\|null given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/taxonomy.php + - + message: '#^Parameter \#1 \$terms of function update_term_cache expects array\, list\\|null given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/taxonomy.php + - + message: '#^Parameter \#1 \$terms of function wp_update_term_count expects array\|int, null given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/taxonomy.php + - + message: '#^Parameter \#2 \$fallback_title of function sanitize_title expects string, int given\.$#' + identifier: argument.type + count: 2 + path: ../../../src/wp-includes/taxonomy.php + - + message: '#^Parameter \#2 \$meta_id of function delete_metadata_by_mid expects int, string\|null given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/taxonomy.php + - + message: '#^Parameter \#2 \$taxonomy of function wp_update_term_count expects string, null given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/taxonomy.php + - + message: '#^Parameter \#2 \$newvalue of function ini_set expects string, int given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/template.php + - + message: '#^Parameter \#3 \$replacement of function _deprecated_file expects string, null given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/theme-compat/comments.php + - + message: '#^Parameter \#3 \$replacement of function _deprecated_file expects string, null given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/theme-compat/footer.php + - + message: '#^Parameter \#3 \$replacement of function _deprecated_file expects string, null given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/theme-compat/header.php + - + message: '#^Parameter \#3 \$replacement of function _deprecated_file expects string, null given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/theme-compat/sidebar.php + - + message: '#^Parameter \#1 \$string of function strlen expects string, int\<2, max\> given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/theme-templates.php + - + message: '#^Parameter \#1 \$string of function mb_strlen expects string, int\<2, max\> given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/user.php + - + message: '#^Parameter \#1 \$user_id of function switch_to_user_locale expects int, string given\.$#' + identifier: argument.type + count: 2 + path: ../../../src/wp-includes/user.php + - + message: '#^Parameter \#3 \$control_callback of function wp_register_widget_control expects callable\(\)\: mixed, '''' given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/widgets.php + - + message: '#^Parameter \#3 \$output_callback of function wp_register_sidebar_widget expects callable\(\)\: mixed, '''' given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/widgets.php + - + message: '#^Parameter \#1 \$text of function esc_attr expects string, int given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/widgets/class-wp-nav-menu-widget.php + - + message: '#^Parameter \#1 \$post of function get_the_title expects int\|WP_Post, string given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/widgets/class-wp-widget-recent-comments.php + - + message: '#^Parameter \#1 \$text of function esc_html expects string, int given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-mail.php diff --git a/tests/phpstan/baselines/argument.unresolvableType.neon b/tests/phpstan/baselines/argument.unresolvableType.neon new file mode 100644 index 0000000000000..b6015731f858b --- /dev/null +++ b/tests/phpstan/baselines/argument.unresolvableType.neon @@ -0,0 +1,25 @@ +# PHPStan baseline for the `argument.unresolvableType` errors in WordPress core. +# +# https://phpstan.org/error-identifiers/argument.unresolvableType +# +# Each entry is scoped to a single file and carries an exact occurrence count, +# so that a new instance is reported as a new error rather than being absorbed +# silently. Fixing an occurrence therefore means decrementing or removing its +# entry here as part of the same change. +# +# The goal is to empty this file and delete it, along with the `includes` entry +# for it in phpstan.neon.dist. +# +# Generated by `composer phpstan:baselines`. Do not edit by hand; regenerate with +# +# composer phpstan:baselines -- --identifier=argument.unresolvableType +# +# which reruns the analysis with this file suppressed so the errors surface again. + +parameters: + ignoreErrors: + - + message: '#^Parameter \#1 \$array_arg of function uksort contains unresolvable type\.$#' + identifier: argument.unresolvableType + count: 2 + path: ../../../src/wp-includes/cron.php diff --git a/tests/phpstan/baselines/arguments.count.neon b/tests/phpstan/baselines/arguments.count.neon new file mode 100644 index 0000000000000..e3cab51b3621e --- /dev/null +++ b/tests/phpstan/baselines/arguments.count.neon @@ -0,0 +1,50 @@ +# PHPStan baseline for the `arguments.count` errors in WordPress core. +# +# https://phpstan.org/error-identifiers/arguments.count +# +# Each entry is scoped to a single file and carries an exact occurrence count, +# so that a new instance is reported as a new error rather than being absorbed +# silently. Fixing an occurrence therefore means decrementing or removing its +# entry here as part of the same change. +# +# The goal is to empty this file and delete it, along with the `includes` entry +# for it in phpstan.neon.dist. +# +# Generated by `composer phpstan:baselines`. Do not edit by hand; regenerate with +# +# composer phpstan:baselines -- --identifier=arguments.count +# +# which reruns the analysis with this file suppressed so the errors surface again. + +parameters: + ignoreErrors: + - + message: '#^Method WP_List_Table\:\:display_rows\(\) invoked with 2 parameters, 0 required\.$#' + identifier: arguments.count + count: 1 + path: ../../../src/wp-admin/includes/ajax-actions.php + - + message: '#^Method WP_List_Table\:\:single_row\(\) invoked with 2 parameters, 1 required\.$#' + identifier: arguments.count + count: 2 + path: ../../../src/wp-admin/includes/ajax-actions.php + - + message: '#^Method WP_List_Table\:\:single_row\(\) invoked with 3 parameters, 1 required\.$#' + identifier: arguments.count + count: 1 + path: ../../../src/wp-admin/includes/ajax-actions.php + - + message: '#^Method WP_Upgrader_Skin\:\:before\(\) invoked with 1 parameter, 0 required\.$#' + identifier: arguments.count + count: 2 + path: ../../../src/wp-admin/includes/class-plugin-upgrader.php + - + message: '#^Method WP_Upgrader_Skin\:\:before\(\) invoked with 1 parameter, 0 required\.$#' + identifier: arguments.count + count: 2 + path: ../../../src/wp-admin/includes/class-theme-upgrader.php + - + message: '#^Method WP_List_Table\:\:display\(\) invoked with 1 parameter, 0 required\.$#' + identifier: arguments.count + count: 1 + path: ../../../src/wp-admin/includes/meta-boxes.php diff --git a/tests/phpstan/baselines/arrayValues.list.neon b/tests/phpstan/baselines/arrayValues.list.neon new file mode 100644 index 0000000000000..630c8e6bd1879 --- /dev/null +++ b/tests/phpstan/baselines/arrayValues.list.neon @@ -0,0 +1,25 @@ +# PHPStan baseline for the `arrayValues.list` errors in WordPress core. +# +# https://phpstan.org/error-identifiers/arrayValues.list +# +# Each entry is scoped to a single file and carries an exact occurrence count, +# so that a new instance is reported as a new error rather than being absorbed +# silently. Fixing an occurrence therefore means decrementing or removing its +# entry here as part of the same change. +# +# The goal is to empty this file and delete it, along with the `includes` entry +# for it in phpstan.neon.dist. +# +# Generated by `composer phpstan:baselines`. Do not edit by hand; regenerate with +# +# composer phpstan:baselines -- --identifier=arrayValues.list +# +# which reruns the analysis with this file suppressed so the errors surface again. + +parameters: + ignoreErrors: + - + message: '#^Parameter \#1 \$array \(non\-empty\-list\\) of array_values is already a list, call has no effect\.$#' + identifier: arrayValues.list + count: 1 + path: ../../../src/wp-admin/includes/image.php diff --git a/tests/phpstan/baselines/assign.propertyType.neon b/tests/phpstan/baselines/assign.propertyType.neon new file mode 100644 index 0000000000000..f53f802b7d1da --- /dev/null +++ b/tests/phpstan/baselines/assign.propertyType.neon @@ -0,0 +1,160 @@ +# PHPStan baseline for the `assign.propertyType` errors in WordPress core. +# +# https://phpstan.org/error-identifiers/assign.propertyType +# +# Each entry is scoped to a single file and carries an exact occurrence count, +# so that a new instance is reported as a new error rather than being absorbed +# silently. Fixing an occurrence therefore means decrementing or removing its +# entry here as part of the same change. +# +# The goal is to empty this file and delete it, along with the `includes` entry +# for it in phpstan.neon.dist. +# +# Generated by `composer phpstan:baselines`. Do not edit by hand; regenerate with +# +# composer phpstan:baselines -- --identifier=assign.propertyType +# +# which reruns the analysis with this file suppressed so the errors surface again. + +parameters: + ignoreErrors: + - + message: '#^Property WP_Comment\:\:\$comment_ID \(numeric\-string\) does not accept int\.$#' + identifier: assign.propertyType + count: 1 + path: ../../../src/wp-admin/includes/comment.php + - + message: '#^Property WP_Comment\:\:\$comment_post_ID \(numeric\-string\) does not accept int\.$#' + identifier: assign.propertyType + count: 1 + path: ../../../src/wp-admin/includes/comment.php + - + message: '#^Property WP_Block_Template\:\:\$author \(int\|null\) does not accept string\.$#' + identifier: assign.propertyType + count: 1 + path: ../../../src/wp-includes/block-template-utils.php + - + message: '#^Property WP_Customize_Control\:\:\$settings \(array\) does not accept string\.$#' + identifier: assign.propertyType + count: 1 + path: ../../../src/wp-includes/class-wp-customize-control.php + - + message: '#^Property WP_Customize_Setting\:\:\$default \(string\) does not accept stdClass\.$#' + identifier: assign.propertyType + count: 1 + path: ../../../src/wp-includes/class-wp-customize-setting.php + - + message: '#^Property WP_Image_Editor_Imagick\:\:\$image \(Imagick\) does not accept null\.$#' + identifier: assign.propertyType + count: 1 + path: ../../../src/wp-includes/class-wp-image-editor-imagick.php + - + message: '#^Property WP_Query\:\:\$posts \(array\\|null\) does not accept array\\.$#' + identifier: assign.propertyType + count: 1 + path: ../../../src/wp-includes/class-wp-query.php + - + message: '#^Property WP_Query\:\:\$posts \(array\\|null\) does not accept list\\|null\.$#' + identifier: assign.propertyType + count: 2 + path: ../../../src/wp-includes/class-wp-query.php + - + message: '#^Property WP_Query\:\:\$posts \(array\\|null\) does not accept list\\.$#' + identifier: assign.propertyType + count: 2 + path: ../../../src/wp-includes/class-wp-query.php + - + message: '#^Property WP_Rewrite\:\:\$rules \(array\\) does not accept string\.$#' + identifier: assign.propertyType + count: 1 + path: ../../../src/wp-includes/class-wp-rewrite.php + - + message: '#^Property WP_Term_Query\:\:\$terms \(array\) does not accept null\.$#' + identifier: assign.propertyType + count: 1 + path: ../../../src/wp-includes/class-wp-term-query.php + - + message: '#^Static property WP_Theme_JSON_Resolver\:\:\$blocks \(WP_Theme_JSON\) does not accept null\.$#' + identifier: assign.propertyType + count: 1 + path: ../../../src/wp-includes/class-wp-theme-json-resolver.php + - + message: '#^Static property WP_Theme_JSON_Resolver\:\:\$core \(WP_Theme_JSON\) does not accept null\.$#' + identifier: assign.propertyType + count: 1 + path: ../../../src/wp-includes/class-wp-theme-json-resolver.php + - + message: '#^Static property WP_Theme_JSON_Resolver\:\:\$i18n_schema \(array\) does not accept null\.$#' + identifier: assign.propertyType + count: 1 + path: ../../../src/wp-includes/class-wp-theme-json-resolver.php + - + message: '#^Static property WP_Theme_JSON_Resolver\:\:\$theme \(WP_Theme_JSON\) does not accept null\.$#' + identifier: assign.propertyType + count: 1 + path: ../../../src/wp-includes/class-wp-theme-json-resolver.php + - + message: '#^Static property WP_Theme_JSON_Resolver\:\:\$user \(WP_Theme_JSON\) does not accept null\.$#' + identifier: assign.propertyType + count: 1 + path: ../../../src/wp-includes/class-wp-theme-json-resolver.php + - + message: '#^Static property WP_Theme_JSON_Resolver\:\:\$user_custom_post_type_id \(int\) does not accept null\.$#' + identifier: assign.propertyType + count: 1 + path: ../../../src/wp-includes/class-wp-theme-json-resolver.php + - + message: '#^Property WP_User\:\:\$roles \(array\\) does not accept array\\.$#' + identifier: assign.propertyType + count: 1 + path: ../../../src/wp-includes/class-wp-user.php + - + message: '#^Property wpdb\:\:\$col_info \(array\) does not accept null\.$#' + identifier: assign.propertyType + count: 1 + path: ../../../src/wp-includes/class-wpdb.php + - + message: '#^Property wpdb\:\:\$last_query \(string\) does not accept null\.$#' + identifier: assign.propertyType + count: 1 + path: ../../../src/wp-includes/class-wpdb.php + - + message: '#^Property WP_Customize_Header_Image_Control\:\:\$default_headers \(string\) does not accept array\.$#' + identifier: assign.propertyType + count: 1 + path: ../../../src/wp-includes/customize/class-wp-customize-header-image-control.php + - + message: '#^Property WP_Customize_Header_Image_Control\:\:\$uploaded_headers \(string\) does not accept array\.$#' + identifier: assign.propertyType + count: 1 + path: ../../../src/wp-includes/customize/class-wp-customize-header-image-control.php + - + message: '#^Property WP_HTML_Tag_Processor\:\:\$is_closing_tag \(bool\) does not accept null\.$#' + identifier: assign.propertyType + count: 1 + path: ../../../src/wp-includes/html-api/class-wp-html-tag-processor.php + - + message: '#^Property WP_Translation_File\:\:\$entries \(array\\) does not accept array\\>\.$#' + identifier: assign.propertyType + count: 1 + path: ../../../src/wp-includes/l10n/class-wp-translation-file.php + - + message: '#^Property WP_REST_Autosaves_Controller\:\:\$revisions_controller \(WP_REST_Revisions_Controller\) does not accept WP_REST_Controller\.$#' + identifier: assign.propertyType + count: 1 + path: ../../../src/wp-includes/rest-api/endpoints/class-wp-rest-autosaves-controller.php + - + message: '#^Property WP_REST_Template_Autosaves_Controller\:\:\$revisions_controller \(WP_REST_Revisions_Controller\) does not accept WP_REST_Controller\.$#' + identifier: assign.propertyType + count: 1 + path: ../../../src/wp-includes/rest-api/endpoints/class-wp-rest-template-autosaves-controller.php + - + message: '#^Property WP_Taxonomy\:\:\$labels \(stdClass\) does not accept array\.$#' + identifier: assign.propertyType + count: 1 + path: ../../../src/wp-includes/taxonomy.php + - + message: '#^Static property WP_Widget_Media\:\:\$l10n_defaults \(array\\) does not accept array\\.$#' + identifier: assign.propertyType + count: 1 + path: ../../../src/wp-includes/widgets/class-wp-widget-media.php diff --git a/tests/phpstan/baselines/binaryOp.invalid.neon b/tests/phpstan/baselines/binaryOp.invalid.neon new file mode 100644 index 0000000000000..167f159cffb34 --- /dev/null +++ b/tests/phpstan/baselines/binaryOp.invalid.neon @@ -0,0 +1,35 @@ +# PHPStan baseline for the `binaryOp.invalid` errors in WordPress core. +# +# https://phpstan.org/error-identifiers/binaryOp.invalid +# +# Each entry is scoped to a single file and carries an exact occurrence count, +# so that a new instance is reported as a new error rather than being absorbed +# silently. Fixing an occurrence therefore means decrementing or removing its +# entry here as part of the same change. +# +# The goal is to empty this file and delete it, along with the `includes` entry +# for it in phpstan.neon.dist. +# +# Generated by `composer phpstan:baselines`. Do not edit by hand; regenerate with +# +# composer phpstan:baselines -- --identifier=binaryOp.invalid +# +# which reruns the analysis with this file suppressed so the errors surface again. + +parameters: + ignoreErrors: + - + message: '#^Binary operation "/" between string and 2 results in an error\.$#' + identifier: binaryOp.invalid + count: 2 + path: ../../../src/wp-content/themes/twentytwenty/functions.php + - + message: '#^Binary operation "\+" between array\\|WP_Comment\>\|int\<1, max\> and 1 results in an error\.$#' + identifier: binaryOp.invalid + count: 1 + path: ../../../src/wp-includes/comment.php + - + message: '#^Binary operation "\+" between string and int results in an error\.$#' + identifier: binaryOp.invalid + count: 1 + path: ../../../src/wp-includes/user.php diff --git a/tests/phpstan/baselines/booleanAnd.alwaysFalse.neon b/tests/phpstan/baselines/booleanAnd.alwaysFalse.neon new file mode 100644 index 0000000000000..4bbff3cdb566b --- /dev/null +++ b/tests/phpstan/baselines/booleanAnd.alwaysFalse.neon @@ -0,0 +1,30 @@ +# PHPStan baseline for the `booleanAnd.alwaysFalse` errors in WordPress core. +# +# https://phpstan.org/error-identifiers/booleanAnd.alwaysFalse +# +# Each entry is scoped to a single file and carries an exact occurrence count, +# so that a new instance is reported as a new error rather than being absorbed +# silently. Fixing an occurrence therefore means decrementing or removing its +# entry here as part of the same change. +# +# The goal is to empty this file and delete it, along with the `includes` entry +# for it in phpstan.neon.dist. +# +# Generated by `composer phpstan:baselines`. Do not edit by hand; regenerate with +# +# composer phpstan:baselines -- --identifier=booleanAnd.alwaysFalse +# +# which reruns the analysis with this file suppressed so the errors surface again. + +parameters: + ignoreErrors: + - + message: '#^Result of && is always false\.$#' + identifier: booleanAnd.alwaysFalse + count: 1 + path: ../../../src/wp-admin/themes.php + - + message: '#^Result of && is always false\.$#' + identifier: booleanAnd.alwaysFalse + count: 1 + path: ../../../src/wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php diff --git a/tests/phpstan/baselines/booleanAnd.alwaysTrue.neon b/tests/phpstan/baselines/booleanAnd.alwaysTrue.neon new file mode 100644 index 0000000000000..52bae4992b186 --- /dev/null +++ b/tests/phpstan/baselines/booleanAnd.alwaysTrue.neon @@ -0,0 +1,25 @@ +# PHPStan baseline for the `booleanAnd.alwaysTrue` errors in WordPress core. +# +# https://phpstan.org/error-identifiers/booleanAnd.alwaysTrue +# +# Each entry is scoped to a single file and carries an exact occurrence count, +# so that a new instance is reported as a new error rather than being absorbed +# silently. Fixing an occurrence therefore means decrementing or removing its +# entry here as part of the same change. +# +# The goal is to empty this file and delete it, along with the `includes` entry +# for it in phpstan.neon.dist. +# +# Generated by `composer phpstan:baselines`. Do not edit by hand; regenerate with +# +# composer phpstan:baselines -- --identifier=booleanAnd.alwaysTrue +# +# which reruns the analysis with this file suppressed so the errors surface again. + +parameters: + ignoreErrors: + - + message: '#^Result of && is always true\.$#' + identifier: booleanAnd.alwaysTrue + count: 2 + path: ../../../src/wp-includes/html-api/class-wp-html-tag-processor.php diff --git a/tests/phpstan/baselines/booleanAnd.leftAlwaysTrue.neon b/tests/phpstan/baselines/booleanAnd.leftAlwaysTrue.neon new file mode 100644 index 0000000000000..c64a48177e085 --- /dev/null +++ b/tests/phpstan/baselines/booleanAnd.leftAlwaysTrue.neon @@ -0,0 +1,45 @@ +# PHPStan baseline for the `booleanAnd.leftAlwaysTrue` errors in WordPress core. +# +# https://phpstan.org/error-identifiers/booleanAnd.leftAlwaysTrue +# +# Each entry is scoped to a single file and carries an exact occurrence count, +# so that a new instance is reported as a new error rather than being absorbed +# silently. Fixing an occurrence therefore means decrementing or removing its +# entry here as part of the same change. +# +# The goal is to empty this file and delete it, along with the `includes` entry +# for it in phpstan.neon.dist. +# +# Generated by `composer phpstan:baselines`. Do not edit by hand; regenerate with +# +# composer phpstan:baselines -- --identifier=booleanAnd.leftAlwaysTrue +# +# which reruns the analysis with this file suppressed so the errors surface again. + +parameters: + ignoreErrors: + - + message: '#^Left side of && is always true\.$#' + identifier: booleanAnd.leftAlwaysTrue + count: 1 + path: ../../../src/wp-admin/network/users.php + - + message: '#^Left side of && is always true\.$#' + identifier: booleanAnd.leftAlwaysTrue + count: 1 + path: ../../../src/wp-admin/themes.php + - + message: '#^Left side of && is always true\.$#' + identifier: booleanAnd.leftAlwaysTrue + count: 1 + path: ../../../src/wp-includes/block-template-utils.php + - + message: '#^Left side of && is always true\.$#' + identifier: booleanAnd.leftAlwaysTrue + count: 1 + path: ../../../src/wp-includes/canonical.php + - + message: '#^Left side of && is always true\.$#' + identifier: booleanAnd.leftAlwaysTrue + count: 1 + path: ../../../src/wp-includes/html-api/class-wp-html-processor.php diff --git a/tests/phpstan/baselines/booleanAnd.rightAlwaysFalse.neon b/tests/phpstan/baselines/booleanAnd.rightAlwaysFalse.neon new file mode 100644 index 0000000000000..367c8dd35051c --- /dev/null +++ b/tests/phpstan/baselines/booleanAnd.rightAlwaysFalse.neon @@ -0,0 +1,25 @@ +# PHPStan baseline for the `booleanAnd.rightAlwaysFalse` errors in WordPress core. +# +# https://phpstan.org/error-identifiers/booleanAnd.rightAlwaysFalse +# +# Each entry is scoped to a single file and carries an exact occurrence count, +# so that a new instance is reported as a new error rather than being absorbed +# silently. Fixing an occurrence therefore means decrementing or removing its +# entry here as part of the same change. +# +# The goal is to empty this file and delete it, along with the `includes` entry +# for it in phpstan.neon.dist. +# +# Generated by `composer phpstan:baselines`. Do not edit by hand; regenerate with +# +# composer phpstan:baselines -- --identifier=booleanAnd.rightAlwaysFalse +# +# which reruns the analysis with this file suppressed so the errors surface again. + +parameters: + ignoreErrors: + - + message: '#^Right side of && is always false\.$#' + identifier: booleanAnd.rightAlwaysFalse + count: 1 + path: ../../../src/wp-includes/class-wpdb.php diff --git a/tests/phpstan/baselines/booleanAnd.rightAlwaysTrue.neon b/tests/phpstan/baselines/booleanAnd.rightAlwaysTrue.neon new file mode 100644 index 0000000000000..64071efcf3d23 --- /dev/null +++ b/tests/phpstan/baselines/booleanAnd.rightAlwaysTrue.neon @@ -0,0 +1,65 @@ +# PHPStan baseline for the `booleanAnd.rightAlwaysTrue` errors in WordPress core. +# +# https://phpstan.org/error-identifiers/booleanAnd.rightAlwaysTrue +# +# Each entry is scoped to a single file and carries an exact occurrence count, +# so that a new instance is reported as a new error rather than being absorbed +# silently. Fixing an occurrence therefore means decrementing or removing its +# entry here as part of the same change. +# +# The goal is to empty this file and delete it, along with the `includes` entry +# for it in phpstan.neon.dist. +# +# Generated by `composer phpstan:baselines`. Do not edit by hand; regenerate with +# +# composer phpstan:baselines -- --identifier=booleanAnd.rightAlwaysTrue +# +# which reruns the analysis with this file suppressed so the errors surface again. + +parameters: + ignoreErrors: + - + message: '#^Right side of && is always true\.$#' + identifier: booleanAnd.rightAlwaysTrue + count: 1 + path: ../../../src/wp-admin/includes/class-wp-site-health.php + - + message: '#^Right side of && is always true\.$#' + identifier: booleanAnd.rightAlwaysTrue + count: 1 + path: ../../../src/wp-admin/includes/schema.php + - + message: '#^Right side of && is always true\.$#' + identifier: booleanAnd.rightAlwaysTrue + count: 1 + path: ../../../src/wp-content/themes/twentynineteen/header.php + - + message: '#^Right side of && is always true\.$#' + identifier: booleanAnd.rightAlwaysTrue + count: 1 + path: ../../../src/wp-includes/block-supports/typography.php + - + message: '#^Right side of && is always true\.$#' + identifier: booleanAnd.rightAlwaysTrue + count: 1 + path: ../../../src/wp-includes/class-wp-walker.php + - + message: '#^Right side of && is always true\.$#' + identifier: booleanAnd.rightAlwaysTrue + count: 2 + path: ../../../src/wp-includes/functions.php + - + message: '#^Right side of && is always true\.$#' + identifier: booleanAnd.rightAlwaysTrue + count: 3 + path: ../../../src/wp-includes/l10n.php + - + message: '#^Right side of && is always true\.$#' + identifier: booleanAnd.rightAlwaysTrue + count: 4 + path: ../../../src/wp-includes/load.php + - + message: '#^Right side of && is always true\.$#' + identifier: booleanAnd.rightAlwaysTrue + count: 2 + path: ../../../src/wp-includes/user.php diff --git a/tests/phpstan/baselines/booleanNot.alwaysFalse.neon b/tests/phpstan/baselines/booleanNot.alwaysFalse.neon new file mode 100644 index 0000000000000..57e8a715cfc8d --- /dev/null +++ b/tests/phpstan/baselines/booleanNot.alwaysFalse.neon @@ -0,0 +1,50 @@ +# PHPStan baseline for the `booleanNot.alwaysFalse` errors in WordPress core. +# +# https://phpstan.org/error-identifiers/booleanNot.alwaysFalse +# +# Each entry is scoped to a single file and carries an exact occurrence count, +# so that a new instance is reported as a new error rather than being absorbed +# silently. Fixing an occurrence therefore means decrementing or removing its +# entry here as part of the same change. +# +# The goal is to empty this file and delete it, along with the `includes` entry +# for it in phpstan.neon.dist. +# +# Generated by `composer phpstan:baselines`. Do not edit by hand; regenerate with +# +# composer phpstan:baselines -- --identifier=booleanNot.alwaysFalse +# +# which reruns the analysis with this file suppressed so the errors surface again. + +parameters: + ignoreErrors: + - + message: '#^Negated boolean expression is always false\.$#' + identifier: booleanNot.alwaysFalse + count: 1 + path: ../../../src/wp-admin/includes/theme.php + - + message: '#^Negated boolean expression is always false\.$#' + identifier: booleanNot.alwaysFalse + count: 1 + path: ../../../src/wp-admin/link-manager.php + - + message: '#^Negated boolean expression is always false\.$#' + identifier: booleanNot.alwaysFalse + count: 2 + path: ../../../src/wp-admin/network/users.php + - + message: '#^Negated boolean expression is always false\.$#' + identifier: booleanNot.alwaysFalse + count: 1 + path: ../../../src/wp-admin/plugins.php + - + message: '#^Negated boolean expression is always false\.$#' + identifier: booleanNot.alwaysFalse + count: 1 + path: ../../../src/wp-includes/class-wp-comment-query.php + - + message: '#^Negated boolean expression is always false\.$#' + identifier: booleanNot.alwaysFalse + count: 1 + path: ../../../src/wp-includes/nav-menu.php diff --git a/tests/phpstan/baselines/booleanNot.alwaysTrue.neon b/tests/phpstan/baselines/booleanNot.alwaysTrue.neon new file mode 100644 index 0000000000000..ae0b0afe52c56 --- /dev/null +++ b/tests/phpstan/baselines/booleanNot.alwaysTrue.neon @@ -0,0 +1,60 @@ +# PHPStan baseline for the `booleanNot.alwaysTrue` errors in WordPress core. +# +# https://phpstan.org/error-identifiers/booleanNot.alwaysTrue +# +# Each entry is scoped to a single file and carries an exact occurrence count, +# so that a new instance is reported as a new error rather than being absorbed +# silently. Fixing an occurrence therefore means decrementing or removing its +# entry here as part of the same change. +# +# The goal is to empty this file and delete it, along with the `includes` entry +# for it in phpstan.neon.dist. +# +# Generated by `composer phpstan:baselines`. Do not edit by hand; regenerate with +# +# composer phpstan:baselines -- --identifier=booleanNot.alwaysTrue +# +# which reruns the analysis with this file suppressed so the errors surface again. + +parameters: + ignoreErrors: + - + message: '#^Negated boolean expression is always true\.$#' + identifier: booleanNot.alwaysTrue + count: 1 + path: ../../../src/wp-admin/includes/class-custom-image-header.php + - + message: '#^Negated boolean expression is always true\.$#' + identifier: booleanNot.alwaysTrue + count: 1 + path: ../../../src/wp-admin/includes/class-language-pack-upgrader.php + - + message: '#^Negated boolean expression is always true\.$#' + identifier: booleanNot.alwaysTrue + count: 1 + path: ../../../src/wp-admin/includes/class-wp-upgrader.php + - + message: '#^Negated boolean expression is always true\.$#' + identifier: booleanNot.alwaysTrue + count: 1 + path: ../../../src/wp-admin/includes/file.php + - + message: '#^Negated boolean expression is always true\.$#' + identifier: booleanNot.alwaysTrue + count: 1 + path: ../../../src/wp-includes/class-wp-block-templates-registry.php + - + message: '#^Negated boolean expression is always true\.$#' + identifier: booleanNot.alwaysTrue + count: 1 + path: ../../../src/wp-includes/class-wp-block.php + - + message: '#^Negated boolean expression is always true\.$#' + identifier: booleanNot.alwaysTrue + count: 1 + path: ../../../src/wp-includes/general-template.php + - + message: '#^Negated boolean expression is always true\.$#' + identifier: booleanNot.alwaysTrue + count: 1 + path: ../../../src/wp-includes/option.php diff --git a/tests/phpstan/baselines/booleanOr.alwaysFalse.neon b/tests/phpstan/baselines/booleanOr.alwaysFalse.neon new file mode 100644 index 0000000000000..86e1740697619 --- /dev/null +++ b/tests/phpstan/baselines/booleanOr.alwaysFalse.neon @@ -0,0 +1,25 @@ +# PHPStan baseline for the `booleanOr.alwaysFalse` errors in WordPress core. +# +# https://phpstan.org/error-identifiers/booleanOr.alwaysFalse +# +# Each entry is scoped to a single file and carries an exact occurrence count, +# so that a new instance is reported as a new error rather than being absorbed +# silently. Fixing an occurrence therefore means decrementing or removing its +# entry here as part of the same change. +# +# The goal is to empty this file and delete it, along with the `includes` entry +# for it in phpstan.neon.dist. +# +# Generated by `composer phpstan:baselines`. Do not edit by hand; regenerate with +# +# composer phpstan:baselines -- --identifier=booleanOr.alwaysFalse +# +# which reruns the analysis with this file suppressed so the errors surface again. + +parameters: + ignoreErrors: + - + message: '#^Result of \|\| is always false\.$#' + identifier: booleanOr.alwaysFalse + count: 1 + path: ../../../src/wp-includes/class-wp-http-cookie.php diff --git a/tests/phpstan/baselines/booleanOr.alwaysTrue.neon b/tests/phpstan/baselines/booleanOr.alwaysTrue.neon new file mode 100644 index 0000000000000..6ee9845afe5be --- /dev/null +++ b/tests/phpstan/baselines/booleanOr.alwaysTrue.neon @@ -0,0 +1,30 @@ +# PHPStan baseline for the `booleanOr.alwaysTrue` errors in WordPress core. +# +# https://phpstan.org/error-identifiers/booleanOr.alwaysTrue +# +# Each entry is scoped to a single file and carries an exact occurrence count, +# so that a new instance is reported as a new error rather than being absorbed +# silently. Fixing an occurrence therefore means decrementing or removing its +# entry here as part of the same change. +# +# The goal is to empty this file and delete it, along with the `includes` entry +# for it in phpstan.neon.dist. +# +# Generated by `composer phpstan:baselines`. Do not edit by hand; regenerate with +# +# composer phpstan:baselines -- --identifier=booleanOr.alwaysTrue +# +# which reruns the analysis with this file suppressed so the errors surface again. + +parameters: + ignoreErrors: + - + message: '#^Result of \|\| is always true\.$#' + identifier: booleanOr.alwaysTrue + count: 1 + path: ../../../src/wp-includes/block-supports/position.php + - + message: '#^Result of \|\| is always true\.$#' + identifier: booleanOr.alwaysTrue + count: 1 + path: ../../../src/wp-includes/class-wp-block.php diff --git a/tests/phpstan/baselines/booleanOr.rightAlwaysTrue.neon b/tests/phpstan/baselines/booleanOr.rightAlwaysTrue.neon new file mode 100644 index 0000000000000..3234f5ad209b0 --- /dev/null +++ b/tests/phpstan/baselines/booleanOr.rightAlwaysTrue.neon @@ -0,0 +1,25 @@ +# PHPStan baseline for the `booleanOr.rightAlwaysTrue` errors in WordPress core. +# +# https://phpstan.org/error-identifiers/booleanOr.rightAlwaysTrue +# +# Each entry is scoped to a single file and carries an exact occurrence count, +# so that a new instance is reported as a new error rather than being absorbed +# silently. Fixing an occurrence therefore means decrementing or removing its +# entry here as part of the same change. +# +# The goal is to empty this file and delete it, along with the `includes` entry +# for it in phpstan.neon.dist. +# +# Generated by `composer phpstan:baselines`. Do not edit by hand; regenerate with +# +# composer phpstan:baselines -- --identifier=booleanOr.rightAlwaysTrue +# +# which reruns the analysis with this file suppressed so the errors surface again. + +parameters: + ignoreErrors: + - + message: '#^Right side of \|\| is always true\.$#' + identifier: booleanOr.rightAlwaysTrue + count: 1 + path: ../../../src/wp-includes/class-wp-customize-manager.php diff --git a/tests/phpstan/baselines/catch.neverThrown.neon b/tests/phpstan/baselines/catch.neverThrown.neon new file mode 100644 index 0000000000000..30c8a4a7cfe4b --- /dev/null +++ b/tests/phpstan/baselines/catch.neverThrown.neon @@ -0,0 +1,25 @@ +# PHPStan baseline for the `catch.neverThrown` errors in WordPress core. +# +# https://phpstan.org/error-identifiers/catch.neverThrown +# +# Each entry is scoped to a single file and carries an exact occurrence count, +# so that a new instance is reported as a new error rather than being absorbed +# silently. Fixing an occurrence therefore means decrementing or removing its +# entry here as part of the same change. +# +# The goal is to empty this file and delete it, along with the `includes` entry +# for it in phpstan.neon.dist. +# +# Generated by `composer phpstan:baselines`. Do not edit by hand; regenerate with +# +# composer phpstan:baselines -- --identifier=catch.neverThrown +# +# which reruns the analysis with this file suppressed so the errors surface again. + +parameters: + ignoreErrors: + - + message: '#^Dead catch \- Exception is never thrown in the try block\.$#' + identifier: catch.neverThrown + count: 1 + path: ../../../src/wp-includes/html-api/class-wp-html-processor.php diff --git a/tests/phpstan/baselines/class.nameCase.neon b/tests/phpstan/baselines/class.nameCase.neon new file mode 100644 index 0000000000000..dd88c5b677aa8 --- /dev/null +++ b/tests/phpstan/baselines/class.nameCase.neon @@ -0,0 +1,25 @@ +# PHPStan baseline for the `class.nameCase` errors in WordPress core. +# +# https://phpstan.org/error-identifiers/class.nameCase +# +# Each entry is scoped to a single file and carries an exact occurrence count, +# so that a new instance is reported as a new error rather than being absorbed +# silently. Fixing an occurrence therefore means decrementing or removing its +# entry here as part of the same change. +# +# The goal is to empty this file and delete it, along with the `includes` entry +# for it in phpstan.neon.dist. +# +# Generated by `composer phpstan:baselines`. Do not edit by hand; regenerate with +# +# composer phpstan:baselines -- --identifier=class.nameCase +# +# which reruns the analysis with this file suppressed so the errors surface again. + +parameters: + ignoreErrors: + - + message: '#^Class MO referenced with incorrect case\: Mo\.$#' + identifier: class.nameCase + count: 1 + path: ../../../src/wp-includes/class-wp-locale-switcher.php diff --git a/tests/phpstan/baselines/class.notFound.neon b/tests/phpstan/baselines/class.notFound.neon new file mode 100644 index 0000000000000..560ceafb56573 --- /dev/null +++ b/tests/phpstan/baselines/class.notFound.neon @@ -0,0 +1,90 @@ +# PHPStan baseline for the `class.notFound` errors in WordPress core. +# +# https://phpstan.org/error-identifiers/class.notFound +# +# Each entry is scoped to a single file and carries an exact occurrence count, +# so that a new instance is reported as a new error rather than being absorbed +# silently. Fixing an occurrence therefore means decrementing or removing its +# entry here as part of the same change. +# +# The goal is to empty this file and delete it, along with the `includes` entry +# for it in phpstan.neon.dist. +# +# Generated by `composer phpstan:baselines`. Do not edit by hand; regenerate with +# +# composer phpstan:baselines -- --identifier=class.notFound +# +# which reruns the analysis with this file suppressed so the errors surface again. + +parameters: + ignoreErrors: + - + message: '#^Property WP_Filesystem_FTPext\:\:\$link has unknown class FTP\\Connection as its type\.$#' + identifier: class.notFound + count: 1 + path: ../../../src/wp-admin/includes/class-wp-filesystem-ftpext.php + - + message: '#^Function _crop_image_resource\(\) has invalid return type GdImage\.$#' + identifier: class.notFound + count: 1 + path: ../../../src/wp-admin/includes/image-edit.php + - + message: '#^Function _flip_image_resource\(\) has invalid return type GdImage\.$#' + identifier: class.notFound + count: 1 + path: ../../../src/wp-admin/includes/image-edit.php + - + message: '#^Function _rotate_image_resource\(\) has invalid return type GdImage\.$#' + identifier: class.notFound + count: 1 + path: ../../../src/wp-admin/includes/image-edit.php + - + message: '#^Parameter \$img of function _crop_image_resource\(\) has invalid type GdImage\.$#' + identifier: class.notFound + count: 1 + path: ../../../src/wp-admin/includes/image-edit.php + - + message: '#^Parameter \$img of function _flip_image_resource\(\) has invalid type GdImage\.$#' + identifier: class.notFound + count: 1 + path: ../../../src/wp-admin/includes/image-edit.php + - + message: '#^Parameter \$img of function _rotate_image_resource\(\) has invalid type GdImage\.$#' + identifier: class.notFound + count: 1 + path: ../../../src/wp-admin/includes/image-edit.php + - + message: '#^Function load_image_to_edit\(\) has invalid return type GdImage\.$#' + identifier: class.notFound + count: 1 + path: ../../../src/wp-admin/includes/image.php + - + message: '#^Call to method html\(\) on an unknown class WP_Press_This_Plugin\.$#' + identifier: class.notFound + count: 1 + path: ../../../src/wp-admin/press-this.php + - + message: '#^Method WP_Image_Editor_GD\:\:_resize\(\) has invalid return type GdImage\.$#' + identifier: class.notFound + count: 1 + path: ../../../src/wp-includes/class-wp-image-editor-gd.php + - + message: '#^Parameter \$image of method WP_Image_Editor_GD\:\:_save\(\) has invalid type GdImage\.$#' + identifier: class.notFound + count: 1 + path: ../../../src/wp-includes/class-wp-image-editor-gd.php + - + message: '#^Property WP_Image_Editor_GD\:\:\$image has unknown class GdImage as its type\.$#' + identifier: class.notFound + count: 1 + path: ../../../src/wp-includes/class-wp-image-editor-gd.php + - + message: '#^Function wp_imagecreatetruecolor\(\) has invalid return type GdImage\.$#' + identifier: class.notFound + count: 1 + path: ../../../src/wp-includes/media.php + - + message: '#^Parameter \$image of function is_gd_image\(\) has invalid type GdImage\.$#' + identifier: class.notFound + count: 1 + path: ../../../src/wp-includes/media.php diff --git a/tests/phpstan/baselines/deadCode.unreachable.neon b/tests/phpstan/baselines/deadCode.unreachable.neon new file mode 100644 index 0000000000000..f8a2c6671a783 --- /dev/null +++ b/tests/phpstan/baselines/deadCode.unreachable.neon @@ -0,0 +1,305 @@ +# PHPStan baseline for the `deadCode.unreachable` errors in WordPress core. +# +# https://phpstan.org/error-identifiers/deadCode.unreachable +# +# Each entry is scoped to a single file and carries an exact occurrence count, +# so that a new instance is reported as a new error rather than being absorbed +# silently. Fixing an occurrence therefore means decrementing or removing its +# entry here as part of the same change. +# +# The goal is to empty this file and delete it, along with the `includes` entry +# for it in phpstan.neon.dist. +# +# Generated by `composer phpstan:baselines`. Do not edit by hand; regenerate with +# +# composer phpstan:baselines -- --identifier=deadCode.unreachable +# +# which reruns the analysis with this file suppressed so the errors surface again. + +parameters: + ignoreErrors: + - + message: '#^Unreachable statement \- code above always terminates\.$#' + identifier: deadCode.unreachable + count: 1 + path: ../../../src/wp-admin/about.php + - + message: '#^Unreachable statement \- code above always terminates\.$#' + identifier: deadCode.unreachable + count: 1 + path: ../../../src/wp-admin/credits.php + - + message: '#^Unreachable statement \- code above always terminates\.$#' + identifier: deadCode.unreachable + count: 3 + path: ../../../src/wp-admin/includes/ajax-actions.php + - + message: '#^Unreachable statement \- code above always terminates\.$#' + identifier: deadCode.unreachable + count: 1 + path: ../../../src/wp-admin/includes/class-wp-internal-pointers.php + - + message: '#^Unreachable statement \- code above always terminates\.$#' + identifier: deadCode.unreachable + count: 2 + path: ../../../src/wp-admin/includes/dashboard.php + - + message: '#^Unreachable statement \- code above always terminates\.$#' + identifier: deadCode.unreachable + count: 2 + path: ../../../src/wp-admin/post.php + - + message: '#^Unreachable statement \- code above always terminates\.$#' + identifier: deadCode.unreachable + count: 1 + path: ../../../src/wp-content/themes/twentyeleven/archive.php + - + message: '#^Unreachable statement \- code above always terminates\.$#' + identifier: deadCode.unreachable + count: 1 + path: ../../../src/wp-content/themes/twentyeleven/author.php + - + message: '#^Unreachable statement \- code above always terminates\.$#' + identifier: deadCode.unreachable + count: 1 + path: ../../../src/wp-content/themes/twentyeleven/category.php + - + message: '#^Unreachable statement \- code above always terminates\.$#' + identifier: deadCode.unreachable + count: 1 + path: ../../../src/wp-content/themes/twentyeleven/inc/widgets.php + - + message: '#^Unreachable statement \- code above always terminates\.$#' + identifier: deadCode.unreachable + count: 1 + path: ../../../src/wp-content/themes/twentyeleven/index.php + - + message: '#^Unreachable statement \- code above always terminates\.$#' + identifier: deadCode.unreachable + count: 1 + path: ../../../src/wp-content/themes/twentyeleven/search.php + - + message: '#^Unreachable statement \- code above always terminates\.$#' + identifier: deadCode.unreachable + count: 1 + path: ../../../src/wp-content/themes/twentyeleven/showcase.php + - + message: '#^Unreachable statement \- code above always terminates\.$#' + identifier: deadCode.unreachable + count: 1 + path: ../../../src/wp-content/themes/twentyeleven/tag.php + - + message: '#^Unreachable statement \- code above always terminates\.$#' + identifier: deadCode.unreachable + count: 1 + path: ../../../src/wp-content/themes/twentyfifteen/archive.php + - + message: '#^Unreachable statement \- code above always terminates\.$#' + identifier: deadCode.unreachable + count: 1 + path: ../../../src/wp-content/themes/twentyfifteen/index.php + - + message: '#^Unreachable statement \- code above always terminates\.$#' + identifier: deadCode.unreachable + count: 1 + path: ../../../src/wp-content/themes/twentyfifteen/search.php + - + message: '#^Unreachable statement \- code above always terminates\.$#' + identifier: deadCode.unreachable + count: 1 + path: ../../../src/wp-content/themes/twentyfourteen/archive.php + - + message: '#^Unreachable statement \- code above always terminates\.$#' + identifier: deadCode.unreachable + count: 1 + path: ../../../src/wp-content/themes/twentyfourteen/author.php + - + message: '#^Unreachable statement \- code above always terminates\.$#' + identifier: deadCode.unreachable + count: 1 + path: ../../../src/wp-content/themes/twentyfourteen/category.php + - + message: '#^Unreachable statement \- code above always terminates\.$#' + identifier: deadCode.unreachable + count: 1 + path: ../../../src/wp-content/themes/twentyfourteen/inc/widgets.php + - + message: '#^Unreachable statement \- code above always terminates\.$#' + identifier: deadCode.unreachable + count: 1 + path: ../../../src/wp-content/themes/twentyfourteen/index.php + - + message: '#^Unreachable statement \- code above always terminates\.$#' + identifier: deadCode.unreachable + count: 1 + path: ../../../src/wp-content/themes/twentyfourteen/search.php + - + message: '#^Unreachable statement \- code above always terminates\.$#' + identifier: deadCode.unreachable + count: 1 + path: ../../../src/wp-content/themes/twentyfourteen/tag.php + - + message: '#^Unreachable statement \- code above always terminates\.$#' + identifier: deadCode.unreachable + count: 1 + path: ../../../src/wp-content/themes/twentyfourteen/taxonomy-post_format.php + - + message: '#^Unreachable statement \- code above always terminates\.$#' + identifier: deadCode.unreachable + count: 1 + path: ../../../src/wp-content/themes/twentynineteen/archive.php + - + message: '#^Unreachable statement \- code above always terminates\.$#' + identifier: deadCode.unreachable + count: 1 + path: ../../../src/wp-content/themes/twentynineteen/index.php + - + message: '#^Unreachable statement \- code above always terminates\.$#' + identifier: deadCode.unreachable + count: 1 + path: ../../../src/wp-content/themes/twentynineteen/search.php + - + message: '#^Unreachable statement \- code above always terminates\.$#' + identifier: deadCode.unreachable + count: 1 + path: ../../../src/wp-content/themes/twentyseventeen/archive.php + - + message: '#^Unreachable statement \- code above always terminates\.$#' + identifier: deadCode.unreachable + count: 1 + path: ../../../src/wp-content/themes/twentyseventeen/index.php + - + message: '#^Unreachable statement \- code above always terminates\.$#' + identifier: deadCode.unreachable + count: 1 + path: ../../../src/wp-content/themes/twentyseventeen/search.php + - + message: '#^Unreachable statement \- code above always terminates\.$#' + identifier: deadCode.unreachable + count: 1 + path: ../../../src/wp-content/themes/twentyseventeen/template-parts/page/content-front-page-panels.php + - + message: '#^Unreachable statement \- code above always terminates\.$#' + identifier: deadCode.unreachable + count: 1 + path: ../../../src/wp-content/themes/twentysixteen/archive.php + - + message: '#^Unreachable statement \- code above always terminates\.$#' + identifier: deadCode.unreachable + count: 1 + path: ../../../src/wp-content/themes/twentysixteen/index.php + - + message: '#^Unreachable statement \- code above always terminates\.$#' + identifier: deadCode.unreachable + count: 1 + path: ../../../src/wp-content/themes/twentysixteen/search.php + - + message: '#^Unreachable statement \- code above always terminates\.$#' + identifier: deadCode.unreachable + count: 1 + path: ../../../src/wp-content/themes/twentythirteen/archive.php + - + message: '#^Unreachable statement \- code above always terminates\.$#' + identifier: deadCode.unreachable + count: 1 + path: ../../../src/wp-content/themes/twentythirteen/author.php + - + message: '#^Unreachable statement \- code above always terminates\.$#' + identifier: deadCode.unreachable + count: 1 + path: ../../../src/wp-content/themes/twentythirteen/category.php + - + message: '#^Unreachable statement \- code above always terminates\.$#' + identifier: deadCode.unreachable + count: 1 + path: ../../../src/wp-content/themes/twentythirteen/index.php + - + message: '#^Unreachable statement \- code above always terminates\.$#' + identifier: deadCode.unreachable + count: 1 + path: ../../../src/wp-content/themes/twentythirteen/search.php + - + message: '#^Unreachable statement \- code above always terminates\.$#' + identifier: deadCode.unreachable + count: 1 + path: ../../../src/wp-content/themes/twentythirteen/tag.php + - + message: '#^Unreachable statement \- code above always terminates\.$#' + identifier: deadCode.unreachable + count: 1 + path: ../../../src/wp-content/themes/twentythirteen/taxonomy-post_format.php + - + message: '#^Unreachable statement \- code above always terminates\.$#' + identifier: deadCode.unreachable + count: 1 + path: ../../../src/wp-content/themes/twentytwelve/archive.php + - + message: '#^Unreachable statement \- code above always terminates\.$#' + identifier: deadCode.unreachable + count: 1 + path: ../../../src/wp-content/themes/twentytwelve/author.php + - + message: '#^Unreachable statement \- code above always terminates\.$#' + identifier: deadCode.unreachable + count: 1 + path: ../../../src/wp-content/themes/twentytwelve/category.php + - + message: '#^Unreachable statement \- code above always terminates\.$#' + identifier: deadCode.unreachable + count: 1 + path: ../../../src/wp-content/themes/twentytwelve/index.php + - + message: '#^Unreachable statement \- code above always terminates\.$#' + identifier: deadCode.unreachable + count: 1 + path: ../../../src/wp-content/themes/twentytwelve/search.php + - + message: '#^Unreachable statement \- code above always terminates\.$#' + identifier: deadCode.unreachable + count: 1 + path: ../../../src/wp-content/themes/twentytwelve/tag.php + - + message: '#^Unreachable statement \- code above always terminates\.$#' + identifier: deadCode.unreachable + count: 1 + path: ../../../src/wp-content/themes/twentytwentyone/archive.php + - + message: '#^Unreachable statement \- code above always terminates\.$#' + identifier: deadCode.unreachable + count: 1 + path: ../../../src/wp-content/themes/twentytwentyone/index.php + - + message: '#^Unreachable statement \- code above always terminates\.$#' + identifier: deadCode.unreachable + count: 1 + path: ../../../src/wp-content/themes/twentytwentyone/search.php + - + message: '#^Unreachable statement \- code above always terminates\.$#' + identifier: deadCode.unreachable + count: 1 + path: ../../../src/wp-includes/capabilities.php + - + message: '#^Unreachable statement \- code above always terminates\.$#' + identifier: deadCode.unreachable + count: 1 + path: ../../../src/wp-includes/class-wp-block.php + - + message: '#^Unreachable statement \- code above always terminates\.$#' + identifier: deadCode.unreachable + count: 31 + path: ../../../src/wp-includes/html-api/class-wp-html-processor.php + - + message: '#^Unreachable statement \- code above always terminates\.$#' + identifier: deadCode.unreachable + count: 1 + path: ../../../src/wp-includes/html-api/class-wp-html-tag-processor.php + - + message: '#^Unreachable statement \- code above always terminates\.$#' + identifier: deadCode.unreachable + count: 1 + path: ../../../src/wp-includes/pluggable.php + - + message: '#^Unreachable statement \- code above always terminates\.$#' + identifier: deadCode.unreachable + count: 1 + path: ../../../src/wp-includes/sitemaps/class-wp-sitemaps.php diff --git a/tests/phpstan/baselines/empty.offset.neon b/tests/phpstan/baselines/empty.offset.neon new file mode 100644 index 0000000000000..ac1410fad0869 --- /dev/null +++ b/tests/phpstan/baselines/empty.offset.neon @@ -0,0 +1,30 @@ +# PHPStan baseline for the `empty.offset` errors in WordPress core. +# +# https://phpstan.org/error-identifiers/empty.offset +# +# Each entry is scoped to a single file and carries an exact occurrence count, +# so that a new instance is reported as a new error rather than being absorbed +# silently. Fixing an occurrence therefore means decrementing or removing its +# entry here as part of the same change. +# +# The goal is to empty this file and delete it, along with the `includes` entry +# for it in phpstan.neon.dist. +# +# Generated by `composer phpstan:baselines`. Do not edit by hand; regenerate with +# +# composer phpstan:baselines -- --identifier=empty.offset +# +# which reruns the analysis with this file suppressed so the errors surface again. + +parameters: + ignoreErrors: + - + message: '#^Offset mixed on array\{\} in empty\(\) does not exist\.$#' + identifier: empty.offset + count: 1 + path: ../../../src/wp-admin/includes/class-wp-internal-pointers.php + - + message: '#^Offset ''created_timestamp'' on array\{\}\|array\{lossless\?\: mixed, bitrate\?\: int, bitrate_mode\?\: mixed, filesize\?\: int, mime_type\?\: mixed, length\?\: int, length_formatted\?\: mixed, width\?\: int, \.\.\.\} in empty\(\) does not exist\.$#' + identifier: empty.offset + count: 1 + path: ../../../src/wp-admin/includes/media.php diff --git a/tests/phpstan/baselines/empty.property.neon b/tests/phpstan/baselines/empty.property.neon new file mode 100644 index 0000000000000..5bc6d4e3cbdd3 --- /dev/null +++ b/tests/phpstan/baselines/empty.property.neon @@ -0,0 +1,65 @@ +# PHPStan baseline for the `empty.property` errors in WordPress core. +# +# https://phpstan.org/error-identifiers/empty.property +# +# Each entry is scoped to a single file and carries an exact occurrence count, +# so that a new instance is reported as a new error rather than being absorbed +# silently. Fixing an occurrence therefore means decrementing or removing its +# entry here as part of the same change. +# +# The goal is to empty this file and delete it, along with the `includes` entry +# for it in phpstan.neon.dist. +# +# Generated by `composer phpstan:baselines`. Do not edit by hand; regenerate with +# +# composer phpstan:baselines -- --identifier=empty.property +# +# which reruns the analysis with this file suppressed so the errors surface again. + +parameters: + ignoreErrors: + - + message: '#^Property WP_Block_Type\:\:\$render_callback \(callable\) in empty\(\) is not falsy\.$#' + identifier: empty.property + count: 1 + path: ../../../src/wp-includes/blocks.php + - + message: '#^Property WP_Customize_Control\:\:\$active_callback \(callable\) in empty\(\) is not falsy\.$#' + identifier: empty.property + count: 1 + path: ../../../src/wp-includes/class-wp-customize-control.php + - + message: '#^Property WP_Customize_Manager\:\:\$nav_menus \(WP_Customize_Nav_Menus\) in empty\(\) is not falsy\.$#' + identifier: empty.property + count: 4 + path: ../../../src/wp-includes/class-wp-customize-manager.php + - + message: '#^Property WP_Customize_Manager\:\:\$widgets \(WP_Customize_Widgets\) in empty\(\) is not falsy\.$#' + identifier: empty.property + count: 1 + path: ../../../src/wp-includes/class-wp-customize-manager.php + - + message: '#^Property WP_Customize_Panel\:\:\$active_callback \(callable\) in empty\(\) is not falsy\.$#' + identifier: empty.property + count: 1 + path: ../../../src/wp-includes/class-wp-customize-panel.php + - + message: '#^Property WP_Customize_Section\:\:\$active_callback \(callable\) in empty\(\) is not falsy\.$#' + identifier: empty.property + count: 1 + path: ../../../src/wp-includes/class-wp-customize-section.php + - + message: '#^Property WP_Customize_Manager\:\:\$nav_menus \(WP_Customize_Nav_Menus\) in empty\(\) is not falsy\.$#' + identifier: empty.property + count: 1 + path: ../../../src/wp-includes/customize/class-wp-customize-nav-menu-item-setting.php + - + message: '#^Property WP_Customize_Manager\:\:\$nav_menus \(WP_Customize_Nav_Menus\) in empty\(\) is not falsy\.$#' + identifier: empty.property + count: 1 + path: ../../../src/wp-includes/customize/class-wp-customize-nav-menu-setting.php + - + message: '#^Property WP_Customize_Partial\:\:\$render_callback \(callable\) in empty\(\) is not falsy\.$#' + identifier: empty.property + count: 2 + path: ../../../src/wp-includes/customize/class-wp-customize-partial.php diff --git a/tests/phpstan/baselines/empty.variable.neon b/tests/phpstan/baselines/empty.variable.neon new file mode 100644 index 0000000000000..28186fb9fc850 --- /dev/null +++ b/tests/phpstan/baselines/empty.variable.neon @@ -0,0 +1,65 @@ +# PHPStan baseline for the `empty.variable` errors in WordPress core. +# +# https://phpstan.org/error-identifiers/empty.variable +# +# Each entry is scoped to a single file and carries an exact occurrence count, +# so that a new instance is reported as a new error rather than being absorbed +# silently. Fixing an occurrence therefore means decrementing or removing its +# entry here as part of the same change. +# +# The goal is to empty this file and delete it, along with the `includes` entry +# for it in phpstan.neon.dist. +# +# Generated by `composer phpstan:baselines`. Do not edit by hand; regenerate with +# +# composer phpstan:baselines -- --identifier=empty.variable +# +# which reruns the analysis with this file suppressed so the errors surface again. + +parameters: + ignoreErrors: + - + message: '#^Variable \$class in empty\(\) always exists and is always falsy\.$#' + identifier: empty.variable + count: 1 + path: ../../../src/wp-admin/includes/class-wp-posts-list-table.php + - + message: '#^Variable \$title in empty\(\) always exists and is always falsy\.$#' + identifier: empty.variable + count: 1 + path: ../../../src/wp-admin/includes/plugin.php + - + message: '#^Variable \$parent_file in empty\(\) always exists and is not falsy\.$#' + identifier: empty.variable + count: 1 + path: ../../../src/wp-admin/themes.php + - + message: '#^Variable \$addl_path in empty\(\) always exists and is always falsy\.$#' + identifier: empty.variable + count: 1 + path: ../../../src/wp-includes/canonical.php + - + message: '#^Variable \$search in empty\(\) always exists and is not falsy\.$#' + identifier: empty.variable + count: 1 + path: ../../../src/wp-includes/class-wp-query.php + - + message: '#^Variable \$status_type_clauses in empty\(\) always exists and is not falsy\.$#' + identifier: empty.variable + count: 1 + path: ../../../src/wp-includes/class-wp-query.php + - + message: '#^Variable \$schema in empty\(\) is never defined\.$#' + identifier: empty.variable + count: 1 + path: ../../../src/wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php + - + message: '#^Variable \$the_parent in empty\(\) always exists and is not falsy\.$#' + identifier: empty.variable + count: 1 + path: ../../../src/wp-includes/taxonomy.php + - + message: '#^Variable \$old_user_data in empty\(\) always exists and is not falsy\.$#' + identifier: empty.variable + count: 1 + path: ../../../src/wp-includes/user.php diff --git a/tests/phpstan/baselines/encapsedStringPart.nonString.neon b/tests/phpstan/baselines/encapsedStringPart.nonString.neon new file mode 100644 index 0000000000000..707a2192a4c8a --- /dev/null +++ b/tests/phpstan/baselines/encapsedStringPart.nonString.neon @@ -0,0 +1,25 @@ +# PHPStan baseline for the `encapsedStringPart.nonString` errors in WordPress core. +# +# https://phpstan.org/error-identifiers/encapsedStringPart.nonString +# +# Each entry is scoped to a single file and carries an exact occurrence count, +# so that a new instance is reported as a new error rather than being absorbed +# silently. Fixing an occurrence therefore means decrementing or removing its +# entry here as part of the same change. +# +# The goal is to empty this file and delete it, along with the `includes` entry +# for it in phpstan.neon.dist. +# +# Generated by `composer phpstan:baselines`. Do not edit by hand; regenerate with +# +# composer phpstan:baselines -- --identifier=encapsedStringPart.nonString +# +# which reruns the analysis with this file suppressed so the errors surface again. + +parameters: + ignoreErrors: + - + message: '#^Part \$form_fields\[''_final''\] \(non\-empty\-array\\) of encapsed string cannot be cast to string\.$#' + identifier: encapsedStringPart.nonString + count: 1 + path: ../../../src/wp-admin/includes/media.php diff --git a/tests/phpstan/baselines/foreach.nonIterable.neon b/tests/phpstan/baselines/foreach.nonIterable.neon new file mode 100644 index 0000000000000..be8bba17a113c --- /dev/null +++ b/tests/phpstan/baselines/foreach.nonIterable.neon @@ -0,0 +1,25 @@ +# PHPStan baseline for the `foreach.nonIterable` errors in WordPress core. +# +# https://phpstan.org/error-identifiers/foreach.nonIterable +# +# Each entry is scoped to a single file and carries an exact occurrence count, +# so that a new instance is reported as a new error rather than being absorbed +# silently. Fixing an occurrence therefore means decrementing or removing its +# entry here as part of the same change. +# +# The goal is to empty this file and delete it, along with the `includes` entry +# for it in phpstan.neon.dist. +# +# Generated by `composer phpstan:baselines`. Do not edit by hand; regenerate with +# +# composer phpstan:baselines -- --identifier=foreach.nonIterable +# +# which reruns the analysis with this file suppressed so the errors surface again. + +parameters: + ignoreErrors: + - + message: '#^Argument of an invalid type stdClass supplied for foreach, only iterables are supported\.$#' + identifier: foreach.nonIterable + count: 1 + path: ../../../src/wp-includes/class-wp-post-type.php diff --git a/tests/phpstan/baselines/function.alreadyNarrowedType.neon b/tests/phpstan/baselines/function.alreadyNarrowedType.neon new file mode 100644 index 0000000000000..5d0293fde3b5b --- /dev/null +++ b/tests/phpstan/baselines/function.alreadyNarrowedType.neon @@ -0,0 +1,105 @@ +# PHPStan baseline for the `function.alreadyNarrowedType` errors in WordPress core. +# +# https://phpstan.org/error-identifiers/function.alreadyNarrowedType +# +# Each entry is scoped to a single file and carries an exact occurrence count, +# so that a new instance is reported as a new error rather than being absorbed +# silently. Fixing an occurrence therefore means decrementing or removing its +# entry here as part of the same change. +# +# The goal is to empty this file and delete it, along with the `includes` entry +# for it in phpstan.neon.dist. +# +# Generated by `composer phpstan:baselines`. Do not edit by hand; regenerate with +# +# composer phpstan:baselines -- --identifier=function.alreadyNarrowedType +# +# which reruns the analysis with this file suppressed so the errors surface again. + +parameters: + ignoreErrors: + - + message: '#^Call to function is_array\(\) with array will always evaluate to true\.$#' + identifier: function.alreadyNarrowedType + count: 2 + path: ../../../src/wp-admin/includes/ajax-actions.php + - + message: '#^Call to function is_wp_error\(\) with WP_Error will always evaluate to true\.$#' + identifier: function.alreadyNarrowedType + count: 1 + path: ../../../src/wp-admin/includes/ajax-actions.php + - + message: '#^Call to function method_exists\(\) with ''ParagonIE_Sodium…'' and ''runtime_speed_test'' will always evaluate to true\.$#' + identifier: function.alreadyNarrowedType + count: 1 + path: ../../../src/wp-admin/includes/file.php + - + message: '#^Call to function is_callable\(\) with ''exif_read_data'' will always evaluate to true\.$#' + identifier: function.alreadyNarrowedType + count: 1 + path: ../../../src/wp-admin/includes/image.php + - + message: '#^Call to function is_callable\(\) with ''iptcparse'' will always evaluate to true\.$#' + identifier: function.alreadyNarrowedType + count: 1 + path: ../../../src/wp-admin/includes/image.php + - + message: '#^Call to function is_numeric\(\) with float\|int\|numeric\-string will always evaluate to true\.$#' + identifier: function.alreadyNarrowedType + count: 1 + path: ../../../src/wp-admin/includes/plugin.php + - + message: '#^Call to function is_string\(\) with string will always evaluate to true\.$#' + identifier: function.alreadyNarrowedType + count: 1 + path: ../../../src/wp-includes/block-editor.php + - + message: '#^Call to function is_string\(\) with string will always evaluate to true\.$#' + identifier: function.alreadyNarrowedType + count: 1 + path: ../../../src/wp-includes/class-wp-block-bindings-registry.php + - + message: '#^Call to function method_exists\(\) with ''Imagick'' and ''setIteratorIndex'' will always evaluate to true\.$#' + identifier: function.alreadyNarrowedType + count: 1 + path: ../../../src/wp-includes/class-wp-image-editor-imagick.php + - + message: '#^Call to function is_callable\(\) with ''exif_read_data'' will always evaluate to true\.$#' + identifier: function.alreadyNarrowedType + count: 1 + path: ../../../src/wp-includes/class-wp-image-editor.php + - + message: '#^Call to function method_exists\(\) with ''SimplePie_Cache'' and ''register'' will always evaluate to true\.$#' + identifier: function.alreadyNarrowedType + count: 1 + path: ../../../src/wp-includes/feed.php + - + message: '#^Call to function is_callable\(\) with ''exif_imagetype'' will always evaluate to true\.$#' + identifier: function.alreadyNarrowedType + count: 1 + path: ../../../src/wp-includes/functions.php + - + message: '#^Call to function is_array\(\) with array will always evaluate to true\.$#' + identifier: function.alreadyNarrowedType + count: 1 + path: ../../../src/wp-includes/interactivity-api/class-wp-interactivity-api.php + - + message: '#^Call to function is_string\(\) with non\-falsy\-string will always evaluate to true\.$#' + identifier: function.alreadyNarrowedType + count: 2 + path: ../../../src/wp-includes/link-template.php + - + message: '#^Call to function wp_die\(\) with arguments non\-falsy\-string, mixed and array\{exit\: false, code\: ''mysql_not_found''\} will always evaluate to true\.$#' + identifier: function.alreadyNarrowedType + count: 1 + path: ../../../src/wp-includes/load.php + - + message: '#^Call to function is_array\(\) with non\-empty\-array\ will always evaluate to true\.$#' + identifier: function.alreadyNarrowedType + count: 1 + path: ../../../src/wp-includes/ms-functions.php + - + message: '#^Call to function is_array\(\) with array\{non\-falsy\-string, non\-falsy\-string&numeric\-string, numeric\-string, numeric\-string\} will always evaluate to true\.$#' + identifier: function.alreadyNarrowedType + count: 1 + path: ../../../src/wp-includes/post.php diff --git a/tests/phpstan/baselines/function.impossibleType.neon b/tests/phpstan/baselines/function.impossibleType.neon new file mode 100644 index 0000000000000..b096e7ff399bd --- /dev/null +++ b/tests/phpstan/baselines/function.impossibleType.neon @@ -0,0 +1,40 @@ +# PHPStan baseline for the `function.impossibleType` errors in WordPress core. +# +# https://phpstan.org/error-identifiers/function.impossibleType +# +# Each entry is scoped to a single file and carries an exact occurrence count, +# so that a new instance is reported as a new error rather than being absorbed +# silently. Fixing an occurrence therefore means decrementing or removing its +# entry here as part of the same change. +# +# The goal is to empty this file and delete it, along with the `includes` entry +# for it in phpstan.neon.dist. +# +# Generated by `composer phpstan:baselines`. Do not edit by hand; regenerate with +# +# composer phpstan:baselines -- --identifier=function.impossibleType +# +# which reruns the analysis with this file suppressed so the errors surface again. + +parameters: + ignoreErrors: + - + message: '#^Call to function is_string\(\) with bool will always evaluate to false\.$#' + identifier: function.impossibleType + count: 1 + path: ../../../src/wp-admin/includes/class-wp-debug-data.php + - + message: '#^Call to function is_wp_error\(\) with 0\|0\.0\|''''\|''0''\|array\{\}\|false\|null will always evaluate to false\.$#' + identifier: function.impossibleType + count: 2 + path: ../../../src/wp-admin/update.php + - + message: '#^Call to function is_wp_error\(\) with array will always evaluate to false\.$#' + identifier: function.impossibleType + count: 2 + path: ../../../src/wp-includes/class-wp-tax-query.php + - + message: '#^Call to function is_string\(\) with bool will always evaluate to false\.$#' + identifier: function.impossibleType + count: 1 + path: ../../../src/wp-includes/load.php diff --git a/tests/phpstan/baselines/function.resultUnused.neon b/tests/phpstan/baselines/function.resultUnused.neon new file mode 100644 index 0000000000000..1ad6a84dbfa4c --- /dev/null +++ b/tests/phpstan/baselines/function.resultUnused.neon @@ -0,0 +1,40 @@ +# PHPStan baseline for the `function.resultUnused` errors in WordPress core. +# +# https://phpstan.org/error-identifiers/function.resultUnused +# +# Each entry is scoped to a single file and carries an exact occurrence count, +# so that a new instance is reported as a new error rather than being absorbed +# silently. Fixing an occurrence therefore means decrementing or removing its +# entry here as part of the same change. +# +# The goal is to empty this file and delete it, along with the `includes` entry +# for it in phpstan.neon.dist. +# +# Generated by `composer phpstan:baselines`. Do not edit by hand; regenerate with +# +# composer phpstan:baselines -- --identifier=function.resultUnused +# +# which reruns the analysis with this file suppressed so the errors surface again. + +parameters: + ignoreErrors: + - + message: '#^Call to function wp_cache_add_non_persistent_groups\(\) on a separate line has no effect\.$#' + identifier: function.resultUnused + count: 1 + path: ../../../src/wp-includes/class-wp-theme.php + - + message: '#^Call to function wp_cache_add_non_persistent_groups\(\) on a separate line has no effect\.$#' + identifier: function.resultUnused + count: 1 + path: ../../../src/wp-includes/load.php + - + message: '#^Call to function wp_cache_close\(\) on a separate line has no effect\.$#' + identifier: function.resultUnused + count: 1 + path: ../../../src/wp-includes/load.php + - + message: '#^Call to function wp_cache_add_non_persistent_groups\(\) on a separate line has no effect\.$#' + identifier: function.resultUnused + count: 1 + path: ../../../src/wp-includes/ms-blogs.php diff --git a/tests/phpstan/baselines/greater.invalid.neon b/tests/phpstan/baselines/greater.invalid.neon new file mode 100644 index 0000000000000..bf9ca0aa0250f --- /dev/null +++ b/tests/phpstan/baselines/greater.invalid.neon @@ -0,0 +1,25 @@ +# PHPStan baseline for the `greater.invalid` errors in WordPress core. +# +# https://phpstan.org/error-identifiers/greater.invalid +# +# Each entry is scoped to a single file and carries an exact occurrence count, +# so that a new instance is reported as a new error rather than being absorbed +# silently. Fixing an occurrence therefore means decrementing or removing its +# entry here as part of the same change. +# +# The goal is to empty this file and delete it, along with the `includes` entry +# for it in phpstan.neon.dist. +# +# Generated by `composer phpstan:baselines`. Do not edit by hand; regenerate with +# +# composer phpstan:baselines -- --identifier=greater.invalid +# +# which reruns the analysis with this file suppressed so the errors surface again. + +parameters: + ignoreErrors: + - + message: '#^Comparison operation "\>" between \*NEVER\* and 0 results in an error\.$#' + identifier: greater.invalid + count: 1 + path: ../../../src/wp-admin/includes/upgrade.php diff --git a/tests/phpstan/baselines/greaterOrEqual.alwaysTrue.neon b/tests/phpstan/baselines/greaterOrEqual.alwaysTrue.neon new file mode 100644 index 0000000000000..9b0969a81be78 --- /dev/null +++ b/tests/phpstan/baselines/greaterOrEqual.alwaysTrue.neon @@ -0,0 +1,60 @@ +# PHPStan baseline for the `greaterOrEqual.alwaysTrue` errors in WordPress core. +# +# https://phpstan.org/error-identifiers/greaterOrEqual.alwaysTrue +# +# Each entry is scoped to a single file and carries an exact occurrence count, +# so that a new instance is reported as a new error rather than being absorbed +# silently. Fixing an occurrence therefore means decrementing or removing its +# entry here as part of the same change. +# +# The goal is to empty this file and delete it, along with the `includes` entry +# for it in phpstan.neon.dist. +# +# Generated by `composer phpstan:baselines`. Do not edit by hand; regenerate with +# +# composer phpstan:baselines -- --identifier=greaterOrEqual.alwaysTrue +# +# which reruns the analysis with this file suppressed so the errors surface again. + +parameters: + ignoreErrors: + - + message: '#^Comparison operation "\>\=" between int\<70400, 80500\> and 70300 is always true\.$#' + identifier: greaterOrEqual.alwaysTrue + count: 1 + path: ../../../src/wp-includes/fonts/class-wp-font-utils.php + - + message: '#^Comparison operation "\>\=" between int\<70400, 80500\> and 70400 is always true\.$#' + identifier: greaterOrEqual.alwaysTrue + count: 1 + path: ../../../src/wp-includes/fonts/class-wp-font-utils.php + - + message: '#^Comparison operation "\>\=" between int\<2592000, 31535999\> and 2592000 is always true\.$#' + identifier: greaterOrEqual.alwaysTrue + count: 1 + path: ../../../src/wp-includes/formatting.php + - + message: '#^Comparison operation "\>\=" between int\<31536000, max\> and 31536000 is always true\.$#' + identifier: greaterOrEqual.alwaysTrue + count: 1 + path: ../../../src/wp-includes/formatting.php + - + message: '#^Comparison operation "\>\=" between int\<3600, 86399\> and 3600 is always true\.$#' + identifier: greaterOrEqual.alwaysTrue + count: 1 + path: ../../../src/wp-includes/formatting.php + - + message: '#^Comparison operation "\>\=" between int\<60, 3599\> and 60 is always true\.$#' + identifier: greaterOrEqual.alwaysTrue + count: 1 + path: ../../../src/wp-includes/formatting.php + - + message: '#^Comparison operation "\>\=" between int\<604800, 2591999\> and 604800 is always true\.$#' + identifier: greaterOrEqual.alwaysTrue + count: 1 + path: ../../../src/wp-includes/formatting.php + - + message: '#^Comparison operation "\>\=" between int\<86400, 604799\> and 86400 is always true\.$#' + identifier: greaterOrEqual.alwaysTrue + count: 1 + path: ../../../src/wp-includes/formatting.php diff --git a/tests/phpstan/baselines/identical.alwaysFalse.neon b/tests/phpstan/baselines/identical.alwaysFalse.neon new file mode 100644 index 0000000000000..47e10c0e67b9a --- /dev/null +++ b/tests/phpstan/baselines/identical.alwaysFalse.neon @@ -0,0 +1,45 @@ +# PHPStan baseline for the `identical.alwaysFalse` errors in WordPress core. +# +# https://phpstan.org/error-identifiers/identical.alwaysFalse +# +# Each entry is scoped to a single file and carries an exact occurrence count, +# so that a new instance is reported as a new error rather than being absorbed +# silently. Fixing an occurrence therefore means decrementing or removing its +# entry here as part of the same change. +# +# The goal is to empty this file and delete it, along with the `includes` entry +# for it in phpstan.neon.dist. +# +# Generated by `composer phpstan:baselines`. Do not edit by hand; regenerate with +# +# composer phpstan:baselines -- --identifier=identical.alwaysFalse +# +# which reruns the analysis with this file suppressed so the errors surface again. + +parameters: + ignoreErrors: + - + message: '#^Strict comparison using \=\=\= between ''update\-selected'' and mixed~\(''activate''\|''activate\-selected''\|''deactivate''\|''deactivate\-selected''\|''delete\-selected''\|''disable\-auto\-update''\|''disable\-auto\-update\-selected''\|''enable\-auto\-update''\|''enable\-auto\-update\-selected''\|''error_scrape''\|''resume''\|''update\-selected''\) will always evaluate to false\.$#' + identifier: identical.alwaysFalse + count: 1 + path: ../../../src/wp-admin/plugins.php + - + message: '#^Strict comparison using \=\=\= between ''exceeded\-max…'' and null will always evaluate to false\.$#' + identifier: identical.alwaysFalse + count: 1 + path: ../../../src/wp-includes/html-api/class-wp-html-processor.php + - + message: '#^Strict comparison using \=\=\= between ''STATE_INCOMPLETE…'' and ''STATE_READY'' will always evaluate to false\.$#' + identifier: identical.alwaysFalse + count: 1 + path: ../../../src/wp-includes/html-api/class-wp-html-tag-processor.php + - + message: '#^Strict comparison using \=\=\= between 3000000000 and 2147483647 will always evaluate to false\.$#' + identifier: identical.alwaysFalse + count: 1 + path: ../../../src/wp-includes/pluggable.php + - + message: '#^Strict comparison using \=\=\= between false and mixed will always evaluate to false\.$#' + identifier: identical.alwaysFalse + count: 1 + path: ../../../src/wp-includes/rest-api/endpoints/class-wp-rest-users-controller.php diff --git a/tests/phpstan/baselines/identical.alwaysTrue.neon b/tests/phpstan/baselines/identical.alwaysTrue.neon new file mode 100644 index 0000000000000..59b590acc867e --- /dev/null +++ b/tests/phpstan/baselines/identical.alwaysTrue.neon @@ -0,0 +1,40 @@ +# PHPStan baseline for the `identical.alwaysTrue` errors in WordPress core. +# +# https://phpstan.org/error-identifiers/identical.alwaysTrue +# +# Each entry is scoped to a single file and carries an exact occurrence count, +# so that a new instance is reported as a new error rather than being absorbed +# silently. Fixing an occurrence therefore means decrementing or removing its +# entry here as part of the same change. +# +# The goal is to empty this file and delete it, along with the `includes` entry +# for it in phpstan.neon.dist. +# +# Generated by `composer phpstan:baselines`. Do not edit by hand; regenerate with +# +# composer phpstan:baselines -- --identifier=identical.alwaysTrue +# +# which reruns the analysis with this file suppressed so the errors surface again. + +parameters: + ignoreErrors: + - + message: '#^Strict comparison using \=\=\= between ''themezip'' and ''themezip'' will always evaluate to true\.$#' + identifier: identical.alwaysTrue + count: 1 + path: ../../../src/wp-admin/includes/class-file-upload-upgrader.php + - + message: '#^Strict comparison using \=\=\= between ''sticky'' and ''sticky'' will always evaluate to true\.$#' + identifier: identical.alwaysTrue + count: 1 + path: ../../../src/wp-includes/block-supports/position.php + - + message: '#^Strict comparison using \=\=\= between ''404'' and ''404'' will always evaluate to true\.$#' + identifier: identical.alwaysTrue + count: 1 + path: ../../../src/wp-includes/class-wp.php + - + message: '#^Strict comparison using \=\=\= between true and true will always evaluate to true\.$#' + identifier: identical.alwaysTrue + count: 1 + path: ../../../src/wp-includes/rest-api.php diff --git a/tests/phpstan/baselines/if.alwaysFalse.neon b/tests/phpstan/baselines/if.alwaysFalse.neon new file mode 100644 index 0000000000000..d0346f40c526c --- /dev/null +++ b/tests/phpstan/baselines/if.alwaysFalse.neon @@ -0,0 +1,55 @@ +# PHPStan baseline for the `if.alwaysFalse` errors in WordPress core. +# +# https://phpstan.org/error-identifiers/if.alwaysFalse +# +# Each entry is scoped to a single file and carries an exact occurrence count, +# so that a new instance is reported as a new error rather than being absorbed +# silently. Fixing an occurrence therefore means decrementing or removing its +# entry here as part of the same change. +# +# The goal is to empty this file and delete it, along with the `includes` entry +# for it in phpstan.neon.dist. +# +# Generated by `composer phpstan:baselines`. Do not edit by hand; regenerate with +# +# composer phpstan:baselines -- --identifier=if.alwaysFalse +# +# which reruns the analysis with this file suppressed so the errors surface again. + +parameters: + ignoreErrors: + - + message: '#^If condition is always false\.$#' + identifier: if.alwaysFalse + count: 1 + path: ../../../src/wp-admin/install.php + - + message: '#^If condition is always false\.$#' + identifier: if.alwaysFalse + count: 2 + path: ../../../src/wp-includes/class-wp-block-processor.php + - + message: '#^If condition is always false\.$#' + identifier: if.alwaysFalse + count: 1 + path: ../../../src/wp-includes/class-wp-theme-json.php + - + message: '#^If condition is always false\.$#' + identifier: if.alwaysFalse + count: 1 + path: ../../../src/wp-includes/load.php + - + message: '#^If condition is always false\.$#' + identifier: if.alwaysFalse + count: 1 + path: ../../../src/wp-includes/rest-api/endpoints/class-wp-rest-comments-controller.php + - + message: '#^If condition is always false\.$#' + identifier: if.alwaysFalse + count: 1 + path: ../../../src/wp-includes/template.php + - + message: '#^If condition is always false\.$#' + identifier: if.alwaysFalse + count: 1 + path: ../../../src/wp-login.php diff --git a/tests/phpstan/baselines/if.alwaysTrue.neon b/tests/phpstan/baselines/if.alwaysTrue.neon new file mode 100644 index 0000000000000..f049efde2d19d --- /dev/null +++ b/tests/phpstan/baselines/if.alwaysTrue.neon @@ -0,0 +1,55 @@ +# PHPStan baseline for the `if.alwaysTrue` errors in WordPress core. +# +# https://phpstan.org/error-identifiers/if.alwaysTrue +# +# Each entry is scoped to a single file and carries an exact occurrence count, +# so that a new instance is reported as a new error rather than being absorbed +# silently. Fixing an occurrence therefore means decrementing or removing its +# entry here as part of the same change. +# +# The goal is to empty this file and delete it, along with the `includes` entry +# for it in phpstan.neon.dist. +# +# Generated by `composer phpstan:baselines`. Do not edit by hand; regenerate with +# +# composer phpstan:baselines -- --identifier=if.alwaysTrue +# +# which reruns the analysis with this file suppressed so the errors surface again. + +parameters: + ignoreErrors: + - + message: '#^If condition is always true\.$#' + identifier: if.alwaysTrue + count: 1 + path: ../../../src/wp-admin/my-sites.php + - + message: '#^If condition is always true\.$#' + identifier: if.alwaysTrue + count: 2 + path: ../../../src/wp-admin/upload.php + - + message: '#^If condition is always true\.$#' + identifier: if.alwaysTrue + count: 1 + path: ../../../src/wp-content/themes/twentynineteen/comments.php + - + message: '#^If condition is always true\.$#' + identifier: if.alwaysTrue + count: 1 + path: ../../../src/wp-content/themes/twentynineteen/template-parts/footer/footer-widgets.php + - + message: '#^If condition is always true\.$#' + identifier: if.alwaysTrue + count: 1 + path: ../../../src/wp-content/themes/twentytwenty/functions.php + - + message: '#^If condition is always true\.$#' + identifier: if.alwaysTrue + count: 1 + path: ../../../src/wp-content/themes/twentytwenty/template-parts/modal-menu.php + - + message: '#^If condition is always true\.$#' + identifier: if.alwaysTrue + count: 2 + path: ../../../src/wp-includes/html-api/class-wp-html-processor.php diff --git a/tests/phpstan/baselines/instanceof.alwaysTrue.neon b/tests/phpstan/baselines/instanceof.alwaysTrue.neon new file mode 100644 index 0000000000000..087710cdf012e --- /dev/null +++ b/tests/phpstan/baselines/instanceof.alwaysTrue.neon @@ -0,0 +1,25 @@ +# PHPStan baseline for the `instanceof.alwaysTrue` errors in WordPress core. +# +# https://phpstan.org/error-identifiers/instanceof.alwaysTrue +# +# Each entry is scoped to a single file and carries an exact occurrence count, +# so that a new instance is reported as a new error rather than being absorbed +# silently. Fixing an occurrence therefore means decrementing or removing its +# entry here as part of the same change. +# +# The goal is to empty this file and delete it, along with the `includes` entry +# for it in phpstan.neon.dist. +# +# Generated by `composer phpstan:baselines`. Do not edit by hand; regenerate with +# +# composer phpstan:baselines -- --identifier=instanceof.alwaysTrue +# +# which reruns the analysis with this file suppressed so the errors surface again. + +parameters: + ignoreErrors: + - + message: '#^Instanceof between Imagick and Imagick will always evaluate to true\.$#' + identifier: instanceof.alwaysTrue + count: 1 + path: ../../../src/wp-admin/includes/class-wp-debug-data.php diff --git a/tests/phpstan/baselines/isset.offset.neon b/tests/phpstan/baselines/isset.offset.neon new file mode 100644 index 0000000000000..1db99c69fbf03 --- /dev/null +++ b/tests/phpstan/baselines/isset.offset.neon @@ -0,0 +1,40 @@ +# PHPStan baseline for the `isset.offset` errors in WordPress core. +# +# https://phpstan.org/error-identifiers/isset.offset +# +# Each entry is scoped to a single file and carries an exact occurrence count, +# so that a new instance is reported as a new error rather than being absorbed +# silently. Fixing an occurrence therefore means decrementing or removing its +# entry here as part of the same change. +# +# The goal is to empty this file and delete it, along with the `includes` entry +# for it in phpstan.neon.dist. +# +# Generated by `composer phpstan:baselines`. Do not edit by hand; regenerate with +# +# composer phpstan:baselines -- --identifier=isset.offset +# +# which reruns the analysis with this file suppressed so the errors surface again. + +parameters: + ignoreErrors: + - + message: '#^Offset \(float\|int\) on non\-empty\-array\ in isset\(\) always exists and is not nullable\.$#' + identifier: isset.offset + count: 1 + path: ../../../src/wp-admin/nav-menus.php + - + message: '#^Offset int\<1, max\> on non\-empty\-list\ in isset\(\) always exists and is not nullable\.$#' + identifier: isset.offset + count: 1 + path: ../../../src/wp-includes/functions.php + - + message: '#^Offset 2 on array\{string, non\-empty\-string, non\-empty\-string\} in isset\(\) always exists and is not nullable\.$#' + identifier: isset.offset + count: 1 + path: ../../../src/wp-includes/media.php + - + message: '#^Offset ''orderby'' on array\{post_parent\: mixed, post_type\: ''revision'', post_status\: ''inherit'', posts_per_page\: mixed, orderby\: mixed, order\: mixed, suppress_filters\: true, post__not_in\?\: mixed, \.\.\.\} in isset\(\) always exists and is not nullable\.$#' + identifier: isset.offset + count: 1 + path: ../../../src/wp-includes/rest-api/endpoints/class-wp-rest-revisions-controller.php diff --git a/tests/phpstan/baselines/isset.property.neon b/tests/phpstan/baselines/isset.property.neon new file mode 100644 index 0000000000000..2a0c8971ceb55 --- /dev/null +++ b/tests/phpstan/baselines/isset.property.neon @@ -0,0 +1,220 @@ +# PHPStan baseline for the `isset.property` errors in WordPress core. +# +# https://phpstan.org/error-identifiers/isset.property +# +# Each entry is scoped to a single file and carries an exact occurrence count, +# so that a new instance is reported as a new error rather than being absorbed +# silently. Fixing an occurrence therefore means decrementing or removing its +# entry here as part of the same change. +# +# The goal is to empty this file and delete it, along with the `includes` entry +# for it in phpstan.neon.dist. +# +# Generated by `composer phpstan:baselines`. Do not edit by hand; regenerate with +# +# composer phpstan:baselines -- --identifier=isset.property +# +# which reruns the analysis with this file suppressed so the errors surface again. + +parameters: + ignoreErrors: + - + message: '#^Property WP_Post\:\:\$post_type \(string\) in isset\(\) is not nullable\.$#' + identifier: isset.property + count: 3 + path: ../../../src/wp-admin/includes/class-walker-nav-menu-checklist.php + - + message: '#^Property WP_Post\:\:\$post_status \(string\) in isset\(\) is not nullable\.$#' + identifier: isset.property + count: 1 + path: ../../../src/wp-admin/includes/class-walker-nav-menu-edit.php + - + message: '#^Property WP_Post\:\:\$post_title \(string\) in isset\(\) is not nullable\.$#' + identifier: isset.property + count: 1 + path: ../../../src/wp-admin/includes/class-wp-posts-list-table.php + - + message: '#^Property WP_Screen\:\:\$post_type \(string\) in isset\(\) is not nullable\.$#' + identifier: isset.property + count: 1 + path: ../../../src/wp-admin/includes/class-wp-screen.php + - + message: '#^Property WP_Taxonomy\:\:\$meta_box_sanitize_cb \(callable\) in isset\(\) is not nullable\.$#' + identifier: isset.property + count: 1 + path: ../../../src/wp-admin/includes/post.php + - + message: '#^Property WP_Site\:\:\$domain \(string\) in isset\(\) is not nullable\.$#' + identifier: isset.property + count: 1 + path: ../../../src/wp-admin/my-sites.php + - + message: '#^Property WP_Customize_Manager\:\:\$selective_refresh \(WP_Customize_Selective_Refresh\) in isset\(\) is not nullable\.$#' + identifier: isset.property + count: 1 + path: ../../../src/wp-content/themes/twentyeleven/inc/theme-options.php + - + message: '#^Property WP_Customize_Manager\:\:\$selective_refresh \(WP_Customize_Selective_Refresh\) in isset\(\) is not nullable\.$#' + identifier: isset.property + count: 1 + path: ../../../src/wp-content/themes/twentyfifteen/inc/customizer.php + - + message: '#^Property WP_Customize_Manager\:\:\$selective_refresh \(WP_Customize_Selective_Refresh\) in isset\(\) is not nullable\.$#' + identifier: isset.property + count: 1 + path: ../../../src/wp-content/themes/twentyfourteen/inc/customizer.php + - + message: '#^Property WP_Customize_Manager\:\:\$selective_refresh \(WP_Customize_Selective_Refresh\) in isset\(\) is not nullable\.$#' + identifier: isset.property + count: 1 + path: ../../../src/wp-content/themes/twentynineteen/inc/customizer.php + - + message: '#^Property WP_Customize_Manager\:\:\$selective_refresh \(WP_Customize_Selective_Refresh\) in isset\(\) is not nullable\.$#' + identifier: isset.property + count: 1 + path: ../../../src/wp-content/themes/twentysixteen/inc/customizer.php + - + message: '#^Property WP_Customize_Manager\:\:\$selective_refresh \(WP_Customize_Selective_Refresh\) in isset\(\) is not nullable\.$#' + identifier: isset.property + count: 1 + path: ../../../src/wp-content/themes/twentythirteen/functions.php + - + message: '#^Property WP_Customize_Manager\:\:\$selective_refresh \(WP_Customize_Selective_Refresh\) in isset\(\) is not nullable\.$#' + identifier: isset.property + count: 1 + path: ../../../src/wp-content/themes/twentytwelve/functions.php + - + message: '#^Property WP_Block_Type\:\:\$editor_style_handles \(array\\) in isset\(\) is not nullable\.$#' + identifier: isset.property + count: 1 + path: ../../../src/wp-includes/block-editor.php + - + message: '#^Property WP_Block_Type\:\:\$selectors \(array\) in isset\(\) is not nullable\.$#' + identifier: isset.property + count: 1 + path: ../../../src/wp-includes/block-supports/states.php + - + message: '#^Property WP_Customize_Control\:\:\$settings \(array\) in isset\(\) is not nullable\.$#' + identifier: isset.property + count: 1 + path: ../../../src/wp-includes/class-wp-customize-control.php + - + message: '#^Property WP_Customize_Manager\:\:\$_changeset_post_id \(int\|false\) in isset\(\) is not nullable\.$#' + identifier: isset.property + count: 1 + path: ../../../src/wp-includes/class-wp-customize-manager.php + - + message: '#^Property WP_Customize_Manager\:\:\$_changeset_uuid \(string\) in isset\(\) is not nullable\.$#' + identifier: isset.property + count: 1 + path: ../../../src/wp-includes/class-wp-customize-manager.php + - + message: '#^Property WP_Customize_Manager\:\:\$_post_values \(array\) in isset\(\) is not nullable\.$#' + identifier: isset.property + count: 1 + path: ../../../src/wp-includes/class-wp-customize-manager.php + - + message: '#^Property WP_Customize_Setting\:\:\$_previewed_blog_id \(int\) in isset\(\) is not nullable\.$#' + identifier: isset.property + count: 2 + path: ../../../src/wp-includes/class-wp-customize-setting.php + - + message: '#^Property WP_Customize_Widgets\:\:\$selective_refreshable_widgets \(array\) in isset\(\) is not nullable\.$#' + identifier: isset.property + count: 1 + path: ../../../src/wp-includes/class-wp-customize-widgets.php + - + message: '#^Property WP_Http_Cookie\:\:\$domain \(string\) in isset\(\) is not nullable\.$#' + identifier: isset.property + count: 1 + path: ../../../src/wp-includes/class-wp-http-cookie.php + - + message: '#^Property WP_Http_Cookie\:\:\$name \(string\) in isset\(\) is not nullable\.$#' + identifier: isset.property + count: 1 + path: ../../../src/wp-includes/class-wp-http-cookie.php + - + message: '#^Property WP_Http_Cookie\:\:\$value \(string\) in isset\(\) is not nullable\.$#' + identifier: isset.property + count: 1 + path: ../../../src/wp-includes/class-wp-http-cookie.php + - + message: '#^Property WP_Post\:\:\$ID \(int\<0, max\>\) in isset\(\) is not nullable\.$#' + identifier: isset.property + count: 1 + path: ../../../src/wp-includes/class-wp-query.php + - + message: '#^Static property WP_Theme\:\:\$persistently_cache \(bool\) in isset\(\) is not nullable\.$#' + identifier: isset.property + count: 1 + path: ../../../src/wp-includes/class-wp-theme.php + - + message: '#^Static property WP_User\:\:\$back_compat_keys \(array\) in isset\(\) is not nullable\.$#' + identifier: isset.property + count: 1 + path: ../../../src/wp-includes/class-wp-user.php + - + message: '#^Property WP_Widget\:\:\$alt_option_name \(string\) in isset\(\) is not nullable\.$#' + identifier: isset.property + count: 1 + path: ../../../src/wp-includes/class-wp-widget.php + - + message: '#^Property wpdb\:\:\$base_prefix \(string\) in isset\(\) is not nullable\.$#' + identifier: isset.property + count: 1 + path: ../../../src/wp-includes/class-wpdb.php + - + message: '#^Property WP_Customize_Partial\:\:\$settings \(array\\) in isset\(\) is not nullable\.$#' + identifier: isset.property + count: 1 + path: ../../../src/wp-includes/customize/class-wp-customize-partial.php + - + message: '#^Property WP_HTML_Text_Replacement\:\:\$text \(string\) in isset\(\) is not nullable\.$#' + identifier: isset.property + count: 2 + path: ../../../src/wp-includes/html-api/class-wp-html-tag-processor.php + - + message: '#^Property WP_Post\:\:\$post_status \(string\) in isset\(\) is not nullable\.$#' + identifier: isset.property + count: 1 + path: ../../../src/wp-includes/link-template.php + - + message: '#^Property WP_Object_Cache\:\:\$cache \(array\) in isset\(\) is not nullable\.$#' + identifier: isset.property + count: 1 + path: ../../../src/wp-includes/ms-blogs.php + - + message: '#^Property WP_Object_Cache\:\:\$global_groups \(array\\) in isset\(\) is not nullable\.$#' + identifier: isset.property + count: 1 + path: ../../../src/wp-includes/ms-blogs.php + - + message: '#^Property WP_Post\:\:\$ID \(int\<0, max\>\) in isset\(\) is not nullable\.$#' + identifier: isset.property + count: 1 + path: ../../../src/wp-includes/nav-menu.php + - + message: '#^Property WP_Term\:\:\$term_id \(int\) in isset\(\) is not nullable\.$#' + identifier: isset.property + count: 1 + path: ../../../src/wp-includes/nav-menu.php + - + message: '#^Property WP_Post\:\:\$post_name \(string\) in isset\(\) is not nullable\.$#' + identifier: isset.property + count: 1 + path: ../../../src/wp-includes/rest-api/endpoints/class-wp-rest-font-families-controller.php + - + message: '#^Property WP_Post\:\:\$post_title \(string\) in isset\(\) is not nullable\.$#' + identifier: isset.property + count: 1 + path: ../../../src/wp-includes/rest-api/endpoints/class-wp-rest-font-families-controller.php + - + message: '#^Property WP_Query\:\:\$max_num_pages \(int\) in isset\(\) is not nullable\.$#' + identifier: isset.property + count: 1 + path: ../../../src/wp-includes/sitemaps/providers/class-wp-sitemaps-posts.php + - + message: '#^Property WP_Site\:\:\$domain \(string\) in isset\(\) is not nullable\.$#' + identifier: isset.property + count: 1 + path: ../../../src/wp-includes/user.php diff --git a/tests/phpstan/baselines/isset.variable.neon b/tests/phpstan/baselines/isset.variable.neon new file mode 100644 index 0000000000000..f14ef0755fdb9 --- /dev/null +++ b/tests/phpstan/baselines/isset.variable.neon @@ -0,0 +1,50 @@ +# PHPStan baseline for the `isset.variable` errors in WordPress core. +# +# https://phpstan.org/error-identifiers/isset.variable +# +# Each entry is scoped to a single file and carries an exact occurrence count, +# so that a new instance is reported as a new error rather than being absorbed +# silently. Fixing an occurrence therefore means decrementing or removing its +# entry here as part of the same change. +# +# The goal is to empty this file and delete it, along with the `includes` entry +# for it in phpstan.neon.dist. +# +# Generated by `composer phpstan:baselines`. Do not edit by hand; regenerate with +# +# composer phpstan:baselines -- --identifier=isset.variable +# +# which reruns the analysis with this file suppressed so the errors surface again. + +parameters: + ignoreErrors: + - + message: '#^Variable \$_POST in isset\(\) always exists and is not nullable\.$#' + identifier: isset.variable + count: 1 + path: ../../../src/wp-admin/includes/class-custom-image-header.php + - + message: '#^Variable \$stylesheet in isset\(\) always exists and is not nullable\.$#' + identifier: isset.variable + count: 1 + path: ../../../src/wp-admin/includes/file.php + - + message: '#^Variable \$_POST in isset\(\) always exists and is not nullable\.$#' + identifier: isset.variable + count: 1 + path: ../../../src/wp-admin/includes/media.php + - + message: '#^Variable \$namespace in isset\(\) always exists and is not nullable\.$#' + identifier: isset.variable + count: 1 + path: ../../../src/wp-includes/class-wp-block-parser.php + - + message: '#^Variable \$loader in isset\(\) always exists and is not nullable\.$#' + identifier: isset.variable + count: 1 + path: ../../../src/wp-includes/class-wp-oembed.php + - + message: '#^Variable \$s in isset\(\) is never defined\.$#' + identifier: isset.variable + count: 1 + path: ../../../src/wp-includes/template.php diff --git a/tests/phpstan/baselines/method.childParameterType.neon b/tests/phpstan/baselines/method.childParameterType.neon new file mode 100644 index 0000000000000..7ddc1325be94c --- /dev/null +++ b/tests/phpstan/baselines/method.childParameterType.neon @@ -0,0 +1,55 @@ +# PHPStan baseline for the `method.childParameterType` errors in WordPress core. +# +# https://phpstan.org/error-identifiers/method.childParameterType +# +# Each entry is scoped to a single file and carries an exact occurrence count, +# so that a new instance is reported as a new error rather than being absorbed +# silently. Fixing an occurrence therefore means decrementing or removing its +# entry here as part of the same change. +# +# The goal is to empty this file and delete it, along with the `includes` entry +# for it in phpstan.neon.dist. +# +# Generated by `composer phpstan:baselines`. Do not edit by hand; regenerate with +# +# composer phpstan:baselines -- --identifier=method.childParameterType +# +# which reruns the analysis with this file suppressed so the errors surface again. + +parameters: + ignoreErrors: + - + message: '#^Parameter \#1 \$comment_status \(bool\) of method WP_Post_Comments_List_Table\:\:get_per_page\(\) should be compatible with parameter \$comment_status \(string\) of method WP_Comments_List_Table\:\:get_per_page\(\)$#' + identifier: method.childParameterType + count: 1 + path: ../../../src/wp-admin/includes/class-wp-post-comments-list-table.php + - + message: '#^Parameter \#3 \$args \(stdClass\) of method Walker_Nav_Menu\:\:end_lvl\(\) should be compatible with parameter \$args \(array\) of method Walker\:\:end_lvl\(\)$#' + identifier: method.childParameterType + count: 1 + path: ../../../src/wp-includes/class-walker-nav-menu.php + - + message: '#^Parameter \#3 \$args \(stdClass\) of method Walker_Nav_Menu\:\:start_lvl\(\) should be compatible with parameter \$args \(array\) of method Walker\:\:start_lvl\(\)$#' + identifier: method.childParameterType + count: 1 + path: ../../../src/wp-includes/class-walker-nav-menu.php + - + message: '#^Parameter \#4 \$args \(stdClass\) of method Walker_Nav_Menu\:\:end_el\(\) should be compatible with parameter \$args \(array\) of method Walker\:\:end_el\(\)$#' + identifier: method.childParameterType + count: 1 + path: ../../../src/wp-includes/class-walker-nav-menu.php + - + message: '#^Parameter \#4 \$args \(stdClass\) of method Walker_Nav_Menu\:\:start_el\(\) should be compatible with parameter \$args \(array\) of method Walker\:\:start_el\(\)$#' + identifier: method.childParameterType + count: 1 + path: ../../../src/wp-includes/class-walker-nav-menu.php + - + message: '#^Parameter \#1 \$id \(int\) of method WP_REST_Global_Styles_Controller\:\:prepare_links\(\) should be compatible with parameter \$post \(WP_Post\) of method WP_REST_Posts_Controller\:\:prepare_links\(\)$#' + identifier: method.childParameterType + count: 1 + path: ../../../src/wp-includes/rest-api/endpoints/class-wp-rest-global-styles-controller.php + - + message: '#^Parameter \#1 \$parent_template_id \(string\) of method WP_REST_Template_Revisions_Controller\:\:get_parent\(\) should be compatible with parameter \$parent_post_id \(int\) of method WP_REST_Revisions_Controller\:\:get_parent\(\)$#' + identifier: method.childParameterType + count: 1 + path: ../../../src/wp-includes/rest-api/endpoints/class-wp-rest-template-revisions-controller.php diff --git a/tests/phpstan/baselines/method.nonObject.neon b/tests/phpstan/baselines/method.nonObject.neon new file mode 100644 index 0000000000000..4d0727f8950f1 --- /dev/null +++ b/tests/phpstan/baselines/method.nonObject.neon @@ -0,0 +1,55 @@ +# PHPStan baseline for the `method.nonObject` errors in WordPress core. +# +# https://phpstan.org/error-identifiers/method.nonObject +# +# Each entry is scoped to a single file and carries an exact occurrence count, +# so that a new instance is reported as a new error rather than being absorbed +# silently. Fixing an occurrence therefore means decrementing or removing its +# entry here as part of the same change. +# +# The goal is to empty this file and delete it, along with the `includes` entry +# for it in phpstan.neon.dist. +# +# Generated by `composer phpstan:baselines`. Do not edit by hand; regenerate with +# +# composer phpstan:baselines -- --identifier=method.nonObject +# +# which reruns the analysis with this file suppressed so the errors surface again. + +parameters: + ignoreErrors: + - + message: '#^Cannot call method inline_edit\(\) on WP_List_Table\|false\.$#' + identifier: method.nonObject + count: 1 + path: ../../../src/wp-admin/edit-tags.php + - + message: '#^Cannot call method inline_edit\(\) on WP_List_Table\|false\.$#' + identifier: method.nonObject + count: 1 + path: ../../../src/wp-admin/edit.php + - + message: '#^Cannot call method embed_scripts\(\) on WP_List_Table\|false\.$#' + identifier: method.nonObject + count: 1 + path: ../../../src/wp-admin/erase-personal-data.php + - + message: '#^Cannot call method process_bulk_action\(\) on WP_List_Table\|false\.$#' + identifier: method.nonObject + count: 1 + path: ../../../src/wp-admin/erase-personal-data.php + - + message: '#^Cannot call method embed_scripts\(\) on WP_List_Table\|false\.$#' + identifier: method.nonObject + count: 1 + path: ../../../src/wp-admin/export-personal-data.php + - + message: '#^Cannot call method process_bulk_action\(\) on WP_List_Table\|false\.$#' + identifier: method.nonObject + count: 1 + path: ../../../src/wp-admin/export-personal-data.php + - + message: '#^Cannot call method theme_installer_single\(\) on WP_List_Table\|false\.$#' + identifier: method.nonObject + count: 1 + path: ../../../src/wp-admin/includes/theme-install.php diff --git a/tests/phpstan/baselines/method.notFound.neon b/tests/phpstan/baselines/method.notFound.neon new file mode 100644 index 0000000000000..5d6bccb070ecb --- /dev/null +++ b/tests/phpstan/baselines/method.notFound.neon @@ -0,0 +1,45 @@ +# PHPStan baseline for the `method.notFound` errors in WordPress core. +# +# https://phpstan.org/error-identifiers/method.notFound +# +# Each entry is scoped to a single file and carries an exact occurrence count, +# so that a new instance is reported as a new error rather than being absorbed +# silently. Fixing an occurrence therefore means decrementing or removing its +# entry here as part of the same change. +# +# The goal is to empty this file and delete it, along with the `includes` entry +# for it in phpstan.neon.dist. +# +# Generated by `composer phpstan:baselines`. Do not edit by hand; regenerate with +# +# composer phpstan:baselines -- --identifier=method.notFound +# +# which reruns the analysis with this file suppressed so the errors surface again. + +parameters: + ignoreErrors: + - + message: '#^Call to an undefined method WP_Upgrader\:\:get_name_for_update\(\)\.$#' + identifier: method.notFound + count: 1 + path: ../../../src/wp-admin/includes/class-language-pack-upgrader-skin.php + - + message: '#^Call to an undefined method WP_Upgrader\:\:plugin_info\(\)\.$#' + identifier: method.notFound + count: 1 + path: ../../../src/wp-admin/includes/class-plugin-installer-skin.php + - + message: '#^Call to an undefined method WP_Upgrader\:\:plugin_info\(\)\.$#' + identifier: method.notFound + count: 1 + path: ../../../src/wp-admin/includes/class-plugin-upgrader-skin.php + - + message: '#^Call to an undefined method WP_Upgrader\:\:theme_info\(\)\.$#' + identifier: method.notFound + count: 1 + path: ../../../src/wp-admin/includes/class-theme-installer-skin.php + - + message: '#^Call to an undefined method WP_Upgrader\:\:theme_info\(\)\.$#' + identifier: method.notFound + count: 1 + path: ../../../src/wp-admin/includes/class-theme-upgrader-skin.php diff --git a/tests/phpstan/baselines/method.unused.neon b/tests/phpstan/baselines/method.unused.neon new file mode 100644 index 0000000000000..f4314a8b42a5c --- /dev/null +++ b/tests/phpstan/baselines/method.unused.neon @@ -0,0 +1,45 @@ +# PHPStan baseline for the `method.unused` errors in WordPress core. +# +# https://phpstan.org/error-identifiers/method.unused +# +# Each entry is scoped to a single file and carries an exact occurrence count, +# so that a new instance is reported as a new error rather than being absorbed +# silently. Fixing an occurrence therefore means decrementing or removing its +# entry here as part of the same change. +# +# The goal is to empty this file and delete it, along with the `includes` entry +# for it in phpstan.neon.dist. +# +# Generated by `composer phpstan:baselines`. Do not edit by hand; regenerate with +# +# composer phpstan:baselines -- --identifier=method.unused +# +# which reruns the analysis with this file suppressed so the errors surface again. + +parameters: + ignoreErrors: + - + message: '#^Static method WP_Internal_Pointers\:\:print_js\(\) is unused\.$#' + identifier: method.unused + count: 1 + path: ../../../src/wp-admin/includes/class-wp-internal-pointers.php + - + message: '#^Method WP_Http\:\:_dispatch_request\(\) is unused\.$#' + identifier: method.unused + count: 1 + path: ../../../src/wp-includes/class-wp-http.php + - + message: '#^Method WP_Script_Modules\:\:get_marked_for_enqueue\(\) is unused\.$#' + identifier: method.unused + count: 1 + path: ../../../src/wp-includes/class-wp-script-modules.php + - + message: '#^Method WP_HTML_Tag_Processor\:\:skip_rawtext\(\) is unused\.$#' + identifier: method.unused + count: 1 + path: ../../../src/wp-includes/html-api/class-wp-html-tag-processor.php + - + message: '#^Method WP_HTML_Tag_Processor\:\:skip_script_data\(\) is unused\.$#' + identifier: method.unused + count: 1 + path: ../../../src/wp-includes/html-api/class-wp-html-tag-processor.php diff --git a/tests/phpstan/baselines/notIdentical.alwaysTrue.neon b/tests/phpstan/baselines/notIdentical.alwaysTrue.neon new file mode 100644 index 0000000000000..5fed187271bfc --- /dev/null +++ b/tests/phpstan/baselines/notIdentical.alwaysTrue.neon @@ -0,0 +1,75 @@ +# PHPStan baseline for the `notIdentical.alwaysTrue` errors in WordPress core. +# +# https://phpstan.org/error-identifiers/notIdentical.alwaysTrue +# +# Each entry is scoped to a single file and carries an exact occurrence count, +# so that a new instance is reported as a new error rather than being absorbed +# silently. Fixing an occurrence therefore means decrementing or removing its +# entry here as part of the same change. +# +# The goal is to empty this file and delete it, along with the `includes` entry +# for it in phpstan.neon.dist. +# +# Generated by `composer phpstan:baselines`. Do not edit by hand; regenerate with +# +# composer phpstan:baselines -- --identifier=notIdentical.alwaysTrue +# +# which reruns the analysis with this file suppressed so the errors surface again. + +parameters: + ignoreErrors: + - + message: '#^Strict comparison using \!\=\= between ''all'' and int will always evaluate to true\.$#' + identifier: notIdentical.alwaysTrue + count: 1 + path: ../../../src/wp-admin/includes/class-wp-links-list-table.php + - + message: '#^Strict comparison using \!\=\= between null and string will always evaluate to true\.$#' + identifier: notIdentical.alwaysTrue + count: 1 + path: ../../../src/wp-includes/block-supports/layout.php + - + message: '#^Strict comparison using \!\=\= between null and int\|string will always evaluate to true\.$#' + identifier: notIdentical.alwaysTrue + count: 2 + path: ../../../src/wp-includes/class-wp-rewrite.php + - + message: '#^Strict comparison using \!\=\= between array\{\} and non\-empty\-array\ will always evaluate to true\.$#' + identifier: notIdentical.alwaysTrue + count: 1 + path: ../../../src/wp-includes/class-wp-view-config-data.php + - + message: '#^Strict comparison using \!\=\= between ''Etc'' and ''Africa''\|''America''\|''Antarctica''\|''Arctic''\|''Asia''\|''Atlantic''\|''Australia''\|''Europe''\|''Indian''\|''Pacific'' will always evaluate to true\.$#' + identifier: notIdentical.alwaysTrue + count: 1 + path: ../../../src/wp-includes/functions.php + - + message: '#^Strict comparison using \!\=\= between ''STATE_COMPLETE'' and ''STATE_READY'' will always evaluate to true\.$#' + identifier: notIdentical.alwaysTrue + count: 1 + path: ../../../src/wp-includes/html-api/class-wp-html-tag-processor.php + - + message: '#^Strict comparison using \!\=\= between ''STATE_INCOMPLETE…'' and ''STATE_READY'' will always evaluate to true\.$#' + identifier: notIdentical.alwaysTrue + count: 1 + path: ../../../src/wp-includes/html-api/class-wp-html-tag-processor.php + - + message: '#^Strict comparison using \!\=\= between ''STATE_MATCHED_TAG'' and ''STATE_READY'' will always evaluate to true\.$#' + identifier: notIdentical.alwaysTrue + count: 1 + path: ../../../src/wp-includes/html-api/class-wp-html-tag-processor.php + - + message: '#^Strict comparison using \!\=\= between 0 and int\\|int\<1, max\> will always evaluate to true\.$#' + identifier: notIdentical.alwaysTrue + count: 1 + path: ../../../src/wp-includes/nav-menu.php + - + message: '#^Strict comparison using \!\=\= between float\|int\|numeric\-string and ''bottom''\|''footer''\|''header''\|''main''\|''menu\-1''\|''menu\-2''\|''navigation''\|''primary''\|''secondary''\|''social''\|''subsidiary''\|''top'' will always evaluate to true\.$#' + identifier: notIdentical.alwaysTrue + count: 2 + path: ../../../src/wp-includes/nav-menu.php + - + message: '#^Strict comparison using \!\=\= between false and int will always evaluate to true\.$#' + identifier: notIdentical.alwaysTrue + count: 1 + path: ../../../src/wp-includes/pluggable.php diff --git a/tests/phpstan/baselines/nullCoalesce.offset.neon b/tests/phpstan/baselines/nullCoalesce.offset.neon new file mode 100644 index 0000000000000..cdf94447df8f3 --- /dev/null +++ b/tests/phpstan/baselines/nullCoalesce.offset.neon @@ -0,0 +1,25 @@ +# PHPStan baseline for the `nullCoalesce.offset` errors in WordPress core. +# +# https://phpstan.org/error-identifiers/nullCoalesce.offset +# +# Each entry is scoped to a single file and carries an exact occurrence count, +# so that a new instance is reported as a new error rather than being absorbed +# silently. Fixing an occurrence therefore means decrementing or removing its +# entry here as part of the same change. +# +# The goal is to empty this file and delete it, along with the `includes` entry +# for it in phpstan.neon.dist. +# +# Generated by `composer phpstan:baselines`. Do not edit by hand; regenerate with +# +# composer phpstan:baselines -- --identifier=nullCoalesce.offset +# +# which reruns the analysis with this file suppressed so the errors surface again. + +parameters: + ignoreErrors: + - + message: '#^Offset 1 on array\{list\, list\\} on left side of \?\? always exists and is not nullable\.$#' + identifier: nullCoalesce.offset + count: 1 + path: ../../../src/wp-includes/block-supports/block-style-variations.php diff --git a/tests/phpstan/baselines/nullCoalesce.property.neon b/tests/phpstan/baselines/nullCoalesce.property.neon new file mode 100644 index 0000000000000..3b30d530a3ab3 --- /dev/null +++ b/tests/phpstan/baselines/nullCoalesce.property.neon @@ -0,0 +1,55 @@ +# PHPStan baseline for the `nullCoalesce.property` errors in WordPress core. +# +# https://phpstan.org/error-identifiers/nullCoalesce.property +# +# Each entry is scoped to a single file and carries an exact occurrence count, +# so that a new instance is reported as a new error rather than being absorbed +# silently. Fixing an occurrence therefore means decrementing or removing its +# entry here as part of the same change. +# +# The goal is to empty this file and delete it, along with the `includes` entry +# for it in phpstan.neon.dist. +# +# Generated by `composer phpstan:baselines`. Do not edit by hand; regenerate with +# +# composer phpstan:baselines -- --identifier=nullCoalesce.property +# +# which reruns the analysis with this file suppressed so the errors surface again. + +parameters: + ignoreErrors: + - + message: '#^Property WP_User\:\:\$ID \(int\) on left side of \?\? is not nullable\.$#' + identifier: nullCoalesce.property + count: 1 + path: ../../../src/wp-includes/author-template.php + - + message: '#^Property WP_Http_Cookie\:\:\$path \(string\) on left side of \?\? is not nullable\.$#' + identifier: nullCoalesce.property + count: 1 + path: ../../../src/wp-includes/class-wp-http-cookie.php + - + message: '#^Property WP_Http_Cookie\:\:\$port \(int\|string\) on left side of \?\? is not nullable\.$#' + identifier: nullCoalesce.property + count: 1 + path: ../../../src/wp-includes/class-wp-http-cookie.php + - + message: '#^Property WP_Locale\:\:\$word_count_type \(string\) on left side of \?\? is not nullable\.$#' + identifier: nullCoalesce.property + count: 1 + path: ../../../src/wp-includes/class-wp-locale.php + - + message: '#^Property WP_Query\:\:\$max_num_pages \(int\) on left side of \?\? is not nullable\.$#' + identifier: nullCoalesce.property + count: 1 + path: ../../../src/wp-includes/general-template.php + - + message: '#^Property WP_Post_Type\:\:\$template \(array\\) on left side of \?\? is not nullable\.$#' + identifier: nullCoalesce.property + count: 1 + path: ../../../src/wp-includes/rest-api/endpoints/class-wp-rest-post-types-controller.php + - + message: '#^Property WP_User\:\:\$ID \(int\) on left side of \?\? is not nullable\.$#' + identifier: nullCoalesce.property + count: 1 + path: ../../../src/wp-includes/user.php diff --git a/tests/phpstan/baselines/offsetAccess.nonOffsetAccessible.neon b/tests/phpstan/baselines/offsetAccess.nonOffsetAccessible.neon new file mode 100644 index 0000000000000..5b2396b941816 --- /dev/null +++ b/tests/phpstan/baselines/offsetAccess.nonOffsetAccessible.neon @@ -0,0 +1,35 @@ +# PHPStan baseline for the `offsetAccess.nonOffsetAccessible` errors in WordPress core. +# +# https://phpstan.org/error-identifiers/offsetAccess.nonOffsetAccessible +# +# Each entry is scoped to a single file and carries an exact occurrence count, +# so that a new instance is reported as a new error rather than being absorbed +# silently. Fixing an occurrence therefore means decrementing or removing its +# entry here as part of the same change. +# +# The goal is to empty this file and delete it, along with the `includes` entry +# for it in phpstan.neon.dist. +# +# Generated by `composer phpstan:baselines`. Do not edit by hand; regenerate with +# +# composer phpstan:baselines -- --identifier=offsetAccess.nonOffsetAccessible +# +# which reruns the analysis with this file suppressed so the errors surface again. + +parameters: + ignoreErrors: + - + message: '#^Cannot access offset ''new_version'' on bool\.$#' + identifier: offsetAccess.nonOffsetAccessible + count: 4 + path: ../../../src/wp-admin/includes/class-wp-debug-data.php + - + message: '#^Cannot access offset mixed on bool\.$#' + identifier: offsetAccess.nonOffsetAccessible + count: 1 + path: ../../../src/wp-admin/includes/plugin.php + - + message: '#^Cannot access offset ''new_version'' on bool\.$#' + identifier: offsetAccess.nonOffsetAccessible + count: 1 + path: ../../../src/wp-admin/update-core.php diff --git a/tests/phpstan/baselines/offsetAccess.notFound.neon b/tests/phpstan/baselines/offsetAccess.notFound.neon new file mode 100644 index 0000000000000..a5e2eb0698cc8 --- /dev/null +++ b/tests/phpstan/baselines/offsetAccess.notFound.neon @@ -0,0 +1,40 @@ +# PHPStan baseline for the `offsetAccess.notFound` errors in WordPress core. +# +# https://phpstan.org/error-identifiers/offsetAccess.notFound +# +# Each entry is scoped to a single file and carries an exact occurrence count, +# so that a new instance is reported as a new error rather than being absorbed +# silently. Fixing an occurrence therefore means decrementing or removing its +# entry here as part of the same change. +# +# The goal is to empty this file and delete it, along with the `includes` entry +# for it in phpstan.neon.dist. +# +# Generated by `composer phpstan:baselines`. Do not edit by hand; regenerate with +# +# composer phpstan:baselines -- --identifier=offsetAccess.notFound +# +# which reruns the analysis with this file suppressed so the errors surface again. + +parameters: + ignoreErrors: + - + message: '#^Offset float does not exist on list\.$#' + identifier: offsetAccess.notFound + count: 1 + path: ../../../src/wp-admin/includes/class-wp-site-health.php + - + message: '#^Offset ''preview'' does not exist on array\{activate\: non\-falsy\-string\}\.$#' + identifier: offsetAccess.notFound + count: 1 + path: ../../../src/wp-admin/includes/class-wp-themes-list-table.php + - + message: '#^Offset ''basedir'' does not exist on string\.$#' + identifier: offsetAccess.notFound + count: 2 + path: ../../../src/wp-includes/fonts.php + - + message: '#^Offset ''baseurl'' does not exist on string\.$#' + identifier: offsetAccess.notFound + count: 2 + path: ../../../src/wp-includes/fonts.php diff --git a/tests/phpstan/baselines/offsetAssign.valueType.neon b/tests/phpstan/baselines/offsetAssign.valueType.neon new file mode 100644 index 0000000000000..8a28f7d980320 --- /dev/null +++ b/tests/phpstan/baselines/offsetAssign.valueType.neon @@ -0,0 +1,25 @@ +# PHPStan baseline for the `offsetAssign.valueType` errors in WordPress core. +# +# https://phpstan.org/error-identifiers/offsetAssign.valueType +# +# Each entry is scoped to a single file and carries an exact occurrence count, +# so that a new instance is reported as a new error rather than being absorbed +# silently. Fixing an occurrence therefore means decrementing or removing its +# entry here as part of the same change. +# +# The goal is to empty this file and delete it, along with the `includes` entry +# for it in phpstan.neon.dist. +# +# Generated by `composer phpstan:baselines`. Do not edit by hand; regenerate with +# +# composer phpstan:baselines -- --identifier=offsetAssign.valueType +# +# which reruns the analysis with this file suppressed so the errors surface again. + +parameters: + ignoreErrors: + - + message: '#^WpOrg\\Requests\\Cookie\\Jar does not accept WpOrg\\Requests\\Cookie\.$#' + identifier: offsetAssign.valueType + count: 2 + path: ../../../src/wp-includes/class-wp-http.php diff --git a/tests/phpstan/baselines/parameter.defaultValue.neon b/tests/phpstan/baselines/parameter.defaultValue.neon new file mode 100644 index 0000000000000..34b6b678fc447 --- /dev/null +++ b/tests/phpstan/baselines/parameter.defaultValue.neon @@ -0,0 +1,105 @@ +# PHPStan baseline for the `parameter.defaultValue` errors in WordPress core. +# +# https://phpstan.org/error-identifiers/parameter.defaultValue +# +# Each entry is scoped to a single file and carries an exact occurrence count, +# so that a new instance is reported as a new error rather than being absorbed +# silently. Fixing an occurrence therefore means decrementing or removing its +# entry here as part of the same change. +# +# The goal is to empty this file and delete it, along with the `includes` entry +# for it in phpstan.neon.dist. +# +# Generated by `composer phpstan:baselines`. Do not edit by hand; regenerate with +# +# composer phpstan:baselines -- --identifier=parameter.defaultValue +# +# which reruns the analysis with this file suppressed so the errors surface again. + +parameters: + ignoreErrors: + - + message: '#^Default value of the parameter \#1 \$admin_header_callback \(''''\) of method Custom_Background\:\:__construct\(\) is incompatible with type callable\(\)\: mixed\.$#' + identifier: parameter.defaultValue + count: 1 + path: ../../../src/wp-admin/includes/class-custom-background.php + - + message: '#^Default value of the parameter \#2 \$admin_image_div_callback \(''''\) of method Custom_Background\:\:__construct\(\) is incompatible with type callable\(\)\: mixed\.$#' + identifier: parameter.defaultValue + count: 1 + path: ../../../src/wp-admin/includes/class-custom-background.php + - + message: '#^Default value of the parameter \#2 \$admin_image_div_callback \(''''\) of method Custom_Image_Header\:\:__construct\(\) is incompatible with type callable\(\)\: mixed\.$#' + identifier: parameter.defaultValue + count: 1 + path: ../../../src/wp-admin/includes/class-custom-image-header.php + - + message: '#^Default value of the parameter \#5 \$callback \(''''\) of function add_comments_page\(\) is incompatible with type callable\(\)\: mixed\.$#' + identifier: parameter.defaultValue + count: 1 + path: ../../../src/wp-admin/includes/plugin.php + - + message: '#^Default value of the parameter \#5 \$callback \(''''\) of function add_dashboard_page\(\) is incompatible with type callable\(\)\: mixed\.$#' + identifier: parameter.defaultValue + count: 1 + path: ../../../src/wp-admin/includes/plugin.php + - + message: '#^Default value of the parameter \#5 \$callback \(''''\) of function add_links_page\(\) is incompatible with type callable\(\)\: mixed\.$#' + identifier: parameter.defaultValue + count: 1 + path: ../../../src/wp-admin/includes/plugin.php + - + message: '#^Default value of the parameter \#5 \$callback \(''''\) of function add_management_page\(\) is incompatible with type callable\(\)\: mixed\.$#' + identifier: parameter.defaultValue + count: 1 + path: ../../../src/wp-admin/includes/plugin.php + - + message: '#^Default value of the parameter \#5 \$callback \(''''\) of function add_media_page\(\) is incompatible with type callable\(\)\: mixed\.$#' + identifier: parameter.defaultValue + count: 1 + path: ../../../src/wp-admin/includes/plugin.php + - + message: '#^Default value of the parameter \#5 \$callback \(''''\) of function add_menu_page\(\) is incompatible with type callable\(\)\: mixed\.$#' + identifier: parameter.defaultValue + count: 1 + path: ../../../src/wp-admin/includes/plugin.php + - + message: '#^Default value of the parameter \#5 \$callback \(''''\) of function add_options_page\(\) is incompatible with type callable\(\)\: mixed\.$#' + identifier: parameter.defaultValue + count: 1 + path: ../../../src/wp-admin/includes/plugin.php + - + message: '#^Default value of the parameter \#5 \$callback \(''''\) of function add_pages_page\(\) is incompatible with type callable\(\)\: mixed\.$#' + identifier: parameter.defaultValue + count: 1 + path: ../../../src/wp-admin/includes/plugin.php + - + message: '#^Default value of the parameter \#5 \$callback \(''''\) of function add_plugins_page\(\) is incompatible with type callable\(\)\: mixed\.$#' + identifier: parameter.defaultValue + count: 1 + path: ../../../src/wp-admin/includes/plugin.php + - + message: '#^Default value of the parameter \#5 \$callback \(''''\) of function add_posts_page\(\) is incompatible with type callable\(\)\: mixed\.$#' + identifier: parameter.defaultValue + count: 1 + path: ../../../src/wp-admin/includes/plugin.php + - + message: '#^Default value of the parameter \#5 \$callback \(''''\) of function add_theme_page\(\) is incompatible with type callable\(\)\: mixed\.$#' + identifier: parameter.defaultValue + count: 1 + path: ../../../src/wp-admin/includes/plugin.php + - + message: '#^Default value of the parameter \#5 \$callback \(''''\) of function add_users_page\(\) is incompatible with type callable\(\)\: mixed\.$#' + identifier: parameter.defaultValue + count: 1 + path: ../../../src/wp-admin/includes/plugin.php + - + message: '#^Default value of the parameter \#6 \$callback \(''''\) of function add_submenu_page\(\) is incompatible with type callable\(\)\: mixed\.$#' + identifier: parameter.defaultValue + count: 1 + path: ../../../src/wp-admin/includes/plugin.php + - + message: '#^Default value of the parameter \#3 \$deprecated \(''''\) of function unregister_setting\(\) is incompatible with type callable\(\)\: mixed\.$#' + identifier: parameter.defaultValue + count: 1 + path: ../../../src/wp-includes/option.php diff --git a/tests/phpstan/baselines/parameter.notFound.neon b/tests/phpstan/baselines/parameter.notFound.neon new file mode 100644 index 0000000000000..c3835cd7903c4 --- /dev/null +++ b/tests/phpstan/baselines/parameter.notFound.neon @@ -0,0 +1,35 @@ +# PHPStan baseline for the `parameter.notFound` errors in WordPress core. +# +# https://phpstan.org/error-identifiers/parameter.notFound +# +# Each entry is scoped to a single file and carries an exact occurrence count, +# so that a new instance is reported as a new error rather than being absorbed +# silently. Fixing an occurrence therefore means decrementing or removing its +# entry here as part of the same change. +# +# The goal is to empty this file and delete it, along with the `includes` entry +# for it in phpstan.neon.dist. +# +# Generated by `composer phpstan:baselines`. Do not edit by hand; regenerate with +# +# composer phpstan:baselines -- --identifier=parameter.notFound +# +# which reruns the analysis with this file suppressed so the errors surface again. + +parameters: + ignoreErrors: + - + message: '#^PHPDoc tag @param references unknown parameter\: \$key$#' + identifier: parameter.notFound + count: 1 + path: ../../../src/wp-includes/functions.php + - + message: '#^PHPDoc tag @param references unknown parameter\: \$url$#' + identifier: parameter.notFound + count: 1 + path: ../../../src/wp-includes/functions.php + - + message: '#^PHPDoc tag @param references unknown parameter\: \$value$#' + identifier: parameter.notFound + count: 1 + path: ../../../src/wp-includes/functions.php diff --git a/tests/phpstan/baselines/parameter.phpDocType.neon b/tests/phpstan/baselines/parameter.phpDocType.neon new file mode 100644 index 0000000000000..db8a7f3b32466 --- /dev/null +++ b/tests/phpstan/baselines/parameter.phpDocType.neon @@ -0,0 +1,25 @@ +# PHPStan baseline for the `parameter.phpDocType` errors in WordPress core. +# +# https://phpstan.org/error-identifiers/parameter.phpDocType +# +# Each entry is scoped to a single file and carries an exact occurrence count, +# so that a new instance is reported as a new error rather than being absorbed +# silently. Fixing an occurrence therefore means decrementing or removing its +# entry here as part of the same change. +# +# The goal is to empty this file and delete it, along with the `includes` entry +# for it in phpstan.neon.dist. +# +# Generated by `composer phpstan:baselines`. Do not edit by hand; regenerate with +# +# composer phpstan:baselines -- --identifier=parameter.phpDocType +# +# which reruns the analysis with this file suppressed so the errors surface again. + +parameters: + ignoreErrors: + - + message: '#^PHPDoc tag @param for parameter \$block_type with type array\ is incompatible with native type string\.$#' + identifier: parameter.phpDocType + count: 1 + path: ../../../src/wp-includes/class-wp-block-processor.php diff --git a/tests/phpstan/baselines/parameter.unresolvableType.neon b/tests/phpstan/baselines/parameter.unresolvableType.neon new file mode 100644 index 0000000000000..1193e163ca555 --- /dev/null +++ b/tests/phpstan/baselines/parameter.unresolvableType.neon @@ -0,0 +1,25 @@ +# PHPStan baseline for the `parameter.unresolvableType` errors in WordPress core. +# +# https://phpstan.org/error-identifiers/parameter.unresolvableType +# +# Each entry is scoped to a single file and carries an exact occurrence count, +# so that a new instance is reported as a new error rather than being absorbed +# silently. Fixing an occurrence therefore means decrementing or removing its +# entry here as part of the same change. +# +# The goal is to empty this file and delete it, along with the `includes` entry +# for it in phpstan.neon.dist. +# +# Generated by `composer phpstan:baselines`. Do not edit by hand; regenerate with +# +# composer phpstan:baselines -- --identifier=parameter.unresolvableType +# +# which reruns the analysis with this file suppressed so the errors surface again. + +parameters: + ignoreErrors: + - + message: '#^PHPDoc tag @param for parameter \$type contains unresolvable type\.$#' + identifier: parameter.unresolvableType + count: 1 + path: ../../../src/wp-includes/class-wp-feed-cache-transient.php diff --git a/tests/phpstan/baselines/parameterByRef.type.neon b/tests/phpstan/baselines/parameterByRef.type.neon new file mode 100644 index 0000000000000..8b7394add3c7e --- /dev/null +++ b/tests/phpstan/baselines/parameterByRef.type.neon @@ -0,0 +1,50 @@ +# PHPStan baseline for the `parameterByRef.type` errors in WordPress core. +# +# https://phpstan.org/error-identifiers/parameterByRef.type +# +# Each entry is scoped to a single file and carries an exact occurrence count, +# so that a new instance is reported as a new error rather than being absorbed +# silently. Fixing an occurrence therefore means decrementing or removing its +# entry here as part of the same change. +# +# The goal is to empty this file and delete it, along with the `includes` entry +# for it in phpstan.neon.dist. +# +# Generated by `composer phpstan:baselines`. Do not edit by hand; regenerate with +# +# composer phpstan:baselines -- --identifier=parameterByRef.type +# +# which reruns the analysis with this file suppressed so the errors surface again. + +parameters: + ignoreErrors: + - + message: '#^Parameter &\$stored_results by\-ref type of method WP_Scripts\:\:get_highest_fetchpriority_with_dependents\(\) expects array\, array\ given\.$#' + identifier: parameterByRef.type + count: 1 + path: ../../../src/wp-includes/class-wp-scripts.php + - + message: '#^Parameter &\$query by\-ref type of method WP_Tax_Query\:\:clean_query\(\) expects array, WP_Error given\.$#' + identifier: parameterByRef.type + count: 2 + path: ../../../src/wp-includes/class-wp-tax-query.php + - + message: '#^Parameter &\$query by\-ref type of method WP_Tax_Query\:\:transform_query\(\) expects array, WP_Error given\.$#' + identifier: parameterByRef.type + count: 1 + path: ../../../src/wp-includes/class-wp-tax-query.php + - + message: '#^Parameter &\$matched_token_byte_length by\-ref type of method WP_Token_Map\:\:read_token\(\) expects int\|null, \(float\|int\) given\.$#' + identifier: parameterByRef.type + count: 1 + path: ../../../src/wp-includes/class-wp-token-map.php + - + message: '#^Parameter &\$has_noncharacters by\-ref type of function _wp_scan_utf8\(\) expects bool\|null, int given\.$#' + identifier: parameterByRef.type + count: 2 + path: ../../../src/wp-includes/compat-utf8.php + - + message: '#^Parameter &\$result by\-ref type of function _page_traverse_name\(\) expects array\, array given\.$#' + identifier: parameterByRef.type + count: 1 + path: ../../../src/wp-includes/post.php diff --git a/tests/phpstan/baselines/parameterByRef.unusedType.neon b/tests/phpstan/baselines/parameterByRef.unusedType.neon new file mode 100644 index 0000000000000..73746ef20c078 --- /dev/null +++ b/tests/phpstan/baselines/parameterByRef.unusedType.neon @@ -0,0 +1,30 @@ +# PHPStan baseline for the `parameterByRef.unusedType` errors in WordPress core. +# +# https://phpstan.org/error-identifiers/parameterByRef.unusedType +# +# Each entry is scoped to a single file and carries an exact occurrence count, +# so that a new instance is reported as a new error rather than being absorbed +# silently. Fixing an occurrence therefore means decrementing or removing its +# entry here as part of the same change. +# +# The goal is to empty this file and delete it, along with the `includes` entry +# for it in phpstan.neon.dist. +# +# Generated by `composer phpstan:baselines`. Do not edit by hand; regenerate with +# +# composer phpstan:baselines -- --identifier=parameterByRef.unusedType +# +# which reruns the analysis with this file suppressed so the errors surface again. + +parameters: + ignoreErrors: + - + message: '#^Function _wp_scan_utf8\(\) never assigns null to &\$has_noncharacters so it can be removed from the by\-ref type\.$#' + identifier: parameterByRef.unusedType + count: 1 + path: ../../../src/wp-includes/compat-utf8.php + - + message: '#^Function _wp_utf8_codepoint_span\(\) never assigns null to &\$found_code_points so it can be removed from the by\-ref type\.$#' + identifier: parameterByRef.unusedType + count: 1 + path: ../../../src/wp-includes/compat-utf8.php diff --git a/tests/phpstan/baselines/property.defaultValue.neon b/tests/phpstan/baselines/property.defaultValue.neon new file mode 100644 index 0000000000000..e604833eade20 --- /dev/null +++ b/tests/phpstan/baselines/property.defaultValue.neon @@ -0,0 +1,110 @@ +# PHPStan baseline for the `property.defaultValue` errors in WordPress core. +# +# https://phpstan.org/error-identifiers/property.defaultValue +# +# Each entry is scoped to a single file and carries an exact occurrence count, +# so that a new instance is reported as a new error rather than being absorbed +# silently. Fixing an occurrence therefore means decrementing or removing its +# entry here as part of the same change. +# +# The goal is to empty this file and delete it, along with the `includes` entry +# for it in phpstan.neon.dist. +# +# Generated by `composer phpstan:baselines`. Do not edit by hand; regenerate with +# +# composer phpstan:baselines -- --identifier=property.defaultValue +# +# which reruns the analysis with this file suppressed so the errors surface again. + +parameters: + ignoreErrors: + - + message: '#^Property Walker_Nav_Menu\:\:\$tree_type \(string\) does not accept default value of type array\\.$#' + identifier: property.defaultValue + count: 1 + path: ../../../src/wp-includes/class-walker-nav-menu.php + - + message: '#^Property WP_Block\:\:\$inner_blocks \(WP_Block_List\) does not accept default value of type array\.$#' + identifier: property.defaultValue + count: 1 + path: ../../../src/wp-includes/class-wp-block.php + - + message: '#^Property WP_Comment_Query\:\:\$date_query \(WP_Date_Query\) does not accept default value of type false\.$#' + identifier: property.defaultValue + count: 1 + path: ../../../src/wp-includes/class-wp-comment-query.php + - + message: '#^Property WP_Comment_Query\:\:\$meta_query \(WP_Meta_Query\) does not accept default value of type false\.$#' + identifier: property.defaultValue + count: 1 + path: ../../../src/wp-includes/class-wp-comment-query.php + - + message: '#^Property WP_Customize_Control\:\:\$active_callback \(callable\(\)\: mixed\) does not accept default value of type ''''\.$#' + identifier: property.defaultValue + count: 1 + path: ../../../src/wp-includes/class-wp-customize-control.php + - + message: '#^Property WP_Customize_Panel\:\:\$active_callback \(callable\(\)\: mixed\) does not accept default value of type ''''\.$#' + identifier: property.defaultValue + count: 1 + path: ../../../src/wp-includes/class-wp-customize-panel.php + - + message: '#^Property WP_Customize_Panel\:\:\$theme_supports \(array\\) does not accept default value of type string\.$#' + identifier: property.defaultValue + count: 1 + path: ../../../src/wp-includes/class-wp-customize-panel.php + - + message: '#^Property WP_Customize_Section\:\:\$active_callback \(callable\(\)\: mixed\) does not accept default value of type ''''\.$#' + identifier: property.defaultValue + count: 1 + path: ../../../src/wp-includes/class-wp-customize-section.php + - + message: '#^Property WP_Customize_Setting\:\:\$sanitize_callback \(callable\(\)\: mixed\) does not accept default value of type ''''\.$#' + identifier: property.defaultValue + count: 1 + path: ../../../src/wp-includes/class-wp-customize-setting.php + - + message: '#^Property WP_Customize_Setting\:\:\$sanitize_js_callback \(callable\(\)\: mixed\) does not accept default value of type ''''\.$#' + identifier: property.defaultValue + count: 1 + path: ../../../src/wp-includes/class-wp-customize-setting.php + - + message: '#^Property WP_Customize_Setting\:\:\$validate_callback \(callable\(\)\: mixed\) does not accept default value of type ''''\.$#' + identifier: property.defaultValue + count: 1 + path: ../../../src/wp-includes/class-wp-customize-setting.php + - + message: '#^Property WP_Query\:\:\$date_query \(WP_Date_Query\) does not accept default value of type false\.$#' + identifier: property.defaultValue + count: 1 + path: ../../../src/wp-includes/class-wp-query.php + - + message: '#^Property WP_Query\:\:\$meta_query \(WP_Meta_Query\) does not accept default value of type false\.$#' + identifier: property.defaultValue + count: 1 + path: ../../../src/wp-includes/class-wp-query.php + - + message: '#^Property WP_Site_Query\:\:\$date_query \(WP_Date_Query\) does not accept default value of type false\.$#' + identifier: property.defaultValue + count: 1 + path: ../../../src/wp-includes/class-wp-site-query.php + - + message: '#^Property WP_Site_Query\:\:\$meta_query \(WP_Meta_Query\) does not accept default value of type false\.$#' + identifier: property.defaultValue + count: 1 + path: ../../../src/wp-includes/class-wp-site-query.php + - + message: '#^Property WP_Term_Query\:\:\$meta_query \(WP_Meta_Query\) does not accept default value of type false\.$#' + identifier: property.defaultValue + count: 1 + path: ../../../src/wp-includes/class-wp-term-query.php + - + message: '#^Property WP_Term\:\:\$term_group \(int\) does not accept default value of type string\.$#' + identifier: property.defaultValue + count: 1 + path: ../../../src/wp-includes/class-wp-term.php + - + message: '#^Property WP_User_Query\:\:\$meta_query \(WP_Meta_Query\) does not accept default value of type false\.$#' + identifier: property.defaultValue + count: 1 + path: ../../../src/wp-includes/class-wp-user-query.php diff --git a/tests/phpstan/baselines/property.nonObject.neon b/tests/phpstan/baselines/property.nonObject.neon new file mode 100644 index 0000000000000..81a4af3e09511 --- /dev/null +++ b/tests/phpstan/baselines/property.nonObject.neon @@ -0,0 +1,255 @@ +# PHPStan baseline for the `property.nonObject` errors in WordPress core. +# +# https://phpstan.org/error-identifiers/property.nonObject +# +# Each entry is scoped to a single file and carries an exact occurrence count, +# so that a new instance is reported as a new error rather than being absorbed +# silently. Fixing an occurrence therefore means decrementing or removing its +# entry here as part of the same change. +# +# The goal is to empty this file and delete it, along with the `includes` entry +# for it in phpstan.neon.dist. +# +# Generated by `composer phpstan:baselines`. Do not edit by hand; regenerate with +# +# composer phpstan:baselines -- --identifier=property.nonObject +# +# which reruns the analysis with this file suppressed so the errors surface again. + +parameters: + ignoreErrors: + - + message: '#^Cannot access property \$download_link on array\|object\.$#' + identifier: property.nonObject + count: 2 + path: ../../../src/wp-admin/includes/ajax-actions.php + - + message: '#^Cannot access property \$id on int\|string\|WP_Term\.$#' + identifier: property.nonObject + count: 1 + path: ../../../src/wp-admin/includes/ajax-actions.php + - + message: '#^Cannot access property \$link on int\|string\|WP_Term\.$#' + identifier: property.nonObject + count: 1 + path: ../../../src/wp-admin/includes/ajax-actions.php + - + message: '#^Cannot access property \$name on array\|object\.$#' + identifier: property.nonObject + count: 1 + path: ../../../src/wp-admin/includes/ajax-actions.php + - + message: '#^Cannot access property \$themes on array\|object\.$#' + identifier: property.nonObject + count: 1 + path: ../../../src/wp-admin/includes/ajax-actions.php + - + message: '#^Cannot access property \$download_link on array\|object\.$#' + identifier: property.nonObject + count: 1 + path: ../../../src/wp-admin/includes/class-theme-upgrader.php + - + message: '#^Cannot access property \$name on array\|object\.$#' + identifier: property.nonObject + count: 1 + path: ../../../src/wp-admin/includes/class-theme-upgrader.php + - + message: '#^Cannot access property \$version on array\|object\.$#' + identifier: property.nonObject + count: 1 + path: ../../../src/wp-admin/includes/class-theme-upgrader.php + - + message: '#^Cannot access property \$current on array\|object\.$#' + identifier: property.nonObject + count: 3 + path: ../../../src/wp-admin/includes/class-wp-automatic-updater.php + - + message: '#^Cannot access property \$response on array\|object\.$#' + identifier: property.nonObject + count: 1 + path: ../../../src/wp-admin/includes/class-wp-automatic-updater.php + - + message: '#^Cannot access property \$version on array\|object\.$#' + identifier: property.nonObject + count: 1 + path: ../../../src/wp-admin/includes/class-wp-automatic-updater.php + - + message: '#^Cannot access property \$info on array\|object\.$#' + identifier: property.nonObject + count: 1 + path: ../../../src/wp-admin/includes/class-wp-plugin-install-list-table.php + - + message: '#^Cannot access property \$plugins on array\|object\.$#' + identifier: property.nonObject + count: 1 + path: ../../../src/wp-admin/includes/class-wp-plugin-install-list-table.php + - + message: '#^Cannot access property \$parent on array\|object\.$#' + identifier: property.nonObject + count: 1 + path: ../../../src/wp-admin/includes/class-wp-terms-list-table.php + - + message: '#^Cannot access property \$term_id on array\|object\.$#' + identifier: property.nonObject + count: 2 + path: ../../../src/wp-admin/includes/class-wp-terms-list-table.php + - + message: '#^Cannot access property \$info on array\|object\.$#' + identifier: property.nonObject + count: 1 + path: ../../../src/wp-admin/includes/class-wp-theme-install-list-table.php + - + message: '#^Cannot access property \$themes on array\|object\.$#' + identifier: property.nonObject + count: 1 + path: ../../../src/wp-admin/includes/class-wp-theme-install-list-table.php + - + message: '#^Cannot access property \$author on array\|object\.$#' + identifier: property.nonObject + count: 2 + path: ../../../src/wp-admin/includes/plugin-install.php + - + message: '#^Cannot access property \$downloaded on array\|object\.$#' + identifier: property.nonObject + count: 2 + path: ../../../src/wp-admin/includes/plugin-install.php + - + message: '#^Cannot access property \$external on array\|object\.$#' + identifier: property.nonObject + count: 1 + path: ../../../src/wp-admin/includes/plugin-install.php + - + message: '#^Cannot access property \$homepage on array\|object\.$#' + identifier: property.nonObject + count: 2 + path: ../../../src/wp-admin/includes/plugin-install.php + - + message: '#^Cannot access property \$name on array\|object\.$#' + identifier: property.nonObject + count: 1 + path: ../../../src/wp-admin/includes/plugin-install.php + - + message: '#^Cannot access property \$requires on array\|object\.$#' + identifier: property.nonObject + count: 2 + path: ../../../src/wp-admin/includes/plugin-install.php + - + message: '#^Cannot access property \$sections on array\|object\.$#' + identifier: property.nonObject + count: 5 + path: ../../../src/wp-admin/includes/plugin-install.php + - + message: '#^Cannot access property \$slug on array\|object\.$#' + identifier: property.nonObject + count: 3 + path: ../../../src/wp-admin/includes/plugin-install.php + - + message: '#^Cannot access property \$tested on array\|object\.$#' + identifier: property.nonObject + count: 2 + path: ../../../src/wp-admin/includes/plugin-install.php + - + message: '#^Cannot access property \$version on array\|object\.$#' + identifier: property.nonObject + count: 2 + path: ../../../src/wp-admin/includes/plugin-install.php + - + message: '#^Cannot access property \$meta_key on object\|true\.$#' + identifier: property.nonObject + count: 4 + path: ../../../src/wp-admin/includes/post.php + - + message: '#^Cannot access property \$post_id on object\|true\.$#' + identifier: property.nonObject + count: 2 + path: ../../../src/wp-admin/includes/post.php + - + message: '#^Cannot access property \$download_link on array\|object\.$#' + identifier: property.nonObject + count: 2 + path: ../../../src/wp-admin/update.php + - + message: '#^Cannot access property \$name on array\|object\.$#' + identifier: property.nonObject + count: 2 + path: ../../../src/wp-admin/update.php + - + message: '#^Cannot access property \$version on array\|object\.$#' + identifier: property.nonObject + count: 2 + path: ../../../src/wp-admin/update.php + - + message: '#^Cannot access property \$comment_shortcuts on WP_User\|false\.$#' + identifier: property.nonObject + count: 1 + path: ../../../src/wp-admin/user-edit.php + - + message: '#^Cannot access property \$infinite_scrolling on WP_User\|false\.$#' + identifier: property.nonObject + count: 1 + path: ../../../src/wp-admin/user-edit.php + - + message: '#^Cannot access property \$id on int\|string\|WP_Term\.$#' + identifier: property.nonObject + count: 1 + path: ../../../src/wp-includes/category-template.php + - + message: '#^Cannot access property \$link on int\|string\|WP_Term\.$#' + identifier: property.nonObject + count: 1 + path: ../../../src/wp-includes/category-template.php + - + message: '#^Cannot access property \$themes on array\|object\.$#' + identifier: property.nonObject + count: 1 + path: ../../../src/wp-includes/class-wp-customize-manager.php + - + message: '#^Cannot access property \$object_id on array\|WP_Error\|WP_Term\.$#' + identifier: property.nonObject + count: 1 + path: ../../../src/wp-includes/class-wp-term-query.php + - + message: '#^Cannot access property \$term_id on string\|WP_Customize_Setting\.$#' + identifier: property.nonObject + count: 1 + path: ../../../src/wp-includes/customize/class-wp-customize-nav-menu-control.php + - + message: '#^Cannot access property \$link_id on array\|object\.$#' + identifier: property.nonObject + count: 3 + path: ../../../src/wp-includes/link-template.php + - + message: '#^Cannot access property \$plugins on array\|object\.$#' + identifier: property.nonObject + count: 1 + path: ../../../src/wp-includes/rest-api/endpoints/class-wp-rest-block-directory-controller.php + - + message: '#^Cannot access property \$auto_add on WP_Term\|false\.$#' + identifier: property.nonObject + count: 1 + path: ../../../src/wp-includes/rest-api/endpoints/class-wp-rest-menus-controller.php + - + message: '#^Cannot access property \$download_link on array\|object\.$#' + identifier: property.nonObject + count: 1 + path: ../../../src/wp-includes/rest-api/endpoints/class-wp-rest-plugins-controller.php + - + message: '#^Cannot access property \$language_packs on array\|object\.$#' + identifier: property.nonObject + count: 1 + path: ../../../src/wp-includes/rest-api/endpoints/class-wp-rest-plugins-controller.php + - + message: '#^Cannot access property \$parent on array\|object\.$#' + identifier: property.nonObject + count: 1 + path: ../../../src/wp-includes/taxonomy.php + - + message: '#^Cannot access property \$template_name on array\.$#' + identifier: property.nonObject + count: 1 + path: ../../../src/wp-includes/taxonomy.php + - + message: '#^Cannot access property \$term_id on array\|object\.$#' + identifier: property.nonObject + count: 4 + path: ../../../src/wp-includes/taxonomy.php diff --git a/tests/phpstan/baselines/property.notFound.neon b/tests/phpstan/baselines/property.notFound.neon new file mode 100644 index 0000000000000..7893a2dd24bb6 --- /dev/null +++ b/tests/phpstan/baselines/property.notFound.neon @@ -0,0 +1,330 @@ +# PHPStan baseline for the `property.notFound` errors in WordPress core. +# +# https://phpstan.org/error-identifiers/property.notFound +# +# Each entry is scoped to a single file and carries an exact occurrence count, +# so that a new instance is reported as a new error rather than being absorbed +# silently. Fixing an occurrence therefore means decrementing or removing its +# entry here as part of the same change. +# +# The goal is to empty this file and delete it, along with the `includes` entry +# for it in phpstan.neon.dist. +# +# Generated by `composer phpstan:baselines`. Do not edit by hand; regenerate with +# +# composer phpstan:baselines -- --identifier=property.notFound +# +# which reruns the analysis with this file suppressed so the errors surface again. + +parameters: + ignoreErrors: + - + message: '#^Access to an undefined property WP_Upgrader_Skin\:\:\$language_update\.$#' + identifier: property.notFound + count: 2 + path: ../../../src/wp-admin/includes/class-language-pack-upgrader.php + - + message: '#^Access to an undefined property WP_Upgrader\:\:\$new_plugin_data\.$#' + identifier: property.notFound + count: 1 + path: ../../../src/wp-admin/includes/class-plugin-installer-skin.php + - + message: '#^Access to an undefined property WP_Upgrader_Skin\:\:\$plugin_active\.$#' + identifier: property.notFound + count: 1 + path: ../../../src/wp-admin/includes/class-plugin-upgrader.php + - + message: '#^Access to an undefined property WP_Upgrader_Skin\:\:\$plugin_info\.$#' + identifier: property.notFound + count: 1 + path: ../../../src/wp-admin/includes/class-plugin-upgrader.php + - + message: '#^Access to an undefined property WP_Upgrader\:\:\$new_theme_data\.$#' + identifier: property.notFound + count: 1 + path: ../../../src/wp-admin/includes/class-theme-installer-skin.php + - + message: '#^Access to an undefined property WP_Upgrader_Skin\:\:\$api\.$#' + identifier: property.notFound + count: 2 + path: ../../../src/wp-admin/includes/class-theme-upgrader.php + - + message: '#^Access to an undefined property WP_Upgrader_Skin\:\:\$theme_info\.$#' + identifier: property.notFound + count: 1 + path: ../../../src/wp-admin/includes/class-theme-upgrader.php + - + message: '#^Access to an undefined property WP_Post\:\:\$attr_title\.$#' + identifier: property.notFound + count: 1 + path: ../../../src/wp-admin/includes/class-walker-nav-menu-checklist.php + - + message: '#^Access to an undefined property WP_Post\:\:\$classes\.$#' + identifier: property.notFound + count: 1 + path: ../../../src/wp-admin/includes/class-walker-nav-menu-checklist.php + - + message: '#^Access to an undefined property WP_Post\:\:\$menu_item_parent\.$#' + identifier: property.notFound + count: 1 + path: ../../../src/wp-admin/includes/class-walker-nav-menu-checklist.php + - + message: '#^Access to an undefined property WP_Post\:\:\$object\.$#' + identifier: property.notFound + count: 1 + path: ../../../src/wp-admin/includes/class-walker-nav-menu-checklist.php + - + message: '#^Access to an undefined property WP_Post\:\:\$object_id\.$#' + identifier: property.notFound + count: 2 + path: ../../../src/wp-admin/includes/class-walker-nav-menu-checklist.php + - + message: '#^Access to an undefined property WP_Post\:\:\$target\.$#' + identifier: property.notFound + count: 1 + path: ../../../src/wp-admin/includes/class-walker-nav-menu-checklist.php + - + message: '#^Access to an undefined property WP_Post\:\:\$title\.$#' + identifier: property.notFound + count: 2 + path: ../../../src/wp-admin/includes/class-walker-nav-menu-checklist.php + - + message: '#^Access to an undefined property WP_Post\:\:\$type\.$#' + identifier: property.notFound + count: 1 + path: ../../../src/wp-admin/includes/class-walker-nav-menu-checklist.php + - + message: '#^Access to an undefined property WP_Post\:\:\$url\.$#' + identifier: property.notFound + count: 1 + path: ../../../src/wp-admin/includes/class-walker-nav-menu-checklist.php + - + message: '#^Access to an undefined property WP_Post\:\:\$xfn\.$#' + identifier: property.notFound + count: 1 + path: ../../../src/wp-admin/includes/class-walker-nav-menu-checklist.php + - + message: '#^Access to an undefined property WP_Post\:\:\$classes\.$#' + identifier: property.notFound + count: 1 + path: ../../../src/wp-admin/includes/class-walker-nav-menu-edit.php + - + message: '#^Access to an undefined property WP_Post\:\:\$description\.$#' + identifier: property.notFound + count: 1 + path: ../../../src/wp-admin/includes/class-walker-nav-menu-edit.php + - + message: '#^Access to an undefined property WP_Post\:\:\$menu_item_parent\.$#' + identifier: property.notFound + count: 1 + path: ../../../src/wp-admin/includes/class-walker-nav-menu-edit.php + - + message: '#^Access to an undefined property WP_Post\:\:\$object\.$#' + identifier: property.notFound + count: 4 + path: ../../../src/wp-admin/includes/class-walker-nav-menu-edit.php + - + message: '#^Access to an undefined property WP_Post\:\:\$object_id\.$#' + identifier: property.notFound + count: 3 + path: ../../../src/wp-admin/includes/class-walker-nav-menu-edit.php + - + message: '#^Access to an undefined property WP_Post\:\:\$target\.$#' + identifier: property.notFound + count: 1 + path: ../../../src/wp-admin/includes/class-walker-nav-menu-edit.php + - + message: '#^Access to an undefined property WP_Post\:\:\$title\.$#' + identifier: property.notFound + count: 4 + path: ../../../src/wp-admin/includes/class-walker-nav-menu-edit.php + - + message: '#^Access to an undefined property WP_Post\:\:\$type\.$#' + identifier: property.notFound + count: 1 + path: ../../../src/wp-admin/includes/class-walker-nav-menu-edit.php + - + message: '#^Access to an undefined property WP_Post\:\:\$type_label\.$#' + identifier: property.notFound + count: 1 + path: ../../../src/wp-admin/includes/class-walker-nav-menu-edit.php + - + message: '#^Access to an undefined property WP_Post\:\:\$url\.$#' + identifier: property.notFound + count: 2 + path: ../../../src/wp-admin/includes/class-walker-nav-menu-edit.php + - + message: '#^Access to an undefined property WP_Post\:\:\$xfn\.$#' + identifier: property.notFound + count: 1 + path: ../../../src/wp-admin/includes/class-walker-nav-menu-edit.php + - + message: '#^Access to an undefined property WP_Theme\:\:\$author\.$#' + identifier: property.notFound + count: 3 + path: ../../../src/wp-admin/includes/class-wp-debug-data.php + - + message: '#^Access to an undefined property WP_Theme\:\:\$name\.$#' + identifier: property.notFound + count: 4 + path: ../../../src/wp-admin/includes/class-wp-debug-data.php + - + message: '#^Access to an undefined property WP_Theme\:\:\$parent_theme\.$#' + identifier: property.notFound + count: 1 + path: ../../../src/wp-admin/includes/class-wp-debug-data.php + - + message: '#^Access to an undefined property WP_Theme\:\:\$version\.$#' + identifier: property.notFound + count: 5 + path: ../../../src/wp-admin/includes/class-wp-debug-data.php + - + message: '#^Access to an undefined property WP_Theme\:\:\$auto_update_forced\.$#' + identifier: property.notFound + count: 1 + path: ../../../src/wp-admin/includes/class-wp-ms-themes-list-table.php + - + message: '#^Access to an undefined property WP_Theme\:\:\$update_supported\.$#' + identifier: property.notFound + count: 1 + path: ../../../src/wp-admin/includes/class-wp-ms-themes-list-table.php + - + message: '#^Access to an undefined property WP_Theme\:\:\$name\.$#' + identifier: property.notFound + count: 8 + path: ../../../src/wp-admin/includes/class-wp-site-health.php + - + message: '#^Access to an undefined property WP_Post\:\:\$_wp_attachment_image_alt\.$#' + identifier: property.notFound + count: 2 + path: ../../../src/wp-admin/includes/image.php + - + message: '#^Access to an undefined property WP_Post\:\:\$front_or_home\.$#' + identifier: property.notFound + count: 1 + path: ../../../src/wp-admin/includes/nav-menu.php + - + message: '#^Access to an undefined property WP_Post\:\:\$privacy_policy_page\.$#' + identifier: property.notFound + count: 1 + path: ../../../src/wp-admin/includes/nav-menu.php + - + message: '#^Access to an undefined property wpdb\:\:\$categories\.$#' + identifier: property.notFound + count: 4 + path: ../../../src/wp-admin/includes/upgrade.php + - + message: '#^Access to an undefined property wpdb\:\:\$link2cat\.$#' + identifier: property.notFound + count: 1 + path: ../../../src/wp-admin/includes/upgrade.php + - + message: '#^Access to an undefined property wpdb\:\:\$post2cat\.$#' + identifier: property.notFound + count: 4 + path: ../../../src/wp-admin/includes/upgrade.php + - + message: '#^Access to an undefined property WP_Term\:\:\$truncated_name\.$#' + identifier: property.notFound + count: 2 + path: ../../../src/wp-admin/nav-menus.php + - + message: '#^Access to an undefined property WP_Theme\:\:\$version\.$#' + identifier: property.notFound + count: 1 + path: ../../../src/wp-admin/update-core.php + - + message: '#^Access to an undefined property WP_Post\:\:\$description\.$#' + identifier: property.notFound + count: 1 + path: ../../../src/wp-content/themes/twentyfifteen/functions.php + - + message: '#^Access to an undefined property WP_Post\:\:\$classes\.$#' + identifier: property.notFound + count: 2 + path: ../../../src/wp-content/themes/twentynineteen/inc/icon-functions.php + - + message: '#^Access to an undefined property WP_Post\:\:\$url\.$#' + identifier: property.notFound + count: 1 + path: ../../../src/wp-content/themes/twentynineteen/inc/icon-functions.php + - + message: '#^Access to an undefined property WP_Post\:\:\$classes\.$#' + identifier: property.notFound + count: 1 + path: ../../../src/wp-content/themes/twentynineteen/inc/template-functions.php + - + message: '#^Access to an undefined property WP_Post\:\:\$classes\.$#' + identifier: property.notFound + count: 1 + path: ../../../src/wp-content/themes/twentyseventeen/inc/icon-functions.php + - + message: '#^Access to an undefined property WP_Post\:\:\$classes\.$#' + identifier: property.notFound + count: 2 + path: ../../../src/wp-content/themes/twentytwenty/inc/template-tags.php + - + message: '#^Access to an undefined property WP_Post\:\:\$url\.$#' + identifier: property.notFound + count: 1 + path: ../../../src/wp-content/themes/twentytwenty/inc/template-tags.php + - + message: '#^Access to an undefined property WP_Post\:\:\$url\.$#' + identifier: property.notFound + count: 1 + path: ../../../src/wp-content/themes/twentytwentyone/inc/menu-functions.php + - + message: '#^Access to an undefined property WP_Post_Type\:\:\$capabilities\.$#' + identifier: property.notFound + count: 1 + path: ../../../src/wp-includes/capabilities.php + - + message: '#^Access to an undefined property WP_Term\:\:\$link\.$#' + identifier: property.notFound + count: 2 + path: ../../../src/wp-includes/category-template.php + - + message: '#^Access to an undefined property WP_Post\:\:\$current\.$#' + identifier: property.notFound + count: 1 + path: ../../../src/wp-includes/class-walker-nav-menu.php + - + message: '#^Access to an undefined property WP_Post\:\:\$title\.$#' + identifier: property.notFound + count: 2 + path: ../../../src/wp-includes/class-walker-nav-menu.php + - + message: '#^Access to an undefined property WP_Query\:\:\$comments_by_type\.$#' + identifier: property.notFound + count: 3 + path: ../../../src/wp-includes/comment-template.php + - + message: '#^Access to an undefined property WP_Post\:\:\$attr_title\.$#' + identifier: property.notFound + count: 2 + path: ../../../src/wp-includes/customize/class-wp-customize-nav-menu-item-setting.php + - + message: '#^Access to an undefined property WP_Post\:\:\$db_id\.$#' + identifier: property.notFound + count: 2 + path: ../../../src/wp-includes/customize/class-wp-customize-nav-menu-item-setting.php + - + message: '#^Access to an undefined property WP_Post\:\:\$description\.$#' + identifier: property.notFound + count: 2 + path: ../../../src/wp-includes/customize/class-wp-customize-nav-menu-item-setting.php + - + message: '#^Access to an undefined property WP_Post\:\:\$type\.$#' + identifier: property.notFound + count: 3 + path: ../../../src/wp-includes/customize/class-wp-customize-nav-menu-item-setting.php + - + message: '#^Access to an undefined property WP_Post\:\:\$type_label\.$#' + identifier: property.notFound + count: 1 + path: ../../../src/wp-includes/customize/class-wp-customize-nav-menu-item-setting.php + - + message: '#^Access to an undefined property WP_Post\:\:\$url\.$#' + identifier: property.notFound + count: 4 + path: ../../../src/wp-includes/customize/class-wp-customize-nav-menu-item-setting.php diff --git a/tests/phpstan/baselines/property.onlyWritten.neon b/tests/phpstan/baselines/property.onlyWritten.neon new file mode 100644 index 0000000000000..f4b740e1bdf02 --- /dev/null +++ b/tests/phpstan/baselines/property.onlyWritten.neon @@ -0,0 +1,25 @@ +# PHPStan baseline for the `property.onlyWritten` errors in WordPress core. +# +# https://phpstan.org/error-identifiers/property.onlyWritten +# +# Each entry is scoped to a single file and carries an exact occurrence count, +# so that a new instance is reported as a new error rather than being absorbed +# silently. Fixing an occurrence therefore means decrementing or removing its +# entry here as part of the same change. +# +# The goal is to empty this file and delete it, along with the `includes` entry +# for it in phpstan.neon.dist. +# +# Generated by `composer phpstan:baselines`. Do not edit by hand; regenerate with +# +# composer phpstan:baselines -- --identifier=property.onlyWritten +# +# which reruns the analysis with this file suppressed so the errors surface again. + +parameters: + ignoreErrors: + - + message: '#^Property WP_REST_Template_Autosaves_Controller\:\:\$parent_post_type is never read, only written\.$#' + identifier: property.onlyWritten + count: 1 + path: ../../../src/wp-includes/rest-api/endpoints/class-wp-rest-template-autosaves-controller.php diff --git a/tests/phpstan/baselines/property.phpDocType.neon b/tests/phpstan/baselines/property.phpDocType.neon new file mode 100644 index 0000000000000..2f69939d19f8b --- /dev/null +++ b/tests/phpstan/baselines/property.phpDocType.neon @@ -0,0 +1,45 @@ +# PHPStan baseline for the `property.phpDocType` errors in WordPress core. +# +# https://phpstan.org/error-identifiers/property.phpDocType +# +# Each entry is scoped to a single file and carries an exact occurrence count, +# so that a new instance is reported as a new error rather than being absorbed +# silently. Fixing an occurrence therefore means decrementing or removing its +# entry here as part of the same change. +# +# The goal is to empty this file and delete it, along with the `includes` entry +# for it in phpstan.neon.dist. +# +# Generated by `composer phpstan:baselines`. Do not edit by hand; regenerate with +# +# composer phpstan:baselines -- --identifier=property.phpDocType +# +# which reruns the analysis with this file suppressed so the errors surface again. + +parameters: + ignoreErrors: + - + message: '#^PHPDoc type array of property WP_Customize_Nav_Menu_Item_Setting\:\:\$default is not covariant with PHPDoc type string of overridden property WP_Customize_Setting\:\:\$default\.$#' + identifier: property.phpDocType + count: 1 + path: ../../../src/wp-includes/customize/class-wp-customize-nav-menu-item-setting.php + - + message: '#^PHPDoc type array of property WP_Customize_Nav_Menu_Setting\:\:\$default is not covariant with PHPDoc type string of overridden property WP_Customize_Setting\:\:\$default\.$#' + identifier: property.phpDocType + count: 1 + path: ../../../src/wp-includes/customize/class-wp-customize-nav-menu-setting.php + - + message: '#^PHPDoc type false of property WP_REST_Attachments_Controller\:\:\$allow_batch is not covariant with PHPDoc type array of overridden property WP_REST_Posts_Controller\:\:\$allow_batch\.$#' + identifier: property.phpDocType + count: 1 + path: ../../../src/wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php + - + message: '#^PHPDoc type false of property WP_REST_Font_Faces_Controller\:\:\$allow_batch is not covariant with PHPDoc type array of overridden property WP_REST_Posts_Controller\:\:\$allow_batch\.$#' + identifier: property.phpDocType + count: 1 + path: ../../../src/wp-includes/rest-api/endpoints/class-wp-rest-font-faces-controller.php + - + message: '#^PHPDoc type false of property WP_REST_Font_Families_Controller\:\:\$allow_batch is not covariant with PHPDoc type array of overridden property WP_REST_Posts_Controller\:\:\$allow_batch\.$#' + identifier: property.phpDocType + count: 1 + path: ../../../src/wp-includes/rest-api/endpoints/class-wp-rest-font-families-controller.php diff --git a/tests/phpstan/baselines/property.private.neon b/tests/phpstan/baselines/property.private.neon new file mode 100644 index 0000000000000..900a8e51dc423 --- /dev/null +++ b/tests/phpstan/baselines/property.private.neon @@ -0,0 +1,60 @@ +# PHPStan baseline for the `property.private` errors in WordPress core. +# +# https://phpstan.org/error-identifiers/property.private +# +# Each entry is scoped to a single file and carries an exact occurrence count, +# so that a new instance is reported as a new error rather than being absorbed +# silently. Fixing an occurrence therefore means decrementing or removing its +# entry here as part of the same change. +# +# The goal is to empty this file and delete it, along with the `includes` entry +# for it in phpstan.neon.dist. +# +# Generated by `composer phpstan:baselines`. Do not edit by hand; regenerate with +# +# composer phpstan:baselines -- --identifier=property.private +# +# which reruns the analysis with this file suppressed so the errors surface again. + +parameters: + ignoreErrors: + - + message: '#^Access to private property WP_Theme\:\:\$stylesheet\.$#' + identifier: property.private + count: 20 + path: ../../../src/wp-admin/includes/class-wp-debug-data.php + - + message: '#^Access to private property WP_Theme\:\:\$template\.$#' + identifier: property.private + count: 2 + path: ../../../src/wp-admin/includes/class-wp-debug-data.php + - + message: '#^Access to private property WP_Block_Type\:\:\$uses_context\.$#' + identifier: property.private + count: 1 + path: ../../../src/wp-admin/includes/post.php + - + message: '#^Access to private property WP_Block_Type\:\:\$variations\.$#' + identifier: property.private + count: 1 + path: ../../../src/wp-admin/includes/post.php + - + message: '#^Access to private property WP_Block_Type\:\:\$uses_context\.$#' + identifier: property.private + count: 1 + path: ../../../src/wp-includes/class-wp-block.php + - + message: '#^Access to private property WP_Object_Cache\:\:\$cache\.$#' + identifier: property.private + count: 2 + path: ../../../src/wp-includes/ms-blogs.php + - + message: '#^Access to private property WP_Block_Type\:\:\$uses_context\.$#' + identifier: property.private + count: 1 + path: ../../../src/wp-includes/rest-api/endpoints/class-wp-rest-block-types-controller.php + - + message: '#^Access to private property WP_Block_Type\:\:\$variations\.$#' + identifier: property.private + count: 1 + path: ../../../src/wp-includes/rest-api/endpoints/class-wp-rest-block-types-controller.php diff --git a/tests/phpstan/baselines/property.protected.neon b/tests/phpstan/baselines/property.protected.neon new file mode 100644 index 0000000000000..b29d125bd2a7a --- /dev/null +++ b/tests/phpstan/baselines/property.protected.neon @@ -0,0 +1,60 @@ +# PHPStan baseline for the `property.protected` errors in WordPress core. +# +# https://phpstan.org/error-identifiers/property.protected +# +# Each entry is scoped to a single file and carries an exact occurrence count, +# so that a new instance is reported as a new error rather than being absorbed +# silently. Fixing an occurrence therefore means decrementing or removing its +# entry here as part of the same change. +# +# The goal is to empty this file and delete it, along with the `includes` entry +# for it in phpstan.neon.dist. +# +# Generated by `composer phpstan:baselines`. Do not edit by hand; regenerate with +# +# composer phpstan:baselines -- --identifier=property.protected +# +# which reruns the analysis with this file suppressed so the errors surface again. + +parameters: + ignoreErrors: + - + message: '#^Access to protected property WP_List_Table\:\:\$screen\.$#' + identifier: property.protected + count: 1 + path: ../../../src/wp-admin/erase-personal-data.php + - + message: '#^Access to protected property WP_List_Table\:\:\$screen\.$#' + identifier: property.protected + count: 1 + path: ../../../src/wp-admin/export-personal-data.php + - + message: '#^Access to protected property WP_List_Table\:\:\$screen\.$#' + identifier: property.protected + count: 1 + path: ../../../src/wp-admin/includes/ajax-actions.php + - + message: '#^Access to protected property wpdb\:\:\$dbh\.$#' + identifier: property.protected + count: 3 + path: ../../../src/wp-admin/includes/class-wp-debug-data.php + - + message: '#^Access to protected property wpdb\:\:\$dbhost\.$#' + identifier: property.protected + count: 1 + path: ../../../src/wp-admin/includes/class-wp-debug-data.php + - + message: '#^Access to protected property wpdb\:\:\$dbname\.$#' + identifier: property.protected + count: 1 + path: ../../../src/wp-admin/includes/class-wp-debug-data.php + - + message: '#^Access to protected property wpdb\:\:\$dbuser\.$#' + identifier: property.protected + count: 1 + path: ../../../src/wp-admin/includes/class-wp-debug-data.php + - + message: '#^Access to protected property WP_Object_Cache\:\:\$global_groups\.$#' + identifier: property.protected + count: 2 + path: ../../../src/wp-includes/ms-blogs.php diff --git a/tests/phpstan/baselines/property.unusedType.neon b/tests/phpstan/baselines/property.unusedType.neon new file mode 100644 index 0000000000000..9f1bd06e86c8f --- /dev/null +++ b/tests/phpstan/baselines/property.unusedType.neon @@ -0,0 +1,25 @@ +# PHPStan baseline for the `property.unusedType` errors in WordPress core. +# +# https://phpstan.org/error-identifiers/property.unusedType +# +# Each entry is scoped to a single file and carries an exact occurrence count, +# so that a new instance is reported as a new error rather than being absorbed +# silently. Fixing an occurrence therefore means decrementing or removing its +# entry here as part of the same change. +# +# The goal is to empty this file and delete it, along with the `includes` entry +# for it in phpstan.neon.dist. +# +# Generated by `composer phpstan:baselines`. Do not edit by hand; regenerate with +# +# composer phpstan:baselines -- --identifier=property.unusedType +# +# which reruns the analysis with this file suppressed so the errors surface again. + +parameters: + ignoreErrors: + - + message: '#^Property WP_HTML_Tag_Processor\:\:\$skip_newline_at \(int\|null\) is never assigned int so it can be removed from the property type\.$#' + identifier: property.unusedType + count: 1 + path: ../../../src/wp-includes/html-api/class-wp-html-tag-processor.php diff --git a/tests/phpstan/baselines/return.empty.neon b/tests/phpstan/baselines/return.empty.neon new file mode 100644 index 0000000000000..5abf2badb5636 --- /dev/null +++ b/tests/phpstan/baselines/return.empty.neon @@ -0,0 +1,30 @@ +# PHPStan baseline for the `return.empty` errors in WordPress core. +# +# https://phpstan.org/error-identifiers/return.empty +# +# Each entry is scoped to a single file and carries an exact occurrence count, +# so that a new instance is reported as a new error rather than being absorbed +# silently. Fixing an occurrence therefore means decrementing or removing its +# entry here as part of the same change. +# +# The goal is to empty this file and delete it, along with the `includes` entry +# for it in phpstan.neon.dist. +# +# Generated by `composer phpstan:baselines`. Do not edit by hand; regenerate with +# +# composer phpstan:baselines -- --identifier=return.empty +# +# which reruns the analysis with this file suppressed so the errors surface again. + +parameters: + ignoreErrors: + - + message: '#^Function twentytwenty_generate_css\(\) should return string but empty return statement found\.$#' + identifier: return.empty + count: 1 + path: ../../../src/wp-content/themes/twentytwenty/inc/custom-css.php + - + message: '#^Function wp_dropdown_languages\(\) should return string but empty return statement found\.$#' + identifier: return.empty + count: 1 + path: ../../../src/wp-includes/l10n.php diff --git a/tests/phpstan/baselines/return.missing.neon b/tests/phpstan/baselines/return.missing.neon new file mode 100644 index 0000000000000..11bb654bafb2c --- /dev/null +++ b/tests/phpstan/baselines/return.missing.neon @@ -0,0 +1,225 @@ +# PHPStan baseline for the `return.missing` errors in WordPress core. +# +# https://phpstan.org/error-identifiers/return.missing +# +# Each entry is scoped to a single file and carries an exact occurrence count, +# so that a new instance is reported as a new error rather than being absorbed +# silently. Fixing an occurrence therefore means decrementing or removing its +# entry here as part of the same change. +# +# The goal is to empty this file and delete it, along with the `includes` entry +# for it in phpstan.neon.dist. +# +# Generated by `composer phpstan:baselines`. Do not edit by hand; regenerate with +# +# composer phpstan:baselines -- --identifier=return.missing +# +# which reruns the analysis with this file suppressed so the errors surface again. + +parameters: + ignoreErrors: + - + message: '#^Method Twenty_Eleven_Ephemera_Widget\:\:form\(\) should return string\|null but return statement is missing\.$#' + identifier: return.missing + count: 1 + path: ../../../src/wp-content/themes/twentyeleven/inc/widgets.php + - + message: '#^Method Twenty_Fourteen_Ephemera_Widget\:\:form\(\) should return string\|null but return statement is missing\.$#' + identifier: return.missing + count: 1 + path: ../../../src/wp-content/themes/twentyfourteen/inc/widgets.php + - + message: '#^Function get_category_by_path\(\) should return array\|WP_Error\|WP_Term\|null but return statement is missing\.$#' + identifier: return.missing + count: 1 + path: ../../../src/wp-includes/category.php + - + message: '#^Method WP_Customize_Manager\:\:get_control\(\) should return WP_Customize_Control\|null but return statement is missing\.$#' + identifier: return.missing + count: 1 + path: ../../../src/wp-includes/class-wp-customize-manager.php + - + message: '#^Method WP_Customize_Manager\:\:get_panel\(\) should return WP_Customize_Panel\|null but return statement is missing\.$#' + identifier: return.missing + count: 1 + path: ../../../src/wp-includes/class-wp-customize-manager.php + - + message: '#^Method WP_Customize_Manager\:\:get_section\(\) should return WP_Customize_Section\|null but return statement is missing\.$#' + identifier: return.missing + count: 1 + path: ../../../src/wp-includes/class-wp-customize-manager.php + - + message: '#^Method WP_Customize_Manager\:\:get_setting\(\) should return WP_Customize_Setting\|null but return statement is missing\.$#' + identifier: return.missing + count: 1 + path: ../../../src/wp-includes/class-wp-customize-manager.php + - + message: '#^Method WP_Customize_Widgets\:\:get_setting_type\(\) should return string\|null but return statement is missing\.$#' + identifier: return.missing + count: 1 + path: ../../../src/wp-includes/class-wp-customize-widgets.php + - + message: '#^Method WP_Image_Editor_Imagick\:\:set_imagick_time_limit\(\) should return int\|null but return statement is missing\.$#' + identifier: return.missing + count: 1 + path: ../../../src/wp-includes/class-wp-image-editor-imagick.php + - + message: '#^Method WP_Customize_Header_Image_Control\:\:get_current_image_src\(\) should return string\|null but return statement is missing\.$#' + identifier: return.missing + count: 1 + path: ../../../src/wp-includes/customize/class-wp-customize-header-image-control.php + - + message: '#^Function post_type_archive_title\(\) should return string\|null but return statement is missing\.$#' + identifier: return.missing + count: 1 + path: ../../../src/wp-includes/general-template.php + - + message: '#^Function single_month_title\(\) should return string\|false\|null but return statement is missing\.$#' + identifier: return.missing + count: 1 + path: ../../../src/wp-includes/general-template.php + - + message: '#^Function single_post_title\(\) should return string\|null but return statement is missing\.$#' + identifier: return.missing + count: 1 + path: ../../../src/wp-includes/general-template.php + - + message: '#^Function single_term_title\(\) should return string\|null but return statement is missing\.$#' + identifier: return.missing + count: 1 + path: ../../../src/wp-includes/general-template.php + - + message: '#^Function the_date\(\) should return string\|null but return statement is missing\.$#' + identifier: return.missing + count: 1 + path: ../../../src/wp-includes/general-template.php + - + message: '#^Function the_modified_date\(\) should return string\|null but return statement is missing\.$#' + identifier: return.missing + count: 1 + path: ../../../src/wp-includes/general-template.php + - + message: '#^Function wp_title\(\) should return string\|null but return statement is missing\.$#' + identifier: return.missing + count: 1 + path: ../../../src/wp-includes/general-template.php + - + message: '#^Function edit_term_link\(\) should return string\|null but return statement is missing\.$#' + identifier: return.missing + count: 1 + path: ../../../src/wp-includes/link-template.php + - + message: '#^Function get_next_posts_link\(\) should return string\|null but return statement is missing\.$#' + identifier: return.missing + count: 1 + path: ../../../src/wp-includes/link-template.php + - + message: '#^Function get_next_posts_page_link\(\) should return string\|null but return statement is missing\.$#' + identifier: return.missing + count: 2 + path: ../../../src/wp-includes/link-template.php + - + message: '#^Function get_previous_posts_link\(\) should return string\|null but return statement is missing\.$#' + identifier: return.missing + count: 1 + path: ../../../src/wp-includes/link-template.php + - + message: '#^Function get_previous_posts_page_link\(\) should return string\|null but return statement is missing\.$#' + identifier: return.missing + count: 1 + path: ../../../src/wp-includes/link-template.php + - + message: '#^Function next_posts\(\) should return string\|null but return statement is missing\.$#' + identifier: return.missing + count: 1 + path: ../../../src/wp-includes/link-template.php + - + message: '#^Function previous_posts\(\) should return string\|null but return statement is missing\.$#' + identifier: return.missing + count: 1 + path: ../../../src/wp-includes/link-template.php + - + message: '#^Function wp_list_users\(\) should return string\|null but return statement is missing\.$#' + identifier: return.missing + count: 1 + path: ../../../src/wp-includes/user.php + - + message: '#^Method WP_Nav_Menu_Widget\:\:form\(\) should return string\|null but return statement is missing\.$#' + identifier: return.missing + count: 1 + path: ../../../src/wp-includes/widgets/class-wp-nav-menu-widget.php + - + message: '#^Method WP_Widget_Archives\:\:form\(\) should return string\|null but return statement is missing\.$#' + identifier: return.missing + count: 1 + path: ../../../src/wp-includes/widgets/class-wp-widget-archives.php + - + message: '#^Method WP_Widget_Block\:\:form\(\) should return string\|null but return statement is missing\.$#' + identifier: return.missing + count: 1 + path: ../../../src/wp-includes/widgets/class-wp-widget-block.php + - + message: '#^Method WP_Widget_Calendar\:\:form\(\) should return string\|null but return statement is missing\.$#' + identifier: return.missing + count: 1 + path: ../../../src/wp-includes/widgets/class-wp-widget-calendar.php + - + message: '#^Method WP_Widget_Categories\:\:form\(\) should return string\|null but return statement is missing\.$#' + identifier: return.missing + count: 1 + path: ../../../src/wp-includes/widgets/class-wp-widget-categories.php + - + message: '#^Method WP_Widget_Custom_HTML\:\:form\(\) should return string\|null but return statement is missing\.$#' + identifier: return.missing + count: 1 + path: ../../../src/wp-includes/widgets/class-wp-widget-custom-html.php + - + message: '#^Method WP_Widget_Links\:\:form\(\) should return string\|null but return statement is missing\.$#' + identifier: return.missing + count: 1 + path: ../../../src/wp-includes/widgets/class-wp-widget-links.php + - + message: '#^Method WP_Widget_Media\:\:form\(\) should return string\|null but return statement is missing\.$#' + identifier: return.missing + count: 1 + path: ../../../src/wp-includes/widgets/class-wp-widget-media.php + - + message: '#^Method WP_Widget_Meta\:\:form\(\) should return string\|null but return statement is missing\.$#' + identifier: return.missing + count: 1 + path: ../../../src/wp-includes/widgets/class-wp-widget-meta.php + - + message: '#^Method WP_Widget_Pages\:\:form\(\) should return string\|null but return statement is missing\.$#' + identifier: return.missing + count: 1 + path: ../../../src/wp-includes/widgets/class-wp-widget-pages.php + - + message: '#^Method WP_Widget_Recent_Comments\:\:form\(\) should return string\|null but return statement is missing\.$#' + identifier: return.missing + count: 1 + path: ../../../src/wp-includes/widgets/class-wp-widget-recent-comments.php + - + message: '#^Method WP_Widget_Recent_Posts\:\:form\(\) should return string\|null but return statement is missing\.$#' + identifier: return.missing + count: 1 + path: ../../../src/wp-includes/widgets/class-wp-widget-recent-posts.php + - + message: '#^Method WP_Widget_RSS\:\:form\(\) should return string\|null but return statement is missing\.$#' + identifier: return.missing + count: 1 + path: ../../../src/wp-includes/widgets/class-wp-widget-rss.php + - + message: '#^Method WP_Widget_Search\:\:form\(\) should return string\|null but return statement is missing\.$#' + identifier: return.missing + count: 1 + path: ../../../src/wp-includes/widgets/class-wp-widget-search.php + - + message: '#^Method WP_Widget_Tag_Cloud\:\:form\(\) should return string\|null but return statement is missing\.$#' + identifier: return.missing + count: 2 + path: ../../../src/wp-includes/widgets/class-wp-widget-tag-cloud.php + - + message: '#^Method WP_Widget_Text\:\:form\(\) should return string\|null but return statement is missing\.$#' + identifier: return.missing + count: 2 + path: ../../../src/wp-includes/widgets/class-wp-widget-text.php diff --git a/tests/phpstan/baselines/return.type.neon b/tests/phpstan/baselines/return.type.neon new file mode 100644 index 0000000000000..9d99e94d0abd6 --- /dev/null +++ b/tests/phpstan/baselines/return.type.neon @@ -0,0 +1,165 @@ +# PHPStan baseline for the `return.type` errors in WordPress core. +# +# https://phpstan.org/error-identifiers/return.type +# +# Each entry is scoped to a single file and carries an exact occurrence count, +# so that a new instance is reported as a new error rather than being absorbed +# silently. Fixing an occurrence therefore means decrementing or removing its +# entry here as part of the same change. +# +# The goal is to empty this file and delete it, along with the `includes` entry +# for it in phpstan.neon.dist. +# +# Generated by `composer phpstan:baselines`. Do not edit by hand; regenerate with +# +# composer phpstan:baselines -- --identifier=return.type +# +# which reruns the analysis with this file suppressed so the errors surface again. + +parameters: + ignoreErrors: + - + message: '#^Method WP_Automatic_Updater\:\:update\(\) should return WP_Error\|null but returns false\.$#' + identifier: return.type + count: 2 + path: ../../../src/wp-admin/includes/class-wp-automatic-updater.php + - + message: '#^Function convert_to_screen\(\) should return WP_Screen but returns object\{id\: string, base\: string\}&stdClass\.$#' + identifier: return.type + count: 1 + path: ../../../src/wp-admin/includes/template.php + - + message: '#^Function twentytwenty_get_color_for_area\(\) should return string but returns false\.$#' + identifier: return.type + count: 1 + path: ../../../src/wp-content/themes/twentytwenty/functions.php + - + message: '#^Function filter_block_kses\(\) should return array but returns ArrayAccess&WP_Block_Parser_Block\.$#' + identifier: return.type + count: 1 + path: ../../../src/wp-includes/blocks.php + - + message: '#^Method WP_Block_Processor\:\:extract_full_block_and_advance\(\) should return array\\|null but returns array\\.$#' + identifier: return.type + count: 1 + path: ../../../src/wp-includes/class-wp-block-processor.php + - + message: '#^Method WP_Block_Processor\:\:extract_full_block_and_advance\(\) should return array\\|null but returns array\\|string\|null\>\.$#' + identifier: return.type + count: 1 + path: ../../../src/wp-includes/class-wp-block-processor.php + - + message: '#^Method WP_Block_Type\:\:__get\(\) should return array\\|string\|null but returns array\\.$#' + identifier: return.type + count: 1 + path: ../../../src/wp-includes/class-wp-block-type.php + - + message: '#^Method WP_Image_Editor_Imagick\:\:set_imagick_time_limit\(\) should return int\|null but returns float\.$#' + identifier: return.type + count: 1 + path: ../../../src/wp-includes/class-wp-image-editor-imagick.php + - + message: '#^Method WP_Image_Editor_Imagick\:\:write_image\(\) should return WP_Error\|true but returns bool\.$#' + identifier: return.type + count: 1 + path: ../../../src/wp-includes/class-wp-image-editor-imagick.php + - + message: '#^Method WP_Term_Query\:\:get_terms\(\) should return array\\|string but returns int\.$#' + identifier: return.type + count: 1 + path: ../../../src/wp-includes/class-wp-term-query.php + - + message: '#^Method wp_xmlrpc_server\:\:mw_newPost\(\) should return int\|IXR_Error but returns string\.$#' + identifier: return.type + count: 1 + path: ../../../src/wp-includes/class-wp-xmlrpc-server.php + - + message: '#^Method wp_xmlrpc_server\:\:wp_newTerm\(\) should return int\|IXR_Error but returns string\.$#' + identifier: return.type + count: 1 + path: ../../../src/wp-includes/class-wp-xmlrpc-server.php + - + message: '#^Function _upgrade_cron_array\(\) should return array\{version\: 2, \.\.\.\, interval\?\: int\<0, max\>\}\>\>\>\} but returns non\-empty\-array\<''version''\|int, array\, interval\?\: int\<0, max\>\}\>\|int\>\.$#' + identifier: return.type + count: 1 + path: ../../../src/wp-includes/cron.php + - + message: '#^Method WP_Customize_Nav_Menu_Setting\:\:filter_wp_get_nav_menu_object\(\) should return object\|null but returns false\.$#' + identifier: return.type + count: 1 + path: ../../../src/wp-includes/customize/class-wp-customize-nav-menu-setting.php + - + message: '#^Function _wp_filter_font_directory\(\) should return string but returns array\.$#' + identifier: return.type + count: 1 + path: ../../../src/wp-includes/fonts.php + - + message: '#^Method WP_Translation_Controller\:\:get_entries\(\) should return array\ but returns array\\>\.$#' + identifier: return.type + count: 1 + path: ../../../src/wp-includes/l10n/class-wp-translation-controller.php + - + message: '#^Method WP_Translation_File\:\:entries\(\) should return array\\> but returns array\\.$#' + identifier: return.type + count: 1 + path: ../../../src/wp-includes/l10n/class-wp-translation-file.php + - + message: '#^Function update_meta_cache\(\) should return array\|false but returns bool\.$#' + identifier: return.type + count: 1 + path: ../../../src/wp-includes/meta.php + - + message: '#^Function wp_post_revision_title\(\) should return string\|false but returns null\.$#' + identifier: return.type + count: 1 + path: ../../../src/wp-includes/post-template.php + - + message: '#^Function wp_post_revision_title_expanded\(\) should return string\|false but returns null\.$#' + identifier: return.type + count: 1 + path: ../../../src/wp-includes/post-template.php + - + message: '#^Function wp_set_post_categories\(\) should return array\|WP_Error\|false but returns true\.$#' + identifier: return.type + count: 1 + path: ../../../src/wp-includes/post.php + - + message: '#^Function wp_trash_post\(\) should return WP_Post\|false\|null but returns bool\.$#' + identifier: return.type + count: 1 + path: ../../../src/wp-includes/post.php + - + message: '#^Function wp_untrash_post\(\) should return WP_Post\|false\|null but returns bool\.$#' + identifier: return.type + count: 1 + path: ../../../src/wp-includes/post.php + - + message: '#^Method WP_REST_Autosaves_Controller\:\:get_item\(\) should return WP_Error\|WP_Post but returns WP_REST_Response\.$#' + identifier: return.type + count: 1 + path: ../../../src/wp-includes/rest-api/endpoints/class-wp-rest-autosaves-controller.php + - + message: '#^Method WP_REST_Controller\:\:get_object_type\(\) should return string but returns null\.$#' + identifier: return.type + count: 1 + path: ../../../src/wp-includes/rest-api/endpoints/class-wp-rest-controller.php + - + message: '#^Method WP_REST_Template_Autosaves_Controller\:\:get_item\(\) should return WP_Error\|WP_Post but returns WP_REST_Response\.$#' + identifier: return.type + count: 1 + path: ../../../src/wp-includes/rest-api/endpoints/class-wp-rest-template-autosaves-controller.php + - + message: '#^Function _wp_preview_post_thumbnail_filter\(\) should return array\|null but returns string\.$#' + identifier: return.type + count: 2 + path: ../../../src/wp-includes/revision.php + - + message: '#^Function term_exists\(\) should return array\{term_id\: numeric\-string, term_taxonomy_id\: numeric\-string\}\|int\|null but returns string\.$#' + identifier: return.type + count: 1 + path: ../../../src/wp-includes/taxonomy.php + - + message: '#^Function _wp_get_current_user\(\) should return WP_User but returns null\.$#' + identifier: return.type + count: 2 + path: ../../../src/wp-includes/user.php diff --git a/tests/phpstan/baselines/return.unusedType.neon b/tests/phpstan/baselines/return.unusedType.neon new file mode 100644 index 0000000000000..2acebf529a781 --- /dev/null +++ b/tests/phpstan/baselines/return.unusedType.neon @@ -0,0 +1,100 @@ +# PHPStan baseline for the `return.unusedType` errors in WordPress core. +# +# https://phpstan.org/error-identifiers/return.unusedType +# +# Each entry is scoped to a single file and carries an exact occurrence count, +# so that a new instance is reported as a new error rather than being absorbed +# silently. Fixing an occurrence therefore means decrementing or removing its +# entry here as part of the same change. +# +# The goal is to empty this file and delete it, along with the `includes` entry +# for it in phpstan.neon.dist. +# +# Generated by `composer phpstan:baselines`. Do not edit by hand; regenerate with +# +# composer phpstan:baselines -- --identifier=return.unusedType +# +# which reruns the analysis with this file suppressed so the errors surface again. + +parameters: + ignoreErrors: + - + message: '#^Function plugins_api\(\) never returns array so it can be removed from the return type\.$#' + identifier: return.unusedType + count: 1 + path: ../../../src/wp-admin/includes/plugin-install.php + - + message: '#^Function _fix_attachment_links\(\) never returns WP_Error so it can be removed from the return type\.$#' + identifier: return.unusedType + count: 1 + path: ../../../src/wp-admin/includes/post.php + - + message: '#^Function get_preferred_from_update_core\(\) never returns array so it can be removed from the return type\.$#' + identifier: return.unusedType + count: 1 + path: ../../../src/wp-admin/includes/update.php + - + message: '#^Function get_the_tag_list\(\) never returns WP_Error so it can be removed from the return type\.$#' + identifier: return.unusedType + count: 1 + path: ../../../src/wp-includes/category-template.php + - + message: '#^Function get_the_tag_list\(\) never returns false so it can be removed from the return type\.$#' + identifier: return.unusedType + count: 1 + path: ../../../src/wp-includes/category-template.php + - + message: '#^Function get_category\(\) never returns null so it can be removed from the return type\.$#' + identifier: return.unusedType + count: 1 + path: ../../../src/wp-includes/category.php + - + message: '#^Method WP_Recovery_Mode_Cookie_Service\:\:recovery_mode_hash\(\) never returns false so it can be removed from the return type\.$#' + identifier: return.unusedType + count: 1 + path: ../../../src/wp-includes/class-wp-recovery-mode-cookie-service.php + - + message: '#^Function wp_get_code_editor_settings\(\) never returns false so it can be removed from the return type\.$#' + identifier: return.unusedType + count: 1 + path: ../../../src/wp-includes/general-template.php + - + message: '#^Function get_post_gallery\(\) never returns string so it can be removed from the return type\.$#' + identifier: return.unusedType + count: 1 + path: ../../../src/wp-includes/media.php + - + message: '#^Function wp_imagecreatetruecolor\(\) never returns GdImage so it can be removed from the return type\.$#' + identifier: return.unusedType + count: 1 + path: ../../../src/wp-includes/media.php + - + message: '#^Function wp_mime_type_icon\(\) never returns false so it can be removed from the return type\.$#' + identifier: return.unusedType + count: 1 + path: ../../../src/wp-includes/post.php + - + message: '#^Function _set_preview\(\) never returns false so it can be removed from the return type\.$#' + identifier: return.unusedType + count: 1 + path: ../../../src/wp-includes/revision.php + - + message: '#^Function get_term_to_edit\(\) never returns int so it can be removed from the return type\.$#' + identifier: return.unusedType + count: 1 + path: ../../../src/wp-includes/taxonomy.php + - + message: '#^Function get_term_to_edit\(\) never returns null so it can be removed from the return type\.$#' + identifier: return.unusedType + count: 1 + path: ../../../src/wp-includes/taxonomy.php + - + message: '#^Function wp_is_password_reset_allowed_for_user\(\) never returns WP_Error so it can be removed from the return type\.$#' + identifier: return.unusedType + count: 1 + path: ../../../src/wp-includes/user.php + - + message: '#^Function validate_another_blog_signup\(\) never returns null so it can be removed from the return type\.$#' + identifier: return.unusedType + count: 1 + path: ../../../src/wp-signup.php diff --git a/tests/phpstan/baselines/smallerOrEqual.alwaysTrue.neon b/tests/phpstan/baselines/smallerOrEqual.alwaysTrue.neon new file mode 100644 index 0000000000000..aed03b30ea48d --- /dev/null +++ b/tests/phpstan/baselines/smallerOrEqual.alwaysTrue.neon @@ -0,0 +1,25 @@ +# PHPStan baseline for the `smallerOrEqual.alwaysTrue` errors in WordPress core. +# +# https://phpstan.org/error-identifiers/smallerOrEqual.alwaysTrue +# +# Each entry is scoped to a single file and carries an exact occurrence count, +# so that a new instance is reported as a new error rather than being absorbed +# silently. Fixing an occurrence therefore means decrementing or removing its +# entry here as part of the same change. +# +# The goal is to empty this file and delete it, along with the `includes` entry +# for it in phpstan.neon.dist. +# +# Generated by `composer phpstan:baselines`. Do not edit by hand; regenerate with +# +# composer phpstan:baselines -- --identifier=smallerOrEqual.alwaysTrue +# +# which reruns the analysis with this file suppressed so the errors surface again. + +parameters: + ignoreErrors: + - + message: '#^Comparison operation "\<\=" between 0 and int\<0, max\>\|false is always true\.$#' + identifier: smallerOrEqual.alwaysTrue + count: 1 + path: ../../../src/wp-includes/class-wp-theme-json.php diff --git a/tests/phpstan/baselines/staticClassAccess.privateMethod.neon b/tests/phpstan/baselines/staticClassAccess.privateMethod.neon new file mode 100644 index 0000000000000..c3a8969643a94 --- /dev/null +++ b/tests/phpstan/baselines/staticClassAccess.privateMethod.neon @@ -0,0 +1,190 @@ +# PHPStan baseline for the `staticClassAccess.privateMethod` errors in WordPress core. +# +# https://phpstan.org/error-identifiers/staticClassAccess.privateMethod +# +# Each entry is scoped to a single file and carries an exact occurrence count, +# so that a new instance is reported as a new error rather than being absorbed +# silently. Fixing an occurrence therefore means decrementing or removing its +# entry here as part of the same change. +# +# The goal is to empty this file and delete it, along with the `includes` entry +# for it in phpstan.neon.dist. +# +# Generated by `composer phpstan:baselines`. Do not edit by hand; regenerate with +# +# composer phpstan:baselines -- --identifier=staticClassAccess.privateMethod +# +# which reruns the analysis with this file suppressed so the errors surface again. + +parameters: + ignoreErrors: + - + message: '#^Unsafe call to private method WP_Classic_To_Block_Menu_Converter\:\:group_by_parent_id\(\) through static\:\:\.$#' + identifier: staticClassAccess.privateMethod + count: 1 + path: ../../../src/wp-includes/class-wp-classic-to-block-menu-converter.php + - + message: '#^Unsafe call to private method WP_Classic_To_Block_Menu_Converter\:\:to_blocks\(\) through static\:\:\.$#' + identifier: staticClassAccess.privateMethod + count: 2 + path: ../../../src/wp-includes/class-wp-classic-to-block-menu-converter.php + - + message: '#^Unsafe call to private method WP_Navigation_Fallback\:\:create_classic_menu_fallback\(\) through static\:\:\.$#' + identifier: staticClassAccess.privateMethod + count: 1 + path: ../../../src/wp-includes/class-wp-navigation-fallback.php + - + message: '#^Unsafe call to private method WP_Navigation_Fallback\:\:create_default_fallback\(\) through static\:\:\.$#' + identifier: staticClassAccess.privateMethod + count: 1 + path: ../../../src/wp-includes/class-wp-navigation-fallback.php + - + message: '#^Unsafe call to private method WP_Navigation_Fallback\:\:get_default_fallback_blocks\(\) through static\:\:\.$#' + identifier: staticClassAccess.privateMethod + count: 1 + path: ../../../src/wp-includes/class-wp-navigation-fallback.php + - + message: '#^Unsafe call to private method WP_Navigation_Fallback\:\:get_fallback_classic_menu\(\) through static\:\:\.$#' + identifier: staticClassAccess.privateMethod + count: 1 + path: ../../../src/wp-includes/class-wp-navigation-fallback.php + - + message: '#^Unsafe call to private method WP_Navigation_Fallback\:\:get_most_recently_created_nav_menu\(\) through static\:\:\.$#' + identifier: staticClassAccess.privateMethod + count: 1 + path: ../../../src/wp-includes/class-wp-navigation-fallback.php + - + message: '#^Unsafe call to private method WP_Navigation_Fallback\:\:get_most_recently_published_navigation\(\) through static\:\:\.$#' + identifier: staticClassAccess.privateMethod + count: 3 + path: ../../../src/wp-includes/class-wp-navigation-fallback.php + - + message: '#^Unsafe call to private method WP_Navigation_Fallback\:\:get_nav_menu_at_primary_location\(\) through static\:\:\.$#' + identifier: staticClassAccess.privateMethod + count: 1 + path: ../../../src/wp-includes/class-wp-navigation-fallback.php + - + message: '#^Unsafe call to private method WP_Navigation_Fallback\:\:get_nav_menu_with_primary_slug\(\) through static\:\:\.$#' + identifier: staticClassAccess.privateMethod + count: 1 + path: ../../../src/wp-includes/class-wp-navigation-fallback.php + - + message: '#^Unsafe call to private method WP_Theme_JSON_Resolver\:\:inject_variations_from_block_style_variation_files\(\) through static\:\:\.$#' + identifier: staticClassAccess.privateMethod + count: 1 + path: ../../../src/wp-includes/class-wp-theme-json-resolver.php + - + message: '#^Unsafe call to private method WP_Theme_JSON_Resolver\:\:inject_variations_from_block_styles_registry\(\) through static\:\:\.$#' + identifier: staticClassAccess.privateMethod + count: 1 + path: ../../../src/wp-includes/class-wp-theme-json-resolver.php + - + message: '#^Unsafe call to private method WP_Theme_JSON_Resolver\:\:recursively_iterate_json\(\) through static\:\:\.$#' + identifier: staticClassAccess.privateMethod + count: 2 + path: ../../../src/wp-includes/class-wp-theme-json-resolver.php + - + message: '#^Unsafe call to private method WP_Theme_JSON_Resolver\:\:remove_json_comments\(\) through static\:\:\.$#' + identifier: staticClassAccess.privateMethod + count: 2 + path: ../../../src/wp-includes/class-wp-theme-json-resolver.php + - + message: '#^Unsafe call to private method WP_Theme_JSON_Resolver\:\:style_variation_has_scope\(\) through static\:\:\.$#' + identifier: staticClassAccess.privateMethod + count: 1 + path: ../../../src/wp-includes/class-wp-theme-json-resolver.php + - + message: '#^Unsafe call to private method WP_Theme_JSON\:\:compute_spacing_sizes\(\) through static\:\:\.$#' + identifier: staticClassAccess.privateMethod + count: 3 + path: ../../../src/wp-includes/class-wp-theme-json.php + - + message: '#^Unsafe call to private method WP_Theme_JSON\:\:get_block_name_from_metadata_path\(\) through static\:\:\.$#' + identifier: staticClassAccess.privateMethod + count: 2 + path: ../../../src/wp-includes/class-wp-theme-json.php + - + message: '#^Unsafe call to private method WP_Theme_JSON\:\:get_block_nodes\(\) through static\:\:\.$#' + identifier: staticClassAccess.privateMethod + count: 3 + path: ../../../src/wp-includes/class-wp-theme-json.php + - + message: '#^Unsafe call to private method WP_Theme_JSON\:\:get_feature_selector\(\) through static\:\:\.$#' + identifier: staticClassAccess.privateMethod + count: 1 + path: ../../../src/wp-includes/class-wp-theme-json.php + - + message: '#^Unsafe call to private method WP_Theme_JSON\:\:get_viewport_breakpoint_value_in_pixels\(\) through static\:\:\.$#' + identifier: staticClassAccess.privateMethod + count: 1 + path: ../../../src/wp-includes/class-wp-theme-json.php + - + message: '#^Unsafe call to private method WP_Theme_JSON\:\:is_valid_viewport_breakpoint_size\(\) through static\:\:\.$#' + identifier: staticClassAccess.privateMethod + count: 1 + path: ../../../src/wp-includes/class-wp-theme-json.php + - + message: '#^Unsafe call to private method WP_Theme_JSON\:\:merge_spacing_sizes\(\) through static\:\:\.$#' + identifier: staticClassAccess.privateMethod + count: 2 + path: ../../../src/wp-includes/class-wp-theme-json.php + - + message: '#^Unsafe call to private method WP_Theme_JSON\:\:remove_indirect_properties\(\) through static\:\:\.$#' + identifier: staticClassAccess.privateMethod + count: 2 + path: ../../../src/wp-includes/class-wp-theme-json.php + - + message: '#^Unsafe call to private method WP_Theme_JSON\:\:resolve_custom_css_format\(\) through static\:\:\.$#' + identifier: staticClassAccess.privateMethod + count: 1 + path: ../../../src/wp-includes/class-wp-theme-json.php + - + message: '#^Unsafe call to private method WP_Theme_JSON\:\:sanitize_viewport_settings\(\) through static\:\:\.$#' + identifier: staticClassAccess.privateMethod + count: 3 + path: ../../../src/wp-includes/class-wp-theme-json.php + - + message: '#^Unsafe call to private method WP_Theme_JSON\:\:unwrap_shared_block_style_variations\(\) through static\:\:\.$#' + identifier: staticClassAccess.privateMethod + count: 1 + path: ../../../src/wp-includes/class-wp-theme-json.php + - + message: '#^Unsafe call to private method WP_Theme_JSON\:\:update_button_width_declarations\(\) through static\:\:\.$#' + identifier: staticClassAccess.privateMethod + count: 4 + path: ../../../src/wp-includes/class-wp-theme-json.php + - + message: '#^Unsafe call to private method WP_Theme_JSON\:\:update_paragraph_text_indent_selector\(\) through static\:\:\.$#' + identifier: staticClassAccess.privateMethod + count: 4 + path: ../../../src/wp-includes/class-wp-theme-json.php + - + message: '#^Unsafe call to private method WP_Theme_JSON\:\:update_separator_declarations\(\) through static\:\:\.$#' + identifier: staticClassAccess.privateMethod + count: 1 + path: ../../../src/wp-includes/class-wp-theme-json.php + - + message: '#^Unsafe call to private method WP_Font_Face_Resolver\:\:convert_font_face_properties\(\) through static\:\:\.$#' + identifier: staticClassAccess.privateMethod + count: 1 + path: ../../../src/wp-includes/fonts/class-wp-font-face-resolver.php + - + message: '#^Unsafe call to private method WP_Font_Face_Resolver\:\:maybe_parse_name_from_comma_separated_list\(\) through static\:\:\.$#' + identifier: staticClassAccess.privateMethod + count: 1 + path: ../../../src/wp-includes/fonts/class-wp-font-face-resolver.php + - + message: '#^Unsafe call to private method WP_Font_Face_Resolver\:\:parse_settings\(\) through static\:\:\.$#' + identifier: staticClassAccess.privateMethod + count: 2 + path: ../../../src/wp-includes/fonts/class-wp-font-face-resolver.php + - + message: '#^Unsafe call to private method WP_Font_Face_Resolver\:\:to_kebab_case\(\) through static\:\:\.$#' + identifier: staticClassAccess.privateMethod + count: 1 + path: ../../../src/wp-includes/fonts/class-wp-font-face-resolver.php + - + message: '#^Unsafe call to private method WP_Font_Face_Resolver\:\:to_theme_file_uri\(\) through static\:\:\.$#' + identifier: staticClassAccess.privateMethod + count: 1 + path: ../../../src/wp-includes/fonts/class-wp-font-face-resolver.php diff --git a/tests/phpstan/baselines/ternary.alwaysFalse.neon b/tests/phpstan/baselines/ternary.alwaysFalse.neon new file mode 100644 index 0000000000000..b73c48bc42724 --- /dev/null +++ b/tests/phpstan/baselines/ternary.alwaysFalse.neon @@ -0,0 +1,25 @@ +# PHPStan baseline for the `ternary.alwaysFalse` errors in WordPress core. +# +# https://phpstan.org/error-identifiers/ternary.alwaysFalse +# +# Each entry is scoped to a single file and carries an exact occurrence count, +# so that a new instance is reported as a new error rather than being absorbed +# silently. Fixing an occurrence therefore means decrementing or removing its +# entry here as part of the same change. +# +# The goal is to empty this file and delete it, along with the `includes` entry +# for it in phpstan.neon.dist. +# +# Generated by `composer phpstan:baselines`. Do not edit by hand; regenerate with +# +# composer phpstan:baselines -- --identifier=ternary.alwaysFalse +# +# which reruns the analysis with this file suppressed so the errors surface again. + +parameters: + ignoreErrors: + - + message: '#^Ternary operator condition is always false\.$#' + identifier: ternary.alwaysFalse + count: 2 + path: ../../../src/wp-admin/includes/class-wp-debug-data.php diff --git a/tests/phpstan/baselines/ternary.alwaysTrue.neon b/tests/phpstan/baselines/ternary.alwaysTrue.neon new file mode 100644 index 0000000000000..295254051f683 --- /dev/null +++ b/tests/phpstan/baselines/ternary.alwaysTrue.neon @@ -0,0 +1,30 @@ +# PHPStan baseline for the `ternary.alwaysTrue` errors in WordPress core. +# +# https://phpstan.org/error-identifiers/ternary.alwaysTrue +# +# Each entry is scoped to a single file and carries an exact occurrence count, +# so that a new instance is reported as a new error rather than being absorbed +# silently. Fixing an occurrence therefore means decrementing or removing its +# entry here as part of the same change. +# +# The goal is to empty this file and delete it, along with the `includes` entry +# for it in phpstan.neon.dist. +# +# Generated by `composer phpstan:baselines`. Do not edit by hand; regenerate with +# +# composer phpstan:baselines -- --identifier=ternary.alwaysTrue +# +# which reruns the analysis with this file suppressed so the errors surface again. + +parameters: + ignoreErrors: + - + message: '#^Ternary operator condition is always true\.$#' + identifier: ternary.alwaysTrue + count: 1 + path: ../../../src/wp-admin/menu-header.php + - + message: '#^Ternary operator condition is always true\.$#' + identifier: ternary.alwaysTrue + count: 1 + path: ../../../src/wp-admin/theme-install.php diff --git a/tests/phpstan/baselines/varTag.noVariable.neon b/tests/phpstan/baselines/varTag.noVariable.neon new file mode 100644 index 0000000000000..36e0f9fed1382 --- /dev/null +++ b/tests/phpstan/baselines/varTag.noVariable.neon @@ -0,0 +1,60 @@ +# PHPStan baseline for the `varTag.noVariable` errors in WordPress core. +# +# https://phpstan.org/error-identifiers/varTag.noVariable +# +# Each entry is scoped to a single file and carries an exact occurrence count, +# so that a new instance is reported as a new error rather than being absorbed +# silently. Fixing an occurrence therefore means decrementing or removing its +# entry here as part of the same change. +# +# The goal is to empty this file and delete it, along with the `includes` entry +# for it in phpstan.neon.dist. +# +# Generated by `composer phpstan:baselines`. Do not edit by hand; regenerate with +# +# composer phpstan:baselines -- --identifier=varTag.noVariable +# +# which reruns the analysis with this file suppressed so the errors surface again. + +parameters: + ignoreErrors: + - + message: '#^PHPDoc tag @var does not specify variable name\.$#' + identifier: varTag.noVariable + count: 1 + path: ../../../src/wp-admin/install.php + - + message: '#^PHPDoc tag @var does not specify variable name\.$#' + identifier: varTag.noVariable + count: 1 + path: ../../../src/wp-admin/profile.php + - + message: '#^PHPDoc tag @var does not specify variable name\.$#' + identifier: varTag.noVariable + count: 1 + path: ../../../src/wp-admin/upgrade.php + - + message: '#^PHPDoc tag @var does not specify variable name\.$#' + identifier: varTag.noVariable + count: 1 + path: ../../../src/wp-cron.php + - + message: '#^PHPDoc tag @var above assignment does not specify variable name\.$#' + identifier: varTag.noVariable + count: 9 + path: ../../../src/wp-includes/class-wp-query.php + - + message: '#^PHPDoc tag @var does not specify variable name\.$#' + identifier: varTag.noVariable + count: 1 + path: ../../../src/wp-includes/kses.php + - + message: '#^PHPDoc tag @var does not specify variable name\.$#' + identifier: varTag.noVariable + count: 2 + path: ../../../src/wp-includes/rest-api.php + - + message: '#^PHPDoc tag @var does not specify variable name\.$#' + identifier: varTag.noVariable + count: 1 + path: ../../../src/xmlrpc.php diff --git a/tests/phpstan/baselines/variable.undefined.neon b/tests/phpstan/baselines/variable.undefined.neon new file mode 100644 index 0000000000000..dca18ebe74acc --- /dev/null +++ b/tests/phpstan/baselines/variable.undefined.neon @@ -0,0 +1,995 @@ +# PHPStan baseline for the `variable.undefined` errors in WordPress core. +# +# https://phpstan.org/error-identifiers/variable.undefined +# +# Each entry is scoped to a single file and carries an exact occurrence count, +# so that a new instance is reported as a new error rather than being absorbed +# silently. Fixing an occurrence therefore means decrementing or removing its +# entry here as part of the same change. +# +# The goal is to empty this file and delete it, along with the `includes` entry +# for it in phpstan.neon.dist. +# +# Generated by `composer phpstan:baselines`. Do not edit by hand; regenerate with +# +# composer phpstan:baselines -- --identifier=variable.undefined +# +# which reruns the analysis with this file suppressed so the errors surface again. + +parameters: + ignoreErrors: + - + message: '#^Variable \$wp_query might not be defined\.$#' + identifier: variable.undefined + count: 1 + path: ../../../src/wp-activate.php + - + message: '#^Variable \$pagenow might not be defined\.$#' + identifier: variable.undefined + count: 3 + path: ../../../src/wp-admin/admin.php + - + message: '#^Variable \$wp_db_version might not be defined\.$#' + identifier: variable.undefined + count: 1 + path: ../../../src/wp-admin/admin.php + - + message: '#^Variable \$comment_ids might not be defined\.$#' + identifier: variable.undefined + count: 1 + path: ../../../src/wp-admin/edit-comments.php + - + message: '#^Variable \$comment_status might not be defined\.$#' + identifier: variable.undefined + count: 1 + path: ../../../src/wp-admin/edit-comments.php + - + message: '#^Variable \$messages might not be defined\.$#' + identifier: variable.undefined + count: 1 + path: ../../../src/wp-admin/edit-comments.php + - + message: '#^Variable \$is_IE might not be defined\.$#' + identifier: variable.undefined + count: 1 + path: ../../../src/wp-admin/edit-form-advanced.php + - + message: '#^Variable \$title might not be defined\.$#' + identifier: variable.undefined + count: 1 + path: ../../../src/wp-admin/edit-form-advanced.php + - + message: '#^Variable \$cat_id might not be defined\.$#' + identifier: variable.undefined + count: 1 + path: ../../../src/wp-admin/edit-link-form.php + - + message: '#^Variable \$link might not be defined\.$#' + identifier: variable.undefined + count: 10 + path: ../../../src/wp-admin/edit-link-form.php + - + message: '#^Variable \$title might not be defined\.$#' + identifier: variable.undefined + count: 1 + path: ../../../src/wp-admin/edit-link-form.php + - + message: '#^Variable \$message might not be defined\.$#' + identifier: variable.undefined + count: 1 + path: ../../../src/wp-admin/edit-tag-form.php + - + message: '#^Variable \$tag might not be defined\.$#' + identifier: variable.undefined + count: 19 + path: ../../../src/wp-admin/edit-tag-form.php + - + message: '#^Variable \$tag_ID might not be defined\.$#' + identifier: variable.undefined + count: 2 + path: ../../../src/wp-admin/edit-tag-form.php + - + message: '#^Variable \$tax might not be defined\.$#' + identifier: variable.undefined + count: 7 + path: ../../../src/wp-admin/edit-tag-form.php + - + message: '#^Variable \$taxonomy might not be defined\.$#' + identifier: variable.undefined + count: 1 + path: ../../../src/wp-admin/edit-tag-form.php + - + message: '#^Variable \$current_screen might not be defined\.$#' + identifier: variable.undefined + count: 1 + path: ../../../src/wp-admin/edit-tags.php + - + message: '#^Variable \$message might not be defined\.$#' + identifier: variable.undefined + count: 1 + path: ../../../src/wp-admin/edit-tags.php + - + message: '#^Variable \$taxnow might not be defined\.$#' + identifier: variable.undefined + count: 1 + path: ../../../src/wp-admin/edit-tags.php + - + message: '#^Variable \$taxonomy might not be defined\.$#' + identifier: variable.undefined + count: 9 + path: ../../../src/wp-admin/edit-tags.php + - + message: '#^Variable \$wpdb might not be defined\.$#' + identifier: variable.undefined + count: 4 + path: ../../../src/wp-admin/export.php + - + message: '#^Variable \$_object might not be defined\.$#' + identifier: variable.undefined + count: 1 + path: ../../../src/wp-admin/includes/ajax-actions.php + - + message: '#^Variable \$callback might not be defined\.$#' + identifier: variable.undefined + count: 2 + path: ../../../src/wp-admin/includes/ajax-actions.php + - + message: '#^Variable \$parent might not be defined\.$#' + identifier: variable.undefined + count: 2 + path: ../../../src/wp-admin/includes/ajax-actions.php + - + message: '#^Variable \$stat might not be defined\.$#' + identifier: variable.undefined + count: 1 + path: ../../../src/wp-admin/includes/ajax-actions.php + - + message: '#^Variable \$type might not be defined\.$#' + identifier: variable.undefined + count: 1 + path: ../../../src/wp-admin/includes/ajax-actions.php + - + message: '#^Variable \$original_result might not be defined\.$#' + identifier: variable.undefined + count: 2 + path: ../../../src/wp-admin/includes/class-core-upgrader.php + - + message: '#^Variable \$rollback_result might not be defined\.$#' + identifier: variable.undefined + count: 2 + path: ../../../src/wp-admin/includes/class-core-upgrader.php + - + message: '#^Variable \$wp_version might not be defined\.$#' + identifier: variable.undefined + count: 9 + path: ../../../src/wp-admin/includes/class-core-upgrader.php + - + message: '#^Variable \$context might not be defined\.$#' + identifier: variable.undefined + count: 3 + path: ../../../src/wp-admin/includes/class-wp-automatic-updater.php + - + message: '#^Variable \$item_name might not be defined\.$#' + identifier: variable.undefined + count: 1 + path: ../../../src/wp-admin/includes/class-wp-automatic-updater.php + - + message: '#^Variable \$subject might not be defined\.$#' + identifier: variable.undefined + count: 1 + path: ../../../src/wp-admin/includes/class-wp-automatic-updater.php + - + message: '#^Variable \$upgrader might not be defined\.$#' + identifier: variable.undefined + count: 8 + path: ../../../src/wp-admin/includes/class-wp-automatic-updater.php + - + message: '#^Variable \$was_active might not be defined\.$#' + identifier: variable.undefined + count: 1 + path: ../../../src/wp-admin/includes/class-wp-automatic-updater.php + - + message: '#^Variable \$wp_version might not be defined\.$#' + identifier: variable.undefined + count: 1 + path: ../../../src/wp-admin/includes/class-wp-community-events.php + - + message: '#^Variable \$key might not be defined\.$#' + identifier: variable.undefined + count: 1 + path: ../../../src/wp-admin/includes/class-wp-importer.php + - + message: '#^Variable \$text might not be defined\.$#' + identifier: variable.undefined + count: 1 + path: ../../../src/wp-admin/includes/class-wp-ms-themes-list-table.php + - + message: '#^Variable \$description might not be defined\.$#' + identifier: variable.undefined + count: 1 + path: ../../../src/wp-admin/includes/class-wp-plugins-list-table.php + - + message: '#^Variable \$plugin_name might not be defined\.$#' + identifier: variable.undefined + count: 5 + path: ../../../src/wp-admin/includes/class-wp-plugins-list-table.php + - + message: '#^Variable \$authors_dropdown might not be defined\.$#' + identifier: variable.undefined + count: 1 + path: ../../../src/wp-admin/includes/class-wp-posts-list-table.php + - + message: '#^Variable \$badge_class might not be defined\.$#' + identifier: variable.undefined + count: 1 + path: ../../../src/wp-admin/includes/class-wp-privacy-policy-content.php + - + message: '#^Variable \$badge_title might not be defined\.$#' + identifier: variable.undefined + count: 1 + path: ../../../src/wp-admin/includes/class-wp-privacy-policy-content.php + - + message: '#^Variable \$wp_version might not be defined\.$#' + identifier: variable.undefined + count: 6 + path: ../../../src/wp-admin/includes/class-wp-site-health-auto-updates.php + - + message: '#^Variable \$output might not be defined\.$#' + identifier: variable.undefined + count: 1 + path: ../../../src/wp-admin/includes/class-wp-site-health.php + - + message: '#^Variable \$taxonomy might not be defined\.$#' + identifier: variable.undefined + count: 2 + path: ../../../src/wp-admin/includes/edit-tag-messages.php + - + message: '#^Variable \$resized might not be defined\.$#' + identifier: variable.undefined + count: 2 + path: ../../../src/wp-admin/includes/image.php + - + message: '#^Variable \$class might not be defined\.$#' + identifier: variable.undefined + count: 1 + path: ../../../src/wp-admin/includes/media.php + - + message: '#^Variable \$html might not be defined\.$#' + identifier: variable.undefined + count: 2 + path: ../../../src/wp-admin/includes/media.php + - + message: '#^Variable \$num_posts might not be defined\.$#' + identifier: variable.undefined + count: 2 + path: ../../../src/wp-admin/includes/media.php + - + message: '#^Variable \$result might not be defined\.$#' + identifier: variable.undefined + count: 1 + path: ../../../src/wp-admin/includes/media.php + - + message: '#^Variable \$network_current might not be defined\.$#' + identifier: variable.undefined + count: 1 + path: ../../../src/wp-admin/includes/plugin.php + - + message: '#^Variable \$metakey might not be defined\.$#' + identifier: variable.undefined + count: 3 + path: ../../../src/wp-admin/includes/post.php + - + message: '#^Variable \$revision might not be defined\.$#' + identifier: variable.undefined + count: 2 + path: ../../../src/wp-admin/includes/revision.php + - + message: '#^Variable \$required_mysql_version might not be defined\.$#' + identifier: variable.undefined + count: 3 + path: ../../../src/wp-admin/includes/update-core.php + - + message: '#^Variable \$required_php_version might not be defined\.$#' + identifier: variable.undefined + count: 3 + path: ../../../src/wp-admin/includes/update-core.php + - + message: '#^Variable \$working_dir_local might not be defined\.$#' + identifier: variable.undefined + count: 3 + path: ../../../src/wp-admin/includes/update-core.php + - + message: '#^Variable \$wp_version might not be defined\.$#' + identifier: variable.undefined + count: 9 + path: ../../../src/wp-admin/includes/update-core.php + - + message: '#^Variable \$id might not be defined\.$#' + identifier: variable.undefined + count: 1 + path: ../../../src/wp-admin/includes/upgrade.php + - + message: '#^Variable \$compat might not be defined\.$#' + identifier: variable.undefined + count: 1 + path: ../../../src/wp-admin/install.php + - + message: '#^Variable \$linkcheck might not be defined\.$#' + identifier: variable.undefined + count: 2 + path: ../../../src/wp-admin/link.php + - + message: '#^Variable \$wpdb might not be defined\.$#' + identifier: variable.undefined + count: 9 + path: ../../../src/wp-admin/maint/repair.php + - + message: '#^Variable \$update_data might not be defined\.$#' + identifier: variable.undefined + count: 2 + path: ../../../src/wp-admin/menu.php + - + message: '#^Variable \$current_user might not be defined\.$#' + identifier: variable.undefined + count: 2 + path: ../../../src/wp-admin/my-sites.php + - + message: '#^Variable \$current_user might not be defined\.$#' + identifier: variable.undefined + count: 1 + path: ../../../src/wp-admin/nav-menus.php + - + message: '#^Variable \$wpdb might not be defined\.$#' + identifier: variable.undefined + count: 2 + path: ../../../src/wp-admin/network.php + - + message: '#^Variable \$current_blog might not be defined\.$#' + identifier: variable.undefined + count: 2 + path: ../../../src/wp-admin/network/admin.php + - + message: '#^Variable \$current_site might not be defined\.$#' + identifier: variable.undefined + count: 2 + path: ../../../src/wp-admin/network/admin.php + - + message: '#^Variable \$wpdb might not be defined\.$#' + identifier: variable.undefined + count: 2 + path: ../../../src/wp-admin/network/site-new.php + - + message: '#^Variable \$wpdb might not be defined\.$#' + identifier: variable.undefined + count: 5 + path: ../../../src/wp-admin/network/site-settings.php + - + message: '#^Variable \$update might not be defined\.$#' + identifier: variable.undefined + count: 1 + path: ../../../src/wp-admin/network/site-users.php + - + message: '#^Variable \$s might not be defined\.$#' + identifier: variable.undefined + count: 1 + path: ../../../src/wp-admin/network/sites.php + - + message: '#^Variable \$page might not be defined\.$#' + identifier: variable.undefined + count: 1 + path: ../../../src/wp-admin/network/themes.php + - + message: '#^Variable \$status might not be defined\.$#' + identifier: variable.undefined + count: 1 + path: ../../../src/wp-admin/network/themes.php + - + message: '#^Variable \$usersearch might not be defined\.$#' + identifier: variable.undefined + count: 2 + path: ../../../src/wp-admin/network/users.php + - + message: '#^Variable \$user_email might not be defined\.$#' + identifier: variable.undefined + count: 1 + path: ../../../src/wp-admin/options-discussion.php + - + message: '#^Variable \$is_caddy might not be defined\.$#' + identifier: variable.undefined + count: 2 + path: ../../../src/wp-admin/options-permalink.php + - + message: '#^Variable \$is_nginx might not be defined\.$#' + identifier: variable.undefined + count: 1 + path: ../../../src/wp-admin/options-permalink.php + - + message: '#^Variable \$wp_rewrite might not be defined\.$#' + identifier: variable.undefined + count: 8 + path: ../../../src/wp-admin/options-permalink.php + - + message: '#^Variable \$options_to_update might not be defined\.$#' + identifier: variable.undefined + count: 1 + path: ../../../src/wp-admin/options.php + - + message: '#^Variable \$wpdb might not be defined\.$#' + identifier: variable.undefined + count: 2 + path: ../../../src/wp-admin/options.php + - + message: '#^Variable \$page might not be defined\.$#' + identifier: variable.undefined + count: 18 + path: ../../../src/wp-admin/plugins.php + - + message: '#^Variable \$plugins might not be defined\.$#' + identifier: variable.undefined + count: 1 + path: ../../../src/wp-admin/plugins.php + - + message: '#^Variable \$status might not be defined\.$#' + identifier: variable.undefined + count: 18 + path: ../../../src/wp-admin/plugins.php + - + message: '#^Variable \$user_ID might not be defined\.$#' + identifier: variable.undefined + count: 3 + path: ../../../src/wp-admin/plugins.php + - + message: '#^Variable \$typenow might not be defined\.$#' + identifier: variable.undefined + count: 1 + path: ../../../src/wp-admin/post.php + - + message: '#^Variable \$h1 might not be defined\.$#' + identifier: variable.undefined + count: 1 + path: ../../../src/wp-admin/revision.php + - + message: '#^Variable \$return_to_post might not be defined\.$#' + identifier: variable.undefined + count: 1 + path: ../../../src/wp-admin/revision.php + - + message: '#^Variable \$wpdb might not be defined\.$#' + identifier: variable.undefined + count: 7 + path: ../../../src/wp-admin/setup-config.php + - + message: '#^Variable \$post might not be defined\.$#' + identifier: variable.undefined + count: 1 + path: ../../../src/wp-admin/site-editor.php + - + message: '#^Variable \$taxnow might not be defined\.$#' + identifier: variable.undefined + count: 1 + path: ../../../src/wp-admin/term.php + - + message: '#^Variable \$docs_select might not be defined\.$#' + identifier: variable.undefined + count: 1 + path: ../../../src/wp-admin/theme-editor.php + - + message: '#^Variable \$paged might not be defined\.$#' + identifier: variable.undefined + count: 1 + path: ../../../src/wp-admin/theme-install.php + - + message: '#^Variable \$self might not be defined\.$#' + identifier: variable.undefined + count: 1 + path: ../../../src/wp-admin/themes.php + - + message: '#^Variable \$submenu might not be defined\.$#' + identifier: variable.undefined + count: 1 + path: ../../../src/wp-admin/themes.php + - + message: '#^Variable \$auto_update_notice might not be defined\.$#' + identifier: variable.undefined + count: 2 + path: ../../../src/wp-admin/update-core.php + - + message: '#^Variable \$wp_version might not be defined\.$#' + identifier: variable.undefined + count: 3 + path: ../../../src/wp-admin/update-core.php + - + message: '#^Variable \$message might not be defined\.$#' + identifier: variable.undefined + count: 1 + path: ../../../src/wp-admin/upgrade.php + - + message: '#^Variable \$version_url might not be defined\.$#' + identifier: variable.undefined + count: 1 + path: ../../../src/wp-admin/upgrade.php + - + message: '#^Variable \$wp_db_version might not be defined\.$#' + identifier: variable.undefined + count: 1 + path: ../../../src/wp-admin/upgrade.php + - + message: '#^Variable \$wpdb might not be defined\.$#' + identifier: variable.undefined + count: 2 + path: ../../../src/wp-admin/upload.php + - + message: '#^Variable \$_wp_admin_css_colors might not be defined\.$#' + identifier: variable.undefined + count: 1 + path: ../../../src/wp-admin/user-edit.php + - + message: '#^Variable \$user_login might not be defined\.$#' + identifier: variable.undefined + count: 1 + path: ../../../src/wp-admin/user-edit.php + - + message: '#^Variable \$wp_roles might not be defined\.$#' + identifier: variable.undefined + count: 1 + path: ../../../src/wp-admin/user-edit.php + - + message: '#^Variable \$wpdb might not be defined\.$#' + identifier: variable.undefined + count: 12 + path: ../../../src/wp-admin/user-edit.php + - + message: '#^Variable \$blog_id might not be defined\.$#' + identifier: variable.undefined + count: 1 + path: ../../../src/wp-admin/user-new.php + - + message: '#^Variable \$wpdb might not be defined\.$#' + identifier: variable.undefined + count: 3 + path: ../../../src/wp-admin/user-new.php + - + message: '#^Variable \$current_blog might not be defined\.$#' + identifier: variable.undefined + count: 2 + path: ../../../src/wp-admin/user/admin.php + - + message: '#^Variable \$current_site might not be defined\.$#' + identifier: variable.undefined + count: 2 + path: ../../../src/wp-admin/user/admin.php + - + message: '#^Variable \$blog_id might not be defined\.$#' + identifier: variable.undefined + count: 1 + path: ../../../src/wp-admin/users.php + - + message: '#^Variable \$current_user might not be defined\.$#' + identifier: variable.undefined + count: 7 + path: ../../../src/wp-admin/users.php + - + message: '#^Variable \$redirect might not be defined\.$#' + identifier: variable.undefined + count: 10 + path: ../../../src/wp-admin/users.php + - + message: '#^Variable \$usersearch might not be defined\.$#' + identifier: variable.undefined + count: 2 + path: ../../../src/wp-admin/users.php + - + message: '#^Variable \$wp_roles might not be defined\.$#' + identifier: variable.undefined + count: 1 + path: ../../../src/wp-admin/users.php + - + message: '#^Variable \$wpdb might not be defined\.$#' + identifier: variable.undefined + count: 6 + path: ../../../src/wp-admin/users.php + - + message: '#^Variable \$title might not be defined\.$#' + identifier: variable.undefined + count: 1 + path: ../../../src/wp-admin/widgets-form-blocks.php + - + message: '#^Variable \$control might not be defined\.$#' + identifier: variable.undefined + count: 4 + path: ../../../src/wp-admin/widgets-form.php + - + message: '#^Variable \$control_callback might not be defined\.$#' + identifier: variable.undefined + count: 1 + path: ../../../src/wp-admin/widgets-form.php + - + message: '#^Variable \$title might not be defined\.$#' + identifier: variable.undefined + count: 2 + path: ../../../src/wp-admin/widgets-form.php + - + message: '#^Variable \$wp_registered_sidebars might not be defined\.$#' + identifier: variable.undefined + count: 3 + path: ../../../src/wp-admin/widgets-form.php + - + message: '#^Variable \$wp_registered_widget_controls might not be defined\.$#' + identifier: variable.undefined + count: 1 + path: ../../../src/wp-admin/widgets-form.php + - + message: '#^Variable \$wp_registered_widget_updates might not be defined\.$#' + identifier: variable.undefined + count: 1 + path: ../../../src/wp-admin/widgets-form.php + - + message: '#^Variable \$post might not be defined\.$#' + identifier: variable.undefined + count: 3 + path: ../../../src/wp-content/themes/twentyeleven/header.php + - + message: '#^Variable \$post might not be defined\.$#' + identifier: variable.undefined + count: 6 + path: ../../../src/wp-content/themes/twentyeleven/image.php + - + message: '#^Variable \$post might not be defined\.$#' + identifier: variable.undefined + count: 1 + path: ../../../src/wp-content/themes/twentyeleven/showcase.php + - + message: '#^Variable \$next_id might not be defined\.$#' + identifier: variable.undefined + count: 1 + path: ../../../src/wp-content/themes/twentyfourteen/functions.php + - + message: '#^Variable \$post might not be defined\.$#' + identifier: variable.undefined + count: 2 + path: ../../../src/wp-content/themes/twentyfourteen/image.php + - + message: '#^Variable \$post might not be defined\.$#' + identifier: variable.undefined + count: 1 + path: ../../../src/wp-content/themes/twentyseventeen/template-parts/page/content-front-page-panels.php + - + message: '#^Variable \$post might not be defined\.$#' + identifier: variable.undefined + count: 1 + path: ../../../src/wp-content/themes/twentyseventeen/template-parts/page/content-front-page.php + - + message: '#^Variable \$post might not be defined\.$#' + identifier: variable.undefined + count: 3 + path: ../../../src/wp-content/themes/twentyten/header.php + - + message: '#^Variable \$post might not be defined\.$#' + identifier: variable.undefined + count: 3 + path: ../../../src/wp-content/themes/twentyten/loop-attachment.php + - + message: '#^Variable \$post might not be defined\.$#' + identifier: variable.undefined + count: 3 + path: ../../../src/wp-content/themes/twentyten/loop.php + - + message: '#^Variable \$wp_query might not be defined\.$#' + identifier: variable.undefined + count: 2 + path: ../../../src/wp-content/themes/twentyten/loop.php + - + message: '#^Variable \$next_id might not be defined\.$#' + identifier: variable.undefined + count: 1 + path: ../../../src/wp-content/themes/twentythirteen/functions.php + - + message: '#^Variable \$post might not be defined\.$#' + identifier: variable.undefined + count: 3 + path: ../../../src/wp-content/themes/twentythirteen/image.php + - + message: '#^Variable \$post might not be defined\.$#' + identifier: variable.undefined + count: 6 + path: ../../../src/wp-content/themes/twentytwelve/image.php + - + message: '#^Variable \$comments might not be defined\.$#' + identifier: variable.undefined + count: 1 + path: ../../../src/wp-content/themes/twentytwenty/comments.php + - + message: '#^Variable \$post_meta might not be defined\.$#' + identifier: variable.undefined + count: 1 + path: ../../../src/wp-content/themes/twentytwenty/inc/template-tags.php + - + message: '#^Variable \$post might not be defined\.$#' + identifier: variable.undefined + count: 4 + path: ../../../src/wp-content/themes/twentytwentyone/image.php + - + message: '#^Variable \$wp_query might not be defined\.$#' + identifier: variable.undefined + count: 2 + path: ../../../src/wp-content/themes/twentytwentyone/search.php + - + message: '#^Variable \$original_content might not be defined\.$#' + identifier: variable.undefined + count: 1 + path: ../../../src/wp-includes/blocks.php + - + message: '#^Variable \$name might not be defined\.$#' + identifier: variable.undefined + count: 1 + path: ../../../src/wp-includes/class-walker-category.php + - + message: '#^Variable \$comment_closing_at might not be defined\.$#' + identifier: variable.undefined + count: 1 + path: ../../../src/wp-includes/class-wp-block-processor.php + - + message: '#^Variable \$comment_opening_at might not be defined\.$#' + identifier: variable.undefined + count: 3 + path: ../../../src/wp-includes/class-wp-block-processor.php + - + message: '#^Variable \$has_closer might not be defined\.$#' + identifier: variable.undefined + count: 2 + path: ../../../src/wp-includes/class-wp-block-processor.php + - + message: '#^Variable \$has_void_flag might not be defined\.$#' + identifier: variable.undefined + count: 1 + path: ../../../src/wp-includes/class-wp-block-processor.php + - + message: '#^Variable \$json_at might not be defined\.$#' + identifier: variable.undefined + count: 1 + path: ../../../src/wp-includes/class-wp-block-processor.php + - + message: '#^Variable \$json_length might not be defined\.$#' + identifier: variable.undefined + count: 1 + path: ../../../src/wp-includes/class-wp-block-processor.php + - + message: '#^Variable \$name_at might not be defined\.$#' + identifier: variable.undefined + count: 3 + path: ../../../src/wp-includes/class-wp-block-processor.php + - + message: '#^Variable \$name_length might not be defined\.$#' + identifier: variable.undefined + count: 3 + path: ../../../src/wp-includes/class-wp-block-processor.php + - + message: '#^Variable \$namespace_at might not be defined\.$#' + identifier: variable.undefined + count: 5 + path: ../../../src/wp-includes/class-wp-block-processor.php + - + message: '#^Variable \$section_id might not be defined\.$#' + identifier: variable.undefined + count: 1 + path: ../../../src/wp-includes/class-wp-customize-widgets.php + - + message: '#^Variable \$filter_id might not be defined\.$#' + identifier: variable.undefined + count: 1 + path: ../../../src/wp-includes/class-wp-duotone.php + - + message: '#^Variable \$processed_response might not be defined\.$#' + identifier: variable.undefined + count: 5 + path: ../../../src/wp-includes/class-wp-http-streams.php + - + message: '#^Variable \$response might not be defined\.$#' + identifier: variable.undefined + count: 1 + path: ../../../src/wp-includes/class-wp-http-streams.php + - + message: '#^Variable \$result might not be defined\.$#' + identifier: variable.undefined + count: 1 + path: ../../../src/wp-includes/class-wp-image-editor.php + - + message: '#^Variable \$network might not be defined\.$#' + identifier: variable.undefined + count: 1 + path: ../../../src/wp-includes/class-wp-network.php + - + message: '#^Variable \$cache_found might not be defined\.$#' + identifier: variable.undefined + count: 1 + path: ../../../src/wp-includes/class-wp-query.php + - + message: '#^Variable \$cache_key might not be defined\.$#' + identifier: variable.undefined + count: 3 + path: ../../../src/wp-includes/class-wp-query.php + - + message: '#^Variable \$post_type_cap might not be defined\.$#' + identifier: variable.undefined + count: 2 + path: ../../../src/wp-includes/class-wp-query.php + - + message: '#^Variable \$end might not be defined\.$#' + identifier: variable.undefined + count: 2 + path: ../../../src/wp-includes/class-wp-walker.php + - + message: '#^Variable \$excerpt might not be defined\.$#' + identifier: variable.undefined + count: 1 + path: ../../../src/wp-includes/class-wp-xmlrpc-server.php + - + message: '#^Variable \$matches might not be defined\.$#' + identifier: variable.undefined + count: 1 + path: ../../../src/wp-includes/class-wp.php + - + message: '#^Variable \$query might not be defined\.$#' + identifier: variable.undefined + count: 1 + path: ../../../src/wp-includes/class-wp.php + - + message: '#^Variable \$_comments might not be defined\.$#' + identifier: variable.undefined + count: 2 + path: ../../../src/wp-includes/comment-template.php + - + message: '#^Variable \$post_author might not be defined\.$#' + identifier: variable.undefined + count: 1 + path: ../../../src/wp-includes/comment.php + - + message: '#^Variable \$comment might not be defined\.$#' + identifier: variable.undefined + count: 4 + path: ../../../src/wp-includes/feed-atom-comments.php + - + message: '#^Variable \$post might not be defined\.$#' + identifier: variable.undefined + count: 1 + path: ../../../src/wp-includes/feed-rdf.php + - + message: '#^Variable \$comment might not be defined\.$#' + identifier: variable.undefined + count: 2 + path: ../../../src/wp-includes/feed-rss2-comments.php + - + message: '#^Variable \$mime might not be defined\.$#' + identifier: variable.undefined + count: 1 + path: ../../../src/wp-includes/functions.php + - + message: '#^Variable \$gen might not be defined\.$#' + identifier: variable.undefined + count: 1 + path: ../../../src/wp-includes/general-template.php + - + message: '#^Variable \$my_year might not be defined\.$#' + identifier: variable.undefined + count: 1 + path: ../../../src/wp-includes/general-template.php + - + message: '#^Variable \$cache_key might not be defined\.$#' + identifier: variable.undefined + count: 1 + path: ../../../src/wp-includes/global-styles-and-settings.php + - + message: '#^Variable \$cached might not be defined\.$#' + identifier: variable.undefined + count: 1 + path: ../../../src/wp-includes/global-styles-and-settings.php + - + message: '#^Variable \$token_length might not be defined\.$#' + identifier: variable.undefined + count: 1 + path: ../../../src/wp-includes/html-api/class-wp-html-decoder.php + - + message: '#^Variable \$node might not be defined\.$#' + identifier: variable.undefined + count: 2 + path: ../../../src/wp-includes/html-api/class-wp-html-processor.php + - + message: '#^Variable \$parts might not be defined\.$#' + identifier: variable.undefined + count: 1 + path: ../../../src/wp-includes/kses.php + - + message: '#^Variable \$post might not be defined\.$#' + identifier: variable.undefined + count: 3 + path: ../../../src/wp-includes/link-template.php + - + message: '#^Variable \$current_blog might not be defined\.$#' + identifier: variable.undefined + count: 3 + path: ../../../src/wp-includes/ms-files.php + - + message: '#^Variable \$current_blog might not be defined\.$#' + identifier: variable.undefined + count: 7 + path: ../../../src/wp-includes/ms-settings.php + - + message: '#^Variable \$table_prefix might not be defined\.$#' + identifier: variable.undefined + count: 1 + path: ../../../src/wp-includes/ms-settings.php + - + message: '#^Variable \$wpdb might not be defined\.$#' + identifier: variable.undefined + count: 3 + path: ../../../src/wp-includes/ms-settings.php + - + message: '#^Variable \$icon might not be defined\.$#' + identifier: variable.undefined + count: 1 + path: ../../../src/wp-includes/post.php + - + message: '#^Variable \$wp_version might not be defined\.$#' + identifier: variable.undefined + count: 2 + path: ../../../src/wp-includes/script-loader.php + - + message: '#^Variable \$wp_template_path might not be defined\.$#' + identifier: variable.undefined + count: 2 + path: ../../../src/wp-includes/template.php + - + message: '#^Variable \$manage_url might not be defined\.$#' + identifier: variable.undefined + count: 1 + path: ../../../src/wp-includes/user.php + - + message: '#^Variable \$error might not be defined\.$#' + identifier: variable.undefined + count: 1 + path: ../../../src/wp-login.php + - + message: '#^Variable \$rp_key might not be defined\.$#' + identifier: variable.undefined + count: 1 + path: ../../../src/wp-login.php + - + message: '#^Variable \$rp_login might not be defined\.$#' + identifier: variable.undefined + count: 1 + path: ../../../src/wp-login.php + - + message: '#^Variable \$user_login might not be defined\.$#' + identifier: variable.undefined + count: 2 + path: ../../../src/wp-login.php + - + message: '#^Variable \$subject might not be defined\.$#' + identifier: variable.undefined + count: 1 + path: ../../../src/wp-mail.php + - + message: '#^Variable \$table_prefix might not be defined\.$#' + identifier: variable.undefined + count: 1 + path: ../../../src/wp-settings.php + - + message: '#^Variable \$wp_filter might not be defined\.$#' + identifier: variable.undefined + count: 1 + path: ../../../src/wp-settings.php + - + message: '#^Variable \$wp_query might not be defined\.$#' + identifier: variable.undefined + count: 1 + path: ../../../src/wp-signup.php + - + message: '#^Variable \$posts might not be defined\.$#' + identifier: variable.undefined + count: 1 + path: ../../../src/wp-trackback.php + - + message: '#^Variable \$wpdb might not be defined\.$#' + identifier: variable.undefined + count: 4 + path: ../../../src/wp-trackback.php diff --git a/tests/phpstan/baselines/while.alwaysFalse.neon b/tests/phpstan/baselines/while.alwaysFalse.neon new file mode 100644 index 0000000000000..3c924003ea783 --- /dev/null +++ b/tests/phpstan/baselines/while.alwaysFalse.neon @@ -0,0 +1,25 @@ +# PHPStan baseline for the `while.alwaysFalse` errors in WordPress core. +# +# https://phpstan.org/error-identifiers/while.alwaysFalse +# +# Each entry is scoped to a single file and carries an exact occurrence count, +# so that a new instance is reported as a new error rather than being absorbed +# silently. Fixing an occurrence therefore means decrementing or removing its +# entry here as part of the same change. +# +# The goal is to empty this file and delete it, along with the `includes` entry +# for it in phpstan.neon.dist. +# +# Generated by `composer phpstan:baselines`. Do not edit by hand; regenerate with +# +# composer phpstan:baselines -- --identifier=while.alwaysFalse +# +# which reruns the analysis with this file suppressed so the errors surface again. + +parameters: + ignoreErrors: + - + message: '#^While loop condition is always false\.$#' + identifier: while.alwaysFalse + count: 1 + path: ../../../src/wp-includes/feed-rdf.php diff --git a/tests/phpstan/baselines/while.alwaysTrue.neon b/tests/phpstan/baselines/while.alwaysTrue.neon new file mode 100644 index 0000000000000..5da6550e89cc0 --- /dev/null +++ b/tests/phpstan/baselines/while.alwaysTrue.neon @@ -0,0 +1,300 @@ +# PHPStan baseline for the `while.alwaysTrue` errors in WordPress core. +# +# https://phpstan.org/error-identifiers/while.alwaysTrue +# +# Each entry is scoped to a single file and carries an exact occurrence count, +# so that a new instance is reported as a new error rather than being absorbed +# silently. Fixing an occurrence therefore means decrementing or removing its +# entry here as part of the same change. +# +# The goal is to empty this file and delete it, along with the `includes` entry +# for it in phpstan.neon.dist. +# +# Generated by `composer phpstan:baselines`. Do not edit by hand; regenerate with +# +# composer phpstan:baselines -- --identifier=while.alwaysTrue +# +# which reruns the analysis with this file suppressed so the errors surface again. + +parameters: + ignoreErrors: + - + message: '#^While loop condition is always true\.$#' + identifier: while.alwaysTrue + count: 1 + path: ../../../src/wp-admin/includes/dashboard.php + - + message: '#^While loop condition is always true\.$#' + identifier: while.alwaysTrue + count: 1 + path: ../../../src/wp-admin/includes/nav-menu.php + - + message: '#^While loop condition is always true\.$#' + identifier: while.alwaysTrue + count: 1 + path: ../../../src/wp-content/themes/twentyeleven/archive.php + - + message: '#^While loop condition is always true\.$#' + identifier: while.alwaysTrue + count: 1 + path: ../../../src/wp-content/themes/twentyeleven/author.php + - + message: '#^While loop condition is always true\.$#' + identifier: while.alwaysTrue + count: 1 + path: ../../../src/wp-content/themes/twentyeleven/category.php + - + message: '#^While loop condition is always true\.$#' + identifier: while.alwaysTrue + count: 1 + path: ../../../src/wp-content/themes/twentyeleven/inc/widgets.php + - + message: '#^While loop condition is always true\.$#' + identifier: while.alwaysTrue + count: 1 + path: ../../../src/wp-content/themes/twentyeleven/index.php + - + message: '#^While loop condition is always true\.$#' + identifier: while.alwaysTrue + count: 1 + path: ../../../src/wp-content/themes/twentyeleven/search.php + - + message: '#^While loop condition is always true\.$#' + identifier: while.alwaysTrue + count: 1 + path: ../../../src/wp-content/themes/twentyeleven/showcase.php + - + message: '#^While loop condition is always true\.$#' + identifier: while.alwaysTrue + count: 1 + path: ../../../src/wp-content/themes/twentyeleven/tag.php + - + message: '#^While loop condition is always true\.$#' + identifier: while.alwaysTrue + count: 1 + path: ../../../src/wp-content/themes/twentyfifteen/archive.php + - + message: '#^While loop condition is always true\.$#' + identifier: while.alwaysTrue + count: 1 + path: ../../../src/wp-content/themes/twentyfifteen/index.php + - + message: '#^While loop condition is always true\.$#' + identifier: while.alwaysTrue + count: 1 + path: ../../../src/wp-content/themes/twentyfifteen/search.php + - + message: '#^While loop condition is always true\.$#' + identifier: while.alwaysTrue + count: 1 + path: ../../../src/wp-content/themes/twentyfourteen/archive.php + - + message: '#^While loop condition is always true\.$#' + identifier: while.alwaysTrue + count: 1 + path: ../../../src/wp-content/themes/twentyfourteen/author.php + - + message: '#^While loop condition is always true\.$#' + identifier: while.alwaysTrue + count: 1 + path: ../../../src/wp-content/themes/twentyfourteen/category.php + - + message: '#^While loop condition is always true\.$#' + identifier: while.alwaysTrue + count: 1 + path: ../../../src/wp-content/themes/twentyfourteen/inc/widgets.php + - + message: '#^While loop condition is always true\.$#' + identifier: while.alwaysTrue + count: 1 + path: ../../../src/wp-content/themes/twentyfourteen/index.php + - + message: '#^While loop condition is always true\.$#' + identifier: while.alwaysTrue + count: 1 + path: ../../../src/wp-content/themes/twentyfourteen/search.php + - + message: '#^While loop condition is always true\.$#' + identifier: while.alwaysTrue + count: 1 + path: ../../../src/wp-content/themes/twentyfourteen/tag.php + - + message: '#^While loop condition is always true\.$#' + identifier: while.alwaysTrue + count: 1 + path: ../../../src/wp-content/themes/twentyfourteen/taxonomy-post_format.php + - + message: '#^While loop condition is always true\.$#' + identifier: while.alwaysTrue + count: 1 + path: ../../../src/wp-content/themes/twentynineteen/archive.php + - + message: '#^While loop condition is always true\.$#' + identifier: while.alwaysTrue + count: 1 + path: ../../../src/wp-content/themes/twentynineteen/index.php + - + message: '#^While loop condition is always true\.$#' + identifier: while.alwaysTrue + count: 1 + path: ../../../src/wp-content/themes/twentynineteen/search.php + - + message: '#^While loop condition is always true\.$#' + identifier: while.alwaysTrue + count: 1 + path: ../../../src/wp-content/themes/twentyseventeen/archive.php + - + message: '#^While loop condition is always true\.$#' + identifier: while.alwaysTrue + count: 1 + path: ../../../src/wp-content/themes/twentyseventeen/front-page.php + - + message: '#^While loop condition is always true\.$#' + identifier: while.alwaysTrue + count: 1 + path: ../../../src/wp-content/themes/twentyseventeen/index.php + - + message: '#^While loop condition is always true\.$#' + identifier: while.alwaysTrue + count: 1 + path: ../../../src/wp-content/themes/twentyseventeen/search.php + - + message: '#^While loop condition is always true\.$#' + identifier: while.alwaysTrue + count: 1 + path: ../../../src/wp-content/themes/twentyseventeen/template-parts/page/content-front-page-panels.php + - + message: '#^While loop condition is always true\.$#' + identifier: while.alwaysTrue + count: 1 + path: ../../../src/wp-content/themes/twentysixteen/archive.php + - + message: '#^While loop condition is always true\.$#' + identifier: while.alwaysTrue + count: 1 + path: ../../../src/wp-content/themes/twentysixteen/index.php + - + message: '#^While loop condition is always true\.$#' + identifier: while.alwaysTrue + count: 1 + path: ../../../src/wp-content/themes/twentysixteen/search.php + - + message: '#^While loop condition is always true\.$#' + identifier: while.alwaysTrue + count: 1 + path: ../../../src/wp-content/themes/twentyten/loop-attachment.php + - + message: '#^While loop condition is always true\.$#' + identifier: while.alwaysTrue + count: 1 + path: ../../../src/wp-content/themes/twentyten/loop-page.php + - + message: '#^While loop condition is always true\.$#' + identifier: while.alwaysTrue + count: 1 + path: ../../../src/wp-content/themes/twentyten/loop-single.php + - + message: '#^While loop condition is always true\.$#' + identifier: while.alwaysTrue + count: 1 + path: ../../../src/wp-content/themes/twentythirteen/archive.php + - + message: '#^While loop condition is always true\.$#' + identifier: while.alwaysTrue + count: 1 + path: ../../../src/wp-content/themes/twentythirteen/author.php + - + message: '#^While loop condition is always true\.$#' + identifier: while.alwaysTrue + count: 1 + path: ../../../src/wp-content/themes/twentythirteen/category.php + - + message: '#^While loop condition is always true\.$#' + identifier: while.alwaysTrue + count: 1 + path: ../../../src/wp-content/themes/twentythirteen/index.php + - + message: '#^While loop condition is always true\.$#' + identifier: while.alwaysTrue + count: 1 + path: ../../../src/wp-content/themes/twentythirteen/search.php + - + message: '#^While loop condition is always true\.$#' + identifier: while.alwaysTrue + count: 1 + path: ../../../src/wp-content/themes/twentythirteen/tag.php + - + message: '#^While loop condition is always true\.$#' + identifier: while.alwaysTrue + count: 1 + path: ../../../src/wp-content/themes/twentythirteen/taxonomy-post_format.php + - + message: '#^While loop condition is always true\.$#' + identifier: while.alwaysTrue + count: 1 + path: ../../../src/wp-content/themes/twentytwelve/archive.php + - + message: '#^While loop condition is always true\.$#' + identifier: while.alwaysTrue + count: 1 + path: ../../../src/wp-content/themes/twentytwelve/author.php + - + message: '#^While loop condition is always true\.$#' + identifier: while.alwaysTrue + count: 1 + path: ../../../src/wp-content/themes/twentytwelve/category.php + - + message: '#^While loop condition is always true\.$#' + identifier: while.alwaysTrue + count: 1 + path: ../../../src/wp-content/themes/twentytwelve/index.php + - + message: '#^While loop condition is always true\.$#' + identifier: while.alwaysTrue + count: 1 + path: ../../../src/wp-content/themes/twentytwelve/search.php + - + message: '#^While loop condition is always true\.$#' + identifier: while.alwaysTrue + count: 1 + path: ../../../src/wp-content/themes/twentytwelve/tag.php + - + message: '#^While loop condition is always true\.$#' + identifier: while.alwaysTrue + count: 1 + path: ../../../src/wp-content/themes/twentytwenty/index.php + - + message: '#^While loop condition is always true\.$#' + identifier: while.alwaysTrue + count: 1 + path: ../../../src/wp-content/themes/twentytwenty/singular.php + - + message: '#^While loop condition is always true\.$#' + identifier: while.alwaysTrue + count: 1 + path: ../../../src/wp-content/themes/twentytwenty/templates/template-cover.php + - + message: '#^While loop condition is always true\.$#' + identifier: while.alwaysTrue + count: 1 + path: ../../../src/wp-content/themes/twentytwentyone/archive.php + - + message: '#^While loop condition is always true\.$#' + identifier: while.alwaysTrue + count: 1 + path: ../../../src/wp-content/themes/twentytwentyone/index.php + - + message: '#^While loop condition is always true\.$#' + identifier: while.alwaysTrue + count: 1 + path: ../../../src/wp-content/themes/twentytwentyone/search.php + - + message: '#^While loop condition is always true\.$#' + identifier: while.alwaysTrue + count: 1 + path: ../../../src/wp-includes/block-template.php + - + message: '#^While loop condition is always true\.$#' + identifier: while.alwaysTrue + count: 1 + path: ../../../src/wp-includes/theme-compat/embed.php diff --git a/tests/phpstan/bootstrap.php b/tests/phpstan/bootstrap.php index 6eedeec93c4a7..b203ecdc11117 100644 --- a/tests/phpstan/bootstrap.php +++ b/tests/phpstan/bootstrap.php @@ -7,7 +7,14 @@ * Loaded as a `bootstrapFile` by PHPStan; see `base.neon`. */ -// wp_initial_constants() +/* + * A fixed, fictional path rather than the real checkout location. PHPStan resolves + * no files through this constant, and deriving it from __DIR__ embeds the developer's + * own path in error messages, making output differ between machines. + */ +define( 'ABSPATH', '/var/www/html/' ); + +/** @see wp_initial_constants() */ define( 'KB_IN_BYTES', 1024 ); define( 'MB_IN_BYTES', 1024 * KB_IN_BYTES ); define( 'GB_IN_BYTES', 1024 * MB_IN_BYTES ); @@ -16,9 +23,10 @@ define( 'EB_IN_BYTES', 1024 * PB_IN_BYTES ); define( 'ZB_IN_BYTES', 1024 * EB_IN_BYTES ); define( 'YB_IN_BYTES', 1024 * ZB_IN_BYTES ); -define( 'WP_START_TIMESTAMP', microtime( true ) ); -define( 'WP_MEMORY_LIMIT', '' ); -define( 'WP_MAX_MEMORY_LIMIT', '' ); +define( 'WP_START_TIMESTAMP', 1700000000.0 ); // Fixed rather than microtime( true ), whose value would differ on every run. +define( 'WP_MEMORY_LIMIT', '40M' ); +define( 'WP_MAX_MEMORY_LIMIT', '256M' ); +define( 'WP_CONTENT_DIR', ABSPATH . 'wp-content' ); define( 'WP_DEVELOPMENT_MODE', '' ); define( 'WP_DEBUG', false ); define( 'WP_DEBUG_DISPLAY', false ); @@ -35,25 +43,25 @@ define( 'MONTH_IN_SECONDS', 30 * DAY_IN_SECONDS ); define( 'YEAR_IN_SECONDS', 365 * DAY_IN_SECONDS ); -// wp_set_lang_dir() -define( 'WP_LANG_DIR', '' ); +/** @see wp_set_lang_dir() */ +define( 'WP_LANG_DIR', WP_CONTENT_DIR . '/languages' ); // wp_plugin_directory_constants() -define( 'WP_CONTENT_URL', '' ); -define( 'WP_PLUGIN_DIR', '' ); -define( 'WP_PLUGIN_URL', '' ); -define( 'PLUGINDIR', '' ); -define( 'WPMU_PLUGIN_DIR', '' ); -define( 'WPMU_PLUGIN_URL', '' ); -define( 'MUPLUGINDIR', '' ); +define( 'WP_CONTENT_URL', 'https://example.com/wp-content' ); +define( 'WP_PLUGIN_DIR', WP_CONTENT_DIR . '/plugins' ); +define( 'WP_PLUGIN_URL', WP_CONTENT_URL . '/plugins' ); +define( 'PLUGINDIR', 'wp-content/plugins' ); +define( 'WPMU_PLUGIN_DIR', WP_CONTENT_DIR . '/mu-plugins' ); +define( 'WPMU_PLUGIN_URL', WP_CONTENT_URL . '/mu-plugins' ); +define( 'MUPLUGINDIR', 'wp-content/mu-plugins' ); -// ms_cookie_constants() +/** @see ms_cookie_constants() */ define( 'COOKIEPATH', '' ); define( 'SITECOOKIEPATH', '' ); define( 'ADMIN_COOKIE_PATH', '' ); define( 'COOKIE_DOMAIN', '' ); -// wp_cookie_constants() +/** @see wp_cookie_constants() */ define( 'COOKIEHASH', '' ); define( 'USER_COOKIE', '' ); define( 'PASS_COOKIE', '' ); @@ -64,34 +72,43 @@ define( 'PLUGINS_COOKIE_PATH', '' ); define( 'RECOVERY_MODE_COOKIE', '' ); -// wp_ssl_constants() +/** @see wp_ssl_constants() */ define( 'FORCE_SSL_LOGIN', false ); define( 'FORCE_SSL_ADMIN', false ); -// wp_functionality_constants() +/** @see wp_functionality_constants() */ define( 'AUTOSAVE_INTERVAL', MINUTE_IN_SECONDS ); define( 'EMPTY_TRASH_DAYS', 1 ); define( 'WP_POST_REVISIONS', true ); define( 'WP_CRON_LOCK_TIMEOUT', MINUTE_IN_SECONDS ); -// wp_templating_constants() -define( 'TEMPLATEPATH', '' ); -define( 'STYLESHEETPATH', '' ); -define( 'WP_DEFAULT_THEME', '' ); +/** @see wp_templating_constants() */ +define( 'TEMPLATEPATH', WP_CONTENT_DIR . '/themes/twentytwentyfive' ); +define( 'STYLESHEETPATH', WP_CONTENT_DIR . '/themes/twentytwentyfive' ); +define( 'WP_DEFAULT_THEME', 'twentytwentyfive' ); -// ms_file_constants() +/** @see ms_file_constants() */ define( 'WPMU_SENDFILE', false ); define( 'WPMU_ACCEL_REDIRECT', false ); -// ms_load_current_site_and_network() +/** @see ms_load_current_site_and_network() */ define( 'NOBLOGREDIRECT', '' ); -// ms_upload_constants() -define( 'UPLOADBLOGSDIR', '' ); -define( 'BLOGUPLOADDIR', '' ); +/** @see ms_upload_constants() */ +define( 'UPLOADBLOGSDIR', 'wp-content/blogs.dir' ); +define( 'BLOGUPLOADDIR', WP_CONTENT_DIR . '/blogs.dir/1/files/' ); + +/** @see WP_Filesystem() */ +define( 'FS_CONNECT_TIMEOUT', 30 ); // 30 seconds. +define( 'FS_TIMEOUT', 30 ); // 30 seconds. +define( 'FS_CHMOD_DIR', 0755 ); +define( 'FS_CHMOD_FILE', 0644 ); -// Misc constants not part of the default lifecycle. -define( 'FS_CONNECT_TIMEOUT', 1 ); -define( 'FS_TIMEOUT', 1 ); -define( 'FS_CHMOD_DIR', 1 ); -define( 'FS_CHMOD_FILE', 1 ); +/** @see add_theme_support() */ +define( 'NO_HEADER_TEXT', false ); +define( 'HEADER_IMAGE_WIDTH', 0 ); +define( 'HEADER_IMAGE_HEIGHT', 0 ); +define( 'HEADER_TEXTCOLOR', '' ); +define( 'HEADER_IMAGE', '' ); +define( 'BACKGROUND_COLOR', '' ); +define( 'BACKGROUND_IMAGE', '' ); diff --git a/tests/phpstan/generate-baselines.php b/tests/phpstan/generate-baselines.php new file mode 100644 index 0000000000000..de0073e83cc54 --- /dev/null +++ b/tests/phpstan/generate-baselines.php @@ -0,0 +1,662 @@ + all-errors.neon + * + * @package WordPress + */ + +namespace WordPress\PHPStan; + +if ( 'cli' !== PHP_SAPI ) { + fwrite( STDERR, "This script must be run from the command line.\n" ); + exit( 1 ); +} + +$repo_root = dirname( __DIR__, 2 ); + +// $argv is only populated when register_argc_argv is on, so read it defensively. +$args = array(); +foreach ( (array) ( $_SERVER['argv'] ?? array() ) as $arg ) { + if ( is_string( $arg ) ) { + $args[] = $arg; + } +} +array_shift( $args ); + +$config_option = 'phpstan.neon.dist'; +$output_option = 'tests/phpstan/baselines'; +$memory_limit = '2G'; +$only_identifiers = array(); +$combined = false; + +foreach ( $args as $arg ) { + if ( '--help' === $arg || '-h' === $arg ) { + fwrite( STDOUT, get_usage() ); + exit( 0 ); + } + + if ( '--combined' === $arg ) { + $combined = true; + continue; + } + + if ( 1 === preg_match( '/^--identifier=(.+)$/', $arg, $matches ) ) { + foreach ( explode( ',', $matches[1] ) as $identifier ) { + $identifier = trim( $identifier ); + if ( '' !== $identifier ) { + $only_identifiers[] = $identifier; + } + } + continue; + } + + if ( 1 === preg_match( '/^--config=(.+)$/', $arg, $matches ) ) { + $config_option = $matches[1]; + continue; + } + + if ( 1 === preg_match( '/^--output-dir=(.+)$/', $arg, $matches ) ) { + $output_option = $matches[1]; + continue; + } + + if ( 1 === preg_match( '/^--memory-limit=(.+)$/', $arg, $matches ) ) { + $memory_limit = $matches[1]; + continue; + } + + fwrite( STDERR, "Unrecognized option: $arg\n\n" . get_usage() ); + exit( 1 ); +} + +$config_path = $repo_root . '/' . ltrim( $config_option, '/' ); +$output_dir = $repo_root . '/' . trim( $output_option, '/' ); + +if ( ! is_file( $config_path ) ) { + fwrite( STDERR, "Configuration not found: $config_option\n" ); + exit( 1 ); +} + +/* + * The temporary configuration has to sit beside the original, because a neon + * file's `includes` entries resolve relative to its own directory. + */ +/* + * Both temporary files sit beside the configuration, and so inside the + * repository, for two separate reasons. + * + * A neon file's `includes` resolve relative to its own directory, so the copy of + * the configuration has to live where the original did. + * + * PHPStan writes a PHP baseline's paths as __DIR__ followed by a relative chain, + * which it can only produce when the baseline shares an ancestry with the files + * it names. Generated somewhere else, the system temporary directory included, + * it emits `__DIR__ . '//absolute/path'` instead, and every path in it then + * resolves to somewhere under that directory rather than to the source file. + */ +$temp_config = dirname( $config_path ) . '/.phpstan-baselines-' . getmypid() . '.neon'; +$temp_baseline = dirname( $config_path ) . '/.phpstan-baselines-' . getmypid() . '.php'; + +register_shutdown_function( + static function () use ( $temp_config, $temp_baseline ): void { + foreach ( array( $temp_config, $temp_baseline ) as $file ) { + if ( is_file( $file ) ) { + unlink( $file ); + } + } + } +); + +file_put_contents( $temp_config, strip_baseline_includes( $config_path, $output_dir ) ); + +/* + * PHPStan reports on stdout, which --combined reserves for the baseline itself, + * so its output is sent to stderr. That keeps it visible on a terminal while + * leaving stdout parseable when it is redirected. + */ +$command = sprintf( + '%s analyse --configuration=%s --generate-baseline=%s --allow-empty-baseline --no-progress --memory-limit=%s 1>&2', + escapeshellarg( $repo_root . '/vendor/bin/phpstan' ), + escapeshellarg( $temp_config ), + escapeshellarg( $temp_baseline ), + escapeshellarg( $memory_limit ) +); + +fwrite( STDERR, "Analyzing with $config_option, existing baselines suppressed...\n" ); + +$exit_code = 0; +passthru( $command, $exit_code ); + +if ( 0 !== $exit_code || ! is_file( $temp_baseline ) ) { + fwrite( STDERR, "PHPStan failed, nothing written.\n" ); + exit( 1 ); +} + +/** + * The entries of each error, grouped by the identifier of the error it suppresses. + * + * @var array, path: non-empty-string}>> $grouped + */ +$grouped = array(); + +foreach ( read_baseline( $temp_baseline ) as $entry ) { + $grouped[ $entry['identifier'] ][] = $entry; +} +ksort( $grouped ); + +if ( $only_identifiers ) { + $grouped = array_intersect_key( $grouped, array_flip( $only_identifiers ) ); +} + +if ( $combined ) { + $all = array(); + foreach ( $grouped as $entries ) { + $all = array_merge( $all, $entries ); + } + echo build_baseline( $all, $output_dir, "# Every identifier, combined.\n" ); + exit( 0 ); +} + +if ( ! is_dir( $output_dir ) && ! mkdir( $output_dir, 0755, true ) ) { + fwrite( STDERR, "Could not create $output_option\n" ); + exit( 1 ); +} + +foreach ( $grouped as $identifier => $entries ) { + file_put_contents( + $output_dir . '/' . $identifier . '.neon', + build_baseline( $entries, $output_dir, build_baseline_header( $identifier, $config_option ) ) + ); + + printf( + "%s: %d entries, %d errors\n", + $output_option . '/' . $identifier . '.neon', + count( $entries ), + count_errors( $entries ) + ); +} + +/* + * An identifier that reports nothing has been driven to zero, so retire its file + * rather than leaving a stale one behind whose entries would then be reported as + * unmatched ignores. + * + * A run restricted to particular identifiers only knows about those, so it may + * only retire those. A full run has seen everything and may retire any file that + * no longer corresponds to a reported identifier. + */ +$retired = $only_identifiers; + +if ( ! $only_identifiers ) { + foreach ( find_baselines( $output_dir ) as $file ) { + $retired[] = basename( $file, '.neon' ); + } +} + +foreach ( $retired as $identifier ) { + if ( isset( $grouped[ $identifier ] ) ) { + continue; + } + + $file = $output_dir . '/' . $identifier . '.neon'; + if ( is_file( $file ) && unlink( $file ) ) { + printf( "%s: no errors remain, file deleted.\n", $output_option . '/' . $identifier . '.neon' ); + } else { + printf( "%s: no errors reported.\n", $identifier ); + } +} + +update_config_includes( $config_path, $config_option, $output_dir ); + +/** + * Returns the usage message. + * + * @return non-falsy-string Usage message. + */ +function get_usage(): string { + return <<<'TEXT' + Generates PHPStan baselines split by error identifier. + + Writes one baseline per identifier, retires any whose identifier no longer + reports anything, and rewrites the list of them between the + `# phpstan:baselines` markers in the configuration's `includes`, so that + neither addition nor removal has to be done by hand. + + Usage: + composer phpstan:baselines [-- ] + + Options: + --identifier= Only write the baseline for this identifier. Repeatable, + or comma separated. When an identifier is named and the + analysis reports none of it, its baseline file is deleted + rather than left behind empty. + Default: every identifier reported. + --config= Configuration to analyze with, relative to the repository + root. Default: phpstan.neon.dist + --output-dir= Where the per-identifier baselines are written, relative + to the repository root. Paths inside them are written + relative to this directory. + Default: tests/phpstan/baselines + --combined Print one combined baseline to stdout instead of writing + per-identifier files. Nothing is written to disk. + --memory-limit= Passed through to PHPStan. Default: 2G + -h, --help Show this message. + + Examples: + Refresh every baseline: + composer phpstan:baselines + + Refresh one: + composer phpstan:baselines -- --identifier=variable.undefined + + Refresh several, either comma separated or by repeating the option: + composer phpstan:baselines -- --identifier=variable.undefined,isset.variable + composer phpstan:baselines -- --identifier=isset.variable --identifier=empty.variable + + Inspect everything as one baseline without writing any files: + composer phpstan:baselines -- --combined + + TEXT; +} + +/** + * Reads a file, failing loudly rather than continuing with false. + * + * @param non-falsy-string $path Absolute path to the file. + * @return string File contents. + */ +function read_file( string $path ): string { + $contents = file_get_contents( $path ); + + if ( false === $contents ) { + fwrite( STDERR, "Could not read $path\n" ); + exit( 1 ); + } + + return $contents; +} + +/** + * Reads a baseline generated in PHPStan's PHP format. + * + * The file returns the entries as an array, so it is required rather than + * parsed. Its `path` values are built from __DIR__ and so arrive absolute. + * + * @param non-falsy-string $path Absolute path to the generated baseline. + * @return list, path: non-empty-string}> Baseline entries. + */ +function read_baseline( string $path ): array { + $data = require $path; + + $parameters = is_array( $data ) ? ( $data['parameters'] ?? null ) : null; + $ignore_errors = is_array( $parameters ) ? ( $parameters['ignoreErrors'] ?? null ) : null; + + if ( ! is_array( $ignore_errors ) ) { + fwrite( STDERR, "Unexpected baseline structure in $path\n" ); + exit( 1 ); + } + + $entries = array(); + + foreach ( $ignore_errors as $entry ) { + if ( ! is_array( $entry ) + || ! isset( $entry['message'], $entry['identifier'], $entry['count'], $entry['path'] ) + || ! is_string( $entry['message'] ) + || ! is_string( $entry['identifier'] ) + || ! is_int( $entry['count'] ) + || $entry['count'] < 0 + || ! is_string( $entry['path'] ) + || '' === $entry['path'] + ) { + fwrite( STDERR, "Unexpected baseline entry in $path.\n" ); + exit( 1 ); + } + + /* + * PHPStan attaches an identifier to every error it reports, so an entry + * without a usable one means this is not a baseline that can be split by + * identifier. Skipping it would quietly drop a suppression. + */ + if ( '' === $entry['identifier'] || '0' === $entry['identifier'] ) { + fwrite( STDERR, "Baseline entry in $path has no identifier.\n" ); + exit( 1 ); + } + + $entries[] = array( + 'message' => $entry['message'], + 'identifier' => $entry['identifier'], + 'count' => $entry['count'], + 'path' => $entry['path'], + ); + } + + return $entries; +} + +/** + * Returns the configuration with any `includes` of the baseline directory removed. + * + * Those files suppress the very errors being regenerated, so they have to be out + * of the way for the analysis to report anything. + * + * @param non-falsy-string $config_path Absolute path to the configuration file. + * @param non-falsy-string $output_dir Absolute path to the baseline directory. + * @return string Configuration contents. + */ +function strip_baseline_includes( string $config_path, string $output_dir ): string { + $config_dir = dirname( $config_path ); + $in_block = false; + $kept = array(); + + foreach ( explode( "\n", read_file( $config_path ) ) as $line ) { + if ( 1 === preg_match( '/^includes:/', $line ) ) { + $in_block = true; + $kept[] = $line; + continue; + } + + // A non-indented, non-blank line ends the block. + if ( $in_block && '' !== trim( $line ) && 1 !== preg_match( '/^\s/', $line ) ) { + $in_block = false; + } + + if ( $in_block && 1 === preg_match( '/^\s*-\s*(\S+)\s*$/', $line, $matches ) ) { + $included = $matches[1]; + $absolute = ( '/' === $included[0] ) ? $included : $config_dir . '/' . $included; + + if ( 0 === strpos( normalize_path( $absolute ), normalize_path( $output_dir ) . '/' ) ) { + continue; + } + } + + $kept[] = $line; + } + + return implode( "\n", $kept ); +} + +/** + * Lists the per-identifier baselines present on disk. + * + * @param non-empty-string $output_dir Absolute path to the baseline directory. + * @return list Absolute paths, sorted by name. + */ +function find_baselines( string $output_dir ): array { + $found = glob( $output_dir . '/*.neon' ); + + if ( false === $found ) { + return array(); + } + + sort( $found ); + + $files = array(); + foreach ( $found as $file ) { + if ( '' !== $file ) { + $files[] = $file; + } + } + + return $files; +} + +/** + * Rewrites the managed region of the configuration's `includes` list. + * + * The region is delimited by marker comments, so the hand written entries around + * it are never touched. Where the markers are absent they are appended to the end + * of the `includes` block, which is what happens the first time this is run + * against a configuration. + * + * @param non-falsy-string $config_path Absolute path to the configuration file. + * @param non-empty-string $config_option Configuration path, as passed on the command line. + * @param non-empty-string $output_dir Absolute path to the baseline directory. + */ +function update_config_includes( string $config_path, string $config_option, string $output_dir ): void { + $start_marker = '# phpstan:baselines start'; + $end_marker = '# phpstan:baselines end'; + + $before = read_file( $config_path ); + $lines = explode( "\n", $before ); + + $start = null; + $end = null; + foreach ( $lines as $i => $line ) { + if ( $start_marker === trim( $line ) ) { + $start = $i; + } + if ( $end_marker === trim( $line ) ) { + $end = $i; + } + } + + $region = array( "\t" . $start_marker ); + foreach ( find_baselines( $output_dir ) as $file ) { + $region[] = "\t- " . get_relative_path( dirname( $config_path ), $file ); + } + $region[] = "\t" . $end_marker; + + if ( null !== $start && null !== $end && $start < $end ) { + $updated = array_merge( + array_slice( $lines, 0, $start ), + $region, + array_slice( $lines, $end + 1 ) + ); + } else { + $insert = find_includes_end( $lines ); + + if ( null === $insert ) { + fwrite( STDERR, "No `includes` block found in $config_option, left untouched.\n" ); + return; + } + + $updated = array_merge( + array_slice( $lines, 0, $insert ), + array( '' ), + $region, + array_slice( $lines, $insert ) + ); + } + + $after = implode( "\n", $updated ); + + if ( $before === $after ) { + return; + } + + file_put_contents( $config_path, $after ); + printf( "%s: `includes` updated.\n", $config_option ); +} + +/** + * Finds where the `includes` block ends. + * + * @param list $lines Configuration lines. + * @return int|null Index of the first line after the block, or null when there is none. + */ +function find_includes_end( array $lines ): ?int { + $in_block = false; + $last = null; + + foreach ( $lines as $i => $line ) { + if ( 1 === preg_match( '/^includes:/', $line ) ) { + $in_block = true; + $last = $i; + continue; + } + + if ( ! $in_block || '' === trim( $line ) ) { + continue; + } + + // A non-indented line ends the block. + if ( 1 !== preg_match( '/^\s/', $line ) ) { + break; + } + + $last = $i; + } + + return null === $last ? null : $last + 1; +} + +/** + * Resolves ".." segments in a path without requiring it to exist. + * + * @param non-empty-string $path Path to normalize. + * @return non-falsy-string Normalized path, always absolute. + */ +function normalize_path( string $path ): string { + $parts = array(); + + foreach ( explode( '/', $path ) as $part ) { + if ( '' === $part || '.' === $part ) { + continue; + } + if ( '..' === $part ) { + array_pop( $parts ); + continue; + } + $parts[] = $part; + } + + return '/' . implode( '/', $parts ); +} + +/** + * Expresses one absolute path relative to a directory. + * + * A result of "0" is possible in principle, when the target is a single segment + * named "0" directly inside $from_dir, so this is non-empty rather than non-falsy. + * + * @param non-empty-string $from_dir Directory to express the path relative to. + * @param non-empty-string $to_path Path to express. + * @return non-empty-string Relative path, or "." when the two are the same. + */ +function get_relative_path( string $from_dir, string $to_path ): string { + $from = explode( '/', trim( normalize_path( $from_dir ), '/' ) ); + $to = explode( '/', trim( normalize_path( $to_path ), '/' ) ); + + while ( $from && $to && $from[0] === $to[0] ) { + array_shift( $from ); + array_shift( $to ); + } + + $relative = str_repeat( '../', count( $from ) ) . implode( '/', $to ); + + return '' === $relative ? '.' : $relative; +} + +/** + * Totals the `count` values across a set of entries. + * + * @param list, path: non-empty-string}> $entries Baseline entries. + * @return int<0, max> Total number of errors. + */ +function count_errors( array $entries ): int { + $total = 0; + + foreach ( $entries as $entry ) { + $total += $entry['count']; + } + + return $total; +} + +/** + * Builds a baseline file in PHPStan's NEON format. + * + * The entry layout matches what PHPStan itself writes, so a regenerated file can + * be diffed against one it produced. Paths are rewritten relative to the file's + * own directory, since that is what a NEON `path` resolves against. + * + * @param list, path: non-empty-string}> $entries Baseline entries. + * @param non-empty-string $output_dir Directory the file is written to. + * @param string $header Comment block, or an empty string. + * @return non-falsy-string Baseline file contents. + */ +function build_baseline( array $entries, string $output_dir, string $header ): string { + $contents = ( '' === $header ? '' : $header . "\n" ) . "parameters:\n\tignoreErrors:\n"; + + foreach ( $entries as $entry ) { + $contents .= "\t\t-\n" + . "\t\t\tmessage: " . quote_neon_value( $entry['message'] ) . "\n" + . "\t\t\tidentifier: " . $entry['identifier'] . "\n" + . "\t\t\tcount: " . $entry['count'] . "\n" + . "\t\t\tpath: " . get_relative_path( $output_dir, $entry['path'] ) . "\n"; + } + + return $contents; +} + +/** + * Quotes a value for NEON. + * + * A single quoted NEON string has no escape sequences other than a doubled + * quote, so the backslashes in a message pattern survive as written. This is the + * same quoting PHPStan applies when it generates a baseline itself. + * + * @param string $value Value to quote. + * @return non-falsy-string Quoted value. + */ +function quote_neon_value( string $value ): string { + return "'" . str_replace( "'", "''", $value ) . "'"; +} + +/** + * Builds the header comment for a per-identifier baseline. + * + * @param non-falsy-string $identifier Error identifier, a group followed by a code. + * @param non-empty-string $config Configuration path, as passed on the command line. + * @return non-falsy-string Comment block. + */ +function build_baseline_header( string $identifier, string $config ): string { + return <<assertInstanceOf( 'IXR_Error', $actual, $message ); @@ -908,6 +910,8 @@ public function assertIXRError( $actual, $message = '' ) { * * @param mixed $actual The value to check. * @param string $message Optional. Message to display when the assertion fails. + * + * @phpstan-assert !IXR_Error $actual */ public function assertNotIXRError( $actual, $message = '' ) { if ( $actual instanceof IXR_Error ) { diff --git a/tests/phpunit/includes/object-cache.php b/tests/phpunit/includes/object-cache.php index 142a310f6f26c..14224f967c767 100644 --- a/tests/phpunit/includes/object-cache.php +++ b/tests/phpunit/includes/object-cache.php @@ -2364,11 +2364,7 @@ public function add_non_persistent_groups( $groups ) { public function get_from_runtime_cache( $key, $group ) { $derived_key = $this->buildKey( $key, $group ); - if ( isset( $this->cache[ $derived_key ] ) ) { - return $this->cache[ $derived_key ]; - } - - return false; + return $this->cache[ $derived_key ] ?? false; } /** diff --git a/tests/phpunit/tests/admin/includesFile.php b/tests/phpunit/tests/admin/includesFile.php index ab2e82cf1dc8e..fab002c5e5e2c 100644 --- a/tests/phpunit/tests/admin/includesFile.php +++ b/tests/phpunit/tests/admin/includesFile.php @@ -399,8 +399,8 @@ public function mock_http_request( $response, $parsed_args, $url ) { * @covers ::download_url * @ticket 54738 * - * @param string $filter A callback containing a fake Content-Type header. - * @param string $ext The expected file extension to match. + * @param callable $filter A callback containing a fake Content-Type header. + * @param string $extension The expected file extension to match. */ public function test_download_url_should_use_the_content_type_header_to_set_extension_of_a_file_if_extension_was_not_determined( $filter, $extension ) { add_filter( 'pre_http_request', $filter ); diff --git a/tests/phpunit/tests/admin/includesMedia.php b/tests/phpunit/tests/admin/includesMedia.php new file mode 100644 index 0000000000000..35c542009db8d --- /dev/null +++ b/tests/phpunit/tests/admin/includesMedia.php @@ -0,0 +1,177 @@ +|null $expected The expected file size in bytes, or null if none should be displayed. + */ + public function test_attachment_submitbox_metadata_filesize( $filesize, ?int $expected ) { + $id = self::factory()->attachment->create_object( + array( + 'file' => 'test-image.jpg', + 'post_title' => 'Attachment Title', + 'post_parent' => 0, + 'post_mime_type' => 'image/jpeg', + ) + ); + $this->assertIsInt( $id ); + + wp_update_attachment_metadata( + $id, + array( + 'width' => 50, + 'height' => 50, + 'file' => 'test-image.jpg', + 'filesize' => $filesize, + ) + ); + + $GLOBALS['post'] = get_post( $id ); + + $output = get_echo( 'attachment_submitbox_metadata' ); + + if ( null === $expected ) { + $this->assertStringNotContainsString( 'misc-pub-filesize', $output, 'The file size should not have been displayed.' ); + } else { + $this->assertStringContainsString( size_format( $expected ), $output, 'The displayed file size did not match the normalized file size.' ); + } + } + + /** + * Data provider. + * + * @return array|null }> + */ + public function data_attachment_submitbox_metadata_filesize(): array { + return array( + 'an integer' => array( + 'filesize' => 12345, + 'expected' => 12345, + ), + 'a numeric string' => array( + 'filesize' => '12345', + 'expected' => 12345, + ), + 'a float' => array( + 'filesize' => 12345.6, + 'expected' => 12345, + ), + 'a float as a string' => array( + 'filesize' => '12345.6', + 'expected' => 12345, + ), + 'an exponential string' => array( + 'filesize' => '1e3', + 'expected' => 1000, + ), + 'a value smaller than a byte' => array( + 'filesize' => 0.5, + 'expected' => null, + ), + 'zero' => array( + 'filesize' => 0, + 'expected' => null, + ), + 'a negative integer' => array( + 'filesize' => -12345, + 'expected' => null, + ), + 'an empty string' => array( + 'filesize' => '', + 'expected' => null, + ), + 'a non-numeric string' => array( + 'filesize' => 'not-a-number', + 'expected' => null, + ), + 'an array' => array( + 'filesize' => array( 12345 ), + 'expected' => null, + ), + 'null' => array( + 'filesize' => null, + 'expected' => null, + ), + 'false' => array( + 'filesize' => false, + 'expected' => null, + ), + 'true' => array( + 'filesize' => true, + 'expected' => null, + ), + ); + } + + /** + * Tests that an unusable `filesize` in the attachment metadata falls back to the size of the file. + * + * @ticket 65686 + * + * @covers ::attachment_submitbox_metadata + * + * @dataProvider data_attachment_submitbox_metadata_filesize_falls_back_to_the_file + * + * @param mixed $filesize The `filesize` value stored in the attachment metadata. + */ + public function test_attachment_submitbox_metadata_filesize_falls_back_to_the_file( $filesize ) { + $id = self::factory()->attachment->create_upload_object( DIR_TESTDATA . '/images/canola.jpg' ); + $this->assertIsInt( $id ); + $file = get_attached_file( $id ); + $this->assertIsString( $file ); + + $meta = wp_get_attachment_metadata( $id ); + $this->assertIsArray( $meta ); + $meta['filesize'] = $filesize; + wp_update_attachment_metadata( $id, $meta ); + + $GLOBALS['post'] = get_post( $id ); + + $output = get_echo( 'attachment_submitbox_metadata' ); + + $filesize = wp_filesize( $file ); + $this->assertIsInt( $filesize ); + $this->assertStringContainsString( size_format( $filesize ), $output ); + } + + /** + * Data provider. + * + * @return array + */ + public function data_attachment_submitbox_metadata_filesize_falls_back_to_the_file(): array { + return array( + 'a value smaller than a byte' => array( 'filesize' => 0.5 ), + 'zero' => array( 'filesize' => 0 ), + 'a negative integer' => array( 'filesize' => -12345 ), + 'an empty string' => array( 'filesize' => '' ), + 'a non-numeric string' => array( 'filesize' => 'not-a-number' ), + 'an array' => array( 'filesize' => array( 12345 ) ), + 'null' => array( 'filesize' => null ), + 'false' => array( 'filesize' => false ), + 'true' => array( 'filesize' => true ), + ); + } +} diff --git a/tests/phpunit/tests/admin/wpOnThisDay.php b/tests/phpunit/tests/admin/wpOnThisDay.php deleted file mode 100644 index 61aef6401683a..0000000000000 --- a/tests/phpunit/tests/admin/wpOnThisDay.php +++ /dev/null @@ -1,333 +0,0 @@ -modify( '-' . $years_ago . ' years' )->format( 'Y-m-d' ) . ' ' . $time; - - return self::factory()->post->create( - array( - 'post_author' => $author_id, - 'post_date' => $post_date, - 'post_date_gmt' => get_gmt_from_date( $post_date ), - 'post_status' => 'publish', - 'post_title' => $title, - ) - ); - } - - /** - * Creates a published post near, but not on, today's prior-year calendar day. - * - * @param int $author_id Author ID. - * @param string $title Post title. - * @param int $day_offset Number of days from today's prior-year calendar day. - * @return int Post ID. - */ - private function create_nearby_post( $author_id, $title = 'Almost a memory', $day_offset = 1 ) { - $post_date = current_datetime() - ->modify( '-1 year' ) - ->modify( ( $day_offset >= 0 ? '+' : '' ) . $day_offset . ' days' ) - ->format( 'Y-m-d' ) . ' 12:00:00'; - - return self::factory()->post->create( - array( - 'post_author' => $author_id, - 'post_date' => $post_date, - 'post_date_gmt' => get_gmt_from_date( $post_date ), - 'post_status' => 'publish', - 'post_title' => $title, - ) - ); - } - - /** - * Invokes _wp_dashboard_on_this_day_date_query_clause(). - * - * @param string $date Date string. - * @return array Date query clause. - */ - private static function get_date_query_clause( $date ) { - return _wp_dashboard_on_this_day_date_query_clause( new DateTimeImmutable( $date, wp_timezone() ) ); - } - - /** - * @covers ::wp_dashboard_on_this_day_setup - */ - public function test_setup_always_registers_widget_and_postbox_class_filter() { - $this->set_up_dashboard_screen(); - - $user_id = self::factory()->user->create( array( 'role' => 'author' ) ); - wp_set_current_user( $user_id ); - - wp_dashboard_on_this_day_setup(); - - $dashboard_widgets = $GLOBALS['wp_meta_boxes']['dashboard']['normal']['core'] ?? array(); - - $this->assertArrayHasKey( 'wp_dashboard_on_this_day', $dashboard_widgets ); - $this->assertSame( 'On This Day', $dashboard_widgets['wp_dashboard_on_this_day']['title'] ); - $this->assertNotFalse( - has_filter( - 'postbox_classes_dashboard_wp_dashboard_on_this_day', - 'wp_dashboard_on_this_day_postbox_classes' - ) - ); - } - - /** - * @covers ::wp_dashboard_on_this_day_postbox_classes - */ - public function test_postbox_classes_hides_widget_without_matching_posts() { - $user_id = self::factory()->user->create( array( 'role' => 'author' ) ); - wp_set_current_user( $user_id ); - - $this->assertContains( 'hidden', wp_dashboard_on_this_day_postbox_classes( array( '' ) ) ); - } - - /** - * @covers ::wp_dashboard_on_this_day_postbox_classes - */ - public function test_postbox_classes_does_not_hide_widget_with_matching_posts() { - $user_id = self::factory()->user->create( array( 'role' => 'author' ) ); - wp_set_current_user( $user_id ); - $this->create_matching_post( $user_id ); - - $this->assertNotContains( 'hidden', wp_dashboard_on_this_day_postbox_classes( array( '' ) ) ); - } - - /** - * @covers ::wp_dashboard_on_this_day_setup - */ - public function test_setup_adds_dashboard_widget_with_matching_post_from_another_author() { - $this->set_up_dashboard_screen(); - - $user_id = self::factory()->user->create( array( 'role' => 'author' ) ); - $other_user_id = self::factory()->user->create( array( 'role' => 'author' ) ); - wp_set_current_user( $user_id ); - $this->create_matching_post( $other_user_id ); - - wp_dashboard_on_this_day_setup(); - - $dashboard_widgets = $GLOBALS['wp_meta_boxes']['dashboard']['normal']['core'] ?? array(); - - $this->assertArrayHasKey( 'wp_dashboard_on_this_day', $dashboard_widgets ); - } - - /** - * @covers ::_wp_dashboard_on_this_day_date_query_clause - */ - public function test_get_date_query_clause_includes_february_29_on_february_28_in_non_leap_year() { - $clause = self::get_date_query_clause( '2023-02-28 12:00:00' ); - - $this->assertSame( - array( - 'relation' => 'OR', - array( - 'month' => 2, - 'day' => 28, - ), - array( - 'month' => 2, - 'day' => 29, - ), - ), - $clause - ); - } - - /** - * @covers ::_wp_dashboard_on_this_day_date_query_clause - */ - public function test_get_date_query_clause_does_not_include_february_29_on_february_28_in_leap_year() { - $clause = self::get_date_query_clause( '2024-02-28 12:00:00' ); - - $this->assertSame( - array( - 'month' => 2, - 'day' => 28, - ), - $clause - ); - } - - /** - * @covers ::_wp_dashboard_on_this_day_date_query_clause - */ - public function test_get_date_query_clause_matches_february_29_on_leap_day() { - $clause = self::get_date_query_clause( '2024-02-29 12:00:00' ); - - $this->assertSame( - array( - 'month' => 2, - 'day' => 29, - ), - $clause - ); - } - - /** - * @covers ::wp_dashboard_on_this_day - */ - public function test_widget_outputs_placeholder_without_matching_posts() { - $user_id = self::factory()->user->create( array( 'role' => 'author' ) ); - wp_set_current_user( $user_id ); - - ob_start(); - wp_dashboard_on_this_day(); - $output = ob_get_clean(); - - $this->assertStringContainsString( 'No posts were published on this day in previous years.', $output ); - $this->assertStringNotContainsString( '
    ', $output ); - } - - /** - * @covers ::wp_dashboard_on_this_day - */ - public function test_widget_ignores_nearby_prior_year_posts() { - $user_id = self::factory()->user->create( array( 'role' => 'author' ) ); - wp_set_current_user( $user_id ); - $this->create_nearby_post( $user_id ); - - ob_start(); - wp_dashboard_on_this_day(); - $output = ob_get_clean(); - - $this->assertStringNotContainsString( 'Almost a memory', $output ); - $this->assertStringContainsString( 'No posts were published on this day in previous years.', $output ); - } - - /** - * @covers ::wp_dashboard_on_this_day - */ - public function test_widget_uses_singular_copy_for_a_single_post() { - $user_id = self::factory()->user->create( array( 'role' => 'author' ) ); - wp_set_current_user( $user_id ); - $this->create_matching_post( $user_id ); - - ob_start(); - wp_dashboard_on_this_day(); - $output = ob_get_clean(); - - $this->assertStringContainsString( 'One post has been published on ' . wp_date( 'F jS' ) . ':', $output ); - } - - /** - * @covers ::wp_dashboard_on_this_day - */ - public function test_widget_labels_posts_from_other_authors() { - $user_id = self::factory()->user->create( - array( - 'display_name' => 'Current Writer', - 'role' => 'author', - ) - ); - $other_user_id = self::factory()->user->create( - array( - 'display_name' => 'Guest Writer', - 'role' => 'author', - ) - ); - wp_set_current_user( $user_id ); - - $this->create_matching_post( $user_id, 'A note from me' ); - $this->create_matching_post( $other_user_id, 'A note from someone else' ); - - ob_start(); - wp_dashboard_on_this_day(); - $output = ob_get_clean(); - - $this->assertStringContainsString( 'A note from me', $output ); - $this->assertStringNotContainsString( 'by Current Writer', $output ); - $this->assertStringContainsString( 'A note from someone else', $output ); - $this->assertStringContainsString( 'by Guest Writer', $output ); - $this->assertStringContainsString( '', $output ); - } - - /** - * @covers ::wp_dashboard_on_this_day - */ - public function test_widget_groups_posts_by_year() { - $user_id = self::factory()->user->create( array( 'role' => 'author' ) ); - wp_set_current_user( $user_id ); - - $this->create_matching_post( $user_id, 'Pretending to meditate', 1, '12:00:00' ); - $this->create_matching_post( $user_id, 'Slow internet and good books', 1, '11:00:00' ); - $this->create_matching_post( $user_id, 'Late-night shipping log', 2, '12:00:00' ); - - ob_start(); - wp_dashboard_on_this_day(); - $output = ob_get_clean(); - - $last_year = current_datetime()->modify( '-1 year' )->format( 'Y' ); - $two_years_ago = current_datetime()->modify( '-2 years' )->format( 'Y' ); - - $this->assertStringContainsString( '3 posts have been published on ' . wp_date( 'F jS' ) . ':', $output ); - $this->assertStringContainsString( '

    ' . $last_year . '

    ', $output ); - $this->assertStringContainsString( '

    ' . $two_years_ago . '

    ', $output ); - $this->assertStringContainsString( 'Pretending to meditate', $output ); - $this->assertStringContainsString( 'Slow internet and good books', $output ); - $this->assertStringContainsString( 'Late-night shipping log', $output ); - } - - /** - * @covers ::wp_dashboard_on_this_day - * @covers ::wp_dashboard_on_this_day_get_posts - */ - public function test_widget_limits_posts_to_ten() { - $user_id = self::factory()->user->create( array( 'role' => 'author' ) ); - wp_set_current_user( $user_id ); - - for ( $years_ago = 1; $years_ago <= 11; $years_ago++ ) { - $this->create_matching_post( $user_id, 'Anniversary post ' . $years_ago, $years_ago ); - } - - ob_start(); - wp_dashboard_on_this_day(); - $output = ob_get_clean(); - - $this->assertStringContainsString( '10 posts have been published on ' . wp_date( 'F jS' ) . ':', $output ); - $this->assertStringContainsString( 'Anniversary post 1<', $output ); - $this->assertStringContainsString( 'Anniversary post 10<', $output ); - $this->assertStringNotContainsString( 'Anniversary post 11', $output ); - } -} diff --git a/tests/phpunit/tests/ajax/wpAjaxAutocompleteUser.php b/tests/phpunit/tests/ajax/wpAjaxAutocompleteUser.php new file mode 100644 index 0000000000000..3d1eecf2f0fa3 --- /dev/null +++ b/tests/phpunit/tests/ajax/wpAjaxAutocompleteUser.php @@ -0,0 +1,412 @@ +user->create( array( 'role' => 'administrator' ) ); + self::$site_admin_id = $factory->user->create( array( 'role' => 'administrator' ) ); + self::$subscriber_id = $factory->user->create( array( 'role' => 'subscriber' ) ); + self::$target_user_id = $factory->user->create( + array( + 'role' => 'subscriber', + 'user_login' => 'autocompleteuser', + 'user_email' => 'autocompleteuser+bat\'leth@klingon.example.org', + ) + ); + + if ( is_multisite() ) { + grant_super_admin( self::$super_admin_id ); + } + } + + /** + * Runs the Ajax handler and returns the response passed to wp_die(). + * + * The handler never echoes anything, so the response is only available + * through the exception thrown by the die handler. + * + * @return string The raw response. + */ + protected function handle_autocomplete_user(): string { + try { + $this->_handleAjax( 'autocomplete-user' ); + } catch ( WPAjaxDieStopException $e ) { + return $e->getMessage(); + } + + $this->fail( 'wp_ajax_autocomplete_user() did not stop execution.' ); + } + + /** + * Tests that users of the current site are returned when searching them. + * + * @ticket 65051 + */ + public function test_should_return_users_matching_the_search_term() { + wp_set_current_user( self::$super_admin_id ); + + $_GET = wp_slash( + array( + 'autocomplete_type' => 'search', + 'term' => 'autocompleteuser', + ) + ); + + $response = json_decode( $this->handle_autocomplete_user(), true ); + + $this->assertIsArray( $response, 'The response should be a JSON encoded array.' ); + $this->assertCount( 1, $response, 'Only the matching user should be returned.' ); + $result = array_first( $response ); + $this->assertIsArray( $result ); + $this->assertSame( 'autocompleteuser', $result['value'], 'The user login should be returned as the value.' ); + $this->assertIsString( $result['label'] ); + $this->assertStringContainsString( 'autocompleteuser+bat\'leth@klingon.example.org', $result['label'], 'The label should contain the email address.' ); + } + + /** + * Tests that the email address is returned when it is the requested field. + * + * @ticket 65051 + */ + public function test_should_return_the_email_address_as_the_value_when_requested() { + wp_set_current_user( self::$super_admin_id ); + + $_GET = wp_slash( + array( + 'autocomplete_type' => 'search', + 'autocomplete_field' => 'user_email', + 'term' => 'autocompleteuser', + ) + ); + + $response = json_decode( $this->handle_autocomplete_user(), true ); + + $this->assertIsArray( $response, 'The response should be a JSON encoded array.' ); + $this->assertCount( 1, $response, 'Only the matching user should be returned.' ); + $result = array_first( $response ); + $this->assertIsArray( $result ); + $this->assertSame( 'autocompleteuser+bat\'leth@klingon.example.org', $result['value'], 'The email address should be returned as the value.' ); + } + + /** + * Tests that users of the current site are excluded when adding a user to it. + * + * @ticket 65051 + */ + public function test_should_exclude_users_of_the_current_site_when_adding() { + wp_set_current_user( self::$super_admin_id ); + + // The default autocomplete type is 'add', which excludes existing users of the site. + $_GET = wp_slash( + array( + 'term' => 'autocompleteuser', + ) + ); + + $response = json_decode( $this->handle_autocomplete_user(), true ); + + $this->assertSame( array(), $response, 'A user of the current site should not be suggested.' ); + } + + /** + * Tests that HTML tags are removed from the search term. + * + * @ticket 65051 + * + * @dataProvider data_terms_containing_tags + * + * @param string $term Term containing HTML tags. + */ + public function test_should_strip_tags_from_the_search_term( string $term ) { + wp_set_current_user( self::$super_admin_id ); + + $_GET = wp_slash( + array( + 'autocomplete_type' => 'search', + 'term' => $term, + ) + ); + + $search = null; + add_action( + 'pre_get_users', + static function ( WP_User_Query $query ) use ( &$search ) { + $search = $query->get( 'search' ); + } + ); + + $response = json_decode( $this->handle_autocomplete_user(), true ); + $this->assertIsArray( $response, 'The response should be a JSON encoded array.' ); + + $this->assertSame( '*autocompleteuser*', $search, 'The search term should be sanitized before it is passed to get_users().' ); + $this->assertCount( 1, $response, 'The sanitized term should still match the user.' ); + } + + /** + * Data provider. + * + * Note that `wp_strip_all_tags()` removes script and style elements along + * with their contents, while for other tags only the tags themselves are + * removed. + * + * @return array + */ + public static function data_terms_containing_tags(): array { + return array( + 'script element after the term' => array( 'autocompleteuser' ), + 'tags wrapping the term' => array( 'autocompleteuser' ), + ); + } + + /** + * Tests that searching for an email address with apostrophes is successful. + * + * @ticket 65051 + */ + public function test_search_email_address_with_apostrophe() { + wp_set_current_user( self::$super_admin_id ); + + $_GET = wp_slash( + array( + 'autocomplete_type' => 'search', + 'autocomplete_field' => 'user_email', + 'term' => 'autocompleteuser+bat\'leth@klingon.example.org', + ) + ); + + $response = json_decode( $this->handle_autocomplete_user(), true ); + + $this->assertIsArray( $response, 'The response should be a JSON encoded array.' ); + $this->assertCount( 1, $response, 'Only the matching user should be returned.' ); + $result = array_first( $response ); + $this->assertIsArray( $result ); + $this->assertSame( 'autocompleteuser+bat\'leth@klingon.example.org', $result['value'], 'The email address should be returned as the value.' ); + } + + /** + * Tests that a missing search term does not return results. + * + * @ticket 65051 + */ + public function test_missing_term_does_not_return_results() { + wp_set_current_user( self::$super_admin_id ); + + $_GET = wp_slash( + array( + 'autocomplete_type' => 'search', + ) + ); + + $this->assertSame( '0', $this->handle_autocomplete_user() ); + } + + /** + * Tests that an empty search term does not return results. + * + * @ticket 65051 + * + * @dataProvider data_empty_terms + * + * @param string $term Empty or whitespace-only term. + */ + public function test_empty_term_does_not_return_results( string $term ) { + wp_set_current_user( self::$super_admin_id ); + + $_GET = wp_slash( + array( + 'autocomplete_type' => 'search', + 'term' => $term, + ) + ); + + $this->assertSame( '0', $this->handle_autocomplete_user() ); + } + + /** + * Data provider. + * + * @return array + */ + public static function data_empty_terms(): array { + return array( + 'empty string' => array( '' ), + 'whitespace only' => array( ' ' ), + ); + } + + /** + * Tests that a non-string search term does not return results. + * + * @ticket 65051 + */ + public function test_non_string_term_does_not_return_results() { + wp_set_current_user( self::$super_admin_id ); + + $_GET = wp_slash( + array( + 'autocomplete_type' => 'search', + 'term' => array( 'autocompleteuser' ), + ) + ); + + $this->assertSame( '0', $this->handle_autocomplete_user() ); + } + + /** + * Tests that a term consisting only of asterisks does not match all users. + * + * @ticket 65051 + */ + public function test_asterisk_only_term_does_not_return_results() { + wp_set_current_user( self::$super_admin_id ); + + $_GET = wp_slash( + array( + 'autocomplete_type' => 'search', + 'term' => '**', + ) + ); + + $this->assertSame( '0', $this->handle_autocomplete_user() ); + } + + /** + * Tests that a term wrapped in asterisks still matches. + * + * @ticket 65051 + */ + public function test_asterisk_wrapped_term_returns_results() { + wp_set_current_user( self::$super_admin_id ); + + $_GET = wp_slash( + array( + 'autocomplete_type' => 'search', + 'term' => '*autocompleteuser*', + ) + ); + + $response = json_decode( $this->handle_autocomplete_user(), true ); + + $this->assertIsArray( $response, 'The response should be a JSON encoded array.' ); + $this->assertCount( 1, $response, 'The matching user should be returned.' ); + } + + /** + * Tests that users without the 'promote_users' capability are denied. + * + * @ticket 65051 + */ + public function test_should_deny_users_without_the_promote_users_capability() { + wp_set_current_user( self::$subscriber_id ); + + $_GET = wp_slash( + array( + 'autocomplete_type' => 'search', + 'term' => 'autocompleteuser', + ) + ); + + $this->assertSame( '-1', $this->handle_autocomplete_user() ); + } + + /** + * Tests that site administrators are denied unless the filter allows them. + * + * @ticket 65051 + */ + public function test_should_deny_site_administrators_by_default() { + wp_set_current_user( self::$site_admin_id ); + + $_GET = wp_slash( + array( + 'autocomplete_type' => 'search', + 'term' => 'autocompleteuser', + ) + ); + + $this->assertSame( '-1', $this->handle_autocomplete_user() ); + } + + /** + * Tests that site administrators are allowed by the + * 'autocomplete_users_for_site_admins' filter. + * + * @ticket 65051 + */ + public function test_should_allow_site_administrators_when_filtered() { + wp_set_current_user( self::$site_admin_id ); + + add_filter( 'autocomplete_users_for_site_admins', '__return_true' ); + + $_GET = wp_slash( + array( + 'autocomplete_type' => 'search', + 'term' => 'autocompleteuser', + ) + ); + + $response = json_decode( $this->handle_autocomplete_user(), true ); + + $this->assertIsArray( $response, 'The response should be a JSON encoded array.' ); + $this->assertCount( 1, $response, 'The matching user should be returned.' ); + } + + /** + * Tests that no autocompletion happens on large networks. + * + * @ticket 65051 + */ + public function test_should_deny_the_request_on_a_large_network() { + wp_set_current_user( self::$super_admin_id ); + + add_filter( 'wp_is_large_network', '__return_true' ); + + $_GET = wp_slash( + array( + 'autocomplete_type' => 'search', + 'term' => 'autocompleteuser', + ) + ); + + $this->assertSame( '-1', $this->handle_autocomplete_user() ); + } +} diff --git a/tests/phpunit/tests/ajax/wpAjaxImageEditor.php b/tests/phpunit/tests/ajax/wpAjaxImageEditor.php index 205f61636149c..03f14f6dd8fa8 100644 --- a/tests/phpunit/tests/ajax/wpAjaxImageEditor.php +++ b/tests/phpunit/tests/ajax/wpAjaxImageEditor.php @@ -194,4 +194,136 @@ public function test_filesize_restored_after_restoring_original_image() { $this->assertSameSetsWithIndex( $pre_file_sizes, $post_restore_file_sizes, 'Filesize should have restored after restoring the original image.' ); } + + /** + * Ensure editing an image does not fatal when the attachment metadata has no usable `sizes` data. + * + * Attachment metadata is not guaranteed to contain a `sizes` array. It can be missing when + * sub-size generation never ran or failed (for example `wp_create_image_subsizes()` returns an + * empty array when the file cannot be parsed), or when it is removed by a plugin filtering + * `wp_get_attachment_metadata`. `wp_save_image()` only validates that the metadata itself is an + * array, then passes `$meta['sizes']` straight to `array_merge()`. + * + * @ticket 65748 + * + * @covers ::wp_save_image + * + * @dataProvider data_save_image_with_unusable_sizes_metadata + * + * @param array{ sizes?: mixed } $meta Attachment metadata to store before editing, minus the file-specific keys. + */ + public function test_save_image_with_unusable_sizes_metadata( array $meta ) { + require_once ABSPATH . 'wp-admin/includes/image-edit.php'; + + $filename = DIR_TESTDATA . '/images/canola.jpg'; + $contents = file_get_contents( $filename ); + $this->assertIsString( $contents ); + + $upload = wp_upload_bits( wp_basename( $filename ), null, $contents ); + $id = $this->_make_attachment( $upload ); + $this->assertIsInt( $id ); + + $original_meta = wp_get_attachment_metadata( $id ); + $this->assertIsArray( $original_meta ); + + // Keep the real file/dimension data, only make `sizes` unusable. + $meta = array_merge( + wp_array_slice_assoc( $original_meta, array( 'width', 'height', 'file', 'filesize' ) ), + $meta + ); + + wp_update_attachment_metadata( $id, $meta ); + + $_REQUEST['action'] = 'image-editor'; + $_REQUEST['context'] = 'edit-attachment'; + $_REQUEST['postid'] = $id; + $_REQUEST['target'] = 'all'; + $_REQUEST['do'] = 'save'; + $_REQUEST['history'] = '[{"c":{"x":5,"y":8,"w":289,"h":322}}]'; + + $ret = wp_save_image( $id ); + + $this->assertObjectNotHasProperty( 'error', $ret, 'Saving the image should not have returned an error.' ); + + $saved_meta = wp_get_attachment_metadata( $id ); + + $this->assertIsArray( $saved_meta, 'The saved attachment metadata should be an array.' ); + $this->assertArrayHasKey( 'sizes', $saved_meta ); + $this->assertIsArray( $saved_meta['sizes'], 'The saved attachment metadata should contain a `sizes` array.' ); + $this->assertArrayHasKey( 'thumbnail', $saved_meta['sizes'], 'The edited image should have regenerated the thumbnail size.' ); + } + + /** + * Ensure restoring an image does not fatal when the attachment metadata has no usable `sizes` data. + * + * `wp_restore_image()` writes each backed up size with `$meta['sizes'][ $default_size ] = $data` + * without ever checking that `$meta['sizes']` is an array. A scalar value raises + * "Cannot use a scalar value as an array", and `false` is deprecated as of PHP 8.1 and + * an error as of PHP 9. The same metadata that fatals `wp_save_image()` reaches this code. + * + * @ticket 65748 + * + * @covers ::wp_restore_image + * + * @dataProvider data_save_image_with_unusable_sizes_metadata + * + * @param array{ sizes?: mixed } $meta Replacement `sizes` metadata to store before restoring. + */ + public function test_restore_image_with_unusable_sizes_metadata( array $meta ) { + require_once ABSPATH . 'wp-admin/includes/image-edit.php'; + + $filename = DIR_TESTDATA . '/images/canola.jpg'; + $contents = file_get_contents( $filename ); + $this->assertIsString( $contents ); + + $upload = wp_upload_bits( wp_basename( $filename ), null, $contents ); + $id = $this->_make_attachment( $upload ); + $this->assertIsInt( $id ); + + $_REQUEST['action'] = 'image-editor'; + $_REQUEST['context'] = 'edit-attachment'; + $_REQUEST['postid'] = $id; + $_REQUEST['target'] = 'all'; + $_REQUEST['do'] = 'save'; + $_REQUEST['history'] = '[{"c":{"x":5,"y":8,"w":289,"h":322}}]'; + + // Edit the image first so that `_wp_attachment_backup_sizes` holds the original sizes. + wp_save_image( $id ); + + $this->assertNotEmpty( + get_post_meta( $id, '_wp_attachment_backup_sizes', true ), + 'The image edit should have stored backup sizes to restore from.' + ); + + // Keep the metadata written by the edit, only make `sizes` unusable. + $edited_meta = wp_get_attachment_metadata( $id ); + $this->assertIsArray( $edited_meta ); + unset( $edited_meta['sizes'] ); + + wp_update_attachment_metadata( $id, array_merge( $edited_meta, $meta ) ); + + wp_restore_image( $id ); + + $restored_meta = wp_get_attachment_metadata( $id ); + $this->assertIsArray( $restored_meta ); + + $this->assertArrayHasKey( 'sizes', $restored_meta ); + $this->assertIsArray( $restored_meta['sizes'], 'The restored attachment metadata should contain a `sizes` array.' ); + $this->assertArrayHasKey( 'thumbnail', $restored_meta['sizes'], 'The restored image should have the thumbnail size restored from the backup sizes.' ); + } + + /** + * Data provider. + * + * @return array + */ + public function data_save_image_with_unusable_sizes_metadata(): array { + return array( + 'no sizes key' => array( array() ), + 'null sizes' => array( array( 'sizes' => null ) ), + 'empty string' => array( array( 'sizes' => '' ) ), + 'string sizes' => array( array( 'sizes' => 'not-an-array' ) ), + 'boolean sizes' => array( array( 'sizes' => false ) ), + ); + } } diff --git a/tests/phpunit/tests/auth.php b/tests/phpunit/tests/auth.php index a290d11e118e6..91072d0b7dfd1 100644 --- a/tests/phpunit/tests/auth.php +++ b/tests/phpunit/tests/auth.php @@ -993,6 +993,24 @@ public function test_user_request_key_handling() { $check = wp_validate_user_request_key( $request_id, '' ); $this->assertWPError( $check ); $this->assertSame( 'missing_key', $check->get_error_code() ); + + // A missing request should fail closed, even when get_post() could fall + // back to the global post. + $had_global_post = array_key_exists( 'post', $GLOBALS ); + $global_post = $GLOBALS['post'] ?? null; + $GLOBALS['post'] = get_post( $request_id ); + try { + $check = wp_validate_user_request_key( 0, $key ); + } finally { + if ( $had_global_post ) { + $GLOBALS['post'] = $global_post; + } else { + unset( $GLOBALS['post'] ); + } + } + + $this->assertWPError( $check ); + $this->assertSame( 'invalid_request', $check->get_error_code() ); } /** diff --git a/tests/phpunit/tests/block-supports/layout.php b/tests/phpunit/tests/block-supports/layout.php index c9a8dd34b1371..7ad00f7cc364c 100644 --- a/tests/phpunit/tests/block-supports/layout.php +++ b/tests/phpunit/tests/block-supports/layout.php @@ -1124,4 +1124,68 @@ public function test_layout_support_flag_with_non_string_class_name() { 'Layout support should render the expected markup when className is not a string' ); } + + /** + * Tests that layout support returns early, without resolving global settings, + * for a block that cannot produce any layout output. + * + * Resolving global settings reads the user's `wp_global_styles` post with a + * `WP_Query`, which fires `the_posts`. A callback on that hook that renders + * blocks re-enters this filter, so the bail-out has to happen before the + * lookup or the recursion has no base case. + * + * @ticket 65741 + * + * @covers ::wp_render_layout_support_flag + */ + public function test_layout_support_flag_returns_early_before_resolving_global_settings() { + $user_data_resolutions = 0; + add_filter( + 'wp_theme_json_data_user', + static function ( $theme_json ) use ( &$user_data_resolutions ) { + ++$user_data_resolutions; + return $theme_json; + } + ); + + // A block with no layout support and no child layout, as produced by + // parsing content that has no block delimiters. + $block_content = '

    Not a block.

    '; + $block = array( + 'blockName' => null, + 'attrs' => array(), + ); + + // Start from a cold cache, as on a front-end request. + wp_clean_theme_json_cache(); + + $this->assertSame( + $block_content, + wp_render_layout_support_flag( $block_content, $block ), + 'Block content should be returned unchanged when the block has no layout support.' + ); + $this->assertSame( + 0, + $user_data_resolutions, + 'Global settings should not be resolved for a block that cannot produce layout output.' + ); + + // A block that does support layout still resolves global settings, which + // confirms the assertion above is not passing because of a warm cache. + wp_clean_theme_json_cache(); + + wp_render_layout_support_flag( + '
    ', + array( + 'blockName' => 'core/group', + 'attrs' => array( 'layout' => array( 'type' => 'constrained' ) ), + ) + ); + + $this->assertGreaterThan( + 0, + $user_data_resolutions, + 'Global settings should still be resolved for a block that supports layout.' + ); + } } diff --git a/tests/phpunit/tests/blocks/wpBlockMetadataRegistry.php b/tests/phpunit/tests/blocks/wpBlockMetadataRegistry.php index 4a1ddb3c94c02..8eae5dda0772d 100644 --- a/tests/phpunit/tests/blocks/wpBlockMetadataRegistry.php +++ b/tests/phpunit/tests/blocks/wpBlockMetadataRegistry.php @@ -43,6 +43,90 @@ public function test_get_nonexistent_metadata() { $this->assertNull( $retrieved_metadata ); } + public function test_get_metadata_ignores_sibling_paths_with_matching_prefix() { + $path = WP_PLUGIN_DIR . '/prefix-plugin/blocks'; + $sibling_path = WP_PLUGIN_DIR . '/prefix-plugin/blocks-extra'; + $manifest_data = array( + 'test-block' => array( + 'name' => 'test-block', + 'title' => 'Test Block', + ), + ); + + file_put_contents( $this->temp_manifest_file, 'temp_manifest_file ); + + $this->assertSame( $manifest_data['test-block'], WP_Block_Metadata_Registry::get_metadata( $path . '/test-block' ) ); + $this->assertNull( WP_Block_Metadata_Registry::get_metadata( $sibling_path . '/test-block' ) ); + $this->assertFalse( WP_Block_Metadata_Registry::has_metadata( $sibling_path . '/test-block/block.json' ) ); + } + + public function test_register_collection_allows_root_sibling_path_with_matching_prefix() { + $path = WP_CONTENT_DIR . '/plugin'; + $manifest_data = array( + 'test-block' => array( + 'name' => 'test-block', + 'title' => 'Test Block', + ), + ); + + file_put_contents( $this->temp_manifest_file, 'assertTrue( WP_Block_Metadata_Registry::register_collection( $path, $this->temp_manifest_file ) ); + $this->assertSame( $manifest_data['test-block'], WP_Block_Metadata_Registry::get_metadata( $path . '/test-block' ) ); + } + + public function test_register_collection_rejects_dot_segment_plugin_root_path() { + $this->setExpectedIncorrectUsage( 'WP_Block_Metadata_Registry::register_collection' ); + + $result = WP_Block_Metadata_Registry::register_collection( WP_PLUGIN_DIR . '/.', $this->temp_manifest_file ); + $this->assertFalse( $result, 'Plugin root path with a dot segment should not be registered' ); + } + + public function test_register_collection_rejects_dot_segment_plugin_root_parent_path() { + $this->setExpectedIncorrectUsage( 'WP_Block_Metadata_Registry::register_collection' ); + + $result = WP_Block_Metadata_Registry::register_collection( WP_PLUGIN_DIR . '/..', $this->temp_manifest_file ); + $this->assertFalse( $result, 'Plugin root parent path with a dot segment should not be registered' ); + } + + public function test_get_collection_block_metadata_files_preserves_unc_path_prefix() { + $path = '//server/share/prefix-plugin/blocks'; + $manifest_data = array( + 'test-block' => array( + 'name' => 'test-block', + 'title' => 'Test Block', + ), + ); + + file_put_contents( $this->temp_manifest_file, 'assertTrue( WP_Block_Metadata_Registry::register_collection( $path . '/.', $this->temp_manifest_file ) ); + $this->assertSame( + array( $path . '/test-block/block.json' ), + WP_Block_Metadata_Registry::get_collection_block_metadata_files( $path ) + ); + } + + public function test_get_collection_block_metadata_files_preserves_stream_wrapper_prefix() { + $path = 'file://block-metadata-registry/blocks'; + $manifest_data = array( + 'test-block' => array( + 'name' => 'test-block', + 'title' => 'Test Block', + ), + ); + + file_put_contents( $this->temp_manifest_file, 'assertTrue( WP_Block_Metadata_Registry::register_collection( $path . '/.', $this->temp_manifest_file ) ); + $this->assertSame( + array( $path . '/test-block/block.json' ), + WP_Block_Metadata_Registry::get_collection_block_metadata_files( $path ) + ); + } + public function test_has_metadata() { $path = WP_PLUGIN_DIR . '/another/test/path'; $manifest_data = array( diff --git a/tests/phpunit/tests/blocks/wpBlockType.php b/tests/phpunit/tests/blocks/wpBlockType.php index a73efa8ce8a7d..3e69b67d965db 100644 --- a/tests/phpunit/tests/blocks/wpBlockType.php +++ b/tests/phpunit/tests/blocks/wpBlockType.php @@ -528,9 +528,9 @@ public function test_variations_callback_are_lazy_loaded() { ) ); - $this->assertSame( false, $callback_called, 'The callback should not be called before the variations are accessed.' ); + $this->assertFalse( $callback_called, 'The callback should not be called before the variations are accessed.' ); $block_type->variations; // access the variations. - $this->assertSame( true, $callback_called, 'The callback should be called when the variations are accessed.' ); + $this->assertTrue( $callback_called, 'The callback should be called when the variations are accessed.' ); } /** @@ -555,7 +555,7 @@ public function test_variations_precedence_over_callback_post_registration() { // If the variations are defined after registration but before first access, the callback should not override it. $this->assertSameSets( $test_variations, $block_type->get_variations(), 'Variations are same as variations set' ); - $this->assertSame( false, $callback_called, 'The callback was never called.' ); + $this->assertFalse( $callback_called, 'The callback was never called.' ); } /** @@ -617,7 +617,7 @@ public function test_get_block_type_variations_filter_with_variation_callback() $obtained_variations = $block_type->variations; // access the variations. - $this->assertSame( true, $callback_called, 'The callback should be called when the variations are accessed.' ); + $this->assertTrue( $callback_called, 'The callback should be called when the variations are accessed.' ); $this->assertSameSets( $obtained_variations, $expected_variations, 'The variations obtained from the callback should be filtered.' ); } diff --git a/tests/phpunit/tests/comment/wpNotifyNoteMentions.php b/tests/phpunit/tests/comment/wpNotifyNoteMentions.php new file mode 100644 index 0000000000000..f8e1eddc75293 --- /dev/null +++ b/tests/phpunit/tests/comment/wpNotifyNoteMentions.php @@ -0,0 +1,455 @@ +, + * subject: string, + * message: string, + * headers: list, + * }> + */ + private array $sent = array(); + + /** + * Captured wp_mail() recipients for the current test. + * + * @var list + */ + private array $sent_to = array(); + + /** + * Sets up shared fixtures. + * + * @param WP_UnitTest_Factory $factory Factory. + */ + public static function wpSetUpBeforeClass( WP_UnitTest_Factory $factory ) { + self::$post_author = $factory->user->create_and_get( array( 'role' => 'editor' ) ); + self::$commenter = $factory->user->create_and_get( array( 'role' => 'editor' ) ); + self::$mentioned = $factory->user->create_and_get( array( 'role' => 'editor' ) ); + + self::$post = $factory->post->create_and_get( array( 'post_author' => self::$post_author->ID ) ); + } + + public function set_up() { + parent::set_up(); + $this->sent = array(); + $this->sent_to = array(); + // Short-circuit wp_mail() and record what would have been sent. + add_filter( 'pre_wp_mail', array( $this, 'capture_mail' ), 10, 2 ); + } + + /** + * Records wp_mail() calls and short-circuits delivery. + * + * @param null $short_circuit Short-circuit value. + * @param array $atts wp_mail() arguments. + * @return bool Always true to indicate a "sent" message. + * + * @phpstan-param array{ + * to: non-falsy-string|list, + * subject: string, + * message: string, + * headers: string|list, + * ... + * } $atts + * @phpstan-return true + */ + public function capture_mail( $short_circuit, array $atts ): bool { + $to = (array) $atts['to']; + + $this->sent[] = array( + 'to' => $to, + 'subject' => $atts['subject'], + 'message' => $atts['message'], + 'headers' => (array) $atts['headers'], + ); + + foreach ( $to as $recipient ) { + $this->sent_to[] = $recipient; + } + + return true; + } + + /** + * Builds a note comment for the shared post. + * + * @param string $content Note content. + * @param int $user_id Author user ID. + * @param int $parent_id Parent note ID (0 for a top-level note). + * @return WP_Comment The inserted note. + */ + private function insert_note( string $content, int $user_id, int $parent_id = 0 ): WP_Comment { + $comment = self::factory()->comment->create_and_get( + array( + 'comment_post_ID' => self::$post->ID, + 'comment_type' => 'note', + 'comment_content' => $content, + 'comment_parent' => $parent_id, + 'user_id' => $user_id, + ) + ); + assert( $comment instanceof WP_Comment ); + return $comment; + } + + /** + * Builds the stored markup for a mention of the given user. + * + * @param int $user_id User ID to mention. + * @param string $label Optional. The mention's visible text. + * @return string The mention chip markup. + */ + private function get_mention_markup( int $user_id, string $label = '@Mentioned' ): string { + return sprintf( '%s', $user_id, $label ); + } + + /** + * @ticket 65639 + * + * @covers ::wp_get_note_mentioned_user_ids + */ + public function test_parses_mentioned_user_ids() { + $content = '

    Hi @Jane and ' + . '@Bob.

    '; + + $this->assertSame( array( 5, 9 ), wp_get_note_mentioned_user_ids( $content ) ); + } + + /** + * @ticket 65639 + * + * @covers ::wp_get_note_mentioned_user_ids + */ + public function test_ignores_non_mentions_and_deduplicates() { + $content = '

    not a mention ' + . 'an anchor, not a chip ' + . '@Jane ' + . '@Jane again ' + . 'no user class

    '; + + $this->assertSame( array( 5 ), wp_get_note_mentioned_user_ids( $content ) ); + } + + /** + * @ticket 65639 + * + * @covers ::wp_send_note_notification + */ + public function test_mentioned_user_is_emailed() { + $note = $this->insert_note( + 'Ping ' . $this->get_mention_markup( self::$mentioned->ID ), + self::$commenter->ID + ); + + wp_notify_note_mentions( $note ); + + $this->assertContains( self::$mentioned->user_email, $this->sent_to ); + } + + /** + * @ticket 65639 + * + * @covers ::wp_send_note_notification + */ + public function test_email_contains_context_and_editor_link() { + /* + * The editor link comes from get_edit_post_link(), which is scoped to + * the current user; in the REST flow that is the note's author. + */ + wp_set_current_user( self::$commenter->ID ); + + $note = $this->insert_note( + '

    Please review ' . $this->get_mention_markup( self::$mentioned->ID, '@Reviewer' ) . '

    ', + self::$commenter->ID + ); + + wp_notify_note_mentions( $note ); + + $this->assertCount( 1, $this->sent ); + $email = $this->sent[0]; + + $this->assertStringContainsString( 'You were mentioned in a note', $email['subject'] ); + // The note text is included, stripped of markup. + $this->assertStringContainsString( 'Please review @Reviewer', $email['message'] ); + $this->assertStringNotContainsString( 'ID, 'url' ); + $this->assertIsString( $edit_link ); + $this->assertStringContainsString( + $edit_link, + $email['message'] + ); + } + + /** + * The editor link is composed for the recipient, not for whoever happens to + * be current, so it survives contexts with no logged-in user such as WP-Cron. + * + * @ticket 65639 + * + * @covers ::wp_send_note_notification + */ + public function test_editor_link_is_built_for_the_recipient() { + wp_set_current_user( 0 ); + + $note = $this->insert_note( + $this->get_mention_markup( self::$mentioned->ID ), + self::$commenter->ID + ); + + wp_notify_note_mentions( $note ); + + $this->assertCount( 1, $this->sent ); + + // The switch is temporary; the caller's context is left as it was found. + $this->assertSame( 0, get_current_user_id() ); + + wp_set_current_user( self::$mentioned->ID ); + $edit_link = get_edit_post_link( self::$post->ID, 'url' ); + $this->assertIsString( $edit_link ); + $this->assertStringContainsString( $edit_link, $this->sent[0]['message'] ); + } + + /** + * @ticket 65639 + * + * @covers ::wp_send_note_notification + */ + public function test_email_is_sent_as_plain_text() { + $note = $this->insert_note( + $this->get_mention_markup( self::$mentioned->ID ), + self::$commenter->ID + ); + + wp_notify_note_mentions( $note ); + + $this->assertCount( 1, $this->sent ); + $this->assertStringContainsString( 'Content-Type: text/plain', implode( "\n", $this->sent[0]['headers'] ) ); + } + + /** + * The post title is escaped on the way into the database, so it is decoded + * exactly once for the plain text email. Decoding twice resolves entities + * the author meant to be read literally. + * + * @ticket 65639 + * + * @covers ::wp_send_note_notification + */ + public function test_email_subject_decodes_the_post_title_once() { + // Stored form of the literal title "Tom & Jerry". + add_filter( + 'the_title', + static function () { + return 'Tom &amp; Jerry'; + } + ); + + $note = $this->insert_note( + $this->get_mention_markup( self::$mentioned->ID ), + self::$commenter->ID + ); + + wp_notify_note_mentions( $note ); + + $this->assertCount( 1, $this->sent ); + $this->assertStringContainsString( 'Tom & Jerry', $this->sent[0]['subject'] ); + $this->assertStringNotContainsString( 'Tom & Jerry', $this->sent[0]['subject'] ); + } + + /** + * Note content is stored as HTML, so markup is stripped before entities are + * decoded. Decoding first would turn escaped text into tags and strip it. + * + * @ticket 65639 + * + * @covers ::wp_send_note_notification + */ + public function test_email_keeps_escaped_markup_in_the_note_text() { + $note = $this->insert_note( + '

    Use <code> tags ' . $this->get_mention_markup( self::$mentioned->ID ) . '

    ', + self::$commenter->ID + ); + + wp_notify_note_mentions( $note ); + + $this->assertCount( 1, $this->sent ); + $this->assertStringContainsString( 'Use tags', $this->sent[0]['message'] ); + } + + /** + * @ticket 65639 + */ + public function test_author_is_not_notified_about_their_own_note() { + $note = $this->insert_note( + 'Note to ' . $this->get_mention_markup( self::$commenter->ID, '@Me' ), + self::$commenter->ID + ); + + wp_notify_note_mentions( $note ); + + $this->assertNotContains( self::$commenter->user_email, $this->sent_to ); + } + + /** + * @ticket 65639 + */ + public function test_post_author_is_left_to_the_postauthor_notification() { + $note = $this->insert_note( + 'Hey ' . $this->get_mention_markup( self::$post_author->ID, '@Author' ), + self::$commenter->ID + ); + + wp_notify_note_mentions( $note ); + + /* + * wp_new_comment_via_rest_notify_postauthor() notifies the post author + * of every note; the mention path must not also email them or they + * would receive a duplicate. + */ + $this->assertNotContains( self::$post_author->user_email, $this->sent_to ); + } + + /** + * @ticket 65639 + */ + public function test_mentioned_user_without_note_access_is_not_emailed() { + $subscriber = self::factory()->user->create_and_get( array( 'role' => 'subscriber' ) ); + $this->assertInstanceOf( WP_User::class, $subscriber ); + + $note = $this->insert_note( + 'Ping ' . $this->get_mention_markup( $subscriber->ID, '@Subscriber' ), + self::$commenter->ID + ); + + wp_notify_note_mentions( $note ); + + /* + * Notes are only readable by users who can edit them; a subscriber + * cannot, so emailing them would leak content they cannot see. + */ + $this->assertNotContains( $subscriber->user_email, $this->sent_to ); + } + + /** + * @ticket 65639 + */ + public function test_mentioning_a_nonexistent_user_sends_nothing() { + $note = $this->insert_note( + 'Ghost ' . $this->get_mention_markup( 999999, '@Ghost' ), + self::$commenter->ID + ); + + wp_notify_note_mentions( $note ); + + $this->assertEmpty( $this->sent_to ); + } + + /** + * @ticket 65639 + */ + public function test_no_notifications_when_disabled() { + update_option( 'wp_notes_notify', 0 ); + + $note = $this->insert_note( + 'Ping ' . $this->get_mention_markup( self::$mentioned->ID ), + self::$commenter->ID + ); + + wp_notify_note_mentions( $note ); + + $this->assertEmpty( $this->sent_to ); + } + + /** + * @ticket 65639 + */ + public function test_editing_a_note_does_not_renotify() { + $note = $this->insert_note( + 'Ping ' . $this->get_mention_markup( self::$mentioned->ID ), + self::$commenter->ID + ); + + // Simulate the update path of rest_insert_comment ( $creating false ). + wp_notify_note_mentions( $note, null, false ); + + $this->assertEmpty( $this->sent_to ); + } + + /** + * Creating a note through the REST endpoint must trigger the mention email. + * + * This exercises the `rest_insert_comment` wiring (hook name, priority and + * argument count), which the direct calls above bypass. + * + * @ticket 65639 + */ + public function test_rest_note_creation_triggers_mention_email() { + wp_set_current_user( self::$commenter->ID ); + + $request = new WP_REST_Request( 'POST', '/wp/v2/comments' ); + $request->set_param( 'post', self::$post->ID ); + $request->set_param( 'type', 'note' ); + $request->set_param( 'content', 'Ping ' . $this->get_mention_markup( self::$mentioned->ID ) ); + + $response = rest_get_server()->dispatch( $request ); + + $this->assertSame( 201, $response->get_status() ); + $this->assertContains( self::$mentioned->user_email, $this->sent_to ); + } + + /** + * Updating a note through the REST endpoint must not re-notify. + * + * @ticket 65639 + */ + public function test_rest_note_update_does_not_renotify() { + $note = $this->insert_note( + 'Ping ' . $this->get_mention_markup( self::$mentioned->ID ), + self::$commenter->ID + ); + + wp_set_current_user( self::$commenter->ID ); + + $request = new WP_REST_Request( 'PUT', '/wp/v2/comments/' . $note->comment_ID ); + $request->set_param( 'content', 'Edited ping ' . $this->get_mention_markup( self::$mentioned->ID ) ); + + $response = rest_get_server()->dispatch( $request ); + + $this->assertSame( 200, $response->get_status() ); + $this->assertNotContains( self::$mentioned->user_email, $this->sent_to ); + } +} diff --git a/tests/phpunit/tests/dependencies/scripts.php b/tests/phpunit/tests/dependencies/scripts.php index bbf301fe424cd..1fabd1dbe4f35 100644 --- a/tests/phpunit/tests/dependencies/scripts.php +++ b/tests/phpunit/tests/dependencies/scripts.php @@ -557,9 +557,8 @@ public function get_data_to_filter_eligible_strategies() { * * @dataProvider get_data_to_filter_eligible_strategies * - * @param callable $set_up Set up. - * @param bool $async_only Async only. - * @param bool $expected Expected return value. + * @param callable $set_up Set up. + * @param string[] $expected Expected return value. */ public function test_filter_eligible_strategies( $set_up, $expected ) { $handle = $set_up(); diff --git a/tests/phpunit/tests/filesystem/wpFilesystemDirect/chmod.php b/tests/phpunit/tests/filesystem/wpFilesystemDirect/chmod.php index fef30631730e0..816614c7ff5e8 100644 --- a/tests/phpunit/tests/filesystem/wpFilesystemDirect/chmod.php +++ b/tests/phpunit/tests/filesystem/wpFilesystemDirect/chmod.php @@ -101,4 +101,36 @@ public function test_should_change_mode_recursively(): void { 'The mode was not applied to a file in a nested subdirectory.' ); } + + /** + * Tests that `WP_Filesystem_Direct::chmod()` uses the correct mask for comparing permissions. + * + * The `& 0777` mask should be used to strip the filetype bits from the `fileperms( $file )` value + * so that the current permission bits can be used for comparison with the requested mode. + * + * @ticket 65695 + */ + public function test_should_use_correct_permission_mask_for_files(): void { + if ( self::is_windows() ) { + $this->markTestSkipped( 'chmod() does not support octal modes on Windows.' ); + } + + $file = self::$file_structure['visible_file']['path']; + + // Set the initial permissions. + self::$filesystem->chmod( $file, 0600 ); + + $this->assertTrue( + self::$filesystem->chmod( $file, 0644 ), + 'chmod() did not report success.' + ); + + clearstatcache(); + + $this->assertSame( + '644', + self::$filesystem->getchmod( $file ), + 'The requested mode was not applied to the file.' + ); + } } diff --git a/tests/phpunit/tests/filesystem/wpFilesystemDirect/delete.php b/tests/phpunit/tests/filesystem/wpFilesystemDirect/delete.php index 4afe54890a5f3..2bc3087f42663 100644 --- a/tests/phpunit/tests/filesystem/wpFilesystemDirect/delete.php +++ b/tests/phpunit/tests/filesystem/wpFilesystemDirect/delete.php @@ -69,7 +69,7 @@ public function test_should_delete_a_directory_with_contents() { * * @dataProvider data_should_delete_a_file * - * @param string $key The key for the file in `self::$filesystem_structure`. + * @param string $file The key for the file in `self::$file_structure`. */ public function test_should_delete_a_file( $file ) { $file = self::$file_structure[ $file ]['path'] . $file; diff --git a/tests/phpunit/tests/filesystem/wpFilesystemDirect/isDir.php b/tests/phpunit/tests/filesystem/wpFilesystemDirect/isDir.php index 1a367851f6a78..c23e301485c15 100644 --- a/tests/phpunit/tests/filesystem/wpFilesystemDirect/isDir.php +++ b/tests/phpunit/tests/filesystem/wpFilesystemDirect/isDir.php @@ -35,8 +35,6 @@ public function test_should_determine_that_a_path_is_a_directory() { * @dataProvider data_should_determine_that_a_path_is_not_a_directory * * @param string $path The path to check. - * @param string $type The type of resource. Accepts 'f' or 'd'. - * Used to invert $expected due to data provider setup. */ public function test_should_determine_that_a_path_is_not_a_directory( $path ) { $this->assertFalse( self::$filesystem->is_dir( self::$file_structure['test_dir']['path'] . $path ) ); diff --git a/tests/phpunit/tests/fonts/font-face/wp-font-face-tests-dataset.php b/tests/phpunit/tests/fonts/font-face/wp-font-face-tests-dataset.php index d410acb7c4124..c0d7f9e328016 100644 --- a/tests/phpunit/tests/fonts/font-face/wp-font-face-tests-dataset.php +++ b/tests/phpunit/tests/fonts/font-face/wp-font-face-tests-dataset.php @@ -308,11 +308,7 @@ public function get_expected_fonts_for_fonts_block_theme( $key = '' ) { ); } - if ( isset( $data[ $key ] ) ) { - return $data[ $key ]; - } - - return $data; + return $data[ $key ] ?? $data; } public static function get_custom_font_families( $key = '' ) { @@ -397,11 +393,7 @@ public static function get_custom_font_families( $key = '' ) { ); } - if ( isset( $data[ $key ] ) ) { - return $data[ $key ]; - } - - return $data; + return $data[ $key ] ?? $data; } public static function get_custom_style_variations( $key = '' ) { @@ -488,10 +480,6 @@ public static function get_custom_style_variations( $key = '' ) { ); } - if ( isset( $data[ $key ] ) ) { - return $data[ $key ]; - } - - return $data; + return $data[ $key ] ?? $data; } } diff --git a/tests/phpunit/tests/fonts/font-face/wpPrintFontFaces.php b/tests/phpunit/tests/fonts/font-face/wpPrintFontFaces.php index a1fea3ac948e2..7dd7a09e5134c 100644 --- a/tests/phpunit/tests/fonts/font-face/wpPrintFontFaces.php +++ b/tests/phpunit/tests/fonts/font-face/wpPrintFontFaces.php @@ -59,6 +59,27 @@ public function test_should_escape_tags() { @font-face{font-family:"Source Serif Pro";font-style:normal;font-weight:200 900;font-display:fallback;src:url('http://example.com/assets/source-serif-pro/SourceSerif4Variable-Roman.ttf.woff2') format('woff2');font-stretch:;} +CSS; + + $output = get_echo( 'wp_print_font_faces', array( $fonts ) ); + $this->assertEqualHTML( $expected_output, $output ); + } + + public function test_should_escape_css_delimiters() { + $fonts = array( + 'Fuzz' => array( + array( + 'src' => array( "http://example.com/assets/fonts/a');color:red;/*.woff2" ), + 'font-family' => 'Fuzz";color:red;/*', + ), + ), + ); + + $expected_output = <<<'CSS' + + CSS; $output = get_echo( 'wp_print_font_faces', array( $fonts ) ); diff --git a/tests/phpunit/tests/formatting/isEmail.php b/tests/phpunit/tests/formatting/isEmail.php index b793af2c4a70d..24cee87dde0c3 100644 --- a/tests/phpunit/tests/formatting/isEmail.php +++ b/tests/phpunit/tests/formatting/isEmail.php @@ -36,6 +36,8 @@ public static function data_valid_email_provider() { 'phil@TLA.example', 'ace@204.32.222.14', 'kevin@many.subdomains.make.a.happy.man.edu', + 'a@b', + 'first.last@example', 'a@b.co', 'bill+ted@example.com', '..@example.com', @@ -90,17 +92,6 @@ public static function data_invalid_email_provider() { */ '(ab)cd@couc.ou', - /* - * The next address is not globally deliverable, - * so it may work with PHPMailer and break with - * mail sending services. Best not allow users - * to paint themselves into that corner. This also - * avoids security problems like those that were - * used to probe the WordPress server's local - * network. - */ - 'toto@to', - /* * Several addresses are best rejected because * we don't want to allow sending to fe80::, 192.168 diff --git a/tests/phpunit/tests/formatting/makeClickable.php b/tests/phpunit/tests/formatting/makeClickable.php index 5e4c62a1494ef..e7492e5ae3ba0 100644 --- a/tests/phpunit/tests/formatting/makeClickable.php +++ b/tests/phpunit/tests/formatting/makeClickable.php @@ -32,6 +32,7 @@ public function data_valid_mailto() { array( 'Foo.Bar@a.b.c.d.example.com' ), array( '0@example.com' ), array( 'foo@example-example.com' ), + array( 'mail@xn--fsqu00a.xn--4rr70v' ), ); } @@ -57,6 +58,7 @@ public function data_invalid_mailto() { array( '@example.com' ), array( 'foo @example.com' ), array( 'foo@example' ), + array( 'mail@example.xn--' ), ); } diff --git a/tests/phpunit/tests/formatting/sanitizeFileName.php b/tests/phpunit/tests/formatting/sanitizeFileName.php index d0e366f121a5a..5907c6ef31284 100644 --- a/tests/phpunit/tests/formatting/sanitizeFileName.php +++ b/tests/phpunit/tests/formatting/sanitizeFileName.php @@ -95,6 +95,7 @@ public function data_wp_filenames() { return array( array( urldecode( '%B1myfile.png' ), 'myfile.png' ), array( urldecode( '%B1myfile' ), 'myfile' ), + array( hex2bin( '2e2e5c62797465732dfe882d07' ), '' ), array( 'demo bar.png', 'demo-bar.png' ), array( 'demo' . json_decode( '"\u00a0"' ) . 'bar.png', 'demo-bar.png' ), ); diff --git a/tests/phpunit/tests/functions/allowedProtocols.php b/tests/phpunit/tests/functions/allowedProtocols.php index 4d4ecf5320e14..63ca3ab0945ce 100644 --- a/tests/phpunit/tests/functions/allowedProtocols.php +++ b/tests/phpunit/tests/functions/allowedProtocols.php @@ -27,8 +27,8 @@ public function test_allowed_protocol_has_an_example() { * @depends test_allowed_protocol_has_an_example * @dataProvider data_example_urls * - * @param string The scheme. - * @param string Example URL. + * @param string $protocol The scheme. + * @param string $url Example URL. */ public function test_allowed_protocols( $protocol, $url ) { $this->assertSame( $url, esc_url( $url, $protocol ) ); diff --git a/tests/phpunit/tests/functions/doEnclose.php b/tests/phpunit/tests/functions/doEnclose.php index 6d36fd373a779..425b28c9dd6d0 100644 --- a/tests/phpunit/tests/functions/doEnclose.php +++ b/tests/phpunit/tests/functions/doEnclose.php @@ -221,8 +221,8 @@ public function test_function_enclosure_links_should_be_filterable() { * * @since 5.3.0 * - * @param array $post_links An array of enclosure links. - * @param int $post_id Post ID. + * @param array $enclosure_links An array of enclosure links. + * @param int $post_id Post ID. * @return array An array of enclosure links. */ public function filter_enclosure_links( $enclosure_links, $post_id ) { diff --git a/tests/phpunit/tests/functions/wpPrivacyExportsDir.php b/tests/phpunit/tests/functions/wpPrivacyExportsDir.php new file mode 100644 index 0000000000000..ddf068e4d8475 --- /dev/null +++ b/tests/phpunit/tests/functions/wpPrivacyExportsDir.php @@ -0,0 +1,43 @@ +assertSame( $expected, wp_privacy_exports_dir() ); + } + + /** + * @ticket 59710 + */ + public function test_wp_privacy_exports_dir_filtered() { + add_filter( 'wp_privacy_exports_dir', array( $this, 'filter_wp_privacy_exports_dir' ) ); + + $upload_dir = wp_upload_dir(); + $expected_dir = trailingslashit( $upload_dir['basedir'] ) . 'filtered-exports/'; + $actual_dir = wp_privacy_exports_dir(); + $this->assertSame( $expected_dir, $actual_dir ); + + remove_filter( 'wp_privacy_exports_dir', array( $this, 'filter_wp_privacy_exports_dir' ) ); + } + + /** + * Filters the personal data exports directory for tests. + * + * @param string $exports_dir Default exports directory. + * @return string Filtered exports directory. + */ + public function filter_wp_privacy_exports_dir( $exports_dir ) { + return str_replace( 'wp-personal-data-exports/', 'filtered-exports/', $exports_dir ); + } +} diff --git a/tests/phpunit/tests/functions/wpPrivacyExportsUrl.php b/tests/phpunit/tests/functions/wpPrivacyExportsUrl.php new file mode 100644 index 0000000000000..6891640d172b0 --- /dev/null +++ b/tests/phpunit/tests/functions/wpPrivacyExportsUrl.php @@ -0,0 +1,40 @@ +assertSame( trailingslashit( $upload_dir['baseurl'] ) . 'wp-personal-data-exports/', wp_privacy_exports_url() ); + } + + /** + * @ticket 59709 + */ + public function test_wp_privacy_exports_url_filtered() { + add_filter( 'wp_privacy_exports_url', array( $this, 'filter_wp_privacy_exports_url' ) ); + + $upload_dir = wp_upload_dir(); + $expected_url = trailingslashit( $upload_dir['baseurl'] ) . 'filtered-exports/'; + $actual_url = wp_privacy_exports_url(); + $this->assertSame( $expected_url, $actual_url ); + } + + /** + * Filters the personal data exports directory URL for tests. + * + * @param string $exports_url Default exports directory URL. + * @return string Filtered exports directory URL. + */ + public function filter_wp_privacy_exports_url( $exports_url ) { + return str_replace( 'wp-personal-data-exports/', 'filtered-exports/', $exports_url ); + } +} diff --git a/tests/phpunit/tests/general/template.php b/tests/phpunit/tests/general/template.php index 00b683971a6a0..a83b69dc5d556 100644 --- a/tests/phpunit/tests/general/template.php +++ b/tests/phpunit/tests/general/template.php @@ -138,6 +138,49 @@ public function test_get_site_icon_url_returns_fallback_when_attachment_url_fail $this->assertSame( $fallback, $url, 'Fallback URL should be returned when attachment URL lookup fails.' ); } + /** + * Ensures the site icon URL scheme is upgraded for the current request, but never downgraded. + * + * The site icon is display chrome that also renders in wp-admin and on the + * login screen, where wp_get_attachment_image_url() does not correct the scheme. + * + * On an HTTPS request with an http:// siteurl the icon must still be served + * over HTTPS to avoid a broken, mixed-content image. + * + * @ticket 65696 + * + * @group site_icon + * + * @covers ::get_site_icon_url + * + * @requires function imagejpeg + */ + public function test_get_site_icon_url_scheme() { + $this->set_site_icon(); + + set_current_screen( 'dashboard' ); + $this->assertTrue( is_admin(), 'Test should run in the admin context.' ); + $this->assertFalse( is_ssl(), 'Baseline request should not be detected as SSL.' ); + $this->assertStringStartsWith( 'http://', get_site_icon_url(), 'Baseline icon URL should use the HTTP scheme.' ); + + $_SERVER['HTTPS'] = 'on'; + $this->assertTrue( is_ssl(), 'Request should now be detected as SSL.' ); + $this->assertStringStartsWith( 'https://', get_site_icon_url(), 'Site icon URL should use the HTTPS scheme on an SSL admin request.' ); + + add_filter( + 'upload_dir', + static function ( $uploads ) { + $uploads['url'] = set_url_scheme( $uploads['url'], 'https' ); + $uploads['baseurl'] = set_url_scheme( $uploads['baseurl'], 'https' ); + return $uploads; + } + ); + + unset( $_SERVER['HTTPS'] ); + $this->assertFalse( is_ssl(), 'Request should no longer be detected as SSL.' ); + $this->assertStringStartsWith( 'https://', get_site_icon_url(), 'Site icon URL should preserve the HTTPS scheme on a non-SSL request.' ); + } + /** * @group site_icon * @covers ::site_icon_url diff --git a/tests/phpunit/tests/html-api/wpHtmlTagProcessor.php b/tests/phpunit/tests/html-api/wpHtmlTagProcessor.php index 84d90a84190fc..66e01dbdbed3e 100644 --- a/tests/phpunit/tests/html-api/wpHtmlTagProcessor.php +++ b/tests/phpunit/tests/html-api/wpHtmlTagProcessor.php @@ -464,6 +464,214 @@ public function test_get_attribute_names_with_prefix_returns_attribute_added_by_ ); } + /** + * Ensures that a new attribute added via set_attribute() is reported by + * get_attribute_names_with_prefix() immediately after being added. + * + * @ticket 64567 + * + * @covers WP_HTML_Tag_Processor::get_attribute_names_with_prefix + */ + public function test_get_attribute_names_with_prefix_immediately_reflects_new_attributes() { + $processor = new WP_HTML_Tag_Processor( '
    ' ); + $processor->next_tag(); + + $this->assertSame( + array( 'existing' ), + $processor->get_attribute_names_with_prefix( '' ), + 'Failed to report existing attribute names: check test setup.' + ); + + $processor->set_attribute( 'new', true ); + + $this->assertSame( + array( 'new', 'existing' ), + $processor->get_attribute_names_with_prefix( '' ), + 'Failed to report newly-added attribute.' + ); + + $this->assertSame( + array( 'existing' ), + $processor->get_attribute_names_with_prefix( 'exist' ), + 'Should have only reported existing attribute matching given prefix.' + ); + } + + /** + * Ensures that changes enqueued for modifiable text do not get reported as added attributes. + * + * This is a fairly-specific test against an internal implementation detail, but is worth + * adding to catch potential regressions since modifiable text updates share a namespace with + * attribute updates. + * + * @ticket 64567 + * + * @covers WP_HTML_Tag_Processor::get_attribute_names_with_prefix + */ + public function test_get_attribute_names_with_prefix_ignores_immediately_added_modifiable_text() { + $processor = new WP_HTML_Tag_Processor( '' ); + $processor->next_tag(); + + $this->assertSame( + array( 'existing' ), + $processor->get_attribute_names_with_prefix( '' ), + 'Failed to report existing attribute names: check test setup.' + ); + + $processor->set_modifiable_text( 'content!' ); + + $this->assertSame( + array( 'existing' ), + $processor->get_attribute_names_with_prefix( '' ), + 'Failed to report that the attributes were unchanged.' + ); + } + + /** + * Ensures that an attribute removed via remove_attribute() is no longer reported + * by get_attribute_names_with_prefix() immediately after being removed. + * + * @ticket 64567 + * + * @covers WP_HTML_Tag_Processor::get_attribute_names_with_prefix + */ + public function test_get_attribute_names_with_prefix_immediately_reflects_removed_attributes() { + $processor = new WP_HTML_Tag_Processor( '
    ' ); + $processor->next_tag(); + + $this->assertSame( + array( 'existing', 'data-removed' ), + $processor->get_attribute_names_with_prefix( '' ), + 'Failed to report all existing attributes: check test setup.' + ); + + $processor->remove_attribute( 'data-removed' ); + + $this->assertSame( + array(), + $processor->get_attribute_names_with_prefix( 'data-' ), + 'Expected no custom data attributes after removing the only one.' + ); + + $this->assertSame( + array( 'existing' ), + $processor->get_attribute_names_with_prefix( '' ), + 'Expected to report only the attribute which wasn’t removed.' + ); + } + + /** + * Ensures that when the `class` attribute is newly added via add_class(), that + * it’s reported by get_attribute_names_with_prefix() immediately after being added. + * + * @ticket 64567 + * + * @covers WP_HTML_Tag_Processor::get_attribute_names_with_prefix + */ + public function test_get_attribute_names_with_prefix_immediately_reflects_class_after_adding_classes() { + $processor = new WP_HTML_Tag_Processor( '
    ' ); + $processor->next_tag(); + + $this->assertSame( + array( 'existing' ), + $processor->get_attribute_names_with_prefix( '' ), + 'Expected to only report the existing attribute: check test setup.' + ); + + $processor->add_class( 'added' ); + + $this->assertSame( + array( 'class' ), + $processor->get_attribute_names_with_prefix( 'class' ), + 'Failed to report the newly-added `class` attribute.' + ); + } + + /** + * Ensures that when the `class` attribute is emptied via remove_class(), that + * it’s reported by get_attribute_names_with_prefix() immediately after being added. + * + * @ticket 64567 + * + * @covers WP_HTML_Tag_Processor::get_attribute_names_with_prefix + */ + public function test_get_attribute_names_with_prefix_immediately_reflects_class_after_removing_all_classes() { + $processor = new WP_HTML_Tag_Processor( '
    ' ); + $processor->next_tag(); + + $this->assertSame( + array( 'class', 'existing' ), + $processor->get_attribute_names_with_prefix( '' ), + 'Expected to find proper existing attributes: check test setup.' + ); + + $processor->remove_class( 'red' ); + + $this->assertSame( + array( 'class', 'existing' ), + $processor->get_attribute_names_with_prefix( '' ), + 'Should have the same attributes after removing one of two classes.' + ); + + $processor->remove_class( 'green' ); + + $this->assertSame( + array( 'existing' ), + $processor->get_attribute_names_with_prefix( '' ), + 'Should have removed the `class` attribute after removing all class names.' + ); + } + + /** + * Ensures get_attribute_names_with_prefix() agrees with get_attribute() + * after pending updates, returning each name once with no stale entries. + * + * @ticket 64567 + * + * @covers WP_HTML_Tag_Processor::get_attribute_names_with_prefix + */ + public function test_get_attribute_names_with_prefix_immediately_agrees_with_get_attribute_after_updates() { + $processor = new WP_HTML_Tag_Processor( '
    Test
    ' ); + $processor->next_tag(); + + $this->assertSame( + array( 'data-keep', 'data-drop' ), + $processor->get_attribute_names_with_prefix( '' ), + 'Failed to find expected existing attributes: check test setup.' + ); + + $this->assertSame( + '1', + $processor->get_attribute( 'data-keep' ), + 'Failed to find expected existing `data-keep` attribute value: check test setup.' + ); + + $this->assertSame( + '2', + $processor->get_attribute( 'data-drop' ), + 'Failed to find expected existing `data-drop` attribute value: check test setup.' + ); + + $processor->set_attribute( 'data-keep', 'updated' ); + $processor->set_attribute( 'data-add', 'new' ); + $processor->remove_attribute( 'data-drop' ); + + $names = $processor->get_attribute_names_with_prefix( 'data-' ); + + $this->assertSame( + array( 'data-add', 'data-keep' ), + $names, + 'Failed to report the expected attribute names after removing and adding attributes.' + ); + + foreach ( $names as $name ) { + $this->assertNotNull( + $processor->get_attribute( $name ), + "get_attribute_names_with_prefix() reported '{$name}' but get_attribute() did not agree." + ); + } + } + /** * @ticket 56299 * diff --git a/tests/phpunit/tests/icons/wpIconsRegistry.php b/tests/phpunit/tests/icons/wpIconsRegistry.php index 42853d2666a95..f33906dcd119a 100644 --- a/tests/phpunit/tests/icons/wpIconsRegistry.php +++ b/tests/phpunit/tests/icons/wpIconsRegistry.php @@ -150,6 +150,32 @@ public function test_register_icon_twice() { $this->assertFalse( $this->registry->register( 'test-collection/duplicate', $settings ) ); } + /** + * Should sanitize inline SVG content when registering an icon. + * + * @ticket 64847 + * + * @covers WP_Icons_Registry::get_registered_icon + */ + public function test_register_sanitizes_inline_content() { + $name = 'test-collection/unsafe-content'; + $settings = array( + 'label' => 'Icon', + 'content' => '', + ); + + $result = $this->registry->register( $name, $settings ); + $icon = $this->registry->get_registered_icon( $name ); + + $this->assertTrue( $result ); + $this->assertIsArray( $icon ); + $this->assertStringContainsString( 'assertStringContainsString( 'M1 1h2v2z', $icon['content'] ); + $this->assertStringNotContainsString( 'assertStringNotContainsString( 'onload', $icon['content'] ); + $this->assertStringNotContainsString( 'onclick', $icon['content'] ); + } + /** * @ticket 64651 * diff --git a/tests/phpunit/tests/image/meta.php b/tests/phpunit/tests/image/meta.php index babd3a94bb3b8..b6a1849fae9ae 100644 --- a/tests/phpunit/tests/image/meta.php +++ b/tests/phpunit/tests/image/meta.php @@ -184,8 +184,8 @@ public function test_exif_keywords() { * @ticket 52826 * @ticket 52922 * - * @param string Stream's URI. - * @param array Expected metadata. + * @param string $file Stream's URI. + * @param array $expected Expected metadata. */ public function test_stream( $file, $expected ) { $actual = wp_read_image_metadata( $file ); diff --git a/tests/phpunit/tests/interactivity-api/wpInteractivityAPI-wp-bind.php b/tests/phpunit/tests/interactivity-api/wpInteractivityAPI-wp-bind.php index 02fc0d09293f7..1951919941a32 100644 --- a/tests/phpunit/tests/interactivity-api/wpInteractivityAPI-wp-bind.php +++ b/tests/phpunit/tests/interactivity-api/wpInteractivityAPI-wp-bind.php @@ -45,9 +45,9 @@ public function set_up() { * Invokes the `process_directives` method of WP_Interactivity_API class. * * @param string $html The HTML that needs to be processed. - * @return array An array containing an instance of the WP_HTML_Tag_Processor and the processed HTML. + * @return array{ 0: WP_HTML_Tag_Processor, 1: string } An array containing an instance of the WP_HTML_Tag_Processor and the processed HTML. */ - private function process_directives( $html ) { + private function process_directives( string $html ): array { $new_html = $this->interactivity->process_directives( $html ); $p = new WP_HTML_Tag_Processor( $new_html ); $p->next_tag(); @@ -93,6 +93,50 @@ public function test_wp_bind_sets_number_value() { $this->assertSame( '100', $p->get_attribute( 'width' ) ); } + /** + * Tests that a float value is formatted as a string when set as an attribute + * via `data-wp-bind`. + * + * @ticket 65740 + * + * @covers ::process_directives + */ + public function test_wp_bind_sets_float_value() { + $this->interactivity->state( 'myPlugin', array( 'ratio' => 1.5 ) ); + + $html = '
    Text
    '; + list($p, $new_html) = $this->process_directives( $html ); + $this->assertSame( '1.5', $p->get_attribute( 'data-ratio' ) ); + $this->assertSame( '
    Text
    ', $new_html ); + } + + /** + * Tests that a float value is not formatted with the locale's decimal separator. + * + * Casting a float to string is locale-dependent before PHP 8.0, whereas the + * client receives the number from the JSON-encoded store, which never is. + * + * @ticket 65740 + * + * @covers ::process_directives + */ + public function test_wp_bind_sets_float_value_independently_of_the_locale() { + $previous_locale = setlocale( LC_NUMERIC, '0' ); // Passing "0" queries the current setting without changing it. + if ( false === setlocale( LC_NUMERIC, 'de_DE.UTF-8', 'de_DE', 'de_DE@euro', 'German' ) ) { + $this->markTestSkipped( 'No locale with a comma decimal separator is available.' ); + } + + try { + $this->interactivity->state( 'myPlugin', array( 'ratio' => 1.5 ) ); + + $html = '
    Text
    '; + list($p) = $this->process_directives( $html ); + $this->assertSame( '1.5', $p->get_attribute( 'data-ratio' ) ); + } finally { + setlocale( LC_NUMERIC, false === $previous_locale ? 'C' : $previous_locale ); + } + } + /** * Tests that true strings are set properly as attribute values. * @@ -410,7 +454,7 @@ public function test_wp_bind_handles_nested_bindings() { public function test_wp_bind_handles_true_value() { $html = '
    '; list($p) = $this->process_directives( $html ); - $this->assertSame( true, $p->get_attribute( 'id' ) ); + $this->assertTrue( $p->get_attribute( 'id' ) ); } /** @@ -423,7 +467,7 @@ public function test_wp_bind_handles_true_value() { public function test_wp_bind_ignores_unique_ids() { $html = '
    '; list($p) = $this->process_directives( $html ); - $this->assertSame( true, $p->get_attribute( 'id' ) ); + $this->assertTrue( $p->get_attribute( 'id' ) ); $html = '
    '; list($p) = $this->process_directives( $html ); @@ -444,4 +488,505 @@ public function test_wp_bind_ignores_unique_id_but_processes_valid_binds() { list($p) = $this->process_directives( $html ); $this->assertSame( 'some-id', $p->get_attribute( 'id' ) ); } + + /** + * Data provider for float values which JSON cannot represent. + * + * @return array Data provider. + */ + public function data_non_finite_values(): array { + return array( + 'INF' => array( 'value' => INF ), + '-INF' => array( 'value' => -INF ), + 'NAN' => array( 'value' => NAN ), + ); + } + + /** + * Tests that `data-wp-bind` rejects INF and NAN. + * + * These are scalars, but a store holding one fails to encode in its + * entirety, so the client is sent no state at all rather than a value which + * merely disagrees with the server. + * + * @ticket 65740 + * + * @covers ::process_directives + * + * @dataProvider data_non_finite_values + * + * @expectedIncorrectUsage WP_Interactivity_API::data_wp_bind_processor + * + * @param float $value Non-finite value to bind. + */ + public function test_wp_bind_rejects_non_finite_value( $value ) { + $this->interactivity->state( 'myPlugin', array( 'nonFinite' => $value ) ); + + $html = '
    Text
    '; + list($p) = $this->process_directives( $html ); + $this->assertNull( $p->get_attribute( 'data-ratio' ), 'Expected no attribute to have been set for a value JSON cannot represent.' ); + $this->assertSame( + array( + 'WP_Interactivity_API::data_wp_bind_processor' => 'Attempted to bind a non-finite number to the "data-ratio" attribute. Ensure the state/context property or the derived state closure resolves to a finite number or a string. (This message was added in version 7.1.0.)', + ), + $this->caught_doing_it_wrong, + 'Expected _doing_it_wrong() to have been called once with the non-finite value message.' + ); + } + + /** + * Data provider for values a bound object may serialize to. + * + * @return array Data provider. + */ + public function data_json_serializable_values(): array { + return array( + 'string' => array( + 'value' => 'serialized-form', + 'expected' => 'serialized-form', + ), + 'integer' => array( + 'value' => 42, + 'expected' => '42', + ), + 'float' => array( + 'value' => 1.5, + 'expected' => '1.5', + ), + /* + * The JSON encoder keeps resolving a serializable object which serializes to another one, so the + * value bound on the server has to follow it all the way down to match what the client receives. + */ + 'nested' => array( + 'value' => $this->get_json_serializable( 'serialized-form' ), + 'expected' => 'serialized-form', + ), + ); + } + + /** + * Tests that an object is bound as whatever it serializes to for the client. + * + * @ticket 65740 + * + * @covers ::process_directives + * + * @dataProvider data_json_serializable_values + * + * @param mixed $value Value the object serializes to. + * @param string $expected Expected attribute value. + */ + public function test_wp_bind_sets_json_serializable_value( $value, string $expected ) { + $this->interactivity->state( 'myPlugin', array( 'serializable' => $this->get_json_serializable( $value ) ) ); + + $html = '
    Text
    '; + list($p) = $this->process_directives( $html ); + $this->assertSame( $expected, $p->get_attribute( 'id' ) ); + } + + /** + * Tests that the bound attribute value matches what the client is sent. + * + * This is what makes serializable objects safe to bind: the value rendered + * into the attribute is the same one the client store is hydrated with, so + * evaluating the directive again in the browser is a no-op. + * + * @ticket 65740 + * + * @covers ::process_directives + */ + public function test_wp_bind_json_serializable_value_matches_the_client_store() { + $this->interactivity->state( 'myPlugin', array( 'serializable' => $this->get_json_serializable( 'serialized-form' ) ) ); + + $html = '
    Text
    '; + list($p) = $this->process_directives( $html ); + + $data = $this->interactivity->filter_script_module_interactivity_data( array() ); + $encoded = wp_json_encode( $data['state'] ); + $this->assertIsString( $encoded, 'Expected the client state to be encodable as JSON.' ); + + $this->assertSame( 'serialized-form', $p->get_attribute( 'id' ) ); + $this->assertStringContainsString( + '"serializable":"serialized-form"', + $encoded, + 'Expected the rendered attribute value to match the value sent to the client.' + ); + } + + /** + * Tests that a bound object which cannot be serialized does not abort the render. + * + * `JsonSerializable::jsonSerialize()` is arbitrary code, so resolving an object + * through the JSON encoder can throw. A binding must not be able to take down the + * page, which is the whole point of checking the value at all. An exception + * escaping the directive processor would also leave the context and namespace + * stacks unrestored, breaking every later `process_directives()` call on the same + * instance. + * + * @ticket 65740 + * + * @covers ::process_directives + * + * @expectedIncorrectUsage WP_Interactivity_API::data_wp_bind_processor + */ + public function test_wp_bind_rejects_object_which_fails_to_serialize() { + $unserializable = new class() implements JsonSerializable { + /** + * Fails to produce a value for the client. + * + * @return mixed Never returns. + * @throws RuntimeException Always. + */ + #[\ReturnTypeWillChange] + public function jsonSerialize() { + throw new RuntimeException( 'This object cannot be serialized.' ); + } + }; + + $this->interactivity->state( + 'myPlugin', + array( + 'unserializable' => $unserializable, + 'id' => 'some-id', + ) + ); + + $html = '
    Text
    '; + list($p) = $this->process_directives( $html ); + $this->assertNull( $p->get_attribute( 'id' ), 'Expected no attribute to have been set for an object which cannot be serialized.' ); + $this->assertSame( + array( + 'WP_Interactivity_API::data_wp_bind_processor' => 'Attempted to bind a non-scalar value to the "id" attribute. Ensure the state/context property or the derived state closure resolves to a string, number, or boolean. (This message was added in version 7.1.0.)', + ), + $this->caught_doing_it_wrong, + 'Expected _doing_it_wrong() to have been called once with the non-scalar value message.' + ); + + // The stacks are restored for the next render only if no exception escaped. + $html = '
    Text
    '; + list($p) = $this->process_directives( $html ); + $this->assertSame( 'some-id', $p->get_attribute( 'id' ), 'Expected a later render on the same instance to be unaffected.' ); + } + + /** + * Tests that an object serializing to a value JSON cannot represent is rejected. + * + * The encoding does not fail here the way it does for a bare INF. When + * `json_encode()` rejects the value, `wp_json_encode()` retries with a plain + * object rebuilt from the public properties, which discards `jsonSerialize()` + * entirely and encodes to `{}`. The object therefore resolves to something + * non-scalar and is reported as such, rather than with the non-finite message. + * + * The store is rebuilt the same way, so the client is sent `{}` for this + * reference. The two still agree that there is no usable value here. + * + * @ticket 65740 + * + * @covers ::process_directives + * + * @expectedIncorrectUsage WP_Interactivity_API::data_wp_bind_processor + */ + public function test_wp_bind_rejects_object_serializing_to_a_non_finite_value() { + $this->interactivity->state( 'myPlugin', array( 'nonFinite' => $this->get_json_serializable( INF ) ) ); + + $html = '
    Text
    '; + list($p) = $this->process_directives( $html ); + $this->assertNull( $p->get_attribute( 'id' ), 'Expected no attribute to have been set.' ); + $this->assertSame( + array( + 'WP_Interactivity_API::data_wp_bind_processor' => 'Attempted to bind a non-scalar value to the "id" attribute. Ensure the state/context property or the derived state closure resolves to a string, number, or boolean. (This message was added in version 7.1.0.)', + ), + $this->caught_doing_it_wrong, + 'Expected the non-scalar message, since the object resolves to an empty object rather than failing to encode.' + ); + + $data = $this->interactivity->filter_script_module_interactivity_data( array() ); + $encoded = wp_json_encode( $data['state'] ); + $this->assertIsString( $encoded, 'Expected the client state to still be encodable as JSON.' ); + $this->assertStringContainsString( + '"nonFinite":{}', + $encoded, + 'Expected the client to be sent the same empty object the server resolved.' + ); + } + + /** + * Tests that an object serializing to null removes the attribute quietly. + * + * The object is resolved before the null check, so it reaches it as the null + * the client will receive, and is treated the same as a null value would be. + * There is nothing to report: null is a value the client can be sent. + * + * @ticket 65740 + * + * @covers ::process_directives + */ + public function test_wp_bind_removes_attribute_for_object_serializing_to_null() { + $this->interactivity->state( 'myPlugin', array( 'nothing' => $this->get_json_serializable( null ) ) ); + + $html = '
    Text
    '; + list($p, $new_html) = $this->process_directives( $html ); + $this->assertNull( $p->get_attribute( 'id' ), 'Expected the pre-existing attribute to have been removed.' ); + $this->assertEqualHTML( '
    Text
    ', $new_html ); + $this->assertSame( + array(), + $this->caught_doing_it_wrong, + 'Expected an object serializing to null to be treated as a null value, without reporting a usage error.' + ); + } + + /** + * Tests that an object serializing to a boolean keeps the boolean attribute + * semantics of the value it serializes to. + * + * Resolving the object first means the checks below it do not have to know an + * object was ever involved, so the existing handling composes. This asserts + * that it does. + * + * @ticket 65740 + * + * @covers ::process_directives + */ + public function test_wp_bind_applies_boolean_semantics_to_object_serializing_to_a_boolean() { + $this->interactivity->state( + 'myPlugin', + array( + 'yes' => $this->get_json_serializable( true ), + 'no' => $this->get_json_serializable( false ), + ) + ); + + // True sets a bare boolean attribute. + $html = '
    Text
    '; + list($p, $new_html) = $this->process_directives( $html ); + $this->assertTrue( $p->get_attribute( 'hidden' ) ); + $this->assertSame( '', $new_html ); + + // False removes it. + $html = ''; + list($p, $new_html) = $this->process_directives( $html ); + $this->assertNull( $p->get_attribute( 'hidden' ) ); + $this->assertEqualHTML( '
    Text
    ', $new_html ); + + // On a `data-` or `aria-` attribute it becomes the string Preact would write. + $html = '
    Text
    '; + list($p) = $this->process_directives( $html ); + $this->assertSame( 'true', $p->get_attribute( 'data-open' ) ); + + $html = '
    Text
    '; + list($p) = $this->process_directives( $html ); + $this->assertSame( 'false', $p->get_attribute( 'aria-hidden' ) ); + } + + /** + * Tests that a bound number is written the same way the client store writes it. + * + * This is the invariant the number formatting exists for. A cast would round to + * `precision` where the store uses `serialize_precision`, so both are rendered + * by the same encoder instead of being compared after the fact. + * + * @ticket 65740 + * + * @covers ::process_directives + */ + public function test_wp_bind_number_value_matches_the_client_store() { + $this->interactivity->state( 'myPlugin', array( 'ratio' => 1 / 3 ) ); + + $html = '
    Text
    '; + list($p) = $this->process_directives( $html ); + + $data = $this->interactivity->filter_script_module_interactivity_data( array() ); + $encoded = wp_json_encode( $data['state'] ); + $this->assertIsString( $encoded, 'Expected the client state to be encodable as JSON.' ); + + $expected = wp_json_encode( 1 / 3 ); + $this->assertSame( $expected, $p->get_attribute( 'data-ratio' ) ); + $this->assertStringContainsString( + '"ratio":' . $expected, + $encoded, + 'Expected the rendered attribute value to match the number sent to the client.' + ); + } + + /** + * Creates an object which serializes to the given value for the client. + * + * @param mixed $value Value the object serializes to. + * @return JsonSerializable Object serializing to `$value`. + */ + private function get_json_serializable( $value ): JsonSerializable { + return new class( $value ) implements JsonSerializable { + /** + * Value the object serializes to. + * + * @var mixed + */ + private $value; + + /** + * Constructor. + * + * @param mixed $value Value the object serializes to. + */ + public function __construct( $value ) { + $this->value = $value; + } + + /** + * Returns the value for JSON serialization. + * + * @return mixed Value the client receives. + */ + #[\ReturnTypeWillChange] + public function jsonSerialize() { + return $this->value; + } + }; + } + + /** + * Data provider for values which cannot be stored in an attribute value. + * + * WP_HTML_Tag_Processor::set_attribute() escapes an ordinary attribute with + * strtr() and one of the URI attributes listed by wp_kses_uri_attributes() + * with esc_url(). Neither should be reached with a non-scalar value, so each + * value is paired with one attribute at a time: a regression in one of those + * paths then cannot be masked by the other failing first. + * + * @return array Data provider. + */ + public function data_non_scalar_values(): array { + $values = array( + 'list' => array( 'a', 'b' ), + 'associative array' => array( 'a' => 'b' ), + 'empty array' => array(), + 'object' => new stdClass(), + 'stringable object' => new class() { + /** + * Returns the string representation. + * + * @return string String representation. + */ + public function __toString() { + return 'stringified'; + } + }, + 'stringable object serializing to an array' => new class() implements JsonSerializable { + /** + * Returns the string representation. + * + * @return string String representation. + */ + public function __toString() { + return 'stringified'; + } + + /** + * Returns the value for JSON serialization. + * + * @return array Value the client receives. + */ + #[\ReturnTypeWillChange] + public function jsonSerialize() { + return array( 'not' => 'the string representation' ); + } + }, + 'object serializing to an array' => $this->get_json_serializable( array( 'a', 'b' ) ), + ); + + $attributes = array( + 'ordinary attribute' => array( + 'tag_name' => 'div', + 'attribute' => 'id', + 'existing_value' => 'other-id', + ), + 'URI attribute' => array( + 'tag_name' => 'a', + 'attribute' => 'href', + 'existing_value' => 'https://example.com/', + ), + ); + + $data = array(); + foreach ( $values as $value_label => $value ) { + foreach ( $attributes as $attribute_label => $attribute ) { + $data[ "$value_label in $attribute_label" ] = array( 'value' => $value ) + $attribute; + } + } + return $data; + } + + /** + * Tests that `data-wp-bind` rejects non-scalar values instead of passing + * them along to WP_HTML_Tag_Processor::set_attribute(). + * + * @ticket 65740 + * + * @covers ::process_directives + * + * @dataProvider data_non_scalar_values + * + * @expectedIncorrectUsage WP_Interactivity_API::data_wp_bind_processor + * + * @param mixed $value Non-scalar value to bind. + * @param string $tag_name Tag name to bind the value on. + * @param string $attribute Attribute name to bind the value to. + * @param string $existing_value Pre-existing value for the bound attribute. Unused, as the attribute is absent here. + */ + public function test_wp_bind_rejects_non_scalar_value( $value, string $tag_name, string $attribute, string $existing_value ) { + unset( $existing_value ); // The bound attribute is absent here, so there is no pre-existing value to remove. + + $this->interactivity->state( 'myPlugin', array( 'nonScalar' => $value ) ); + + $html = sprintf( '<%1$s data-wp-bind--%2$s="myPlugin::state.nonScalar">Text', $tag_name, $attribute ); + list($p, $new_html) = $this->process_directives( $html ); + $this->assertNull( $p->get_attribute( $attribute ), "Expected no $attribute attribute to have been set for a non-scalar value." ); + $this->assertSame( $html, $new_html, 'Expected the markup to be left unchanged.' ); + $this->assertSame( + array( + 'WP_Interactivity_API::data_wp_bind_processor' => sprintf( + 'Attempted to bind a non-scalar value to the "%s" attribute. Ensure the state/context property or the derived state closure resolves to a string, number, or boolean. (This message was added in version 7.1.0.)', + $attribute + ), + ), + $this->caught_doing_it_wrong, + 'Expected _doing_it_wrong() to have been called once with the non-scalar value message.' + ); + } + + /** + * Tests that `data-wp-bind` removes a pre-existing attribute when the + * evaluated value is non-scalar. + * + * @ticket 65740 + * + * @covers ::process_directives + * + * @dataProvider data_non_scalar_values + * + * @expectedIncorrectUsage WP_Interactivity_API::data_wp_bind_processor + * + * @param mixed $value Non-scalar value to bind. + * @param string $tag_name Tag name to bind the value on. + * @param string $attribute Attribute name to bind the value to. + * @param string $existing_value Pre-existing value for the bound attribute. + */ + public function test_wp_bind_removes_existing_attribute_for_non_scalar_value( $value, string $tag_name, string $attribute, string $existing_value ) { + $this->interactivity->state( 'myPlugin', array( 'nonScalar' => $value ) ); + + $html = sprintf( '<%1$s %2$s="%3$s" data-wp-bind--%2$s="myPlugin::state.nonScalar">Text', $tag_name, $attribute, $existing_value ); + list($p, $new_html) = $this->process_directives( $html ); + $this->assertNull( $p->get_attribute( $attribute ), "Expected the pre-existing $attribute attribute to have been removed." ); + $this->assertEqualHTML( sprintf( '<%1$s data-wp-bind--%2$s="myPlugin::state.nonScalar">Text', $tag_name, $attribute ), $new_html ); + $this->assertSame( + array( + 'WP_Interactivity_API::data_wp_bind_processor' => sprintf( + 'Attempted to bind a non-scalar value to the "%s" attribute. Ensure the state/context property or the derived state closure resolves to a string, number, or boolean. (This message was added in version 7.1.0.)', + $attribute + ), + ), + $this->caught_doing_it_wrong, + 'Expected _doing_it_wrong() to have been called once with the non-scalar value message.' + ); + } } diff --git a/tests/phpunit/tests/interactivity-api/wpInteractivityAPI.php b/tests/phpunit/tests/interactivity-api/wpInteractivityAPI.php index 20b249bd8c44e..430fe606f9277 100644 --- a/tests/phpunit/tests/interactivity-api/wpInteractivityAPI.php +++ b/tests/phpunit/tests/interactivity-api/wpInteractivityAPI.php @@ -845,6 +845,7 @@ public function test_extract_directive_value_invalid_json() { * name. * * @ticket 64106 + * @ticket 64898 * * @covers ::parse_directive_name */ @@ -890,6 +891,53 @@ public function test_parse_directive_name() { $this->assertNull( $result['suffix'] ); $this->assertNull( $result['unique_id'] ); + /* + * Should keep a "0" prefix, suffix, and unique ID rather than discarding it as empty. The + * client's `parseDirectiveName` normalizes with `|| null`, which discards only the empty + * string, because a non-empty string such as "0" is truthy in JavaScript. Using empty() + * here would discard "0" and diverge from the client. + */ + $this->assertSame( + array( + 'prefix' => 'test', + 'suffix' => null, + 'unique_id' => '0', + ), + $parse_directive_name->invoke( $this->interactivity, 'data-wp-test---0' ) + ); + $this->assertSame( + array( + 'prefix' => 'test', + 'suffix' => '0', + 'unique_id' => null, + ), + $parse_directive_name->invoke( $this->interactivity, 'data-wp-test--0' ) + ); + $this->assertSame( + array( + 'prefix' => 'test', + 'suffix' => '0', + 'unique_id' => 'unique-id', + ), + $parse_directive_name->invoke( $this->interactivity, 'data-wp-test--0---unique-id' ) + ); + $this->assertSame( + array( + 'prefix' => 'test', + 'suffix' => 'suffix', + 'unique_id' => '0', + ), + $parse_directive_name->invoke( $this->interactivity, 'data-wp-test--suffix---0' ) + ); + $this->assertSame( + array( + 'prefix' => '0', + 'suffix' => 'suffix', + 'unique_id' => null, + ), + $parse_directive_name->invoke( $this->interactivity, 'data-wp-0--suffix' ) + ); + // Should handle only dashes (4 or more dashes). $result = $parse_directive_name->invoke( $this->interactivity, 'data-wp-test----' ); $this->assertSame( 'test', $result['prefix'] ); @@ -919,12 +967,50 @@ public function test_parse_directive_name() { $this->assertSame( 'test', $result['prefix'] ); $this->assertNull( $result['suffix'] ); $this->assertSame( 'unique-id--wrong-suffix', $result['unique_id'] ); + + // Should reject a name containing characters a directive name cannot contain. + $this->assertNull( $parse_directive_name->invoke( $this->interactivity, 'data-wp-test.suffix' ) ); + + /* + * Should reject a name which is nothing but the prefix, rather than returning an empty + * prefix. The client's `parseDirectiveName` returns `{ prefix: '' }` here instead, but + * neither an empty prefix nor null matches a registered directive, so the outcome is the + * same on both sides: the attribute is ignored. + */ + $this->assertNull( $parse_directive_name->invoke( $this->interactivity, 'data-wp-' ) ); + + /* + * Should reject a name whose prefix would begin with a hyphen, which the directive syntax + * does not allow. Neither reading of such a name is meaningful: treating the hyphens as a + * suffix separator leaves the prefix empty, and treating them as part of the prefix names + * a directive which cannot be registered. The client's `parseDirectiveName` still splits + * `data-wp---foo` into `{ prefix: '', suffix: 'foo' }`, but as with an empty name, no + * result here matches a registered directive, so the attribute is ignored on both sides + * either way. + */ + $this->assertNull( $parse_directive_name->invoke( $this->interactivity, 'data-wp--bind' ) ); + $this->assertNull( $parse_directive_name->invoke( $this->interactivity, 'data-wp---foo' ) ); + $this->assertNull( $parse_directive_name->invoke( $this->interactivity, 'data-wp----foo' ) ); + + /* + * Should still accept a suffix which begins with hyphens, since only the prefix is + * constrained. Here the prefix is "style" and the suffix is "--var". + */ + $this->assertSame( + array( + 'prefix' => 'style', + 'suffix' => '--var', + 'unique_id' => null, + ), + $parse_directive_name->invoke( $this->interactivity, 'data-wp-style----var' ) + ); } /** * Tests the ability to get the valid entries of a specific directive in an HTML element. * * @ticket 64106 + * @ticket 64898 * * @covers ::get_directive_entries */ @@ -1139,6 +1225,26 @@ function ( $d ) { $results ) ); + + /* + * Should skip an attribute whose directive name cannot be parsed. Such a name is still + * matched by the prefix search, so it reaches here and has to be filtered out rather than + * destructured. + */ + $html = '
    '; + $p = new WP_Interactivity_API_Directives_Processor( $html ); + $p->next_tag(); + $this->assertSame( + array( + array( + 'namespace' => 'myPlugin', + 'value' => 'kept', + 'suffix' => 'valid', + 'unique_id' => null, + ), + ), + $get_directive_entries->invoke( $this->interactivity, $p, 'test' ) + ); } /** diff --git a/tests/phpunit/tests/kses.php b/tests/phpunit/tests/kses.php index 59353a2b7a20c..f560d88403524 100644 --- a/tests/phpunit/tests/kses.php +++ b/tests/phpunit/tests/kses.php @@ -536,6 +536,227 @@ public function test_wp_kses_allowed_html() { $this->assertSame( $allowedtags, wp_kses_allowed_html( 'data' ) ); } + /** + * Tests that the comment content context allows only the mention span beyond the defaults. + * + * @ticket 65622 + * + * @covers ::_wp_kses_allow_note_mention_span + */ + public function test_wp_kses_allowed_html_pre_comment_content_allows_only_the_mention_span() { + global $allowedtags; + + $allowed = wp_kses_allowed_html( 'pre_comment_content' ); + + $this->assertSame( + array( 'class' => true ), + $allowed['span'], + 'The mention span should be allowed in comment content.' + ); + + unset( $allowed['span'] ); + $this->assertSame( + $allowedtags, + $allowed, + 'Nothing beyond the mention span should be allowed on top of the default comment tags.' + ); + } + + /** + * Tests that a note mention survives content sanitization of a `note` comment. + * + * @ticket 65622 + * + * @covers ::_wp_kses_allow_note_mention_span + * @covers ::_wp_kses_sanitize_note_mention_classes + */ + public function test_note_mention_markup_survives_note_content_sanitization() { + add_filter( 'pre_comment_content', 'wp_filter_kses' ); + + $content = 'Hello @admin!'; + $filtered = wp_filter_comment( wp_slash( $this->get_mention_commentdata( 'note', $content ) ) ); + + remove_filter( 'pre_comment_content', 'wp_filter_kses' ); + + $this->assertSame( $content, wp_unslash( $filtered['comment_content'] ) ); + } + + /** + * Tests that the mention markup also survives in regular comment content. + * + * The allowance is always on rather than scoped per comment type: the + * mention markup is inert, so uniform sanitization avoids stateful + * arming and disarming of kses filters around each note write. + * + * @ticket 65622 + * + * @covers ::_wp_kses_allow_note_mention_span + * @covers ::_wp_kses_sanitize_note_mention_classes + */ + public function test_note_mention_markup_survives_regular_comment_content_sanitization() { + add_filter( 'pre_comment_content', 'wp_filter_kses' ); + $content = 'Hello @admin!'; + $filtered = wp_filter_comment( wp_slash( $this->get_mention_commentdata( 'comment', $content ) ) ); + + $this->assertSame( $content, wp_unslash( $filtered['comment_content'] ) ); + } + + /** + * Tests that span classes are reduced to the two mention tokens. + * + * @ticket 65622 + * + * @covers ::_wp_kses_sanitize_note_mention_classes + */ + public function test_note_mention_span_classes_are_reduced_to_the_mention_tokens() { + add_filter( 'pre_comment_content', 'wp_filter_kses' ); + $content = 'Hello @admin!'; + $filtered = wp_filter_comment( wp_slash( $this->get_mention_commentdata( 'note', $content ) ) ); + + $this->assertSame( + 'Hello @admin!', + wp_unslash( $filtered['comment_content'] ), + 'Class tokens beyond `wp-note-mention` and `user-N` should be stripped from spans.' + ); + } + + /** + * Tests that class tokens are reduced on spans regardless of tag-name casing. + * + * kses preserves tag-name casing, so the class reduction must match `SPAN` + * case-insensitively rather than bail on a `get_mention_commentdata( 'note', $content ) ) ); + + $this->assertEqualHTML( + 'Hello @admin!', + wp_unslash( $filtered['comment_content'] ), + '', + 'Class tokens should be reduced on spans regardless of tag-name casing.' + ); + } + + /** + * Tests that the class attribute is removed when no mention tokens remain. + * + * @ticket 65622 + * + * @covers ::_wp_kses_sanitize_note_mention_classes + */ + public function test_note_mention_class_attribute_removed_when_no_tokens_remain() { + add_filter( 'pre_comment_content', 'wp_filter_kses' ); + $content = 'Hello there!'; + $filtered = wp_filter_comment( wp_slash( $this->get_mention_commentdata( 'comment', $content ) ) ); + + // Markup-equivalence assertion: the HTML API's whitespace handling + // when removing the final attribute is not part of its contract. + $this->assertEqualHTML( + 'Hello there!', + wp_unslash( $filtered['comment_content'] ), + '', + 'A span with no valid mention tokens should lose its class attribute entirely.' + ); + } + + /** + * Tests that only the `class` attribute is allowed on mention spans. + * + * @ticket 65622 + * + * @covers ::_wp_kses_allow_note_mention_span + */ + public function test_note_mention_allows_only_class_on_mention_spans() { + add_filter( 'pre_comment_content', 'wp_filter_kses' ); + $content = 'Hello @admin!'; + $filtered = wp_filter_comment( wp_slash( $this->get_mention_commentdata( 'note', $content ) ) ); + + $this->assertSame( + 'Hello @admin!', + wp_unslash( $filtered['comment_content'] ), + 'Attributes beyond `class` should be stripped from spans.' + ); + } + + /** + * Tests that `class` is still stripped from links in comment content. + * + * @ticket 65622 + * + * @covers ::_wp_kses_allow_note_mention_span + */ + public function test_class_is_still_stripped_from_links_in_comment_content() { + add_filter( 'pre_comment_content', 'wp_filter_kses' ); + + /* + * The href is external to the test site so that wp_rel_ugc() - which + * applies to notes like any other comment - deterministically appends + * `rel="nofollow ugc"`. + */ + $content = 'Hello @admin!'; + $filtered = wp_filter_comment( wp_slash( $this->get_mention_commentdata( 'note', $content ) ) ); + + $this->assertSame( + 'Hello @admin!', + wp_unslash( $filtered['comment_content'] ), + 'The class allowance is scoped to spans; links keep the default sanitization.' + ); + } + + /** + * Tests that the class reduction is skipped while the restrictive comment kses is inactive. + * + * Users with `unfiltered_html` are filtered through `wp_filter_post_kses` + * (or not at all), where arbitrary classes are permitted; the mention + * class reduction must not narrow what they can post. + * + * @ticket 65622 + * + * @covers ::_wp_kses_sanitize_note_mention_classes + */ + public function test_note_mention_class_reduction_skipped_when_restrictive_kses_is_inactive() { + // kses_init() hooks wp_filter_kses by default in the test + // environment, so detach it to simulate the unfiltered_html setup. + // The test framework restores filters after each test. + remove_filter( 'pre_comment_content', 'wp_filter_kses' ); + + $content = 'Hello there!'; + + $this->assertSame( + wp_slash( $content ), + _wp_kses_sanitize_note_mention_classes( wp_slash( $content ) ), + 'Span classes should be left untouched when wp_filter_kses is not active.' + ); + } + + /** + * Builds a complete commentdata array for wp_filter_comment(). + * + * @param 'note'|'comment' $comment_type The comment type. + * @param string $content The comment content. + * @return array{ + * comment_content: string, + * ... + * } + */ + private function get_mention_commentdata( string $comment_type, string $content ): array { + return array( + 'comment_content' => $content, + 'comment_type' => $comment_type, + 'comment_author' => 'admin', + 'comment_author_IP' => '127.0.0.1', + 'comment_author_url' => 'http://example.org', + 'comment_author_email' => 'admin@example.org', + 'comment_agent' => '', + ); + } + public function test_hyphenated_tag() { $content = 'Alot of hyphens.'; $custom_tags = array( @@ -1962,6 +2183,18 @@ public function test_wp_kses_main_tag_standard_attributes() { $this->assertEqualHTML( $html, wp_kses_post( $html ) ); } + /** + * Tests that the autofocus attribute is allowed on dialog elements and removed from other focusable elements. + * + * @ticket 65491 + */ + public function test_wp_kses_dialog_autofocus_attribute() { + $html = 'Content
    Some content
    '; + $expected = 'Content
    Some content
    '; + + $this->assertEqualHTML( $expected, wp_kses_post( $html ) ); + } + /** * Test that Invoker Commands API attributes are preserved on buttons in post content. * diff --git a/tests/phpunit/tests/media.php b/tests/phpunit/tests/media.php index 03fe3b4c02460..5aee3f8b6955f 100644 --- a/tests/phpunit/tests/media.php +++ b/tests/phpunit/tests/media.php @@ -667,6 +667,315 @@ public function test_wp_prepare_attachment_for_js_without_image_sizes() { $this->assertArrayHasKey( 'sizes', $prepped ); } + /** + * Tests that an unusable `full` entry in the `sizes` metadata is skipped. + * + * Attachments that are not images, such as PDFs, are handled by a separate branch that reads + * the `full` entry of the `sizes` metadata directly. That entry is not guaranteed to be there, + * nor to carry dimensions when it is, and reading it unconditionally raises "Undefined array + * key" warnings. + * + * @ticket 65748 + * + * @dataProvider data_wp_prepare_attachment_for_js_unusable_full_size + * + * @covers ::wp_prepare_attachment_for_js + * + * @param array $sizes Value to store as the `sizes` metadata. + */ + public function test_wp_prepare_attachment_for_js_with_an_unusable_full_size( array $sizes ) { + $id = $this->create_pdf_attachment( $sizes ); + + $prepped = wp_prepare_attachment_for_js( $id ); + + $this->assertIsArray( $prepped ); + $this->assertArrayHasKey( 'sizes', $prepped ); + + $sizes = $prepped['sizes']; + + $this->assertIsArray( $sizes ); + $this->assertArrayNotHasKey( 'full', $sizes, 'An unusable `full` size should not have been exposed.' ); + } + + /** + * Tests that a usable `full` entry in the `sizes` metadata is still exposed. + * + * @ticket 65748 + * + * @covers ::wp_prepare_attachment_for_js + */ + public function test_wp_prepare_attachment_for_js_with_a_usable_full_size() { + $id = $this->create_pdf_attachment( + array( + 'full' => array( + 'file' => 'test-document-pdf.jpg', + 'width' => 232, + 'height' => 300, + 'mime-type' => 'image/jpeg', + ), + ) + ); + + $prepped = wp_prepare_attachment_for_js( $id ); + + $this->assertIsArray( $prepped ); + $this->assertArrayHasKey( 'sizes', $prepped ); + + $sizes = $prepped['sizes']; + + $this->assertIsArray( $sizes ); + $this->assertArrayHasKey( 'full', $sizes, 'A usable `full` size should have been exposed.' ); + + $full = $sizes['full']; + + $this->assertIsArray( $full ); + $this->assertSame( 232, $full['width'] ); + $this->assertSame( 300, $full['height'] ); + $this->assertSame( 'portrait', $full['orientation'] ); + $this->assertIsString( $full['url'] ); + $this->assertStringEndsWith( '/test-document-pdf.jpg', $full['url'] ); + } + + /** + * Data provider. + * + * @return array }> + */ + public function data_wp_prepare_attachment_for_js_unusable_full_size(): array { + return array( + 'no full size' => array( + array( + 'thumbnail' => array( + 'file' => 'test-document-pdf-116x150.jpg', + 'width' => 116, + 'height' => 150, + 'mime-type' => 'image/jpeg', + ), + ), + ), + 'full without dimensions' => array( + array( + 'full' => array( + 'file' => 'test-document-pdf.jpg', + 'mime-type' => 'image/jpeg', + ), + ), + ), + 'full without a height' => array( + array( + 'full' => array( + 'file' => 'test-document-pdf.jpg', + 'width' => 232, + 'mime-type' => 'image/jpeg', + ), + ), + ), + 'full with an empty file' => array( + array( + 'full' => array( + 'file' => '', + 'width' => 232, + 'height' => 300, + 'mime-type' => 'image/jpeg', + ), + ), + ), + ); + } + + /** + * Creates a PDF attachment carrying the given `sizes` metadata. + * + * A PDF is used so that wp_prepare_attachment_for_js() takes the branch for attachments that + * are not images, which is the one that reads the `full` entry of the `sizes` metadata. + * + * @param array $sizes Value to store as the `sizes` metadata. + * @return int Attachment ID. + */ + private function create_pdf_attachment( array $sizes ): int { + $id = wp_insert_attachment( + array( + 'post_title' => 'Attachment Title', + 'post_type' => 'attachment', + 'post_parent' => 0, + 'post_mime_type' => 'application/pdf', + 'guid' => home_url( '/wp-content/uploads/test-document.pdf' ), + ) + ); + + wp_update_attachment_metadata( + $id, + array( + 'file' => 'test-document.pdf', + 'sizes' => $sizes, + ) + ); + + return $id; + } + + /** + * Tests that a `filesize` stored in the attachment metadata is normalized to a positive integer. + * + * When the stored value cannot be normalized, it should be treated as missing so that the + * filesystem fallback runs instead. + * + * @ticket 65686 + * + * @dataProvider data_wp_prepare_attachment_for_js_filesize + * + * @param mixed $filesize The `filesize` value stored in the attachment metadata. + * @param int<0, max>|null $expected The expected `filesizeInBytes` value, or null if it should not be set. + */ + public function test_wp_prepare_attachment_for_js_filesize( $filesize, ?int $expected ) { + $id = self::factory()->attachment->create_object( + array( + 'file' => 'test-image.jpg', + 'post_title' => 'Attachment Title', + 'post_parent' => 0, + 'post_mime_type' => 'image/jpeg', + ) + ); + $this->assertIsInt( $id ); + + wp_update_attachment_metadata( + $id, + array( + 'width' => 50, + 'height' => 50, + 'file' => 'test-image.jpg', + 'filesize' => $filesize, + ) + ); + + $post = get_post( $id ); + $this->assertInstanceOf( WP_Post::class, $post ); + $prepped = wp_prepare_attachment_for_js( $post ); + $this->assertIsArray( $prepped ); + + if ( null === $expected ) { + $this->assertArrayNotHasKey( 'filesizeInBytes', $prepped, 'The filesize should not have been set.' ); + $this->assertArrayNotHasKey( 'filesizeHumanReadable', $prepped, 'The human readable filesize should not have been set.' ); + } else { + $this->assertSame( $expected, $prepped['filesizeInBytes'], 'The filesize was not normalized to an integer.' ); + $this->assertSame( size_format( $expected ), $prepped['filesizeHumanReadable'], 'The human readable filesize did not match the normalized filesize.' ); + } + } + + /** + * Data provider. + * + * @return array|null }> + */ + public function data_wp_prepare_attachment_for_js_filesize(): array { + return array( + 'an integer' => array( + 'filesize' => 12345, + 'expected' => 12345, + ), + 'a numeric string' => array( + 'filesize' => '12345', + 'expected' => 12345, + ), + 'a float' => array( + 'filesize' => 12345.6, + 'expected' => 12345, + ), + 'a float as a string' => array( + 'filesize' => '12345.6', + 'expected' => 12345, + ), + 'an exponential string' => array( + 'filesize' => '1e3', + 'expected' => 1000, + ), + 'a value smaller than a byte' => array( + 'filesize' => 0.5, + 'expected' => null, + ), + 'zero' => array( + 'filesize' => 0, + 'expected' => null, + ), + 'a negative integer' => array( + 'filesize' => -12345, + 'expected' => null, + ), + 'an empty string' => array( + 'filesize' => '', + 'expected' => null, + ), + 'a non-numeric string' => array( + 'filesize' => 'not-a-number', + 'expected' => null, + ), + 'an array' => array( + 'filesize' => array( 12345 ), + 'expected' => null, + ), + 'null' => array( + 'filesize' => null, + 'expected' => null, + ), + 'false' => array( + 'filesize' => false, + 'expected' => null, + ), + 'true' => array( + 'filesize' => true, + 'expected' => null, + ), + ); + } + + /** + * Tests that an unusable `filesize` in the attachment metadata falls back to the size of the file. + * + * @ticket 65686 + * + * @dataProvider data_wp_prepare_attachment_for_js_filesize_falls_back_to_the_file + * + * @param mixed $filesize The `filesize` value stored in the attachment metadata. + */ + public function test_wp_prepare_attachment_for_js_filesize_falls_back_to_the_file( $filesize ) { + $id = self::factory()->attachment->create_upload_object( DIR_TESTDATA . '/images/canola.jpg' ); + $this->assertIsInt( $id ); + $post = get_post( $id ); + $this->assertInstanceOf( WP_Post::class, $post ); + $file = get_attached_file( $id ); + $this->assertIsString( $file ); + + $meta = wp_get_attachment_metadata( $id ); + $this->assertIsArray( $meta ); + $meta['filesize'] = $filesize; + wp_update_attachment_metadata( $id, $meta ); + + $prepped = wp_prepare_attachment_for_js( $post ); + $this->assertIsArray( $prepped ); + $this->assertArrayHasKey( 'filesizeInBytes', $prepped ); + + $this->assertSame( wp_filesize( $file ), $prepped['filesizeInBytes'] ); + } + + /** + * Data provider. + * + * @return array + */ + public function data_wp_prepare_attachment_for_js_filesize_falls_back_to_the_file(): array { + return array( + 'a value smaller than a byte' => array( 'filesize' => 0.5 ), + 'zero' => array( 'filesize' => 0 ), + 'a negative integer' => array( 'filesize' => -12345 ), + 'an empty string' => array( 'filesize' => '' ), + 'a non-numeric string' => array( 'filesize' => 'not-a-number' ), + 'an array' => array( 'filesize' => array( 12345 ) ), + 'null' => array( 'filesize' => null ), + 'false' => array( 'filesize' => false ), + 'true' => array( 'filesize' => true ), + ); + } + /** * @ticket 19067 * @expectedDeprecated wp_convert_bytes_to_hr @@ -3016,6 +3325,9 @@ public function test_get_image_send_to_editor_defaults_no_caption_no_rel() { * * @ticket 36246 * @requires function imagejpeg + * + * @covers ::wp_get_attachment_image + * @covers ::wp_get_attachment_metadata */ public function test_wp_get_attachment_image_should_use_wp_get_attachment_metadata() { add_filter( 'wp_get_attachment_metadata', array( $this, 'filter_36246' ), 10, 2 ); diff --git a/tests/phpunit/tests/multisite/updateBlogDetails.php b/tests/phpunit/tests/multisite/updateBlogDetails.php index 62c0f7b355cd7..078d421dfc520 100644 --- a/tests/phpunit/tests/multisite/updateBlogDetails.php +++ b/tests/phpunit/tests/multisite/updateBlogDetails.php @@ -53,7 +53,7 @@ public function test_update_blog_details() { * * @param string $flag The name of the flag being set or unset on a site. * @param string $flag_value '0' or '1'. The value of the flag being set. - * @param string $action The hook expected to fire for the flag name and flag combination. + * @param string $hook The hook expected to fire for the flag name and flag combination. * * @dataProvider data_flag_hooks */ diff --git a/tests/phpunit/tests/post.php b/tests/phpunit/tests/post.php index e609fa0d3003d..842502a971cba 100644 --- a/tests/phpunit/tests/post.php +++ b/tests/phpunit/tests/post.php @@ -668,8 +668,6 @@ public function data_stick_post_with_unexpected_sticky_posts_option() { * * @ticket 52007 * @covers ::stick_post - * - * @param mixed $stick Value to pass to stick_post(). */ public function test_stick_post_removes_duplicate_post_ids_when_adding_new_value() { update_option( 'sticky_posts', array( 1, 1, 2, 2 ) ); diff --git a/tests/phpunit/tests/post/wpGetAttachmentMetadata.php b/tests/phpunit/tests/post/wpGetAttachmentMetadata.php new file mode 100644 index 0000000000000..028356a04168d --- /dev/null +++ b/tests/phpunit/tests/post/wpGetAttachmentMetadata.php @@ -0,0 +1,281 @@ +create_attachment(); + + $this->assertFalse( wp_get_attachment_metadata( $attachment_id ) ); + } + + /** + * Ensure stored metadata that is not an array is reported as a failure. + * + * The documented return of `array|false` has to hold on the `$unfiltered` path too, since + * callers such as wp-admin/post.php read the metadata that way in order to modify it and + * pass it back to wp_update_attachment_metadata(). + * + * @ticket 65748 + * + * @dataProvider data_non_array_stored_metadata_values + * + * @param mixed $metadata Value to store as `_wp_attachment_metadata`. + */ + public function test_should_return_false_when_the_stored_metadata_is_not_an_array( $metadata ) { + $attachment_id = $this->create_attachment(); + + update_post_meta( $attachment_id, '_wp_attachment_metadata', $metadata ); + + $this->assertFalse( wp_get_attachment_metadata( $attachment_id ), 'The filtered metadata should have been reported as missing.' ); + $this->assertFalse( wp_get_attachment_metadata( $attachment_id, true ), 'The unfiltered metadata should have been reported as missing.' ); + } + + /** + * Ensure the `sizes` key is not invented for attachments that have no sub-sizes. + * + * An attachment is not necessarily an image. Audio, video and document attachments + * legitimately store metadata without a `sizes` key, and fabricating one would both + * blur that distinction and pollute the stored metadata for any caller that reads + * the metadata, modifies it, and passes it back to wp_update_attachment_metadata(). + * + * @ticket 65748 + */ + public function test_should_not_add_a_sizes_key_when_the_metadata_has_none() { + $metadata = array( + 'bitrate' => 128000, + 'length' => 191, + 'fileformat' => 'mp3', + ); + + $attachment_id = $this->create_attachment( $metadata ); + + $this->assertSame( $metadata, wp_get_attachment_metadata( $attachment_id ) ); + } + + /** + * Ensure a usable `sizes` array is passed through untouched. + * + * @ticket 65748 + */ + public function test_should_preserve_a_usable_sizes_array() { + $metadata = array( + 'file' => '2026/08/image.jpg', + 'sizes' => array( + 'thumbnail' => array( + 'file' => 'image-150x150.jpg', + 'width' => 150, + 'height' => 150, + 'mime-type' => 'image/jpeg', + ), + ), + ); + + $attachment_id = $this->create_attachment( $metadata ); + + $this->assertSame( $metadata, wp_get_attachment_metadata( $attachment_id ) ); + } + + /** + * Ensure a `sizes` key holding something other than an array is replaced with an empty array. + * + * Callers such as wp_save_image() pass `$meta['sizes']` straight to array_merge(), which + * is a fatal error for a scalar. Guarding the value here means every caller can rely on + * `sizes` being an array whenever the key is present. + * + * @ticket 65748 + * + * @dataProvider data_non_array_sizes_values + * + * @param mixed $sizes Value to store under the `sizes` key. + */ + public function test_should_replace_a_non_array_sizes_value_with_an_empty_array( $sizes ) { + $attachment_id = $this->create_attachment( + array( + 'file' => '2026/08/image.jpg', + 'sizes' => $sizes, + ) + ); + + $metadata = wp_get_attachment_metadata( $attachment_id ); + + $this->assertIsArray( $metadata, 'The metadata should have been returned as an array.' ); + $this->assertArrayHasKey( 'sizes', $metadata, 'The `sizes` key should still be present.' ); + $this->assertSame( array(), $metadata['sizes'], 'The unusable `sizes` value should have been replaced.' ); + } + + /** + * Ensure the stored metadata is returned verbatim when filters are skipped. + * + * Passing `$unfiltered` as true is documented as skipping the filters, and callers such as + * wp-admin/post.php read the metadata this way in order to modify and re-save it. Normalizing + * the value here would write the normalization back into the database. + * + * @ticket 65748 + * + * @dataProvider data_non_array_sizes_values + * + * @param mixed $sizes Value to store under the `sizes` key. + */ + public function test_should_not_replace_a_non_array_sizes_value_when_unfiltered( $sizes ) { + $metadata = array( + 'file' => '2026/08/image.jpg', + 'sizes' => $sizes, + ); + + $attachment_id = $this->create_attachment( $metadata ); + + $this->assertSame( $metadata, wp_get_attachment_metadata( $attachment_id, true ) ); + } + + /** + * Ensure a filtered value that is not an array is reported as a failure. + * + * The function documents a return of `array|false`, so a filter returning something else + * should surface as a failure rather than being handed to callers that expect an array. + * + * @ticket 65748 + * + * @dataProvider data_non_array_filter_return_values + * + * @param mixed $value Value for the filter to return. + */ + public function test_should_return_false_when_the_filter_returns_a_non_array( $value ) { + $attachment_id = $this->create_attachment( array( 'file' => '2026/08/image.jpg' ) ); + + add_filter( + 'wp_get_attachment_metadata', + static function () use ( $value ) { + return $value; + } + ); + + $this->assertFalse( wp_get_attachment_metadata( $attachment_id ) ); + } + + /** + * Ensure the `sizes` value is normalized after the filter has run, not before. + * + * @ticket 65748 + */ + public function test_should_normalize_a_sizes_value_introduced_by_the_filter() { + // Stored without a `sizes` key, so the key can only come from the filter. + $attachment_id = $this->create_attachment( array( 'file' => '2026/08/image.jpg' ) ); + + add_filter( + 'wp_get_attachment_metadata', + static function ( array $data ): array { + $data['sizes'] = 'not-an-array'; + return $data; + } + ); + + $metadata = wp_get_attachment_metadata( $attachment_id ); + + $this->assertIsArray( $metadata, 'The metadata should have been returned as an array.' ); + $this->assertArrayHasKey( 'sizes', $metadata, 'The `sizes` key should still be present.' ); + $this->assertSame( array(), $metadata['sizes'], 'The value set by the filter should have been replaced.' ); + } + + /** + * Ensure the filter is not applied when filters are skipped. + */ + public function test_should_not_apply_the_filter_when_unfiltered() { + $metadata = array( 'file' => '2026/08/image.jpg' ); + + $attachment_id = $this->create_attachment( $metadata ); + + add_filter( 'wp_get_attachment_metadata', '__return_empty_array' ); + + $this->assertSame( $metadata, wp_get_attachment_metadata( $attachment_id, true ) ); + } + + /** + * Data provider. + * + * Only values that survive a round trip through the meta table are listed. A value that + * comes back falsy, such as an empty string, was already treated as missing metadata. + * + * @return array + */ + public function data_non_array_stored_metadata_values(): array { + return array( + 'string' => array( 'not-an-array' ), + 'integer' => array( 1 ), + 'float' => array( 1.5 ), + 'object' => array( new stdClass() ), + ); + } + + /** + * Data provider. + * + * @return array + */ + public function data_non_array_sizes_values(): array { + return array( + 'null' => array( null ), + 'empty string' => array( '' ), + 'string' => array( 'not-an-array' ), + 'boolean false' => array( false ), + 'integer' => array( 0 ), + ); + } + + /** + * Data provider. + * + * @return array + */ + public function data_non_array_filter_return_values(): array { + return array( + 'null' => array( null ), + 'empty string' => array( '' ), + 'string' => array( 'not-an-array' ), + 'boolean false' => array( false ), + 'boolean true' => array( true ), + 'integer' => array( 1 ), + 'float' => array( 1.5 ), + 'object' => array( new stdClass() ), + ); + } + + /** + * Creates an attachment, optionally storing metadata for it. + * + * The metadata is stored with update_post_meta() rather than wp_update_attachment_metadata() + * so that it reaches the database without passing through the update filter, leaving the + * stored value entirely under the control of the test. + * + * @param array|null $metadata Optional. Metadata to store as + * `_wp_attachment_metadata`. Default null, meaning + * no metadata is stored at all. + * @return int Attachment ID. + */ + private function create_attachment( ?array $metadata = null ): int { + $attachment_id = self::factory()->attachment->create_object( + array( + 'file' => '2026/08/image.jpg', + 'post_mime_type' => 'image/jpeg', + ) + ); + + $this->assertIsInt( $attachment_id, 'Failed to create the attachment fixture.' ); + + if ( null !== $metadata ) { + update_post_meta( $attachment_id, '_wp_attachment_metadata', $metadata ); + } + + return $attachment_id; + } +} diff --git a/tests/phpunit/tests/privacy/wpPrivacyProcessPersonalDataExportPage.php b/tests/phpunit/tests/privacy/wpPrivacyProcessPersonalDataExportPage.php index 5410f851f4b2a..fb610a3d2cfd5 100644 --- a/tests/phpunit/tests/privacy/wpPrivacyProcessPersonalDataExportPage.php +++ b/tests/phpunit/tests/privacy/wpPrivacyProcessPersonalDataExportPage.php @@ -358,7 +358,7 @@ public function data_send_as_email_options() { * * @dataProvider data_send_as_email_options * - * @param bool Whether the final results of the export should be emailed to the user. + * @param bool $send_as_email Whether the final results of the export should be emailed to the user. */ public function test_send_error_when_invalid_request_id( $send_as_email ) { $response = array( @@ -388,7 +388,7 @@ public function test_send_error_when_invalid_request_id( $send_as_email ) { * * @dataProvider data_send_as_email_options * - * @param bool Whether the final results of the export should be emailed to the user. + * @param bool $send_as_email Whether the final results of the export should be emailed to the user. */ public function test_send_error_when_invalid_request_action_name( $send_as_email ) { $response = array( @@ -420,8 +420,7 @@ public function test_send_error_when_invalid_request_action_name( $send_as_email * * @dataProvider data_send_as_email_options * - * @param bool Whether the final results of the export should be emailed to the user. - * + * @param bool $send_as_email Whether the final results of the export should be emailed to the user. */ public function test_raw_data_post_meta( $send_as_email ) { $this->assertEmpty( get_post_meta( self::$request_id, '_export_data_raw', true ) ); @@ -461,7 +460,7 @@ public function test_raw_data_post_meta( $send_as_email ) { * * @dataProvider data_send_as_email_options * - * @param bool Whether the final results of the export should be emailed to the user. + * @param bool $send_as_email Whether the final results of the export should be emailed to the user. */ public function test_add_post_meta_with_groups_data_only_available_when_export_file_generated( $send_as_email ) { // Adds post meta when processing data, given the first exporter on the first page and send as email. diff --git a/tests/phpunit/tests/privacy/wpPrivacyResetPolicyPageForPost.php b/tests/phpunit/tests/privacy/wpPrivacyResetPolicyPageForPost.php new file mode 100644 index 0000000000000..50ce04cb1bd44 --- /dev/null +++ b/tests/phpunit/tests/privacy/wpPrivacyResetPolicyPageForPost.php @@ -0,0 +1,172 @@ +post->create( array( 'post_type' => 'page' ) ); + assert( is_int( $page_id ) ); + $this->policy_page_id = $page_id; + update_option( 'wp_page_for_privacy_policy', $this->policy_page_id ); + } + + public function tear_down(): void { + delete_option( 'wp_page_for_privacy_policy' ); + parent::tear_down(); + } + + /** + * Tests that trashing the Privacy Policy page does NOT reset the option, + * so that restoring from trash preserves the assignment. + * + * @ticket 56694 + */ + public function test_trashing_privacy_policy_page_does_not_reset_option(): void { + wp_trash_post( $this->policy_page_id ); + + $this->assertSame( + $this->policy_page_id, + (int) get_option( 'wp_page_for_privacy_policy' ), + 'Trashing the Privacy Policy page should not reset wp_page_for_privacy_policy.' + ); + } + + /** + * Tests that permanently deleting the Privacy Policy page resets the option to 0. + * + * @ticket 56694 + */ + public function test_deleting_privacy_policy_page_resets_option(): void { + wp_delete_post( $this->policy_page_id, true ); + + $this->assertSame( 0, (int) get_option( 'wp_page_for_privacy_policy' ) ); + } + + /** + * Tests that trashing a different page does not change the option. + * + * @ticket 56694 + */ + public function test_trashing_a_different_page_does_not_reset_option(): void { + $other_page_id = self::factory()->post->create( array( 'post_type' => 'page' ) ); + $this->assertIsInt( $other_page_id ); + wp_trash_post( $other_page_id ); + + $this->assertSame( + $this->policy_page_id, + (int) get_option( 'wp_page_for_privacy_policy' ), + 'Trashing an unrelated page should not reset wp_page_for_privacy_policy.' + ); + } + + /** + * Tests that deleting a non-page post type does not change the option. + * + * @ticket 56694 + */ + public function test_deleting_non_page_post_type_does_not_reset_option(): void { + $post_id = self::factory()->post->create( array( 'post_type' => 'post' ) ); + $this->assertIsInt( $post_id ); + wp_delete_post( $post_id, true ); + + $this->assertSame( + $this->policy_page_id, + (int) get_option( 'wp_page_for_privacy_policy' ), + 'Deleting a non-page post should not reset wp_page_for_privacy_policy.' + ); + } + + /** + * Tests that WP_Privacy_Policy_Content::notice() resets the option to 0 + * when the stored ID points to a page that no longer exists. + * + * @ticket 56694 + * + * @covers WP_Privacy_Policy_Content::notice + */ + public function test_notice_self_heals_when_policy_page_does_not_exist(): void { + require_once ABSPATH . 'wp-admin/includes/class-wp-privacy-policy-content.php'; + + update_option( 'wp_page_for_privacy_policy', 99999 ); + + $user_id = self::factory()->user->create( array( 'role' => 'administrator' ) ); + $this->assertIsInt( $user_id ); + wp_set_current_user( $user_id ); + if ( is_multisite() ) { + grant_super_admin( $user_id ); + } + set_current_screen( 'post' ); + + $post = self::factory()->post->create_and_get( array( 'post_type' => 'page' ) ); + $this->assertInstanceOf( WP_Post::class, $post ); + WP_Privacy_Policy_Content::notice( $post ); + + $this->assertSame( + 0, + (int) get_option( 'wp_page_for_privacy_policy' ), + 'notice() should reset the option to 0 when the stored page does not exist.' + ); + } + + /** + * Tests that _reset_privacy_policy_page_for_post() does not call + * update_option() when wp_page_for_privacy_policy is already 0. + * + * @ticket 56694 + */ + public function test_no_update_option_when_policy_page_already_zero(): void { + update_option( 'wp_page_for_privacy_policy', 0 ); + + $call_count = 0; + add_filter( + 'pre_update_option_wp_page_for_privacy_policy', + static function ( $value ) use ( &$call_count ) { + ++$call_count; + return $value; + } + ); + + $other_page_id = self::factory()->post->create( array( 'post_type' => 'page' ) ); + $this->assertIsInt( $other_page_id ); + wp_delete_post( $other_page_id, true ); + + $this->assertSame( + 0, + $call_count, + 'update_option() should not be called when wp_page_for_privacy_policy is already 0.' + ); + } + + /** + * Tests that untrashing the Privacy Policy page preserves the option, + * confirming the trash/restore cycle keeps the assignment intact. + * + * @ticket 56694 + */ + public function test_untrashing_privacy_policy_page_preserves_option(): void { + wp_trash_post( $this->policy_page_id ); + wp_untrash_post( $this->policy_page_id ); + + $this->assertSame( + $this->policy_page_id, + (int) get_option( 'wp_page_for_privacy_policy' ), + 'Untrashing the Privacy Policy page should preserve wp_page_for_privacy_policy.' + ); + } +} diff --git a/tests/phpunit/tests/rest-api/rest-attachments-controller.php b/tests/phpunit/tests/rest-api/rest-attachments-controller.php index 268ac019c3bc9..7f4dcd06c2f71 100644 --- a/tests/phpunit/tests/rest-api/rest-attachments-controller.php +++ b/tests/phpunit/tests/rest-api/rest-attachments-controller.php @@ -207,6 +207,18 @@ private function enable_client_side_media_processing(): void { do_action( 'rest_api_init', $wp_rest_server ); } + /** + * Turns client-side media processing off and rebuilds the REST server so the + * routes are registered with the feature disabled. + */ + private function disable_client_side_media_processing(): void { + add_filter( 'wp_client_side_media_processing_enabled', '__return_false' ); + + global $wp_rest_server; + $wp_rest_server = new Spy_REST_Server(); + do_action( 'rest_api_init', $wp_rest_server ); + } + public function test_register_routes() { $routes = rest_get_server()->get_routes(); $this->assertArrayHasKey( '/wp/v2/media', $routes ); @@ -3412,9 +3424,16 @@ public function test_upload_unsupported_image_type_with_filter() { * Tests the permissions check directly with file params set, since the core * check uses get_file_params() which is only populated for multipart uploads. * + * The check is only relaxed when client-side media processing is enabled, + * since that is what makes the client able to handle the image, so the + * feature is enabled here. + * * @ticket 64836 + * @ticket 65517 */ public function test_upload_unsupported_image_type_skipped_when_not_generating_sub_sizes() { + $this->enable_client_side_media_processing(); + wp_set_current_user( self::$author_id ); add_filter( 'wp_image_editors', '__return_empty_array' ); @@ -3875,6 +3894,175 @@ public function test_sideload_scaled_image() { $this->assertGreaterThan( 0, $metadata['filesize'], 'Filesize should be positive.' ); } + /** + * When the client generates sub-sizes (generate_sub_sizes is false), the + * server must not perform its own "big image" downscaling on upload. + * + * Otherwise the server creates a `-scaled` file and records the upload as + * `original_image`. The client's subsequent scaled sideload then collides + * with that `-scaled` file and is renamed `-scaled-1`, the thumbnails + * inherit the numbered name, and the server-generated full-size file is + * left orphaned on disk. + * + * @ticket 65708 + * @requires function imagejpeg + */ + public function test_create_item_skips_big_image_scaling_when_client_generates_sub_sizes() { + $this->enable_client_side_media_processing(); + + wp_set_current_user( self::$author_id ); + + // Force the threshold below the image's dimensions so scaling would be + // triggered were it not suppressed for client-side processing. + add_filter( + 'big_image_size_threshold', + static function () { + return 1000; + } + ); + + // Upload a large image with the client handling sub-size generation. + $request = new WP_REST_Request( 'POST', '/wp/v2/media' ); + $request->set_header( 'Content-Type', 'image/jpeg' ); + $request->set_header( 'Content-Disposition', 'attachment; filename=33772.jpg' ); + $request->set_param( 'generate_sub_sizes', false ); + $request->set_body( file_get_contents( DIR_TESTDATA . '/images/33772.jpg' ) ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + $attachment_id = $data['id']; + + $this->assertSame( 201, $response->get_status(), 'Uploading the image should succeed.' ); + + // The uploaded full-size image should be stored untouched: no + // server-side "-scaled" file and no original_image swap. + $original_file = get_attached_file( $attachment_id, true ); + $original_basename = wp_basename( $original_file ); + $original_name_stem = pathinfo( $original_basename, PATHINFO_FILENAME ); + $this->assertStringNotContainsString( '-scaled', $original_basename, 'The server should not create a -scaled file when the client generates sub-sizes.' ); + + $metadata = wp_get_attachment_metadata( $attachment_id ); + $this->assertArrayNotHasKey( 'original_image', $metadata, 'The server should not record an original_image when it does not scale the upload.' ); + + // The client's scaled sideload should now record the untouched upload as + // original_image and keep the -scaled name without a numeric suffix. + $request = new WP_REST_Request( 'POST', "/wp/v2/media/{$attachment_id}/sideload" ); + $request->set_header( 'Content-Type', 'image/jpeg' ); + $request->set_header( 'Content-Disposition', "attachment; filename={$original_name_stem}-scaled.jpg" ); + $request->set_param( 'image_size', 'scaled' ); + $request->set_body( file_get_contents( DIR_TESTDATA . '/images/33772.jpg' ) ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertSame( 200, $response->get_status(), 'Sideloading the scaled image should succeed.' ); + + $sub_size = $response->get_data(); + $this->assertSame( $original_basename, $sub_size['original_image'], 'The untouched upload should be recorded as original_image.' ); + $this->assertSame( "{$original_name_stem}-scaled.jpg", wp_basename( $sub_size['file'] ), 'The scaled sideload should keep the -scaled name without a numeric collision suffix.' ); + } + + /** + * The complete client-side flow for an image over the "big image" threshold + * should write only files that the metadata tracks, so that deleting the + * attachment removes all of them. + * + * When the server scales the upload as well, its own full-size file is + * never referenced by the metadata and survives "Delete Permanently", the + * client's scaled sideload collides with the server's "-scaled" file and is + * stored as "-scaled-1", and the sub-sizes inherit the numbered name. + * + * @ticket 65708 + * @covers WP_REST_Attachments_Controller::create_item + * @covers WP_REST_Attachments_Controller::sideload_item + * @covers WP_REST_Attachments_Controller::finalize_item + * @requires function imagejpeg + */ + public function test_client_side_big_image_flow_leaves_no_orphaned_files() { + $this->enable_client_side_media_processing(); + + wp_set_current_user( self::$author_id ); + + // Force the threshold below the uploaded image's dimensions so scaling + // would be triggered were it not suppressed for client-side processing. + add_filter( + 'big_image_size_threshold', + static function () { + return 1000; + } + ); + + $upload_dir = wp_upload_dir(); + $files_before = (array) glob( $upload_dir['path'] . '/*' ); + + // 1. Upload the full-size image; the client owns all the derivatives. + // 33772.jpg is 1920x1080, so it exceeds the threshold above. + $request = new WP_REST_Request( 'POST', '/wp/v2/media' ); + $request->set_header( 'Content-Type', 'image/jpeg' ); + $request->set_header( 'Content-Disposition', 'attachment; filename=big-photo.jpg' ); + $request->set_param( 'generate_sub_sizes', false ); + $request->set_body( file_get_contents( DIR_TESTDATA . '/images/33772.jpg' ) ); + $response = rest_get_server()->dispatch( $request ); + $attachment_id = $response->get_data()['id']; + + $this->assertSame( 201, $response->get_status(), 'Uploading the image should succeed.' ); + + /* + * 2. Sideload a thumbnail, as the client does for each sub-size. The + * client names it after the file it uploaded, so a server-side + * rename of that file is what pushes this into a collision. + * test-image.jpg is 50x50, within the registered thumbnail maximum. + */ + $request = new WP_REST_Request( 'POST', "/wp/v2/media/{$attachment_id}/sideload" ); + $request->set_header( 'Content-Type', 'image/jpeg' ); + $request->set_header( 'Content-Disposition', 'attachment; filename=big-photo-150x150.jpg' ); + $request->set_param( 'image_size', 'thumbnail' ); + $request->set_body( file_get_contents( DIR_TESTDATA . '/images/test-image.jpg' ) ); + $response = rest_get_server()->dispatch( $request ); + $thumbnail_data = $response->get_data(); + + $this->assertSame( 200, $response->get_status(), 'Sideloading the thumbnail should succeed.' ); + $this->assertSame( 'big-photo-150x150.jpg', wp_basename( $thumbnail_data['file'] ), 'The thumbnail should not inherit a numeric collision suffix.' ); + + // 3. Sideload the scaled full-size image. canola.jpg is 640x480, the + // size the client would have downscaled the upload to. + $request = new WP_REST_Request( 'POST', "/wp/v2/media/{$attachment_id}/sideload" ); + $request->set_header( 'Content-Type', 'image/jpeg' ); + $request->set_header( 'Content-Disposition', 'attachment; filename=big-photo-scaled.jpg' ); + $request->set_param( 'image_size', 'scaled' ); + $request->set_body( file_get_contents( self::$test_file ) ); + $response = rest_get_server()->dispatch( $request ); + $scaled_data = $response->get_data(); + + $this->assertSame( 200, $response->get_status(), 'Sideloading the scaled image should succeed.' ); + + // 4. Finalize, which writes the collected sub-size metadata in one pass. + $request = new WP_REST_Request( 'POST', "/wp/v2/media/{$attachment_id}/finalize" ); + $request->set_param( 'sub_sizes', array( $thumbnail_data, $scaled_data ) ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertSame( 200, $response->get_status(), 'Finalize should succeed.' ); + + $metadata = wp_get_attachment_metadata( $attachment_id ); + + $this->assertSame( 'big-photo.jpg', $metadata['original_image'], 'The untouched upload should be recorded as original_image.' ); + $this->assertSame( 'big-photo-scaled.jpg', wp_basename( $metadata['file'] ), 'The client-supplied scaled image should become the attached file.' ); + $this->assertSame( 'big-photo-150x150.jpg', $metadata['sizes']['thumbnail']['file'], 'The thumbnail should keep its dimension-based name.' ); + + // Every file written for this attachment must be reachable from the + // metadata, otherwise it is orphaned on disk. + $written = array_map( 'wp_basename', array_diff( (array) glob( $upload_dir['path'] . '/*' ), $files_before ) ); + sort( $written ); + $this->assertSame( + array( 'big-photo-150x150.jpg', 'big-photo-scaled.jpg', 'big-photo.jpg' ), + $written, + 'The flow should write only the full-size upload, its scaled copy, and the sub-sizes.' + ); + + // Deleting the attachment should therefore clean all of them up. + wp_delete_attachment( $attachment_id, true ); + + $remaining = array_diff( (array) glob( $upload_dir['path'] . '/*' ), $files_before ); + $this->assertSame( array(), array_values( $remaining ), 'Deleting the attachment should leave no files behind.' ); + } + /** * Tests that sideloading scaled image requires authentication. * @@ -5330,6 +5518,162 @@ public function test_create_item_from_url_returns_error_on_download_failure() { $this->assertSame( 500, $response->get_status() ); } + /** + * Verifies that the URL sideload path enforces the multisite maximum file + * size, for parity with the multipart and raw-body upload paths. + * + * @ticket 65517 + * @group multisite + * @group ms-required + * + * @covers WP_REST_Attachments_Controller::create_item_from_url + * @covers WP_REST_Attachments_Controller::check_upload_size + */ + public function test_create_item_from_url_exceeds_multisite_max_filesize() { + $this->enable_client_side_media_processing(); + + wp_set_current_user( self::$superadmin_id ); + update_site_option( 'fileupload_maxk', 1 ); + update_site_option( 'upload_space_check_disabled', false ); + + // Ensure ample space is available so the file-size limit is what rejects it. + add_filter( 'pre_get_space_used', '__return_zero' ); + add_filter( 'pre_http_request', array( $this, 'mock_image_download' ), 10, 3 ); + + $request = new WP_REST_Request( 'POST', '/wp/v2/media' ); + $request->set_param( 'url', 'https://example.com/too-big.jpg' ); + $request->set_param( 'generate_sub_sizes', false ); + + $response = rest_get_server()->dispatch( $request ); + + remove_filter( 'pre_http_request', array( $this, 'mock_image_download' ), 10 ); + + $this->assertErrorResponse( 'rest_upload_file_too_big', $response, 400 ); + } + + /** + * Verifies that the URL sideload path enforces the multisite site upload + * space quota, for parity with the multipart and raw-body upload paths. + * + * @ticket 65517 + * @group multisite + * @group ms-required + * + * @covers WP_REST_Attachments_Controller::create_item_from_url + * @covers WP_REST_Attachments_Controller::check_upload_size + */ + public function test_create_item_from_url_exceeds_multisite_site_upload_space() { + $this->enable_client_side_media_processing(); + + wp_set_current_user( self::$superadmin_id ); + add_filter( 'get_space_allowed', '__return_zero' ); + update_site_option( 'upload_space_check_disabled', false ); + + add_filter( 'pre_http_request', array( $this, 'mock_image_download' ), 10, 3 ); + + $request = new WP_REST_Request( 'POST', '/wp/v2/media' ); + $request->set_param( 'url', 'https://example.com/no-space.jpg' ); + $request->set_param( 'generate_sub_sizes', false ); + + $response = rest_get_server()->dispatch( $request ); + + remove_filter( 'pre_http_request', array( $this, 'mock_image_download' ), 10 ); + + $this->assertErrorResponse( 'rest_upload_limited_space', $response, 400 ); + } + + /** + * Verifies that the URL sideload path enforces the site's maximum upload + * size on single site as well as multisite. + * + * check_upload_size() returns early when ! is_multisite(), so before this + * check a single site had no ceiling at all on this path. + * + * @ticket 65517 + * + * @covers WP_REST_Attachments_Controller::create_item_from_url + */ + public function test_create_item_from_url_exceeds_max_upload_size() { + $this->enable_client_side_media_processing(); + + wp_set_current_user( self::$superadmin_id ); + + // The fixture the download is mocked with is comfortably larger than this. + add_filter( 'upload_size_limit', array( $this, 'filter_small_upload_size_limit' ), 20 ); + add_filter( 'pre_http_request', array( $this, 'mock_image_download' ), 10, 3 ); + + $request = new WP_REST_Request( 'POST', '/wp/v2/media' ); + $request->set_param( 'url', 'https://example.com/too-big.jpg' ); + $request->set_param( 'generate_sub_sizes', false ); + + $response = rest_get_server()->dispatch( $request ); + + remove_filter( 'pre_http_request', array( $this, 'mock_image_download' ), 10 ); + + $this->assertErrorResponse( 'rest_upload_file_too_big', $response, 400 ); + } + + /** + * Verifies that the download itself is bounded, so an oversized remote file + * is not written to disk in full before the size check rejects it. + * + * @ticket 65517 + * + * @covers WP_REST_Attachments_Controller::create_item_from_url + */ + public function test_create_item_from_url_limits_the_download_size() { + $this->enable_client_side_media_processing(); + + wp_set_current_user( self::$superadmin_id ); + + $request_args = null; + + $capture_args = static function ( $response, $args, $url ) use ( &$request_args ) { + $request_args = $args; + + if ( ! empty( $args['filename'] ) ) { + copy( DIR_TESTDATA . '/images/canola.jpg', $args['filename'] ); + } + + return array( + 'response' => array( + 'code' => 200, + 'message' => 'OK', + ), + 'headers' => array(), + 'cookies' => array(), + 'body' => '', + ); + }; + + add_filter( 'pre_http_request', $capture_args, 10, 3 ); + + $request = new WP_REST_Request( 'POST', '/wp/v2/media' ); + $request->set_param( 'url', 'https://example.com/photo.jpg' ); + $request->set_param( 'generate_sub_sizes', false ); + + rest_get_server()->dispatch( $request ); + + remove_filter( 'pre_http_request', $capture_args, 10 ); + + $this->assertIsArray( $request_args, 'The download request should have been made.' ); + $this->assertSame( + (int) wp_max_upload_size() + 1, + $request_args['limit_response_size'], + 'The download should be capped one byte past the maximum upload size.' + ); + } + + /** + * Filters the maximum upload size down to a value smaller than the image + * fixture used to mock the download. + * + * @return int A deliberately small upload size limit, in bytes. + */ + public function filter_small_upload_size_limit() { + return 1024; + } + /** * Verifies that a URL with no usable path bails with a 400 before any * download is attempted, rather than handing an empty filename to the @@ -5505,6 +5849,166 @@ public function test_url_registered_as_creatable_arg() { $this->assertSame( 'uri', $creatable['args']['url']['format'] ); } + /** + * Verifies that the media creation arguments are registered even when + * client-side media processing is disabled. + * + * The feature is determined per request, from the scheme and host, so gating + * the schema on it would advertise different arguments for the same site + * depending on how it was reached. + * + * @ticket 65517 + * + * @covers WP_REST_Attachments_Controller::get_endpoint_args_for_item_schema + */ + public function test_creatable_args_registered_without_client_side_media_processing() { + $this->disable_client_side_media_processing(); + + $routes = rest_get_server()->get_routes(); + $creatable = null; + foreach ( $routes['/wp/v2/media'] as $route ) { + if ( ! empty( $route['methods'][ WP_REST_Server::CREATABLE ] ) ) { + $creatable = $route; + break; + } + } + + $this->assertNotNull( $creatable, 'The media route should register a CREATABLE handler.' ); + $this->assertArrayHasKey( 'url', $creatable['args'] ); + $this->assertArrayHasKey( 'generate_sub_sizes', $creatable['args'] ); + $this->assertArrayHasKey( 'convert_format', $creatable['args'] ); + } + + /** + * Verifies that sideloading an external image works when client-side media + * processing is disabled. + * + * @ticket 65517 + * + * @covers WP_REST_Attachments_Controller::create_item + * @covers WP_REST_Attachments_Controller::create_item_from_url + */ + public function test_create_item_from_url_without_client_side_media_processing() { + $this->disable_client_side_media_processing(); + + wp_set_current_user( self::$superadmin_id ); + + add_filter( 'pre_http_request', array( $this, 'mock_image_download' ), 10, 3 ); + + $request = new WP_REST_Request( 'POST', '/wp/v2/media' ); + $request->set_param( 'url', 'https://example.com/photo.jpg' ); + + $response = rest_get_server()->dispatch( $request ); + + remove_filter( 'pre_http_request', array( $this, 'mock_image_download' ), 10 ); + + $data = $response->get_data(); + + $this->assertSame( 201, $response->get_status() ); + $this->assertSame( 'image', $data['media_type'] ); + $this->assertSame( 'https://example.com/photo.jpg', $this->last_download_url ); + } + + /** + * Verifies that the `url` argument's validation runs when client-side media + * processing is disabled, so an unsafe URL is rejected with a 400 rather than + * reaching the download. + * + * @ticket 65517 + * + * @covers WP_REST_Attachments_Controller::get_endpoint_args_for_item_schema + */ + public function test_url_arg_rejects_unsafe_urls_without_client_side_media_processing() { + $this->disable_client_side_media_processing(); + + wp_set_current_user( self::$superadmin_id ); + + $request = new WP_REST_Request( 'POST', '/wp/v2/media' ); + $request->set_param( 'url', 'http://127.0.0.1/private.jpg' ); + + $response = rest_get_server()->dispatch( $request ); + + $this->assertErrorResponse( 'rest_invalid_param', $response, 400 ); + } + + /** + * Verifies that `generate_sub_sizes` is honored when client-side media + * processing is disabled. + * + * Skipping sub-size generation is a request the server can carry out on its + * own, so it does not depend on the feature. Sub-sizes can still be added + * later with wp_update_image_subsizes(). + * + * @ticket 65517 + * + * @covers WP_REST_Attachments_Controller::create_item + */ + public function test_generate_sub_sizes_honored_without_client_side_media_processing() { + $this->disable_client_side_media_processing(); + + wp_set_current_user( self::$superadmin_id ); + + add_filter( 'pre_http_request', array( $this, 'mock_image_download' ), 10, 3 ); + + $request = new WP_REST_Request( 'POST', '/wp/v2/media' ); + $request->set_param( 'url', 'https://example.com/photo.jpg' ); + $request->set_param( 'generate_sub_sizes', false ); + + $response = rest_get_server()->dispatch( $request ); + + remove_filter( 'pre_http_request', array( $this, 'mock_image_download' ), 10 ); + + $data = $response->get_data(); + + $this->assertSame( 201, $response->get_status() ); + + $metadata = wp_get_attachment_metadata( $data['id'], true ); + $this->assertEmpty( + $metadata['sizes'] ?? array(), + 'Sub-sizes should not be generated when generate_sub_sizes is false.' + ); + } + + /** + * Verifies that `generate_sub_sizes` does not relax the unsupported image + * type check when client-side media processing is disabled. + * + * That check exists because the server cannot process the image, so it should + * only be relaxed when the client can process it instead. Otherwise the + * upload is stored unprocessable. + * + * @ticket 65517 + * + * @covers WP_REST_Attachments_Controller::create_item_permissions_check + */ + public function test_unsupported_image_type_still_checked_without_client_side_media_processing() { + $this->disable_client_side_media_processing(); + + wp_set_current_user( self::$author_id ); + + add_filter( 'wp_image_editors', '__return_empty_array' ); + + $request = new WP_REST_Request( 'POST', '/wp/v2/media' ); + $request->set_file_params( + array( + 'file' => array( + 'name' => 'avif-lossy.avif', + 'type' => 'image/avif', + 'tmp_name' => self::$test_avif_file, + 'error' => 0, + 'size' => filesize( self::$test_avif_file ), + ), + ) + ); + $request->set_param( 'generate_sub_sizes', false ); + + $controller = new WP_REST_Attachments_Controller( 'attachment' ); + $result = $controller->create_item_permissions_check( $request ); + + $this->assertWPError( $result ); + $this->assertSame( 'rest_upload_image_type_not_supported', $result->get_error_code() ); + } + /** * Verifies that the `url` argument rejects values that are not safe to * request server-side, guarding the sideload against SSRF. diff --git a/tests/phpunit/tests/rest-api/rest-autosaves-controller.php b/tests/phpunit/tests/rest-api/rest-autosaves-controller.php index 7815f8ced23c9..179ce60e047df 100644 --- a/tests/phpunit/tests/rest-api/rest-autosaves-controller.php +++ b/tests/phpunit/tests/rest-api/rest-autosaves-controller.php @@ -9,21 +9,21 @@ * @group restapi */ class WP_Test_REST_Autosaves_Controller extends WP_Test_REST_Post_Type_Controller_Testcase { - protected static $post_id; - protected static $page_id; - protected static $draft_page_id; + protected static int $post_id; + protected static int $page_id; + protected static int $draft_page_id; - protected static $autosave_post_id; - protected static $autosave_page_id; + protected static int $autosave_post_id; + protected static int $autosave_page_id; - protected static $editor_id; - protected static $contributor_id; + protected static int $editor_id; + protected static int $contributor_id; - protected static $parent_page_id; - protected static $child_page_id; - protected static $child_draft_page_id; + protected static int $parent_page_id; + protected static int $child_page_id; + protected static int $child_draft_page_id; - private $post_autosave; + private WP_Post $post_autosave; protected function set_post_data( $args = array() ) { $defaults = array( @@ -731,16 +731,46 @@ protected function check_get_autosave_response( $response, $autosave ) { $this->assertSame( rest_url( '/wp/v2/' . $parent_base . '/' . $autosave->post_parent ), $links['parent'][0]['href'] ); } - public function test_get_item_sets_up_postdata() { + /** + * The autosave's postdata should be set up while preparing the response, + * so rendered fields reflect the autosave, without leaking into the global + * post after the request completes. + * + * @ticket 65495 + * + * @global int|null $id ID from the set up global post data. + * + * @covers WP_REST_Autosaves_Controller::prepare_item_for_response + */ + public function test_get_item_sets_up_postdata_without_leaking_global_post() { + global $id; + + // Populate the global $wp_query with the post and set it up. wp_set_current_user( self::$editor_id ); - $request = new WP_REST_Request( 'GET', '/wp/v2/posts/' . self::$post_id . '/autosaves/' . self::$autosave_post_id ); - rest_get_server()->dispatch( $request ); + query_posts( array( 'p' => self::$post_id ) ); + the_post(); - $post = get_post(); - $parent_post_id = wp_is_post_revision( $post->ID ); + // Assert initial state. + $post = get_post(); + $this->assertInstanceOf( WP_Post::class, $post, 'The global post should be set up before the request.' ); + $this->assertSame( self::$post_id, $post->ID, 'The global post should be the parent post before the request.' ); + $this->assertSame( self::$post_id, $id, 'The global $id should be the parent post ID before the request.' ); - $this->assertSame( $post->ID, self::$autosave_post_id ); - $this->assertSame( $parent_post_id, self::$post_id ); + // Make the request to get the autosave. + $request = new WP_REST_Request( 'GET', '/wp/v2/posts/' . self::$post_id . '/autosaves/' . self::$autosave_post_id ); + $response = rest_get_server()->dispatch( $request ); + $this->assertSame( 200, $response->get_status() ); + $data = $response->get_data(); + $this->assertIsArray( $data ); + $this->assertArrayHasKey( 'title', $data ); + $this->assertIsArray( $data['title'] ); + $this->assertSame( get_the_title( self::$autosave_post_id ), $data['title']['rendered'], 'Expected the rendered title to reflect the autosave, proving postdata was set up during preparation.' ); + + // The global post is restored to the post that was set before the request. + $post = get_post(); + $this->assertInstanceOf( WP_Post::class, $post, 'The global post should still be set after the request.' ); + $this->assertSame( self::$post_id, $post->ID, 'The global post should be restored to the post that was set before the request.' ); + $this->assertSame( self::$post_id, $id, 'The global $id should be restored to the post that was set before the request.' ); } public function test_update_item_draft_page_with_parent() { diff --git a/tests/phpunit/tests/rest-api/rest-block-type-controller.php b/tests/phpunit/tests/rest-api/rest-block-type-controller.php index 7ba693286c993..3cf8c5244d77c 100644 --- a/tests/phpunit/tests/rest-api/rest-block-type-controller.php +++ b/tests/phpunit/tests/rest-api/rest-block-type-controller.php @@ -750,11 +750,8 @@ public function test_get_item_no_permission( $method ) { } /** - * @dataProvider data_readable_http_methods * @ticket 47620 * @ticket 56481 - * - * @param string $method HTTP method to use. */ public function test_prepare_item() { $registry = new WP_Block_Type_Registry(); diff --git a/tests/phpunit/tests/rest-api/rest-comments-controller.php b/tests/phpunit/tests/rest-api/rest-comments-controller.php index 8542bcd42af24..7162b278839d5 100644 --- a/tests/phpunit/tests/rest-api/rest-comments-controller.php +++ b/tests/phpunit/tests/rest-api/rest-comments-controller.php @@ -3646,7 +3646,7 @@ public static function data_head_request_with_specified_fields_returns_success_r /** * Create a test post with note. * - * @param int $user_id Post author's user ID. + * @param string $role User role to assign the post author. * @return int Post ID. */ protected function create_test_post_with_note( $role ) { diff --git a/tests/phpunit/tests/rest-api/rest-post-meta-fields.php b/tests/phpunit/tests/rest-api/rest-post-meta-fields.php index 5ce72a57fa55f..0f8584f469892 100644 --- a/tests/phpunit/tests/rest-api/rest-post-meta-fields.php +++ b/tests/phpunit/tests/rest-api/rest-post-meta-fields.php @@ -2348,7 +2348,7 @@ public function test_update_meta_with_unchanged_values_and_custom_authentication $this->assertSame( 200, $response->get_status() ); $data = $response->get_data(); - $this->assertSame( false, $data['meta']['authenticated'] ); + $this->assertFalse( $data['meta']['authenticated'] ); } /** diff --git a/tests/phpunit/tests/rest-api/rest-revisions-controller.php b/tests/phpunit/tests/rest-api/rest-revisions-controller.php index 52011afcb9318..71b333fa39fd4 100644 --- a/tests/phpunit/tests/rest-api/rest-revisions-controller.php +++ b/tests/phpunit/tests/rest-api/rest-revisions-controller.php @@ -8,22 +8,24 @@ * @group restapi */ class WP_Test_REST_Revisions_Controller extends WP_Test_REST_Controller_Testcase { - protected static $post_id; - protected static $post_id_2; - protected static $page_id; + protected static int $post_id; + protected static int $post_id_2; + protected static int $page_id; - protected static $editor_id; - protected static $contributor_id; + protected static int $editor_id; + protected static int $contributor_id; + + private int $total_revisions; - private $total_revisions; private $revisions; - private $revision_1; - private $revision_id1; - private $revision_2; - private $revision_id2; - private $revision_3; - private $revision_id3; - private $revision_2_1_id; + + private WP_Post $revision_1; + private int $revision_id1; + private WP_Post $revision_2; + private int $revision_id2; + private WP_Post $revision_3; + private int $revision_id3; + private int $revision_2_1_id; public static function wpSetUpBeforeClass( WP_UnitTest_Factory $factory ) { self::$post_id = $factory->post->create(); @@ -266,6 +268,266 @@ public function test_get_item() { $this->assertSame( self::$editor_id, $data['author'] ); } + /** + * Preparing a revision must not leak the revision into the global post. + * + * @ticket 65495 + * + * @global int|null $id ID from the set up global post data. + * + * @covers WP_REST_Revisions_Controller::prepare_item_for_response + */ + public function test_get_items_restores_global_post() { + global $id; + + // Populate the global $wp_query with the post and set it up. + wp_set_current_user( self::$editor_id ); + query_posts( array( 'p' => self::$post_id ) ); + the_post(); + + // Assert initial state. + $post = get_post(); + $this->assertInstanceOf( WP_Post::class, $post, 'The global post should be set up before the request.' ); + $this->assertSame( self::$post_id, $post->ID, 'The global post should be the parent post before the request.' ); + $this->assertSame( self::$post_id, $id, 'The global $id should be the parent post ID before the request.' ); + + // Capture the arguments the rest_prepare_revision filter receives. + $mock = new MockAction(); + add_filter( 'rest_prepare_revision', array( $mock, 'filter' ), 10, 3 ); + + // Make the request to get revisions. + $request = new WP_REST_Request( 'GET', '/wp/v2/posts/' . self::$post_id . '/revisions' ); + $request->set_param( 'context', 'edit' ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertSame( 200, $response->get_status() ); + + // The filter is passed each revision, not the global post restored afterwards. + $this->check_rest_prepare_revision_filter_args( + $mock, + array( $this->revision_id3, $this->revision_id2, $this->revision_id1 ), + $request + ); + + // The global post is restored to the post that was set before the request. + $post = get_post(); + $this->assertInstanceOf( WP_Post::class, $post, 'The global post should still be set after the request.' ); + $this->assertSame( self::$post_id, $post->ID, 'The global post should be restored to the post that was set before the request.' ); + $this->assertSame( self::$post_id, $id, 'The global $id should be restored to the post that was set before the request.' ); + } + + /** + * Preparing a revision for a HEAD request must also restore the global post. + * + * The collection endpoint short-circuits before preparing items for HEAD + * requests, so the single revision endpoint is used to reach the HEAD + * branch of prepare_item_for_response(). + * + * @ticket 65495 + * + * @global int|null $id ID from the set up global post data. + * + * @covers WP_REST_Revisions_Controller::prepare_item_for_response + */ + public function test_get_item_head_request_restores_global_post() { + global $id; + + // Populate the global $wp_query with the post and set it up. + wp_set_current_user( self::$editor_id ); + query_posts( array( 'p' => self::$post_id ) ); + the_post(); + + // Assert initial state. + $post = get_post(); + $this->assertInstanceOf( WP_Post::class, $post, 'The global post should be set up before the request.' ); + $this->assertSame( self::$post_id, $post->ID, 'The global post should be the parent post before the request.' ); + $this->assertSame( self::$post_id, $id, 'The global $id should be the parent post ID before the request.' ); + + // Capture the arguments the rest_prepare_revision filter receives. + $mock = new MockAction(); + add_filter( 'rest_prepare_revision', array( $mock, 'filter' ), 10, 3 ); + + // Make the HEAD request to get a revision. + $request = new WP_REST_Request( 'HEAD', '/wp/v2/posts/' . self::$post_id . '/revisions/' . $this->revision_id1 ); + $request->set_param( 'context', 'edit' ); + $response = rest_get_server()->dispatch( $request ); + $this->assertSame( 200, $response->get_status() ); + + // The filter is passed the revision, not the global post restored afterwards. + $this->check_rest_prepare_revision_filter_args( $mock, array( $this->revision_id1 ), $request ); + + // The global post is restored to the post that was set before the request. + $post = get_post(); + $this->assertInstanceOf( WP_Post::class, $post, 'The global post should still be set after the request.' ); + $this->assertSame( self::$post_id, $post->ID, 'The global post should be restored to the post that was set before the request.' ); + $this->assertSame( self::$post_id, $id, 'The global $id should be restored to the post that was set before the request.' ); + } + + /** + * When there is no global post before the request, none should be set afterwards. + * + * @ticket 65495 + * + * @global WP_Query|null $wp_query The global WP_Query. + * + * @covers WP_REST_Revisions_Controller::prepare_item_for_response + */ + public function test_get_items_without_global_post_leaves_it_unset() { + global $wp_query; + + // Leave the global $wp_query without a post, so there is no post data to restore. + wp_set_current_user( self::$editor_id ); + $this->assertInstanceOf( WP_Query::class, $wp_query, 'The WP_Query global must be set for wp_reset_postdata() to have anything to restore from.' ); + $this->assertNull( get_post(), 'The global post should not have been initially set.' ); + + // Capture the arguments the rest_prepare_revision filter receives. + $mock = new MockAction(); + add_filter( 'rest_prepare_revision', array( $mock, 'filter' ), 10, 3 ); + + // Make the request to get a revision. + $request = new WP_REST_Request( 'GET', '/wp/v2/posts/' . self::$post_id . '/revisions' ); + $request->set_param( 'context', 'edit' ); + $response = rest_get_server()->dispatch( $request ); + $this->assertSame( 200, $response->get_status() ); + + // The filter is passed each revision, even though there is no global post to restore. + $this->check_rest_prepare_revision_filter_args( + $mock, + array( $this->revision_id3, $this->revision_id2, $this->revision_id1 ), + $request + ); + + /* + * Note: At this point, the global $id is still populated because there was no $wp_query->post to begin with, + * so WP_Query::reset_postdata() has nothing to set it to. It does not null out any globals when there is no post. + */ + $this->assertNull( get_post(), 'The global post should not be set when there was none before the request.' ); + } + + /** + * A main query with a post must not cause a global post to be set where there was none. + * + * The restore calls wp_reset_postdata() to clear the revision's post data, and that + * repopulates the global post from the main query. The global post must be unset + * afterwards so the request does not introduce one that was not there before. + * + * @ticket 65495 + * + * @global WP_Query|null $wp_query The global WP_Query. + * @global int|null $id ID from the set up global post data. + * + * @covers WP_REST_Revisions_Controller::prepare_item_for_response + */ + public function test_get_items_without_global_post_leaves_it_unset_when_main_query_has_post() { + global $wp_query, $id; + + /* + * Populate the main query with the post, but do not run the loop, so the main + * query has a post to restore from while the global post remains unset. + */ + wp_set_current_user( self::$editor_id ); + query_posts( array( 'p' => self::$post_id ) ); + + // Assert initial state. + $this->assertInstanceOf( WP_Query::class, $wp_query, 'The WP_Query global must be set for wp_reset_postdata() to have anything to restore from.' ); + $this->assertInstanceOf( WP_Post::class, $wp_query->post, 'The main query should have a post for wp_reset_postdata() to restore from.' ); + $this->assertSame( self::$post_id, $wp_query->post->ID, 'The main query should have the parent post before the request.' ); + $this->assertNull( get_post(), 'The global post should not have been initially set.' ); + + // Make the request to get revisions. + $request = new WP_REST_Request( 'GET', '/wp/v2/posts/' . self::$post_id . '/revisions' ); + $request->set_param( 'context', 'edit' ); + $response = rest_get_server()->dispatch( $request ); + $this->assertSame( 200, $response->get_status() ); + + $this->assertNull( get_post(), 'The global post should not be set when there was none before the request, even though the main query has a post.' ); + $this->assertSame( self::$post_id, $id, 'The remaining post data should be reset to the main query post rather than left on the revision.' ); + } + + /** + * A filter that reassigns the global post must not change the revision being prepared. + * + * The revision is held in a method-local variable rather than the global post, so a + * filter which swaps the global out mid-preparation (as a plugin running a secondary + * loop on 'the_content' may do) cannot retarget the fields prepared after it. + * + * @ticket 65495 + * + * @covers WP_REST_Revisions_Controller::prepare_item_for_response + */ + public function test_prepare_item_for_response_is_unaffected_by_a_filter_reassigning_the_global_post() { + wp_set_current_user( self::$editor_id ); + + $decoy_post = get_post( self::$post_id_2 ); + $this->assertInstanceOf( WP_Post::class, $decoy_post ); + $this->assertNotSame( $this->revision_1->post_excerpt, $decoy_post->post_excerpt, 'The decoy post must have a different excerpt for this test to be meaningful.' ); + + // Simulate a plugin that leaves a different post in the global while filtering the content. + add_filter( + 'the_content', + static function ( $content ) use ( $decoy_post ) { + $GLOBALS['post'] = $decoy_post; + return $content; + } + ); + + // Capture the post the rest_prepare_revision filter receives. + $mock = new MockAction(); + add_filter( 'rest_prepare_revision', array( $mock, 'filter' ), 10, 3 ); + + $request = new WP_REST_Request( 'GET', '/wp/v2/posts/' . self::$post_id . '/revisions/' . $this->revision_id1 ); + $request->set_param( 'context', 'edit' ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertSame( 200, $response->get_status() ); + + // Fields prepared after 'the_content' still come from the revision. + $data = $response->get_data(); + $this->assertIsArray( $data ); + $this->assertArrayHasKey( 'excerpt', $data ); + $this->assertIsArray( $data['excerpt'] ); + $this->assertSame( $this->revision_id1, $data['id'], 'The prepared id should be the revision, not the post left in the global by the filter.' ); + $this->assertSame( $this->revision_1->post_excerpt, $data['excerpt']['raw'], 'The prepared excerpt should come from the revision, not the post left in the global by the filter.' ); + + // The filter is still passed the revision. + $this->check_rest_prepare_revision_filter_args( $mock, array( $this->revision_id1 ), $request ); + } + + /** + * Clearing the global post must not detach an existing `global $post` binding. + * + * `global $post` binds a caller to the global symbol table entry. Unsetting that + * entry detaches the binding, so a caller which sets the post after the request + * would be writing somewhere get_post() can no longer see. Note that the caller's + * own `global $post` is what creates the entry as null, which is why there is no + * previous global post to restore here. + * + * @ticket 65495 + * + * @global WP_Post|null $post Global post object. + * + * @covers WP_REST_Revisions_Controller::prepare_item_for_response + */ + public function test_prepare_item_for_response_does_not_detach_an_existing_global_post_binding() { + // Bind to the global post the way a caller does before dispatching a request. + global $post; + + wp_set_current_user( self::$editor_id ); + $this->assertNull( $post, 'The global post should not be set before the request.' ); + + $request = new WP_REST_Request( 'GET', '/wp/v2/posts/' . self::$post_id . '/revisions/' . $this->revision_id1 ); + $request->set_param( 'context', 'edit' ); + $response = rest_get_server()->dispatch( $request ); + $this->assertSame( 200, $response->get_status() ); + + // The caller sets the global post, expecting template tags to pick it up. + $post = get_post( self::$post_id ); + + $global_post = get_post(); + $this->assertInstanceOf( WP_Post::class, $global_post, 'get_post() should see the post set through the binding after the request.' ); + $this->assertSame( self::$post_id, $global_post->ID, 'get_post() should return the post the caller set, not a stale or detached value.' ); + } + /** * @dataProvider data_readable_http_methods * @ticket 56481 @@ -601,6 +863,33 @@ public function additional_field_update_callback( $value, $post, $field_name ) { update_post_meta( $post->ID, $field_name, $value ); } + /** + * Checks the arguments the rest_prepare_revision filter received. + * + * The filter must be passed the revision that was prepared. Restoring the + * global post afterwards must not replace it with the previous global post. + * + * @param MockAction $mock Mock registered on the rest_prepare_revision filter. + * @param int[] $revision_ids Expected revision IDs, in the order the filter is expected to fire. + * @param WP_REST_Request $request Request the revisions were prepared for. + */ + private function check_rest_prepare_revision_filter_args( MockAction $mock, array $revision_ids, WP_REST_Request $request ): void { + $filter_args = $mock->get_args(); + + $this->assertCount( count( $revision_ids ), $filter_args, 'The rest_prepare_revision filter should fire once per prepared revision.' ); + + foreach ( $filter_args as $index => $args ) { + $call = 'Filter call ' . $index . ': '; + + $this->assertCount( 3, $args, $call . 'the filter should receive three arguments.' ); + $this->assertInstanceOf( WP_REST_Response::class, $args[0], $call . 'the first argument should be the response.' ); + $this->assertInstanceOf( WP_Post::class, $args[1], $call . 'the second argument should be a post object.' ); + $this->assertSame( 'revision', $args[1]->post_type, $call . 'the second argument should be a revision, not the restored global post.' ); + $this->assertSame( $revision_ids[ $index ], $args[1]->ID, $call . 'the second argument should be the revision that was prepared.' ); + $this->assertSame( $request, $args[2], $call . 'the third argument should be the request.' ); + } + } + protected function check_get_revision_response( $response, $revision ) { if ( $response instanceof WP_REST_Response ) { $links = $response->get_links(); @@ -639,16 +928,54 @@ protected function check_get_revision_response( $response, $revision ) { $this->assertSame( rest_url( '/wp/v2/' . $parent_base . '/' . $revision->post_parent ), $links['parent'][0]['href'] ); } - public function test_get_item_sets_up_postdata() { + /** + * The revision's postdata should be set up while preparing the response, + * so rendered fields reflect the revision, without leaking into the global + * post after the request completes. + * + * @ticket 65495 + * + * @global int|null $id ID from the set up global post data. + * + * @covers WP_REST_Revisions_Controller::prepare_item_for_response + */ + public function test_get_item_sets_up_postdata_without_leaking_global_post() { + global $id; + + // Populate the global $wp_query with the post and set it up. wp_set_current_user( self::$editor_id ); - $request = new WP_REST_Request( 'GET', '/wp/v2/posts/' . self::$post_id . '/revisions/' . $this->revision_id1 ); - rest_get_server()->dispatch( $request ); + query_posts( array( 'p' => self::$post_id ) ); + the_post(); + + // Assert initial state. + $post = get_post(); + $this->assertInstanceOf( WP_Post::class, $post, 'The global post should be set up before the request.' ); + $this->assertSame( self::$post_id, $post->ID, 'The global post should be the parent post before the request.' ); + $this->assertSame( self::$post_id, $id, 'The global $id should be the parent post ID before the request.' ); + + // Capture the arguments the rest_prepare_revision filter receives. + $mock = new MockAction(); + add_filter( 'rest_prepare_revision', array( $mock, 'filter' ), 10, 3 ); + + // Make the request to get a revision. + $request = new WP_REST_Request( 'GET', '/wp/v2/posts/' . self::$post_id . '/revisions/' . $this->revision_id1 ); + $response = rest_get_server()->dispatch( $request ); + $this->assertSame( 200, $response->get_status() ); - $post = get_post(); - $parent_post_id = wp_is_post_revision( $post->ID ); + // The filter is passed the revision, not the global post restored afterwards. + $this->check_rest_prepare_revision_filter_args( $mock, array( $this->revision_id1 ), $request ); - $this->assertSame( $post->ID, $this->revision_id1 ); - $this->assertSame( $parent_post_id, self::$post_id ); + $data = $response->get_data(); + $this->assertIsArray( $data ); + $this->assertArrayHasKey( 'title', $data ); + $this->assertIsArray( $data['title'] ); + $this->assertSame( get_the_title( $this->revision_id1 ), $data['title']['rendered'], 'Expected the rendered title to reflect the revision, proving postdata was set up during preparation.' ); + + // The global post is restored to the post that was set before the request. + $post = get_post(); + $this->assertInstanceOf( WP_Post::class, $post, 'The global post should still be set after the request.' ); + $this->assertSame( self::$post_id, $post->ID, 'The global post should be restored to the post that was set before the request.' ); + $this->assertSame( self::$post_id, $id, 'The global $id should be restored to the post that was set before the request.' ); } /** diff --git a/tests/phpunit/tests/rest-api/rest-search-controller.php b/tests/phpunit/tests/rest-api/rest-search-controller.php index e4235fd699798..c996e49147a0f 100644 --- a/tests/phpunit/tests/rest-api/rest-search-controller.php +++ b/tests/phpunit/tests/rest-api/rest-search-controller.php @@ -360,13 +360,14 @@ public function test_get_items_search_type_invalid( $method ) { * * @param string $method HTTP method to use. */ - public function test_get_items_search_type_post_subtype_invalid() { + public function test_get_items_search_type_post_subtype_invalid( $method ) { $response = $this->do_request_with_params( array( 'per_page' => 100, 'type' => 'post', 'subtype' => 'invalid', - ) + ), + $method ); $this->assertErrorResponse( 'rest_invalid_param', $response, 400 ); diff --git a/tests/phpunit/tests/rest-api/rest-view-config-controller.php b/tests/phpunit/tests/rest-api/rest-view-config-controller.php index 9bfbdd67c7021..34fcd55e2466d 100644 --- a/tests/phpunit/tests/rest-api/rest-view-config-controller.php +++ b/tests/phpunit/tests/rest-api/rest-view-config-controller.php @@ -385,4 +385,29 @@ public function test_get_item_schema() { array_keys( $schema['properties'] ) ); } + + /** + * `search` and `page` are not part of the view schema: they are managed via + * the URL, which is their only source of truth. + * + * @covers ::get_item_schema + */ + public function test_get_item_schema_excludes_url_managed_view_properties() { + $controller = new WP_REST_View_Config_Controller(); + $schema = $controller->get_item_schema(); + + $views = array( + 'default_view' => $schema['properties']['default_view']['properties'], + 'view_list item view' => $schema['properties']['view_list']['items']['properties']['view']['properties'], + 'default_layouts.table' => $schema['properties']['default_layouts']['properties']['table']['properties'], + 'default_layouts.grid' => $schema['properties']['default_layouts']['properties']['grid']['properties'], + 'default_layouts.list' => $schema['properties']['default_layouts']['properties']['list']['properties'], + 'default_layouts.activity' => $schema['properties']['default_layouts']['properties']['activity']['properties'], + ); + + foreach ( $views as $label => $properties ) { + $this->assertArrayNotHasKey( 'search', $properties, "$label should not declare a `search` property." ); + $this->assertArrayNotHasKey( 'page', $properties, "$label should not declare a `page` property." ); + } + } } diff --git a/tests/phpunit/tests/rest-api/wpRestBlockPatternCategoriesController.php b/tests/phpunit/tests/rest-api/wpRestBlockPatternCategoriesController.php index 408021b039d31..8962b330a2a9d 100644 --- a/tests/phpunit/tests/rest-api/wpRestBlockPatternCategoriesController.php +++ b/tests/phpunit/tests/rest-api/wpRestBlockPatternCategoriesController.php @@ -141,8 +141,6 @@ public function test_get_items_with_head_request_should_not_prepare_block_patter /** * @ticket 56481 - * - * @param string $path The path to test. */ public function test_head_request_with_specified_fields_returns_success_response() { wp_set_current_user( self::$admin_id ); diff --git a/tests/phpunit/tests/rest-api/wpRestMenusController.php b/tests/phpunit/tests/rest-api/wpRestMenusController.php index 864b09417d2cb..46f9877e3cfc0 100644 --- a/tests/phpunit/tests/rest-api/wpRestMenusController.php +++ b/tests/phpunit/tests/rest-api/wpRestMenusController.php @@ -316,7 +316,7 @@ public function test_update_item() { $data = $response->get_data(); $this->assertSame( 'New Name', $data['name'] ); $this->assertSame( 'New Description', $data['description'] ); - $this->assertSame( true, $data['auto_add'] ); + $this->assertTrue( $data['auto_add'] ); $this->assertSame( 'new-name', $data['slug'] ); $this->assertSame( 'just meta', $data['meta']['test_single_menu'] ); $this->assertFalse( isset( $data['meta']['test_cat_meta'] ) ); diff --git a/tests/phpunit/tests/rest-api/wpRestTemplatesController.php b/tests/phpunit/tests/rest-api/wpRestTemplatesController.php index 42eed8dfa9c35..e8ff29beaac33 100644 --- a/tests/phpunit/tests/rest-api/wpRestTemplatesController.php +++ b/tests/phpunit/tests/rest-api/wpRestTemplatesController.php @@ -638,6 +638,26 @@ public function test_get_item_from_registry() { $this->assertSame( 404, $response->get_status(), 'Fetching an unregistered template should return 404.' ); } + /** + * A file-backed template has no publication or modification date, which should + * be exposed as `null` rather than the `false` returned by `mysql_to_rfc3339()`. + * + * @ticket 65728 + * @covers WP_REST_Templates_Controller::prepare_item_for_response + */ + public function test_get_item_dates_are_null_for_file_backed_template() { + wp_set_current_user( self::$admin_id ); + switch_theme( 'block-theme' ); + + $request = new WP_REST_Request( 'GET', '/wp/v2/templates/block-theme//page-home' ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertSame( 200, $response->get_status(), 'Fetching a file-backed template should return 200.' ); + $this->assertNull( $data['date'], 'The date should be null for a file-backed template.' ); + $this->assertNull( $data['modified'], 'The modified date should be null for a file-backed template.' ); + } + /** * @ticket 54507 * @dataProvider data_sanitize_template_id diff --git a/tests/phpunit/tests/rewrite.php b/tests/phpunit/tests/rewrite.php index 2bb7254abfcef..24c7e4e1459fc 100644 --- a/tests/phpunit/tests/rewrite.php +++ b/tests/phpunit/tests/rewrite.php @@ -167,7 +167,7 @@ public function test_url_to_postid_of_http_site_when_current_site_uses_https() { * @param string $url The complete home URL including scheme and path. * @param string $path Path relative to the home URL. Blank string if no path is specified. * @param string|null $orig_scheme Scheme to give the home URL context. - * @param int|null $blog_id Site ID, or null for the current site. + * @param int|null $_blog_id Site ID, or null for the current site. * @return string The complete home URL including scheme and path. */ public function filter_http_home_url( $url, $path, $orig_scheme, $_blog_id ) { diff --git a/tests/phpunit/tests/theme/wpThemeJson.php b/tests/phpunit/tests/theme/wpThemeJson.php index e61d618fed286..130bd5ae9d323 100644 --- a/tests/phpunit/tests/theme/wpThemeJson.php +++ b/tests/phpunit/tests/theme/wpThemeJson.php @@ -805,6 +805,7 @@ public function test_get_stylesheet_renders_enabled_protected_properties() { /** * @ticket 52991 * @ticket 54336 + * @ticket 65724 */ public function test_get_stylesheet_preset_classes_work_with_compounded_selectors() { $theme_json = new WP_Theme_JSON( @@ -828,7 +829,7 @@ public function test_get_stylesheet_preset_classes_work_with_compounded_selector ); $this->assertSame( - '.wp-block-heading.has-white-color{color: var(--wp--preset--color--white) !important;}.wp-block-heading.has-white-background-color{background-color: var(--wp--preset--color--white) !important;}.wp-block-heading.has-white-border-color{border-color: var(--wp--preset--color--white) !important;}', + ':where(.wp-block-heading).has-white-color{color: var(--wp--preset--color--white) !important;}:where(.wp-block-heading).has-white-background-color{background-color: var(--wp--preset--color--white) !important;}:where(.wp-block-heading).has-white-border-color{border-color: var(--wp--preset--color--white) !important;}', $theme_json->get_stylesheet( array( 'presets' ) ) ); } @@ -894,6 +895,7 @@ public function test_get_stylesheet_preset_css_vars_use_feature_selector() { * @ticket 58550 * @ticket 60936 * @ticket 61165 + * @ticket 65724 */ public function test_get_stylesheet_preset_rules_come_after_block_rules() { $theme_json = new WP_Theme_JSON( @@ -926,7 +928,7 @@ public function test_get_stylesheet_preset_rules_come_after_block_rules() { ); $styles = ':root :where(.wp-block-group){color: red;}'; - $presets = '.wp-block-group.has-grey-color{color: var(--wp--preset--color--grey) !important;}.wp-block-group.has-grey-background-color{background-color: var(--wp--preset--color--grey) !important;}.wp-block-group.has-grey-border-color{border-color: var(--wp--preset--color--grey) !important;}'; + $presets = ':where(.wp-block-group).has-grey-color{color: var(--wp--preset--color--grey) !important;}:where(.wp-block-group).has-grey-background-color{background-color: var(--wp--preset--color--grey) !important;}:where(.wp-block-group).has-grey-border-color{border-color: var(--wp--preset--color--grey) !important;}'; $variables = '.wp-block-group{--wp--preset--color--grey: grey;}'; $all = $variables . $styles . $presets; diff --git a/tests/phpunit/tests/user.php b/tests/phpunit/tests/user.php index f600adbcb1164..eba28c38f2cf1 100644 --- a/tests/phpunit/tests/user.php +++ b/tests/phpunit/tests/user.php @@ -119,6 +119,47 @@ public function test_get_users_of_blog() { $this->assertSameSets( $nusers, $found ); } + /** + * Tests that accent-distinct Unicode email addresses are not treated as duplicates. + * + * @ticket 31992 + */ + public function test_unicode_email_addresses_with_distinct_accents_are_not_duplicates() { + global $wpdb; + + if ( 'utf8mb4' !== $wpdb->charset ) { + $this->markTestSkipped( 'The test database does not use utf8mb4.' ); + } + + if ( ! function_exists( 'idn_to_ascii' ) ) { + $this->markTestSkipped( 'idn_to_ascii() is unavailable.' ); + } + + $emails = array( + "josejose@gr\u{00E5}.org", + "jos\u{00E9}jos\u{00E9}@gr\u{00E5}.org", + "jose\u{0301}jose\u{0301}@gr\u{00E5}.org", + ); + + $user_ids = array(); + foreach ( $emails as $index => $email ) { + $user_id = wp_insert_user( + array( + 'user_login' => 'unicode_email_' . $index . '_' . wp_generate_password( 6, false ), + 'user_pass' => 'password', + 'user_email' => $email, + ) + ); + + $this->assertNotWPError( $user_id ); + $user_ids[ $email ] = $user_id; + } + + foreach ( $user_ids as $email => $user_id ) { + $this->assertSame( $user_id, email_exists( $email ) ); + } + } + // Simple get/set tests for user_option functions. public function test_user_option() { $key = rand_str(); diff --git a/tests/phpunit/tests/view-config-data.php b/tests/phpunit/tests/view-config-data.php index 1d56adb786644..68940ad6c2d5c 100644 --- a/tests/phpunit/tests/view-config-data.php +++ b/tests/phpunit/tests/view-config-data.php @@ -1721,6 +1721,233 @@ public function test_merge_rejects_unknown_key() { $this->assertSame( array( 'default_view' => array( 'type' => 'table' ) ), self::read_config( $data ) ); } + /** + * merge() rejects an associative patch value where a list lives: the shapes + * do not line up, so merging would have to guess what the string keys mean. + * The current list survives untouched instead of being discarded. + * + * @ticket 65577 + * + * @covers ::merge + */ + public function test_merge_rejects_associative_patch_over_a_list() { + $this->setExpectedIncorrectUsage( 'WP_View_Config_Data::merge_properties' ); + + $data = new WP_View_Config_Data( + array( + 'view_list' => array( + array( + 'slug' => 'all', + 'title' => 'All items', + ), + ), + ) + ); + $before = self::read_config( $data ); + + // The pre-7.1 slug-keyed shape, not the documented list of members. + $data->merge( + array( + 'view_list' => array( + 'published' => array( 'title' => 'Live' ), + ), + ), + 1 + ); + + $this->assertSame( $before, self::read_config( $data ) ); + } + + /** + * merge() rejects a non-empty list patch value where an associative value + * lives, the mirror of the associative-over-list mismatch: the current map + * survives untouched instead of being discarded. + * + * @ticket 65577 + * + * @covers ::merge + */ + public function test_merge_rejects_list_patch_over_an_associative_value() { + $this->setExpectedIncorrectUsage( 'WP_View_Config_Data::merge_properties' ); + + $data = new WP_View_Config_Data( + array( + 'default_view' => array( + 'sort' => array( + 'field' => 'title', + 'direction' => 'asc', + ), + ), + ) + ); + $before = self::read_config( $data ); + + $data->merge( + array( + 'default_view' => array( + 'sort' => array( 'title', 'asc' ), + ), + ), + 1 + ); + + $this->assertSame( $before, self::read_config( $data ) ); + } + + /** + * An empty array under merge() is a no-op for both shapes: it has no + * members to merge, and being shape-ambiguous it must not reset the + * current value either. Clearing a list is spelled replace() with an + * empty list; resetting a key is spelled null. + * + * @ticket 65577 + * + * @covers ::merge + */ + public function test_merge_empty_array_is_a_noop() { + $data = new WP_View_Config_Data( + array( + 'default_view' => array( + 'filters' => array( + array( + 'field' => 'author', + 'operator' => 'isAny', + ), + ), + 'sort' => array( + 'field' => 'title', + 'direction' => 'asc', + ), + ), + ) + ); + $before = self::read_config( $data ); + + $data->merge( + array( + 'default_view' => array( + 'filters' => array(), + 'sort' => array(), + ), + ), + 1 + ); + + $this->assertSame( $before, self::read_config( $data ) ); + } + + /** + * A nested null deletes just the leaf it names in every case, including + * inside a list member that did not exist yet: an appended member has no + * existing leaf to delete, so its nulls are dropped rather than stored + * (the same rationale as set() and the lists replace() swaps in). + * + * @ticket 65577 + * + * @covers ::merge + */ + public function test_merge_appended_member_drops_nested_nulls() { + $data = new WP_View_Config_Data( + array( + 'view_list' => array( + array( + 'slug' => 'all', + 'title' => 'All items', + ), + ), + ) + ); + $data->merge( + array( + 'view_list' => array( + array( + 'slug' => 'mine', + 'view' => array( 'filters' => null ), + ), + ), + ), + 1 + ); + + $this->assertSame( + array( + 'view_list' => array( + array( + 'slug' => 'all', + 'title' => 'All items', + ), + array( + 'slug' => 'mine', + 'view' => array(), + ), + ), + ), + self::read_config( $data ) + ); + } + + /** + * replace() rejects a non-empty list patch value where an associative value + * lives, the same rule merge() enforces: a list in the patch replaces the + * current list wholesale, but it cannot land where a map lives. The current + * map survives untouched instead of being discarded. + * + * @ticket 65577 + * + * @covers ::replace + */ + public function test_replace_rejects_list_patch_over_an_associative_value() { + $this->setExpectedIncorrectUsage( 'WP_View_Config_Data::merge_properties' ); + + $data = new WP_View_Config_Data( + array( + 'default_view' => array( + 'sort' => array( + 'field' => 'title', + 'direction' => 'asc', + ), + ), + ) + ); + $before = self::read_config( $data ); + + $data->replace( + array( + 'default_view' => array( + 'sort' => array( 'title', 'asc' ), + ), + ), + 1 + ); + + $this->assertSame( $before, self::read_config( $data ) ); + } + + /** + * An empty array is exempt from the shape guard, so replace() with an + * empty list stays the documented way to clear a list. + * + * @ticket 65577 + * + * @covers ::replace + */ + public function test_replace_empty_list_still_clears_a_list() { + $data = new WP_View_Config_Data( + array( + 'view_list' => array( + array( + 'slug' => 'all', + 'title' => 'All items', + ), + ), + ) + ); + + $data->replace( array( 'view_list' => array() ), 1 ); + + $this->assertSame( array( 'view_list' => array() ), self::read_config( $data ) ); + } + /** * merge() treats a scalar list member as its own identity: an incoming diff --git a/tests/phpunit/tests/view-config.php b/tests/phpunit/tests/view-config.php index 75e5c3327266b..cabd15d831494 100644 --- a/tests/phpunit/tests/view-config.php +++ b/tests/phpunit/tests/view-config.php @@ -69,7 +69,7 @@ class Tests_View_Config_API extends WP_UnitTestCase { * Tears down each test. */ public function tear_down() { - remove_all_filters( 'get_entity_view_config_postType_unregistered_cpt' ); + remove_all_filters( 'get_entity_view_config_posttype_unregistered_cpt' ); remove_all_filters( 'get_entity_view_config_custom_kind_custom_name' ); parent::tear_down(); } @@ -119,6 +119,28 @@ public function test_view_list_uses_post_type_all_items_label() { unregister_post_type( 'view_config_cpt' ); } + /** + * The dynamic filter name lowercases the entity kind and name. + */ + public function test_filter_hook_name_is_lowercased() { + $called = false; + add_filter( + 'get_entity_view_config_posttype_unregistered_cpt', + function ( $data ) use ( &$called ) { + $called = true; + return $data; + } + ); + + wp_get_entity_view_config( 'postType', 'Unregistered_CPT' ); + + $this->assertTrue( $called ); + $this->assertSame( + 'get_entity_view_config_posttype_unregistered_cpt', + wp_get_entity_view_config_hook_name( 'postType', 'Unregistered_CPT' ) + ); + } + /** * The dynamic filter receives the data container and the entity descriptor. */ diff --git a/tests/phpunit/tests/xmlrpc/wp/uploadFile.php b/tests/phpunit/tests/xmlrpc/wp/uploadFile.php index 00cb601b28f3d..4dab3333fd8a0 100644 --- a/tests/phpunit/tests/xmlrpc/wp/uploadFile.php +++ b/tests/phpunit/tests/xmlrpc/wp/uploadFile.php @@ -34,4 +34,310 @@ public function test_valid_attachment() { $this->assertIsString( $result['url'] ); $this->assertIsString( $result['type'] ); } + + /** + * Tests that a non-array data argument returns an error instead of + * triggering a fatal error. + * + * The data argument (the fourth parameter) is expected to be a struct, + * which is passed to the method as an array. When it is any other type, + * the method must return an IXR_Error rather than attempting to access + * array offsets on a non-array value. + * + * @ticket 65611 + * + * @covers wp_xmlrpc_server::mw_newMediaObject + */ + public function test_invalid_attachment_data_should_return_error() { + $this->make_user_by_role( 'editor' ); + + $result = $this->myxmlrpcserver->mw_newMediaObject( array( 0, 'editor', 'editor', 'not-a-struct' ) ); + $this->assertIXRError( $result, 'A non-array data argument should return an IXR_Error.' ); + $this->assertSame( 400, $result->code, 'The error code should be 400.' ); + } + + /** + * Tests that an anonymous request with a non-array data argument returns + * the login error rather than triggering a fatal error. + * + * The reported fatal error was reached without credentials because the + * data struct was read before the login was attempted. The struct must + * only be read once the request is authenticated. + * + * @ticket 65611 + * + * @covers wp_xmlrpc_server::mw_newMediaObject + */ + public function test_anonymous_request_with_invalid_attachment_data_should_return_login_error() { + $result = $this->myxmlrpcserver->mw_newMediaObject( array( 0, 'not-a-user', 'not-a-password', 'not-a-struct' ) ); + $this->assertIXRError( $result, 'An anonymous request should return an IXR_Error.' ); + $this->assertSame( 403, $result->code, 'The error code should be the 403 returned for a failed login.' ); + } + + /** + * Tests that a user who cannot upload files is rejected before the data is + * read. + * + * The capability is checked ahead of the attachment data, so a user who is + * not allowed to upload is told that rather than being told the data is + * malformed. Sending unusable data must not change which error comes back. + * + * @ticket 65611 + * + * @covers wp_xmlrpc_server::mw_newMediaObject + */ + public function test_incapable_user() { + $this->make_user_by_role( 'subscriber' ); + + $result = $this->myxmlrpcserver->mw_newMediaObject( array( 0, 'subscriber', 'subscriber', 'not-a-struct' ) ); + $this->assertIXRError( $result, 'A user who cannot upload files should return an IXR_Error.' ); + $this->assertSame( 401, $result->code, 'The error code should be the 401 returned for a missing capability.' ); + } + + /** + * Tests that too few arguments return an error instead of emitting a PHP + * notice for the undefined arguments. + * + * @ticket 65611 + * + * @covers wp_xmlrpc_server::mw_newMediaObject + * + * @dataProvider data_insufficient_arguments + * + * @param list $args The arguments to pass to the method. + */ + public function test_insufficient_arguments_should_return_error( array $args ) { + $this->make_user_by_role( 'editor' ); + + $result = $this->myxmlrpcserver->mw_newMediaObject( $args ); + $this->assertIXRError( $result, 'Insufficient arguments should return an IXR_Error.' ); + $this->assertSame( 400, $result->code, 'The error code should be 400.' ); + } + + /** + * Data provider. + * + * @return array}> + */ + public function data_insufficient_arguments(): array { + return array( + 'no arguments' => array( + 'args' => array(), + ), + 'only the blog ID' => array( + 'args' => array( 0 ), + ), + 'missing the data' => array( + 'args' => array( 0, 'editor', 'editor' ), + ), + ); + } + + /** + * Tests that a data struct without a usable file name returns an error + * instead of emitting a PHP notice for the undefined array key. + * + * A file name is required to write the upload, so the request cannot + * succeed. It must fail with an IXR_Error rather than by reading an + * undefined array offset. + * + * @ticket 65611 + * + * @covers wp_xmlrpc_server::mw_newMediaObject + * + * @dataProvider data_attachment_data_without_name + * + * @param array $data The data argument to pass to the method. + */ + public function test_attachment_data_without_name_should_return_error( array $data ) { + $this->make_user_by_role( 'editor' ); + + $result = $this->myxmlrpcserver->mw_newMediaObject( array( 0, 'editor', 'editor', $data ) ); + $this->assertIXRError( $result, 'A data argument without a name should return an IXR_Error.' ); + $this->assertSame( 400, $result->code, 'The error code should be 400.' ); + } + + /** + * Data provider. + * + * @return array}> + */ + public function data_attachment_data_without_name(): array { + return array( + 'empty struct' => array( + 'data' => array(), + ), + 'only type and bits' => array( + 'data' => array( + 'type' => 'image/jpeg', + 'bits' => 'contents', + ), + ), + 'non-string name' => array( + 'data' => array( + 'name' => array( 'a2-small.jpg' ), + 'type' => 'image/jpeg', + 'bits' => 'contents', + ), + ), + ); + } + + /** + * Tests that a file name left empty by sanitization returns the same error + * as an absent one. + * + * sanitize_file_name() strips special characters and then trims the + * remaining leading and trailing '.', '-' and '_' characters, so a name + * built only from those is reduced to an empty string. That leaves nothing + * to write, which is a malformed request rather than a server failure, so + * it must be reported as a 400 like any other unusable name instead of + * reaching wp_upload_bits() and surfacing as a 500. + * + * @ticket 65611 + * + * @covers wp_xmlrpc_server::mw_newMediaObject + * + * @dataProvider data_attachment_data_with_unusable_name + * + * @param string $name The file name to pass to the method. + */ + public function test_attachment_data_with_unusable_name_should_return_error( string $name ) { + $this->make_user_by_role( 'editor' ); + + $data = array( + 'name' => $name, + 'type' => 'image/jpeg', + 'bits' => 'contents', + ); + + $result = $this->myxmlrpcserver->mw_newMediaObject( array( 0, 'editor', 'editor', $data ) ); + $this->assertIXRError( $result, 'A name left empty by sanitization should return an IXR_Error.' ); + $this->assertSame( 400, $result->code, 'The error code should be 400.' ); + } + + /** + * Data provider. + * + * @return array + */ + public function data_attachment_data_with_unusable_name(): array { + return array( + 'empty name' => array( + 'name' => '', + ), + 'only dots' => array( + 'name' => '...', + ), + 'only dashes' => array( + 'name' => '---', + ), + 'only underscores' => array( + 'name' => '___', + ), + 'only a space' => array( + 'name' => ' ', + ), + 'only special chars' => array( + 'name' => '///', + ), + 'only a question mark' => array( + 'name' => '?', + ), + ); + } + + /** + * Tests that a data struct with a non-string type or bits member returns an + * error instead of triggering a fatal error. + * + * A struct sent for either member arrives as an array. An array reaches + * fwrite() by way of wp_upload_bits(), which throws a TypeError, and it + * survives sanitize_mime_type() to reach the database as the attachment's + * post MIME type. Both members must be rejected before that point. + * + * @ticket 65611 + * + * @covers wp_xmlrpc_server::mw_newMediaObject + * + * @dataProvider data_attachment_data_with_invalid_members + * + * @param array $data The data argument to pass to the method. + */ + public function test_attachment_data_with_invalid_members_should_return_error( array $data ) { + $this->make_user_by_role( 'editor' ); + + $result = $this->myxmlrpcserver->mw_newMediaObject( array( 0, 'editor', 'editor', $data ) ); + $this->assertIXRError( $result, 'A data argument with a non-string member should return an IXR_Error.' ); + $this->assertSame( 400, $result->code, 'The error code should be 400.' ); + } + + /** + * Data provider. + * + * @return array}> + */ + public function data_attachment_data_with_invalid_members(): array { + return array( + 'non-string bits' => array( + 'data' => array( + 'name' => 'a2-small.jpg', + 'type' => 'image/jpeg', + 'bits' => array( 'contents' ), + ), + ), + 'non-string type' => array( + 'data' => array( + 'name' => 'a2-small.jpg', + 'type' => array( 'image/jpeg' ), + 'bits' => 'contents', + ), + ), + ); + } + + /** + * Tests that a data struct without the optional members is still accepted. + * + * Only the name is required. The type and bits members are tolerated when + * absent, and must not emit a PHP notice for the undefined array keys. + * + * @ticket 65611 + * + * @covers wp_xmlrpc_server::mw_newMediaObject + * + * @dataProvider data_attachment_data_with_optional_members_omitted + * + * @param array $data The data argument to pass to the method. + */ + public function test_attachment_data_with_optional_members_omitted_should_be_accepted( array $data ) { + $this->make_user_by_role( 'editor' ); + + $result = $this->myxmlrpcserver->mw_newMediaObject( array( 0, 'editor', 'editor', $data ) ); + $this->assertNotIXRError( $result ); + $this->assertIsString( $result['id'] ); + $this->assertStringMatchesFormat( '%d', $result['id'] ); + } + + /** + * Data provider. + * + * @return array}> + */ + public function data_attachment_data_with_optional_members_omitted(): array { + return array( + 'missing type' => array( + 'data' => array( + 'name' => 'a2-small.jpg', + 'bits' => file_get_contents( DIR_TESTDATA . '/images/a2-small.jpg' ), + ), + ), + 'missing bits' => array( + 'data' => array( + 'name' => 'a2-small.jpg', + 'type' => 'image/jpeg', + ), + ), + ); + } } diff --git a/tests/qunit/index.html b/tests/qunit/index.html index a6b6177014586..d0e81acedb502 100644 --- a/tests/qunit/index.html +++ b/tests/qunit/index.html @@ -152,6 +152,7 @@ + diff --git a/tests/qunit/wp-admin/js/theme.js b/tests/qunit/wp-admin/js/theme.js index c17a5d59d41f9..d82b26e1521db 100644 --- a/tests/qunit/wp-admin/js/theme.js +++ b/tests/qunit/wp-admin/js/theme.js @@ -12,16 +12,18 @@ nextTheme: function() { nextCalled++; }, previousTheme: function() { prevCalled++; }, keyEvent: function( event ) { - if ( event.shiftKey || event.ctrlKey || event.altKey || event.metaKey ) { + if ( event.shiftKey || event.ctrlKey || event.metaKey ) { return; } // Right arrow - if ( event.keyCode === 39 ) { + if ( event.altKey && event.keyCode === 39 ) { + event.preventDefault(); this.nextTheme(); } // Left arrow - else if ( event.keyCode === 37 ) { + else if ( event.altKey && event.keyCode === 37 ) { + event.preventDefault(); this.previousTheme(); } } @@ -34,28 +36,70 @@ themePreview = createThemePreview(); }); - QUnit.test( 'Arrow keys without modifiers', function( assert ) { + QUnit.test( 'Arrow keys with Alt modifier', function( assert ) { // Right arrow themePreview.keyEvent( $.Event( 'keydown', { keyCode: 39, + altKey: true, shiftKey: false, ctrlKey: false }) ); - assert.equal( nextCalled, 1, 'Right arrow triggers nextTheme' ); + assert.equal( nextCalled, 1, 'Alt + Right arrow triggers nextTheme' ); // Left arrow themePreview.keyEvent( $.Event( 'keydown', { keyCode: 37, + altKey: true, shiftKey: false, ctrlKey: false }) ); - assert.equal( prevCalled, 1, 'Left arrow triggers previousTheme' ); + assert.equal( prevCalled, 1, 'Alt + Left arrow triggers previousTheme' ); } ); + QUnit.test( 'Arrow keys without Alt do nothing', function( assert ) { + // Right arrow without Alt - should NOT call nextTheme + themePreview.keyEvent( $.Event( 'keydown', { + keyCode: 39, + altKey: false, + shiftKey: false, + ctrlKey: false + }) ); + assert.equal( nextCalled, 0, 'Right arrow without Alt does nothing' ); + + // Left arrow without Alt - should NOT call previousTheme + themePreview.keyEvent( $.Event( 'keydown', { + keyCode: 37, + altKey: false, + shiftKey: false, + ctrlKey: false + }) ); + assert.equal( prevCalled, 0, 'Left arrow without Alt does nothing' ); + } ); + + QUnit.test( 'PreventDefault is called for arrow keys with Alt', function( assert ) { + // This test would need to check if preventDefault was called + var event = $.Event( 'keydown', { + keyCode: 39, + altKey: true, + shiftKey: false, + ctrlKey: false + }); + + // Mock the preventDefault method to track if it's called + var preventDefaultCalled = false; + event.preventDefault = function() { + preventDefaultCalled = true; + }; + + themePreview.keyEvent( event ); + assert.ok( preventDefaultCalled, 'preventDefault is called for arrow keys with Alt' ); + }); + QUnit.test( 'Shift+Arrow keys do nothing', function( assert ) { // Shift + Right themePreview.keyEvent( $.Event( 'keydown', { keyCode: 39, + altKey: false, shiftKey: true, ctrlKey: false }) ); @@ -64,6 +108,7 @@ // Shift + Left themePreview.keyEvent( $.Event( 'keydown', { keyCode: 37, + altKey: false, shiftKey: true, ctrlKey: false }) ); @@ -74,6 +119,7 @@ // Ctrl + Right themePreview.keyEvent( $.Event( 'keydown', { keyCode: 39, + altKey: false, ctrlKey: true, shiftKey: false }) ); @@ -82,6 +128,7 @@ // Ctrl + Left themePreview.keyEvent( $.Event( 'keydown', { keyCode: 37, + altKey: false, ctrlKey: true, shiftKey: false }) ); diff --git a/tests/qunit/wp-includes/js/media/test-media-models.js b/tests/qunit/wp-includes/js/media/test-media-models.js new file mode 100644 index 0000000000000..b6d5563f584b6 --- /dev/null +++ b/tests/qunit/wp-includes/js/media/test-media-models.js @@ -0,0 +1,254 @@ +/* globals wp */ +/* jshint qunit: true */ +/* eslint-env qunit */ +/* eslint-disable no-magic-numbers */ + +( function() { + 'use strict'; + + QUnit.module( 'Media Models - Order Normalization' ); + + // Test valid uppercase values + QUnit.test( 'Attachments should accept uppercase "ASC" order', function( assert ) { + var collection = new wp.media.model.Attachments( [], { + props: { + order: 'ASC' + } + }); + + assert.strictEqual( collection.props.get('order'), 'ASC', + 'Order should remain ASC when passed as uppercase' ); + }); + + QUnit.test( 'Attachments should accept uppercase "DESC" order', function( assert ) { + var collection = new wp.media.model.Attachments( [], { + props: { + order: 'DESC' + } + }); + + assert.strictEqual( collection.props.get('order'), 'DESC', + 'Order should remain DESC when passed as uppercase' ); + }); + + // Test lowercase normalization + QUnit.test( 'Attachments should normalize lowercase "asc" to uppercase', function( assert ) { + var collection = new wp.media.model.Attachments( [], { + props: { + order: 'asc' + } + }); + + assert.strictEqual( collection.props.get('order'), 'ASC', + 'Order should be converted from lowercase asc to uppercase ASC' ); + }); + + QUnit.test( 'Attachments should normalize lowercase "desc" to uppercase', function( assert ) { + var collection = new wp.media.model.Attachments( [], { + props: { + order: 'desc' + } + }); + + assert.strictEqual( collection.props.get('order'), 'DESC', + 'Order should be converted from lowercase desc to uppercase DESC' ); + }); + + // Test mixed case normalization + QUnit.test( 'Attachments should normalize mixed case "AsC" to uppercase', function( assert ) { + var collection = new wp.media.model.Attachments( [], { + props: { + order: 'AsC' + } + }); + + assert.strictEqual( collection.props.get('order'), 'ASC', + 'Order should be converted from mixed case AsC to uppercase ASC' ); + }); + + QUnit.test( 'Attachments should normalize mixed case "DeSc" to uppercase', function( assert ) { + var collection = new wp.media.model.Attachments( [], { + props: { + order: 'DeSc' + } + }); + + assert.strictEqual( collection.props.get('order'), 'DESC', + 'Order should be converted from mixed case DeSc to uppercase DESC' ); + }); + + // Test invalid string values + QUnit.test( 'Attachments should default invalid string order to "DESC"', function( assert ) { + var collection = new wp.media.model.Attachments( [], { + props: { + order: 'invalid' + } + }); + + assert.strictEqual( collection.props.get('order'), 'DESC', + 'Invalid string order should default to DESC' ); + }); + + QUnit.test( 'Attachments should default empty string order to "DESC"', function( assert ) { + var collection = new wp.media.model.Attachments( [], { + props: { + order: '' + } + }); + + assert.strictEqual( collection.props.get('order'), 'DESC', + 'Empty string order should default to DESC' ); + }); + + /* + * An unset order is left alone so the existing 'DESC' fallbacks in + * Attachments.comparator() still apply. Any value that *is* set gets + * normalized, otherwise a truthy non-string would sort ascending. + */ + QUnit.test( 'Attachments should leave a null order value unset', function( assert ) { + var collection = new wp.media.model.Attachments( [], { + props: { + order: null + } + }); + + assert.strictEqual( collection.props.get('order'), null, + 'Null order should remain null' ); + }); + + QUnit.test( 'Attachments should leave an undefined order value unset', function( assert ) { + var collection = new wp.media.model.Attachments( [], { + props: { + order: undefined + } + }); + + assert.strictEqual( collection.props.get('order'), undefined, + 'Undefined order should remain undefined' ); + }); + + QUnit.test( 'Attachments should default a numeric order to "DESC"', function( assert ) { + var collection = new wp.media.model.Attachments( [], { + props: { + order: 123 + } + }); + + assert.strictEqual( collection.props.get('order'), 'DESC', + 'Numeric order should default to DESC' ); + }); + + QUnit.test( 'Attachments should default a boolean true order to "DESC"', function( assert ) { + var collection = new wp.media.model.Attachments( [], { + props: { + order: true + } + }); + + assert.strictEqual( collection.props.get('order'), 'DESC', + 'Boolean true order should default to DESC' ); + }); + + QUnit.test( 'Attachments should default a boolean false order to "DESC"', function( assert ) { + var collection = new wp.media.model.Attachments( [], { + props: { + order: false + } + }); + + assert.strictEqual( collection.props.get('order'), 'DESC', + 'Boolean false order should default to DESC' ); + }); + + QUnit.test( 'Attachments should default an object order to "DESC"', function( assert ) { + var collection = new wp.media.model.Attachments( [], { + props: { + order: { value: 'ASC' } + } + }); + + assert.strictEqual( collection.props.get('order'), 'DESC', + 'Object order should default to DESC' ); + }); + + QUnit.test( 'Attachments should default an array order to "DESC"', function( assert ) { + var collection = new wp.media.model.Attachments( [], { + props: { + order: ['ASC', 'DESC'] + } + }); + + assert.strictEqual( collection.props.get('order'), 'DESC', + 'Array order should default to DESC' ); + }); + + // Test when no order property is provided + QUnit.test( 'Attachments should work when no order property is provided', function( assert ) { + var collection = new wp.media.model.Attachments( [], { + props: { + orderby: 'date' + } + }); + + assert.strictEqual( collection.props.get('order'), undefined, + 'Order should be undefined when not provided' ); + }); + + /* + * Query no longer normalizes the order itself, it relies on inheriting the + * normalization above. Note these pass `args` rather than `props.query`: + * setting `query` would kick off a server request via `_requery()`. + */ + QUnit.test( 'Query should inherit order normalization from Attachments', function( assert ) { + var query = new wp.media.model.Query( [], { + props: { + order: 'asc' + }, + args: {} + }); + + assert.strictEqual( query.props.get('order'), 'ASC', + 'Query model should normalize order through inheritance from Attachments' ); + assert.ok( query instanceof wp.media.model.Attachments, + 'Query should be instance of Attachments' ); + }); + + QUnit.test( 'Query should default invalid order to "DESC"', function( assert ) { + var query = new wp.media.model.Query( [], { + props: { + order: 'random' + }, + args: {} + }); + + assert.strictEqual( query.props.get('order'), 'DESC', + 'Query model should default invalid order to DESC' ); + }); + + // Test whitespace handling + QUnit.test( 'Attachments should handle order with whitespace', function( assert ) { + var collection = new wp.media.model.Attachments( [], { + props: { + order: ' asc ' + } + }); + + assert.notStrictEqual( collection.props.get('order'), 'ASC', + 'Order with whitespace should not match ASC exactly' ); + assert.strictEqual( collection.props.get('order'), 'DESC', + 'Order with whitespace should default to DESC as it does not match ASC/DESC after toUpperCase' ); + }); + + // Test unicode characters + QUnit.test( 'Attachments should handle order with unicode characters', function( assert ) { + var collection = new wp.media.model.Attachments( [], { + props: { + order: 'asc\u200B' // Zero-width space + } + }); + + assert.strictEqual( collection.props.get('order'), 'DESC', + 'Order with unicode characters should default to DESC' ); + }); + +})(); diff --git a/tools/component-fuzz/README.md b/tools/component-fuzz/README.md new file mode 100644 index 0000000000000..e7a18cd0553ee --- /dev/null +++ b/tools/component-fuzz/README.md @@ -0,0 +1,1434 @@ +# Component Fuzzers + +Pure-PHP fuzzing harness for broad WordPress component surfaces. It follows the +same operating model as `tools/html-api-fuzz`: deterministic generation from a +seed, bounded inputs, structured replayable artifacts, and explicit invariants +instead of one-off example tests. + +The harness intentionally boots a no-external-DB subset of WordPress. Surface +modules therefore focus on public APIs that can be exercised without a live +database, network requests, or a configured site. + +## Surfaces + +- `abilities`: Abilities API category and ability registry lifecycles, + action-gated registration, metadata/default preparation, filtered discovery, + schema validation, custom subclass execution, permission checks, execution, + query pipeline ordering, validation filters, callback exception handling, and + unregister/re-register behavior. +- `account-security`: no-DB account recovery and security APIs, including + application password lifecycle/hash/authentication behavior, password-reset + key lifecycle validation, direct `reset_password()` hook/storage/repeat + replacement contracts, recovery key/cookie validation, and paused extension + storage transitions. +- `admin-ajax`: bounded admin-AJAX response helper coverage, including + captured `wp_die()` handlers, JSON response helpers, `WP_Ajax_Response` + XML boundaries, nonce/capability failures, Heartbeat nonce hook branches, + selected safe AJAX handlers, Find Posts modal/query/JSON branches, + attachment query/save workflows, compression-test capability/body branches, + and superglobal/output-buffer restoration. +- `admin-bar`: no-DB toolbar node lifecycle, default root/submenu binding, + group/container behavior, render escaping/raw HTML contracts, and + back-compat parent alias and tabindex rendering contracts, initialization + hook/theme-support side effects, `show_admin_bar()` filter/global + restoration, default menu hook registration, default callback-produced + toolbar node graphs for WordPress logo, account, appearance, comments, + search, secondary groups, and single-site site-menu, new-content, updates, + sidebar-toggle, and command-palette nodes. +- `admin-dashboard`: no-live-DB admin dashboard API coverage, including + dashboard widget registration/control callbacks, meta-box context and + priority normalization, dashboard container rendering across column counts, + direct At a Glance/right-now widget rendering for publish-only post/page + counts, comment counts, dashboard-glance items, privacy and version/theme + messages, and rightnow/activity hook output, + safe recent draft/post/comment output helpers, activity post query + argument/link branches, cached RSS loading/AJAX/cache replay branches, + Browser Happy remote/cache failure and rendering branches, direct + `wp_dashboard_setup()` GET registration across site/network/user dashboard + hooks, Community Events dashboard markup/template contracts, and + filter/global/superglobal/output-buffer restoration. +- `admin-edit-metaboxes`: no-live-DB classic edit-screen meta box callback + coverage, including publish-box status/visibility/action branches, flat and + hierarchical taxonomy boxes with capability gates, excerpt/trackback/custom + field/comment/slug helpers, author override dropdown contracts, page + attributes, post formats, attachment submit and ID3 metadata boxes, link + target/XFN/advanced/submit boxes, default `register_and_do_post_meta_boxes()` + box registration, hook payloads, context ordering, and state restoration. +- `admin-screen`: no-DB admin screen, settings, and meta-box APIs, including + `WP_Screen` normalization/current-screen globals, help tabs and screen + options, rendered per-page/layout controls, screen meta/help sidebar and + screen-reader content lifecycles, column header filter locality, settings + registry/default/sanitize callbacks, escaped settings field and nonce output, + settings error/admin notice rendering and filter state, meta-box + ordering/removal/callback args, and accordion section rendering. +- `admin-workflows`: no-DB admin menu, list-table, referer-helper, Settings + API, and admin notice workflows, including menu/submenu global registration + and removal, hook suffix and menu URL behavior, parent file normalization, + synthetic `WP_List_Table` pagination/columns/views/bulk actions/row + actions/tablenav rendering, direct bulk-action/month-dropdown helper + contracts, generated request/action matrices for `current_action()`, + capability-gated row and bulk actions, view/bulk filters, nonce URLs, + custom bulk-action dispatch, selected item normalization, safe redirect + cleanup/fallback behavior, allowed-host checks, safe admin/AJAX nonce checks, + settings section/field/error rendering, filtered admin notice getter/echo + contracts, and captured date/time AJAX format wrappers without process exits. +- `admin-list-tables`: no-live-DB concrete admin list-table subclass coverage + for posts, media, comments, terms, users, plugins, plugin install search + results, themes, selected-mode theme install API results, link manager, + post-comments metabox, network themes, application passwords, and guarded + network sites/users, including columns/ + hidden/sortable/default-primary logic, views, actions, bulk actions, exact row + URL/nonce and HTML escaping, base `WP_List_Table` pagination/per-page output, + pagination/counts, synthetic object/bookmark/pre-query/user-meta/plugin/theme + fixtures, capability gates including legacy link-manager option gates, + install/update/activate action rendering, screenshot/icon and description + escaping, localized update-count and theme-root transient cleanup, compact + post-comments table rendering, application-password Last IP escaping and + empty-value fallback, JS row templates, and state/filter restoration. +- `admin-media-chrome`: no-DB admin media chrome helper coverage, including + attachment edit field preparation, media item and compat markup escaping, + image form controls, image editor chrome from cache-seeded metadata, + edit attachment details form output, thumbnail/icon helper filters, direct + caption/send-to-editor helper output, legacy upload tab/header/form shell + hooks, legacy `media-upload.php` entry dispatch, legacy upload send/gallery + dispatch, URL insert dispatch, gallery save iframe dispatch, type/default + iframe dispatch, legacy library/gallery iframe rendering, legacy media query + date/filter boundaries, query aliases/defaults, date-query stub edges for + scalar time, calendar-unit, and option-dependent week projections, and WP_Error exit capture, + `wp_media_attach_action()` attach/detach redirect and denial exits, media-view + enqueue settings/string contracts, in-process iframe shell rendering, and safe + media button/uploader bypass output. +- `admin-options-submission`: no-DB `wp-admin/options.php` update-flow + coverage, including registered Settings API allowlists and sanitize + callbacks, settings error transients, General Settings date/time/timezone + branches, Reading/Discussion/Media/Writing core option-page sanitization, + conditional Writing Settings allowlist gates, pending admin email + hash/confirmation-mail semantics, legacy `page_options` submissions, + nonce/capability/unknown-page failure paths, redirect capture, and + global/filter/option restoration without process exits. +- `ai-client`: no-DB WordPress AI Client API coverage for SDK DTO + round-trips, enum strictness, provider registry isolation, model-selection + preferences across provider/model collisions, prompt builder ability + integration, cache and event adapters, deterministic in-memory generation, + HTTPlug discovery of the WordPress HTTP adapter, PSR-7 to WordPress HTTP + argument mapping, SDK request option merging, response body edge cases, and + transport error propagation without network calls. +- `assets`: script/style registration lifecycle, dependency ordering, inline + assets, style add-data output metadata, loading strategies, scoped loader-tag + filters, script modules, and printed tag escaping. +- `script-loader-runtime`: server-side script-loader runtime helpers, including + default script/style/module registrations, handle normalization, duplicate + update behavior, conditional polyfill inline script generation, + inline/localized data placement, concatenated + load-scripts.php/load-styles.php URL construction and exclusion boundaries, + tag/settings escaping, script translations, emoji settings/styles with + temporary generated loader-asset materialization, style + JIT localization with isolated `AUTOSAVE_INTERVAL` coverage, style + inlining, block-loader guards, strategy/fetchpriority/module interactions, + generated classic-script module import-map/modulepreload graphs, and print + side-effect boundaries. +- `appearance-media`: no-upload appearance media helper coverage, including + custom background POST normalization, custom header default processing and + selection, frontend header/background helpers, custom background head-callback + CSS and custom-logo hide-header-text CSS, custom header video markup/settings + and print-time script localization side effects, synthetic custom-logo + attachment markup/filter contracts, site icon sizes/meta tags, real site-icon + attachment URL/fallback resolution, background remove safe-redirect guards, + and state restoration without admin upload/AJAX dispatch. +- `auth-flow`: no-DB authentication and session flow coverage, including + synthetic user rows, username/email/password authentication filters, + direct `wp_login_form()` rendering/filter/escaping contracts, + sign-on and clear-auth-cookie actions with cookie sending short-circuited, + public `wp_logout()` composition, generated auth-cookie scheme boundaries and + filter payloads, auth cookie validation hooks/default parsing, current-user + and cookie global restoration, stale-cookie rehydration guards, and session + token lifecycle operations. +- `blocks`: block parser/serializer round trips, optimized block detection, + dynamic render filters, render-time block bindings, block type metadata, + variations, block hook insertion, ignored metadata, post-object wrapper and + REST response block hook metadata, style, pattern/category, bindings, and + supports registries, including dynamic block attribute preparation and + wrapper attribute merging. +- `block-supports`: no-DB core block-support lifecycle coverage, including + `WP_Block_Supports` registration and wrapper merging, auto-generated control + markers, direct support callbacks for style families, skip-serialization + gates, background, dimensions, visibility, position, layout, elements, custom + CSS, state-style, helper-matrix, and duotone preset/custom/unset/global-style + render behavior, safe stored CSS/SVG/editor assets, and + registry/global/style/duotone static store restoration. +- `core-block-render`: no-DB direct render-callback coverage for representative + dynamic core blocks, including site title/tagline option handling, search + label/query/placeholder escaping, loginout current-request redirect links, + post title/date/excerpt/read-more context rendering, temporary excerpt filter + cleanup, button/file/image markup transforms, lightbox filter locality, + server-side `core/navigation`/navigation-link/submenu/home-link/page-list + rendering, responsive overlay/interactivity markup, duplicate navigation + label uniqueness, submenu visibility migration, synthetic page-list + active/ancestor classes, frontend list-style archives/categories/latest + posts/latest comments/tag cloud/calendar rendering, archive/calendar cache + key handling, posts/comments/terms query filters, and + global/superglobal/option restoration. +- `block-widgets`: block-backed widget behavior, including `WP_Widget_Block` + rendering, dynamic legacy class mapping matrix, malformed/unknown block + fallbacks, content sanitization on update, form escaping, `the_widget()` + display callback/action flow, registered control rendering, widgets block + editor support toggles, widget ID parsing, unregistered-widget cleanup, and + sidebars widget mapping, `retrieve_widgets()` remapping/customizer + persistence boundaries, lost/inactive block widget recovery, and widget-editor + script/style dependency warning contracts. +- `bookmark-links`: no-live-DB legacy bookmark/link-manager API coverage, + including in-memory link rows and link categories for `get_bookmark()`, + `get_bookmarks()`, `wp_list_bookmarks()` and `_walk_bookmarks()` escaping, + image/update/filter-payload rendering, edit bookmark links, bookmark + sanitizers, selected deprecated wrappers, safe link CRUD, argument filtering, + ordering, limits, visibility, ratings, and bookmark caches. +- `block-templates`: no-DB block template and block theme resolution coverage, + including template registry lifecycle, file-backed templates and parts, + parent/child theme precedence, theme.json metadata, hierarchy resolution, and + malformed filename/path and direct-ID traversal guards with template CPT + queries short-circuited. +- `block-editor-adjuncts`: no-DB block editor adjunct API coverage, including + editor context objects, category and allowed-block filters, legacy widget and + merged editor settings, local theme style helpers, editor stylesheet + add/get/remove lifecycle behavior, iframe asset collection, REST preload path + normalization with dispatch short-circuited, and global restoration. +- `capabilities`: no-DB role registry lifecycle and mutation idempotence, + numeric/associative and boundary capability grants, `WP_User` role/direct cap + aggregation and mutators, `WP_User::for_site()` cap-key isolation, + `user_can_for_site()` and `author_can()` wrapper contracts, role and user + capability filter locality, generated `map_meta_cap()` filter contexts, + cheap meta-cap mappings, and primitive/meta cap monotonicity. +- `canonical-routing`: no-live-DB canonical redirect and front-end routing helpers, + including method/search/preview bailouts, host/path/query cleanup, invalid + date redirects, DB-stub-backed 404 permalink guessing, old-slug and old-date + redirect helpers, feed/pagination canonicalization, attachment page + permalink/raw-file redirects, redirect filter cancellation and same-host + replacement cascades, canonical URL output helpers for status, paged, + comment-page, plain-permalink, filter, and singular output gates, fragment + stripping, and generated query-argument removal and fragment stripping helper + matrices. +- `classic-walkers`: deterministic Walker base and classic walker coverage, + including `walk()`, `paged_walk()`, direct `display_element()` traversal, + page/category/comment/nav rendering, current/selected classes, admin nav menu + checklist/edit field names, direct admin nav helper contracts, nav menu + quick-search JSON/markup dispatch, post-type and taxonomy meta-box + pagination/search queries, generated has-children oracles, bounded HTML + balance, escaping contracts, and global/filter/superglobal/output-buffer + restoration. +- `content`: slashing, metadata serialization, post and term field sanitization, + whole-post `sanitize_post()` object/array consistency and filter locality, + `get_extended()` more-tag splitting, post-template title/excerpt/password + helper filters and cookie branches, direct `get_the_content()`/`the_content()` + rendering, content pagination and ``/`` handling, + `wp_link_pages()` link/filter contracts, query variables, `WP_Date_Query`, + and title/class/key sanitizers. +- `content-lifecycle`: in-memory wpdb-backed post, post-meta, term, user, and + comment CRUD lifecycles, including insert/update/read/delete round trips, + direct `wp_insert_user()`/`wp_update_user()` pre-user filters, + `wp_pre_insert_user_data`, default/custom user-meta filters, + role/default-role contracts, activation-key clearing, password/email-change + hooks and filters, illegal-login and empty-data fail-closed branches, + direct `wp_delete_user()` deletion/reassignment, hook ordering, usermeta + removal, lookup disappearance, post/link reassignment, custom post-type + `delete_with_user` filtering, link deletion, and invalid-user fail-closed + branches, + duplicate and invalid-input errors, sanitizer agreement, monotonic IDs, + direct `wp_update_term()`/`wp_delete_term()` ID preservation, readable field + refresh, duplicate-slug/missing-parent/empty-name failures, child reparenting, + object relationship cleanup, and term lifecycle hook payloads, + metadata cache invalidation, post-to-term relationship field modes, helper + caches, relationship hook arguments, status-transition hook/cache/default + side-effect branches, persisted post/page transition render and visibility + oracles for `wp_publish_post()`, `wp_update_post()`, `get_post_class()`, + `is_post_publicly_viewable()`, `read_post` capability paths, hook timing, + and read-only no-mutation probes, direct `set_post_type()` row mutation and + cache cleanup, + classic admin `bulk_edit_posts()` return partitioning, locked/skipped + preservation, status/author/title/content/excerpt/discussion/date updates, + category/tag/post-format/sticky mutations, bulk/sticky hook payloads, + sentinel/no-stomp preservation, invalid-date fail-closed behavior, and page + parent ancestry skips, top-level capability-denial `wp_die()` capture, + permission-filtered tag assignment, and sticky edit capability gates, + public page/post lookup helpers for ancestry paths, attachment fallback, + hierarchy queries, child normalization, filter payloads, output formats, and + salted query-cache invalidation, post and attachment count/MIME helper contracts, + featured-image helper lifecycle contracts, post-delete cleanup, cache/count + refresh behavior, and per-iteration state restoration. +- `comments`: comment filtering, sanitizer agreement, max-length boundaries, + type partitioning, comment classes, author URL/email links, excerpt/text + helpers, comment cookies, direct `wp_list_comments()` list orchestration, + public comment count/link/popup helpers, comment page navigation and + pagination wrappers, reply/cancel link and comment form rendering branches, + and permalink pagination contracts for `get_page_of_comment()`/ + `get_comment_link()`. +- `community-events`: no-network Community Events API client coverage, + including IP header selection and anonymization, minimal/fail-closed request + bodies, transient key/cache behavior, strict coordinate matching, cache + expiration normalization, search-triggered cache refresh behavior, event + trimming and WordCamp pinning, response normalization, API error contracts, + and admin AJAX `wp_ajax_get_community_events()` JSON envelope and + user-location persistence behavior. +- `comment-workflow`: in-memory comment submission, duplicate/flood approval + decisions, direct `wp_new_comment()` preprocessing and insert hooks, + notification wrapper/direct mail filter paths with intercepted delivery, + moderation short-circuits, update/status transition hooks, generated + moderation transition notification/count/cache/capability/no-mutation + oracles, trash/untrash and spam/unspam restoration, force-delete + reparenting/meta/count/hook contracts, and WP_Error failure paths without + process exits. +- `cron`: in-memory cron scheduling, recurrence lookup, schedule/unschedule + and next-scheduled filter contracts, unschedule-hook pre-filter return + contracts, duplicate single-event windows, scheduled-event lookup + ordering/exactness, ready-job partitioning, spawn request/lock boundaries, + public `wp_cron()` shutdown deferral/immediate-run wrapper behavior, + private cron option persistence/migration helper contracts, unscheduling and + rescheduling contracts. +- `default-widgets`: classic default widget subclass coverage, including + constructor/options contracts, saved-instance callback lifecycles, update + sanitization, form escaping, filtered rendering for text/custom + HTML/search/meta/list/nav-menu widgets, cache-backed calendar/archive output, + navigation widget argument filters, and local RSS fixtures without network + requests. +- `customizer`: no-DB Customizer API coverage for manager registry lifecycles, + setting sanitize/validate/post value flows, slashed customized JSON ingestion + and programmatic post-value merge precedence, multidimensional option + previewing, container/control JSON exports, active callbacks, built-in + control rendering hooks/escaping/selection behavior, media/upload/image/ + cropped-image/site-icon control JSON/template/capability contracts, selective + refresh partial registration/rendering, and direct inactive/active theme + preview filter/action lifecycle coverage for child-theme + stylesheet/template switching without changeset persistence. +- `customizer-nav-widgets-requests`: in-memory wpdb-backed Customizer nav menu + and widget request coverage, including loaded component/capability hook + gates, menu available/search AJAX, auto-draft insertion and publish cleanup, + dynamic nav menu/menu-item settings, placeholder menu remaps into locations + and `widget_nav_menu`, preview HMAC/export metadata, signed widget instance + round trips, widget update AJAX, and widget selective-refresh partials. +- `customizer-persistence`: no-live-DB Customizer persistence coverage for + changeset UUID/data normalization, stub-backed `customize_changeset` post + content parsing, changeset lock/heartbeat persistence, transactional + changeset saves, `WP_Customize_Manager::save()` AJAX request gates and JSON + envelopes, publish-side changeset application to options, theme mods, and + Custom CSS posts, publish hook ordering, invalid publish transaction + rollback, Custom CSS setting validate/sanitize/preview/update behavior, + custom CSS post filters, and global/superglobal restoration. +- `date-time`: deterministic no-DB date/time helper coverage, including + `wp_date()`/`DateTimeImmutable` agreement, `date_i18n()` and `mysql2date()` + timestamp oracles, timezone option filters, direct `wp_timezone_choice()` + markup generation for empty, named, BC-only, UTC, manual-offset, hostile, and + locale variants, GMT/local round trips, ISO8601 offset parsing and datetime + conversion across DST boundaries, week windows, `current_time()`, + `current_datetime()`, timezone override offsets, `wp_checkdate()` + validity/filter contracts, `wp_maybe_decline_date()` locale month + declension rules, date/human diff filter contracts, and safe human time + diffs. +- `discovery`: robots meta directives, scoped public/private robots helper + output, front-controller `do_robots()` robots.txt output and hook order, + front-controller `do_favicon()` favicon redirects through `template-loader.php`, + sitemap enablement and robots.txt injection, provider registration/replacement + filters, query/permalink sitemap URL/index expansion, escaped sitemap XML + rendering, unsupported sitemap field boundaries, stylesheet URL filters, + direct sitemap/index XSL stylesheet output, LTR/RTL stylesheet CSS, sitemap + max-URL filters, and built-in posts/taxonomies/users sitemap providers with + fixture-backed subtype, lastmod, max-page, query-arg, pre-filter, and + public/private gating oracles. +- `email`: Unicode email validation/sanitization, ASCII fallback filters, + WHATWG-style validity, `WP_Email_Address` machine/readable and IDN/punycode + views, generated mixed-script UTF-8 address-model round trips, raw getter + invariants, ASCII-vs-Unicode construction-mode consistency with explicit IDN skips, + optional Unicode API availability skips, disabled-filter fail-closed behavior, + invalid UTF-8, generated malformed address variants and reserved/invalid ACE + domain labels across filter/charset modes, selected boundary lengths, + quoted/escaped local-part rejection, + control-character and Unicode separator sanitization boundaries, local-part + case/width/normalization identity preservation, hook restoration, + normalization-sensitive local parts, generated accent-distinct local-part + alias/index paths, byte-preserving user email search SQL, comment-author email + filtering, UTF-8 comment submission through `wp_handle_comment_submission()` + and direct `wp_new_comment()` sanitization paths, REST user email schema + validation across Unicode/ASCII filter modes, + and no-DB stub-backed user email lookup/duplicate behavior for accent-distinct + local parts/domains, generated Unicode local-part update/collision behavior, + exact Unicode email authentication and machine-view miss behavior, + email-change notification recipient/body preservation, canonical Unicode-domain + save/update collision behavior without MySQL collation/index coverage, + profile email-change confirmation request paths, password-reset Unicode recipient paths, + password-reset notification recipient machine/readable view overrides through + the PHPMailer handoff, current machine-view reset lookup rejection, clickable + mailto rendering boundaries including punycode final-label full links and + malformed final-label no-partial-link cases, and generated + mailto/rendering-context round trips for UTF-8 local parts, WHATWG delimiter + local parts, IDN/punycode domains, escaped display hrefs, and readable text. +- `environment-load`: no-network environment/load/compat helper coverage, + including environment type cache boundaries, isolated `WP_RUN_CORE_TESTS` + environment-type matrices with constant precedence, server/request + normalization, Basic Auth and SSL detection, memory-limit parsing, ini + mutability, isolated `wp_raise_memory_limit()` context/filter negotiation, + installing/maintenance flags, generated JSON/XML request media matrices, + request guard filters, HTTPS migration short-circuits, and UTF-8 + compatibility oracles with parent/child state restoration. +- `error-protection`: no-shutdown error protection and recovery-mode + infrastructure coverage, including paused-extension source normalization and + storage, recovery key/cookie validation, recovery-mode initialization and + cleanup scheduling, cookie activation precedence, active/inactive paused + storage and exit cleanup lifecycle, recovery-link generation, filtered + recovery email payloads and `handle_error()` protected-endpoint rate limiting + without real mail, fatal-error handler formatting oracles, synthetic + `handle()` dispatch with injected fatal data, recovery-link early-return + boundaries, protected-endpoint gates, effective cleanup TTL filtering, and + isolated subprocess coverage for fatal-error handler shutdown registration, + recovery begin-link redirects and dies, and recovery exit redirects, dies, + and cleanup without letting process exits escape the child. +- `editor-helpers`: no-browser classic editor helper coverage for + `_WP_Editors` settings/state normalization, default editor selection filters, + teeny and full TinyMCE/Quicktags filter branches, captured editor markup, + editor script enqueue decisions, TinyMCE translation and inline settings + snippets, internal link query/dialog helpers, media-view stylesheet helpers, + and global restoration. +- `fonts`: font-face CSS serialization and validation, theme.json font-face + resolution and default printing, font directory filters, Font Library + collection registration/JSON loading, REST font collection pagination, + filtering, and response boundaries, REST font-family/font-face write + lifecycle coverage for duplicate guards, upload rewriting, force-delete + cleanup, and cascade deletion, REST font-face preparation boundaries, and font + utility sanitization for family lists, face slugs, schemas, and MIME maps. +- `filesystem`: path normalization and joining, file validation classes, + filename sanitization/uniqueness, unique-filename callback and case-collision + filters, temp names, recursive directory creation/listing and stream wrapper + detection, direct filesystem sandboxing, metadata/time/chmod round trips, and + missing-file failure values, ZIP validity and `unzip_file()` ZipArchive/PclZip + parity over safe, traversal, and `__MACOSX` entries, plus `copy_dir()` skip/error + contracts and `move_dir()` overwrite and copy-fallback behavior. +- `formatting`: escaping helpers, text sanitizers, whitespace normalization, + `wptexturize()` rich-text punctuation and protected-region oracles, + `wptexturize_primes()`, texturize split/shortcode regex recomposition, + autop/shortcode cleanup, clickable text, text/link helpers including + `wp_trim_words()`, `wp_html_excerpt()`, `wp_make_link_relative()`, + `wp_rel_nofollow()`, `wp_rel_ugc()`, `antispambot()`, and + `capital_P_dangit()` contracts, URL sanitization, entity normalization, deep + `map_deep()`/URL-encoding/slash helper shape and round trip contracts, + title/key/class identifier sanitizers, file-name and username sanitizers, + colors, sizes, time strings, UTF-8 helpers, and accent removal. +- `feed-parsers`: local RSS/Atom parser and legacy feed utility API coverage, + including bounded malformed fixtures, Magpie RSS/Atom/RDF item/channel + normalization, AtomParser local-file behavior, SimplePie raw-data parsing and + KSES sanitization, `WP_SimplePie_File` HTTP response/error-state + normalization, `fetch_feed()` empty/single/multi/error orchestration through + preempted HTTP responses, transient-backed feed cache hooks, date/status + helpers, local file adapter guards, no-network assertions, and state + restoration. +- `feed-rendering`: no-DB RSS2, Atom, RDF, RSS 0.92, and comments feed template + rendering over synthetic query loops, including feed item/entry counts, self + links, legacy `do_feed()` dispatch normalization, self-link request URI + host/filter escaping, CDATA terminator escaping, excerpt/content mode + switches, exact `rss_enclosure()` parser fixtures, enclosure metadata, comment + feed escaping, and feed build date selection. +- `frontend-features`: no-DB frontend feature helper coverage for speculative + loading and view transitions, including direct speculation rule validation, + configuration eligibility, mode/eagerness filters, generated URL-pattern + exclusions, disabled lifecycle/load-action isolation, script tag escaping, + theme support behavior, view-transition CSS registration timing, enqueueing, + and global restoration. +- `html-api`: HTML tag and tree processor mutation escaping, deterministic + rich HTML generation, normalization/recovery idempotence with tree + preservation, token walking, breadcrumb stack replay, bookmark/seek replay, + semantic parser mode probes, modifiable text escaping, token serialization, + and namespace/comment/rawtext boundary checks. +- `http`: synthetic HTTP response arrays, request wrapper dispatch, + real `WP_Http::request()` to Requests success-path option mapping and + response conversion through a fake no-network transport, response objects, + header/cookie parsing, proxy decisions, redirect safety, chunk-transfer + decoding, `WP_Http_Encoding` compression/decompression and accept-encoding + policy contracts, direct no-network request normalization and early error + contracts, URL validation, CORS/origin allowlist helpers, HTTP support + capability normalization, allowed HTTP request host mirroring, and relative + URL resolution without live network requests. +- `icons-connectors`: no-DB Icons and Connectors API coverage, including + connector registry lifecycle and init discovery, settings/REST key masking, + AI-provider update validation fail-closed behavior, API-key mask/ + file-modification policy, script module serialization, plugin install/ + activation status metadata, icon manifest/search behavior, SVG sanitization + and file caching, REST icons schema/permission/error contracts, and state + restoration. +- `images`: image constraint and resize math, synthetic intermediate metadata, + metadata dimension lookup, responsive `srcset`/`sizes` generation and filter + boundaries, attachment image helpers and attribute filters, image tag + attribute insertion, content tag image/iframe filtering, auto-sizes helper + gates, loading optimization attributes, and image filetype/extension helpers. +- `image-metadata`: local admin image metadata parser coverage over generated + bounded JPEG/TIFF/PNG byte fixtures, including `wp_read_image_metadata()` + malformed-file behavior, EXIF/IPTC field extraction and sanitization when PHP + extensions are available, locale-aware XMP alt text extraction/fallbacks, + real Core EXIF/IPTC/XMP fixture replay for representative camera, timestamp, + keyword, orientation, UTF-8 caption, and accessibility-alt fields, EXIF + helper normalization, image metadata filters, temp-file cleanup, and state + restoration. +- `identity`: usernames, emails, identity sanitizer filter contracts, + capabilities, generated user contact-method filters and additional-key + propagation, generic `wp_dropdown_users()` selector/filter contracts, + generated `WP_User` identity field/cache/filter behavior, avatar data/URL/HTML + filter pipelines, text/comment filters, comment cookies/current-commenter + payloads, options, password hashing/checking, parse helpers. +- `import-diff`: importer registry and upload-form helpers, `WP_Importer` + imported post/comment lookup against the in-memory stub, import upload handler + fail-closed and cleanup paths, `wp_text_diff()` rendering/escaping/ + normalization, and `WP_Error` export/merge/remove transfer semantics. +- `install-schema`: no-DB install and upgrade schema coverage, including + `wp_get_db_schema()` table sets, `make_db_current()`/silent wrapper scope + expansion, global-table upgrade gate filters, `dbDelta()` CREATE TABLE + parsing, missing-table creation and replay no-ops, equivalent-schema no-ops, + isolated column/index diffs, SQL table allowlists, and malformed DDL + fail-closed behavior. +- `interactivity`: server-side directive processing for context, bind, class, + style, text, and each directives, explicit namespace/negation/length + evaluation, bind/class/style/text directive syntax and suffix/unique-ID + ordering, context namespace stack merge/sort/restoration, derived state + closure tracking and fail-closed errors, script-module router metadata, + unsupported/unbalanced HTML fallbacks, derived context/element helpers, and + state/config merge serialization. +- `hooks`: filter/action priority ordering, accepted arguments, removal, + nested and reentrant hook stack state, preinitialized hook normalization, + deprecated hook wrapper fast paths and side-effect hooks, ref-array + deprecated dispatch, `current_filter()`, `doing_filter()`, `did_action()`. +- `kses`: KSES policies, wrapper agreement, protocol filtering/helper contracts, + low-level helper contracts, exact `wp_kses_hair()` parser fixtures, + explicit null/control-character stripping and slash-zero handling, + filter-aware safe CSS, generated policy/protocol/style mutation matrices, + scoped generated context policy mutation matrices with custom URI attribute + and safe CSS filters, cross-hook CSS URL protocol checks combining + `safe_style_css`, `safecss_filter_attr_allow_css`, and dynamic + `wp_kses_uri_attributes`, semicolon-bearing `data:` CSS URL parser + diagnostics under default, permissive, and guarded allow-CSS hooks, + semicolon-free `data:,` CSS URL rejection and semicolon-fragmented + non-data bad-protocol diagnostics under allow-CSS hooks, + attribute/entity/comment handling, deterministic attribute constraint + matrices for required, values, max/min, valueless, and callback checks, + serialized block attribute KSES filtering, PDF object policy and + upload-host/port URL gates, dynamic URI attribute filtering, full-tag + attribute parsing, no-HTML filtering, custom context locality, strict/custom + policy monotonicity, generated widened-policy idempotence, block/comment + boundary inputs, temporary CSS allow-list restoration, save/comment filter + lifecycle wiring, capability-gated KSES initialization, and filter/global + restoration. +- `l10n`: translation fallbacks, escaped translation helpers, plural/nooped + selection, textdomain load/unload state, translation path guards, locale and + user-locale switching including generated stack/action payload matrices, + script translation helpers, malformed string boundaries, localized + numbers/dates, and filter/action restoration. +- `translations`: no-DB POMO and translation-file parsing/loading coverage, + including generated `Translation_Entry` lookup and merge behavior, + `NOOP_Translations` identity contracts, MO/PO/PHP translation file round + trips, explicit plural-rule oracles, short-circuited translation API, + available/installed language metadata, dropdown language normalization, + guarded language-pack helpers, transient-backed translation update helpers, + direct `WP_Translation_Controller` locale/domain/file isolation and lazy + malformed-file eviction, textdomain load/unload cycles, helper agreement with + loaded domain entries, malformed-file closed failures, and state restoration across `$l10n`, + `$l10n_unloaded`, registry, controller, current-user, and filter globals. +- `mail`: no-delivery `wp_mail()` composition coverage, including argument + filters, pre-send short-circuiting, PHPMailer recipient/header/content + handoff, UTF-8 local-part recipient/display-name preservation, IDN domain + punycode handoff, RFC2822 display-name MIME header encoding with literal + mailbox preservation and decoded header round trips, array and string header + parsing, newline-delimited attachments and embeds, multipart + boundary/header/body preservation through serialized MIME output, reusable + mailer cleanup/reset behavior, invalid From failure payloads, early failure + cleanup, success/failure actions, and emoji email body staticization. +- `markup`: block parse/serialize/render guards, deterministic `do_blocks()` + fixture rendering for generated block trees with synthetic callback oracles + and block-rendering state restoration, shortcodes, text trimming, excerpts, + balanced tags, URL extraction, link attribute helpers, and embed helpers. +- `media-editor`: no-DB media image editor coverage for editor selection, + GD/Imagick execution or explicit availability accounting, output format filters, abstract editor + filename/quality/EXIF-orientation contracts, resize/save metadata, + intermediate and generated sub-sizes, missing sub-size detection, and + cache/filter-backed attachment metadata helpers with temp-file cleanup. +- `media-image-edit-requests`: admin media image-edit request coverage for + history normalization, preview streaming, save/restore metadata, crop + wrappers, generated `wp_ajax_image_editor()` request matrices for denied, + bad-nonce, save, scale-error, restore, and unknown-action branches, AJAX + preview/crop/sub-size boundaries, nonce/capability gates, file/metadata/id + filters, and temp-root attachment fixtures using a deterministic fake image + editor. +- `media-ingest`: no-network media upload and sideload ingest coverage over + generated temp fixtures, including upload directory filters, MIME/filetype + boundaries, sanitized unique filenames, parent-date upload subdirectory + routing for `media_handle_upload()`, direct handle prefilter/move hooks, upload + override/error semantics, attachment row/post field/postmeta creation in the + in-memory wpdb stub, download short-circuit cleanup, metadata update failure + paths, and cleanup restoration. +- `media-metadata`: local audio/video metadata parser coverage over generated + bounded byte fixtures, including `wp_read_audio_metadata()` and + `wp_read_video_metadata()` malformed-file behavior, ID3 tag helper + sanitization, creation timestamp extraction, audio/video extension and ID3 key + filters, public audio/video shortcode rendering and filter contracts, + mediaelement fallback escaping, HTML5 library switching, YouTube/Vimeo URL + normalization, invalid-source embedded-link fallbacks, public + `gallery_shortcode()`/`wp_playlist_shortcode()` rendering over cache-seeded + attachment selections, gallery style/attribute/link filters, playlist JSON and + script hooks, protected-parent fail-closed behavior, `wp_attachment_is()` + MIME/extension branches, image/document classification, MIME/extension + disagreement, wrapper behavior, attachment metadata get/update/delete filter + contracts, generated metadata replacement and gallery/playlist shortcode cache + coherence/no-mutation checks, original-image path/URL and image-meta matching + normalization across seeded upload storage styles, and + `wp_generate_attachment_metadata()` audio/video cover attachment + creation/reuse and no-cover skip behavior. +- `media-remote`: no-live-network remote media helper coverage for + `download_url()`, `media_sideload_image()`, and selected + `media_handle_sideload()` branches, including HTTP short-circuit fixtures, + case-insensitive download headers, Content-Disposition/content-type filename + derivation and sanitization matrix, + signature soft-fail/hard-fail temp-file behavior, URL extension/MIME + boundaries, extension-filtered sideload return types and metadata, + temp-file cleanup, size/type rejection, sideload prefilter cleanup and + override filter contracts, filter locality, and global restoration. +- `metadata`: no-DB Metadata API registration, subtype visibility, defaults, + registration argument edges, legacy callbacks, sanitize/auth/protected-meta + filters, current subtype-aware API accounting for post/term/comment/user + metadata, cache-backed lookup shape, filtered and in-memory CRUD cache + invalidation, by-mid short-circuit filter payloads and fail-closed inputs, + mid-row helpers, cache priming, duplicate-aware lazyloader queue/reset + behavior, and REST meta field schema, custom exposed names, prepared/default + values, validation failures, capability-gated update paths, and reset/delete + semantics. +- `multisite`: no-DB multisite/network API coverage, including synthetic + `WP_Site` and `WP_Network` objects, site data normalization, cache-backed + lookups, legacy blog identity helpers, bootstrap current-site/current-network + resolution, blog-switch stack/cache restoration, filter-backed network option + reads, stub-backed network option CRUD, signup validation for unsafe email + domains, pending `wp_signups` reservations, stale reservation cleanup, + blogname/title/nonce contracts, large-network threshold/filter contracts, + pre-query-short-circuited site/network queries, domain/path lookup helpers, + current/switched URL helpers, and an optional true-multisite subprocess for + DB-backed site lifecycle and `sitemeta` write paths when a readable + `wp-tests-config.php` is available. +- `navigation`: nav menu location registration, theme menu assignment lookup, + menu object and item setup filters, current-item class derivation, current-tree + parent/ancestor propagation, walker output, depth pruning, + short-circuit/fallback behavior, args/items-wrap normalization, container + allowlists, attribute filter escaping, and filtered no-DB `wp_nav_menu()` + rendering. +- `navigation-lifecycle`: in-memory wpdb-backed nav menu persistence and REST + menu controller coverage, including `wp_create_nav_menu()`, + `wp_update_nav_menu_object()`, `wp_delete_nav_menu()`, + `wp_update_nav_menu_item()`, associated object cleanup callbacks, + auto-add page behavior, menu-location remapping, orphan/self-parent item + normalization, sanitized menu item meta, REST menu/menu-item/location + permission gates, invalid location/object errors, forced-delete semantics, + links, `WP_Navigation_Fallback::get_fallback()` classic-menu-to-block + conversion with `core/navigation-link` and `core/navigation-submenu` oracles, + primary-location selection priority, duplicate-free fallback reuse, and state + restoration. +- `network-media`: URL parsing/sanitization/validation, URL scheme + normalization, path normalization, filename sanitization, filetype checks, + unique filenames, generated collision/alternate-extension filename oracles, + callback/filter contracts, `wp_upload_bits()` exact-byte writes, filtered + upload roots, virtual subsize collision file-list short-circuits, + upload-bits and final upload filter payloads, sideload success plus + form-action, prefilter, override, MIME rejection, custom error-handler, and + final upload filter contracts, multisite upload quota, remaining-space, + size-limit, network upload MIME allowlists, direct file-too-large checks, + `check_upload_size()` error/state behavior, and over-quota helpers. +- `options-autoload`: no-DB option CRUD, cache, autoload, and filter coverage + for generated option values, including alloptions membership, notoptions + transitions, raw serialized cache shape, default/pre/update filters, cache + priming stability, registered option-group priming, bulk autoload mutators, + lifecycle action payload ordering, safe option-name boundary cases, exact + default autoload policy filter/cache membership behavior, and generated + persistence sequences with independent option table, alloptions/notoptions, + individual-cache, public-read, autoload-filter, and lifecycle-hook oracles. +- `plugin-theme`: plugin headers, plugin path helpers, invalid plugin path + validation, admin helper discovery/file-edit/active-state contracts, no-DB + plugin dependency metadata, dependency slug/name/API-data fallbacks, active + dependency option states, theme headers, parent/child relationships, active + theme file helpers, screenshots, and broken theme errors. +- `plugin-theme-lifecycle`: no-network plugin/theme lifecycle coverage over + generated temp fixtures, including plugin validation and requirement errors, + dependency failure states, activation/deactivation success and output-failure + paths, multi-plugin deactivation scope/action payload ordering, active and + sitewide-active option shapes, plugin/theme deletion validation, theme + enumeration and requirement checks, safe child-theme switching, theme + support/template globals, read-only REST plugin/theme controller paths, and + bounded REST plugin create/install, status update, and delete behavior + against generated temp plugin fixtures and packages. +- `update-install-upgrader`: no-network update/install/upgrader coverage, + including generated core/plugin/theme update transient shapes, aggregate + update counts/titles, `WP_Upgrader_Skin` and `Automatic_Upgrader_Skin` + output behavior, `WP_Upgrader` local/filtered download and temp-directory + install-package lifecycles, temp-backup cleanup/restore rollback contracts, + plugin/theme package validation helpers, no-update upgrade branches, + auto-update decision filters, VCS and PHP compatibility gates, core + version-policy decisions, and maintenance-mode writes against a temp + filesystem only. +- `utility-internals`: no-DB low-level utility coverage for `WP_List_Util`, + list helper wrappers, `WP_Token_Map`, `WP_MatchesMapRegex`, and + `WP_URL_Pattern_Prefixer`, including reference filter/pluck/sort oracles, + chained filter/sort/pluck state, parse-list and array-path helper contracts, + token lookup/precomputed table round trips, non-default key-length + `WP_Token_Map::to_array()` export equivalence, rewrite match substitution, + URL-pattern prefix escaping/idempotence boundaries, `_wp_to_kebab_case()` + lodash-compatibility fixtures, hierarchy-loop detection helpers, unique ID + and UUID contracts, boolean validation semantics, diagnostic/error helper + hook and trace contracts, and state restoration. +- `user-preferences`: no-request-dispatch admin UI preference coverage, + including sanitized user-setting/admin-color serialization, hidden column and + meta-box preference defaults/saved values, postbox order/classes, screen + option registration/rendering, Screen Options visibility caching and filters, + composed Screen Options output for columns, meta boxes, layout, pagination, + view modes, and custom settings, layout column rendering and legacy filters, + subprocess-isolated AJAX preference handlers and `set_screen_options()` + redirect/exit paths, filter locality, and state restoration. +- `post-embeds`: in-memory wpdb-backed WordPress-as-oEmbed-provider coverage, + including post type embeddability predicates, public visibility fail-closed + behavior, oEmbed response width clamps, rich iframe and thumbnail conversion, + plain/pretty/path-conflict embed URL selection, iframe/blockquote/script + markup contracts, discovery link output, `wp_maybe_enqueue_oembed_host_js()` + action-gated script enqueue behavior, direct `WP_oEmbed_Controller` item + responses, same-site `pre_oembed_result` short-circuit behavior, and public + custom provider registration/removal, consumer `wp_oembed_get()` and + `WP_Embed::autoembed()` post-meta cache behavior, REST oEmbed proxy provider + fetch/transient-cache behavior with nonce-excluded cache keys, dimension + cache misses, TTL/filter oracles, and no-network HTTP interception. +- `post-types`: post type and post status registry defaults, registration + filter/action/meta-box lifecycle, REST route registration boundaries and + late-route ordering, duplicate post-type replacement cleanup, support feature + registration, capability generation, registry query operators, query/archive + normalization, unregister cleanup, archive/feed link filters, and status + filtering including status viewability and strict filter behavior. +- `privacy`: no-DB user request and privacy helper coverage, including + synthetic `WP_User_Request` objects, request lifecycle helpers, action and + confirmation descriptions, request-key hash/expiration validation including + missing-request and global-post fallback fail-closed behavior, export group + HTML escaping, exporter/eraser registry and processor shape contracts, + final erasure completion status/meta/action behavior, + built-in comments exporter/eraser payload and anonymization behavior, built-in + user exporter profile/community-location/session-token payloads and additional + profile filter contracts, built-in media exporter author/type filtering, + 50-item pagination, URL payloads, and registration callbacks, export + notification recipient/subject/content/header filters through an intercepted + PHPMailer handoff, real personal data export ZIP generation through filtered + temp export roots, `export.json`/`index.html` archive inspection, legacy + export-file meta migration, fail-closed JSON-error paths, directory/expiration + cleanup filters, anonymization helpers, privacy policy suggestion/default text, + suggested-text lifecycle cache transitions, text-change cache and admin + notices without real mail or network delivery. +- `privacy-admin-requests`: in-memory wpdb-backed admin privacy request + coverage, including export and erasure request list-table views, counts, + status filtering, prepared items, row action nonce/data attributes, checkbox + and status markup, bulk complete/delete/resend and direct helper contracts, + resend confirmation key regeneration and intercepted confirmation mail, + admin add-request/retry form handlers, pending-request cleanup, personal data + export/erasure AJAX success flows, default processor state transitions, + capability and request-shape gates, selected exporter/eraser/page callbacks, + malformed callback responses, scoped Unicode email filters, runtime cache + isolation, and state restoration. +- `query`: no-DB query builder and execution APIs, including meta/tax/date + query tree sanitization, SQL fragment generation, relation normalization, + query-var parsing, seeded `WP_Query` execution/found-row result oracles, + scalar `DATE_FORMAT()` time projections, option-dependent week modes, + negated day-of-year/week/weekday row oracles across posts/users/comments, + generated user/comment date-query execution row oracles with fixture-modeled + expected IDs, scoped start-of-week filters, and comment cache-domain freshness, + direct `after`/`before` bound filtering across user/comment datetime columns, + one-level `OR` relation row oracles across user/comment date bounds and + projections, + classic post-search parser/order/result SQL oracles for terms, exclusions, + columns, stopwords, attachment filename left-join branches, password gates, + literal clause-keyword searches, empty relevance-order boundaries, and + relevance ranking, wpdb stub status-OR branch handling, + cache-key/cache-hit determinism, `WP_User_Query` field/order/search/role/ + capability/has-published-post SQL-shape oracles and hook mutation locality, + user/comment pre-query short-circuits, narrow WP_Query hook-callback + difference accounting, and deterministic global restoration. +- `query-loop`: no-DB `WP_Query` execution and loop-state coverage through + `posts_pre_query` fixtures, including loop wrapper delegation, + setup/reset postdata globals, nested secondary-query reset and conditional + scoping, single/page flag behavior, found/max-page coherence, empty-result + events, offset/no-found-rows field-shape windows for `ids` and `id=>parent`, + `the_posts` result-filter finalization, and deterministic state restoration. +- `registries`: no-DB modern registry coverage for connectors, icons, block + metadata collections, block bindings sources, and speculation rules, + including lifecycle validation, helper oracles, manifest + sanitization/caching, connector override re-registration, block metadata + path-boundary/cache behavior, virtual path prefix preservation, block + bindings source validation, unregister/re-register ordering, callback/filter + payloads, allowlist behavior, rule serialization, and state restoration. +- `rest`: request normalization, parameter precedence, JSON bodies, route regexes, + `register_rest_route()` wrapper merge/override semantics, schema + sanitize/validate, permissions, HEAD/GET behavior, `/batch/v1` dispatch, + allow-batch gates, child request propagation, validation modes, response + links, CURIE compaction, embedding, envelopes, headers, response conversion, + and global REST registration state restoration. +- `request-lifecycle`: no-DB front-controller lifecycle coverage for + `WP::parse_request()`, rewrite/pathinfo/index matching, public/private + query-var gates, query-var precedence and GET/POST mismatch termination, + generated query-var and conditional-tag matrices over single/page/search/feed + request shapes, canonical redirect cancellation/bailout guards that preserve + query/global/request state, `WP::main()` sequencing with query + short-circuits, `register_globals()`, `handle_404()` status transitions, and + `send_headers()` filters/actions with feed content-type, last-modified, ETag, + stale conditional request, subprocess-isolated 304/error exit paths, and + deterministic global restoration. +- `rest-controllers`: no-DB default REST endpoint controller coverage for + registry-backed post types, post statuses, taxonomies, settings, block types, + block patterns, block pattern categories, block-pattern remote loader + dispatch, local theme pattern file loading, dynamic block renderer dispatch, + menu-location route/permission/link contracts, plugin/theme route/schema + contracts, and REST search handlers, including context/_fields filtering, + registered additional-field get/update/schema callbacks, collection params, + permission gates, settings route dispatch permission/schema/update behavior, + default-filtered search/settings route dispatch with sanitized cross-handler + subtypes, `_fields` projection, `Allow` headers, editable route args, and + default REST filter cleanup, + namespace-specific REST links, intercepted + core/featured/theme pattern directory requests, snake-case remote pattern + normalization, duplicate suppression, remote-load filter gates, + `WP_Theme::get_block_patterns()` header parsing/cache behavior, lazy + `filePath` content loading, local pattern duplicate preservation, + plugin/theme sanitizer contracts, + route-dispatched defaults/schema validation, custom search handler + result/header/link propagation, invalid subtype rejection, public + search-result schema callback contents, post-format search term/link and + pagination behavior, built-in post/term search handler subtype discovery, + `TYPE_ANY` expansion, include/exclude/search/page/per-page query mapping, + protected-title/no-title preparation branches, REST item/about links, invalid + values, and state restoration. +- `rest-application-passwords`: in-memory user/app-password backed REST + application password controller coverage, including collection/item/ + introspection route and schema contracts, create/update/delete dispatch, + one-time password response and stored hash agreement, response context + filtering for edit/view/embed, logged-out and invalid-payload failure paths + before storage mutation, PUT name mutation with `app_id` immutability, item + and bulk delete accounting, created/last-used/last-IP formatting, usage + recording and same-day throttle behavior, `_fields` projection and links, + password non-exposure outside edit context, exact REST pre/after/prepare hook + payloads, capability-denied and availability error matrices, current-user + introspection, stale UUID failures, REST application-password auth status + globals, REST index authentication metadata, default-filter idempotence, and + cloned global/filter restoration. +- `rest-directory-services`: no-network REST coverage for WordPress.org-backed + directory service controllers, including block-directory, pattern-directory, + and URL-details route/schema contracts, direct `plugins_api()` and + `themes_api()` argument/default/result filters, plugin/theme WordPress.org + HTTP serialization, malformed/error JSON handling, SSL-to-HTTP fallback, + terminal transport failure handling, generated argument/default/filter/HTTP + matrices, plugin short-circuit external markers, WP_Error short-circuits, + theme `query_themes` and `feature_list` response normalization, plugin API and HTTP short-circuits, + block-directory `_fields` projection, explicit block title/icon mapping and + installed-plugin link discovery, permission matrices, request validation + errors, transformed response schemas, pattern and URL cache/transient + behavior, HEAD/cache-hit boundaries, pattern-directory direct preparation, + sanitization, `_fields` projection, prepare-filter payloads, proxy query + allowlisting and derived WordPress.org argument overwrite behavior, + malformed/unclosed head metadata extraction, metadata parsing precedence, + relative media URL normalization, cloned `WP_Hook` snapshot restoration, + REST default-filter isolation, and global/filter restoration. +- `rest-media-attachments`: in-memory wpdb-backed REST media attachment write + coverage, including `Content-Disposition` filename parsing, raw upload + validation failures, raw body `create_item()` success through the upload + directory and attachment postmeta pipeline, raw body client-side sideloads + for generated subsizes and original-image metadata, non-image/PDF sideload + rejection, permission gates, client-side media-processing route/argument + contracts, metadata finalization filters, route-dispatched post-process and + edit paths, deterministic image-editor operations, edited attachment + creation, parent-image metadata, `_fields` response projection, edit-media + fail-closed paths, upload-directory cache isolation across ordered media + surfaces, temp upload cleanup, and global/filter restoration. +- `rest-widgets-sidebars`: no-live-DB REST widget, widget-type, and sidebar + controller coverage, including route/schema contracts, public + `show_in_rest` read gates, widget type sorting/projection and + `encode_form_data()` instance/hash round trips, isolated + `/widget-types/{id}/render` iframe preview dispatch, text widget + create/update persistence, route-dispatched widget create/get/update/delete + paths, sidebar reassignment/reorder mutation dispatch, invalid widget/sidebar + schema fail-closed paths, widget/sidebar/delete hook payload timing, legacy + widget `form_data` updates, soft/force delete hooks, HEAD short-circuits, and + widget/global/filter restoration. +- `rest-object-controllers`: in-memory wpdb-backed REST object controller + coverage for posts, terms, comments, users, revisions, and attachments, + including schema/context/_fields filtering, deterministic collection + parameter sanitizer/validation matrices, route-dispatched collection GET + projection, pagination header/link, prepare-hook, permission, and + query-short-circuit oracles across posts, categories, comments, and users, + permission gates, route registration/dispatch, route index/help-data + projection, REST links, invalid IDs/types, sanitized content/meta fields, + safe create/update/delete error paths, route-dispatched write validation and + response projection for object updates, route-dispatched create/delete + validation, projection, trash-not-supported preservation, + force-delete/reassign lifecycle and metadata cleanup, route-dispatched + taxonomy term parent/schema/capability/meta mutation coverage, term + insert/prepare/delete hook timing, force-delete previous-meta projection and + term-meta row cleanup, upload-no-data paths, and deterministic state + restoration without live uploads, remote requests, or a live database. +- `rest-site-editor`: no-live-DB Site Editor REST controller coverage for + global styles, template/template-part response shaping, template create, + update, trash/reset/force-delete mutation lifecycles, template revisions and + autosaves, bounded template and template-part collection GET dispatch, + template item and lookup fallback route dispatch, navigation fallback, direct + block-template ZIP export generation, edit-site export guards, and + subprocess-isolated live edit-site export streaming, + including route normalization, schema/context behavior, permission and error + contracts, `_fields` projection, filter-backed collection query oracles, + custom CSS validation, temp theme fixtures, REST insert hooks, origin + metadata, area/theme taxonomy assignments, streamed ZIP inspection, archive + cleanup, and state restoration. +- `revisions-autosaves`: in-memory wpdb-backed revision and autosave API + coverage, including protected revision field/filter contracts, autosave + create/update/delete and post-lock behavior, autosave and revision predicates, + latest revision count and URL helpers, user-filtered autosave lookup, + revision insert/save/restore/delete helpers, revisioned meta copy and + restore behavior, post type support gates, revision title/list helpers, + revision UI diffs, JS payload preparation, direct revision template output, + preview overlay behavior, bounded preview request dispatch, route-dispatched + REST revision collection/item/delete and autosave collection/item reads, + parent and schema failures, raw-field/link/preview projection, scoped delete + capability mapping, REST hook payloads, and global/filter restoration. +- `rewrite`: rewrite tags, permastruct/rule generation, collision ordering, + endpoint expansion and mask propagation, match substitution, query arg and + build/parse helpers, rewrite-tag removal/query-var retention boundaries, URL + parsing, home/site URL helpers, weird path fragments, cheap no-DB + `url_to_postid()` paths, and pretty-permalink `url_to_postid()` resolution + through generated rewrite rules and short-circuited `WP_Query` oracles. +- `security`: salts and HMACs, password and fast-hash verification, native + bcrypt compatibility and migration signals, nonce generation/verification, + nonce tick/lifetime boundaries, nonce URLs and hidden fields, referer + retrieval precedence/restoration, admin/ajax referer paths including invalid + admin nonce `wp_nonce_ays()` termination branches, synthetic auth cookies and + session token grace/failure edges, password filter locality, redirect + sanitization, redirect validation matrices, sanitize/validate metamorphic + behavior, and safe redirect filters. +- `shortcodes`: no-DB shortcode registry lifecycle, attribute parsing/default + merging and dynamic filters, invalid registration and non-callable callback + guards, callback argument and rendering filter contracts, nested parse + boundaries, callback mutation during rendering, scoped media image context + hooks including priority-zero preexisting filters, escaped shortcode + boundaries, accepted/rejected HTML-attribute shortcode rendering, + placeholder encoding/unescaping for ignore_html, comments, CDATA, and escaped + shortcode forms, tag-name edge/collision behavior, tag discovery, apply + aliasing, stripping preservation and strip filters, presence checks, + malformed inputs, and exact global/hook restoration. +- `site-health`: no-DB Site Health/update/HTTPS helper coverage, including + generated update transients and dismissed core update options, aggregate + update counts/titles, HTTPS option booleans, migration replacement, HTTPS + detection short-circuits, generated `site_status_tests` registry/filter + behavior, plugin/theme auto-update configuration status branches, selected + direct `WP_Site_Health` tests without remote requests, + synthetic loopback and REST availability request outcomes, scheduled event + missed/late/future cron classification, HTTP-blocking constant checks, and + persistent object cache threshold/filter direct-test behavior. +- `site-health-debug`: bounded `WP_Debug_Data` coverage for Site Health Info + formatting, diagnostic size helpers, and an isolated full `debug_data()` scan, + including private field/section suppression, debug vs info labels, + `debug_information` filter locality, scoped `SHOW TABLE STATUS` and + `SHOW VARIABLES` wpdb doubles, malformed database-size row boundary + accounting, generated directory/database/total-size aggregation, MySQL + variable lookup fallbacks, fake no-network WordPress.org + communication, bounded Ghostscript detection, path-size loading placeholders, + and child-process cleanup/restoration checks. +- `state`: object cache groups, direct `wp_cache_switch_to_blog()` local/global + group prefixing, multi-operations, and cache-addition suspension, option, + transient, cache-backed and option-backed site-transient APIs, + update/expiration cleanup, dynamic transient filters, serialization, JSON, and + value helpers. +- `style`: style engine serialization, preset/classname and CSS variable + boundaries, CSS declaration filtering, theme.json schema/data merging and + variable resolution, block style variation declarations, registered block + style `style_data` source-order injection, block-support wrapper + serialization, selector/path helpers, scoped editor style helpers, custom + properties, seeded global stylesheet guards, direct global settings/styles + getter paths, `wp_global_styles` user-data lookup, safe-flag fail-closed + behavior, and global style post-ID creation/cache oracles. +- `syndication`: oEmbed provider registration, embed handler lifecycle, oEmbed + wildcard/regex matching, cache-key lookup, no-network fetch short-circuits, + HTML/XML filtering, feed metadata escaping, default feed normalization, + automatic feed-link head output gates, `feed_links_extra()` branch output for + singular, post type archive, category, tag, custom taxonomy, author, and + search query states, self links, comment feed-link generation/filtering, and + Atom text construction. +- `taxonomy`: taxonomy registration lifecycle, object-type associations, + registration filter/action locality, registry query consistency, + argument/callback/default-term normalization, query-var and rewrite + side-effect boundaries, REST controller creation, term sanitization and field + filters, slug/name normalization, synthetic `WP_Term` behavior, hierarchy + helper edge cases, `get_terms()` short-circuit contracts, cheap term-link + paths, hierarchical parent-slug expansion, `get_term_parents_list()` + agreement, and term-link filter ordering across legacy taxonomy-specific + hooks. +- `taxonomy-relationships`: in-memory wpdb-backed object/term assignment + coverage, including `wp_set_object_terms()` replace/append behavior, + field-variant agreement, multi-object `all_with_object_id` mapping, scoped + removal, membership/object lookup helpers, invalid-input fail-closed paths, + `get_the_terms()` relationship cache population/invalidation, and generated + multi-object `update_object_term_cache()`/`clean_object_term_cache()` + priming, empty-entry, warm-cache, and re-prime invariants, plus generated + multi-object mutation/removal/delete sequences with independent expected + relationship, per-taxonomy cache-invalidation, term-count, relationship-hook, + and post-type feature/meta-cap restoration oracles for REST/XML-RPC adjacency. +- `template-hierarchy`: no-DB classic PHP template hierarchy coverage, + including child/parent lookup priority, query-template filters, direct + archive/page/search/404/embed/author/date/home/front-page/privacy/singular/ + attachment helper filters and path confinement, stylesheet/template root + precedence, single template ordering, generated + category/tag/taxonomy decoded-slug and term-ID ordering, `load_template()` + include semantics, template-part hooks and args, and isolated + `comments_template()` child/parent/custom file loading and comment-query + contracts without leaking `COMMENTS_TEMPLATE` into the parent process. +- `template-links`: no-DB public template and link helpers, including body and + language attributes and filter ordering/locality, `get_post_class()`/ + `post_class()` container tokens, document title stability, resource + hints/preloads, pagination/search/feed/site/admin URLs, archive post-list + navigation and pagination wrappers, `_navigation_markup()` escaping, direct + `get_search_form()` HTML5/XHTML rendering, aria/query escaping, hook/filter + ordering and null fallback behavior, canonical and shortlink head output, + synthetic post preview/edit/delete/shortlink/permalink helpers, date and + author archive URL branches, author display/meta/link/post-count/listing + helpers, multi-author transient/filter behavior, previous/next adjacent post + relation links, adjacent image attachment links, and cached bookmark field/list + rendering. +- `widgets`: classic sidebar registry lifecycle, widget factory instance + registration, direct widget/control callbacks, generated sidebars, widget ID + parsing, sidebar option cache/filter behavior, sidebar assignment + moves/removals, inactive widget placement, render callback wrappers, + `dynamic_sidebar()` action/filter ordering, no-external-DB guards, and + registry restoration. +- `wpdb-sql`: no-connection real `wpdb` SQL formatting coverage, including + placeholder count/type handling, `%i` identifier containment, literal percent + and LIKE escaping, malformed placeholders, and captured insert/update/delete/ + replace builder SQL shape, including null values across string, integer, and + float builder formats, plus no-DB charset and invalid-text coverage for + `check_ascii()`, `strip_invalid_text()`, CRUD fail-closed behavior on stripped + or truncated `%s` fields, `strip_invalid_text_for_column()`, and guarded query + invalid-text stripping over deterministic column/table metadata fixtures. +- `wxr-export`: subprocess-isolated WXR export coverage over deterministic + synthetic posts, terms, authors, comments, and meta, including export + argument filtering, title/content/excerpt export filters, XML/CDATA/UTF-8 + safety, meta skip filters, null post/term/comment meta serialization, + non-exportable and invalid content fallback behavior, filtered-comment + omission, nav-menu term boundaries, attachment URL/file metadata + serialization, author and term ordering, filtered filename and XML + content-type header intent with observable-header assertions when available, + and state restoration. +- `xmlrpc`: no-DB IXR/XML-RPC protocol coverage, including value escaping, + request/message round trips, invalid XML fail-closed behavior, fault XML, + system method dispatch, mixed success/fault multicall ordering, method + registry filters, demo helpers, disabled login behavior, bounded pre-network + pingback fail-closed/read-only lookup branches, legacy post title/category XML + helpers, authenticated read-only `wp.getPost`, `wp.getPosts`, + `wp.getMediaItem`, and `wp.getMediaLibrary` post/media field filtering, + authenticated `wp.newPost`, `wp.editPost`, and `wp.deletePost` post write + lifecycle branches, authenticated `wp.getTerms`, `wp.getTerm`, + `wp.newTerm`, `wp.editTerm`, and `wp.deleteTerm` taxonomy method lifecycles, + auth/capability/error paths, media MIME/parent filters, prepared-term and + call hooks, hook cleanup, and short-circuited `WP_HTTP_IXR_Client` + transport/error handling without option, post-sleep pingback fetch, or live + network side effects. + +Some checks deliberately skip cases that would invoke DB-backed or dynamic block +rendering side effects. The Admin Screen surface intentionally avoids admin page +submission, `options.php` writes, user preference persistence, real post objects, +and block-editor compatibility shims that would inspect installed plugins. The +`script-loader-runtime` surface complements `assets` by covering server-side +runtime helpers in `script-loader.php`, `functions.wp-scripts.php`, and +`functions.wp-styles.php` without browser execution. It directly calls the emoji +detection printer instead of the public static-once wrapper so iterations remain +isolated, directly asserts concatenated loader query chunks and separate +strategy/external asset tags under forced concat globals, and covers +just-in-time script localization for autosave, mce-view, and word-count in an +isolated child process so `AUTOSAVE_INTERVAL` does not leak into the parent. It +avoids admin/page dispatch, process exits, live HTTP, live DB-backed block +queries, and browser module execution while still asserting restored globals, +filters, and output buffers. +The +Admin Workflows surface intentionally avoids `admin.php`/`admin-ajax.php` request +dispatch, direct DB-backed concrete `WP_*_List_Table` subclasses, and direct +`die()` or destructive `wp_ajax_*` wrappers. It covers the base list table API +with synthetic items, records scoped accounting for concrete list-table coverage +owned by `admin-list-tables` and `privacy-admin-requests`, referer helpers where +valid nonces or `stop=false` avoid exits, and deterministic date/time AJAX +wrappers through captured `wp_die()` termination. This is not full admin page +dispatch coverage. The `admin-ajax` surface calls selected `wp_ajax_*` handlers +directly with captured `wp_die()` termination; attachment workflows use +synthetic attachment rows, bounded capability grants, and narrow post MIME LIKE +support in the in-memory `wpdb` stub. The `admin-list-tables` surface complements +that base coverage by loading concrete `WP_*_List_Table` subclasses with +synthetic rows, object-cache fixtures, temporary plugin/theme/network-theme +metadata, exact row-action nonce checks, and `posts_pre_query`, +`comments_pre_query`, `terms_pre_query`, `users_pre_query`, and +`sites_pre_query` short-circuits. `WP_Theme_Install_List_Table::prepare_items()` +raw-requires `theme-install.php`, so the theme install API-argument invariant +runs once per PHP process and later iterations seed concrete table items, +pagination, and view globals directly to replay row/action/escaping coverage +without triggering redeclarations. It intentionally skips full admin dispatch, +install/update tables, destructive plugin/theme operations, real uploads, and +true multisite write paths; privacy request tables and their AJAX handlers live +in the dedicated `privacy-admin-requests` surface, and network site/user rows +remain synthetic when the shared PHP process is not in multisite mode. The +`post-embeds` surface covers direct provider helpers and the oEmbed item +controller without loading the full embed template, dispatching theme rendering, +performing remote discovery, or requiring generated build artifacts; when the +source checkout lacks the built `wp-embed.js` file it suppresses only that +expected file-read warning while still asserting the generated embed markup +shape. The +`appearance-media` surface covers custom background/header/site icon helpers, +frontend head callbacks, printed custom-header script side effects, and +synthetic site-icon attachment metadata without invoking media uploads, image +crops, AJAX actions, or admin page dispatch. The +block templates surface short-circuits template CPT queries through +`posts_pre_query` and asserts that file enumeration and direct template-ID +resolution remain confined to the active theme template directories. The +`block-widgets` surface exercises `WP_Widget_Block`, sidebars widget option +mapping, `retrieve_widgets()` remap/lost-widget recovery, and +`wp_check_widget_editor_deps()` script/style conflict warnings without loading +the browser widgets editor, performing REST persistence, or depending on theme +files. Dependency-warning coverage asserts both widget-editor handles, script +and style conflict classes, dependency-chain enqueued semantics, scoped +`_doing_it_wrong()` capture, version/message payloads, and asset/hook/global +restoration. The +Admin Media Chrome surface intentionally avoids SAPI-marked browser upload +success, real attachments created by browser flows, media modal runtime +behavior, and browser-side image editor UI; it covers `wp_media_attach_action()` +attach and detach redirect exits through isolated child processes. Server-side +image-edit AJAX save, preview, crop, restore, and sub-size request branches are +covered by `media-image-edit-requests`. The surface covers direct server-side +helpers with synthetic attachment rows and cache/filter-backed metadata only. The +base `customizer` surface covers direct manager/theme-preview lifecycles but +intentionally avoids changeset save/publish, `setup_theme()`-driven theme +switching, nav-menu persistence, widget persistence, media enqueue/browser +modal flows, real uploads/crops, and real post/option storage beyond the +existing no-DB option stub. Its media-control coverage uses default URLs and +cache-seeded attachments for server-side JSON/template contracts only. +`customizer-persistence` covers changesets and custom CSS persistence, while +`customizer-nav-widgets-requests` covers bounded nav-menu/widget request, remap, +and selective-refresh persistence paths against the in-memory `wpdb` stub. The +`admin-options-submission` surface +covers the bounded `wp-admin/options.php` submission/update branch without +loading the full admin bootstrap, redirects, or process exits; it locally +installs only the two documented `new_admin_email` dynamic option hooks when exercising the pending +admin email confirmation path. It also mirrors the conditional Writing Settings +allowlist gates for post-by-email, legacy DB-version formatting options, and +public-blog update services without loading the exiting admin controller. The +`options-autoload` surface uses that same bounded in-memory option table and +object cache, and deliberately focuses on core option/autoload/cache/hook +semantics, including registered option-group cache priming, rather than admin +form submission, network options, transients, or arbitrary SQL support. The +`media-editor` surface short-circuits attachment metadata updates and +intentionally avoids media paths that insert attachments, create cover-image +attachments, process audio/video thumbnails, or otherwise require real postmeta +writes. The `media-image-edit-requests` surface complements it with bounded +in-memory attachment rows, temp upload roots, captured AJAX termination, and a +fake `WP_Image_Editor`; it avoids codec-dependent image decoding and +subprocess-only `IMAGE_EDIT_OVERWRITE` branches. The `multisite` surface leaves `MULTISITE` +disabled for the shared PHP process; true multisite `sitemeta` write paths and +site creation/update/deletion run only in an isolated subprocess when a readable +`wp-tests-config.php` points at a real multisite test database, and that row +skips explicitly when no such config is available or the configured database is +not reachable. Broader DB-backed multisite query execution remains +short-circuited through filters, while non-multisite network-option CRUD +remains covered by the existing option stub. The Site +Health surface avoids loopback, WordPress.org, REST availability, update +download, mail, cron, and filesystem-writing checks unless they are fully +short-circuited. The mail +surface intercepts PHPMailer send calls and never attempts real delivery. The +`media-metadata` surface uses malformed local fixtures and cache-seeded or +in-memory stub attachments only; it does not download remote media, invoke +codecs or external binaries, or persist attachments outside the stub/temp upload +harness. Its `wp_generate_attachment_metadata()` coverage enables audio/video +thumbnail support only inside a scoped row to exercise cover attachment +creation/reuse and cleanup. Generated metadata replacement coverage also +asserts gallery/playlist shortcode cache coherence, stale-field removal, and +read-only shortcode no-mutation behavior through in-memory image/audio/video +attachments. Its shortcode rendering coverage stays on direct shortcode helper +calls with generated local-looking URLs, cache-seeded gallery/playlist +attachments, `posts_pre_query` short-circuits, and scoped filter/script/style +cleanup, not browser playback. The +`image-metadata` surface complements that audio/video coverage with generated +local image byte fixtures and repo-local Core image fixture replays. It does not +invoke image codecs, live uploads, remote media, or attachment persistence; +EXIF/IPTC extraction rows are recorded as explicit skips when the PHP build +lacks `exif_read_data()` or `iptcparse()`, while malformed/no-metadata image +paths and filter cleanup still run. The +`rest-controllers` surface remains no-live-DB and registry-first, with bounded +temp-file coverage only for local block pattern loader behavior; it also +dispatches the dynamic block renderer controller through a local REST server to +cover route args, attribute validation/sanitization, POST bodies, post context, +and `pre_render_block` filter cleanup, and isolates menu-location controller +routes, schema, anonymous/read-access/capability permissions, `_fields` +projection, assigned menu IDs, REST links, prepare filters, invalid-location +errors, and nav-menu/current-user restoration. DB-backed posts, terms, comments, +users, revisions, and attachments are covered by `rest-object-controllers` +against the in-memory `wpdb` stub. The +`rest-application-passwords` surface complements lower-level account-security +coverage by dispatching the REST controller with synthetic users and scoped +application-password metadata, while directly asserting the REST auth-status and +index-advertisement plumbing that sits outside controller CRUD methods. Its +route-dispatched mutation matrix also covers logged-out and invalid create +failures, valid one-time password creation, PUT `app_id` immutability, +introspection, delete denial/removal, bulk deletion, hook payload timing, and +REST default-filter isolation. +`rest-directory-services` complements the REST controller surfaces by exercising +WordPress.org-backed directory controllers and direct plugin/theme directory +APIs without live network access. Its runtime now clones `WP_Hook` entries when +snapshotting/restoring `wp_filter` and fingerprints hook callback shape, so REST +default filters installed during directory-controller dispatch cannot leak into +later `/batch/v1` REST checks. +`rest-media-attachments` covers the bounded REST attachment upload and +client-side media-processing write paths using raw request bodies, temp upload +roots, attachment postmeta, response projection, metadata finalization, +permission gates, URL sideload downloads short-circuited through local HTTP +fixtures, and raw sideload metadata updates for generated subsizes and +original-image files. Its runtime explicitly refreshes default and +parent-date-keyed upload-directory cache entries so previous media surfaces +cannot leak year/month upload paths into raw REST uploads. It intentionally +avoids multipart success paths that depend on PHP's `is_uploaded_file()` state +and admin image-edit request paths, which are covered by +`media-image-edit-requests` when they can be kept process-local and +codec-independent. `rest-widgets-sidebars` +complements the lower-level widget surfaces by exercising REST controller +permissions, schemas, instance encoding, sidebar mutation, legacy form-data +paths, and the `/widget-types/{id}/render` iframe endpoint directly. The render +path runs in an isolated child process so +the global `IFRAME_REQUEST` constant cannot leak into the shared runner. The +object surface records explicit skip rows for template controllers that depend +on block-theme filesystem state and template CPT queries. Broad collection +query translation for posts, attachments, revisions, users, comments, and terms +is covered through REST query filters, query-class pre-query short-circuits, +HEAD pagination headers, no broad SQL execution, and filter restoration. It +also dispatches taxonomy term mutation routes against category and tag +controllers to cover parent schema boundaries, capability denial, REST-visible +term-meta sanitization, insert/after-insert/prepare/delete hook order, +force-delete previous-meta projection, and term-meta cleanup. It documents +limits for invalid enum-error formatting +branches that are not warning-safe under the stripped bootstrap. The +registry-backed surface covers plugin/theme controller route, schema, +collection parameter, sanitizer, and permission-gate contracts without +plugin/theme filesystem lifecycle effects. Lifecycle-heavy plugin/theme read and +status paths are covered by `plugin-theme-lifecycle`, along with bounded REST +plugin create/install, status update, and inactive-delete controller methods. +Block pattern coverage now includes +registry-backed response shaping, intercepted remote core, featured, and theme +pattern directory loaders, and local theme `patterns/` PHP file loading through +a scoped temp theme with cache/header/duplicate/lazy-content oracles. +The `rest-site-editor` surface creates a bounded temp theme under the harness +`WP_CONTENT_DIR` and uses the in-memory `wpdb` stub for `wp_global_styles`, +`wp_template`, `wp_template_part`, `revision`, and `wp_navigation` fixtures. It +dispatches only bounded template/template-part item routes and the lookup +fallback route through `WP_REST_Server`; direct template mutation coverage uses +targeted controller calls, scoped capabilities, and fixture-local +`WP_Block_Template` lookups for create/update/delete/reset oracles. Broad +template collection queries and unbounded theme/template CPT query paths remain +skipped. Export coverage uses the direct ZIP generator and a subprocess-isolated +`WP_REST_Edit_Site_Export_Controller::export()` oracle with bounded +`pre_get_block_templates` fixtures, structured stdout metadata, streamed ZIP +inspection, generated ZIP cleanup checks, and parent-process state restoration. +The `block-editor-adjuncts` surface keeps REST preloading on synthetic +`rest_pre_dispatch` responses and keeps theme styles local to temp fixtures; +it does not load editor screens, dispatch DB-backed REST controllers, fetch +remote editor styles, or render browser UI. +The `plugin-theme-lifecycle` surface uses a process-local temp `wp-content` +tree and generated minimal fixtures only; it does not activate repository +plugins or switch to repository themes. Network-wide activation is not forced +when the shared process is not running with `MULTISITE`; in that mode the +surface verifies sitewide option shapes and the non-multisite false branch. +REST plugin/theme controller coverage includes read/status/parameter paths and +bounded REST plugin create/install paths with mocked `plugins_api()` responses, +generated ZIP packages, direct filesystem transport, active-install permission +gates, status updates, and inactive temp-plugin deletion. It avoids live remote +lookups/downloads and destructive operations on repository plugins or themes. +The `content-lifecycle` surface uses a bounded in-memory `wpdb` stub that +recognizes the narrow SQL shapes emitted by core post, term, user, comment, and +metadata lifecycle APIs; it is not a general SQL engine. Post metadata coverage +asserts add/read/update/delete, unique keys, serialized array values, cache +invalidation, metadata hooks, and post-delete cleanup. Post-to-term relationship +coverage asserts category/tag set/append/replace/remove/delete helpers, object +term field modes, relationship caches, hook payloads, and post-delete cleanup. +Post status transition coverage asserts hook order, direct-transition status +storage behavior, count/timeinfo cache invalidation and preservation branches, +empty-GUID publish repair, scheduled future-post hook clearing, and cleanup of +the generated status post type and hooks. +Post and attachment count coverage asserts `wp_count_posts()` status grouping, +readable private-post filtering, count caches, `wp_count_attachments()` MIME and +trash grouping, `get_post_mime_types()` converted group/filter behavior, and +`get_available_post_mime_types()` DB and short-circuit filter paths. +Featured-image coverage asserts `set_post_thumbnail()`/`delete_post_thumbnail()` +postmeta lifecycle behavior, `get_post_thumbnail_id()`/`has_post_thumbnail()` +fail-closed and filter contracts, thumbnail HTML/URL/caption echo helpers, +non-image cleanup, and `update_post_thumbnail_cache()` cache priming. +Page lookup coverage asserts `get_page_by_path()`, `get_pages()`, +`get_page_children()`, and `get_children()` against deterministic page trees, +encoded and full ancestry paths, attachment fallback and page/attachment slug +collisions, custom hierarchical post types, salted hit/miss `post-queries` +caches, include/child/parent/exclude-tree/limit rewrites, filter payloads, +numeric/object/global argument normalization, output shape modes, and cleanup of +posts, filters, globals, and generated post types. +Broader taxonomy relationship behavior is covered by `taxonomy-relationships`, +still limited to the recognized term relationship SQL shapes emitted by the +targeted core APIs. +The `taxonomy` surface remains no-DB; term-query coverage short-circuits through +`terms_pre_query` and asserts query parsing/filter contracts rather than SQL +hydration. +The `bookmark-links` surface extends that stub only for the `wp_links` shapes +emitted by bookmark APIs and safe link CRUD: ID lookups, visibility/search, +include/exclude/category joins through `link_category`, supported order/limit +clauses, and `link_id` projections. It intentionally does not emulate arbitrary +link-manager SQL, admin page dispatch, or live database behavior. +The `auth-flow` surface short-circuits auth cookie sending and covers the direct +`wp_login_form()` and `wp_logout()` lifecycles, but avoids browser login-page +dispatch, redirects, real mail, application-password API requests, and +process-exit paths. +The `canonical-routing` surface calls `redirect_canonical()` with +`do_redirect=false`; 404 permalink guessing uses bounded in-memory post rows, +attachment-page redirects use synthetic parent/attachment rows, and old-slug +paths that would otherwise call `wp_redirect()` and `exit` are exercised only +through bounded cancellation filters. Old-slug and old-date redirects use +deterministic in-memory post/meta rows plus `old_slug_redirect_url` +cancellation, so the lookup, filter, and URL-building branches are covered +without live DB access or browser dispatch. +The `classic-walkers` surface uses synthetic objects, object-cache fixtures, and +local filters only. It loads the single comment/admin walker class files when +available, and covers the direct nav menu quick-search dispatcher plus +post-type/taxonomy menu item meta-box pagination/search queries with scoped +`posts_pre_query` and `terms_pre_query` fixtures. It still avoids browser admin +page dispatch and any DB-backed menu/page/category/comment queries. +The `import-diff` surface covers importer registry, importer form, imported +post/comment lookup, fail-closed importer upload handling, cleanup, text diff, +and WP_Error transfer/lifecycle helpers without remote importer discovery, real +PHP SAPI upload success, or importer dispatch screens. WXR download generation +is covered separately by `wxr-export`. +The `wxr-export` surface invokes actual `export_wp()` once per isolated PHP +subprocess because core defines `wxr_*` helper functions inside that function. +It uses a surface-local in-memory `wpdb` double and deterministic fixtures only; +it does not use a live database, contact the network, or write download files. +PHP CLI usually does not expose `header()` calls through `headers_list()`, so +filename/content-type checks are asserted when observable and otherwise recorded +as explicit skips while the filename filter invocation remains captured. +The `media-ingest` surface uses local temp files only, routes uploads through a +filtered temp upload root, and passes a custom upload action for +`media_handle_upload()` so CLI fixtures use core's readable-file branch instead +of PHP SAPI uploaded-file state. It verifies that upload destinations and GUIDs +use the non-page parent post date subdirectory. It exercises direct +`wp_handle_*()` hooks and `download_url()` only through local files or +`pre_http_request` short-circuits. It avoids browser media UI flows, +audio/video cover-art generation, and writes outside the component-fuzz temp +root. +The `media-remote` surface complements that local ingest coverage by exercising +remote download and sideload helpers with `pre_http_request` fixtures only. It +records every streamed temp filename observed by the HTTP short-circuit, +including package signature soft-fail/hard-fail paths, removes returned temp +files, routes successful sideloads through a temp upload root, and asserts that +its HTTP, signature, upload, extension, and error-body filters are removed after +each check. It never lets unregistered remote URLs fall through to the live HTTP +transport. +The `comment-workflow` surface uses the bounded content/comment rows in the +in-memory `wpdb` stub and avoids notification mail, browser cookie writes, and +`wp_die()` paths by requesting `WP_Error` returns or using non-exiting helpers. +The `revisions-autosaves` surface uses the bounded post and postmeta rows in +the in-memory `wpdb` stub. It exercises autosave creation/update/delete, +post-lock windows, protected revision field filters, revision title/list +helpers, latest revision count and URL helpers, user-filtered autosave lookup, +post type support gates, and restore action/edit-user side effects without +browser dispatch. The in-memory `wpdb` post query stub supports the bounded +`found_posts` and `post_author` equality/`IN` shapes needed by these helpers, +including author intersections across status-`OR` branches. The surface avoids +browser/admin page dispatch, while covering `_show_post_preview()` and +`wp_print_revision_templates()` through direct bounded helper calls with +captured output, and dispatches REST revision/autosave read and force-delete +routes through an isolated `WP_REST_Server` to cover permission failures, +query/schema failures, parent/revision matching, response projection, preview +links, REST default-filter cleanup, and hook payloads without leaking +`DOING_AUTOSAVE` across iterations. +synthetic superglobals, captured `wp_die()` responses, and output buffers. +The `feed-rendering` surface renders core feed templates through synthetic +`WP_Query` loops backed by the in-memory `wpdb` stub. It avoids live HTTP +headers, remote enclosures, DB-backed query execution, and arbitrary invalid +bytes so XML structure and escaping remain useful oracles. Direct self-link +helper coverage mutates `REQUEST_URI`/`HTTP_HOST` without dispatching requests. +Direct enclosure helper coverage replays bounded `rss_enclosure()` newline and +filter fixtures against local post meta only. +The `community-events` surface short-circuits `wp_remote_get()` through +`pre_http_request`; it never contacts api.wordpress.org and limits coverage to +request construction, response normalization, cache behavior, the local admin +AJAX wrapper, and helper contracts rather than dashboard browser rendering. +The `editor-helpers` surface exercises classic editor settings and generated +markup without loading browser editors. It avoids live TinyMCE/Quicktags +execution while still covering inline classic-block settings serialization, and +avoids external asset fetching, admin page dispatch, and AJAX media-shortcode +preview paths. Direct internal-link query coverage short-circuits DB lookup with +generated `posts_pre_query` fixtures while asserting query/result filters, +sanitized titles, permalinks, custom-post labels, empty-result behavior, and +link dialog single-print markup. +The `user-preferences` surface exercises `set_screen_options()` and AJAX +preference handlers in a subprocess so redirect, raw `exit`, and `wp_die()` +paths cannot terminate the parent fuzz runner. It still covers the underlying +user-setting, user-option precedence/deletion, screen visibility caching, and +screen preference helpers directly in-process. +The `update-install-upgrader` surface intentionally avoids live package +downloads, real ZIP unpacking into `wp-content/upgrade`, real plugin/theme +activation or switching, full plugin/theme/core update execution, core +`update-core.php` replacement, language-pack updates, automatic updater run +loops, fatal-error loopback checks, and any process-exit paths. It exercises +safe class/helper paths directly, including synthetic plugin/theme automatic +update notification result classification and failure-cache behavior, and only +uses filters to short-circuit network, email delivery, or external filesystem +credentials. +The `utility-internals` surface focuses on deterministic pure-PHP helpers and +does not replace higher-level rewrite, frontend-feature, or REST coverage that +uses the same classes incidentally. Case-insensitive token-map assertions avoid +known ambiguous overlapping-token inputs and keep exact lookup coverage over the +full generated mapping; token-map export assertions cover key lengths 1, 2, and +3 to guard prefix reconstruction and NUL padding boundaries. Kebab-case +assertions anchor block/style slug compatibility at the helper boundary, and +hierarchy-loop assertions cover terminating chains, self loops, callback-backed +cycles, start-parent overrides, direct tortoise-hare probes, and callback +argument propagation. Diagnostic/error assertions cover `is_wp_error()` action +payload and counter deltas, `wp_debug_backtrace_summary()` raw/pretty/skip/ +ignore-class behavior over a controlled stack, and `wp_trigger_error()` hook +ordering, suppression filters, `WP_DEBUG` gating, local error-handler capture, +and cloned hook-state restoration. +Skips are recorded in `results.ndjson` with a reason and do not mask failures +or PHP errors. + +## Commands + +List registered surfaces: + +```sh +php tools/component-fuzz/runner.php --list-surfaces +``` + +Run all surfaces for 25 generated cases each: + +```sh +php tools/component-fuzz/runner.php --seed 1 --iterations 25 +``` + +Run selected surfaces and stop on the first failure: + +```sh +php tools/component-fuzz/runner.php --surface kses,rest --seed 100 --iterations 200 --fail-fast +``` + +Each run writes `summary.json` and `results.ndjson` under +`artifacts/component-fuzz/run-...` unless `--output-dir` is provided. + +## Surface Contract + +Surface modules live in `tools/component-fuzz/surfaces/*Surface.php` and define: + +```php +namespace ComponentFuzz\Surfaces; + +final class ExampleSurface { + public const NAME = 'example'; + + public static function run( \ComponentFuzz\FuzzContext $ctx ): array { + return array( + $ctx->pass( 'invariant-name', array( 'input' => '...' ) ), + ); + } +} +``` + +Each returned row should be a structured invariant result. Failures should carry +enough input preview and oracle detail to reproduce the case from the surface +name, seed, and iteration in `results.ndjson`. diff --git a/tools/component-fuzz/dashboard.html b/tools/component-fuzz/dashboard.html new file mode 100644 index 0000000000000..dd8b3cb36384a --- /dev/null +++ b/tools/component-fuzz/dashboard.html @@ -0,0 +1,4392 @@ + + + + + + Component Fuzzers Project Dashboard + + + +
    +
    +
    +

    Component Fuzzers Project Dashboard

    +
    Scope: broad WordPress component fuzzing with deterministic generators and explicit invariants.
    +
    +
    + Updated: 2026-07-17
    + Branch: component-fuzzers +
    +
    + +
    +
    +
    Registered Surfaces
    +
    112
    +
    README parity confirmed: 112 documented
    +
    +
    +
    Latest Broad Run
    +
    100%
    +
    Non-skipped pass rate: 5096 passed, 0 failed, 0 errored, 1 skipped at 112 surfaces over 1 iteration after adding importer get_page() HTTP wrapper coverage.
    +
    +
    +
    Latest Focused Wave
    +
    100%
    +
    Latest focused addition: import-diff importer get_page() HTTP wrapper coverage exercises no-network pre_http_request interception, default GET requests, HEAD method mapping, safe-URL request args, 60-second timeout injection via http_request_timeout, Basic Auth only when both username and password are present, exact fake response propagation, and explicit cleanup of pre-HTTP and importer timeout filters. Focused seed 224 passed 11 checks, seeds 1 and 224 over 25 iterations passed 275 checks each, seed 57123 over 10 iterations passed 110 checks, adjacent import/http stack passed 120 checks, broad smoke passed 5096 checks with 1 skip, and syntax/diff checks passed.
    +
    Previous focused addition: feed-rendering direct feed anchor coverage exercises the_feed_link() for default and Atom feeds, exact escaped feed hrefs, raw caller anchor preservation, feed_link URL filtering before href escaping, the_feed_link complete-anchor filtering, generated marker/query escaping, and scoped filter cleanup. Focused seed 224 passed 13 checks, seed 224 over 25 iterations passed 325 checks, seeds 1 and 57123 over 10 iterations passed 130 checks each, adjacent feed/parser/syndication/http/widget stack passed 56 checks, broad smoke passed 5095 checks with 1 skip, and syntax/diff checks passed.
    +
    Previous focused addition: mail emoji email-wrapper coverage exercises wp_staticize_emoji_for_email() across array headers, CRLF/LF string headers, text/html, text/plain, missing headers, missing message, mixed-case content type, and wp_mail_content_type force-on/force-off filters; asserts exact runtime wp_staticize_emoji() output only for HTML cases, byte-stable non-HTML branches, exact preservation of recipients, subject, headers, attachments, and embeds, and scoped filter cleanup. Focused seed 224 passed 12 checks, seed 224 over 25 iterations passed 300 checks, seeds 1 and 57123 over 10 iterations passed 120 checks each, adjacent mail/email/privacy stack passed 3840 checks, broad smoke passed 5094 checks with 1 skip, and syntax/diff checks passed.
    +
    Previous focused addition: frontend-features URL pattern prefixer generated-context coverage exercises home/site/uploads contexts with relative, leading-slash, already-prefixed, wildcard, and escaped +/* path patterns, grouping-sensitive :?# base paths, invalid-context _doing_it_wrong() diagnostics, idempotence, no double-slash output, and hook cleanup. Focused seed 224 passed 8 checks, seed 224 over 25 iterations passed 200 checks, seeds 1 and 57123 over 10 iterations passed 80 checks each, adjacent frontend/script/style stack passed 83 checks, broad smoke passed 5094 checks with 1 skip, and syntax/diff checks passed.
    +
    Previous focused addition: cron pre-filter failure-contract coverage exercises generated pre_schedule_event, schedule_event, pre_reschedule_event, pre_unschedule_event, and pre_clear_scheduled_hook false-return branches; asserts WP_Error codes when $wp_error is true, plain false returns when it is false, no storage for rejected schedules, preserved existing events for rejected reschedule/unschedule/clear operations, exact filter payloads for single and recurring events, interval preservation, and hook cleanup. Focused seed 224 passed 101 checks, seed 224 over 25 iterations passed 2525 checks, seeds 1 and 57123 over 10 iterations passed 1010 checks each, adjacent cron/lifecycle/options stack passed 176 checks, broad smoke passed 5094 checks with 1 skip, and syntax/diff checks passed.
    +
    Previous focused addition: auth-flow auth_redirect() boundary coverage installs scoped secure/scheme/login/nocache/redirect hooks and exercises forced HTTPS redirects before cookie validation, valid logged-in cookie return paths, per-user use_ssl admin redirects after auth_redirect, invalid-cookie options.php referer login redirects, and invalid-cookie current-request login redirects; asserts redirect targets/statuses without reaching exit, login URL reauth=1 payloads, nocache headers, hook locality, force-SSL restoration, request globals, and in-memory row cleanup. Focused seed 224 passed 13 checks, seed 224 over 25 iterations passed 325 checks, seeds 1 and 57123 over 5 iterations passed 65 checks each, adjacent auth/security/request stack passed 68 checks, broad smoke passed 5089 checks with 1 skip, and syntax/diff checks passed.
    +
    Previous focused addition: account-security retrieve-password request-path coverage exercises generated empty, unknown email, policy-blocked, send-gated, and successful password-reset requests through retrieve_password(); asserts invalid paths fail before key/mail side effects, lostpassword_errors and send_retrieve_password_email stay pre-mutation boundaries, successful POST email lookup stores a checkable timestamped fast-hash key, mail composition exposes canonical login/key data through title/message/notification filters, pre_wp_mail intercepts delivery, and scoped hooks, globals, and in-memory rows restore. Focused seed 224 passed 9 checks, seed 224 over 25 iterations passed 225 checks, seeds 1 and 57123 over 5 iterations passed 45 checks each, adjacent auth/security/mail stack passed 53 checks, broad smoke passed 5088 checks with 1 skip, and syntax/diff checks passed.
    +
    Previous focused addition: rest-widgets-sidebars sidebar default-filtered route-envelope coverage dispatches generated /wp/v2/sidebars and /wp/v2/sidebars/{id} requests through the real routes and default REST filters; asserts public and hidden item GET projection, collection projection for public, inactive, and hidden rows, widget links and target hints, Allow headers, item and collection HEAD empty bodies, prepare-filter locality, response envelopes, scoped caps, and default-filter cleanup. Focused seed 224 passed 13 checks, seed 224 over 25 iterations passed 325 checks, seeds 1 and 57123 over 5 iterations passed 65 checks each, adjacent widget/REST stack passed 112 checks, broad smoke passed 5087 checks with 1 skip, and syntax/diff checks passed.
    +
    Previous focused addition: rest-controllers block-renderer default-filtered field coverage dispatches generated /wp/v2/block-renderer/{name} requests through the real route and default REST filters; asserts GET/POST _fields=rendered, JSON POST body parsing with post context, _fields=missing rendering before final body pruning to [], HEAD following the render callback path, Allow: GET, POST, pre_render_block/render_block_data ordering and locality, response envelopes, and default-filter/global/post/block cleanup. Focused seed 224 passed 26 checks, seed 224 over 25 iterations passed 650 checks, seeds 1 and 57123 over 5 iterations passed 130 checks each, adjacent REST stack passed 71 checks, broad smoke passed 5086 checks with 1 skip, and syntax/diff checks passed.
    +
    Previous focused addition: security admin-referer hook-edge coverage installs generated check_admin_referer and wp_verify_nonce_failed listeners around current, previous-tick, custom query-arg, invalid non-empty, empty, and missing nonce cases; asserts custom query args win over default nonces, valid results return 1/2, invalid non-empty nonces fire wp_verify_nonce_failed before check_admin_referer, empty/missing nonces stay quiet, invalid cases route through wp_nonce_ays() with 403 capture, hook stacks are local, and filters/superglobals restore. Focused seed 224 passed 19 checks, seed 224 over 25 iterations passed 475 checks, seeds 1 and 12345 over 5 iterations passed 95 checks each, hooks,security over 5 iterations passed 155 checks, adjacent request/canonical/http stack passed 80 checks, adjacent auth/admin stack passed 70 checks, broad smoke passed 5085 checks with 1 skip, and syntax/diff checks passed.
    +
    Previous focused addition: rest-controllers search field/link error-envelope coverage dispatches generated /wp/v2/search requests through the real route schema and default REST filters; asserts _fields=_links returns only handler plus collection links, nested _links.self,_links.collection prunes unrequested rels, _fields=type,subtype prepares with internal id but strips ids and links from final bodies, malformed handler results return 500 rest_search_handler_error envelopes for GET and HEAD before preparation, and Allow headers, response envelopes, default filters, and globals stay aligned. Focused seed 224 passed 25 checks, seed 224 over 25 iterations passed 625 checks, seeds 1 and 57123 over 5 iterations passed 125 checks each, adjacent rest,rest-controllers,rest-object-controllers smoke passed 70 checks, broad smoke passed 5084 checks with 1 skip, and syntax/diff checks passed.
    +
    Previous focused addition: rest-controllers search include/exclude coercion coverage dispatches generated /wp/v2/search requests through the real route schema and a custom search handler; asserts comma scalar, numeric-string array, duplicate, zero, and negative IDs sanitize to integer arrays without wp_parse_id_list() semantics, HEAD still calls search_items() but skips preparation, invalid string/fractional/nested/associative include/exclude forms fail with 400 rest_invalid_param before handlers, _fields, total headers, links, Allow headers, envelopes, default filters, and globals stay aligned. Focused seed 224 passed 24 checks, seed 224 over 25 iterations passed 600 checks, seeds 1 and 57123 over 5 iterations passed 120 checks each, adjacent rest,rest-controllers,rest-object-controllers smoke passed 69 checks, broad smoke passed 5083 checks with 1 skip, and syntax/diff checks passed.
    +
    Previous focused addition: rest-controllers plugin/theme item-route dispatch boundary coverage creates a generated temp plugin fixture under WP_PLUGIN_DIR, dispatches plugin GET/DELETE item routes and encoded theme item routes through rest_dispatch_request, and keeps plugin PATCH/DELETE denial and missing-plugin contrasts before callbacks; asserts plugin route tokens sanitize to .php basenames, theme %2F stylesheets decode, denied responses use stable REST envelopes, controller preparation and lifecycle hooks do not fire, plugin/theme options remain unchanged, the fixture/cache are cleaned, and filters/globals/default REST filters restore. Focused seed 224 passed 23 checks, seed 224 over 25 iterations passed 575 checks, seeds 1 and 57123 over 5 iterations passed 115 checks each, adjacent rest,rest-controllers,rest-object-controllers smoke passed 68 checks, broad smoke passed 5082 checks with 1 skip, and syntax/diff checks passed.
    +
    Previous focused addition: rest-controllers plugin/theme allowed dispatch boundary coverage grants scoped caps around valid plugin list, plugin create, active-theme list, and full theme list requests, then short-circuits at rest_dispatch_request; asserts sanitized defaults and scalar status arrays reach the dispatch filter, inactive plugin create defaults status to inactive, active plugin create requires activate_plugins, inactive theme lists require theme-management caps, denied contrasts skip dispatch, controller preparation hooks do not fire, plugin/theme options remain unchanged, and filters/globals/default REST filters restore. Focused seed 224 passed 22 checks, seed 224 over 25 iterations passed 550 checks, seeds 1 and 57123 over 5 iterations passed 110 checks each, adjacent rest,rest-controllers,rest-object-controllers smoke passed 67 checks, broad smoke passed 5081 checks with 1 skip, and syntax/diff checks passed.
    +
    Previous focused addition: rest-controllers plugin/theme argument-envelope coverage dispatches generated plugin collection, plugin create, and theme collection argument failures through WP_REST_Server; asserts bad status enum/type, bad plugin collection context, bad plugin search type, missing slug, array slug type, scalar slug=!!! pattern, and bad create status return stable 400 envelopes before permissions or lifecycle callbacks; also asserts scalar status sanitizes to arrays before permission denial, dispatch callbacks remain untouched, wp_sprintf_l enum formatting is scoped, and filters/globals/default REST filters restore. Focused seed 224 passed 21 checks, seed 224 over 25 iterations passed 525 checks, seeds 1 and 57123 over 5 iterations passed 105 checks each, adjacent rest,rest-controllers,rest-object-controllers smoke passed 66 checks, broad smoke passed 5080 checks with 1 skip, and syntax/diff checks passed.
    +
    Previous focused addition: rest-controllers menu-location argument/permission boundary coverage installs generated menu locations and scoped rest_menu_read_access, theme_mod_nav_menu_locations, rest_prepare_menu_location, and wp_sprintf_l support around invalid and valid context dispatches; asserts invalid collection/item contexts return 400 rest_invalid_param with context => rest_not_in_enum, read-access filters observe raw invalid contexts while preparation remains blocked, valid anonymous view requests remain rest_cannot_view, read-access override responses preserve assigned menu IDs, and filters/globals/default REST filters restore. Focused seed 224 passed 20 checks, seed 224 over 25 iterations passed 500 checks, seeds 1 and 57123 over 5 iterations passed 100 checks each, adjacent rest,rest-controllers,rest-object-controllers smoke passed 65 checks, broad smoke passed 5079 checks with 1 skip, and syntax/diff checks passed.
    +
    Previous focused addition: rest batch-v1 request schema boundary coverage exercises top-level validation mode and required requests validation plus generated child path, method, body, and header shapes; asserts omitted top-level validation and child method default to executable normal/POST behavior with query/body/header preservation, invalid cases return 400 before callbacks with stable parameter attribution, callback counts remain local, and response envelopes stay aligned. Focused seed 224 passed 16 checks, seed 224 over 25 iterations passed 400 checks, seeds 1 and 57123 over 5 iterations passed 80 checks each, adjacent rest,rest-controllers,rest-object-controllers smoke passed 64 checks, broad smoke passed 5078 checks with 1 skip, and syntax/diff checks passed.
    +
    Previous focused addition: security nonce verifier hook-edge coverage installs generated check_ajax_referer and wp_verify_nonce_failed listeners around current, previous-tick, invalid, empty, custom-query, and Ajax stop=true nonce cases; asserts exact wrapper return values, empty nonce non-dispatch, invalid non-empty nonce failure payloads, verifier-before-wrapper hook ordering, Ajax wp_die() routing and 403 response capture, hook stack locality, scoped filter cleanup, and superglobal restoration. Focused seed 224 passed 18 checks, seed 224 over 25 iterations passed 450 checks, seeds 1 and 12345 over 5 iterations passed 90 checks each, hooks,security over 5 iterations passed 150 checks, adjacent request/canonical/http stack passed 79 checks, adjacent auth/admin stack passed 69 checks, broad smoke passed 5077 checks with 1 skip, and syntax/diff checks passed.
    +
    Previous focused addition: kses reentrant allowed-html leakage diagnostics install generated wp_kses_allowed_html string contexts that reenter wp_kses() with explicit inner policies; assert current-core $pass_allowed_html/$pass_allowed_protocols leakage is modeled exactly, first-token outer policy still applies before leakage, later tokens follow the leaked inner policy, unsafe protocols/event handlers/control bytes remain stripped, hook stack locality holds, scoped filters are removed, and hook/global snapshots restore. Focused seed 224 passed 1109 checks, seed 224 over 25 iterations passed 27812 checks, seeds 1, 100, and 424242 over 5 iterations passed 5558, 5556, and 5562 checks, adjacent kses,security,markup,shortcodes,formatting,blocks,block-supports smoke passed 1207 checks, hooks,kses,security over 5 iterations passed 5703 checks, broad smoke passed 5076 checks with 1 skip, and syntax/diff checks passed.
    +
    Previous focused addition: kses pre-hook lifecycle coverage installs generated pre_kses and wp_kses_allowed_html filters around custom string and explicit policies; asserts normalized content reaches early/late filters in priority order, original allowed-html argument types and protocol arrays are preserved, hook stack locality is exact, modeled hook mutations are still sanitized, outputs are fixed points when pre-hook mutation is disabled, scoped allowed-html contexts stay isolated, and hook/global snapshots restore. Focused seed 224 passed 1108 checks, seed 224 over 25 iterations passed 27787 checks, seeds 1, 100, and 424242 over 5 iterations passed 5553, 5551, and 5557 checks, adjacent kses,security,markup,shortcodes,formatting,blocks,block-supports smoke passed 1206 checks, hooks,kses,security over 5 iterations passed 5698 checks, broad smoke passed 5075 checks with 1 skip, and syntax/diff checks passed.
    +
    Previous focused addition: rest batch-v1 parsed no-route child alignment coverage adds valid siblings before and after generated parsed paths that match no registered route; asserts normal mode keeps ordered 404 rest_no_route envelopes with data.status=404, dispatches child pre/post filters for no-route slots, preserves valid sibling permission/callback execution and post-dispatch headers, and require-all-validate reports failed=validation with null valid-sibling slots, no child pre/post dispatch, and no permission/callback execution. Focused seed 224 passed 15 checks, seed 224 over 25 iterations passed 375 checks, seeds 1 and 57123 over 5 iterations each passed 75 checks, adjacent rest,rest-controllers,rest-object-controllers smoke passed 63 checks, broad smoke passed 5074 checks with 1 skip, and syntax/diff checks passed.
    +
    Previous focused addition: security generated redirect dispatch boundary coverage exercises wp_redirect() and wp_safe_redirect() sequencing for redirect/status filters, falsey-location cancellation before invalid status enforcement, invalid filtered statuses reaching captured wp_die() before x_redirect_by, sanitized final locations passed to x_redirect_by, filtered safe-redirect fallbacks, allowed-host expansion, root-relative and protocol-relative inputs, and headerless interruption before real Location headers. Focused seed 224 passed 17 checks, seed 224 over 25 iterations passed 425 checks, seeds 1 and 12345 over 5 iterations each passed 85 checks, adjacent security,request-lifecycle,formatting,canonical-routing,http smoke passed 78 checks, adjacent security,auth-flow,account-security,identity,admin-ajax,admin-options-submission,admin-workflows smoke passed 68 checks, broad smoke passed 5073 checks with 1 skip, and syntax/diff checks passed.
    +
    Previous focused addition: kses generated low-level helper contract coverage exercises deterministic string, array-policy, malformed-attribute, and entity cases for wp_kses_stripslashes(), wp_kses_array_lc(), wp_kses_html_error(), wp_kses_decode_entities(), wp_kses_normalize_entities(), and the named/numeric entity callbacks; asserts exact local models for slash-before-double-quote stripping, two-level key lowercasing, byte-oriented numeric entity decoding, HTML/XML entity normalization, callback casing/valid-Unicode behavior, and idempotence where the helper contract supports it. Focused seed 224 passed 1107 checks, seed 224 over 25 iterations passed 27762 checks, extra seeds 1, 100, and 424242 over 5 iterations passed 5548, 5546, and 5552 checks, adjacent kses,security,markup,shortcodes,formatting,blocks,block-supports smoke passed 1204 checks, broad smoke passed 5072 checks with 1 skip, and syntax/diff checks passed.
    +
    Previous focused addition: rest batch-v1 malformed child path parsing coverage adds a valid batch-allowed sibling followed by generated wp_parse_url() failure paths such as empty authorities, bad userinfo, and invalid ports; asserts normal mode returns aligned 400 parse_path_failed child envelopes without child pre/post dispatch for malformed slots, still executes the valid sibling, and require-all-validate returns failed=validation with a null valid slot and parse-failed envelopes without child execution. Focused seed 224 passed 14 checks, seed 224 over 25 iterations passed 350 checks, adjacent rest,rest-controllers,rest-object-controllers smoke passed 62 checks, broad smoke passed 5071 checks with 1 skip, and syntax/diff checks passed; targeted PHPUnit was unavailable because vendor/bin/phpunit is absent in this worktree.
    +
    Previous focused addition: rest-object-controllers require-all mixed collection-create validation abort coverage adds a three-child /batch/v1 request ordered as valid post create, invalid category parent create, and valid user create under require-all-validate; asserts top-level 207 with failed=validation, valid siblings return null slots, the invalid category envelope carries 400 rest_invalid_param for parent, direct invalid body parity holds, no child rest_post_dispatch fires during the aborted batch, no post/term/user row appears by generated slug/login/email, defensive cleanup is row-aware, and content-count/filter/global/wpdb restoration holds. Focused seed 224 passed 29 checks, seed 224 over 25 iterations passed 725 checks, adjacent rest,rest-controllers,rest-object-controllers,content-lifecycle,query,capabilities smoke passed 896 checks, broad smoke passed 5070 checks with 1 skip, and syntax/diff checks passed.
    +
    Previous focused addition: rest-object-controllers normal mixed collection-create batch coverage adds a three-child /batch/v1 request ordered as valid post create, invalid category parent create, and valid user create; asserts top-level 207 without failed, child envelopes stay in request order with 201, 400 rest_invalid_param, and 201 statuses, direct-dispatch body/status/header parity, valid post/user persistence, invalid term non-creation, cleanup deletion of valid siblings, child rest_post_dispatch locality, and content-count/filter/global restoration. Focused seed 224 passed 28 checks, seed 224 over 25 iterations passed 700 checks, adjacent rest,rest-controllers,rest-object-controllers,content-lifecycle,query,capabilities smoke passed 895 checks, broad smoke passed 5069 checks with 1 skip, and syntax/diff checks passed.
    +
    Previous focused addition: kses wp_kses_post_deep() nested structure coverage exercises generated strings, scalars, arrays, and public stdClass payloads with unsafe HTML values and dangerous-looking array keys; asserts exact recursive equivalence to map_deep( ..., 'wp_kses_post' ), container/key/property shape preservation, scalar leaf conversion through wp_kses_post(), idempotence, unsafe post-content token stripping, safe URL preservation, pre_kses hook stability, and KSES global restoration. Focused seed 224 passed 1106 checks, seed 224 over 25 iterations passed 27737 checks, adjacent kses,security,markup,shortcodes,formatting,blocks,block-supports smoke passed 1203 checks, broad smoke passed 5068 checks with 1 skip, and syntax/diff checks passed.
    +
    Previous focused addition: rest-widgets-sidebars missing-sidebar update diagnostics exercise route-dispatched PATCH /wp/v2/sidebars/{missing_id} with a schema-valid widgets array; captures the current non-fail-closed behavior where the route returns a 200 inactive-sidebar-shaped response, emits null-sidebar warnings, creates a raw unregistered sidebar entry, steals the requested widget from the registered public sidebar, fires rest_save_sidebar with a null sidebar payload, and still returns rest_sidebar_not_found for a follow-up GET. Focused seed 224 passed 12 checks, seed 224 over 25 iterations passed 300 checks, adjacent widget surfaces passed 250 checks, broad smoke passed 5066 checks with 1 skip, and syntax/diff checks passed.
    +
    Previous focused addition: rest-widgets-sidebars raw sidebar widget projection coverage exercises WP_REST_Sidebars_Controller::update_item() with _fields=id,status,widgets and a generated widget list containing a stolen registered widget, an unregistered stale widget id, the existing public text widget, and a duplicate valid widget id; asserts clean fixture seeding, REST response projection filters the stale id while preserving duplicate valid ids, raw sidebars_widgets stores the exact requested list, hidden sidebar state and inactive widget movement remain correct, rest_save_sidebar observes the raw request payload, and caps/hooks/state are restored. Focused seed 224 passed 11 checks, seed 224 over 25 iterations passed 275 checks, adjacent widget surfaces passed 245 checks, broad smoke passed 5065 checks with 1 skip, and syntax/diff checks passed.
    +
    Previous focused addition: revisions-autosaves built-in post/page parent REST meta validation contrast exercises PUT /wp/v2/posts/{id} and PUT /wp/v2/pages/{id} with registered string, object, and auth-denied revisioned meta keys, then contrasts direct autosave raw revisioned-meta storage; asserts valid scalar/object parent meta storage, wrong-typed string meta fails with rest_invalid_type, object extra properties fail with rest_additional_properties_forbidden, scalar top-level meta fails with rest_invalid_param, auth_callback => false blocks parent update and null-delete with rest_cannot_update and rest_cannot_delete, allowed null delete removes parent raw meta while projecting the registered empty string, direct autosave storage keeps wrong-typed revisioned meta raw while REST revision projection returns null, auth-denied revisioned meta still stores on the autosave revision, and filters/current user/meta keys are restored. Revisions/autosaves seed 224 passed 20 checks, seeds 1, 224, and 57123 over 25 iterations each passed 500 checks, seed 100 over 20 iterations passed 400 checks, adjacent REST/content smoke passed 958 checks, broad smoke passed 5064 checks with 1 skip, and syntax/diff checks passed.
    +
    Previous focused addition: revisions-autosaves built-in REST negative write and malformed meta boundary coverage exercises POST/GET autosave routes and GET/DELETE revision routes for /wp/v2/posts and /wp/v2/pages inside a local PHP child with WP_RUN_CORE_TESTS defined before bootstrap; asserts anonymous and logged-in users without caps fail closed with rest_cannot_edit, rest_cannot_read, or rest_cannot_delete and no writes, invalid IDs and cross-type parents return the correct rest_post_invalid_id, rest_post_invalid_parent, or rest_post_no_autosave errors, scalar top-level meta is rejected before autosave creation, wrong-typed revisioned meta values are accepted as raw revision meta and projected as null, unregistered and non-revisioned REST meta are ignored, existing autosaves can be overwritten by malformed raw revisioned meta, and DOING_AUTOSAVE does not leak. Revisions/autosaves seed 224 passed 19 checks, seeds 1, 224, and 57123 over 25 iterations each passed 475 checks, seed 100 over 20 iterations passed 380 checks, adjacent REST/content smoke passed 948 checks, broad smoke passed 5063 checks with 1 skip, and syntax/diff checks passed.
    +
    Previous focused addition: revisions-autosaves isolated built-in REST autosave mutation coverage exercises POST /wp/v2/posts/{id}/autosaves and POST /wp/v2/pages/{id}/autosaves in a local PHP child with WP_RUN_CORE_TESTS defined before bootstrap; asserts same-author unlocked drafts update the parent instead of creating per-user autosaves, other users create per-user autosave revisions with revisioned REST meta, existing autosaves update in place and fire wp_creating_autosave once, parent-equivalent no-op requests return the existing autosave without update hooks, autosave item routes return the current user's autosave even when another autosave ID is passed, normal and autosave revision item routes project revision-row meta instead of current parent meta, and DOING_AUTOSAVE does not leak into parent or child state. Revisions/autosaves seed 224 passed 18 checks, seeds 1, 224, and 57123 over 25 iterations each passed 450 checks, seed 100 over 20 iterations passed 360 checks, adjacent REST/content smoke passed 947 checks, broad smoke passed 5062 checks with 1 skip, and syntax/diff checks passed.
    +
    Previous focused addition: revisions-autosaves built-in post/page REST parity coverage exercises /wp/v2/posts/{id}/revisions, /wp/v2/pages/{id}/revisions, and matching autosave routes through an isolated WP_REST_Server; asserts built-in parent routes dispatch, orderby=include requires include and preserves include ordering, revision HEAD requests use ID-only queries with pagination headers and no body preparation, out-of-bounds page and offset requests fail closed while nonzero offset takes precedence over an out-of-bounds page, autosave collections expose seeded autosaves, same-parent autosave item routes return the current user's autosave, autosave HEAD stays body-free, cross-type post/page parent IDs return rest_post_invalid_parent, and filters/current user are restored. Revisions/autosaves seed 224 passed 17 checks, seeds 1, 224, and 57123 over 25 iterations each passed 425 checks, seed 100 over 20 iterations passed 340 checks, adjacent REST/content smoke passed 955 checks, broad smoke passed 5061 checks with 1 skip, and syntax/diff checks passed.
    +
    Previous focused addition: revisions-autosaves batch/v1 nested subroute gate coverage exercises generated custom post type revision and autosave routes through an isolated WP_REST_Server; asserts direct revision collection/item, autosave item, invalid relevance ordering, and parent item baselines work before batch checks, batch child requests avoid unsupported GET methods, normal batch mode returns rest_batch_not_allowed envelopes for nested revision delete and autosave create requests before callbacks, queries, deletes, or writes run, require-all-validate nulls an allowed parent-item sibling while surfacing nested revision/autosave validation errors, child post-dispatch headers/events stay local to the normal batch gate, seeded rows and content counts remain unchanged, and custom filters/current user are restored. Revisions/autosaves seed 224 passed 16 checks, seeds 1, 224, and 57123 over 25 iterations each passed 400 checks, seed 100 over 20 iterations passed 320 checks, adjacent REST/content smoke passed 954 checks, broad smoke passed 5060 checks with 1 skip, and syntax/diff checks passed.
    +
    Previous focused addition: rest-object-controllers batch/v1 item delete failure and reassign parity coverage exercises fresh post, category, and user targets plus a generated user-owned post through an isolated WP_REST_Server; asserts unauthenticated force DELETE envelopes match direct dispatch bodies/status/headers using response_to_data(), no-force post/category/user DELETE failures return rest_trash_not_supported without mutation, comments and media item DELETE routes fail at rest_batch_not_allowed, require-all-validate aborts valid sibling deletes on invalid user reassign with null slots and no row changes, forced post/category/user deletes match direct body/status/header parity, user deletion reassigns the owned post, child post-dispatch locality excludes /batch/v1 and require-all null siblings, and capability/default REST filter/server/action/current-user/wpdb/setup-row cleanup is restored. REST object seed 224 passed 27 checks, REST object seeds 1, 224, and 57123 over 25 iterations each passed 675 checks, REST object seed 100 over 20 iterations passed 540 checks, adjacent REST/query/content/capability smoke passed 1452 checks, broad smoke passed 5059 checks with 1 skip, and syntax/diff checks passed.
    +
    Previous focused addition: content-lifecycle generated author_name boolean and non-zero scalar legacy author input coverage compares true, integer/float/string whole-number forms, plus-prefixed and zero-padded numeric strings, decimal floats, decimal strings, exponent-like strings, and negative integer/float/string scalars under orderby=ID, across fields=ids, fields=id=>parent, and fields=all; asserts early scalar digit/comma/hyphen stripping, final scalar author resolution from author_name, retained legacy author__in/author__not_in arrays, SQL post_author IN or post_author NOT IN plus post_author = according to core precedence, matching versus empty payloads when legacy filters allow or exclude the resolved author, parent/status/class shapes, salted post-queries payloads, request/cache sharing with canonical explicit author filter groups for whole-number scalar forms, distinct request/cache boundaries from plain author_name, and digit-concatenated decimal/exponent cache-key separation from canonical numeric groups. Content focused seed 224 passed 30 checks, content seeds 1, 224, and 57123 over 25 iterations each passed 750 checks, content seed 100 over 20 iterations passed 600 checks, adjacent posts/canonical/REST smoke passed 1513 checks, broad smoke passed 5053 checks with 1 skip, and syntax/diff checks passed.
    +
    Previous focused addition: content-lifecycle generated author_name empty, non-scalar, and parsed-zero legacy author input coverage compares empty strings, whitespace-only strings, false, integer/string zero forms, padded and negative zero strings, arrays, objects, comma-only separators, hyphen-only separators, and 0-0 under orderby=ID, across fields=ids, fields=id=>parent, and fields=all; asserts early non-scalar-to-empty and scalar digit/comma/hyphen stripping, final scalar author resolution from author_name, plain-like empty/non-scalar query-var convergence with no legacy include/exclude arrays, parsed-zero author__not_in=[0] behavior for separator-only inputs, SQL post_author = only versus post_author NOT IN (0) plus post_author =, matching payload authors, parent/status/class shapes, salted post-queries payloads, plain-like request/cache convergence with plain author_name, and parsed-zero request/cache convergence with the zero-token family while remaining distinct from plain. Content focused seed 224 passed 30 checks, content seeds 1, 224, and 57123 over 25 iterations each passed 750 checks, content seed 100 over 20 iterations passed 600 checks, adjacent posts/canonical/REST smoke passed 1513 checks, broad smoke passed 5053 checks with 1 skip, and syntax/diff checks passed.
    +
    Previous focused addition: content-lifecycle generated author_name percent-encoded and noisy legacy author collision coverage compares noisy wrapper strings, malformed %zz strings that pre-strip to canonical A,-B, reversed noisy exclusions, literal-comma plus encoded-noise strings, encoded comma/no-separator strings such as A%2C-B, A%252C-B, and A%2C0%2C-B, encoded hyphen strings such as A%2D-B and A%252D-B, and 0%2C-B include-two behavior under orderby=ID, across fields=ids, fields=id=>parent, and fields=all; asserts the early legacy-author digit/comma/hyphen strip without percent decoding, normalized author_name query vars, final scalar author resolution from the slug, derived legacy arrays, SQL post_author IN or post_author NOT IN plus post_author = according to core precedence, matching versus empty payloads, parent/status/class shapes, salted post-queries payloads, canonical request/cache convergence for noisy stripped forms, retained-digit encoded-comma and encoded-hyphen cache separation from plain author_name, literal-comma encoded-noise NOT IN request convergence, and shared request/cache keys for normalized upper/lower percent cases. Content focused seed 224 passed 30 checks, content seeds 1, 224, and 57123 over 25 iterations each passed 750 checks, content seed 100 over 20 iterations passed 600 checks, adjacent posts/canonical/REST smoke passed 1513 checks, broad smoke passed 5053 checks with 1 skip, and syntax/diff checks passed.
    +
    Previous focused addition: content-lifecycle generated author_name whitespace and zero-token legacy author collision coverage compares stripped whitespace pseudo-delimiters, comma-surviving empty tokens, explicit 0 tokens, and literal numeric-zero strings including generated A -B, A\n-B, A,,0,-B, ,A,-B,, 0, 00, 0,0, and whitespace-concatenated A 0 -B A patterns under orderby=ID, across fields=ids, fields=id=>parent, and fields=all; asserts the early legacy-author digit/comma/hyphen strip, normalized author_name query vars, final scalar author resolution from the slug, derived legacy arrays including parsed zero exclusions, SQL post_author IN or post_author NOT IN plus post_author = according to core precedence, matching versus empty payloads, parent/status/class shapes, salted post-queries payloads, whitespace-stripped positive-author request/key convergence, literal-zero convergence with plain author_name, parsed-zero request/key separation from plain, ignored include key boundaries, and cache-key sharing for normalized zero-token groups. Content focused seed 224 passed 30 checks, content seeds 1, 224, and 57123 over 25 iterations each passed 750 checks, content seed 100 over 20 iterations passed 600 checks, adjacent posts/canonical/REST smoke passed 1513 checks, broad smoke passed 5053 checks with 1 skip, and syntax/diff checks passed.
    +
    Previous focused addition: content-lifecycle generated author_name mixed signed legacy author collision coverage compares canonical, reversed, and duplicated positive/negative legacy strings including generated A,-B, B,-B, B,-A, A,-A, and A,B,-A,-B patterns under orderby=ID, across fields=ids, fields=id=>parent, and fields=all; asserts normalized author_name query vars, final scalar author resolution from the slug, derived legacy author__in/author__not_in arrays, SQL post_author NOT IN plus post_author = without post_author IN, matching payloads versus empty excluded-slug collisions, parent/status/class shapes, salted post-queries payloads, shared SQL requests by exclusion set, shared cache keys for normalized signed order/duplicate variants, distinct cache keys for ignored positive authors, and cache-key separation from plain author_name queries. Content focused seed 224 passed 30 checks, content seeds 1, 224, and 57123 over 25 iterations each passed 750 checks, content seed 100 over 20 iterations passed 600 checks, adjacent posts/canonical/REST smoke passed 1513 checks, broad smoke passed 5053 checks with 1 skip, and syntax/diff checks passed.
    +
    Previous focused addition: content-lifecycle generated author_name simultaneous author include/exclude collision coverage compares raw canonical, reversed, and duplicated author__in arrays against normalized author__not_in exclusions for the generated nicename-resolved author and neighboring generated author under orderby=ID, across fields=ids, fields=id=>parent, and fields=all; asserts normalized author_name query vars, final scalar author resolution from the slug, raw ignored include query vars, sorted unique exclusions, SQL post_author NOT IN plus post_author = without post_author IN, matching payloads versus empty excluded-slug collisions, parent/status/class shapes, salted post-queries payloads, shared SQL requests for ignored include variants, distinct cache keys for ignored raw includes, shared cache keys for normalized exclusion order/duplicate variants, and cache-key separation from plain and single-filter author_name queries. Content focused seed 224 passed 30 checks, content seeds 1, 224, and 57123 over 25 iterations each passed 750 checks, content seed 100 over 20 iterations passed 600 checks, adjacent posts/canonical/REST smoke passed 1513 checks, broad smoke passed 5053 checks with 1 skip, and syntax/diff checks passed.
    +
    Previous focused addition: content-lifecycle generated author_name explicit author filter collision coverage compares matching and conflicting legacy author, author__in, and author__not_in filters for the generated nicename-resolved author versus a neighboring generated author under orderby=ID, across fields=ids, fields=id=>parent, and fields=all; asserts normalized author_name query vars, final scalar author resolution from the slug, sorted explicit include/exclude query vars, conjunctive SQL post_author IN/post_author NOT IN plus post_author = clauses, matching payloads versus empty collisions, parent/status/class shapes, salted post-queries payloads, singleton legacy/array request-key convergence, normalized pair request-key convergence, and cache-key separation from the plain author_name query. Content focused seed 224 passed 30 checks, content seeds 1, 224, and 57123 over 25 iterations each passed 750 checks, content seed 100 over 20 iterations passed 600 checks, adjacent posts/canonical/REST smoke passed 1513 checks, broad smoke passed 5053 checks with 1 skip, and syntax/diff checks passed.
    +
    Previous focused addition: content-lifecycle generated author_name double-slash empty-segment cache boundary coverage compares unknown double-slash, known-prefix double-slash, and nested-known double-slash paths for the existing generated author-0 sentinel leaf under orderby=ID, across fields=ids, fields=id=>parent, and fields=all; asserts normalized empty author_name query vars, strict false author resolution, SQL post_author = 0, author-0 payloads, parent/status/class shapes, salted post-queries payloads, shared SQL requests with normal missing-slug variants per projection, shared cache keys within the empty-segment family, and distinct cache keys from normal missing-slug variants. Content focused seed 224 passed 30 checks, content seeds 1, 224, and 57123 over 25 iterations each passed 750 checks, content seed 100 over 20 iterations passed 600 checks, adjacent posts/canonical/REST smoke passed 1513 checks, broad smoke passed 5053 checks with 1 skip, and syntax/diff checks passed.
    +
    Previous focused addition: content-lifecycle generated author_name miss and zero-author fallback coverage compares canonical unknown, known-prefix unknown, and trailing known-prefix unknown slug paths for the existing generated author-0 sentinel leaf under orderby=ID, across fields=ids, fields=id=>parent, and fields=all; asserts normalized missing author_name query vars, strict false author resolution, SQL post_author = 0, author-0 payloads, parent/status/class shapes, salted post-queries payloads, shared SQL requests per projection, and shared cache keys per projection. Content focused seed 224 passed 30 checks, content seeds 1, 224, and 57123 over 25 iterations each passed 750 checks, content seed 100 over 20 iterations passed 600 checks, adjacent posts/canonical/REST smoke passed 1513 checks, broad smoke passed 5053 checks with 1 skip, and syntax/diff checks passed.
    +
    Previous focused addition: content-lifecycle generated author_name slug normalization and slash-tail fallback coverage compares canonical, nested, and trailing-slash generated user nicename paths for generated published pretty CPT children under orderby=ID, across fields=ids, fields=id=>parent, and fields=all; asserts normalized author_name query vars, resolved generated author IDs, authored child ID payloads, parent/status/class shapes, salted post-queries payloads, SQL post_author equality, shared SQL requests per projection, and shared cache keys per projection. Content focused seed 224 passed 30 checks, content seeds 1, 224, and 57123 over 25 iterations each passed 750 checks, content seed 100 over 20 iterations passed 600 checks, adjacent posts/canonical/REST smoke passed 1513 checks, broad smoke passed 5053 checks with 1 skip, and syntax/diff checks passed.
    +
    Previous focused addition: content-lifecycle generated author include/exclude precedence coverage compares overlapping canonical, reversed, and duplicated author__in/author__not_in arrays for generated published pretty CPT children under orderby=ID, across fields=ids, fields=id=>parent, and fields=all; asserts author__not_in wins over ignored author inclusions, raw order/duplicate-preserving ignored include query vars, sorted unique exclusion query vars, excluded-author ID payloads, parent/status/class shapes, salted post-queries payloads, SQL post_author NOT IN without post_author IN, shared SQL requests per projection, and distinct cache keys for ignored raw include variants. Content focused seed 224 passed 30 checks, content seeds 1, 224, and 57123 over 25 iterations each passed 750 checks, content seed 100 over 20 iterations passed 600 checks, adjacent posts/canonical/REST smoke passed 1513 checks, broad smoke passed 5053 checks with 1 skip, and syntax/diff checks passed.
    +
    Previous focused addition: content-lifecycle generated parent include/exclude precedence coverage compares overlapping canonical, reversed, and duplicated post_parent__in/post_parent__not_in arrays for generated published pretty CPT children under orderby=ID, across fields=ids, fields=id=>parent, and fields=all; asserts post_parent__in wins over ignored parent exclusions, raw order/duplicate-preserving parent include and exclude query vars, identical included ID payloads, parent/status/class shapes, salted post-queries payloads, SQL post_parent IN without NOT IN, shared SQL requests per projection, and distinct cache keys for ignored raw parent-exclusion variants. Content focused seed 224 passed 30 checks, content seeds 1, 224, and 57123 over 25 iterations each passed 750 checks, content seed 100 over 20 iterations passed 600 checks, adjacent posts/canonical/REST smoke passed 1513 checks, broad smoke passed 5053 checks with 1 skip, and syntax/diff checks passed.
    +
    Previous focused addition: content-lifecycle generated post include/exclude precedence coverage compares overlapping canonical, reversed, and duplicated post__in/post__not_in arrays for generated published pretty CPT children under orderby=ID, across fields=ids, fields=id=>parent, and fields=all; asserts post__in wins over ignored exclusions, raw order/duplicate-preserving include and exclude query vars, identical included ID payloads, parent/status/class shapes, salted post-queries payloads, shared SQL requests per projection, and distinct cache keys for ignored raw exclusion variants. Content focused seed 224 passed 30 checks, content seeds 1, 224, and 57123 over 25 iterations each passed 750 checks, content seed 100 over 20 iterations passed 600 checks, adjacent posts/canonical/REST smoke passed 1513 checks, broad smoke passed 5053 checks with 1 skip, and syntax/diff checks passed.
    +
    Previous focused addition: content-lifecycle generated parent-inclusion ordering-boundary coverage compares canonical, reversed, and duplicated post_parent__in arrays for generated published pretty CPT children under orderby=post_parent__in, across fields=ids, fields=id=>parent, and fields=all; asserts raw order/duplicate-preserving parent query vars, first-occurrence parent-ordered unique ID payloads, parent/status/class shapes, salted post-queries payloads, distinct raw-order cache keys per projection, and in-memory WPDB FIELD(post_parent,...) ordering with post_parent IN row filtering. Content focused seed 224 passed 30 checks, content seeds 1, 224, and 57123 over 25 iterations each passed 750 checks, content seed 100 over 20 iterations passed 600 checks, adjacent posts/canonical/REST smoke passed 1513 checks, broad smoke passed 5053 checks with 1 skip, and syntax/diff checks passed.
    +
    Previous focused addition: content-lifecycle generated slug-inclusion ordering-boundary coverage compares canonical, reversed, and duplicated post_name__in arrays for generated published pretty CPT rows under orderby=post_name__in, across fields=ids, fields=id=>parent, and fields=all; asserts sanitized order/duplicate-preserving query vars, first-occurrence ordered unique ID payloads, parent/status/class shapes, salted post-queries payloads, distinct raw-order cache keys per projection, and in-memory WPDB FIELD(post_name,...) ordering with post_name IN row filtering. Content focused seed 224 passed 30 checks, content seeds 1, 224, and 57123 over 25 iterations each passed 750 checks, content seed 100 over 20 iterations passed 600 checks, adjacent posts/canonical/REST smoke passed 1513 checks, broad smoke passed 5053 checks with 1 skip, and syntax/diff checks passed.
    +
    Previous focused addition: content-lifecycle generated post-inclusion ordering-boundary coverage compares canonical, reversed, and duplicated post__in arrays over generated published pretty CPT children under orderby=post__in, across fields=ids, fields=id=>parent, and fields=all; asserts raw order/duplicate-preserving post__in query vars, first-occurrence ordered unique ID payloads, parent/status/class shapes, salted post-queries payloads, distinct raw-order cache keys per projection, and in-memory WPDB FIELD(ID,...) ordering with ID IN row filtering. Content focused seed 224 passed 30 checks, content seeds 1, 224, and 57123 over 25 iterations each passed 750 checks, content seed 100 over 20 iterations passed 600 checks, adjacent posts/canonical/REST smoke passed 1513 checks, broad smoke passed 5053 checks with 1 skip, and syntax/diff checks passed.
    +
    Previous focused addition: content-lifecycle generated post-inclusion cache-key normalization compares canonical, reversed, and duplicated post__in arrays over generated published pretty CPT children under orderby=ID, across fields=ids, fields=id=>parent, and fields=all; asserts raw order/duplicate-preserving post__in query vars, identical ordered ID payloads, parent/status/class shapes, salted post-queries payloads, shared cache keys per projection, and in-memory WPDB ID IN row filtering. Content focused seed 224 passed 30 checks, content seeds 1, 224, and 57123 over 25 iterations each passed 750 checks, content seed 100 over 20 iterations passed 600 checks, adjacent posts/canonical/REST smoke passed 1513 checks, broad smoke passed 5053 checks with 1 skip, and syntax/diff checks passed.
    +
    Previous focused addition: content-lifecycle generated post-type array cache-key normalization compares canonical, reversed, and duplicated post_type arrays over generated pretty/query CPT rows under orderby=ID, across fields=ids, fields=id=>parent, and fields=all; asserts sorted unique sanitized query vars, identical mixed-CPT ordered ID payloads, parent/status/class shapes, salted post-queries payloads, shared cache keys per projection, and in-memory WPDB post_type IN row filtering. Content focused seed 224 passed 30 checks, content seeds 1, 224, and 57123 over 25 iterations each passed 750 checks, content seed 100 over 20 iterations passed 600 checks, adjacent posts/canonical/REST smoke passed 1513 checks, broad smoke passed 5053 checks with 1 skip, and syntax/diff checks passed.
    +
    Previous focused addition: content-lifecycle generated post-status array cache-key normalization compares canonical, reversed, and duplicated post_status arrays for generated published/private pretty CPT rows under orderby=ID, across fields=ids, fields=id=>parent, and fields=all; asserts sorted unique sanitized query vars, identical ordered ID payloads, parent/status/class shapes, salted post-queries payloads, shared cache keys per projection, and in-memory WPDB OR-status row filtering. Content focused seed 224 passed 30 checks, content seeds 1, 224, and 57123 over 25 iterations each passed 750 checks, content seed 100 over 20 iterations passed 600 checks, adjacent posts/canonical/REST smoke passed 1513 checks, broad smoke passed 5053 checks with 1 skip, and syntax/diff checks passed.
    +
    Previous focused addition: content-lifecycle generated legacy author query-var normalization compares canonical, reversed, and duplicated mixed positive/negative author strings under orderby=ID, across fields=ids, fields=id=>parent, and fields=all; asserts signed sorted author CSV query vars, derived author__in/author__not_in arrays, author__not_in SQL precedence with a neutral-author sentinel row, identical ordered payloads, parent/status/class shapes, salted post-queries payloads, shared cache keys per projection, and in-memory WPDB post_author NOT IN row filtering. Content focused seed 224 passed 30 checks, content seeds 1, 224, and 57123 over 25 iterations each passed 750 checks, content seed 100 over 20 iterations passed 600 checks, adjacent posts/canonical/REST smoke passed 1513 checks, broad smoke passed 5053 checks with 1 skip, and syntax/diff checks passed.
    +
    Previous focused addition: content-lifecycle generated author-inclusion cache-key normalization reuses the generated published pretty CPT child authors and compares canonical, reversed, and duplicated author__in arrays under orderby=ID, across fields=ids, fields=id=>parent, and fields=all; asserts identical ordered ID payloads, parent/status/class shapes, salted post-queries payloads, sorted unique query vars, shared cache keys per projection, and in-memory WPDB post_author IN row filtering. Content focused seed 224 passed 30 checks, content seeds 1, 224, and 57123 over 25 iterations each passed 750 checks, content seed 100 over 20 iterations passed 600 checks, adjacent posts/canonical/REST smoke passed 1513 checks, broad smoke passed 5053 checks with 1 skip, and syntax/diff checks passed.
    +
    Previous focused addition: content-lifecycle generated author-exclusion cache-key normalization assigns two generated published pretty CPT children to generated authors and compares canonical, reversed, and duplicated author__not_in arrays under orderby=ID, across fields=ids, fields=id=>parent, and fields=all; asserts identical ordered ID payloads, parent/status/class shapes, salted post-queries payloads, sorted unique query vars, shared cache keys per projection, in-memory WPDB post_author NOT IN row filtering, and generated user cleanup. Content focused seed 224 passed 30 checks, content seeds 1, 224, and 57123 over 25 iterations each passed 750 checks, content seed 100 over 20 iterations passed 600 checks, adjacent posts/canonical/REST smoke passed 1513 checks, broad smoke passed 5053 checks with 1 skip, and syntax/diff checks passed.
    +
    Previous focused addition: content-lifecycle generated slug-inclusion cache-key normalization compares canonical, reversed, and duplicated post_name__in arrays for generated published pretty CPT rows under orderby=ID, across fields=ids, fields=id=>parent, and fields=all; asserts identical ordered ID payloads, parent/status/class shapes, salted post-queries payloads, sanitized query vars preserving order and duplicates, and shared cache keys per projection across duplicate/reversed variants. Content focused seed 224 passed 30 checks, content seeds 1, 224, and 57123 over 25 iterations each passed 750 checks, content seed 100 over 20 iterations passed 600 checks, adjacent posts/canonical/REST smoke passed 1513 checks, broad smoke passed 5053 checks with 1 skip, and syntax/diff checks passed.
    +
    Previous focused addition: content-lifecycle generated post-exclusion cache-key boundary hardening compares canonical, reversed, and duplicated post__not_in arrays for generated published pretty CPT rows under orderby=ID, across fields=ids, fields=id=>parent, and fields=all; asserts identical ordered ID payloads, parent/status/class shapes, salted post-queries payloads, sorted duplicate-preserving query vars, canonical/reversed cache-key sharing, duplicate-key separation, and simple in-memory WPDB ID NOT IN row filtering. Content focused seed 224 passed 30 checks, content seeds 1, 224, and 57123 over 25 iterations each passed 750 checks, content seed 100 over 20 iterations passed 600 checks, adjacent posts/canonical/REST smoke passed 1513 checks, broad smoke passed 5053 checks with 1 skip, and syntax/diff checks passed.
    +
    Previous focused addition: content-lifecycle custom hierarchical parent-exclusion cache-key boundary hardening compares canonical, reversed, and duplicated post_parent__not_in arrays for generated published pretty CPT rows under orderby=ID, across fields=ids, fields=id=>parent, and fields=all; asserts identical ordered ID payloads, parent/status/class shapes, salted post-queries payloads, sorted duplicate-preserving query vars, canonical/reversed cache-key sharing, duplicate-key separation, and in-memory WPDB NOT IN row filtering. Content focused seed 224 passed 30 checks, content seeds 1, 224, and 57123 over 25 iterations each passed 750 checks, content seed 100 over 20 iterations passed 600 checks, adjacent posts/canonical/REST smoke passed 1513 checks, broad smoke passed 5053 checks with 1 skip, and syntax/diff checks passed.
    +
    Previous focused addition: rest-object-controllers custom hierarchical collection parent-array normalization hardening route-dispatches canonical, reversed, and duplicated parent and parent_exclude request arrays with an include allowlist; asserts equivalent collection data and pagination headers, raw sanitized REST arg preservation, WP_Query post_parent__in order/duplicate preservation, duplicate-preserving sorted post_parent__not_in query vars, and GET-only prepare-event deltas. REST object seed 224 passed 21 checks, REST object seeds 1, 224, and 57123 over 25 iterations each passed 525 checks, REST object seed 100 over 20 iterations passed 420 checks, adjacent posts/canonical/REST smoke passed 1513 checks, broad smoke passed 5053 checks with 1 skip, and syntax/diff checks passed.
    +
    Previous focused addition: content-lifecycle custom hierarchical parent-filter cache-key normalization hardening compares canonical, reversed, and duplicated post_parent__in arrays for generated published pretty CPT children under orderby=ID, across fields=ids, fields=id=>parent, and fields=all; asserts exact ordered ID payloads, normalized parent maps, object status/class shapes, salted post-queries payloads, and shared generated cache keys per projection while preserving the separate order-bearing orderby=post_parent__in family. Content focused seed 224 passed 30 checks, content seeds 1, 224, and 57123 over 25 iterations each passed 750 checks, content seed 100 over 20 iterations passed 600 checks, adjacent posts/canonical/REST smoke passed 1513 checks, broad smoke passed 5053 checks with 1 skip, and syntax/diff checks passed.
    +
    Previous focused addition: content-lifecycle custom hierarchical selected-field ordering query-cache hardening compares published pretty CPT children queried through post_parent__in under orderby=ID and orderby=post_parent__in, across fields=ids, fields=id=>parent, and fields=all; asserts exact ordered ID payloads, normalized parent maps, object status/class shapes, salted post-queries payloads, and no cache-key overlap between ordering families with different ordered ID payloads; also teaches the in-memory WPDB stub to sort FIELD(post_parent,...). Content focused seed 224 passed 30 checks, content seeds 1, 224, and 57123 over 25 iterations each passed 750 checks, content seed 100 over 20 iterations passed 600 checks, adjacent posts/canonical/REST smoke passed 1513 checks, broad smoke passed 5053 checks with 1 skip, and syntax/diff checks passed.
    +
    Previous focused addition: content-lifecycle custom hierarchical query-cache result-shape parity extends the generated private/pending parent-status mutation fixture to compare WP_Query fields=ids, fields=id=>parent, and fields=all for the same combined parent/status bucket, asserting shared generated cache keys when ordered by slug, exact ID projections, normalized parent maps, object status/class shapes, and matching salted post-queries payloads. Content focused seed 224 passed 30 checks, content seeds 1, 224, and 57123 over 25 iterations each passed 750 checks, content seed 100 over 20 iterations passed 600 checks, adjacent posts/canonical/REST smoke passed 1513 checks, broad smoke passed 5053 checks with 1 skip, and syntax/diff checks passed.
    +
    Previous focused addition: content-lifecycle custom hierarchical parent/status query-cache hardening adds a generated pending sibling to the existing reparent-to-private mutation fixture, asserting WP_Query private, pending, and combined parent-filter buckets have distinct generated cache keys, salted post-queries entries contain exact ID projections before mutation, stale salted buckets miss immediately after wp_update_post() bumps last_changed, and the same keys repopulate with the private child plus pending sibling after mutation. Content focused seed 224 passed 30 checks, content seeds 1, 224, and 57123 over 25 iterations each passed 750 checks, content seed 100 over 20 iterations passed 600 checks, adjacent posts/canonical/REST smoke passed 1513 checks, broad smoke passed 5053 checks with 1 skip, and syntax/diff checks passed.
    +
    Previous focused addition: content-lifecycle admin bulk edit capability hardening adds editable draft fixtures under a no-publish_posts capability filter, asserting requested _status=publish is stored as pending, requested _status=private preserves the previous draft status, both rows remain updated rather than skipped or locked, title/content and _edit_last still mutate, bulk_edit_posts action payloads preserve the raw requested statuses, and capability events record the denied publish cap. Content focused seed 224 passed 30 checks, content seeds 1, 224, and 57123 over 25 iterations each passed 750 checks, content seed 100 over 20 iterations passed 600 checks, adjacent posts/canonical/REST smoke passed 1513 checks, broad smoke passed 5053 checks with 1 skip, and syntax/diff checks passed.
    +
    Previous focused addition: rest-object-controllers custom hierarchical private-status permission hardening extends generated collection parent filters with a private child under a generated parent, asserting status=private is rejected before query execution for users without edit/read-private caps, succeeds for read-private-only users, preserves post_status/post_parent__in query vars, response headers, _links.up, and prepare-event locality; extends route-dispatched parent assignment with an edit-only/no-publish_pages update attempting status=publish plus cross-type parent reassignment, asserting rest_cannot_publish, unchanged parent/status/slug storage, no prepare events, and no transition hooks. REST object seed 224 passed 21 checks, REST object seeds 1, 224, and 57123 over 25 iterations each passed 525 checks, REST object seed 100 over 20 iterations passed 420 checks, adjacent posts/canonical/REST smoke passed 1513 checks, broad smoke passed 5053 checks with 1 skip, and syntax/diff checks passed.
    +
    Previous focused addition: content-lifecycle and rest-object-controllers custom hierarchical parent/status cache hardening extends existing parent-fallback rows with a generated child reparented from a pretty CPT parent to a cross-type query-var CPT parent while moving from publish to private, asserting get_page_by_path() old-path misses and new-path hits, stale post_parent:{ID} cache eviction, salted cache invalidation under last_changed, and get_posts() parent/status projections; extends route-dispatched custom hierarchical REST parent assignment with scoped rest_pre_insert_{$post_type} status coercion from requested publish to pending, stored parent/status parity, pending collection parent/status readback, exact rest_insert_{$post_type} and transition hook ordering, and capability-denied parent/status preservation. Content focused seed 224 passed 30 checks, content seeds 1, 224, and 57123 over 25 iterations each passed 750 checks, content seed 100 over 20 iterations passed 600 checks, REST object seed 224 passed 21 checks, REST object seeds 1, 224, and 57123 over 25 iterations each passed 525 checks, REST object seed 100 over 20 iterations passed 420 checks, adjacent posts/canonical/REST smoke passed 1513 checks, broad smoke passed 5053 checks with 1 skip, and syntax/diff checks passed.
    +
    Previous focused addition: content-lifecycle and rest-object-controllers custom hierarchical query/untrash hardening extends existing parent-fallback rows with get_page_by_path() mixed-depth lookup oracles for generated pretty/query-var CPT ancestor chains, asserting cross-type post-type arrays hit, scalar post-type lookups miss, encoded paths resolve in ARRAY_A, salted hit/miss caches are populated, and cache invalidation bumps last_changed; extends route-dispatched custom hierarchical REST parent assignment with direct wp_untrash_post() lifecycle coverage, asserting hook order, default draft restoration, trash-meta and desired-slug cleanup, route-dispatched draft reparenting, collection GET readback, collection HEAD totals, and prepare-event locality. Content focused seed 224 passed 30 checks, content seeds 1, 224, and 57123 over 25 iterations each passed 750 checks, content seed 100 over 20 iterations passed 600 checks, REST object seed 224 passed 21 checks, REST object seeds 1, 224, and 57123 over 25 iterations each passed 525 checks, REST object seed 100 over 20 iterations passed 420 checks, adjacent posts/canonical/REST smoke passed 1513 checks, broad smoke passed 5053 checks with 1 skip, and syntax/diff checks passed.
    +
    Previous focused addition: content-lifecycle and rest-object-controllers custom hierarchical mixed-depth/trash-delete hardening extends existing parent-fallback rows with generated three-level pretty/query-var CPT ancestor chains that cross post types at each level, asserting get_page_uri(), get_post_permalink(), and post_type_link parent payloads preserve the full mixed-depth path; extends route-dispatched custom hierarchical REST parent assignment with soft-trash, repeated-trash rejection, and force-delete lifecycle coverage, asserting stored parent/status, trash slug mutation, response link/up and previous-response parity, exact delete/prepare hook capture, and delete-filter restoration. Content focused seed 224 passed 30 checks, content seeds 1, 224, and 57123 over 25 iterations each passed 750 checks, content seed 100 over 20 iterations passed 600 checks, REST object seed 224 passed 21 checks, REST object seeds 1, 224, and 57123 over 25 iterations each passed 525 checks, REST object seed 100 over 20 iterations passed 420 checks, adjacent posts/canonical/REST smoke passed 1496 checks, broad smoke passed 5053 checks with 1 skip, and syntax/diff checks passed.
    +
    Previous focused addition: content-lifecycle and rest-object-controllers custom hierarchical private/trash parent hardening extends existing parent-fallback rows with generated published pretty and query-var CPT children under private and trashed parents, asserting get_page_uri(), get_post_permalink(), and post_type_link parent payloads preserve the non-public ancestor path while is_post_publicly_viewable() remains local to each post; extends route-dispatched custom hierarchical REST parent assignment with a draft-to-private update that reassigns to a cross-type parent, asserting stored status/parent, response link/up parity, exact transition event capture, prepare-event locality, and status-filter restoration. Content focused seed 224 passed 30 checks, content seeds 1, 224, and 57123 over 25 iterations each passed 750 checks, content seed 100 over 20 iterations passed 600 checks, REST object seed 224 passed 21 checks, REST object seeds 1, 224, and 57123 over 25 iterations each passed 525 checks, REST object seed 100 over 20 iterations passed 420 checks, adjacent posts/canonical/REST smoke passed 1496 checks, broad smoke passed 5053 checks with 1 skip, and syntax/diff checks passed.
    +
    Previous focused addition: content-lifecycle and rest-object-controllers custom hierarchical cross-type ancestor/capability-denial hardening extends existing parent-fallback rows with generated pretty and query-var CPT children whose stored parents are different post types, asserting get_page_uri(), get_post_permalink(), and post_type_link parent payloads preserve the cross-type ancestor path while cleanup restores generated posts, types, query vars, and rewrite state; extends route-dispatched custom hierarchical REST parent assignment with a capability-denied update after removing the scoped cap grant, asserting rest_cannot_edit, stored parent no-mutation, no prepare or wp_insert_post_parent hierarchy events for the denied post, and normal filter restoration. Content focused seed 224 passed 30 checks, content seeds 1, 224, and 57123 over 25 iterations each passed 750 checks, content seed 100 over 20 iterations passed 600 checks, REST object seed 224 passed 21 checks, REST object seeds 1, 224, and 57123 over 25 iterations each passed 525 checks, REST object seed 100 over 20 iterations passed 420 checks, adjacent posts/canonical/REST smoke passed 1496 checks, bootstrap smoke passed, broad smoke passed 5053 checks with 1 skip, and syntax/diff checks passed.
    +
    Previous focused addition: content-lifecycle and rest-object-controllers custom hierarchical invalid-parent/loop-break hardening extends existing parent-fallback rows with missing-parent and self-parent get_page_uri()/get_post_permalink() fail-closed matrices for generated pretty and query-var CPT links, including exact post_type_link parent payloads and cleanup; extends route-dispatched custom hierarchical REST parent assignment with a corrupted two-node parent loop outside the updated post, asserting wp_check_post_hierarchy_for_loops() breaks both loop members to root while preserving the requested parent, response link/up parity, nested wp_insert_post_parent event order, prepare-event locality, and scoped hierarchy-filter restoration. Content focused seed 224 passed 30 checks, content seeds 1, 224, and 57123 over 25 iterations each passed 750 checks, content seed 100 over 20 iterations passed 600 checks, REST object seed 224 passed 21 checks, REST object seeds 1, 224, and 57123 over 25 iterations each passed 525 checks, REST object seed 100 over 20 iterations passed 420 checks, adjacent posts/canonical/REST smoke passed 1496 checks, bootstrap smoke passed, broad smoke passed 5053 checks with 1 skip, and syntax/diff checks passed.
    +
    Previous focused addition: content-lifecycle and rest-object-controllers custom hierarchical loop/query-var hardening extends existing parent-fallback rows with raw reserved query-var URI cases for get_post_permalink()/get_permalink(), including spaces, slash segments, plus signs, ampersands, percent escapes, leavename/sample/event cleanup, and WP/query-var restoration; extends route-dispatched custom hierarchical REST parent assignment with self-parent and descendant-loop normalization through wp_insert_post_parent before/after filter capture, stored parent/link/up-link parity, sibling tree preservation, and scoped core loop-guard filter restoration. Content focused seed 224 passed 30 checks, content seeds 1, 224, and 57123 over 25 iterations each passed 750 checks, content seed 100 over 20 iterations passed 600 checks, REST object seed 224 passed 21 checks, REST object seeds 1, 224, and 57123 over 25 iterations each passed 525 checks, REST object seed 100 over 20 iterations passed 420 checks, adjacent posts/canonical/REST smoke passed 1496 checks, bootstrap smoke passed, broad smoke passed 5053 checks with 1 skip, and syntax/diff checks passed.
    +
    Previous focused addition: content-lifecycle and rest-object-controllers custom hierarchical parent fallback coverage exercises get_post_permalink()/get_permalink() single links for generated public hierarchical CPTs across pretty rewrite/no-query-var, query-var rewrite-disabled, and plain fallback branches, including leavename, draft, sample, post_type_link event, missing-post, generated query-var, WP global, rewrite, post-type, filter, and content-count cleanup oracles; adds route-dispatched custom hierarchical REST parent assignment validation for missing-parent create/update rejection before mutation, same-type and cross-type parent acceptance, root clearing, projected fallback link fields, response up links, prepare events, caps/default REST/server/actions/current-user/WP/post-type/query-var cleanup. Content focused seed 224 passed 30 checks, content seeds 1, 224, and 57123 over 25 iterations each passed 750 checks, content seed 100 over 20 iterations passed 600 checks, REST object seed 224 passed 21 checks, REST object seeds 1, 224, and 57123 over 25 iterations each passed 525 checks, REST object seed 100 over 20 iterations passed 420 checks, adjacent posts/canonical/REST smoke passed 1496 checks, bootstrap smoke passed, broad smoke passed 5053 checks with 1 skip, and syntax/diff checks passed.
    +
    Previous focused addition: rest-object-controllers and post-types custom hierarchical collection/rewrite coverage exercises generated public hierarchical CPT REST collection parent, parent_exclude, and root filters through route dispatch; validates hierarchical-only collection params, menu_order sorting, _fields projection, child _links.up, HEAD query translation, no_found_rows short-circuit totals, prepare hooks, and cleanup; adds a post type rewrite/link matrix for hierarchical pretty/no-query-var, query-only archive fallback, fronted custom archive, and rewrite-disabled hidden branches with permastruct, rewrite-tag, archive-rule, feed-link, query-var, and unregister cleanup oracles. REST object seed 224 passed 20 checks, REST object seeds 1, 224, and 57123 over 25 iterations each passed 500 checks, REST object seed 100 over 20 iterations passed 400 checks, post-types seed 224 passed 14 checks, post-types seeds 1, 224, and 57123 over 25 iterations each passed 350 checks, post-types seed 100 over 20 iterations passed 280 checks, adjacent posts/canonical/REST smoke passed 1494 checks, bootstrap smoke passed, broad smoke passed 5051 checks with 1 skip, and syntax/diff checks passed.
    +
    Previous focused addition: content-lifecycle and rest-object-controllers custom hierarchical post type permalink coverage exercises generated public hierarchical CPT sample permalink matrices for title-derived child slugs, same-parent collisions, different-parent scoped collisions, numeric slug suffixing, raw post_type_link sample events, and get_sample_permalink_html() preview links while preserving stored drafts and avoiding query-var cleanup leakage; adds route-dispatched generated REST base create/update coverage for draft and pending parent-scoped collisions, omitted parent reuse, explicit parent and root scopes, generated_slug and permalink_template edit responses, pre_wp_unique_post_slug post-type/parent event capture, and count/filter/server/post-type cleanup. Content focused seed 224 passed 29 checks, content seeds 1, 224, and 57123 over 25 iterations each passed 725 checks, content seed 100 over 20 iterations passed 580 checks, REST object seed 224 passed 19 checks, REST object seeds 1, 224, and 57123 over 25 iterations each passed 475 checks, REST object seed 100 over 20 iterations passed 380 checks, adjacent posts/canonical/REST smoke passed 1492 checks, bootstrap smoke passed, broad smoke passed 5049 checks with 1 skip, and syntax/diff checks passed.
    +
    Previous focused addition: content-lifecycle and rest-object-controllers hierarchical page sample permalink and REST parent-slug coverage exercises parent-scoped page sample permalink matrices for title-derived slugs, same-parent collisions, same slug under a different parent, numeric page slugs, and same-parent attachment collisions; asserts get_sample_permalink_html() renders hierarchical draft preview links with parent path display while preserving stored drafts; fixes the upstream REST draft/pending update path to reuse the existing page parent when the request omits parent; and adds route-dispatched /wp/v2/pages create/update coverage for omitted parent, explicit parent, explicit root, generated slug, permalink template, pre_wp_unique_post_slug parent event capture, and count/filter/server cleanup. Content focused seed 224 passed 28 checks, content seeds 1, 224, and 57123 over 25 iterations each passed 700 checks, content seed 100 over 20 iterations passed 560 checks, REST object seed 224 passed 18 checks, REST object seeds 1, 224, and 57123 over 25 iterations each passed 450 checks, REST object seed 100 over 20 iterations passed 360 checks, adjacent posts/canonical/REST smoke passed 1490 checks, bootstrap smoke passed, broad smoke passed 5047 checks with 1 skip, and syntax/diff checks passed.
    +
    Previous focused addition: content-lifecycle and rest-object-controllers sample permalink numeric/plain and REST lowercase-ID hardening coverage exercises date-archive numeric slug matrices for year, month, day, invalid numeric, and suffix-iteration branches; confirms get_sample_permalink() uses temporary publish-status uniqueness without mutating stored drafts or holders; exercises empty-permalink get_sample_permalink_html() no-placeholder branches for read, preview, no-read, manage-options, and non-manage users; fixes the upstream REST draft/pending create path to pass post ID 0 and a guarded parent into wp_unique_post_slug(); and updates route-dispatched REST draft/pending create and update slug collisions to require unguarded warning-free suffixes through -5 with pre_wp_unique_post_slug publish-status event capture and count/filter/server cleanup. Content focused seed 224 passed 27 checks, content seeds 1, 224, and 57123 over 25 iterations each passed 675 checks, content seed 100 over 20 iterations passed 540 checks, REST object seeds 1, 224, and 57123 over 25 iterations each passed 425 checks, REST object seed 100 over 20 iterations passed 340 checks, adjacent posts/canonical/REST smoke passed 1488 checks, bootstrap smoke passed, broad smoke passed 5045 checks with 1 skip, and syntax/diff checks passed.
    +
    Previous focused addition: content-lifecycle and rest-object-controllers sample permalink HTML/filter and draft/pending slug guard coverage exercises editable_slug, get_sample_permalink, and get_sample_permalink_html callback ordering, temporary sample-post publish/filter state, restored draft post state, preview-link and wp-preview-{ID} target HTML, published view-link HTML, filtered editable slug rendering, route-dispatched REST draft/pending create and update slug collisions, scoped rest_pre_insert_post guard for the known lowercase id create path, pre_wp_unique_post_slug publish-status event capture, exact stored suffixes through -5, and count/filter/server cleanup. Content focused seed 224 passed 26 checks, content seeds 1, 224, and 57123 over 25 iterations each passed 650 checks, content seed 100 over 20 iterations passed 520 checks, REST object seeds 1, 224, and 57123 over 25 iterations each passed 425 checks, REST object seed 100 over 20 iterations passed 340 checks, adjacent posts/canonical/REST smoke passed 1487 checks, bootstrap smoke passed, broad smoke passed 5044 checks with 1 skip, and syntax/diff checks passed.
    +
    Previous focused addition: content-lifecycle and rest-object-controllers old-slug/sample/create-route coverage exercises scoped post_updated installation of wp_check_for_changed_slugs(), published slug-change metadata insertion, duplicate no-op updates, old-slug pruning when a previous slug becomes current again, draft and hierarchical page fail-closed metadata paths, get_sample_permalink() title and explicit-name collision suffixing without stored draft or holder mutation, and route-dispatched POST /wp/v2/posts slug collision creation with projected author,content,generated_slug,id,link,permalink_template,slug,status,title fields plus Location and count-cleanup oracles. Content focused seed 224 passed 26 checks, content seeds 1, 224, and 57123 over 25 iterations each passed 650 checks, content seed 100 over 20 iterations passed 520 checks, REST object seeds 1, 224, and 57123 over 25 iterations each passed 425 checks, REST object seed 100 over 20 iterations passed 340 checks, adjacent posts/canonical/REST smoke passed 1487 checks, bootstrap smoke passed, broad smoke passed 5044 checks with 1 skip, and syntax/diff checks passed.
    +
    Previous focused addition: content-lifecycle and rest-object-controllers slug-collision projection coverage exercises published duplicate inserts, draft-to-publish collision updates, exact suffixed slug storage, per-slug get_posts()/WP_Query lookup separation, pretty permalink and wp_get_canonical_url() projection with scoped query globals, and route-dispatched PUT /wp/v2/posts/{id} collision updates with projected id,link,slug,status,title REST fields. Content focused seed 224 passed 25 checks, content seeds 1, 224, and 57123 over 25 iterations each passed 625 checks, content seed 100 over 20 iterations passed 500 checks, REST object seeds 1, 224, and 57123 over 25 iterations each passed 425 checks, REST object seed 100 over 20 iterations passed 340 checks, adjacent posts/canonical/REST smoke passed 1486 checks, bootstrap smoke passed, broad smoke passed 5043 checks with 1 skip, and syntax/diff checks passed.
    +
    Previous focused addition: content-lifecycle generated post mutation projection coverage exercises wp_update_post() over a draft-to-publish custom post type mutation, cached pre/post WP_Query slug/status results, get_posts() old-slug exclusion and published sibling projection, refreshed post cache plus stale parent/meta/archive cache eviction, pretty permalink generation, get_the_title(), get_post_class(), and permalink filter/rewrite restoration. Focused seed 224 passed 24 checks, focused seeds 1, 224, and 57123 over 25 iterations each passed 600 checks, seed 100 over 20 iterations passed 480 checks, adjacent content-lifecycle,content,query,query-loop,post-types,template-links,canonical-routing,request-lifecycle,rewrite,rest-object-controllers,revisions-autosaves,admin-edit-metaboxes,comments,comment-workflow passed 1502 checks, bootstrap smoke passed, broad smoke passed 5042 checks with 1 skip, and syntax/diff checks passed.
    +
    Previous focused addition: rest-controllers invalid-argument error-envelope coverage dispatches generated settings, search, and block-renderer controller routes through WP_REST_Server; asserts settings update schema detail codes, search type/subtype/include/pagination detail codes, block-renderer context/attribute/unknown-property detail codes, direct envelope parity, Allow headers, no invalid-route handler/render callbacks, stored-option preservation, and filter/server/cap cleanup. Focused seed 224 passed 19 checks, focused seeds 1, 224, and 57123 over 25 iterations each passed 475 checks, seed 100 over 20 iterations passed 380 checks, adjacent rest,rest-controllers,rest-object-controllers,rest-directory-services,rest-media-attachments passed 66 checks, wider REST/query/content adjacency passed 972 checks, bootstrap smoke passed, broad smoke passed 5041 checks with 1 skip, and syntax/diff checks passed.
    +
    Previous focused addition: rest route-argument validation and error-envelope parity exercises schema sanitization errors, custom sanitizer WP_Error details, required-parameter failures, custom validator WP_Error details, direct WP_REST_Server::envelope_response() parity, batch child error envelopes, permission/callback short-circuiting before invalid requests, and sanitized valid callback delivery. Focused seed 224 passed 12 checks, focused seeds 1, 224, and 57123 over 25 iterations each passed 300 checks, seed 100 over 20 iterations passed 240 checks, adjacent rest,rest-controllers,rest-object-controllers,rest-directory-services,rest-media-attachments passed 65 checks, ordered rest,rest-controllers over 3 iterations passed 90 checks, bootstrap smoke passed, broad smoke passed 5040 checks with 1 skip, and syntax/diff checks passed.
    +
    Previous focused addition: kses semicolon-free data URL and bad-protocol fragment diagnostics assert data:,, data:text/plain,, JSON, and PNG-style data URLs are rejected before safecss_filter_attr_allow_css can see them; semicolon-fragmented javascript, vbscript, livescript, and mocha URL forms are isolated across default, all-true permissive, and guarded allow-CSS filters; safe neighboring declarations survive; wp_kses() agrees with direct CSS filtering; and hooks restore. Focused seeds 1, 224, and 57123 over 25 iterations passed 27703, 27712, and 27707 checks, seed 100 over 20 iterations passed 22150 checks, adjacent kses,security,markup,shortcodes,formatting,blocks,block-supports passed 1202 checks, bootstrap smoke passed, broad smoke passed 5039 checks with 1 skip, and syntax/diff checks passed.
    +
    Previous focused addition: kses semicolon-bearing data URL CSS parser diagnostics isolate safecss_filter_attr() semicolon splitting under default, all-true permissive, and guarded safecss_filter_attr_allow_css filters; default safecss_filter_attr()/wp_kses() must not preserve complete generated data:*;* URLs, the current all-true allow-CSS split-fragment exposure is documented exactly, guarded filters reject the split fragments while retaining safe neighbors, and hooks restore. Focused seeds 1, 224, and 57123 over 25 iterations passed 27678, 27687, and 27682 checks, seed 100 over 20 iterations passed 22130 checks, adjacent kses,security,markup,shortcodes,formatting,blocks,block-supports passed 1201 checks, bootstrap smoke passed, broad smoke passed 5038 checks with 1 skip, and syntax/diff checks passed.
    +
    Previous focused addition: kses style/protocol cross-hook coverage combines safe_style_css, permissive safecss_filter_attr_allow_css, and dynamic wp_kses_uri_attributes filters to assert safe CSS URL declarations and custom URI attributes survive, unsafe CSS URL declarations remain stripped before the allow-CSS escape hatch can re-allow them, non-URI data attributes stay data-only, wp_kses()/wp_kses_hair()/wp_kses_attr() agree, and hooks restore. Focused seeds 1, 224, and 57123 over 25 iterations passed 27653, 27662, and 27657 checks, seed 100 over 20 iterations passed 22110 checks, adjacent kses,security,markup,shortcodes,formatting,blocks,block-supports passed 1200 checks, bootstrap smoke passed, broad smoke passed 5037 checks with 1 skip, and syntax/diff checks passed.
    +
    Previous focused addition: rest-directory-services clones WP_Hook entries while snapshotting/restoring wp_filter and fingerprints hook callback shape, preventing rest_api_default_filters() callbacks such as rest_send_allow_header from leaking into later REST batch-v1 checks and over-counting child permission callbacks. Focused seeds 1, 224, and 57123 over 25 iterations each passed 225 checks, ordered rest-directory-services,rest seeds 1, 224, and 57123 over 3 iterations each passed 60 checks, the adjacent REST stack passed 118 checks, bootstrap smoke passed, broad smoke passed 5036 checks with 1 skip, and syntax/diff checks passed.
    +
    Previous focused addition: rest-media-attachments ordered media-surface isolation refreshes default and parent-date-keyed wp_upload_dir() cache entries after resetting REST media upload options, preventing prior media surfaces with year/month upload folders from leaking dated paths into raw REST uploads and client-side sideloads. Focused seeds 1, 224, and 57123 over 25 iterations each passed 225 checks, the previously failing ordered admin-media-chrome,media-ingest,media-remote,image-metadata,rest-media-attachments repro passed 168 checks, full adjacent media-metadata,admin-media-chrome,media-ingest,media-remote,image-metadata,rest-media-attachments passed 204 checks, bootstrap smoke passed, broad smoke passed 5036 checks with 1 skip, and syntax/diff checks passed.
    +
    Previous focused addition: media-metadata generated shortcode cache-coherence coverage exercises first-to-second generated image/audio/video metadata replacement, persisted attached-file state, gallery sub-size selection without an image-source override, playlist ID3/dimension JSON refresh, stale first-generation field rejection, read-only shortcode no-mutation checks, scoped upload/query/script/loading filters, and media global/template restoration. Focused seed 224 passed 12 checks, focused seeds 1, 224, and 57123 over 25 iterations each passed 300 checks, adjacent media-metadata,admin-media-chrome,media-ingest,media-remote,image-metadata smoke passed 177 checks, bootstrap smoke passed, broad smoke passed 5036 checks with 1 skip, and syntax/diff checks passed.
    +
    Previous focused addition: comment-workflow moderation transition coverage exercises generated pending-to-approved, approved-to-hold, and hold-to-spam status changes, approval-triggered post-author notification intent, pending moderation notification intent, wp_count_comments() bucket updates, post comment-count refreshes, comment cache invalidation, scoped map_meta_cap/user_has_cap edit/moderate probes, read-only notification/capability no-mutation checks, and approval notifier cleanup. Focused seed 224 passed 11 checks, focused seeds 1, 224, and 57123 over 25 iterations each passed 275 checks, adjacent comment-workflow,comments,content-lifecycle,content,mail,email,identity,capabilities smoke passed 3568 checks, bootstrap smoke passed, broad smoke passed 5035 checks with 1 skip, and syntax/diff checks passed.
    +
    Previous focused addition: content-lifecycle persisted post/page transition render coverage exercises wp_publish_post() and wp_update_post() over stored status changes, dynamic transition/edit/save/insert/after-insert hook timing, no-op publish stability, get_post_class() status/password classes, public visibility checks, all-caps versus private-read-denied read_post capability paths, render/capability no-mutation checks, and hook/filter/user/post/cookie restoration. Focused seed 224 passed 23 checks, focused seeds 1, 224, and 57123 over 25 iterations each passed 575 checks, adjacent content/query/template/post-type smoke passed 1878 checks, bootstrap smoke passed, broad smoke passed 5034 checks with 1 skip, and syntax/diff checks passed.
    +
    Previous focused addition: rest-object-controllers route-dispatched collection GET coverage exercises generated posts, categories, comments, and users collection responses through WP_REST_Server, including _fields projection, schema-context checks, pagination totals and next links, invalid parameter and privileged filter rejection, prepare-hook payload timing, additional-field callbacks, query short-circuit evidence, content-count preservation, REST default-filter cleanup, and server/action/current-user restoration. Focused seed 1 passed 17 checks, seed 7 over 5 iterations passed 85 checks, focused seeds 1, 224, and 57123 over 25 iterations each passed 425 checks, adjacent REST/query/taxonomy/comment/metadata smoke passed 2830 checks, bootstrap smoke passed, broad smoke passed 5033 checks with 1 skip, and syntax/diff checks passed.
    +
    Previous focused addition: kses scoped generated policy mutation coverage adds generated custom-context allowlists, nested allow/deny fragments, custom URI attribute filtering, safe CSS allow-list mutations, style-like data attributes, exact unsafe-URL stripping checks, direct-policy parity, idempotence, and hook/global restoration. Focused kses seed 1 passed 1105 checks, focused seeds 1, 224, and 57123 over 25 iterations each passed 27628, 27637, and 27632 checks, adjacent sanitizer/markup smoke passed 2402 checks, bootstrap smoke passed, broad smoke passed 5032 checks with 1 skip, and syntax/diff checks passed.
    +
    Previous focused addition: query user/comment date-query relation parity teaches the no-DB datetime predicate evaluator one-level OR groups while preserving BETWEEN ... AND ... predicates, then adds generated WP_User_Query and WP_Comment_Query row oracles across bound-or-projection, early-window-or-late-bound, option-scoped week, GMT comment-date, and branch-local AND clauses. Focused query seed 1 passed 794 checks, focused query seeds 1, 224, 57123, and 20260716 over 25 iterations each passed 19850 checks, query/admin smoke passed 816 checks, broad smoke passed 5031 checks with 1 skip, and syntax/diff checks passed.
    +
    Previous focused addition: query user/comment date-query bound parity generalizes the no-DB datetime-bound stub from post columns to arbitrary table-prefixed date columns, then adds generated WP_User_Query and WP_Comment_Query after/before row oracles over inclusive/exclusive bounds, GMT comment dates, and mixed bound-plus-projection clauses. Focused query seed 1 passed 770 checks, focused query seeds 1, 224, 57123, and 20260716 over 25 iterations each passed 19250 checks, query/admin smoke passed 792 checks, broad smoke passed 5007 checks with 1 skip, and syntax/diff checks passed.
    +
    Previous focused addition: query generated user/comment date-query execution parity adds fixture-modeled WP_User_Query and WP_Comment_Query row oracles for scalar, IN/NOT IN, BETWEEN/NOT BETWEEN, option-scoped week-mode, shifted-week, GMT comment-date, and time projection clauses, while keeping comment SQL fresh with per-run cache domains. Focused query seed 1 passed 746 checks, focused query seeds 1, 224, 57123, and 20260716 over 25 iterations each passed 18650 checks, query/admin smoke passed 768 checks, broad smoke passed 4983 checks with 1 skip, and syntax/diff checks passed.
    +
    Previous focused addition: query and admin-media-chrome date-query week-mode and negated projection coverage adds no-DB SQL evaluator support for WEEK( col, 1 ) and WEEK( DATE_SUB( col, INTERVAL n DAY ), 0 ), then asserts NOT IN/NOT BETWEEN row oracles across seeded posts, users, comments, and media attachments. Focused query seed 1 passed 710 checks, focused query seeds 1, 224, and 57123 over 25 iterations each passed 17750 checks, focused admin-media-chrome seed 1 passed 22 checks, focused admin-media-chrome seeds 1, 224, and 57123 over 25 iterations each passed 550 checks, query/admin smoke passed 732 checks, ordered adjacent media/admin smoke passed 125 checks, bootstrap smoke passed, and broad smoke passed 4947 checks with 1 skip.
    +
    Previous focused addition: query and admin-media-chrome date-query scalar projection coverage hardens the no-DB SQL evaluator for DATE_FORMAT() hour/minute/second comparisons, DAYOFYEAR(), DAYOFWEEK(), WEEKDAY() + 1, and WEEK() row filtering, then asserts SQL-shape to row-oracle agreement across seeded posts and media attachments. Focused query seed 1 passed 674 checks, focused query seeds 1, 224, and 57123 over 25 iterations each passed 16850 checks, focused admin-media-chrome seed 1 passed 22 checks, focused admin-media-chrome seeds 1, 224, and 57123 over 25 iterations each passed 550 checks, query/admin smoke passed 696 checks, ordered adjacent media/admin smoke passed 125 checks, bootstrap smoke passed, and broad smoke passed 4911 checks with 1 skip.
    +
    Previous focused addition: admin-media-chrome generated legacy date-query stub edge coverage hardens the no-DB SQL evaluator for multiple post_status != predicates, full m=YYYYMMDDHHIISS time-unit filtering, and WP_Date_Query IN/BETWEEN projections over year/month/day/hour/minute/second, then exercises media list-table month dropdown SQL and seeded attachment row oracles. Focused seed 1 passed 22 checks, focused seeds 1, 224, and 57123 over 25 iterations each passed 550 checks, ordered adjacent media/admin smoke passed 125 checks, query-surface smoke passed 644 checks, bootstrap smoke passed, and broad smoke passed 4881 checks with 1 skip.
    +
    Previous focused addition: admin-media-chrome generated legacy media query alias/default coverage exercises absent post_mime_type default MIME auto-filter reruns, status=trash versus attachment-filter=trash query normalization, raw and filter-driven detached/mine aliases, media-list-table detached UI divergence, seeded author/detached/trash controls, and no content mutation. Focused seed 1 passed 21 checks, focused seeds 1, 224, and 57123 over 25 iterations each passed 525 checks, ordered adjacent media/admin smoke passed 124 checks, bootstrap smoke passed, and broad smoke passed 4880 checks with 1 skip.
    +
    Previous focused addition: admin-media-chrome generated legacy media query/date filter coverage adds no-DB stub support for media-library month projection and m=YYYYMM date-part filtering, then exercises wp_edit_attachments_query() filename-only _wp_attached_file search opt-in and cleanup, detached and mine attachment filters, found-row pagination, selected month dropdown output, and the legacy upload_per_page versus hard-coded 10 library pagination boundary. Focused seed 1 passed 20 checks, focused seeds 1, 224, and 57123 over 25 iterations each passed 500 checks, ordered adjacent media/admin smoke passed 123 checks, bootstrap smoke passed, and broad smoke passed 4879 checks with 1 skip.
    +
    Previous focused addition: admin-media-chrome generated legacy media library/gallery iframe coverage exercises media_upload_library() and GET media_upload_gallery() in isolated child processes, wp_iframe() hook lifecycle, scoped form URL, MIME-link, and media-item filters, search/pagination normalization, image/all MIME attachment query isolation, gallery parent/attachment selection, chromeless headers, admin-gallery enqueue observability, escaped request/title/custom-field output, and no persistence/send/upload mutation. Focused seed 1 passed 19 checks, focused seeds 1, 224, and 57123 over 25 iterations each passed 475 checks, ordered adjacent media/admin smoke passed 122 checks, bootstrap smoke passed, and broad smoke passed 4878 checks with 1 skip.
    +
    Previous focused addition: admin-media-chrome generated legacy media-upload.php entry-dispatch coverage evaluates the real entry source in isolated child processes after the harness bootstrap, exercising default media_upload_default_type/media_upload_default_tab filters, registered tab dispatch, unknown-tab fallback to media_upload_{$type}, type_url routing, inline requests without IFRAME_REQUEST, raw dynamic hook names for hostile types without output leakage, upload_files and raw post_id edit gates, invalid edit IDs, update_gallery_tab() removal/retention with seeded attachments, asset enqueue boundaries, fixed media-upload body IDs, and no live upload/save/send mutation. Focused seed 1 passed 18 checks, focused seeds 1, 224, and 57123 over 25 iterations each passed 450 checks, ordered adjacent media/admin smoke passed 121 checks, bootstrap smoke passed, and broad smoke passed 4877 checks with 1 skip.
    +
    Previous focused addition: admin-media-chrome generated legacy wp_media_upload_handler() no-POST iframe coverage exercises type_url and default type branches, exact media type coercion, hardcoded default-image callback selection, media_upload_type_url_form() and media_upload_type_form() callback output, scoped media_upload_form_url, type_url_form_media, caption, Plupload, and iframe lifecycle hooks, cast post IDs, chromeless headers, hostile type/tab escaping, and no save/send/upload mutation. Focused seed 1 passed 17 checks, focused seeds 1, 224, and 57123 over 25 iterations each passed 425 checks, ordered adjacent media/admin smoke passed 120 checks, bootstrap smoke passed, and broad smoke passed 4876 checks with 1 skip.
    +
    Previous focused addition: admin-media-chrome generated legacy wp_media_upload_handler() save-branch coverage exercises non-exiting wp_iframe( 'media_upload_gallery_form', $errors ) output, parent-post and attachment-post gallery item selection, chromeless header suppression, scoped media_upload_form_url and attachment_fields_to_edit filters, escaped generated titles/custom fields, admin-gallery enqueue observability, generic upload-notice isolation, and no fall-through into attachment persistence or send-to-editor exits. Focused seed 1 passed 16 checks, focused seeds 1, 224, and 57123 over 25 iterations each passed 400 checks, ordered adjacent media/admin smoke passed 119 checks, bootstrap smoke passed, and broad smoke passed 4875 checks with 1 skip.
    +
    Previous focused addition: admin-media-chrome generated legacy wp_media_upload_handler() insertonlybutton URL insert dispatch coverage exercises image/default-image/file/audio/video/misleading media-type branches, scheme-less URL prefixing, extension-driven audio/video normalization, escaped URL/title/alt/align payloads, dynamic send-to-editor URL filters, and child-process media_send_to_editor() exit capture. Focused seed 1 passed 15 checks, focused seeds 1, 224, and 57123 over 25 iterations each passed 375 checks, ordered adjacent media/admin smoke passed 118 checks, bootstrap smoke passed, and broad smoke passed 4874 checks with 1 skip.
    +
    Previous focused addition: media-ingest parent-date upload routing coverage verifies that media_handle_upload() uses the non-page parent post date for local upload destinations and GUID subdirectories while retaining existing attachment, metadata, post-field, source-cleanup, and temp-root invariants. Focused seeds 1, 224, and 57123 over 25 iterations each passed 250 checks, seed 100 over 50 iterations passed 500 checks, adjacent media-ingest,admin-media-chrome,media-remote,network-media smoke passed 320 checks, bootstrap smoke passed, and broad smoke passed 4873 checks with 1 skip.
    +
    Previous focused addition: admin-media-chrome generated legacy media_upload_form_handler() send/gallery dispatch and media_upload_type_form() WP_Error exit coverage exercises child-process exit capture, nonce/request globals, capability-gated attachment saves, send-to-editor filtering, stripped alt meta persistence, denied-attachment preservation, Thickbox close output, escaped upload errors, and no-live-upload boundaries. Focused seeds 1, 224, and 57123 over 25 iterations each passed 350 checks, ordered adjacent rest-media-attachments,rest,rest-object-controllers,media-ingest,media-remote,media-metadata,admin-media-chrome,media-image-edit-requests,media-editor,images,image-metadata smoke passed 117 checks, bootstrap smoke passed, and broad smoke passed 4873 checks with 1 skip.
    +
    Previous focused addition: admin-media-chrome generated legacy wp_media_attach_action() attach/detach redirect-exit matrix coverage exercises mixed attachment IDs, parent and attachment edit gates, raw wp_posts.post_parent UPDATE effects through the in-memory DB stub, hook/cache event locality, referer cleanup, wp_die() denial, parent-zero return, and child-process exit capture. Focused seed 1 passed 13 checks, focused seeds 1, 224, and 57123 over 25 iterations each passed 325 checks, ordered adjacent rest-media-attachments,rest,rest-object-controllers,media-ingest,media-remote,media-metadata,admin-media-chrome,media-image-edit-requests,media-editor,images,image-metadata smoke passed 116 checks, bootstrap smoke passed, and broad smoke passed 4872 checks with 1 skip.
    +
    Previous focused addition: media-image-edit-requests generated wp_ajax_image_editor() request matrix coverage exercises denied capability, invalid nonce, generated save history, oversized scale error envelopes, restore metadata, and unknown-action refresh branches, with edited-file metadata, backup sizes, refreshed editor HTML, captured wp_die(), fake-editor operations, and request/filter cleanup. Focused seed 224 passed 12 checks, focused seeds 1, 224, and 57123 over 25 iterations each passed 300 checks, ordered adjacent rest-media-attachments,rest,rest-object-controllers,media-ingest,media-remote,media-metadata,admin-media-chrome,media-image-edit-requests,images,image-metadata smoke passed 106 checks, bootstrap smoke passed, and broad smoke passed 4871 checks with 1 skip.
    +
    Previous focused addition: admin-workflows generated list-table request/action matrix coverage exercises current_action() top/filter/bottom boundaries, capability-gated bulk and row actions, view and bulk filters, nonce URLs, tablenav nonces, custom handle_bulk_actions-{$screen} dispatch, selected item normalization, safe redirect cleanup/fallback behavior, allowed-host validation, and request/server/filter/screen cleanup. Focused seed 224 passed 11 checks, focused seeds 1, 224, and 57123 over 25 iterations each passed 275 checks, adjacent admin-workflows,admin-list-tables,admin-ajax,admin-screen,admin-options-submission,user-preferences,privacy-admin-requests,security smoke passed 87 checks, bootstrap smoke passed, and broad smoke passed 4870 checks with 1 skip.
    +
    Previous focused addition: rest-controllers default-filtered search/settings dispatch matrix coverage exercises generated primary/secondary search handlers through WP_REST_Server, cross-handler subtype sanitization, _fields projection through rest_post_dispatch, handler response links, search pagination and Allow headers, settings GET permission denial, authorized settings projection, editable route args and sanitizer callbacks, default REST filter installation, and filter/server/action/cap cleanup. Focused seed 224 passed 18 checks, focused seeds 1, 224, and 57123 over 25 iterations each passed 450 checks, adjacent rest-controllers,rest,rest-object-controllers,query,query-loop,taxonomy,template-links,content-lifecycle smoke passed 20450 checks, bootstrap smoke passed, and broad smoke passed 4869 checks with 1 skip.
    +
    Previous focused addition: rest-directory-services generated direct plugin/theme API matrix coverage exercises array and object args for plugins_api() and themes_api(), query-only per_page defaults, explicit locale/wp_version preservation, filter-added request markers, no-network WordPress.org HTTP serialization, generated malformed/API-error JSON, WP_Error short-circuits without HTTP, plugin short-circuit external markers, theme short-circuit non-external results, plugin/theme information HTTP success, theme query_themes row-object and feature_list array normalization, terminal HTTPS-to-HTTP fallback failures, request-guard restoration, and scoped filter cleanup. Focused seed 224 passed 9 checks, seed 1 over 50 iterations passed 450 checks, seed 100 over 200 iterations passed 1800 checks, seed 57123 over 25 iterations passed 225 checks, ordered adjacent rest,rest-controllers,rest-object-controllers,rest-media-attachments,http,blocks,block-templates,block-editor-adjuncts,plugin-theme-lifecycle,rest-directory-services,admin-list-tables,plugin-theme smoke passed 141 checks, bootstrap smoke passed, and broad smoke passed 4868 checks with 1 skip.
    +
    Previous focused addition: options-autoload default autoload policy and generated persistence coverage exercises exact auto/auto-on/auto-off/on/off storage, scoped wp_default_autoload_value, wp_max_autoloaded_option_size, and wp_autoload_values_to_autoload behavior, alloptions membership filtering that can remove auto but cannot add hostile values, null-autoload update reevaluation versus explicit-on preservation, generated add/update/delete/read/prime/bulk-autoload sequences, independent option table and cache models, public-read checks, lifecycle hook locality, and filter cleanup. Focused seed 224 passed 16 checks, focused seeds 1, 224, and 57123 over 25 iterations each passed 400 checks, adjacent options-autoload,state,multisite,admin-options-submission,admin-workflows,security smoke passed 88 checks with 1 skip, bootstrap smoke passed, and broad smoke passed 4867 checks with 1 skip.
    +
    Previous focused addition: taxonomy-relationships generated mutation coverage exercises multi-object primary/secondary taxonomy maps, mixed ID/slug duplicate replace and append inputs, scoped removals, full taxonomy relationship deletion, independent expected relationship/count/cache models, per-taxonomy cache invalidation, add/delete/set/update-count hook locality, and post-type feature/meta-cap restoration in taxonomy-relationships, query-loop, and content-lifecycle so XML-RPC post-format adjacency remains clean. Focused seed 224 passed 11 checks, focused seeds 1, 224, and 57123 over 25 iterations each passed 275 checks, direct taxonomy-relationships,xmlrpc adjacency passed 240 checks, full adjacent taxonomy-relationships,taxonomy,content-lifecycle,metadata,query,query-loop,rest-object-controllers,xmlrpc smoke passed 8080 checks, bootstrap smoke passed, and broad smoke passed 4865 checks with 1 skip.
    +
    Previous focused addition: request-lifecycle generated request/query lifecycle coverage exercises generated rewrite routes for single, numeric page-preview, search, and feed request shapes; custom public query-var retention; extra-query precedence over GET; WP_Query::parse_query() object/global conditional tag parity; canonical redirect cancellation and preview/search bailout guards; same-host canonical candidates; stable query vars, superglobals, and server state across canonical checks; scoped request/parse_request/redirect_canonical hooks; and global restoration. Focused seed 224 passed 14 checks, focused seeds 1, 224, and 57123 over 25 iterations each passed 350 checks, adjacent request-lifecycle,query,query-loop,canonical-routing,rewrite,template-hierarchy,template-links smoke passed 7970 checks, bootstrap smoke passed, and broad smoke passed 4864 checks with 1 skip.
    +
    Previous focused addition: kses generated policy/protocol/style mutation coverage exercises generated allow-list variants, safe and unsafe protocol sets, scoped safe_style_css custom-property filtering, style-allowed versus style-disallowed policies, safe-href preservation, event/script stripping, structural policy scans, style-attribute CSS scans, block-comment and malformed-comment boundary inputs, idempotent re-filtering, widened-policy stability, and filter restoration; focused seed 224 passed 1101 checks, focused seeds 1, 224, and 57123 over 25 iterations passed 27603, 27612, and 27607 checks, adjacent kses,security,markup,shortcodes,formatting,blocks,block-supports smoke passed 12012 checks, bootstrap smoke passed, and broad smoke passed 4863 checks with 1 skip.
    +
    Previous focused addition: rest-object-controllers REST taxonomy term mutation coverage exercises isolated WP_REST_Server category and tag routes, including registered REST term-meta schema, hierarchical parent route args versus flat-taxonomy parent rejection, anonymous create denial before validation hooks, missing-parent create/update preservation, create/update response projection, Location headers, sanitized REST-visible term meta, rest_pre_insert_category/rest_insert_category/rest_after_insert_category/rest_prepare_category/rest_delete_category hook timing, force-delete previous meta projection, term-meta row cleanup, and cap/server/action/current-user/filter restoration; focused seed 224 passed 16 checks, focused seeds 1, 224, and 57123 over 25 iterations each passed 400 checks, adjacent rest-object-controllers,rest,rest-controllers,content-lifecycle,query,query-loop,taxonomy,comments,comment-workflow,metadata smoke passed 12850 checks, bootstrap smoke passed, and broad smoke passed 4862 checks with 1 skip.
    +
    Previous focused addition: revisions-autosaves REST route-dispatched coverage exercises isolated WP_REST_Server custom post type revision and autosave routes, including anonymous revision collection denial, invalid parent and relevance-ordering schema failures, collection pagination headers and rest_revision_query payloads, item parent mismatch, raw field and parent-link projection, trash-not-supported versus force-delete behavior through a scoped revision delete cap map, REST delete hook payloads, seeded autosave parent validation, collection/item preview-link projection, REST default-filter idempotence, cloned global filter cleanup, and avoiding irreversible DOING_AUTOSAVE leakage from autosave POST dispatch; focused seed 224 passed 15 checks, focused seeds 1, 224, and 57123 over 25 iterations each passed 375 checks, adjacent revisions-autosaves,rest,rest-controllers,rest-object-controllers,content-lifecycle,query,post-types,security,auth-flow,request-lifecycle smoke passed 7780 checks, bootstrap smoke passed, and broad smoke passed 4861 checks with 1 skip.
    +
    Previous focused addition: rest-application-passwords route-dispatched mutation coverage exercises isolated WP_REST_Server app-password collection, item, me, and introspection routes, including logged-out create denial before mutation, missing/blank/malformed payload rejection, valid create one-time password/hash/Location/self-link projection, invalid update preservation, valid PUT name mutation with app_id immutability, introspection field projection, delete permission denial, item and bulk delete behavior, app-password hook payload timing, REST default-filter idempotence, and cloned global filter cleanup; focused seed 224 passed 8 checks, focused seeds 1, 224, and 57123 over 25 iterations each passed 200 checks, adjacent rest-application-passwords,account-security,auth-flow,capabilities,rest,rest-controllers,rest-object-controllers smoke passed 820 checks, bootstrap smoke passed, and broad smoke passed 4860 checks with 1 skip.
    +
    Previous focused addition: rest-widgets-sidebars route-dispatched widget/sidebar mutation coverage exercises isolated WP_REST_Server /wp/v2/widgets, /wp/v2/widgets/{id}, and /wp/v2/sidebars/{id} routes, including unauthenticated widget create denial before mutation, invalid widget type and malformed encoded-instance rejection, valid create/get/update response projection, sidebar reassignment and reorder semantics, invalid sidebar item schema rejection before mutation, soft and force delete behavior, widget/sidebar/delete hook payload timing, and cap/filter/server/current-user cleanup; focused seed 224 passed 10 checks, focused seeds 1, 224, and 57123 over 25 iterations each passed 250 checks, adjacent rest-widgets-sidebars,widgets,default-widgets,block-widgets,customizer-nav-widgets-requests,rest,rest-controllers,block-editor-adjuncts,admin-ajax smoke passed 950 checks, bootstrap smoke passed, and broad smoke passed 4859 checks with 1 skip.
    +
    Previous focused addition: rest-media-attachments route-dispatched edit/post-process coverage exercises isolated WP_REST_Server /wp/v2/media/{id}/post-process and /edit routes, including invalid action enum rejection before mutation, upload-permission denial before mutation, deterministic mock image-editor operations, missing subsize metadata creation, edited attachment creation, parent-image metadata, alt/title/caption/description/post response projection, metadata filter payloads, exact content-count deltas, and cap/editor/filter/server/current-user cleanup; focused seed 224 passed 9 checks, focused seeds 1, 224, and 57123 over 25 iterations each passed 225 checks, adjacent rest-media-attachments,rest,rest-object-controllers,media-ingest,media-remote,media-metadata,admin-media-chrome,media-image-edit-requests smoke passed 860 checks, bootstrap smoke passed, and broad smoke passed 4858 checks with 1 skip.
    +
    Previous focused addition: rest-object-controllers force-delete/reassign dispatch coverage exercises isolated WP_REST_Server hard-delete routes for posts, categories, comments, and users, including unauthenticated post delete denial before mutation, invalid user reassignment preservation, route-created fixture cleanup, previous-response projection, post-author reassignment, object/meta row removal, exact content-count deltas, and cap/filter/server/action/current-user cleanup; focused seed 224 passed 15 checks, focused seeds 1, 224, and 57123 over 25 iterations each passed 375 checks, adjacent rest-object-controllers,rest,rest-controllers,content-lifecycle,query,query-loop,taxonomy,comments,comment-workflow,metadata smoke passed 12840 checks, bootstrap smoke passed, and broad smoke passed 4857 checks with 1 skip.
    +
    Previous focused addition: rest-object-controllers route-dispatched create/delete edge coverage exercises isolated WP_REST_Server creates for posts, categories, comments, and users, including unauthenticated post create denial before mutation, route payload normalization and response projection, scoped REST email validation filters in the stripped bootstrap, stored-row parity, trash/not-supported delete failures that preserve created rows, exact content-count deltas, and cap/filter/server/action/current-user cleanup; focused seed 224 passed 14 checks, focused seeds 1, 224, and 57123 over 25 iterations each passed 350 checks, adjacent rest-object-controllers,rest,rest-controllers,content-lifecycle,query,query-loop,taxonomy,comments,comment-workflow,metadata smoke passed 12830 checks, bootstrap smoke passed, and broad smoke passed 4856 checks with 1 skip.
    +
    Previous focused addition: rest-object-controllers route-dispatched object write edge coverage exercises isolated WP_REST_Server updates for posts, categories, comments, and users, including unauthenticated post update denial before mutation, invalid body-param validation for post author/category parent/comment email/user email, valid update projection and stored-row parity for title/slug/name/content/status/display name, count stability, and REST server/cap/action/current-user cleanup; focused seed 224 passed 13 checks, focused seeds 1, 224, and 57123 over 25 iterations each passed 325 checks, adjacent rest-object-controllers,rest,rest-controllers,content-lifecycle,query,query-loop,taxonomy,comments,comment-workflow,metadata smoke passed 12820 checks, bootstrap smoke passed, and broad smoke passed 4855 checks with 1 skip.
    +
    Previous focused addition: rest-controllers settings dispatch edge coverage exercises WP_REST_Settings_Controller through WP_REST_Server for unauthorized PUT /wp/v2/settings rejection before mutation, authorized invalid-schema payload rejection with multi-param error details, valid body-param sanitization/update/response parity, and REST server/cap/action cleanup; focused seed 224 passed 17 checks, focused seeds 1, 224, and 57123 over 25 iterations each passed 425 checks, adjacent rest-controllers,rest,rest-object-controllers,query,query-loop,taxonomy,template-links,content-lifecycle smoke passed 8130 checks, bootstrap smoke passed, and broad smoke passed 4854 checks with 1 skip.
    +
    Previous focused addition: content-lifecycle bulk-edit capability edge coverage exercises bulk_edit_posts() top-level wp_die() denial for missing edit_posts, no-mutation preservation after denial, category updates with denied assign_post_tags tag filtering, sticky add/remove requests gated by edit_others_posts, final action payloads that preserve denied intent, and die/cap/hook cleanup; focused seed 224 passed 22 checks, focused seeds 1, 224, and 57123 over 25 iterations each passed 550 checks, adjacent content-lifecycle,taxonomy,taxonomy-relationships,metadata,query,query-loop,post-types,capabilities,admin-edit-metaboxes,admin-workflows,admin-list-tables smoke passed 8310 checks, bootstrap smoke passed, and broad smoke passed 4853 checks with 1 skip.
    +
    Previous focused addition: content-lifecycle bulk-edit edge coverage exercises bulk_edit_posts() sentinel/no-stomp requests, invalid explicit dates, page-parent ancestry skips, and the in-memory posts projection needed for SELECT ID, post_parent; it asserts per-post preservation of status, author, password, discussion, title, category, tag, format, and sticky state, invalid-date skip/no-mutation behavior, safe sibling reparenting, ancestor skip partitions, normalized shared action payloads, and hook cleanup; focused seed 224 passed 21 checks, focused seeds 1, 224, and 57123 over 25 iterations each passed 525 checks, adjacent content-lifecycle,taxonomy,taxonomy-relationships,metadata,query,query-loop,post-types,capabilities,admin-edit-metaboxes,admin-workflows,admin-list-tables smoke passed 8300 checks, bootstrap smoke passed, and broad smoke passed 4852 checks with 1 skip.
    +
    Previous focused addition: content-lifecycle classic admin bulk post edit coverage exercises bulk_edit_posts() across updated/locked/skipped return partitions, locked and missing-post preservation, publish/date reset behavior, author/title/content/excerpt/comment/ping updates, category/tag merges, post-format mutation, sticky option updates, post_stuck and final bulk_edit_posts hook payloads, and hook/request cleanup; focused seed 224 passed 20 checks, focused seeds 1, 224, and 57123 over 25 iterations each passed 500 checks, adjacent content-lifecycle,taxonomy,taxonomy-relationships,metadata,query,query-loop,post-types,capabilities,admin-edit-metaboxes,admin-workflows,admin-list-tables smoke passed 8290 checks, bootstrap smoke passed, and broad smoke passed 4851 checks with 1 skip.
    +
    Previous focused addition: content-lifecycle direct term update/delete coverage exercises wp_update_term() and wp_delete_term() across ID preservation, readable field/cache refresh, duplicate-slug, missing-parent, empty-name, and missing-term failures, child reparenting, object relationship cleanup, has_term()/is_object_in_term() visibility, term lifecycle hook ordering/payloads, and hook cleanup; focused seed 224 passed 19 checks, focused seeds 1, 224, and 57123 over 25 iterations each passed 475 checks, adjacent content-lifecycle,taxonomy,taxonomy-relationships,query,metadata,comments,comment-workflow,rest-object-controllers smoke passed 11830 checks, bootstrap smoke passed, and broad smoke passed 4850 checks with 1 skip. A full adjacent set including xmlrpc exposed a pre-existing XML-RPC cross-surface leak that also reproduced without content-lifecycle; xmlrpc alone passed the failing seed.
    +
    Previous focused addition: formatting text/link helper coverage exercises wp_trim_words(), wp_html_excerpt(), wp_make_link_relative(), wp_rel_nofollow(), wp_rel_ugc(), antispambot(), and capital_P_dangit() across exact trimming/excerpting models, filter payloads, relative URL conversion, external/internal rel-token semantics, randomized email obfuscation round trips, title-filter casing branches, and hook cleanup; focused seed 224 passed 18 checks, focused seeds 1, 224, and 57123 over 25 iterations each passed 450 checks, tight adjacent formatting,markup,shortcodes,content,template-links,syndication smoke passed 2600 checks, bootstrap smoke passed, and broad smoke passed 4849 checks with 1 skip. A broader adjacent set including kses and email was intentionally stopped after exceeding the useful validation window.
    +
    Previous focused addition: admin-dashboard At a Glance rendering coverage exercises wp_dashboard_right_now() for singular and plural publish-only post/page counts, capability-gated edit links, comment and moderation counts via wp_count_comments(), dashboard_glance_items, search-engine privacy filters, version/theme text, rightnow_end/activity_box_end output buffering, and hook cleanup; focused seed 224 passed 13 checks, focused seeds 1, 224, and 57123 over 25 iterations each passed 325 checks, adjacent admin-dashboard,admin-screen,admin-workflows,admin-list-tables,site-health,site-health-debug,content-lifecycle,comments,comment-workflow,post-types,taxonomy,taxonomy-relationships smoke passed 14850 checks, bootstrap smoke passed, and broad smoke passed 4848 checks with 1 skip.
    +
    Previous focused addition: xmlrpc authenticated taxonomy method coverage exercises wp.getTerms, wp.getTerm, wp.newTerm, wp.editTerm, and wp.deleteTerm against in-memory user/term fixtures, including authentication failure, capability denial, ordered term listing, prepared-term filtering, invalid taxonomy, empty-name and missing-parent errors, create/edit/delete lifecycle branches, no HTTP attempts, term-meta option gating, and option/runtime/global cleanup; focused seed 224 passed 13 checks, focused seeds 1, 224, and 57123 over 25 iterations each passed 325 checks, adjacent xmlrpc,taxonomy,taxonomy-relationships,rest-object-controllers,content-lifecycle,query,metadata,comments,comment-workflow smoke passed 29875 checks, bootstrap smoke passed, and broad smoke passed 4847 checks with 1 skip.
    +
    Previous focused addition: wpdb-sql no-DB charset and invalid-text coverage exercises check_ascii(), Core-inspired strip_invalid_text() fixtures for latin1, utf8, utf8mb3, utf8mb4, false charsets, byte and char truncation without partial characters, public insert()/replace()/update() fail-closed behavior when processed %s fields would be stripped or truncated, valid string/integer/float/null builder capture, strip_invalid_text_for_column() string/numeric/non-string paths, and guarded raw-query invalid-text stripping through deterministic table metadata; focused seed 224 passed 9 checks, focused seeds 1, 224, and 57123 over 25 iterations each passed 225 checks, adjacent wpdb-sql,query,content-lifecycle,metadata,options-autoload,state,taxonomy-relationships,comments,comment-workflow smoke passed 1200 checks, bootstrap smoke passed, and broad smoke passed 4846 checks with 1 skip.
    +
    Previous focused addition: error-protection recovery-mode initialization, storage, and cleanup lifecycle coverage exercises WP_Recovery_Mode::initialize() hook registration, cleanup-event scheduling, inert begin-link-shaped requests, valid-cookie activation precedence over begin-link query parameters, inactive paused-extension storage fail-closed behavior, active paused-extension get/idempotent set/replacement/delete-all behavior, active recovery exit cleanup of paused storage and email rate limits, and effective cleanup TTL behavior when the link TTL filter is shorter than the email rate limit; focused seed 224 passed 13 checks, focused seeds 1, 224, and 57123 over 25 iterations each passed 325 checks, adjacent error-protection,site-health,site-health-debug,plugin-theme,plugin-theme-lifecycle,update-install-upgrader,filesystem,request-lifecycle,environment-load smoke passed 121 checks, bootstrap smoke passed, and broad smoke passed 4845 checks with 1 skip.
    +
    Previous focused addition: kses null/control-character and filter-lifecycle coverage exercises wp_kses_no_null() across disallowed C0 bytes, preserved tab/line-feed/carriage-return bytes, default removal versus slash_zero=keep, null-obfuscated protocol filtering through wp_kses_bad_protocol(), and kses_init_filters()/kses_remove_filters()/kses_init() hook wiring for save/comment filters, global styles priority 9 filters, unrelated callback preservation, and unfiltered_html capability gates; focused seed 224 passed 1100 checks, focused seeds 1, 224, and 57123 over 25 iterations passed 27578, 27587, and 27582 checks, adjacent kses,security,markup,shortcodes,formatting,content-lifecycle,query,comments,comment-workflow,capabilities smoke passed 2324 checks, bootstrap smoke passed, and broad smoke passed 4844 checks with 1 skip.
    +
    Previous focused addition: customizer media-control subclass coverage exercises direct WP_Customize_Media_Control, WP_Customize_Upload_Control, WP_Customize_Image_Control, WP_Customize_Cropped_Image_Control, and WP_Customize_Site_Icon_Control JSON/template/capability contracts, including default image/document attachment models, button-label merging, upload_files-driven canUpload, URL-to-attachment resolution via cache-seeded attachments, cropped dimension normalization, site-icon print-style hook cleanup, and data-driven media/site-icon templates; focused seed 224 passed 9 checks, focused seeds 1, 224, and 57123 over 25 iterations each passed 225 checks, adjacent customizer,customizer-persistence,customizer-nav-widgets-requests,appearance-media,admin-media-chrome,media-metadata,media-ingest,widgets,block-widgets smoke passed 91 checks, bootstrap smoke passed, and broad smoke passed 4842 checks with 1 skip.
    +
    Previous focused addition: date-time direct wp_maybe_decline_date() coverage exercises forced-on and forced-off month declension, explicit day-month and month-day formats, dotted-day formats, ordinal and range rewrites, no-format heuristic branches, Catalan apostrophe normalization through get_locale(), gettext_with_context payloads, WP_Locale/$locale restoration, and hook cleanup; focused seed 224 passed 16 checks, focused seeds 1, 224, and 57123 over 25 iterations each passed 400 checks, adjacent date-time,formatting,l10n,environment-load,site-health,syndication,feed-rendering smoke passed 99 checks, bootstrap smoke passed, and broad smoke passed 4841 checks with 1 skip.
    +
    Previous focused addition: site-health direct plugin/theme auto-update coverage exercises detect_plugin_theme_auto_update_issues() and get_test_plugin_theme_auto_updates() across good, critical UI-mismatch, both-filter recommended, plugin-only recommended, and theme-only recommended branches, including auto_update_plugin/auto_update_theme forced-item payloads, plugins_auto_update_enabled/themes_auto_update_enabled UI gates, has_filter() branch gating, no-network behavior, filter cleanup, and wp_installing() restoration; focused seed 224 passed 13 checks, focused seeds 1, 224, and 57123 over 25 iterations each passed 325 checks, adjacent site-health,site-health-debug,update-install-upgrader,plugin-theme,plugin-theme-lifecycle,admin-list-tables smoke passed 75 checks, bootstrap smoke passed, and broad smoke passed 4840 checks with 1 skip.
    +
    Previous focused addition: capabilities direct author_can() wrapper coverage exercises post-object and cached post-ID resolution, cached author loading, role/direct/denied/unknown author caps, variadic meta-cap argument forwarding through map_meta_cap, missing-post and missing-author fail-closed branches, get_user_metadata/map_meta_cap observability, and filter/cache cleanup; focused seed 224 passed 11 checks, focused seeds 1, 224, and 57123 over 25 iterations each passed 275 checks, adjacent capabilities,identity,content-lifecycle,query,query-loop,post-types,template-links,rest-object-controllers,admin-list-tables,user-preferences,security,auth-flow,account-security smoke passed 853 checks, bootstrap smoke passed, and broad smoke passed 4839 checks with 1 skip.
    +
    Previous focused addition: content-lifecycle direct wp_delete_user() coverage exercises numeric-string ID casting, novalue reassignment normalization, delete_user/deleted_user hook ordering and payloads, usermeta deletion by mid, source lookup disappearance, post and link reassignment, custom delete_with_user post-type filtering, post_types_to_delete_with_user payloads, wp_delete_link() hook ordering, preserved delete_with_user=false content, invalid-user fail-closed branches, optional admin include loading, and hook cleanup; focused seed 224 passed 18 checks, focused seeds 1, 224, and 57123 over 25 iterations each passed 450 checks, adjacent content-lifecycle,identity,query,query-loop,post-types,bookmark-links,rest-object-controllers,admin-list-tables,user-preferences,capabilities,metadata,comments,comment-workflow,template-links,post-embeds,revisions-autosaves,admin-edit-metaboxes smoke passed 1339 checks, bootstrap smoke passed, and broad smoke passed 4838 checks with 1 skip.
    +
    Previous focused addition: content-lifecycle direct wp_insert_user()/wp_update_user() filter-meta coverage exercises pre-user filters, wp_pre_insert_user_data, insert_user_meta, insert_custom_user_meta, default/custom user meta, default role and explicit role caps, activation-key clearing, password hashing, wp_set_password, user_register, profile_update, wp_update_user, password/email change notification filters, illegal-login and empty-data fail-closed branches, and hook cleanup; focused seed 224 passed 17 checks, focused seeds 1, 224, and 57123 over 25 iterations each passed 425 checks, adjacent content-lifecycle,identity,auth-flow,account-security,capabilities,metadata,query,query-loop,post-types,comments,comment-workflow,template-links,post-embeds,revisions-autosaves,admin-edit-metaboxes,admin-list-tables,user-preferences smoke passed 1337 checks, bootstrap smoke passed, and broad smoke passed 4837 checks with 1 skip.
    +
    Previous focused addition: rest-directory-services direct plugin/theme directory API coverage exercises plugins_api() and themes_api() array-to-object arg normalization, default per_page/locale/wp_version injection, args/API/result filter payloads, no-network WordPress.org HTTP serialization, malformed and API-error JSON handling, SSL-to-HTTP fallback, plugin short-circuit external markers, and theme query_themes/feature_list response normalization; focused seed 224 passed 8 checks, focused seeds 1, 224, and 57123 over 25 iterations each passed 200 checks, ordered adjacent rest,rest-controllers,rest-object-controllers,rest-media-attachments,http,blocks,block-templates,block-editor-adjuncts,plugin-theme-lifecycle,rest-directory-services,admin-list-tables,plugin-theme smoke passed 134 checks, bootstrap smoke passed, and broad smoke passed 4836 checks with 1 skip.
    +
    Previous focused addition: customizer built-in control rendering coverage exercises direct WP_Customize_Control::get_content()/maybe_render() output for text, textarea, checkbox, radio, and select controls, asserting wrapper IDs/classes, global and per-control render hooks, denied-capability suppression, setting-link attributes, escaped labels/values/input attributes, raw description behavior, textarea row defaults, checked state, selected radio/select options, and hook cleanup; focused seed 224 passed 8 checks, focused seeds 1, 224, and 57123 over 25 iterations each passed 200 checks, adjacent customizer,customizer-persistence,customizer-nav-widgets-requests,appearance-media,admin-screen,admin-workflows,admin-ajax,user-preferences,widgets,block-widgets,assets smoke passed 114 checks, bootstrap smoke passed, and broad smoke passed 4835 checks with 1 skip.
    +
    Previous focused addition: account-security direct reset_password() coverage exercises password_reset, wp_set_password, and after_password_reset hook ordering, same-user plaintext payloads, pre-storage versus post-storage hook state, stored hash replacement, previous-password invalidation, user_activation_key clearing, default_password_nag clearing, repeat reset replacement, hook cleanup, and in-memory content cleanup; focused seed 224 passed 8 checks, focused seeds 1, 224, and 57123 over 25 iterations each passed 200 checks, ordered adjacent account-security,auth-flow,security,identity,user-preferences,email,mail,rest,rest-controllers,rest-object-controllers,rest-application-passwords smoke passed 1355 checks, bootstrap smoke passed, and broad smoke passed 4834 checks with 1 skip.
    +
    Previous focused addition: identity user dropdown coverage exercises generic wp_dropdown_users() selector output, no-echo return behavior, generated select name/id/class contracts, all/none/selected options, wp_dropdown_users_args query/parsed-arg payloads, users_pre_query no-DB user fixtures, final wp_dropdown_users HTML filtering, escaped display_name_with_login labels, include_selected cache fallback, single-author hiding, filter restoration, globals restoration, and generated user-cache cleanup; focused seed 224 passed 1 aggregate row with 23 cases and 900 checks, focused seeds 1, 224, and 57123 over 25 iterations each passed 25 aggregate rows, adjacent identity,email,mail,auth-flow,account-security,security,comments,comment-workflow,template-links,user-preferences,privacy,multisite smoke passed 1836 checks with 1 skip, bootstrap smoke passed, and broad smoke passed 4833 checks with 1 skip.
    +
    Previous focused addition: admin-edit-metaboxes author meta box coverage exercises direct post_author_meta_box() output, post_author_override name/id wiring, selected post-author preservation through include_selected when the filtered user query omits that author, wp_dropdown_users_args capability/selected/display payloads, escaped display_name_with_login labels, and state cleanup; focused seed 224 passed 7 checks, focused seeds 1, 224, and 57123 over 25 iterations each passed 175 checks, adjacent admin-edit-metaboxes,admin-screen,admin-workflows,admin-list-tables,admin-media-chrome,user-preferences,taxonomy,taxonomy-relationships,content-lifecycle,comments,comment-workflow,bookmark-links,revisions-autosaves smoke passed 598 checks, bootstrap smoke passed, and broad smoke passed 4833 checks with 1 skip.
    +
    Previous focused addition: rest-directory-services pattern-directory response projection coverage exercises direct WP_REST_Pattern_Directory_Controller::prepare_item_for_response(), hostile remote field sanitization, dropped remote extras, dispatch-time _fields pruning, rest_prepare_block_pattern raw-object/full-data payloads, and no-leak WordPress.org proxy query behavior; focused seed 224 passed 7 checks, focused seeds 1, 224, and 57123 over 25 iterations each passed 175 checks, ordered adjacent rest,rest-controllers,rest-object-controllers,rest-media-attachments,http,blocks,block-templates,block-editor-adjuncts,plugin-theme-lifecycle,rest-directory-services smoke passed 108 checks, bootstrap smoke passed, and broad smoke passed 4832 checks with 1 skip.
    +
    Previous focused addition: network-media sideload error/filter contract coverage exercises wp_handle_sideload() form-action mismatch rejection, wp_handle_sideload_prefilter error short-circuiting before file moves, wp_handle_sideload_overrides and custom upload error-handler payloads, MIME allowlist rejection with local unfiltered_upload denial, successful sideload move semantics, final wp_handle_upload filter context, and scoped $_POST/filter/capability restoration; focused seed 224 passed 1 aggregate row with 303 cases, 1671 API calls, and 2093 assertions, focused seeds 1, 224, and 57123 over 25 iterations each passed 25 aggregate rows, adjacent network-media,media-ingest,media-remote,media-metadata,image-metadata,images,filesystem smoke passed 61 checks, bootstrap smoke passed, and broad smoke passed 4831 checks with 1 skip.
    +
    Previous focused addition: block-editor-adjuncts editor stylesheet lifecycle coverage exercises add_editor_style(), get_editor_stylesheets(), and remove_editor_styles() over generated parent/child theme fixtures, forced stylesheet/template directory and URI filters, external URL sanitization/deduplication, parent-before-child file ordering, missing-file omission, editor_stylesheets filter payloads, support removal idempotence, and local global/filter restoration; focused seeds 1, 224, and 57123 over 25 iterations each passed 200 checks, adjacent block-editor-adjuncts,blocks,block-supports,block-templates,block-widgets,core-block-render,style,script-loader-runtime,assets smoke passed 100 checks, bootstrap smoke passed, and broad smoke passed 4831 checks with 1 skip.
    +
    Previous focused addition: block-supports direct style callback coverage exercises wp_apply_colors_support(), wp_apply_spacing_support(), wp_apply_border_support(), wp_apply_typography_support(), wp_apply_dimensions_support(), and wp_apply_shadow_support() over generated block types, preset class names, inline gradient/spacing/border/type/dimension/shadow CSS, empty and unsupported fail-closed paths, and safe CSS output; focused seeds 1, 224, and 57123 over 25 iterations each passed 200 checks, adjacent block-supports,blocks,style,core-block-render,block-editor-adjuncts,block-widgets,interactivity,fonts smoke passed 82 checks, bootstrap smoke passed, and broad smoke passed 4830 checks with 1 skip.
    +
    Previous focused addition: admin-workflows Settings API/admin notice coverage exercises generated add_settings_section()/add_settings_field() registrations, do_settings_sections()/do_settings_fields() rendering, settings_fields() hidden form inputs, add_settings_error()/get_settings_errors()/settings_errors() filtering and notice type mapping, wp_get_admin_notice()/wp_admin_notice() getter/echo/filter/action parity, escaped generated markup, and request/global/hook restoration; focused seeds 1, 224, and 57123 over 25 iterations each passed 250 checks, adjacent admin-workflows,admin-list-tables,admin-ajax,admin-screen,admin-options-submission,user-preferences,privacy-admin-requests,security smoke passed 86 checks, bootstrap smoke passed, and broad smoke passed 4829 checks with 1 skip.
    +
    Previous focused addition: plugin-theme admin helper coverage exercises generated real plugin fixtures under the harness WP_PLUGIN_DIR, get_plugins() discovery, valid and no-header validate_plugin() contracts, is_network_only_plugin() header handling, get_plugin_files() inclusion/exclusion/de-duplication, validate_file_to_edit() allowed-file and Windows-drive branches, filtered active_plugins/active_sitewide_plugins option state, and plugin fixture/cache/filter cleanup; focused seeds 1, 224, and 57123 over 25 iterations each passed 325 checks, adjacent plugin-theme,plugin-theme-lifecycle,update-install-upgrader,filesystem,admin-list-tables,rest-controllers smoke passed 83 checks, bootstrap smoke passed, and broad smoke passed 4827 checks with 1 skip.
    +
    Previous focused addition: cron option persistence coverage exercises private _get_cron_array(), _upgrade_cron_array(), and _set_cron_array() helper contracts for non-array option normalization, version stripping, legacy event hashing by serialized args, version-two no-op upgrade behavior, versioned empty writes, forced WP_Error( 'could_not_set' ) update failures, and scoped filter/store cleanup; focused seeds 1, 224, and 57123 over 25 iterations each passed 2400 checks, adjacent cron,options-autoload,state,request-lifecycle,http,site-health,site-health-debug,update-install-upgrader smoke passed 191 checks, bootstrap smoke passed, and broad smoke passed 4823 checks with 1 skip.
    +
    Previous focused addition: rest-media-attachments URL sideload coverage exercises WP_REST_Attachments_Controller::create_item() with the url parameter, fail-closed missing-filename and unsupported-extension rejection before HTTP, short-circuited download_url() streaming through pre_http_request, media_handle_sideload() attachment creation, temp upload-root routing, REST insert action payloads, Location headers, response projection, exact downloaded bytes, metadata/filesize storage, temp download cleanup, and scoped HTTP/upload/capability/client-side-media filter cleanup; focused seeds 1, 224, and 57123 over 25 iterations each passed 200 checks, adjacent rest-media-attachments,rest,rest-object-controllers,media-ingest,media-remote,media-metadata,admin-media-chrome,media-image-edit-requests smoke passed 82 checks, bootstrap smoke passed, and broad smoke passed 4818 checks with 1 skip.
    +
    Previous focused addition: media-metadata audio/video cover attachment generation exercises wp_generate_attachment_metadata() cover-art branches for audio and video attachments, scoped thumbnail support gates, cover binary stripping, wp_upload_bits() payload capture, attachment_thumbnail_args mutation, _cover_hash storage, parent _thumbnail_id updates, cover hash reuse across video art, no-cover skip behavior, recursive cover metadata generation, and scoped filter/post-type support cleanup; focused seeds 1, 224, and 57123 over 25 iterations each passed 275 checks, adjacent media-metadata,media-ingest,media-editor,media-image-edit-requests,images,image-metadata smoke passed 59 checks, bootstrap smoke passed, and broad smoke passed 4817 checks with 1 skip.
    +
    Previous focused addition: customizer-persistence publish-side changeset application exercises WP_Customize_Manager::save() publish requests, _wp_customize_publish_changeset()-driven setting saves, option/theme-mod/Custom CSS persistence, filtered custom CSS post writes, publish hook ordering, next changeset UUID response shape, trash cleanup, invalid-setting transaction rollback, request slashing, and scoped hook/filter cleanup; focused seeds 1, 224, and 57123 over 25 iterations each passed 225 checks, adjacent customizer-persistence,customizer,customizer-nav-widgets-requests smoke passed 24 checks, bootstrap smoke passed, and broad smoke passed 4816 checks with 1 skip.
    +
    Previous focused addition: canonical-routing old-slug redirect strengthening exercises wp_old_slug_redirect() post-ID filter cancellation and replacement around DB-stub-backed _wp_old_slug/_wp_old_date lookup coverage, preserving bounded old_slug_redirect_url cancellation so no wp_redirect()/exit path escapes; focused seeds 1, 224, and 57123 over 25 iterations each passed 400 checks, adjacent canonical-routing,request-lifecycle,template-links,content,rewrite smoke passed 80 checks, bootstrap smoke passed, and broad smoke passed 4815 checks with 1 skip.
    +
    Previous focused addition: content-lifecycle classic admin post-save orchestration coverage exercises direct edit_post() requests through _wp_translate_postdata(), _wp_get_allowed_postdata(), add_meta(), get_post_meta_by_id(), update_meta(), delete_meta(), and wp_set_post_lock(), including spoofed type/MIME preservation, guarded guid/meta_input fields, visibility/password final-state rules, custom-field add/update/delete by meta ID, taxonomy sanitizer callbacks, _edit_last/_edit_lock, admin request cleanup, and post/meta hook ordering; focused seeds 1, 224, and 57123 over 25 iterations each passed 400 checks, adjacent content-lifecycle,revisions-autosaves,admin-edit-metaboxes,admin-workflows,query,taxonomy-relationships,template-links,post-types smoke passed 2184 checks, bootstrap smoke passed, and broad smoke passed 4815 checks with 1 skip.
    +
    Previous focused addition: security invalid admin nonce coverage exercises check_admin_referer() failure paths through captured wp_die()/wp_nonce_ays(), including custom nonce-arg precedence, sanitized retry links with removed updated query args, special log-out confirmation URLs, legacy -1 admin-referer false returns without termination, exact check_admin_referer action payloads, and handler/filter/output-buffer cleanup; focused seeds 1, 224, and 57123 over 25 iterations each passed 400 checks, adjacent smokes passed 375 and 189 checks, bootstrap smoke passed, and broad smoke passed 4814 checks with 1 skip.
    +
    Previous focused addition: comments direct wp_list_comments() coverage exercises explicit comment-array type routing for comment, pings, pingback, trackback, and all views, echo/getter parity, wp_list_comments_args filter payloads through a recording walker, global $wp_query->comments/comments_by_type fallback, max_num_comment_pages cpage behavior, option-driven depth/pagination/order defaults, page/per-page override requerying through comments_pre_query, $overridden_cpage page computation, and loop/global cleanup; Feynman the 2nd identified the gap; focused seeds 1, 224, and 57123 over 25 iterations each passed 11575 checks, adjacent smokes passed 2445, 2565, and 2500 checks, bootstrap smoke passed, and broad smoke passed 4813 checks with 1 skip.
    +
    Previous focused addition: script-loader-runtime polyfill inline generation coverage exercises wp_get_script_polyfill() over generated feature tests and registered/unregistered handles, relative base URL expansion, content URL preservation, protocol-relative CDN URLs, version query args, script_loader_src filter payloads, filtered-empty skip behavior, skipped missing handles, exact document.write() fragment ordering, and filter cleanup; focused seeds 1, 224, and 57123 over 25 iterations each passed 400 checks, adjacent script-loader-runtime,assets,blocks,block-supports,style,fonts,frontend-features smoke passed 410 checks, bootstrap smoke passed, and broad smoke passed 4803 checks with 1 skip.
    +
    Previous focused addition: rest-controllers built-in search handler coverage exercises WP_REST_Post_Search_Handler and WP_REST_Term_Search_Handler subtype discovery, TYPE_ANY expansion, generated public versus hidden post types/taxonomies, attachment exclusion, include/exclude/search/page/per-page query mapping, rest_post_search_query/rest_term_search_query payloads, posts_pre_query/terms_pre_query no-SQL result/count short-circuits, protected/private title prefix removal, no-title post support, REST item/about links, permalink filters, and fixture/filter/type/taxonomy cleanup; focused seeds 1, 224, and 57123 over 25 iterations each passed 400 checks, adjacent rest-controllers,rest,rest-object-controllers,query,query-loop,taxonomy,template-links,content-lifecycle smoke passed 8050 checks, bootstrap smoke passed, and broad smoke passed 4802 checks with 1 skip.
    +
    Previous focused addition: rest-site-editor revision/autosave collection dispatch coverage exercises registered /wp/v2/templates/{id}/revisions and /wp/v2/templates/{id}/autosaves collection routes through an isolated WP_REST_Server, denied-before-query permissions, canonical double-slash template parent lookup, bounded revision WP_Query shapes, rest_revision_query payloads, template-id-preserving paginated revision links, HEAD empty-body behavior, invalid-page failures, autosave filtering, _fields projection, and filter/server cleanup; focused seeds 1, 224, and 57123 over 25 iterations each passed 400 checks, adjacent rest-site-editor,rest,rest-object-controllers,block-templates,revisions-autosaves,content-lifecycle,blocks smoke passed 435 checks, bootstrap smoke passed, and broad seed 224 smoke passed 4802 checks with 1 skip.
    +
    Previous focused addition: blocks built-in block bindings source coverage exercises the core pattern-overrides, post-data, post-meta, and term-data source files, registration metadata, idempotent term-data registration, pattern context lookups, post context versus navigation attribute IDs, date/modified/link branches, REST-registered and protected meta gates, public/private/password post visibility gates, escaped term field/link output, non-public taxonomy read gates, and fixture/filter/registry cleanup; focused seeds 1, 224, and 57123 over 25 iterations each passed 250 checks, adjacent blocks,registries,block-supports,core-block-render,content-lifecycle,taxonomy,taxonomy-relationships,navigation,navigation-lifecycle smoke passed 450 checks, bootstrap smoke passed, and broad seed 224 smoke passed 4801 checks with 1 skip.
    +
    Previous focused addition: editor-helpers internal link query/dialog coverage exercises _WP_Editors::wp_link_query() and wp_link_dialog() across generated post/custom-post fixtures, search/pagination query arguments, wp_link_query_args/posts_pre_query/wp_link_query payloads, sanitized hostile titles, post-date and custom-post info labels, permalinks, empty-result false behavior, one-shot dialog markup, nonce/search/result containers, accessibility attributes, cache isolation, generated post-type hook cleanup, and global/static restoration; Plato the 2nd reviewed the slice and the query-cache and future_$post_type cleanup fixes landed before validation; focused seeds 1, 224, and 57123 over 25 iterations each passed 275 checks, adjacent editor-helpers,admin-ajax,query,content-lifecycle,template-links,script-loader-runtime,assets,block-editor-adjuncts,admin-media-chrome,media-editor smoke passed 3775 checks, bootstrap smoke passed, and broad seed 224 smoke passed 4800 checks with 1 skip.
    +
    Previous focused addition: template-links author-template helper coverage exercises get_the_author()/the_author(), modified-author helpers, author metadata aliases, author links, author post counts, author-post links, wp_list_authors(), and is_multi_author() across echo/getter parity, no-global fail-closed branches, metadata filters, include/exclude/admin/empty/full-name/feed/count/plain-list branches, query-shape assertions, distinct-author semantics, transient/filter behavior, and scoped post/user/cache/filter cleanup; Locke the 2nd reviewed the implementation and the DB-stub reset, query-shape, show-fullname exclusion, distinct-author, and user-meta-path fixes landed before validation; focused seeds 1, 224, and 57123 over 25 iterations each passed 425 checks, adjacent template-links,identity,query,query-loop,content-lifecycle,feed-rendering,discovery smoke passed 3905 checks, bootstrap smoke passed, and broad seed 224 smoke passed 4799 checks with 1 skip.
    +
    Previous focused addition: block-widgets editor dependency warning coverage exercises wp_check_widget_editor_deps() across no-conflict, editor-only, both widget-editor handles, script conflicts, style conflicts, both-conflict, and dependency-chain enqueued scenarios, asserting scoped _doing_it_wrong() capture, warning function/version/message payloads, one warning per conflict class, asset queue/registration stability, and hook/global restoration; Lovelace the 2nd reviewed the implementation and the dependency/handle coverage fixes landed before validation; focused seeds 1, 224, and 57123 over 25 iterations each passed 250 checks, adjacent block-widgets,widgets,default-widgets,rest-widgets-sidebars,customizer-nav-widgets-requests,block-editor-adjuncts,assets smoke passed 68 checks, bootstrap smoke passed, and broad seed 224 smoke passed 4798 checks with 1 skip.
    +
    Previous focused addition: content-lifecycle page/post lookup helper coverage exercises get_page_by_path(), get_pages(), get_page_children(), and get_children() across deterministic page trees, encoded and full ancestry paths, attachment fallback and page/attachment slug collision preference, custom hierarchical post types, salted hit/miss post-queries caches and invalidation, get_pages() include/child/parent/exclude-tree/limit rewrites and filter payloads, get_children() numeric/object/global argument normalization, output-shape checks, and post/filter/global/post-type cleanup; Godel the 2nd identified the gap and Linnaeus the 2nd reviewed the implementation; focused seeds 1, 224, and 57123 over 25 iterations each passed 375 checks, adjacent content-lifecycle,query,query-loop,post-types,template-links,core-block-render,post-embeds,xmlrpc,fonts,default-widgets smoke passed 815 checks, bootstrap smoke passed, and broad seed 224 smoke passed 4797 checks with 1 skip.
    +
    Previous focused addition: comments count/navigation helper coverage exercises get_comments_number(), get_comments_number_text()/comments_number(), get_comments_link()/comments_link(), comments_popup_link(), get_comments_pagenum_link(), next/previous comments links, paginate_comments_links(), and comments navigation/pagination wrappers across cached post comment counts, zero/one/many labels, respond/comment fragments, closed/password/custom-label popup branches, oldest/newest default-page edges, singular fail-closed behavior, attribute filters, echo/getter parity, class/ARIA wrappers, and scoped query/post/server/cookie/filter cleanup; Dewey the 2nd reviewed the gap; focused seeds 1, 224, and 57123 over 25 iterations each passed 11350 checks, adjacent smokes passed 2395 and 2520 checks, bootstrap smoke passed, and broad seed 224 smoke passed 4796 checks with 1 skip.
    +
    Previous focused addition: admin-bar single-site callback node graph coverage exercises wp_admin_bar_site_menu(), wp_admin_bar_new_content_menu(), wp_admin_bar_updates_menu(), wp_admin_bar_sidebar_toggle(), and wp_admin_bar_command_palette_menu() with synthetic capabilities, link-manager and user metadata gates, update-total filters, admin/front-end screen branches, command-palette script enqueue and shortcut labels, and scoped filter/user/screen/script/server/wpdb cleanup; focused seeds 1, 224, and 57123 over 25 iterations each passed 225 checks, adjacent admin-bar,post-types,assets,script-loader-runtime,update-install-upgrader,site-health smoke passed 77 checks, bootstrap smoke passed, and broad seed 224 smoke passed 4795 checks with 1 skip.
    +
    Previous focused addition: post-embeds host-script enqueue gate coverage exercises wp_maybe_enqueue_oembed_host_js() and the back-compat wp_oembed_add_host_js action marker with generated post-embed blockquote markup, single-quoted class attributes, non-blockquote lookalikes, missing-action fail-closed behavior, exact HTML preservation, script queue assertions for wp-embed, and scoped hook/script cleanup; focused seeds 1, 224, and 57123 over 25 iterations each passed 200 checks, adjacent post-embeds,rest,http,content,template-links,syndication,media-remote,script-loader-runtime smoke passed 188 checks, bootstrap smoke passed, and broad seed 224 smoke passed 4794 checks with 1 skip.
    +
    Previous focused addition: template-links archive navigation wrapper coverage exercises next_posts(), previous_posts(), get_next_posts_link(), get_previous_posts_link(), get_posts_nav_link()/posts_nav_link(), get_the_posts_navigation()/the_posts_navigation(), get_the_posts_pagination()/the_posts_pagination(), and _navigation_markup() across synthetic archive query states, first/middle/last/one-page edges, singular fail-closed checks, scoped attribute/template/pagination filters, echo/getter parity, class/ARIA escaping, and hook/global/server cleanup; Sagan the 2nd reviewed the gap; focused seeds 1, 224, and 57123 over 25 iterations each passed 400 checks, adjacent template-links,content,query,query-loop,core-block-render,rewrite smoke passed 3900 checks, bootstrap smoke passed, and broad seed 224 smoke passed 4793 checks with 1 skip.
    +
    Previous focused addition: http encoding helper coverage exercises WP_Http_Encoding::compress(), decompress(), compatible_gzinflate(), accept_encoding(), should_decode(), content_encoding(), and is_available() across generated text/binary payloads, raw deflate, zlib-wrapped deflate, gzip headers with filename/comment/header-CRC fields, deterministic malformed payload fail-closed cases, accept-encoding policy gates and filter payloads, and zlib availability oracles; focused seeds 1, 224, and 57123 over 25 iterations each passed 325 checks, adjacent http,feed-parsers,feed-rendering,syndication smoke passed 90 checks, bootstrap smoke passed, and broad seed 224 smoke passed 4792 checks with 1 skip.
    +
    Previous focused addition: http origin helper coverage exercises get_allowed_http_origins(), is_allowed_http_origin(), get_http_origin(), non-OPTIONS send_origin_headers(), wp_http_supports(), and allowed_http_request_hosts() with generated exact-origin variants, scoped allowlist/result/origin filters, port-stripping and slash/case rejection oracles, URL-derived SSL capability normalization, host allowlist mirroring, and server/filter cleanup; focused seeds 1, 224, and 57123 over 25 iterations each passed 300 checks, adjacent http,feed-parsers,feed-rendering,syndication smoke passed 88 checks, bootstrap smoke passed, and broad seed 224 smoke passed 4791 checks with 1 skip.
    +
    Previous focused addition: multisite signup validation coverage exercises is_email_address_unsafe(), wpmu_validate_user_signup(), wpmu_validate_blog_signup(), and signup_nonce_check() with scoped default email validation filters, synthetic users, pending and stale wp_signups rows, unsafe/limited email domains, illegal login filters, blogname/title normalization, domain/path reservations, existing-user overrides, valid and invalid nonce branches, and filter/global cleanup; focused seeds 1, 224, and 57123 over 25 iterations each passed 450 checks with 25 existing true-multisite skips, adjacent multisite,identity,email smoke passed 1266 checks with 1 skip, bootstrap smoke passed, and broad seed 224 smoke passed 4790 checks with 1 skip.
    +
    Previous focused addition: formatting deep mapping helper coverage exercises map_deep(), urlencode_deep(), rawurlencode_deep(), urldecode_deep(), stripslashes_deep(), wp_slash(), and wp_unslash() over generated nested arrays and objects, asserting leaf visitation counts, container shape preservation, exact helper-specific transforms, array-only slash round trips, and bounded expansion; focused seeds 1, 224, and 57123 over 25 iterations each passed 425 checks, adjacent formatting,content,security,markup,kses,shortcodes smoke passed 1190 checks, bootstrap smoke passed, and broad seed 224 smoke passed 4789 checks with 1 skip.
    +
    Previous focused addition: comments direct comment_form() coverage exercises closed-post handling, direct comment_form_title(), get_comment_id_fields()/comment_id_fields() parity, anonymous HTML5 and XHTML form rendering, required-field and email-note behavior, cookies consent, must-log-in and logged-in branches, dynamic field/submit filters, action hooks, synthetic current-user cache seeding, and no-DB sentinel coverage; Avicenna the 2nd reviewed the slice; focused seeds 1, 224, and 57123 over 25 iterations each passed 11325 checks, adjacent comments,comment-workflow,content,identity,template-hierarchy,classic-walkers smoke passed 503 checks, bootstrap smoke passed, and broad seed 224 smoke passed 4788 checks with 1 skip.
    +
    Previous focused addition: editor-helpers direct wp_tinymce_inline_scripts() coverage exercises classic-block filter fan-out, duplicate TinyMCE plugin de-duplication, caption disabling, toolbar/external-plugin merging, raw JSON/array/function serialization, boolean/string serialization, before-script attachment to wp-block-library, and scoped filter cleanup without browser TinyMCE execution; focused seeds 1, 224, and 57123 over 25 iterations each passed 250 checks, adjacent editor-helpers,script-loader-runtime,assets,block-editor-adjuncts,admin-media-chrome,media-editor smoke passed 67 checks, bootstrap smoke passed, and broad seed 224 smoke passed 4783 checks with 1 skip.
    +
    Previous focused addition: date-time direct wp_timezone_choice() markup coverage exercises empty placeholder selection, named PHP timezone IDs, deprecated-but-valid BC timezone IDs, UTC grouping, manual UTC offset values and quarter-hour labels, hostile selected-zone strings, locale-invariant option values, balanced optgroups, single selected-option state, scoped pre_load_textdomain short-circuiting, l10n global restoration, and translation-controller cleanup without admin settings-page dispatch; Singer the 2nd reviewed the slice; focused seeds 1, 224, and 57123 over 25 iterations each passed 375 checks, adjacent date-time,l10n,translations,admin-options-submission,customizer,admin-screen smoke passed 310 checks, bootstrap smoke passed, and broad seed 224 smoke passed 4780 checks with 1 skip.
    +
    Previous focused addition: media-metadata gallery and playlist shortcode rendering coverage exercises gallery_shortcode() and wp_playlist_shortcode() over cache-seeded image/audio/video attachments with posts_pre_query short-circuits, ordered ids mapping, image-only gallery selection, invalid tag fallback, HTML5 file-link galleries, caption/orientation/ARIA output, protected-parent fail-closed behavior, playlist type coercion, boolean normalization, ID3 key filters, thumbnail/MIME-icon branches, JSON escaping, one-time script hooks, and scoped filter/global cleanup; Gauss the 2nd reviewed the gap; focused seeds 1, 224, and 57123 over 25 iterations each passed 250 checks, adjacent media-metadata,shortcodes,images,media-editor,media-ingest,media-remote,admin-media-chrome,script-loader-runtime,content-lifecycle,template-links smoke passed 640 checks, bootstrap smoke passed, and broad seed 224 smoke passed 4804 checks with 1 skip.
    +
    Previous focused addition: media-metadata public audio/video media shortcode rendering coverage exercises wp_audio_shortcode(), wp_video_shortcode(), and wp_mediaelement_fallback() across mediaelement and HTML5 library paths, typed source lists, invalid-source embedded-link fallbacks, override/library/class/output filters, fallback escaping, YouTube/Vimeo URL normalization, boolean/preload normalization, generated encoded payload URLs, and scoped filter/script/style/content-width cleanup without gallery, playlist, or browser playback dispatch; Peirce the 2nd reviewed the slice; focused seeds 1, 224, and 57123 over 25 iterations each passed 225 checks, adjacent media-metadata,images,media-editor,media-ingest,media-remote,admin-media-chrome,shortcodes,markup,script-loader-runtime,default-widgets smoke passed 254 checks, bootstrap smoke passed, and broad seed 224 smoke passed 4779 checks with 1 skip.
    +
    Previous focused addition: template-links direct get_search_form() rendering coverage exercises generated hostile search queries and ARIA labels, HTML5/XHTML format forcing, echo/getter parity, legacy boolean return mode, ordered pre_get_search_form/search_form_args/search_form_format/get_search_form payloads, null-filter fallback, output-buffer stability, and scoped hook/query cleanup without browser template dispatch; Heisenberg the 2nd reviewed the slice; focused seeds 1, 224, and 57123 over 25 iterations each passed 375 checks, adjacent template-links,content,query,canonical-routing,rewrite,request-lifecycle,feed-rendering,syndication smoke passed 3715 checks, adjacent template-links,blocks,core-block-render,block-editor-adjuncts,template-hierarchy smoke passed 250 checks, bootstrap smoke passed, and broad seed 224 smoke passed 4778 checks with 1 skip.
    +
    Previous focused addition: auth-flow direct wp_login_form() rendering coverage exercises generated hostile IDs, labels, username values, redirect URLs, required-field and remember-me toggles, echo/getter parity, default HTTPS redirect derivation from server globals, ordered login_form_defaults/login_form_top/login_form_middle/login_form_bottom filters, and scoped filter/server cleanup without browser login-page dispatch; Lagrange the 2nd reviewed the slice; focused seeds 1, 224, and 57123 over 25 iterations each passed 300 checks, adjacent auth-flow,account-security,security,identity,request-lifecycle smoke passed 240 checks, adjacent auth-flow,rest-application-passwords,rest,rest-controllers,rest-object-controllers smoke passed 285 checks, bootstrap smoke passed, and broad seed 224 smoke passed 4777 checks with 1 skip.
    +
    Previous focused addition: cron public wp_cron() wrapper coverage exercises non-alternate shutdown deferral and immediate shutdown action execution, asserting due jobs remain queued when the wrapper only registers _wp_cron, isolated do_action( 'shutdown' ) dispatch runs exactly one spawn path, loopback payloads and doing_cron locks match, hook/action globals restore, and no real network request escapes; Hegel the 2nd reviewed the slice; focused seeds 1, 224, and 57123 over 25 iterations each passed 2275 checks, adjacent cron,request-lifecycle,http,site-health,site-health-debug,update-install-upgrader smoke passed 750 checks, adjacent cron,options-autoload,state,environment-load smoke passed 710 checks, bootstrap smoke passed, and broad seed 224 smoke passed 4776 checks with 1 skip.
    +
    Previous focused addition: auth-flow logout lifecycle coverage exercises public wp_logout() composition over a synthetic current user with two session tokens and a stale logged-in cookie, asserting current-token destruction, sibling-token preservation, clear_auth_cookie/send_auth_cookies no-header behavior, current-user reset before the wp_logout action, action payloads, and failed stale-cookie rehydration through normal determine_current_user filters; Pauli the 2nd reviewed the slice; focused seeds 1, 224, and 57123 over 25 iterations each passed 275 checks, adjacent auth-flow,account-security,security,identity,request-lifecycle smoke passed 235 checks, adjacent auth-flow,rest-application-passwords,rest,rest-controllers smoke passed 220 checks, bootstrap smoke passed, and broad seed 224 smoke passed 4775 checks with 1 skip.
    +
    Previous focused addition: customizer theme-preview lifecycle coverage exercises direct inactive and active WP_Customize_Manager::start_previewing_theme()/stop_previewing_theme() flows with a synthetic active theme plus inactive child/parent preview fixture, asserting the seven theme-switching filters at priority 10, global get_stylesheet()/get_template()/current-theme/root option resolution, idempotent second start/stop calls, action payloads, active-theme no-filter behavior, and cleanup; Dirac the 2nd reviewed the slice; focused seeds 1, 224, and 57123 over 25 iterations each passed 175 checks, adjacent customizer,customizer-persistence,customizer-nav-widgets-requests smoke passed 115 checks, adjacent plugin-theme-lifecycle,template-hierarchy,block-templates,rest-site-editor smoke passed 240 checks, bootstrap smoke passed, and broad seed 224 smoke passed 4774 checks with 1 skip.
    +
    Previous focused addition: admin-options-submission core-page coverage exercises Reading, Discussion, Media, and Writing Settings submissions through the bounded options.php update flow, including pagination/front-page absint and checkbox defaults, closed discussion statuses, unique moderation/disallowed keyword lists, media dimension normalization, mailserver text stripping, success transient/redirect/nonce/capability oracles, and conditional Writing Settings allowlist gates for post-by-email, legacy DB-version options, and public-blog update services; Aquinas the 2nd identified the missing Writing gate branch; focused seeds 1, 224, and 57123 over 25 iterations each passed 200 checks, adjacent admin-options-submission,admin-workflows,admin-screen,security,options-autoload smoke passed 270 checks, bootstrap smoke passed, and broad seed 224 smoke passed 4773 checks with 1 skip.
    +
    Previous focused addition: taxonomy hierarchical term-link coverage exercises cache-backed parent/child WP_Term objects through custom taxonomy, category, and tag permalink generation, hierarchical rewrite parent-slug expansion, get_term_parents_list() linked and unlinked agreement, and ordered pre_term_link, category_link, tag_link, and term_link filter payloads with cleanup; Zeno the 2nd confirmed the no-DB taxonomy gap before implementation; focused seeds 1, 224, and 57123 over 25 iterations each passed 325 checks, adjacent taxonomy,taxonomy-relationships,template-links,classic-walkers,rest-controllers smoke passed 335 checks, bootstrap smoke passed, and broad seed 224 smoke passed 4771 checks with 1 skip.
    +
    Previous focused addition: state direct object-cache blog-switch coverage exercises wp_cache_switch_to_blog() without full multisite global switching, synthetic multisite-mode cache prefixes, blog-local custom and default groups, global-group sharing and delete behavior, raw stored-key prefix/unprefixed oracles, string blog-ID casting, and runtime/global restoration; Huygens the 2nd flagged adjacent multisite overlap, so this row is scoped to the direct cache API wrapper rather than switch_to_blog(); focused seeds 1, 224, and 57123 over 25 iterations each passed 500 checks, adjacent state,multisite,options-autoload smoke passed 250 checks with 5 existing skips, bootstrap smoke passed, and broad seed 224 smoke passed 4770 checks with 1 skip.
    +
    Previous focused addition: discovery favicon front-controller coverage exercises template-loader.php dispatch for is_favicon(), template_redirect traversal with restored redirect_canonical(), default do_favicon redirect-and-exit behavior, do_faviconico hook order, fallback and filtered get_site_icon_url() payloads, wp_redirect/wp_redirect_status/status_header/x_redirect_by filters, no-output child shutdown capture, and subprocess-unavailable skip behavior; focused seeds 1, 224, and 57123 over 25 iterations each passed 375 checks, adjacent discovery,canonical-routing,request-lifecycle,http smoke passed 110 checks, disabled-subprocess smoke passed 14 checks with 1 skip, bootstrap smoke passed, and broad seed 224 smoke passed 4769 checks with 1 skip.
    +
    Previous focused addition: style global styles user-data/getter coverage exercises wp_get_global_settings(), wp_get_global_styles(), WP_Theme_JSON_Resolver::get_user_data_from_wp_global_styles(), get_user_data(), and get_user_global_styles_post_id() for active-theme wp_global_styles lookup, draft/wrong-theme decoys, custom/base origin separation, block-name path rewriting, variable resolution transforms, settings cache keys and cache cleaning, unsafe missing-flag fail-closed behavior, post-ID creation/cache reuse, and resolver/wpdb/global restoration; focused seeds 1, 224, and 57123 over 25 iterations each passed 400 checks, adjacent style,fonts,block-supports,rest-site-editor smoke passed 245 checks, bootstrap smoke passed, and broad seed 224 smoke passed 4768 checks with 1 skip.
    +
    Previous focused addition: discovery robots.txt front-controller coverage exercises do_robots() for emitted User-agent, Disallow, and Allow lines, site-path admin URL construction, public/private blog_public state, exact do_robotstxt action before robots_txt filtering, filter payload public flags, deterministic filter output extension, duplicate-line guards, and hook cleanup; focused seeds 1, 224, and 57123 over 25 iterations each passed 350 checks, adjacent discovery,canonical-routing,request-lifecycle,http,rest-site-editor,core-block-render,template-links smoke passed 460 checks, bootstrap smoke passed, and broad seed 224 smoke passed 4767 checks with 1 skip.
    +
    Previous focused addition: navigation-lifecycle navigation fallback coverage exercises WP_Navigation_Fallback::get_fallback() and WP_Classic_To_Block_Menu_Converter::convert() for disabled creation, primary-location classic-menu priority over primary-slug and newest-menu decoys, inserted wp_navigation title/slug/status, parseable core/navigation-submenu and core/navigation-link content, top-level and nested item ordering, sanitized custom class/rel attributes, post-type target identity, duplicate-free fallback reuse, query-global state, and filter cleanup; focused seeds 1, 224, and 57123 over 25 iterations each passed 225 checks, adjacent navigation-lifecycle,navigation,customizer-nav-widgets-requests,rest-site-editor,block-templates,core-block-render,template-links,content-lifecycle,taxonomy-relationships smoke passed 490 checks, bootstrap smoke passed, and broad seed 224 smoke passed 4766 checks with 1 skip.
    +
    Previous focused addition: content-lifecycle set_post_type() coverage exercises filtered target post-type mutation, raw pre_post_type and filtered type_save_pre payloads, stale post/parent/meta/archive cache eviction, page-specific all_page_ids cleanup, untouched sibling rows, missing-ID fail-closed behavior, cache-clean action locality, and hook cleanup; adjacent validation also hardened query to allow only known KSES save-pre hook additions while still failing unexpected hook callback drift; focused content seeds 1, 224, and 57123 over 25 iterations each passed 350 checks, focused query sweeps passed 6440, 6440, and 3220 checks, adjacent content-lifecycle,post-types,query,taxonomy,taxonomy-relationships,comments,comment-workflow,revisions-autosaves,template-links smoke passed 5895 checks, bootstrap smoke passed, and broad seed 224 smoke passed 4765 checks with 1 skip.
    +
    Previous focused addition: admin-dashboard Community Events markup/template coverage exercises wp_print_community_events_markup() and wp_print_community_events_templates() for the hidden widget shell, admin notice/error branches, AJAX form endpoint, stable IDs/classes/ARIA controls, location input and submit/cancel controls, results container, all four Underscore template IDs, city placeholders, event fields, organizer links, no generated executable markup, and output-buffer restoration; focused seeds 1, 224, and 57123 over 25 iterations each passed 300 checks, adjacent admin-dashboard,community-events,admin-ajax,admin-screen,admin-workflows,user-preferences smoke passed 315 checks, bootstrap smoke passed, and broad seed 224 smoke passed 4764 checks with 1 skip.
    +
    Previous focused addition: registries block-bindings source coverage exercises WP_Block_Bindings_Registry, WP_Block_Bindings_Source, public wrapper parity, singleton identity, ordered aggregate registration, source property normalization, exact callback and block_bindings_source_value filter payloads, invalid name/property matrices, duplicate and missing unregister warnings, unregister/re-register tail ordering, and singleton/source restoration; Kuhn the 2nd reviewed the gap and snapshot caveats; focused seeds 1, 224, and 57123 over 25 iterations each passed 175 checks, adjacent registries,blocks,block-editor-adjuncts,rest-controllers,style,core-block-render smoke passed 315 checks, bootstrap smoke passed, and broad seed 224 smoke passed 4763 checks with 1 skip.
    +
    Previous focused addition: utility-internals diagnostic/error helper coverage exercises is_wp_error() and the is_wp_error_instance action payload/counter/context contract, wp_debug_backtrace_summary() raw/pretty/skip/ignore-class behavior over a controlled stack, and wp_trigger_error() always-run/filter/run hook ordering, suppression filters, WP_DEBUG gating, local error-handler capture, and sanitized emitted-message boundaries when debug triggering is active; Descartes the 2nd reviewed the gap and hook-state caveats; focused seeds 1, 224, and 57123 over 25 iterations each passed 300 checks, adjacent utility-internals,hooks,import-diff,registries,error-protection,site-health-debug smoke passed 305 checks, bootstrap smoke passed, and broad seed 224 smoke passed 4762 checks with 1 skip.
    +
    Previous focused addition: environment-load memory-limit helper coverage exercises child-process wp_raise_memory_limit() contracts for default admin raises to WP_MAX_MEMORY_LIMIT, high-current no-lowering behavior, image_memory_limit raises above the core max, low cron_memory_limit fallback to the core max, dynamic custom context filters, unlimited-current early return without filter dispatch, scoped hook cleanup, explicit return/post-call memory_limit oracles, child per-case restoration, and unchanged parent memory_limit; Averroes the 2nd reviewed the gap and hardening; focused seeds 1, 224, and 57123 over 25 iterations each passed 450 checks, adjacent environment-load,site-health,site-health-debug,cron,media-editor,update-install-upgrader smoke passed 1520 checks, bootstrap smoke passed, and broad seed 224 smoke passed 4761 checks with 1 skip.
    +
    Previous focused addition: utility-internals helper coverage exercises _wp_to_kebab_case(), wp_find_hierarchy_loop(), wp_find_hierarchy_loop_tortoise_hare(), wp_unique_id(), wp_unique_prefixed_id(), wp_unique_id_from_values(), wp_generate_uuid4(), wp_is_uuid(), and wp_validate_boolean() for lodash-compatible kebab-case fixtures and generated ASCII cases, terminating/self/cyclic hierarchy maps, start_parent override loops, direct tortoise-hare detection/enumeration, callback-argument propagation, process-wide and prefix-scoped monotonic ID counters, deterministic nested-value hashes, UUID v4/lowercase/variant gates, boolean casting semantics, and state restoration; Aristotle the 2nd reviewed the gap and recommended the kebab-case matrix; focused seeds 1, 224, and 57123 over 25 iterations each passed 275 checks, adjacent utility-internals,style,block-supports,fonts,core-block-render,blocks,content-lifecycle,taxonomy,post-types smoke passed 1000 checks, bootstrap smoke passed, and broad seed 224 smoke passed 4760 checks with 1 skip.
    +
    Previous focused addition: content-lifecycle featured-image helper coverage exercises set_post_thumbnail(), delete_post_thumbnail(), get_post_thumbnail_id(), has_post_thumbnail(), get_the_post_thumbnail(), the_post_thumbnail(), thumbnail URL/caption helpers, and update_post_thumbnail_cache() for meta insert/update/delete paths, missing-object fail-closed behavior, non-image cleanup, image source and attribute filtering, begin/end fetch hooks, echo/getter parity, escaped URL output, attachment caption/display filters, cache priming, and filter/global restoration; Mencius the 2nd reviewed the gap and risks; focused seeds 1, 224, and 57123 over 25 iterations each passed 325 checks, adjacent content-lifecycle,template-links,images,admin-edit-metaboxes,post-embeds,revisions-autosaves,media-metadata,media-ingest,admin-media-chrome smoke passed 940 checks, bootstrap smoke passed, and broad seed 224 smoke passed 4757 checks with 1 skip.
    +
    Previous focused addition: content-lifecycle post/attachment count and MIME helper coverage exercises wp_count_posts(), wp_count_attachments(), get_post_mime_types(), and get_available_post_mime_types() for status grouping, readable private-post filtering, zero-filled post statuses, count cache keys, attachment MIME/trash grouping, exact/wildcard/array MIME filters, converted MIME groups, DB-backed available MIME lists, short-circuit filter behavior, and filter/current-user restoration; Helmholtz the 2nd reviewed the gap and risks; focused seeds 1, 224, and 57123 over 25 iterations each passed 300 checks, adjacent content-lifecycle,content,post-types,query,media-ingest,media-metadata,network-media,rest-object-controllers smoke passed 17875 checks, bootstrap smoke passed, and broad seed 224 smoke passed 4756 checks with 1 skip.
    +
    Previous focused addition: blocks post-object and REST block hook coverage exercises apply_block_hooks_to_content_from_post_object(), update_ignored_hooked_blocks_postmeta(), and insert_hooked_blocks_into_rest_response() for wrapper root first/last-child hooks, suppressed wrapper before/after hooks, persisted ignored metadata, REST content.raw/meta mutation, rendered-content refresh, single-instance metadata suppression, and filter/fixture restoration; Herschel the 2nd reviewed the gap and risks; focused seeds 1, 224, and 57123 over 25 iterations each passed 225 checks, adjacent blocks,block-editor-adjuncts,block-supports,block-templates,block-widgets,core-block-render,markup,rest-controllers smoke passed 2225 checks, bootstrap smoke passed, and broad seed 224 smoke passed 4755 checks with 1 skip.
    +
    Previous focused addition: comment-workflow notification mail-path coverage exercises approved post-author and pending moderator wrapper gates, direct wp_notify_postauthor()/wp_notify_moderator() behavior, filtered recipient/header/subject/body payloads, scoped author capability behavior, pre_wp_mail delivery interception, and filter/option/current-user restoration; Raman the 2nd reviewed the gap and risks; focused seeds 1, 224, and 57123 over 25 iterations each passed 250 checks, adjacent comment-workflow,comments,content-lifecycle,content,mail,email smoke passed 5232 checks, bootstrap smoke passed, and broad seed 224 smoke passed 4754 checks with 1 skip.
    +
    Previous focused addition: rest-application-passwords response-shaping coverage exercises direct WP_REST_Application_Passwords_Controller::prepare_item_for_response() and dispatched item GET responses for edit/view/embed context filtering, one-time password exposure only in edit context, stored-hash non-exposure, formatted created/last_used timestamps, last_ip preservation, generated _fields projection with and without requested links, forbidden _fields=password view responses, record_application_password_usage() first-use metadata, same-day usage throttling, missing-UUID fail-closed behavior without storage mutation, and self-link canonicalization; Hilbert the 2nd reviewed the gap and suggested the strengthened oracles; focused seeds 1, 224, and 57123 over 25 iterations each passed 175 checks, adjacent REST/security smoke passed 144 checks, bootstrap smoke passed, and broad seed 224 smoke passed 4753 checks with 1 skip.
    +
    Previous focused addition: mail RFC2822 display-name MIME header coverage exercises wp_mail() through the fake PHPMailer handoff with UTF-8 display names and ASCII mailbox addresses for To, From, Cc, Bcc, and Reply-To; the row asserts PHPMailer recipient arrays, literal mailbox preservation in final MIME headers, exactly-one unfolded address headers, encoded-word display names, iconv_mime_decode() round trips back to readable address forms, custom header preservation, unset SMTP Sender, UseSMTPUTF8 staying false, success-action payloads, and hook cleanup; Cicero the 2nd independently identified the same gap; focused seeds 1, 224, and 57123 over 25 iterations each passed 300 checks, adjacent mail,email,privacy smoke passed 1280 checks, bootstrap smoke passed, and broad seed 224 smoke passed 4752 checks with 1 skip.
    +
    Previous focused addition: privacy export ZIP generation coverage exercises wp_privacy_process_personal_data_export_page() with the real wp_privacy_generate_personal_data_export_file() action, filtered temp export roots, exact export.json/index.html ZIP contents, escaped report HTML, export-created action payloads, index protection, temp report cleanup, legacy _export_file_path migration, invalid request/action/email/JSON fail-closed paths through captured AJAX JSON errors, and action/filter restoration; focused seeds 1, 224, and 57123 over 25 iterations each passed 500 checks, adjacent privacy/admin/filesystem/mail smoke passed 315 checks, bootstrap smoke passed, and broad seed 224 smoke passed 4751 checks with 1 skip.
    +
    Previous focused addition: filesystem archive/copy/move coverage exercises wp_zip_file_is_valid(), unzip_file() through both ZipArchive and PclZip routes, safe archive manifests, traversal and __MACOSX skips, unzip filter payload/restoration, standard filesystem chmod initialization, copy_dir() missing-source and nested skip-list contracts, and move_dir() same-source, existing-destination, overwrite, and copy-fallback behavior; focused seeds 1, 224, and 57123 over 25 iterations each passed 350 checks, adjacent filesystem consumer smoke passed 265 checks, bootstrap smoke passed, and broad seed 224 smoke passed 4750 checks with 1 skip.
    +
    Previous focused addition: rest-site-editor collection GET dispatch coverage exercises registered /wp/v2/templates and /wp/v2/template-parts routes through WP_REST_Server, capability-allowed collection callbacks, bounded pre_get_block_templates fixtures, post-type and area query propagation, _fields projection, content block-version serialization, denied-before-lookup guards, HEAD no-query behavior, and filter/server cleanup; focused seeds 1, 224, and 57123 over 25 iterations each passed 375 checks, adjacent REST/template smoke passed 310 checks, bootstrap smoke passed, and broad seed 224 smoke passed 4748 checks with 1 skip.
    +
    Previous focused addition: formatting wptexturize() coverage exercises exact quote, apostrophe, dash, ellipsis, dimension, ampersand, default protected-tag, filtered protected-tag, protected shortcode, unprotected registered-shortcode, HTML comment, punycode double-hyphen, split-regex recomposition, direct prime classification, marker-leak, idempotence, and filter-cleanup oracles; focused seeds 1 and 224 over 25 iterations each passed 400 checks, seed 100 over 50 iterations passed 800 checks, adjacent formatting/shortcodes/markup/content smoke passed 1900 checks, bootstrap smoke passed, and broad seed 224 smoke passed 4748 checks with 1 skip.
    +
    Previous focused addition: rewrite pretty-permalink url_to_postid() coverage exercises generated rewrite-rule matching, site-base and www URL normalization, query/fragment stripping, custom post-type query-var mapping, non-public query-var filtering, short-circuited WP_Query singular post oracles, missing-post fail-closed behavior, and hook/post-type cleanup; focused seeds 1, 224, and 490 over 25 iterations each passed 475 checks, adjacent rewrite/request/canonical/query smoke passed 2115 checks, bootstrap smoke passed, and broad seed 224 smoke passed 4747 checks with 1 skip.
    +
    Previous focused addition: discovery sitemap stylesheet output coverage exercises WP_Sitemaps_Stylesheet sitemap and index XSL structure, URL-count expressions, sitemap-only column guards, index-only field boundaries, LTR/RTL CSS alignment, CSS/content filter locality, and wp_locale restoration; focused seeds 1, 224, and 49949 over 25 iterations each passed 325 checks, adjacent discovery/REST/rewrite smoke passed 180 checks, bootstrap smoke passed, and broad seed 224 smoke passed 4746 checks with 1 skip.
    +
    Previous focused addition: rest-controllers menu-location controller coverage exercises WP_REST_Menu_Locations_Controller route/schema contracts, anonymous denial, read-access filter overrides, edit_theme_options capability access, _fields projection, assigned menu IDs, REST links, prepare filters, invalid-location errors, and nav-menu/current-user cleanup; focused seeds 1, 224, and 40878 over 25 iterations each passed 400 checks, adjacent REST/navigation smoke passed 275 checks, bootstrap smoke passed, and broad seed 224 smoke passed 4745 checks with 1 skip.
    +
    Previous focused addition: rest-controllers block renderer controller coverage exercises WP_REST_Block_Renderer_Controller route/schema registration, permission and context errors, invalid and unknown attribute rejection, dynamic attribute sanitization, boolean coercion, JSON POST body handling with post context, non-dynamic block rejection, pre_render_block short-circuit payloads, and filter cleanup; focused seeds 1, 224, and 45098 over 25 iterations each passed 375 checks, adjacent REST/block smoke passed 510 checks, bootstrap smoke passed, and broad seed 224 smoke passed 4744 checks with 1 skip.
    +
    Previous focused addition: admin-screen settings error and admin notice coverage exercises add_settings_error(), get_settings_errors(), settings_errors(), transient replay/deletion, sanitize side effects, legacy updated class mapping, hide-on-update behavior, wp_get_admin_notice() exact markup, notice args/markup filters, wp_admin_notice action dispatch, KSES output sanitization, and hook cleanup; focused seeds 1, 224, and 57791 over 25 iterations each passed 250 checks, adjacent admin smoke passed 170 checks, bootstrap smoke passed, and broad seed 224 smoke passed 4742 checks with 1 skip.
    +
    Previous focused addition: feed-rendering exact rss_enclosure() fixture coverage exercises Core newline fixture variants, invalid one-line and one-break-line suppression, empty global post and no-meta no-op behavior, multiple enclosure meta rows, first-MIME-token normalization, filter replacement payloads, and filter cleanup; focused seeds 1 and 224 over 25 iterations each passed 300 checks, adjacent feed-rendering,feed-parsers,syndication,http,widgets smoke passed 265 checks, bootstrap smoke passed, and broad seed 224 smoke passed 4741 checks with 1 skip.
    +
    Previous focused addition: kses exact wp_kses_hair() parser fixture coverage exercises empty and prematurely terminated attributes, quoted/single/unquoted normalization, named/numeric/invalid entity recoding, duplicate first-wins behavior, malformed unclosed quotes, colon/dot and special-character attribute names, slash-separated attributes, spaces around equals, malformed equals patterns, URI protocol filtering, non-URI preservation, and custom allowed protocols; focused seed 1 over 25 iterations passed 27528 checks, seed 224 over 25 iterations passed 27537 checks, adjacent KSES/security/markup/shortcodes/formatting smoke passed 3528 checks, bootstrap smoke passed, and broad seed 224 smoke passed 4740 checks with 1 skip.
    +
    Previous focused addition: canonical-routing old-slug/date redirect coverage exercises wp_old_slug_redirect() over DB-stub-backed _wp_old_slug/_wp_old_date joins, date-qualified slug matching, direct old-date and slug-plus-date fallback lookups, paged/embed URL suffixes, posts last_changed cache-hit and invalidation behavior, old_slug_redirect_post_id capture/cancellation/replacement, old_slug_redirect_url cancellation, fail-closed gates for multi-type, hierarchical, non-404, and empty-name requests, and a narrow alias-aware wpdb SQL recognizer; seeds 1, 224, and 57123 over 25 iterations each passed 400 checks, adjacent canonical/request/template/content smoke passed 340 checks, bootstrap smoke passed, and broad seed 224 smoke passed 4739 checks with 1 skip.
    +
    Previous focused addition: request-lifecycle send_headers() exit-path coverage exercises feed conditional GET 304 exits for matching ETag/date validators, error status exits for explicit 403 query vars, status/header filter payloads before termination, absence of send_headers action after exit-required branches, and child-process isolation with structured JSON shutdown reporting; seeds 1 and 224 over 25 iterations each passed 325 checks, adjacent request/security/routing/rewrite/HTTP smoke passed 360 checks, bootstrap smoke passed, and broad seed 224 smoke passed 4738 checks with 1 skip.
    +
    Previous focused addition: appearance-media background remove redirect coverage exercises Custom_Background::take_action() remove-background nonce validation, same-host wp_safe_redirect() intent capture without headers, unsafe referer fallback routing, background image/thumb clearing, invalid nonce wp_die() fail-closed behavior, check_admin_referer observability, and filter/superglobal cleanup; focused seed 224 passed 12 checks, seeds 1 and 224 over 25 iterations each passed 300 checks, adjacent appearance/media/customizer/filesystem smoke passed 350 checks with no skips, and broad seed 224 smoke passed 4736 checks with 1 skip.
    +
    Previous focused addition: revisions-autosaves preview request dispatch exercises _show_post_preview() missing-query no-ops, valid nonce filter installation, casted preview_id nonce actions, the_preview autosave overlays, invalid nonce wp_die() 403 capture, direct wp_verify_nonce_failed observability without check_admin_referer, and superglobal/filter/user cleanup; focused seed 224 passed 14 checks, seeds 1 and 224 over 25 iterations each passed 350 checks, adjacent revisions/content/query/security/auth/request-lifecycle smoke passed 3550 checks with no skips, and broad seed 224 smoke passed 4735 checks with 1 skip.
    +
    Previous focused addition: canonical-routing attachment redirect coverage exercises redirect_canonical() with synthetic parent/attachment rows, enabled attachment-page attachment_id query redirects to the attachment permalink, disabled attachment pages redirecting public-parent attachments to the raw attachment URL, non-public parent fail-closed behavior, query cleanup, GUID fallback URL projection, option/filter cleanup, and DB-stub reset; focused seed 224 passed 15 checks, seeds 1 and 224 over 25 iterations each passed 375 checks, adjacent routing/content smoke passed 65 checks with no skips, and broad seed 224 smoke passed 4734 checks with 1 skip.
    +
    Previous focused addition: post-types status viewability coverage exercises is_post_status_viewable() with explicit public, private, custom, built-in emulation, internal, and protected status cases, generated status-property matrices, exact string/object lookup parity, unsanitized-name and non-string-scalar fail-closed paths, complete unregistered status objects, strict boolean filter return behavior, early-return filter bypass, and filter cleanup; focused seed 224 passed 13 checks, seeds 1, 224, and 49380 over 25 iterations each passed 325 checks, adjacent post/content/query/taxonomy/REST smoke passed 731 checks with no skips, and broad seed 224 smoke passed 4733 checks with 1 skip.
    +
    Previous focused addition: formatting file-name and username sanitizer coverage exercises exact Core fixtures for multi-extension munging, Unicode space normalization, invalid UTF-8 stripping, unnamed-file fallback, percent/entity/tag removal, strict username ASCII reduction, generated path/control/extension/user cases, idempotence, unsafe-character rejection, and filter cleanup; focused seed 224 passed 15 checks, seeds 1 and 224 over 25 iterations each passed 375 checks, adjacent formatting/security/identity/l10n smoke passed 1339 checks with no skips, and broad seed 224 smoke passed 4732 checks with 1 skip.
    +
    Previous focused addition: block-supports duotone coverage exercises support registration gates, legacy metadata migration, preset/custom/unset/global-style render paths, empty-content CSS generation, unsupported/no-attribute fail-closed paths, safe stored CSS/SVG/editor assets, Core preset slug normalization boundaries, and WP_Duotone static restoration; focused seed 224 passed 7 checks, seeds 1 and 224 over 25 iterations each passed 175 checks, adjacent block/editor/style smoke passed 66 checks with no skips, and broad seed 224 smoke passed 4731 checks with 1 skip.
    +
    Previous focused addition: image-metadata real Core fixture replay covers camera/timestamp/fraction fields, IPTC credit/caption/copyright/title, UTF-8 captions, keywords, orientation, accessibility alt text, metadata shape, final filter payloads, and filter restoration across shuffled JPEG fixtures; focused seed 224 passed 8 checks, seeds 1 and 224 over 25 iterations each passed 200 checks, adjacent media parser/chrome smoke passed 87 checks with no skips, standalone rest-media-attachments sanity passed 7 checks, and broad seed 224 smoke passed 4730 checks with 1 skip.
    +
    Previous focused addition: email make_clickable() coverage links full ACE/punycode final labels without leaving partial xn anchors, keeps malformed final labels unlinked, and converts the old punycode-TLD partial-link skip into a failing assertion; focused seed 224 passed 1248 checks, seed 1 passed 1245 checks, adjacent email,mail,identity smoke passed 1260 checks with no skips, and broad seed 224 smoke passed 4729 checks with 1 skip.
    +
    Previous focused addition: block-templates direct-ID path containment guards get_block_file_template() and get_block_template() against traversal slugs that point at existing files outside the active theme template directories, converts the guarded skip into a failing assertion, preserves file enumeration confinement, and keeps template CPT queries short-circuited; focused seed 224 passed 9 checks, seeds 1 and 224 over 25 iterations each passed 225 checks with no skips, adjacent block/theme/REST smoke passed 84 checks with no skips, and broad seed 224 smoke passed 4728 checks with 2 skips.
    +
    Previous focused addition: admin-list-tables application-password Last IP coverage converts the hostile stored last_ip boundary into an escaping assertion, verifies the row contains esc_html() output, preserves the empty &mdash; fallback, and keeps the synthetic list-table fixture path no-DB; focused seed 224 passed 12 checks, seeds 1 and 224 over 25 iterations each passed 300 checks with no skips, adjacent admin/security smoke passed 78 checks with no skips, and broad seed 224 smoke passed 4727 checks with 3 skips.
    +
    Previous focused addition: classic-walkers nav-menu query coverage exercises direct _wp_ajax_menu_quick_search() post-type and taxonomy JSON responses, direct get-item JSON/markup branches, query-argument oracles for bounded quick search, post-type archive pseudo-items, post-type and taxonomy View All pagination, quick-search tab activation, escaped hostile labels/titles/terms, scoped posts_pre_query/terms_pre_query fixtures, and temporary object-type/filter cleanup; focused seed 224 passed 14 checks, seeds 1 and 224 over 25 iterations each passed 350 checks, adjacent admin/navigation/content/query smoke passed 729 checks with 1 existing skip, and broad seed 224 smoke passed 4726 checks with 4 skips.
    +
    Previous focused addition: admin-ajax Find Posts coverage exercises find_posts_div() modal output, escaped hostile found_action values, valid/empty/invalid wp_ajax_find_posts() request paths, public post-type query construction without attachments, query short-circuit filtering for hidden/attachment/nonmatching fixtures, escaped table rows for title/type/date/status output, nonce hook observability, and request/user/post-type/hook cleanup; focused seed 224 passed 11 checks, seeds 1 and 224 over 25 iterations each passed 275 checks, adjacent admin/content/query smoke passed 711 checks, and broad seed 224 smoke passed 4725 checks with 4 skips.
    +
    Previous focused addition: rest-directory-services block-directory response-shaping coverage exercises public REST dispatch _fields projection, explicit block title/icon mapping, direct installed-plugin link discovery through seeded plugin cache, plugins_api() request payloads, no-live-HTTP guards, and plugin-cache cleanup; focused seed 224 passed 6 checks, seeds 1 and 224 over 25 iterations each passed 150 checks, adjacent REST/directory/block/plugin smoke passed 97 checks with 1 existing skip, and broad seed 224 smoke passed 4724 checks with 4 skips.
    +
    Previous focused addition: core-block-render frontend list-style block coverage exercises server-rendered core/archives, core/categories, core/latest-posts, core/latest-comments, core/tag-cloud, and core/calendar callbacks with deterministic post/comment/term fixtures, archive/calendar cache seeding, dropdown script oracles, latest-posts category migration reinstallation across restored filter state, category hierarchy cache handling, comment/content modes, tag font units, hidden-calendar behavior, and posts/comments/terms filter cleanup; focused seed 224 passed 9 checks, seeds 1 and 224 over 25 iterations each passed 225 checks, adjacent block/navigation smoke passed 131 checks, and broad seed 224 smoke passed 4720 checks with 4 skips.
    +
    Previous focused addition: metadata REST meta field coverage exercises WP_REST_Meta_Fields registered-field discovery, custom exposed names, schema generation for single/multi/object meta, recursive object additionalProperties defaults, default and prepared values, associative meta parameter validation, denied updates, schema validation failures, capability-gated updates, multi-value replacement, null/empty-array reset semantics, subtype/capability filter cleanup, and in-memory metadata readback; focused seed 224 passed 12 checks with no skips, seeds 1 and 224 over 25 iterations each passed 300 checks with no skips, adjacent metadata/REST/content/options/auth/capabilities smoke passed 410 checks, and broad seed 224 smoke passed 4719 checks with 4 skips.
    +
    Previous focused addition: plugin-theme-lifecycle REST plugin create/install coverage exercises WP_REST_Plugins_Controller::create_item() with generated ZIP packages, mocked plugins_api() responses, direct filesystem transport, missing-plugin 404 mapping, generic API 500 mapping, inactive install response and file-state oracles, active install and activation state, broad activation permission denial, plugin-specific activation denial after install, bounded upgrader_pre_download package copies, and temporary suspension/restoration of core updater hooks that would otherwise escape into update-table checks; focused seed 224 passed 14 checks with no skips, seeds 1 and 224 over 25 iterations each passed 350 checks with no skips, adjacent REST/upgrader/filesystem smoke passed 270 checks, and broad seed 224 smoke passed 4718 checks with 4 skips.
    +
    Previous focused addition: template-links adjacent image link coverage exercises exact previous/next image text anchors, first/last attachment edge behavior, echo wrappers, generated image markup for sibling attachments, escaped alt text, and dynamic previous_image_link/next_image_link filter payload cleanup; focused seed 224 passed 14 checks, seeds 1 and 224 over 25 iterations each passed 350 checks, adjacent media/template smoke passed 300 checks, bootstrap smoke passed, and broad seed 224 smoke passed 4743 checks with 1 skip.
    +
    Previous focused addition: error-protection process-control subprocess coverage exercises wp_register_fatal_error_handler(), WP_Recovery_Mode_Link_Service::handle_begin_link(), and WP_Recovery_Mode::handle_exit_recovery_mode() in a child PHP process; the oracles assert readable fatal-error handler drop-in shutdown execution, valid recovery begin-link key consumption and redirect, invalid begin-link WP_Error dies without mutation, inactive exit safe redirects, invalid active exit nonce 403 dies before cleanup, valid active exit cleanup of paused extensions and rate limits, no stray output before JSON reporting, and parent-process isolation; focused seed 224 passed 12 checks with no skips, seeds 1 and 224 over 25 iterations each passed 300 checks with no skips, adjacent process-control smoke passed 41 checks, and broad seed 224 smoke passed 4717 checks with 4 skips.
    +
    Previous focused addition: admin-list-tables direct link-manager and post-comments subclass coverage exercises WP_Links_List_Table and WP_Post_Comments_List_Table without live database access; the oracles assert bookmark cache/query argument handling, link-category term fixtures, legacy link_manager_enabled capability gating, link row IDs/edit-delete nonce URLs/current-category output/custom-column dispatch, compact hidden post-comments table rendering, fixed post-comments per-page behavior, post-scoped comment queries, row actions, class/column contracts, escaped views, and filter/cache restoration; focused seed 224 passed 11 checks with 1 documented current-core skip, seeds 1 and 224 over 25 iterations each passed 275 checks with 25 current-core skips, adjacent admin/bookmark/comment smoke passed 520 checks with 1 skip, and broad seed 224 smoke passed 4716 checks with 5 skips.
    +
    Previous focused addition: core-block-render server-side navigation block coverage exercises explicit-inner-block core/navigation rendering with dynamic navigation links, submenus, home links, and page lists; the oracles assert escaped custom-link labels/descriptions/attributes, submenu visibility migration from openSubmenusOnClick to submenuVisibility, responsive overlay controls, Interactivity API directives and view-module enqueueing, duplicate ariaLabel uniqueness, synthetic page-list active/ancestor classes, self-reference fail-closed behavior, no content mutation, and WP_Navigation_Block_Renderer static restoration; focused seed 224 passed 8 checks, seeds 1 and 224 over 25 iterations each passed 200 checks, adjacent block/navigation smoke passed 130 checks, and broad seed 224 smoke passed 4715 checks with 5 skips.
    +
    Previous focused addition: community-events admin AJAX wrapper coverage exercises wp_ajax_get_community_events() nonce-protected request setup, slashed/unslashed location and timezone inputs, captured JSON success/error envelopes, no ttl leak, trimmed events with WordCamp pinning, user-location persistence on initial IP and manual search, same-IP no-write preservation, API-error no-write behavior, targeted Community Events transient isolation, and full request/global/filter restoration; focused seed 224 passed 10 checks, seeds 1 and 224 over 25 iterations each passed 250 checks, adjacent AJAX/dashboard/HTTP/user-preference smoke passed 54 checks, and broad seed 224 smoke passed 4714 checks with 5 skips.
    +
    Previous focused addition: utility-internals non-default token-map export coverage fixes WP_Token_Map::to_array() prefix reconstruction for key lengths other than 2, then exercises deterministic key-length 1 and 3 maps with short and long shared-prefix tokens, exact contains()/read_token() agreement, order-insensitive export equivalence, no embedded-NUL exported keys, precomputed-table export equivalence, and precomputed_php_source_table() key-length emission; focused seed 224 passed 8 checks, seeds 1 and 224 over 25 iterations each passed 200 checks, adjacent utility smoke passed 53 checks, and broad seed 224 smoke passed 4713 checks with 5 skips.
    +
    Previous focused addition: feed-parsers RDF and fetch_feed() orchestration coverage exercises Magpie RSS 1.0/RDF root detection, rdf:about item attributes, namespace-aware dc:creator and content:encoded preservation, image/textinput parsing, empty URL handling, one-item array normalization, multi-feed RSS/Atom merge behavior, multi-feed error aggregation, wp_feed_options and cache-lifetime filter payloads, deterministic repeated fetches through the cache-enabled path, preempted local HTTP responses, scoped charset setup, no live network attempts, and full filter/state restoration; focused seed 224 passed 11 checks, seeds 1 and 224 over 25 iterations each passed 275 checks, adjacent feed/syndication/widget smoke passed 52 checks, and broad seed 224 smoke passed 4712 checks with 5 skips.
    +
    Previous focused addition: update-install-upgrader plugin/theme automatic-update notification coverage exercises protected WP_Automatic_Updater::after_plugin_theme_update() and send_plugin_theme_email() through a tiny synthetic subclass, without calling updater run loops or real update paths; the success and fail-closed paths assert success/fail/mixed classification, duplicate failure suppression through auto_plugin_theme_update_emails, success clearing of stale failure versions, mixed success/failure persistence, notification-filter suppression, exact auto_plugin_theme_update_email type ordering, captured email subject/body content for plugin/theme names and management URLs, intercepted wp_mail(), restored option/filter state, and no network attempts; focused seed 224 passed 14 checks, seeds 1 and 224 over 25 iterations each passed 350 checks, adjacent plugin/theme/upgrader smoke passed 58 checks with 1 existing skip, and broad seed 224 smoke passed 4710 checks with 5 skips.
    +
    Previous focused addition: wxr-export edge/resilience coverage exercises export_wp() content fallback and nested WXR helpers through the existing isolated child process; the success paths assert non-exportable post type exclusion, explicit non-exportable content fallback to posts plus reachable attachments, invalid content fallback to the exportable post type set, null post/term/comment meta serialization as empty WXR values, approved-comment emission with spam exclusion, get_comment-filtered null omission without empty wp:comment nodes, nav-menu terms only in full exports, associative-array wpdb::prepare() handling in the WXR double, and parent/child state cleanup; focused seed 224 passed 11 checks, seeds 1 and 224 over 25 iterations each passed 275 checks, adjacent wxr-export,import-diff,content smoke passed 36 checks, and broad seed 224 smoke passed 4709 checks with 5 skips.
    +
    Previous focused addition: xmlrpc authenticated post write-method coverage exercises wp_xmlrpc_server::wp_newPost(), wp_editPost(), and wp_deletePost() against in-memory user/post fixtures; the success and fail-closed paths assert auth failure and capability denial without mutation, caller-supplied ID rejection, unknown-status draft fallback, IXR date conversion, stale edit conflicts, valid edit preservation of omitted fields, post-type change rejection, delete missing/trash behavior, scoped xmlrpc_call/xmlrpc_wp_insert_post_data hooks, no HTTP attempts, option/runtime/global cleanup, and a scoped rewrite stub for post slug generation; focused seed 224 passed 12 checks, seeds 1 and 224 over 25 iterations each passed 300 checks, adjacent XML-RPC/content smoke passed 1162 checks, and broad seed 224 smoke passed 4708 checks with 5 skips.
    +
    Previous focused addition: privacy-admin-requests admin form and default processor coverage exercises _wp_personal_data_handle_actions(), _wp_personal_data_cleanup_requests(), and Core's default export/erasure AJAX processor filters through captured admin request paths; the success paths assert pending export creation with confirmation mail, username-resolved confirmed erasure creation, invalid requester rejection, retry key regeneration, expired-pending cleanup, raw export accumulation without file/mail side effects, erasure completion metadata/action dispatch, scoped filter cleanup, and narrow in-memory WP_Query support for request-title/date bounds; focused seed 224 passed 7 checks, seeds 1 and 224 over 25 iterations each passed 175 checks, adjacent privacy/admin/mail smoke passed 1314 checks with 1 skip, and broad seed 224 smoke passed 4707 checks with 5 skips.
    +
    Previous focused addition: admin-list-tables theme install list-table replay coverage keeps the first in-process WP_Theme_Install_List_Table::prepare_items() API-argument invariant intact, then replays later iterations by seeding concrete table items, pagination, view globals, theme fixtures, allowed tags, and capability/action filters without re-requiring theme-install.php; the replay asserts install/update/latest-installed/newer-installed action states, custom action filter slugs, screenshots, preview metadata, descriptions, views, display output escaping, capability gates, and theme-root/global/server/filter cleanup; focused seed 224 passed 10 checks with 1 documented current-core skip, seeds 1 and 224 over 25 iterations each passed 250 checks with 25 current-core skips, adjacent admin smoke passed 42 checks with 1 skip, and broad seed 224 smoke passed 4705 checks with 5 skips.
    +
    Previous focused addition: rest-controllers block pattern local theme file-loader coverage exercises WP_Theme::get_block_patterns(), _register_theme_block_patterns(), and WP_REST_Block_Patterns_Controller::get_items() against a scoped temp block theme; the success paths assert active-theme path resolution, patterns/*.php scanning, required-header rejection, comma-list/int/bool header normalization, pattern cache TTL/type behavior, lazy filePath content loading, category migration, duplicate slug preservation, REST field projection, local no-source behavior, and temporary hook/cache/file cleanup; focused seed 224 passed 14 checks, seeds 1 and 224 over 25 iterations each passed 350 checks, adjacent block/REST smoke passed 57 checks with 1 skip, and broad seed 224 smoke passed 4705 checks with 5 skips.
    +
    Previous focused addition: rest-controllers block pattern remote-loader coverage exercises WP_REST_Block_Patterns_Controller::get_items() with intercepted pattern-directory REST responses for core, featured, and theme loaders; the success paths assert internal request parameters, one-shot loader dispatch, source values, snake-case field normalization, category migration, duplicate suppression, theme-json slug loading through a scoped temp file, remote-load filter gates, and temporary hook/file cleanup; focused seed 224 passed 13 checks, seeds 1 and 224 over 25 iterations each passed 325 checks, adjacent block/REST smoke passed 56 checks with 1 skip, and broad seed 224 smoke passed 4704 checks with 5 skips.
    +
    Previous focused addition: rest-site-editor template mutation lifecycle coverage exercises WP_REST_Templates_Controller create, custom update, theme-template delete rejection, theme-template promotion/reset, non-force trash, already-trashed, and force-delete paths for templates and template parts; the success paths assert 201 Location headers, stored post fields, origin metadata, theme and area taxonomy assignments, REST insert hooks, wp_after_insert_post payloads, trash slug preservation, previous-response data, and temporary hook/filter cleanup; focused seed 224 passed 15 checks, seeds 1 and 224 over 25 iterations each passed 375 checks, adjacent Site Editor/template smoke passed 66 checks with 1 skip, and broad seed 224 smoke passed 4703 checks with 5 skips.
    +
    Previous focused addition: customizer-persistence save AJAX envelope coverage exercises unauthenticated, not-preview, invalid nonce, malformed changeset JSON, bad status, bad date, and successful draft save paths through captured wp_send_json_* responses; the success path asserts stored changeset content, JS-prepared setting validities, customize_save_response filtering, edit-lock ownership, and filter cleanup; focused seed 224 passed 8 checks, seeds 1 and 224 over 25 iterations each passed 200 checks, adjacent Customizer/admin smoke passed 44 checks, and broad seed 224 smoke passed 4702 checks with 5 skips.
    +
    Previous focused addition: plugin-theme-lifecycle REST plugin write/delete coverage exercises capability-denied update gates without mutation, REST status transitions from inactive to active and back, active-plugin delete rejection, inactive temp-plugin deletion through direct filesystem transport, previous-response data, active option cleanup, and plugin cache refresh; focused seed 224 passed 13 checks, seeds 1 and 224 over 25 iterations each passed 325 checks, adjacent plugin/REST/filesystem smoke passed 60 checks with 1 skip, and broad seed 224 smoke passed 4701 checks with 5 skips.
    +
    Previous focused addition: rest-media-attachments raw sideload coverage exercises client-side media-processing sideloads for generated subsize files and original-image metadata, non-image/PDF rejection, exact upload bytes in isolated temp roots, _fields response projection, Location headers, preserved base attachment files, and conversion/capability filter cleanup; focused seed 224 passed 7 checks, seeds 1 and 224 over 25 iterations each passed 175 checks, adjacent media/REST smoke passed 61 checks, and broad seed 224 smoke passed 4700 checks with 5 skips.
    +
    Previous focused addition: privacy-admin-requests bulk resend coverage exercises mixed valid/invalid privacy request selections, nonce-gated resend bulk action accounting, regenerated confirmation keys, stored hash validation, intercepted confirmation emails, subject/content/header filters, placeholder replacement, and temporary filter cleanup; focused seed 224 passed 5 checks, seeds 1 and 224 over 25 iterations each passed 125 checks, adjacent privacy/admin/mail smoke passed 1312 checks with 1 skip, and broad seed 224 smoke passed 4699 checks with 5 skips.
    +
    Previous focused addition: admin-options-submission pending admin email coverage exercises changed-address new_admin_email submissions through the documented dynamic option hooks, pending adminhash creation, intercepted confirmation mail, same-current and invalid-email no-mail/no-hash branches, and temporary hook/filter cleanup; focused seed 224 passed 6 checks, seeds 1 and 224 over 25 iterations each passed 150 checks, adjacent admin options/mail/email/security smoke passed 1304 checks with 1 skip, and broad seed 224 smoke passed 4699 checks with 5 skips.
    +
    Previous focused addition: post-embeds custom provider and autoembed cache coverage landed through 6a78c3ec42, exercising public wp_oembed_add_provider()/wp_oembed_remove_provider(), no-network wp_oembed_get(), provider fetch URL dimensions/DNT/original URL, WP_Embed::shortcode() post-meta cache writes, repeated cache hits, standalone/paragraph autoembed replacement, inline URL preservation, and provider/filter/state restoration; focused seed 224 passed 7 checks, seeds 1 and 224 over 25 iterations each passed 175 checks, adjacent post-embeds/REST/HTTP/content/template/media smoke passed 71 checks, and broad seed 224 smoke passed 4696 checks with 5 skips.
    +
    Previous focused addition: security referer retrieval and native password-hash migration coverage landed through 764df53851, exercising raw/safe/original referer precedence and restoration, current-request self rejection, external redirect rejection, native unprefixed bcrypt verification, wrong-password and mutated-hash rejection, and forced WordPress rehash migration signals; focused seed 224 passed 15 checks, seeds 1 and 224 over 25 iterations each passed 375 checks, adjacent security/request/formatting/routing/HTTP smoke passed 66 checks, and broad seed 224 smoke passed 4695 checks with 5 skips.
    +
    Earlier focused addition: shortcodes HTML-attribute boundary coverage landed through ec3e228603, exercising accepted quoted/unquoted attribute rendering, KSES-rejected onclick and unknown attributes that still execute callbacks before restoring literals, ignore_html delimiter placeholders, escaped shortcode attributes/tag names, comments, CDATA, callback-count accounting, and media image-context hook restoration; focused seed 224 passed 22 checks, seeds 1 and 224 over 25 iterations each passed 550 checks, adjacent markup/blocks/KSES smoke passed 1163 checks, and broad seed 224 smoke passed 4693 checks with 5 skips.
    +
    Earlier focused addition: network-media upload-bits coverage landed through ece71f4765, exercising wp_upload_bits() exact-byte writes into isolated filtered upload roots, year/month upload paths, virtual pre_wp_unique_filename_file_list subsize collisions, wp_upload_bits/wp_unique_filename/wp_handle_upload filter payloads, filter-error short-circuits, empty-name pre-filter failures, and filter restoration; focused seed 224 passed 1 aggregate row with 299 cases, 1667 API calls, and 2087 assertions, seeds 1 and 224 over 25 iterations each passed 25 aggregate rows, adjacent media/filesystem smoke passed 55 checks, and broad seed 224 smoke passed 4692 checks with 5 skips.
    +
    Earlier focused addition: rest-directory-services directory-service oracle coverage landed through e581555577, exercising pattern-directory WordPress.org query allowlisting, derived-argument overwrites for client-supplied proxy parameters, and URL-details malformed/unclosed head metadata fallback precedence; focused seed 224 passed 5 checks, seeds 1 and 224 over 25 iterations each passed 125 checks, adjacent REST/HTTP/media smoke passed 56 checks, and broad seed 224 smoke passed 4692 checks with 5 skips.
    +
    Earlier focused addition: error-protection pure process-boundary-adjacent coverage landed through 61e22a3455, exercising synthetic fatal-handler handle() orchestration and recovery-link begin-link early-return branches without registering shutdown handlers, redirecting, dying, or performing loopbacks; focused seed 224 passed 11 checks with 1 skip, seeds 1 and 224 over 25 iterations each passed 275 checks with 25 skips, adjacent error/Site Health/HTTP/filesystem smoke passed 55 checks with 1 skip, and broad seed 224 smoke passed 4692 checks with 5 skips.
    +
    Earlier focused addition: script-loader-runtime emoji loader-asset skip retirement landed through e52e60ef77, materializing the generated wp-emoji-loader.js asset from the canonical source during the fuzz case and removing it afterward; focused seed 224 passed 15 checks, seeds 1 and 224 over 25 iterations each passed 375 checks, adjacent script/assets smoke passed 69 checks, and broad seed 224 smoke passed 4690 checks with 5 skips.
    +
    Latest focused validations: auth-flow direct wp_login_form() rendering coverage passed focused seeds 1, 224, and 57123 at 300 checks each, adjacent auth/security/request smoke at 240 checks, adjacent REST-auth smoke at 285 checks, and broad seed 224 at 4777 checks with 1 skip; cron public wp_cron() wrapper coverage passed focused seeds 1, 224, and 57123 at 2275 checks each, adjacent cron/system smoke at 750 checks, adjacent cron/state smoke at 710 checks, and broad seed 224 at 4776 checks with 1 skip; site-health-debug bounded WP_Debug_Data::debug_data() full-scan coverage passed focused seeds 1 and 224 at 8 checks with 1 skip each, seed 224 over 25 iterations at 200 checks with 25 skips, adjacent site-health-debug,site-health,http,environment-load,cron at 138 checks with 1 skip, and broad seed 224 at 4668 checks with 25 skips; admin-list-tables theme install selected-mode matrix at 801 passed and 299 skipped, admin-list-tables plugin install list-table matrix at 800 passed and 200 skipped, style block-support wrapper serialization matrix at 1400 passed and 100 skipped, reviewer seed 11704 at 350 passed and 25 skipped, admin-media-chrome caption/send-to-editor helper matrix at 1000 passed and 200 skipped, admin-list-tables base pagination/per-page output matrix at 700 passed and 200 skipped, site-health persistent object cache threshold/filter direct-test matrix at 1100 passed, icons-connectors connector plugin install/activation module-data status matrix at 700 passed, block-widgets retrieve/remap/lost-widget matrix at 900 passed, cron spawn request/lock boundary matrix at 8700 passed, script-loader-runtime concat loader URL/exclusion/sourceURL matrix at 1200 passed and 200 skipped, taxonomy-relationships multi-object cache priming and cleaning matrix at 1000 passed, rest-object-controllers collection parameter request-pipeline matrix at 1000 passed and 200 skipped, media-remote Content-Disposition/content-type filename matrix at 700 passed, network-media generated filename collision/alternate-extension oracle at 100 passed, install-schema missing-table creation/replay at 1100 passed, admin-screen screen-meta rendering lifecycle at 900 passed, frontend-features disabled speculation lifecycle/load-action isolation at 800 passed, media-ingest override/error semantics at 1000 passed, and template-links date/author archive URL helpers at 1200 passed; historical component validations are tracked in the tables below.
    +
    This turn: identity current-user lifecycle helpers and review fixes at 100 passed with 22 cases and 887 checks per iteration plus a 8608 passed, 68 skipped broad run, admin-workflows direct referer-field/original-referer helpers and review fixes at 600 passed and 200 skipped, import-diff importer base helper contracts and review fixes at 900 passed, network-media MIME catalog helpers, upload iframe URL/filter contracts and review fixes, upload policy, custom MIME filetype helpers, and unique filename callback/filter contracts at 100 passed, block-editor-adjuncts selected-post content helper coverage and review fixes at 700 passed, default-widgets review fixes plus follow-up query-runtime, DISTINCT term ID, and nav-menu filter cleanup fixes at 1000 passed with wpdb-sql at 800 passed and shared stub smoke at 2555 passed and 10 skipped, account-security password-reset key lifecycle at 700 passed, identity review at 3 passed with no actionable findings, registries icon file recovery at 600 passed, Unicode email malformed variants, UTF-8 local-part oracles, generated local-part alias/search invariants, and review fixes at 6182 passed and 5 skipped after the 123765-pass follow-up, post-types status-name sanitization at 1100 passed, request-lifecycle status/nocache header contracts at 1100 passed, block-widgets widget ID parsing and cleanup at 800 passed, blocks nested attribute round trips at 700 passed, appearance-media custom-logo helpers at 700 passed and 100 skipped, community-events invalid-IP request minimization at 900 passed, revisions-autosaves retention pruning at 1000 passed and 300 skipped, capabilities site-scoped cap-key contracts at 1000 passed, identity avatar, WP_User field, and username/email existence helpers at 100 passed, comments reply/cancel links at 44800 passed, template-links adjacent relation links at 1100 passed, error-protection recovery-mode handle-error gates at 900 passed and 100 skipped, media-metadata replacement metadata oracles at 800 passed, abilities category-unregister contracts at 800 passed, user-preferences Screen Options visibility caching at 1100 passed and 100 skipped, admin-list-tables network themes row-action coverage at 600 passed and 200 skipped, feed-rendering self-link request URI coverage at 1000 passed, frontend-features view-transition timing at 700 passed, feed-parsers SimplePie file adapter coverage at 900 passed, classic-walkers nav-menu matrix at 1200 passed and 100 skipped, and discovery robots public/private helper matrix at 900 passed.
    +
    Continuation: style block-support wrapper serialization and review fixes passed seed 1 and seed 224 focused checks at 1400 passed with 100 skips, reviewer seed 11704 passed 350 checks with 25 skips, exact render_block hook restoration repros passed, final review found no actionable findings, and the broad run passed 8661 checks with 66 skips. admin-media-chrome caption/send-to-editor helper matrix and review fixes passed 1000 focused checks with 200 skips, reviewer seed 224 passed 1000 checks with 200 skips and no actionable findings after fixes, and the broad run passed 8659 checks with 66 skips. admin-list-tables base pagination/per-page output matrix and review fixes passed 700 focused checks with 200 skips, reviewer seed 224 passed 700 checks with 200 skips and no actionable findings, and the broad run passed 8657 checks with 66 skips. site-health persistent object cache threshold/filter direct-test matrix and review fixes passed 1100 focused checks, reviewer seed 224 passed 1100 checks with no actionable findings after fixes, and the broad run passed 8655 checks with 66 skips. icons-connectors connector plugin install/activation module-data status matrix passed 700 focused checks, reviewer found no actionable findings, and the broad run passed 8653 checks with 66 skips. block-widgets retrieve/remap/lost-widget matrix passed 900 focused checks, reviewer seed 224 passed 900 checks with no actionable findings, and the broad run passed 8651 checks with 66 skips. cron spawn request/lock matrix and review fixes passed 8700 focused checks, reviewer seed 224 passed 8700 checks with no actionable findings, and the broad run passed 8649 checks with 66 skips. script-loader-runtime concat loader URL/exclusion/sourceURL matrix and review fixes passed 1200 focused checks with 200 skips, reviewer seed 224 passed 1200 checks with 200 skips and no actionable findings, and the broad run passed 8647 checks with 66 skips. taxonomy-relationships object-term cache priming/cleaning matrix passed 1000 focused checks, reviewer seed 224 passed 1000 checks with no actionable findings, and the broad run passed 8645 checks with 66 skips. rest-object-controllers collection parameter matrix and request-pipeline review fixes passed 1000 focused checks with 200 skips, reviewer seed 224 passed 1000 checks with 200 skips and no actionable findings, and the broad run passed 8643 checks with 66 skips. media-remote filename derivation matrix passed 700 focused checks, reviewer seed 224 passed 700 checks with no actionable findings, and the broad run passed 8641 checks with 66 skips. network-media generated filename collision/alternate-extension oracles and review fixes passed 100 focused checks, reviewer seed 224 passed 100 checks, and the broad run passed 8639 checks with 66 skips. install-schema missing-table creation/replay and review fixes passed 1100 focused checks, reviewer seed 224 passed 1100 checks, and the broad run passed 8639 checks with 66 skips. admin-screen screen-meta/help-sidebar/screen-reader lifecycle and review fixes passed 900 focused checks, the priority-20 submit-filter repro passed, and the broad run passed 8637 checks with 66 skips. frontend-features speculation lifecycle/load-action isolation and review fixes passed 800 focused checks, the contamination repro failed as expected, and the broad run passed 8635 checks with 66 skips. media-ingest upload override/error semantics and review fixes passed 1000 focused checks plus 8633 broad checks with 66 skips. template-links archive URL helpers and review fixes passed 1200 focused checks plus 8631 broad checks with 66 skips. post-types duplicate registration cleanup fixed stale first-registration query vars and meta-cap mappings, then passed 1200 focused checks, 8629 broad checks with 66 skips, and the post type PHPUnit suite.
    +
    Continuation: request-lifecycle feed header variant coverage landed through 61a0a1d628 with review gaps fixed through b1eea51e9a, covering default-feed alias content type mapping, post-only and comments-feed Last-Modified/ETag selection, stale conditional request headers that must not emit 304 in-process, exact default-feed and post-modified filter payloads, preserved timeinfo cache state, and hook restoration; focused seeds passed 12/0, 12/0, and 60/0, and the broad smoke passed 9125 checks with 62 skips.
    +
    Continuation: rest-site-editor bounded REST dispatch coverage landed through 6002f40de7 with review gaps fixed through 63aadcb347, covering registered template and template-part item routes, single-slash route ID sanitization back to canonical double-slash template IDs, denied access before template lookup with the lookup observer already installed, exact bounded pre_get_block_template lookup logs, exact _fields response-key filtering, /wp/v2/templates/lookup fallback route wiring, empty higher-priority fallback retry behavior, no-fallback empty-object responses, and filter/server restoration; live edit-site export now runs in an isolated child process and asserts structured stdout metadata, streamed ZIP bytes, direct ZIP inspection, export filter logs, temp ZIP unlinking, and parent/child state separation; focused seeds passed 13/1 and 65/5, REST-family smoke passed 88/10, and the prior broad smoke passed 9129 checks with 62 skips.
    +
    Continuation: admin-options-submission no-exit options update-flow coverage now exercises registered Settings API allowlists/sanitizers, settings error transients, General Settings date/time/timezone normalization, pending admin email hash/confirmation-mail semantics, legacy page_options, nonce/capability/unknown-page failure paths, redirect capture, slashed request handling, and global/filter/option restoration; focused seeds passed 6/0 and 150/0, adjacent admin/options/mail/email/security smoke passed 1304/1, and the broad smoke passed 4699 checks with 5 skips.
    +
    Continuation: customizer-nav-widgets-requests now exercises forced Customizer nav_menus/widgets component loading, cap-gated AJAX hook registration, menu available/search request envelopes, auto-draft insertion and publish cleanup, dynamic nav-menu/menu-item setting filters, placeholder menu remaps into theme locations and widget_nav_menu, preview HMAC/export placement data, signed widget instance round trips and tamper rejection, widget update AJAX gates, selective-refresh wrapper metadata, partial rendering, and strict option/content/global restoration; focused seeds passed 8/0 and 200/0, adjacent Customizer/navigation/widget/admin smoke passed 560/0, and the broad smoke passed 4623/28.
    +
    Continuation: admin-media-chrome legacy wp_media_attach_action() coverage now exercises attach/detach redirect exits in isolated child processes, mixed media IDs, parent capability denial, parent-zero no-op returns, raw post-parent UPDATE effects through the in-memory DB stub, hook/cache events, referer cleanup, and no stray child output; focused seeds passed 13/0 and 325/0, ordered adjacent media smoke passed 116/0, bootstrap smoke passed, and the broad smoke passed 4872/1. media-image-edit-requests admin media image edit request coverage covers generated image-editor AJAX request matrices, save/restore metadata, crop envelopes, and media sub-size AJAX boundaries through temp upload roots and a deterministic fake editor.
    +
    Continuation: site-health-debug now exercises full WP_Debug_Data::debug_data() section assembly in an isolated child process with a generated wpdb double, fake successful WordPress.org response, bounded Ghostscript executable, exact single-site section order, empty plugin/theme buckets, path-size loading placeholders, formatted custom/private field oracles, and child hook/PATH cleanup; focused seeds passed 8/1 and 8/1, seed 224 over 25 iterations passed 200/25, adjacent Site Health/environment/HTTP smoke passed 138/1, and the broad smoke passed 4668/25.
    +
    Continuation: rest-controllers now exercises WP_REST_Plugins_Controller and WP_REST_Themes_Controller route/schema, collection parameter, sanitizer, and permission-gate contracts without plugin/theme lifecycle filesystem reads; focused seeds passed 12/0 and 12/0, adjacent REST/widget/admin smoke passed 56/2, and the broad smoke passed 4667/26.
    +
    Continuation: rest-widgets-sidebars now exercises REST widget route/schema registration, public show_in_rest read filtering, widget type sorting/projection and encode_form_data() hashes, isolated /widget-types/{id}/render iframe preview dispatch with fail-closed permission/invalid-id paths, parent-safe IFRAME_REQUEST containment, text-widget preview hooks, route-dispatched widget create/get/update/delete paths, sidebar reordering, invalid widget/sidebar schema failures before mutation, legacy form-data updates, soft/force delete hooks, direct and route-dispatched HEAD behavior, default-filtered sidebar item/collection projections, widget links, target hints, Allow headers, response envelopes, prepare-filter locality, current-core missing-sidebar diagnostics, hook payload timing, and widget update-guard reset isolation; focused runs passed 13/0, 325/0, and 65/0, adjacent widget/REST smoke passed 112/0, syntax/diff checks passed, and the broad smoke passed 5087/1.
    +
    Continuation: block-supports now exercises core block-support registration, direct callback output, wrapper merge and skip-serialization gates, background/dimensions/visibility/position/layout render filters, elements and custom CSS render-data filters, state-style helpers, auto-generated control markers, duotone preset/custom/unset/global-style render paths, safe stored CSS/SVG/editor assets, and registry/global/style/duotone static restoration; focused seeds passed 7/0 and 175/0, adjacent block/editor/style smoke passed 66/0, and the broad smoke passed 4731/1.
    +
    Continuation: admin-edit-metaboxes now exercises classic edit-screen publish, taxonomy, content/comment/custom-field, author override dropdown, page attributes, post format, attachment, thumbnail, link, XFN, advanced link, and default meta-box registration branches; focused seed 224 passed 7/0, focused seeds 1, 224, and 57123 over 25 iterations each passed 175/0, adjacent admin/content/taxonomy/comment/link smoke passed 598/0, bootstrap smoke passed, and the broad smoke passed 4833/1.
    +
    Continuation: rest-media-attachments REST attachment write coverage now exercises Content-Disposition filename parsing, raw upload validation failures, raw body create_item() success through temp upload roots and attachment postmeta, explicit permission gates, client-side media-processing route/argument contracts, metadata finalization filters, _fields response projection, edit-media fail-closed paths, and state restoration; focused seeds passed 6/0 and 150/0, adjacent REST/media/filesystem smoke passed 495/25, and the broad smoke passed 4604 checks with 28 skips.
    +
    Continuation: post-embeds WordPress-as-provider oEmbed coverage now exercises embeddability predicates, visibility fail-closed behavior, width clamps, rich iframe and thumbnail response conversion, plain/pretty/path-conflict embed URLs, discovery link output, direct WP_oEmbed_Controller item responses, same-site pre_oembed_result short-circuiting, narrow wpdb stub projection support for get_page_by_path(), and state restoration; focused seeds passed 5/0 and 125/0, adjacent embed/syndication/REST/content/template/media smoke passed 445/10, and the broad smoke passed 4598 checks with 28 skips.
    +
    Continuation: navigation-lifecycle DB-backed nav menu lifecycle coverage now also exercises navigation fallback creation from classic menus through WP_Navigation_Fallback and WP_Classic_To_Block_Menu_Converter, including disabled-create filters, primary-location priority over decoy menus, inserted wp_navigation post shape, parsed navigation-link/submenu block trees, sanitized custom-link attrs, post-type item identity, duplicate-free fallback reuse, query globals, and state restoration; focused seeds 1, 224, and 57123 each passed 225 checks, adjacent navigation/template/content/taxonomy smoke passed 490 checks, bootstrap smoke passed, and the broad smoke passed 4766 checks with 1 skip.
    +
    Continuation: core-block-render direct render-callback coverage now exercises representative dynamic core blocks for site identity, search/loginout, post context, and button/file/image markup transforms with strict option, cache, filter, global, and superglobal restoration; seed-224 expectation hardening now parses real pagination links and HTML attributes, focused seeds passed 7/0 and the combined 25-iteration admin/core replay passed 300/0, and the broad smoke passed 4585 checks with 28 skips.
    +
    Continuation: kses helper and block attribute coverage landed through 7f913eb4d5 with review gaps fixed through a2b8c3ff0e, e7f99aa17e, and 50de103325, covering exact low-level helper contracts for array lowercasing, quote-slash stripping, malformed attribute recovery, numeric/XML/HTML entity callbacks, serialized block attribute filtering via filter_block_content(), direct filter_block_kses() agreement, recursive filter_block_kses_value() key/value behavior, core/template-part tagName allowlisting, wp_pre_kses_block_attributes hook-path agreement, callback-order preservation, and run-level pre_kses hook-state restoration; focused seeds passed 1100/0, 1097/0, and 5507/0 after the final review fixes, Copernicus found no actionable findings, and the broad smoke passed 9139 checks with 62 skips.
    +
    Continuation: content post content pagination coverage landed through 89ec6030b2, covering list and singular get_the_content() more-tag behavior, , generated pages, protected-post password forms, the_content() filter and CDATA escaping, wp_link_pages() numbered/next/single output and final filters, content pagination hooks, post-cache snapshots, and global/superglobal/cookie/filter restoration; focused seeds passed 15/0, 15/0, and 750/0, neighbor smoke passed 3355/0, Nash found no actionable findings after fixes, and the broad smoke passed 9139 checks with 62 skips.
    +
    Continuation: state option-backed site transient coverage landed through b2b0c9cd6c, covering non-external-cache _site_transient_* option rows, value/timeout update paths, zero-expiration storage, manual timeout expiration cleanup, dynamic site-transient filters, hook cleanup, and external object cache flag restoration; focused seeds passed 19/0, 95/0, and 380/0, adjacent options smoke passed 160/0, Zeno found no actionable findings after fixes, and the broad smoke passed 9141 checks with 62 skips.
    +
    Continuation: cron pre_unschedule_hook coverage landed through db22a88992, adding short-circuit return contracts for zero, false-with-WP_Error, and WP_Error-without-WP_Error branches, preserving queued events, exact filter payloads, hook removal, and current-filter stack restoration; focused seeds passed 90/0, 450/0, and 1800/0, adjacent cron/options/state smoke passed 244/0, Singer found no actionable findings, and the broad smoke passed 9147 checks with 62 skips.
    +
    Continuation: mail invalid-From failure coverage landed through f9fb63e7ae, adding no-delivery oracles for early setFrom() failure, exact wp_mail_failed payloads including embeds, no send/phpmailer_init/success action, reusable PHPMailer cleanup before failure, and hook cleanup; focused seeds passed 11/0, 55/0, and 220/0, adjacent mail/email/privacy smoke passed 2561/2, the full wpMail PHPUnit file passed 32 tests and 87 assertions, Sartre found no actionable findings after fixes, and the broad smoke passed 9149 checks with 62 skips.
    +
    Continuation: hooks deprecated wrapper coverage landed through 9d0ad6c83f, covering no-callback fast paths, deprecated_hook_run payloads, debug-gated deprecated_hook_trigger_error suppression, apply_filters_deprecated()/do_action_deprecated() ref-array dispatch, by-reference mutation, all-hook visibility, counters, cloned hook-global restoration, and hostile preexisting callback isolation; focused seeds passed 12/0, 60/0, 240/0, and 1200/0, adjacent hook/options/plugin smoke passed 68/0, Pasteur found no actionable findings after fixes, and the broad smoke passed 9151 checks with 62 skips.
    +
    Continuation: admin-list-tables plugin install list-table coverage landed through 74d7c296f3 with review fixes through e95cf978f1, covering generated search API args for term/tag/author modes, install tabs/views, API result ordering and pagination totals, install/update/activate action rendering, compatibility notices, icon and description escaping, fail-closed plugin API short-circuiting, localized update-count script restoration, temp plugin fixture cleanup, and global/server/filter restoration; focused seeds passed 8/2, 200/50, and 800/200, adjacent admin/plugin/upgrader smoke passed 60/4, an exact pre-registered updates handle restoration probe passed, Kierkegaard found no remaining findings, and the broad smoke passed 9155 checks with 62 skips.
    +
    Continuation: admin-list-tables theme install list-table coverage landed through a71e18bea4 with review fixes through 09fcc5d910, covering one selected request mode per PHP process because core prepare_items() raw-requires theme-install.php, generated search/browse/feature API args, install tabs/views, result ordering and pagination totals, install/update/latest-installed/newer-installed action states, screenshot and description escaping, fail-closed theme API short-circuiting, theme-root option/cache cleanup, temp theme fixture cleanup, and global/server/filter restoration; focused seeds passed 9/2, 81/29, and 801/299, adjacent admin/plugin/upgrader smoke passed 91/8, exact theme-root option restoration probes passed, Kepler found no remaining findings, and the broad smoke passed 9156 checks with 63 skips.
    +
    Continuation: admin-dashboard Browser Happy coverage landed through 3f5772e8e7 with review fixes through f3527a20ce, covering generated Browse Happy HTTP request payloads, HTTPS endpoint selection, one-week site-transient TTLs, cache reuse, fail-closed WP_Error/non-200/invalid-JSON responses without raw transient option writes, insecure class preservation, regular and Internet Explorer nag rendering, no-network empty-user-agent behavior, cleanup, and Pascal re-review with no findings; focused seeds passed 10/1, 250/25, and 1000/100, adjacent dashboard/site-health/http smoke passed 64/2, and the broad smoke passed 9158 checks with 63 skips.
    +
    Continuation: email UTF-8 address-model coverage landed through 2a84783187, covering generated mixed-script local parts with combining marks, Unicode and Punycode domain views, WP_Email_Address getter invariants, ASCII/unicode round trips, is_email()/sanitize_email() filter-mode agreement, ASCII-mode rejection, reserved ACE-like and invalid xn-- labels, malformed UTF-8 warning capture, and Gauss review with no findings; focused seeds passed 1244/1, 12502/10, and reviewer 3741/3, adjacent email/mail/identity smoke passed 1256/1, and the broad smoke passed 9160 checks with 63 skips.
    +
    Continuation: multisite true lifecycle child coverage landed through 352a5bc4de, replacing the broad true-multisite sitemeta skip with an isolated optional real-DB subprocess for wp_insert_site(), wp_update_site(), wp_delete_site(), duplicate site_taken validation, get_site()/get_sites()/domain_exists() cleanup, explicit network-option and site-option alias sitemeta writes/cache semantics, hook event order, generated residue cleanup, and controlled missing-wp-tests-config.php or unreachable-DB skips; focused runs passed 17/1, 425/25, and 245/5, and the broad smoke passed 4663 checks with 28 skips.
    +
    Continuation: script-loader-runtime JIT localization coverage landed through 12f2cd8d7f with review fixes through 696162da10, replacing the missing-AUTOSAVE_INTERVAL skip with an isolated child-process oracle for autosaveL10n, mceViewL10n, and wordCountL10n, generated shortcode tags and tag-shaped JSON escaping, parent/child state restoration, and row-local subprocess capability skips; focused seeds passed 13/1 and 325/25, disabled-proc_open repro passed 12/2, adjacent assets/script-loader/blocks smoke passed 70/2, Confucius found no remaining findings, and the broad smoke passed 9162 checks with 61 skips.
    +
    Continuation: environment-load environment type matrix coverage landed through 7580167b9c with review fixes through 2047262604, replacing the full-matrix skip with isolated child-process WP_RUN_CORE_TESTS oracles for env-var values, allowed WP_ENVIRONMENT_TYPE constant overrides, invalid constant fail-closed behavior, parent/child env restoration, row-local subprocess capability skips, and failure on child stderr or stray output; focused seeds passed 17/0 and 425/0, the constant-precedence repro passed 17/0, disabled-proc_open repro passed 16/1, adjacent environment/http/site-health-debug smoke passed 70/4, Hegel found no remaining findings, and the broad smoke passed 9164 checks with 59 skips.
    +
    Continuation: template-hierarchy isolated comments_template() coverage landed through d1916878e9 with review fixes through 0d7f1e98c9, replacing the guarded comments-template skip with a child-process oracle for parent-safe COMMENTS_TEMPLATE definition, child-over-parent default comments.php, generated custom comments files, exact comments_template filter paths, short-circuited synthetic comment queries, local $comment_args, cloned hook snapshots, child stderr/stray-output failures, and row-local subprocess capability skips; focused seeds passed 10/0 and 250/0, disabled-proc_open repro passed 9/1, adjacent template/content/comments smoke passed 946/0, James found no remaining findings, and the broad smoke passed 9166 checks with 57 skips.
    +
    Continuation: email Unicode authentication coverage landed through 5d018bedd3, covering stored canonical Unicode account email login via wp_authenticate_email_password(), wrong-password failures, exact alias local-part isolation, machine/Punycode view lookup misses against readable canonical accounts, wp_authenticate_user hook tracking, and stub/cache cleanup; focused seeds passed 1243/1, 2492/2, and 3744/3, adjacent email/mail/identity smoke passed 1255/1, Arendt found no actionable findings, and the broad smoke passed 9153 checks with 62 skips.
    +
    Continuation: query-loop nested reset/global postdata and exact conditional flag oracles landed through b63ed99053, fa12f15d10, and bbe8f53242; focused runs passed seed 1 and seed 224 at 7700 checks each plus seeds 2, 42, and 123456 at 1925 checks each, final review found no actionable findings, and the broad run passed 8665 checks with 66 skips.
    +
    Continuation: Unicode email password-reset notification recipient coverage landed through c45c94ca67 and 0cfbc48c93; Sagan implemented the matrix, Descartes found three review gaps, the fixes now exercise wp_mail() through fake PHPMailer, assert current machine-view reset lookup rejection, and label no-DB collation scope, Fermat found no actionable findings, and the broad run passed 8665 checks with 66 skips.
    +
    Continuation: privacy export-email notification recipient, subject, body-placeholder, header, negative-path, and cleanup coverage landed through 72f3ae35e3 and review fixes through 970437d39f; focused runs passed seed 1 and seed 224 at 170 checks each, reviewer seed 72 passed 1700 checks, lint/syntax/bootstrap checks passed, the broad smoke passed 4324 checks with 33 skips, and Franklin re-review found no actionable findings.
    +
    Continuation: query WP_Query post-search review fixes landed through 992c2393a5, 729e2a5322, 83039db582, and 8e76f34885, adding real post result oracles, password contradiction coverage, literal clause-keyword search cases, attachment filename null-left-join semantics, independent exact-plus-relevance malformed-order boundary checks, and status-OR stub branch coverage including author-scoped private statuses and mixed equality/IN alternatives; focused seeds 1 and 224 passed 64400 checks each and the broad smoke passed 4519 checks with 33 skips. email UTF-8 boundary coverage landed through 31260a0f66 with quoted/escaped local-part, control-character, local-part identity, and hook-restoration oracles; focused email passed 3742 checks with 3 skips and mail smoke passed 10 checks.
    +
    Continuation: interactivity directive syntax/order matrix coverage landed through 7790a7504d, covering bind/class/style/text empty suffixes, unique-ID ignores, event-handler bind warnings, boolean data/ARIA conversion, false attribute/style removals, class/style ordering, non-scalar text, and invalid directive names; Nietzsche found two oracle gaps, both fixed before commit, focused seeds 1 and 224 passed 1100 checks each, and the broad smoke passed 13602 checks with 99 skips.
    +
    Continuation: template-hierarchy direct helper matrix coverage landed through 0afd17bb8d, covering author, date, home, front page, privacy policy, singular, and attachment helper hierarchies, exact dynamic hook type/template payloads, no-DB synthetic author objects, MIME subtype ordering, and generated child/parent path confinement; Hilbert found no actionable findings, focused seeds 1 and 224 passed 900 checks with 100 guarded comments-template skips each, and the broad smoke passed 9073 checks with 66 skips.
    +
    Continuation: images content tag pipeline coverage landed through 93c6f19416, covering direct auto-sizes, dimensions, srcset/sizes, loading optimization, iframe loading helpers, duplicate image/iframe content filtering, exact generated candidate URLs, filter cleanup, and row-local missing API skips; McClintock found three oracle gaps and a follow-up URL gap, all fixed before commit, focused seeds 1 and 224 passed 1000 checks each, and the broad smoke passed 9075 checks with 66 skips.
    +
    Continuation: canonical-routing DB-backed 404 permalink guessing coverage landed through cc00dac113, covering loose and strict name guesses, public post type/status gates, comment-feed and single-page variants, guess short-circuit/cancel filters, and redirect_canonical() query cleanup; focused seeds 1 and 224 passed 350 checks each, adjacent canonical/request/template smoke passed 190 checks, and the broad smoke passed 4660 checks with 28 skips.
    +
    Continuation: syndication feed_links_extra() branch coverage landed through 89e00fbfad, covering singular comments, post type archives, category, tag, custom taxonomy, author, and search query states with exact query-arg, escaping, filter-locality, and cache/global restoration oracles; Tesla found one cleanup gap, fixed before commit, focused seeds 1 and 224 passed 900 checks each, and the broad smoke passed 9079 checks with 66 skips.
    +
    Continuation: admin-bar default callback node graph coverage landed through 555495f583, covering WordPress logo, readable-user account, appearance/theme-support, comments-count, search-form, and secondary-group callbacks with exact node parent/href/meta/title oracles; Planck found hidden no-DB and cleanup gaps, fixed with reflected synthetic users, scoped option/user-meta short-circuits, and wpdb runtime restoration checks; focused seeds 1 and 224 passed 800 checks each, and the broad smoke passed 9081 checks with 66 skips.
    +
    Continuation: xmlrpc pingback fail-closed/read-only lookup coverage landed through efc5b84ca5, covering filtered empty sources, off-site targets, unresolved and missing target posts, same-resource rejects, closed pings, duplicate pingbacks, legacy target extraction forms, and pingback.extensions.getPingbacks URL filtering; Linnaeus found three review gaps, fixed with next-ID runtime restoration, narrower comment URL stub matching, and order-independent URL assertions; focused seeds 1 and 224 passed 1000 checks each, and the broad smoke passed 9083 checks with 66 skips.
    +
    Continuation: revisions-autosaves latest-count URL and user-filtered autosave coverage landed through 4a2cad026c, replacing two stub-limited skips with real oracles for wp_get_latest_revision_id_and_total_count(), wp_get_post_revisions_url(), empty/no-revision posts, revisions-disabled post types, revision-input edit links, and user-specific autosave selection; Archimedes found author-filter SQL broadening gaps, fixed with intersecting equality/IN predicates and status-OR branch handling; focused seeds 1 and 224 passed 600 checks with 50 guarded skips each, and the broad smoke passed 9087 checks with 62 skips.
    +
    Continuation: privacy final erasure completion coverage landed through df3be8a1aa, replacing the old final-path skip with a DB-stub-backed oracle for last-eraser completion, unchanged response shape, completed request status, completed timestamp meta, request identity preservation, exact erased-action firing, and post-cache cleanup; focused seeds passed 17/0, 17/0, and 85/0, and the broad smoke passed 9123 checks with 62 skips.
    +
    Continuation: email UTF-8 comment-submission coverage landed through 276dc47a5b, covering front-door wp_handle_comment_submission() validation for valid UTF-8 local parts, optional Unicode domains, and rejected emoji/fullwidth-at/invalid-UTF-8/leading-combining inputs, plus direct wp_new_comment() display-name recovery and invalid-email sanitization-to-empty behavior; Maxwell found four review gaps, fixed with fuller global snapshots, path-specific return oracles, hook payload assertions, and explicit direct invalid semantics; focused seeds passed 6235/5, 6225/5, and 3745/3, and the broad smoke passed 9089 checks with 62 skips.
    +
    Continuation: Unicode email profile confirmation coverage added direct send_confirmation_on_profile_email() request paths for success, duplicate, invalid, wrong-user, and same-email no-op branches, with exact _new_email meta, hash/link, placeholder replacement, error-data, $_POST, and no-mail/content-filter oracles; review gaps were fixed through d93c8a7e0f with stale-meta preservation and exact escaped self-admin URL checks, focused seeds passed 1242/1, 1245/1, 1255/1, and 3751/3, and the broad smoke passed 9123 checks with 62 skips.
    +
    Continuation: Unicode email local-part update alias coverage added generated wp_update_user() paths for composed accents, combining marks, Greek/Cyrillic variants, compatibility ligatures, fullwidth Latin, and width-sensitive Katakana, with exact lookup invalidation, duplicate collision, and email-change notification recipient/body oracles; focused seeds passed 1254/1, 1241/1, 1244/1, and 3748/3, and the broad smoke passed 9121 checks with 62 skips.
    +
    Continuation: multisite legacy blog identity and bootstrap resolution coverage added for get_blog_details(), blog address/name/URL ID helpers, domain_exists() payloads, and ms_load_current_site_and_network() subdirectory, subdomain, and missing-site branches; focused seeds passed 17/1, 17/1, 85/5, and 340/20, and the broad smoke passed 9119 checks with 62 skips.
    +
    Continuation: xmlrpc authenticated read-only content/media coverage landed through 50b7b3e2a3, covering wp.getPost, wp.getPosts, wp.getMediaItem, and wp.getMediaLibrary auth/capability branches, default and explicit field filtering, IXR dates, future-as-publish response shaping, media metadata/thumbnail projections, same-parent MIME filters, invalid item/type errors, scoped hooks, and seeded content cleanup; review gaps were fixed with cleanup-state capture before snapshot restore, wider post-type global snapshots, same-parent nonmatching media, and OR-style MIME SQL stub handling; focused seeds 1 and 224 passed 550 checks each, and the broad smoke passed 9091 checks with 62 skips.
    +
    Continuation: kses PDF object and dynamic URI attribute filter coverage landed through e3d43f715a, adding deterministic oracles for required PDF object attributes, upload-host and port matching, HTTP/HTTPS acceptance, query/fragment and case-sensitive extension rejection, duplicate attribute first-wins behavior, non-self invalid object fallback, bad-protocol filtering before the PDF callback, scoped upload_dir cleanup, and custom wp_kses_uri_attributes protocol filtering; focused runs passed 1096/0, 1093/0, 5487/0, and 54818/0, the broad smoke passed 9107 checks with 62 skips, and Bacon review found no actionable findings.
    +
    Continuation: kses attribute constraint coverage landed through 8fc00de203, adding deterministic oracles for wp_kses_check_attr_val() max/min length and value checks, valueless attributes, allowed-value lists, value callbacks, wp_kses_attr_check() by-reference mutation behavior, required-attribute fallback stripping, entity-decoded style filtering, full-tag wp_kses_attr_parse() round trips and rejects, and safecss_filter_attr_allow_css restoration; focused runs passed 1094/0, 1091/0, 5477/0, and 54718/0, and the broad smoke passed 9103 checks with 62 skips.
    +
    Continuation: rest batch-v1 pre-dispatch short-circuit coverage builds on dispatch coverage through 94c539db4b, adding valid-child rest_pre_dispatch short-circuit oracles, permission/callback bypass checks, clean child request clone assertions, post-dispatch envelope coverage for short-circuit and validation-error siblings, require-all-validate no-execution checks, and filter/global restoration; focused seed 224 passed 13 checks, seed 224 over 25 iterations passed 325 checks, adjacent rest,rest-controllers,rest-object-controllers smoke passed 295 checks, and broad seed 224 smoke passed 5067 checks with 1 skip.
    +
    Continuation: rest-object-controllers normal mixed batch item update coverage extends the existing object batch update row with valid post and user siblings around an invalid category parent update, asserting ordered 207 child envelopes, valid-sibling persistence, invalid-term no-mutation, child post-dispatch locality, count stability, and cleanup; focused seed 224 passed 27 checks, seed 224 over 25 iterations passed 675 checks, adjacent REST/content/query/capability smoke passed 2682 checks, and broad seed 224 smoke passed 5067 checks with 1 skip.
    +
    Continuation: filesystem recursive helper coverage landed through 60dec6d029; focused runs passed seed 1 and seed 224 at 1200 checks each, reviewer seed 60 passed 1200 checks, syntax/PHPCS/diff checks passed, the broad smoke passed 4326 checks with 33 skips, and Gibbs review found no actionable findings.
    +
    Continuation: comment-workflow hard-delete coverage landed through 5f9d739e21 and review fixes through cc0493297c; focused runs passed seed 1 and seed 224 at 900 checks each, reviewer seed 1 passed 900 checks, syntax/PHPCS/diff checks passed, the broad smoke passed 4328 checks with 33 skips, and Kepler follow-up review found no actionable findings.
    +
    Continuation: content post-template helper coverage landed through c93166b0ec and review fixes through 50ef617a22; focused runs passed seed 1 and seed 224 at 1400 checks each, syntax/PHPCS/diff checks passed, the broad smoke passed 4329 checks with 33 skips, and Leibniz follow-up review found no actionable findings.
    +
    Continuation: blocks block bindings render-pipeline coverage landed through f53cfd1bfc and review fixes through d83d02f5e0 and 9e4c4ee83c; focused runs passed seed 1 and seed 224 at 800 checks each, syntax/PHPCS/diff checks passed, the broad smoke passed 4330 checks with 33 skips, and Curie follow-up review found no actionable findings.
    +
    +
    +
    Active Workers
    +
    0
    +
    No active worker-owned files; latest validated slice expanded import-diff importer get_page() HTTP wrapper coverage, with focused, adjacent, and broad validation passing locally. Next gap target: convert the missing-sidebar update diagnostic after the controller fix, flip the KSES diagnostic after core isolates those globals, continue REST batch/controller boundaries, or pick another pure helper wrapper gap.
    +
    +
    + +
    +
    +

    Current Work

    + Committed main-tree progress and latest validated rich-surface wave. +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    ComponentStatusOwner / WorktreeValidationDependencies
    xmlrpclocal validation passedScoped component worktree component-fuzz-xmlrpc-taxonomy-methods; builds on authenticated post write-method coverage and read-only content/media coverage through 50b7b3e2a3.Main focused runs: seed 224 passed 13 checks, seeds 1, 224, and 57123 over 25 iterations each passed 325 checks after authenticated taxonomy term method coverage; adjacent xmlrpc,taxonomy,taxonomy-relationships,rest-object-controllers,content-lifecycle,query,metadata,comments,comment-workflow smoke passed 29875 checks; bootstrap smoke passed; latest broad run passed 4847 checks with 1 skip.IXR protocol helpers, wp_xmlrpc_server pingback methods, authenticated read-only wp.getPost/wp.getPosts/wp.getMediaItem/wp.getMediaLibrary methods, authenticated wp.newPost/wp.editPost/wp.deletePost write lifecycles, authenticated wp.getTerms/wp.getTerm/wp.newTerm/wp.editTerm/wp.deleteTerm taxonomy lifecycles, scoped option/auth/cap/media/post-insert/prepared-term filters, url_to_postid() query and legacy extraction forms, get_post()/pings_open(), in-memory post/comment/user/media/term fixtures, duplicate pingback lookup by source URL, read-only pingback URL projection, media parent/MIME filtering, HTTP and insert tripwires, object-cache cleanup, hook restoration, and wpdb runtime/next-ID restoration.
    restlocal validation passedScoped worktree component-fuzz-rest-batch-boundary; builds on batch-v1 parsed no-route coverage through 58cdd7fca9, malformed URL coverage through e2838976d6, pre-dispatch short-circuit coverage through 94c539db4b, and prior route-registration wrapper coverage through 263fd2ce32.Latest focused runs: rest seed 224 passed 16 checks and seed 224 over 25 iterations passed 400 checks after batch-v1 request schema boundary coverage; seeds 1 and 57123 over 5 iterations each passed 80 checks; adjacent rest,rest-controllers,rest-object-controllers smoke passed 64 checks; syntax/diff checks passed; broad seed 224 smoke passed 5078 checks with 1 skip.REST request normalization, parameter precedence, schema validation, route dispatch, register_rest_route() common-arg merge precedence, route option schema callbacks, override dispatch reachability, permission semantics, /batch/v1 normal/require-all validation, allow-batch gates, top-level batch request schema validation, defaulted validation and child POST method behavior, child path/method/body/header shape rejection before callbacks, child method/query/body/header propagation, malformed child path parse failures with aligned parse_path_failed envelopes, parsed no-route child paths with aligned 404 rest_no_route envelopes and data.status=404, normal child pre/post dispatch for no-route slots, require-all null valid-sibling slots with no child execution, child rest_pre_dispatch short-circuits, permission/callback bypass, validation-error sibling envelopes, rest_post_dispatch response headers, response envelopes, response links, CURIE compaction, embedding, headers, response conversion, and REST server global/filter restoration.
    rest-application-passwordslocal validation passedScoped component worktree component-fuzz-rest-app-password-dispatch; builds on response-shaping coverage from component-fuzz-rest-app-password-response and status/index plumbing coverage from component-fuzz-rest-app-password-status.Latest focused runs: seed 224 passed 8 checks, seeds 1, 224, and 57123 over 25 iterations each passed 200 checks, adjacent rest-application-passwords,account-security,auth-flow,capabilities,rest,rest-controllers,rest-object-controllers smoke passed 820 checks, bootstrap smoke passed, and latest broad run passed 4860 checks with 1 skip.WP_REST_Application_Passwords_Controller, WP_Application_Passwords, synthetic users, scoped application-password usermeta filters, REST default filters for _fields and index authentication metadata, route-dispatched create/update/delete/introspection behavior, schema and permission failures before storage mutation, one-time password exposure only in edit context, stored-hash agreement and non-exposure, immutable app_id updates, item and bulk delete accounting, formatted created/last-used timestamps, last-IP preservation, requested-link projection, forbidden password projections, exact REST pre/after/prepare hook payloads, usage metadata recording and same-day throttling, missing UUID usage errors without mutation, capability mapping, app-password availability filters, current-user/auth-status globals, REST auth error helpers, REST default-filter idempotence, and cloned REST server/global/filter restoration.
    rest-directory-serviceslatest validation passedScoped component worktree component-fuzz-rest-batch-order; builds on generated direct API coverage from component-fuzz-rest-directory-generated-api, direct plugin/theme API coverage from component-fuzz-rest-directory-direct-api, block-directory field/link coverage through 87b8fc9686, and pattern proxy and URL-details fallback coverage through e581555577.Latest focused runs: seeds 1, 224, and 57123 over 25 iterations each passed 225 checks after cloned WP_Hook snapshot restoration and hook-callback fingerprinting; ordered rest-directory-services,rest seeds 1, 224, and 57123 over 3 iterations each passed 60 checks; adjacent REST stack smoke passed 118 checks; bootstrap smoke passed; broad seed 224 smoke passed 5036 checks with 1 skip. Earlier generated API runs passed seed 1 over 50 iterations, seed 100 over 200 iterations, and ordered post-REST adjacent smoke.WP_REST_Block_Directory_Controller, WP_REST_Pattern_Directory_Controller, WP_REST_URL_Details_Controller, direct plugins_api() and themes_api() default-arg/filter/result contracts, generated plugin/theme directory API matrices, array/object arg normalization, query-only per_page defaults, explicit locale/wp_version preservation, filter-added HTTP request markers, WordPress.org plugin/theme HTTP serialization through pre_http_request, malformed/error JSON handling, WP_Error short-circuits, SSL-to-HTTP fallback and terminal transport failures, plugin short-circuit external markers, theme query_themes row-object and feature_list array normalization, scoped plugins_api and pre_http_request short-circuits, block-directory _fields projection, explicit block title/icon mapping, installed-plugin link discovery through seeded plugin cache, site-transient caches, REST permission/capability filters, pattern-directory direct preparation, hostile remote field sanitization, dropped remote extras, _fields projection after preparation, rest_prepare_block_pattern raw-object/full-data payloads, proxy query allowlisting, derived WordPress.org argument overwrite behavior, malformed/unclosed head metadata parsing and first-match precedence, URL parser boundaries, cloned WP_Hook snapshot restoration, hook-callback state fingerprinting, REST default-filter isolation, and REST server/global restoration.
    admin-list-tablesvalidation passedCurrent slice retires the application-password Last IP escaping boundary; earlier direct link-manager and post-comments subclass coverage, theme install replay, plugin install, privacy accounting, and base pagination follow-ups remain covered.Latest focused runs: seed 224 passed 12 checks, seeds 1 and 224 over 25 iterations each passed 300 checks with no skips, adjacent admin-list-tables,admin-workflows,privacy-admin-requests,admin-ajax,admin-screen,admin-options-submission,user-preferences,account-security,rest-application-passwords smoke passed 78 checks with no skips, and latest broad run passed 4727 checks with 3 skips. Earlier link-manager/post-comments runs passed 275 checks per seed with the now-retired current-core skip.Concrete list tables for posts, media, comments, link manager, post-comments metabox, terms, users, plugins, plugin install search results, themes, selected-mode theme install API results plus repeat-safe seeded render replay, network themes, application passwords, guarded network sites/users, and base WP_List_Table pagination/per-page behavior, including synthetic fixtures, bookmark cache/query arguments, link-category term fixtures, column/header state isolation, exact row-action URL/nonce checks, escaped pagination URLs, legacy link-manager option/capability gates, compact post-comments table rendering, application-password Last IP escaping and empty-value fallback, install/update/activate action rendering, theme install latest/newer installed states, compatibility notices, screenshot/icon and description escaping, fail-closed plugin/theme API short-circuiting, localized update-count and theme-root transient cleanup, row/template rendering, and dedicated privacy request list-table coverage accounting through privacy-admin-requests.
    admin-dashboardvalidation passedScoped component worktree component-fuzz-admin-dashboard-right-now; builds on cached RSS helper coverage through fb3b2503f2, Browser Happy coverage through 3f5772e8e7, direct setup registration coverage through 68427088d5, and Community Events coverage.Latest focused runs: seeds 1, 224, and 57123 over 25 iterations each passed 325 checks with no skips; adjacent admin-dashboard,admin-screen,admin-workflows,admin-list-tables,site-health,site-health-debug,content-lifecycle,comments,comment-workflow,post-types,taxonomy,taxonomy-relationships smoke passed 14850 checks; bootstrap smoke passed; broad seed 224 smoke passed 4848 checks with 1 skip.Dashboard widget registration/control callbacks, meta-box contexts, dashboard column rendering, direct At a Glance wp_dashboard_right_now() rendering for publish-only post/page counts, comment/moderation counts, dashboard-glance items, privacy/version/theme messages, and rightnow/activity hook output, direct wp_dashboard_setup() GET registration for site/network/user dashboard action and widget-filter branches, recent draft/comment helpers, activity recent-post query/link branches with editor/non-editor output, cached RSS loading/AJAX/cache replay branches, Browser Happy wp_check_browser_version() request/cache/TTL/failure contracts, dashboard_browser_nag_class() class preservation, regular and IE wp_dashboard_browser_nag() output escaping, Community Events wp_print_community_events_markup() and template output contracts, no-network HTTP tripwires, transient and filter cleanup, and state restoration.
    admin-edit-metaboxeslocal validation passedScoped worktree component-fuzz-admin-edit-author-box; extends earlier admin edit meta box coverage from component-fuzz-admin-edit-metaboxes.Latest focused runs: seed 224 passed 7 checks, seeds 1, 224, and 57123 over 25 iterations each passed 175 checks, adjacent admin-edit-metaboxes,admin-screen,admin-workflows,admin-list-tables,admin-media-chrome,user-preferences,taxonomy,taxonomy-relationships,content-lifecycle,comments,comment-workflow,bookmark-links,revisions-autosaves smoke passed 598 checks, bootstrap smoke passed, and broad seed 224 smoke passed 4833 checks with 1 skip. Earlier callback/default-registration coverage passed 150 checks per focused sweep.Classic edit-screen meta box callbacks for publish status/visibility/action branches, taxonomy tag/category capability gates, excerpt, trackback, custom field, discussion, comment table header/body, slug, author override dropdown post_author_meta_box() contracts, selected-author include_selected preservation, wp_dropdown_users_args payloads, escaped display_name_with_login labels, page attributes, post formats, attachment submit/ID3/thumbnail boxes, link submit/target/XFN/advanced fields, default register_and_do_post_meta_boxes() IDs, callbacks, contexts, hook payloads, and state restoration.
    account-securitylocal validation passedScoped worktree component-fuzz-account-reset-request; retrieve-password request-path follow-up after reset-key lifecycle and direct reset-password composition coverage.Latest focused runs: seed 224 passed 9 checks and seed 224 over 25 iterations passed 225 checks; seeds 1 and 57123 over 5 iterations each passed 45 checks; adjacent account-security,auth-flow,security,identity,mail smoke passed 53 checks; syntax/diff checks passed; broad seed 224 smoke passed 5088 checks with 1 skip.Application password lifecycle, chunk/hash, authentication API gates, success/failure hooks, email fallback, Basic Auth validation, password-reset key lifecycle and fail-closed paths, retrieve_password() request lookup, lostpassword_errors, send_retrieve_password_email, reset-key hashing, mail composition and pre_wp_mail interception, direct reset_password() hook ordering, storage, activation-key, nag, and repeat replacement contracts, recovery key/cookie services, paused-extension storage, and global/filter restoration.
    ai-clientreview fixes passedAvicenna the 4th follow-up findings fixed through fc2df79179; Laplace the 4th review findings fixed through d66f101681; Mencius the 4th transport follow-up through 5b7b110e98; prior Nietzsche the 3rd exploration plus Bohr the 3rd and Einstein the 3rd reviews found no actionable issues through dccbad638cMain focused run: 900 passed, 0 skipped, 0 failed after transport review follow-up; smoke: 45 passed, 0 skipped, 0 failed; syntax/PHPCS/diff checks passed; latest broad run: 8627 passed, 66 skipped, 0 failed.SDK DTOs and enums, provider registry isolation, provider/model collision selection, tuple and model-instance preferences, provider locks, prompt builder events/prevention, ability resolver integration, cache/event adapters, deterministic in-memory generation, HTTPlug discovery of the WordPress HTTP adapter with discovery state restoration, PSR-7 to wp_safe_remote_request() mapping, exact empty/scalar response bodies, WP_Error to NetworkException paths through direct and SDK transport with wrapped request context, SDK transport option merge precedence including request-only fields, and response DTO mapping.
    emailreviewed validation passedSagan Unicode reset recipient implementation through c45c94ca67; Descartes review findings fixed through 0cfbc48c93; Fermat re-review found no actionable findings; UTF-8 boundary follow-up through 31260a0f66, Cicero review found no actionable findings; comment-submission follow-up through 276dc47a5b, Maxwell review findings fixed before commit; generated local-part update follow-up through 30d42d342f; profile confirmation request follow-up through 2a2ccd0aee with review gaps fixed through d93c8a7e0f; Unicode authentication follow-up through 5d018bedd3; UTF-8 address-model follow-up through 2a84783187, Schrodinger the 2nd implementation and Gauss the 2nd review.Latest focused runs: seed 224 passed 1248 checks, seed 1 passed 1245 checks, adjacent email,mail,identity smoke passed 1260 checks with no skips, and latest broad smoke passed 4729 checks with 1 skip after punycode-TLD make_clickable() coverage and partial-link boundary retirement. Earlier runs include seed 20260627 over 10 iterations passed 12502 checks with 10 skips, reviewer seed 424242 over 3 iterations passed 3741 checks with 3 skips, 62043 passed/50 skipped, post-review 12427/10 and 12404/10, email PHPUnit 86 tests and 179 assertions with 1 skip, and mail smoke 10 checks.Unicode email validation/sanitization, generated UTF-8 local/domain filter-view matrix, generated mixed-script UTF-8 address-model invariants, WP_Email_Address raw getter and ASCII/unicode round-trip oracles, reserved ACE-like and invalid xn-- domain rejection, malformed UTF-8 warning capture, UTF-8 local-part oracle agreement, generated malformed UTF-8/local/domain variants across parser/filter/charset modes, quoted/escaped local-part rejection, control-character and Unicode separator sanitization boundaries, local-part case/width/normalization identity preservation, tracked hook restoration, REST user email schema validation across Unicode/ASCII filter modes, optional Unicode API skips, disabled-filter fail-closed behavior, WP_Email_Address machine/readable views and ASCII-vs-Unicode construction modes, explicit IDN construction-mode skips, IDN/punycode, canonical Unicode-domain user save/update collision behavior with no-DB collation scope labeled, generated Unicode local-part alias index/search/update behavior, exact Unicode email authentication with wp_authenticate_email_password(), wrong-password and alias-isolation branches, machine/Punycode view lookup misses against canonical readable accounts, profile email-change confirmation request success/duplicate/invalid/wrong-user/same-email branches, update-time old-email lookup invalidation, duplicate collision rejection, email-change notification recipient/body preservation, confusable UTF-8 local-part lookup/comment/password-reset/search boundaries, real comment submission via wp_handle_comment_submission() and direct wp_new_comment() recovery/sanitization paths, password-reset notification recipient machine/readable view overrides through fake PHPMailer handoff, current machine-view reset lookup rejection, accent-folded DB-candidate lookup guards, byte-preserving local-part search result oracles, user lookup/password reset paths, make_clickable() mailto rendering boundaries with punycode final-label full-link and malformed final-label no-partial-link cases, and generated mailto/rendering-context round trips for UTF-8 local parts, WHATWG delimiter local parts, IDN/punycode domains, escaped display hrefs, raw URL preservation, and readable link text.
    customizerlocal validation passedScoped component worktree component-fuzz-customizer-control-rendering; builds on direct manager theme-preview coverage from component-fuzz-customizer-theme-preview.Latest focused runs: seed 224 passed 8 checks, seeds 1, 224, and 57123 over 25 iterations each passed 200 checks; adjacent customizer,customizer-persistence,customizer-nav-widgets-requests,appearance-media,admin-screen,admin-workflows,admin-ajax,user-preferences,widgets,block-widgets,assets smoke passed 114 checks; bootstrap smoke passed; broad seed 224 smoke passed 4835 checks with 1 skip.Customizer manager registries, setting callbacks, slashed customized JSON ingestion, cached post-value parsing, programmatic merge precedence, post-value hooks, unknown validation, multidimensional option previewing, JSON exports, active callbacks, built-in text/textarea/checkbox/radio/select control rendering hooks, escaping, setting links, and selection state, selective refresh partials, direct inactive/active theme-preview filter and action lifecycle coverage, child-theme stylesheet/template switching, and state restoration.
    customizer-nav-widgets-requestsvalidation passedLanded through f5f6f5ff23; scoped worktree removed after merge.Main focused runs: seed 1 passed 8 checks, seed 224 over 25 iterations passed 200 checks, adjacent customizer-nav-widgets-requests,customizer,customizer-persistence,navigation-lifecycle,navigation,widgets,default-widgets,block-widgets,admin-ajax,content-lifecycle,taxonomy-relationships,options-autoload smoke passed 560 checks; latest broad run: 4623 passed, 28 skipped, 0 failed.Customizer nav_menus/widgets component loading, capability-gated AJAX hooks, menu available/search requests, auto-draft creation and publish cleanup, dynamic nav menu/menu-item settings, placeholder menu remapping into theme locations and widget_nav_menu, preview HMAC/export metadata, signed widget instance round trips, widget update AJAX gates, selective-refresh wrapper metadata, widget partial rendering, in-memory wpdb content/options, nonces/caps, and global/static restoration.
    block-templatesreviewed validation passedCurrent slice retires the direct-ID traversal guard skip; earlier reviewer Mencius the 2nd found no actionable issues.Latest focused runs: seed 224 passed 9 checks, seeds 1 and 224 over 25 iterations each passed 225 checks with no skips, adjacent block-templates,blocks,block-supports,block-editor-adjuncts,rest-site-editor,rest-controllers,style,template-hierarchy smoke passed 84 checks with no skips, and latest broad run passed 4728 checks with 2 skips. Earlier registry/path runs passed 800 focused checks with historical skips.Template registry lifecycle, public get_block_templates() integration for plugin-only templates, post-type filtering, registered/theme-file collision enrichment without duplicates, file-backed parent/child precedence, template parts, loader globals, malformed filename guards, direct and public template-ID path containment, template CPT short-circuiting, and state restoration.
    block-editor-adjunctsreview fixes passedScoped worktree component-fuzz-block-editor-stylesheets; editor stylesheet lifecycle coverage builds on selected-post content helper coverage.Latest focused runs: seeds 1, 224, and 57123 over 25 iterations each passed 200 checks; adjacent block-editor-adjuncts,blocks,block-supports,block-templates,block-widgets,core-block-render,style,script-loader-runtime,assets smoke passed 100 checks; bootstrap smoke passed; latest broad run: 4831 passed, 1 skipped, 0 failed.Editor contexts, category/allowed-block filters, default/legacy/classic/merged settings, local theme styles, editor stylesheet add/get/remove lifecycle, iframe asset collection, REST preload normalization, first-match block tree helpers, selected-post wp_get_post_content_block_attributes() template lookup, missing-target guards, cache/filter/global restoration, and temp block-theme fixtures.
    comment-workflowvalidation passedScoped worktree component-fuzz-comment-moderation-notify; moderation transition follow-up reviewed by Archimedes. Earlier Popper the 2nd findings fixed in 67234118ad, hard-delete coverage landed through 5f9d739e21, review fixes through cc0493297c, and notification mail-path follow-up was reviewed by Raman the 2nd.Latest focused runs: seed 224 passed 11 checks, seeds 1, 224, and 57123 over 25 iterations each passed 275 checks; adjacent comment-workflow,comments,content-lifecycle,content,mail,email,identity,capabilities smoke passed 3568 checks; bootstrap smoke passed; latest broad smoke: 5035 passed, 1 skipped, 0 failed.In-memory comment submission, direct wp_new_comment() preprocessing, post-filter user-ID normalization against a synthetic user, parent normalization, approval filters, insert/comment hooks, notification wrapper and direct mail paths with intercepted wp_mail(), recipient/header/subject/body filters, generated moderation transition notification/count/cache/capability/no-mutation oracles, wp_count_comments() buckets, post comment-count refresh events, cache-clean events, scoped edit/moderate capability probes, moderation/status helpers, trash/spam restoration, force-delete reparenting/meta/count/hook contracts including commentmeta and type-specific transition hooks, non-counted delete count boundaries, failure paths, and state restoration.
    admin-workflowsvalidation passedMain worktree; scoped concrete list-table accounting through b59b5030c4; Anscombe the 4th review findings fixed through 2beecb7e9e; AJAX format wrapper coverage through 10cb72a48a; generated list-table request/action matrix coverage in current slice.Latest focused runs: seed 224 passed 11 checks with no skips, seeds 1, 224, and 57123 over 25 iterations each passed 275 checks with no skips, adjacent admin-workflows,admin-list-tables,admin-ajax,admin-screen,admin-options-submission,user-preferences,privacy-admin-requests,security smoke passed 87 checks with no skips, bootstrap smoke passed, and latest broad run: 4870 passed, 1 skipped, 0 failed.Admin menu globals and hook suffixes, parent file normalization, synthetic list-table columns/views/bulk actions/row actions/tablenav rendering, generated list-table request/action matrices for current_action() precedence and bottom-action boundaries, capability-gated row/bulk actions, view and bulk filters, nonce URLs, custom bulk-action hook dispatch, selected item normalization, redirect cleanup/fallback behavior, allowed-host validation, scoped accounting for concrete list-table coverage owned by admin-list-tables and privacy-admin-requests, direct current-action and month-dropdown helper contracts, nonce/referer helpers, direct referer/original-referer fields, request-over-header precedence, off-host rejection, fallback behavior, nonce fields, selected attributes, submit buttons, captured date/time AJAX format wrappers, and state restoration.
    default-widgetsreview fixes passedMain worktree; Sartre the 4th review findings and follow-up findings fixed through 2a62dae1e6Main focused run: 1000 passed, 0 skipped, 0 failed after follow-up fixes; wpdb-sql focused run: 800 passed, 0 skipped, 0 failed; direct probes confirmed query-runtime restoration and duplicate DISTINCT t.term_id collapse; shared stub smoke: 2555 passed, 10 skipped, 0 failed; latest broad run: 8619 passed, 66 skipped, 0 failed.Default widget constructors/options, saved-instance display/form/update callback lifecycles, title/content sanitization, exact sibling preservation, form escaping, filtered rendering, real stub-backed nav-menu item lookup/object filtering/walker output, filter cleanup assertions, cache-backed calendar/archive output, local RSS fixtures, no-live-DB guard, and state/object-cache/query-runtime restoration.
    editor-helpersreviewed validation passedScoped worktree component-fuzz-editor-link-query; internal link query/dialog coverage landed in 3c10935ca3 after previous TinyMCE inline-script and enqueue dependency coverage.Latest focused runs: seeds 1, 224, and 57123 over 25 iterations each passed 275 checks; adjacent editor-helpers,admin-ajax,query,content-lifecycle,template-links,script-loader-runtime,assets,block-editor-adjuncts,admin-media-chrome,media-editor smoke passed 3775 checks; bootstrap smoke passed; broad seed 224 passed 4800 checks with 1 skip. Earlier focused run: 900 passed, 0 skipped, 0 failed after enqueue review fixes.Classic editor settings/state normalization, default editor selection filters, full and teeny TinyMCE/Quicktags filters, script enqueue decisions with media/link dependency oracles, captured markup, TinyMCE translations, TinyMCE inline settings, direct _WP_Editors::wp_link_query() query/result filters, generated post/custom-post link results, link dialog single-print markup, media-view styles, and global/static/cache restoration.
    frontend-featureslocal validation passedScoped worktree component-fuzz-frontend-url-prefixer; builds on disabled speculation lifecycle/load-action isolation and view-transition timing coverage.Latest focused runs: seed 224 passed 8 checks, seed 224 over 25 iterations passed 200 checks, seeds 1 and 57123 over 10 iterations each passed 80 checks, adjacent frontend-features,script-loader-runtime,assets,blocks,block-supports,style,fonts smoke passed 83 checks, syntax/diff checks passed, and broad seed 224 smoke passed 5094 checks with 1 skip.Speculation rules configuration, direct WP_Speculation_Rules validation diagnostics and serialization oracles, printed rule tags, disabled lifecycle/load-action isolation, exact generated list-rule URL leakage guards, URL pattern prefixing with generated context/path matrices, idempotence, escaped wildcard/literal characters, grouping-sensitive :?# base paths, invalid-context diagnostics, view-transition registration timing/enqueue helpers, and state restoration.
    admin-screenreview fixes passedMain worktree; Hegel implementation and Nash review findings fixed through 8e087d55e6; settings error/admin notice follow-up in this branch.Latest focused runs: seeds 1, 224, and 57791 over 25 iterations each passed 250 checks with no skips after settings error/admin notice coverage; adjacent admin-screen,admin-options-submission,admin-dashboard,privacy-admin-requests smoke passed 170 checks; latest broad seed 224 run passed 4742 checks with 1 skip.WP_Screen normalization/current globals, help tabs, rendered per-page/layout controls, combined screen-meta panel rendering, help sidebar and callback payload escaping, screen-reader content lifecycle, submit-filter priority cleanup, screen options and settings filters, column header filter locality, settings registry/rendering, Settings API error arrays/transients/sanitize side effects, generic admin notice markup/filter/action/KSES output behavior, meta boxes, accordion rendering, and state restoration.
    admin-media-chromedate stubs passedHarvey caption/send-to-editor follow-up through 123d7bae88; Dalton review fixes through 5c68c49525; admin media boundary skip retirement through 12d7260f6c; current slice adds legacy media date-query stub edge coverage.Latest focused runs: seed 1 passed 22 checks, seeds 1, 224, and 57123 over 25 iterations each passed 550 checks after date-query stub edge coverage; ordered adjacent media/admin smoke passed 125 checks; query-surface smoke passed 644 checks; bootstrap smoke passed; latest broad run passed 4881 checks with 1 skip. Earlier query alias/default coverage passed 525 checks per focused sweep.Attachment edit field preparation, media item and compat markup escaping, image form controls, image editor chrome, edit attachment details form output with sanitized alt/caption/description fields, edit-only compat required/hidden fields, thumbnail/icon helpers, direct image caption/send-to-editor helpers including default caption filter registration, rel/no-rel editor URLs, media send delegation, legacy upload tab/header/form shell hooks, legacy media-upload.php entry dispatch, legacy URL insert dispatch and send-to-editor URL filters, legacy gallery save iframe dispatch, legacy type/default iframe dispatch, legacy library/gallery iframe rendering, legacy media query/date filters and filename-search cleanup, legacy query aliases/default MIME auto-filters, list-table month status-exclusion SQL, full timestamp m filtering, WP_Date_Query date-unit row-filtering, wp_media_attach_action() attach/detach redirects and wp_die() denial exits, media-view enqueue settings/string contracts, in-process iframe shell rendering, media buttons, and state restoration.
    wpdb-sqllocal validation passedScoped component worktree component-fuzz-wpdb-field-charsets; Lagrange reviewed charset/invalid-text no-live-DB pitfalls; builds on Hubble the 2nd review fixes in e1530693f1 and shared stub follow-up through 2a62dae1e6.Latest focused runs: seed 224 passed 9 checks with no skips, seeds 1, 224, and 57123 over 25 iterations each passed 225 checks with no skips, adjacent wpdb-sql,query,content-lifecycle,metadata,options-autoload,state,taxonomy-relationships,comments,comment-workflow smoke passed 1200 checks, bootstrap smoke passed, and broad seed 224 smoke passed 4846 checks with 1 skip.Real no-connection wpdb, prepare placeholder/type behavior, identifier containment, LIKE escaping, malformed placeholder fail-closed paths, insert/update/delete/replace builders, null format handling, deterministic column/table charset and length fixtures, check_ascii(), strip_invalid_text(), CRUD fail-closed paths for stripped or truncated %s data, strip_invalid_text_for_column(), guarded query invalid-text stripping, and global restoration.
    markupreview fixes passedcomponent-fuzz-markup-static-blocks through 4de585019d; earlier Aquinas the 2nd review findings fixed in 6b590ee976Latest focused runs: 575 passed, 0 skipped, 0 failed at seeds 1 and 224; adjacent block/markup smoke passed 11652 checks with 0 skips; latest broad run passed 4670 checks with 24 skips.Block parser/serializer guards, deterministic do_blocks() fixture rendering for generated block trees through synthetic registered block callbacks, callback payload and wrapper-count oracles, render-state restoration, local shortcode registry behavior, strip/balance/excerpt helpers, URL extraction, link target/nofollow attribute oracles, and embed helpers.
    media-metadatalocal validation passedScoped component worktree component-fuzz-media-metadata-state; generated shortcode cache-coherence follow-up reviewed by Bernoulli. Earlier cover generation coverage through component-fuzz-media-cover, gallery/playlist coverage through component-fuzz-media-gallery-playlist, public audio/video shortcode coverage through 6195e533f8, and metadata replacement coverage through e61d09f16a.Latest focused runs: seed 224 passed 12 checks; seeds 1, 224, and 57123 over 25 iterations each passed 300 checks; adjacent media-metadata,admin-media-chrome,media-ingest,media-remote,image-metadata smoke passed 177 checks; direct rest-media-attachments and media-metadata,rest-media-attachments checks passed; full ordered media+REST adjacent smoke now passes 204 checks after REST media upload-directory cache isolation; bootstrap smoke passed; broad seed 224 smoke passed 5036 checks with 1 skip.Local parser fixtures, ID3 helpers, timestamp extraction, audio/video extension and key filters, public wp_audio_shortcode()/wp_video_shortcode()/wp_mediaelement_fallback() rendering, public gallery_shortcode()/wp_playlist_shortcode() rendering over cache-seeded attachments, gallery style/attribute/link filters, playlist JSON/script/ID3 branches, generated image/audio/video metadata replacement cache coherence, persisted _wp_attached_file state, stale first-generation field rejection, shortcode read no-mutation checks, protected-parent fail-closed behavior, mediaelement and HTML5 library paths, typed source lists, invalid-source embedded-link fallbacks, shortcode override/library/class/output filters, fallback escaping, YouTube/Vimeo URL normalization, boolean/preload normalization, attachment MIME/extension classification, image/document wrappers, MIME/extension disagreement, attachment metadata get/update/delete filters, filtered/unfiltered reads, stale-key removal, serializable round trips, relative/absolute/legacy path-to-URL normalization, original-image path/URL and image-meta matching normalization across upload storage styles, metadata generation branches, audio/video cover attachment creation/reuse, _cover_hash lookup, parent thumbnail updates, no-cover skip behavior, and state restoration.
    abilitieslocal validation passedMain worktree through 7bc1abc6c2Main focused run: 800 passed, 0 skipped, 0 failed; smoke: 40 passed, 0 skipped, 0 failed; latest broad run: 8007 passed, 67 skipped, 0 failed.Abilities API categories and abilities, action-gated registration, category unregister behavior that preserves existing abilities while blocking new registrations until re-registration, discovery filters, schema validation, custom subclass execution, query pipeline ordering, unregister/re-register identity, execution filters, callback exceptions, and registry restoration.
    registrieslocal validation passedScoped component worktree component-fuzz-block-bindings-registry; Kuhn the 2nd reviewed block-bindings registry gaps and snapshot caveats; builds on the icon file recovery follow-up through b88d33922c.Latest focused runs: seeds 1, 224, and 57123 over 25 iterations each passed 175 checks; adjacent registries,blocks,block-editor-adjuncts,rest-controllers,style,core-block-render smoke passed 315 checks; bootstrap smoke passed; broad seed 224 smoke passed 4763 checks with 1 skip. Earlier focused run: 600 passed, 0 skipped, 0 failed; PHPUnit: 24 tests, 47 assertions.Connector registry lifecycle, helper oracles, unregister/override replacement, icon manifest sanitization/caching, file-backed icon failure/recovery behavior, block metadata collection path boundaries/cache behavior, virtual path prefix preservation, block bindings singleton/wrapper/source lifecycle, source property validation, invalid name/property matrices, callback and source-value filter payloads, unregister/re-register ordering, speculation rule allowlists, and state restoration.
    contentreview fixes passedMain worktree; reviewers Schrodinger and Socrates findings fixed through eb8f0e9d57; post-template helper follow-up through c93166b0ec and 50ef617a22; content pagination follow-up through 89ec6030b2, reviewed by Nash.Main focused runs: seed 1 and seed 224 each 15 passed, 0 skipped, 0 failed after content pagination coverage; seed 20260627 over 50 iterations passed 750 checks; adjacent content,query,template-links smoke passed 3355 checks; syntax/PHPCS/diff checks passed; latest broad run: 9139 passed, 62 skipped, 0 failed; Nash follow-up review found no actionable findings.Post and term field sanitizers, whole-post sanitize_post() array/object consistency, ordered filter-locality hooks, slashing and metadata serialization, get_extended() more-tag parsing, front-end get_the_title()/get_the_excerpt()/has_excerpt()/post_password_required() protected/private/password-cookie branches and filters including wrong/correct phpass-cookie oracles and hook isolation/restoration, direct get_the_content()/the_content() rendering, generated // pagination, protected content forms, wp_link_pages() output/filter contracts, date/query helpers, and class/key/title sanitizers.
    content-lifecyclevalidation passedScoped component worktree component-fuzz-query-author-name-legacy-scalar; generated author_name boolean and non-zero scalar legacy author input coverage follows empty, non-scalar, parsed-zero, percent-encoded, noisy, whitespace, zero-token, and mixed signed legacy author strings, simultaneous author include/exclude collision coverage, explicit author-name filter collisions, author-name double-slash empty-segment cache boundaries, author-name miss fallback, author-name slug normalization, author include/exclude precedence, parent include/exclude precedence, post include/exclude precedence, parent-inclusion ordering, slug-inclusion ordering, post-inclusion ordering, post-inclusion cache-key, post-type array, post-status array, legacy author query-var, author-inclusion, author-exclusion, slug-inclusion, post-exclusion, parent-exclusion, and parent-filter cache-key normalization, selected-field ordering query-cache hardening, parent/status query-cache result-shape parity, private/pending parent query-cache invalidation, no-publish bulk status coercion, custom hierarchical parent/status mutation cache, mixed-depth get_page_by_path() lookup, mixed-depth ancestor permalink, private/trash parent visibility, cross-type ancestor, invalid-parent, raw query-var fallback, custom hierarchical single permalink fallback, custom hierarchical sample permalink, page parent-slug, numeric/plain permalink, old-slug/sample, status-transition, set-post-type, page lookup, bulk edit, admin post-save, term, relationship, user, count/MIME, and featured-image follow-ups.Latest focused runs: seed 224 passed 30 checks, seeds 1, 224, and 57123 over 25 iterations each passed 750 checks, seed 100 over 20 iterations passed 600 checks; adjacent posts/canonical/REST smoke passed 1513 checks; broad seed 224 smoke passed 5053 checks with 1 skip; syntax/diff checks passed.Custom hierarchical single permalink fallback matrices for generated public CPTs across pretty rewrite/no-query-var, query-var rewrite-disabled, and plain branches, including mixed-depth cross-type ancestor chains, mixed-depth get_page_by_path() lookups that require all ancestor post types, scalar post-type miss paths, encoded ARRAY_A lookup output, salted hit/miss post-queries cache population and invalidation, parent/status mutation boundaries that evict stale post_parent:{ID} and old-path caches before repopulating new-path private-child lookups, WP_Query private/pending/combined parent-filter cache buckets with distinct keys, exact ID projections, stale salted misses after last_changed, repopulated post-mutation buckets, cache-key/payload parity across fields=ids, fields=id=>parent, and fields=all result shapes, selected-field ordering no-cross-pollution checks for orderby=ID versus orderby=post_parent__in over generated published children, generated post-status array cache-key normalization for canonical, reversed, and duplicated post_status arrays over generated published/private rows with sorted unique query vars, per-projection cache-key sharing, and in-memory OR-status row filtering, generated post-type array cache-key normalization for canonical, reversed, and duplicated post_type arrays over generated pretty/query CPT rows with sorted unique query vars, mixed-CPT payloads, per-projection cache-key sharing, and in-memory post_type IN row filtering, generated post-inclusion cache-key normalization for canonical, reversed, and duplicated post__in arrays over generated published pretty CPT children with raw order/duplicate-preserving query vars, sorted unique SQL/cache keys, per-projection cache-key sharing, and in-memory ID IN row filtering, generated post-inclusion ordering-boundary coverage for canonical, reversed, and duplicated post__in arrays under orderby=post__in with first-occurrence ordered payloads, raw-order cache-key separation, and in-memory FIELD(ID,...) ordering, generated post include/exclude precedence coverage for overlapping post__in and post__not_in arrays with included payload precedence, raw include/exclude query-var preservation, shared SQL requests, and ignored-exclusion cache-key separation, per-projection cache-key normalization for canonical, reversed, and duplicated post_parent__in filters under orderby=ID across fields=ids, fields=id=>parent, and fields=all, generated parent-inclusion ordering-boundary coverage for canonical, reversed, and duplicated post_parent__in arrays under orderby=post_parent__in with first-occurrence parent-ordered payloads, raw-order cache-key separation, and in-memory FIELD(post_parent,...) ordering, generated parent include/exclude precedence coverage for overlapping post_parent__in and post_parent__not_in arrays with parent-inclusion payload precedence, raw include/exclude query-var preservation, SQL post_parent IN without NOT IN, shared SQL requests, and ignored-exclusion cache-key separation, parent-exclusion cache-key boundaries for canonical, reversed, and duplicated post_parent__not_in filters with identical payloads, duplicate-preserving sorted query vars, canonical/reversed key sharing, duplicate-key separation, and in-memory NOT IN row filtering, generated post-exclusion cache-key boundaries for canonical, reversed, and duplicated post__not_in filters over the same generated payload family, generated slug-inclusion cache-key normalization for canonical, reversed, and duplicated post_name__in filters with identical generated payloads, sanitized order/duplicate-preserving query vars, and shared per-projection cache keys, generated slug-inclusion ordering-boundary coverage for canonical, reversed, and duplicated post_name__in arrays under orderby=post_name__in with first-occurrence ordered payloads, raw-order cache-key separation, and in-memory FIELD(post_name,...) ordering, generated author-exclusion cache-key normalization for canonical, reversed, and duplicated author__not_in filters over generated child authors with sorted unique query vars, shared per-projection cache keys, in-memory post_author NOT IN row filtering, and generated user cleanup, generated author-inclusion cache-key normalization for canonical, reversed, and duplicated author__in filters over the same generated child authors with sorted unique query vars, shared per-projection cache keys, and in-memory post_author IN row filtering, generated author include/exclude precedence coverage for overlapping author__in and author__not_in arrays with exclusion payload precedence, raw ignored-include query-var preservation, SQL post_author NOT IN without post_author IN, shared SQL requests, and ignored-include cache-key separation, generated author_name slug normalization and slash-tail fallback coverage for canonical, nested, and trailing-slash generated user nicename paths with resolved author IDs, shared SQL requests/cache keys, and authored-child payload parity, generated author_name miss and zero-author fallback coverage for canonical unknown, known-prefix unknown, and trailing known-prefix unknown slug paths with strict false author resolution, SQL post_author = 0, author-0 sentinel payload parity, and shared SQL requests/cache keys, generated author_name double-slash empty-segment cache boundary coverage with normalized empty query vars, strict false author resolution, shared zero-author SQL requests with normal misses, distinct cache keys, and author-0 sentinel payload parity, generated author_name explicit author filter collision coverage with legacy author, author__in, and author__not_in match/mismatch matrices, conjunctive SQL author clauses, singleton and normalized-pair request-key convergence, distinct plain-author-name cache boundaries, and matching/empty payload parity, generated author_name simultaneous author include/exclude collision coverage with raw ignored include query vars, normalized author exclusions, post_author NOT IN precedence without post_author IN SQL, matching/empty payload parity, ignored-include cache-key separation, and normalized-exclusion key sharing, generated author_name boolean and non-zero scalar legacy author input coverage with true-to-one coercion, whole-number scalar convergence, digit-concatenated decimal and exponent-like boundaries, explicit filter group sharing, plain query separation, and selected-field payload parity, generated author_name empty, non-scalar, and parsed-zero legacy author input coverage with plain-like empty/non-scalar convergence, separator-only author__not_in=[0] parsing, plain request/cache sharing, parsed-zero zero-token sharing, and selected-field payload parity, generated author_name percent-encoded and noisy legacy author collision coverage with noisy pre-strip wrappers, malformed %zz canonicalization, encoded comma/hyphen retained-digit positive-only branches, literal-comma encoded-noise NOT IN boundaries, seed-sensitive 0%2C include-two semantics, and selected-field request/cache-key sharing, generated author_name whitespace and zero-token legacy author collision coverage with pre-parser digit/comma/hyphen stripping, whitespace-concatenated positive author filters, comma-surviving zero/empty tokens, literal-zero plain-author-name convergence, parsed-zero NOT IN boundaries, and selected-field request/cache-key sharing, and generated legacy author query-var normalization for canonical, reversed, and duplicated mixed positive/negative lists with signed sorted query vars, derived include/exclude arrays, not-in precedence payloads containing neutral-author sentinel rows, shared per-projection cache keys, and in-memory post_author NOT IN row filtering, cross-type ancestor path propagation, private/trash ancestor path visibility, missing-parent and self-parent fail-closed get_page_uri() paths, raw reserved query-var hierarchy paths with spaces, slash segments, plus signs, ampersands, and percent escapes, leavename, draft, sample, post_type_link event, missing-post, query-var, WP global, rewrite, post-type, filter, and content-count cleanup oracles. In-memory post, post-meta, post-to-term relationship, term, user, and comment CRUD lifecycles, duplicate/invalid inputs, sanitizer agreement, term insert/read duplicate handling, direct wp_update_term()/wp_delete_term() ID preservation, readable field/cache refresh, duplicate-slug, missing-parent, empty-name, and missing-term errors, child reparenting, object relationship cleanup, term lifecycle hook ordering/payloads, metadata cache invalidation, direct wp_insert_user()/wp_update_user() pre-user filter, wp_pre_insert_user_data, default/custom user-meta filter, role/default-role, activation-key clearing, password hashing, password/email change filter, illegal-login, empty-data, direct wp_delete_user() deletion/reassignment, hook ordering, usermeta deletion, lookup disappearance, post/link reassignment, custom delete_with_user post-type filtering, link deletion, invalid-user fail-closed paths, and hook-cleanup contracts, classic admin edit_post() save orchestration through translated request fields, allowed-post-data filtering, custom-field add/update/delete by meta ID, taxonomy sanitizer callbacks, spoofed type/MIME and guarded GUID/meta-input preservation, visibility/password final-state rules, edit-lock metadata, and post/meta hook ordering, classic admin bulk_edit_posts() updated/locked/skipped partitioning, locked/missing preservation, publish date reset, author/title/content/excerpt/comment/ping updates, category/tag merges, post-format mutation, sticky option updates, sticky and bulk-edit hook payloads, sentinel/no-stomp preservation for status/author/password/discussion/taxonomy/format/sticky fields, invalid-date skip/no-mutation branches, page-parent ancestor skip handling backed by widened post projection, top-level bulk-edit capability-denial wp_die() capture, denied-request no-mutation preservation, permission-filtered tag assignment with category updates, no-publish_posts bulk status coercion from requested publish to pending and requested private back to prior draft while preserving title/content/edit metadata, sticky add/remove gating by edit_others_posts, denied-intent bulk action payloads, persisted post/page transition coverage for wp_publish_post(), wp_update_post(), dynamic transition/edit/save/insert/after-insert hook timing, no-op publish stability, get_post_class() status/password classes, is_post_publicly_viewable(), private-read capability branches, render/capability no-mutation probes, direct set_post_type() filtered row mutation, stale cache eviction, page-specific cache cleanup, sibling/missing-ID boundaries, public page/post lookup helper contracts for get_page_by_path(), get_pages(), get_page_children(), and get_children(), ancestry paths, attachment fallback and collision preference, custom hierarchical post types, salted query-cache invalidation, include/child/parent/exclude-tree/limit rewrites, filter payloads, output-shape modes, status-transition hook order, direct-transition status storage, count/timeinfo cache branches, empty-GUID publish repair, future-post cron cleanup, relationship helper/cache/hook oracles, wp_count_posts() readable/cache/status grouping, wp_count_attachments() MIME/trash grouping, MIME group and available-MIME filter paths, set_post_thumbnail()/delete_post_thumbnail() metadata lifecycle, thumbnail HTML/URL/caption helpers, non-image cleanup, cache priming, post-delete cleanup, count refresh, and state restoration.
    auth-flowlocal validation passedScoped component worktree component-fuzz-auth-redirect; builds on public logout lifecycle, direct login-form rendering, and auth-cookie scheme boundary coverage.Latest focused runs: seed 224 passed 13 checks and seed 224 over 25 iterations passed 325 checks; seeds 1 and 57123 over 5 iterations each passed 65 checks; adjacent auth-flow,account-security,security,identity,user-preferences,request-lifecycle smoke passed 68 checks; syntax/diff checks passed; broad seed 224 smoke passed 5089 checks with 1 skip.Synthetic user rows, authentication filters/password paths, direct wp_login_form() rendering/filter/escaping contracts, echo/getter parity, generated required/remember field options, HTTPS default redirect derivation, auth_redirect() secure redirect, scheme, valid-cookie, user-SSL, login URL, nocache, and no-exit redirect boundary coverage, sign-on and clear-auth-cookie short-circuits, public wp_logout() lifecycle composition, current-session destruction with sibling preservation, stale-cookie rehydration guards, generated auth-cookie scheme boundaries and filter payloads, auth-cookie validation hooks, session lifecycle, and current-user/cookie restoration.
    wxr-exportfocused validation passedcomponent-fuzz-wxr-export-edges worktree; Pauli gap scan followed previous Raman review and header observability fixesLatest focused runs: seed 224 passed 11 checks, seeds 1 and 224 over 25 iterations each passed 275 checks, adjacent wxr-export,import-diff,content smoke passed 36 checks, and latest broad run passed 4709 checks with 5 skips.Subprocess-isolated export_wp(), selection arguments, non-exportable and invalid content fallback, null post/term/comment meta serialization, filtered-comment omission, nav-menu term boundaries, title/content/excerpt export filters, XML/CDATA/UTF-8 safety, meta skip filters, attachment URL/filemeta serialization, author/term ordering, filtered filename and XML content-type header intent with observable-header assertions when available, and state restoration.
    identityreview fixes passedScoped worktree component-fuzz-identity-user-dropdowns; extends earlier current-user lifecycle, username/email existence helper, and avatar helper identity coverage.Latest focused runs: seed 224 passed 1 aggregate row with 23 cases and 900 checks, seeds 1, 224, and 57123 over 25 iterations each passed 25 aggregate rows, adjacent identity,email,mail,auth-flow,account-security,security,comments,comment-workflow,template-links,user-preferences,privacy,multisite smoke passed 1836 checks with 1 skip, bootstrap smoke passed, and broad seed 224 smoke passed 4833 checks with 1 skip.Username/email sanitization, identity sanitizer filters, capability keys, generated contact-method filters, generic wp_dropdown_users() selector name/id/class, all/none/selected option, no-echo, args-filter, final-HTML-filter, escaped display_name_with_login, include_selected, single-author hiding, generated WP_User display name, URL, nicename, edit-context escaping and field-filter locality, current-user lifecycle helpers, legacy setup globals including user_level, set_current_user and determine_current_user hook contracts, current-user/global/hook counter restoration, generated user-cache cleanup, username/email existence helper cache/filter contracts, additional user keys, URLs, text/comment helpers, comment cookies/current-commenter payloads, filter-count restoration, options, passwords, and parse helpers.
    fontsexpanded validation passedMain worktree; Einstein implementation in ab3113389c; Euler review fixes through 5251e4ff47; Volta follow-up cache restoration in cdf3ecf90a; REST write lifecycle follow-up in 2cb3c60aa9Latest focused runs: 275 passed, 0 skipped, 0 failed at seeds 1 and 224 over 25 iterations; adjacent smoke: 43 passed, 2 skipped, 0 failed; latest broad smoke: 4662 passed, 28 skipped, 0 failed.Font-face CSS serialization, invalid face rejection, theme.json font-face resolver conversion and default wp_print_font_faces() output, font directory filters, Font Library collection lifecycle/JSON loading, REST font collection pagination, HEAD, _fields, _links, invalid collection skipping, filter cleanup, REST font-family/font-face create lifecycle, duplicate guards, upload src rewriting, relative font-file meta, parent-mismatch and non-force delete errors, force-delete file cleanup, family cascade deletion, REST font-face preparation boundaries, utility normalization, schema sanitization, and MIME maps.
    assetsreviewed validation passedMain worktree; Anscombe the 3rd implementation and Mencius the 3rd review found no actionable issues through c2c69c4132Main focused run: 1400 passed, 0 skipped, 0 failed; latest broad run: 8021 passed, 67 skipped, 0 failed.Script/style dependency ordering, inline assets, style add-data output metadata, strategy/fetchpriority/module data, source/version escaping, scoped loader-tag filters, and script modules.
    media-editorskip retirement passedMain worktree; Russell the 3rd implementation and Boyle the 3rd review findings fixed through f5aad1d00c; real-editor availability accounting through 8fd73afc6cLatest focused runs: seeds 1 and 224 each 225 passed, 0 skipped, 0 failed over 25 iterations; adjacent media smoke passed 101 checks with 0 skips; latest broad run: 4689 passed, 6 skipped, 0 failed.Editor selection, GD execution and Imagick/GD availability accounting, output format filters, abstract editor filename/quality/EXIF-orientation contracts, resize/save metadata, intermediate/generated sub-sizes, missing sub-size detection, cache/filter-backed attachment metadata.
    media-image-edit-requestsvalidation passedScoped worktree component-fuzz-media-image-edit-requests; implementation landed through cfb49670d1; generated image-editor AJAX request matrix coverage in current slice.Latest focused runs: seed 224 passed 12 checks with no skips, seeds 1, 224, and 57123 over 25 iterations each passed 300 checks with no skips, ordered adjacent rest-media-attachments,rest,rest-object-controllers,media-ingest,media-remote,media-metadata,admin-media-chrome,media-image-edit-requests,images,image-metadata smoke passed 106 checks with no skips, bootstrap smoke passed, and latest broad run: 4871 passed, 1 skipped, 0 failed.Server-side image-edit request coverage for normalized edit histories, generated wp_ajax_image_editor() denied capability, invalid nonce, save, scale-error, restore, and unknown-action branches, preview streaming and AJAX, save/restore metadata, save/stream filters, crop wrappers, parent-property copying, crop AJAX metadata/id filters, media sub-size AJAX boundaries, nonce/capability gates, captured wp_die()/JSON envelopes, temp upload roots, fake editor operations, and global/filter/superglobal restoration.
    filesystemvalidation passedScoped worktree component-fuzz-filesystem-archives; builds on unique-filename callback/case follow-up through b2a7077ab8 and recursive helper coverage through 60dec6d029.Latest focused runs: seed 224 passed 14 checks, seeds 1, 224, and 57123 over 25 iterations each passed 350 checks, adjacent filesystem,plugin-theme-lifecycle,update-install-upgrader,media-ingest,network-media smoke passed 265 checks, bootstrap smoke passed, and latest broad run passed 4750 checks with 1 skip.Path normalization/joining, validation classes, filename sanitization/uniqueness, callback and lowercase-extension collision metadata, temp names, wp_mkdir_p() recursive creation, list_files() hidden/exclusion/depth behavior, wp_is_stream()/wp_is_writable() contracts, direct filesystem sandboxing, metadata/time/chmod round trips, missing-file failure values, wp_zip_file_is_valid() valid/invalid archive parity, unzip_file() ZipArchive/PclZip extraction parity and skip guards, copy_dir() missing-source/skip-list behavior, and move_dir() overwrite and fallback-copy behavior.
    classic-walkersworker validation passedCurrent slice adds nav-menu quick-search and meta-box query coverage; earlier nav-menu matrix through 11f544d777 and direct admin nav helper coverage through bc2dec631c.Latest focused runs: seed 224 passed 14 checks, seeds 1 and 224 over 25 iterations each passed 350 checks, adjacent classic-walkers,admin-ajax,admin-list-tables,navigation,content,query,taxonomy,post-types smoke passed 729 checks with 1 existing skip, and latest broad run passed 4726 checks with 4 skips. Earlier direct admin nav helper runs passed 325 checks per seed; earlier nav-menu matrix passed 1200 checks with 100 historical skips.Classic walker traversal, generated hierarchies, depth and parent/current state, generated nav-menu item matrix locality, direct _wp_nav_menu_meta_box_object(), wp_nav_menu_disabled_check(), custom-link meta-box, nav-menu column helper contracts, direct _wp_ajax_menu_quick_search() JSON/markup branches, post-type archive pseudo-items, post-type/taxonomy meta-box pagination and search query oracles, has-children oracles, output escaping/filter boundaries, argument normalization, and state restoration.
    admin-ajaxlocal validation passedFind Posts modal and AJAX search coverage through 6070f5e381; earlier worker Ohm and reviewer Plato findings fixed through 259d6bd90e.Latest focused runs: seed 224 passed 11 checks, seeds 1 and 224 over 25 iterations each passed 275 checks, adjacent admin-ajax,admin-media-chrome,admin-workflows,admin-screen,content,post-types,query smoke passed 711 checks, and latest broad run passed 4725 checks with 4 skips.Captured wp_die() handlers, JSON/XML response helpers, nonce/capability failures, safe AJAX handlers, Find Posts modal output and wp_ajax_find_posts() JSON query branches, attachment query/save workflows, heartbeat nonce/hook/server-time branches, compression-test capability/body branches, superglobal and output-buffer restoration.
    feed-renderinglocal validation passedScoped worktree component-fuzz-feed-anchor-links; builds on legacy feed template/dispatch and exact rss_enclosure() fixture coverage.Latest focused runs: seed 224 passed 13 checks, seed 224 over 25 iterations passed 325 checks, seeds 1 and 57123 over 10 iterations each passed 130 checks, adjacent feed-rendering,feed-parsers,syndication,http,widgets smoke passed 56 checks, syntax/diff checks passed, and broad seed 224 smoke passed 5095 checks with 1 skip.RSS2, Atom, RDF, RSS 0.92, and comments feed templates, namespace/header/item hook payloads, context and loop-ID oracles, legacy do_feed() normalization/default-feed/comment-feed dispatch, direct the_feed_link() anchor rendering, feed_link/the_feed_link filter ordering and escaping, self-link request URI host/filter escaping, escaping, CDATA boundaries, content/excerpt switches, exact rss_enclosure() newline/filter fixture behavior, enclosure/build-date behavior.
    httplocal validation passedScoped worktree component-fuzz-http-encoding through 86ea9ecac9; origin helper coverage through a25a6c03f6; prior Requests success-path coverage through 1cc604ef19, review fixes through afc95daacc, and cleanup hardening through 1aecc04af2.Latest focused runs: seeds 1, 224, and 57123 over 25 iterations each passed 325 checks after WP_Http_Encoding coverage; adjacent http,feed-parsers,feed-rendering,syndication smoke passed 90 checks; bootstrap smoke passed; latest broad run passed 4792 checks with 1 skip.Remote request wrappers, pre_http_request short-circuiting, direct WP_Http::request() normalization and early-error contracts, real Requests success-path option mapping through a no-network fake transport, method/data-format/redirect/SSL/max-bytes/stream/cookie/header oracles, WP_HTTP_Requests_Response conversion, http_response mutation, debug hook cardinality, stream destination guards, chunk-transfer decoding, WP_Http_Encoding compression/decompression and accept-encoding contracts, safe URL flags, response helpers, header/cookie/proxy/redirect contracts, CORS/origin exact-match allowlists, HTTP origin filters, support capability normalization, and allowed request-host mirroring.
    securityfocused passedScoped worktree component-fuzz-security-admin-referer; builds on generated redirect dispatch coverage through 1bedb48d40, invalid admin nonce follow-up through component-fuzz-security-nonce-ays, review fixes through 47b9b32fb5, referer/native-password follow-up through 764df53851, and AJAX nonce verifier hook-edge coverage.Latest focused runs: seed 224 passed 19 checks and seed 224 over 25 iterations passed 475 checks after admin-referer hook-edge coverage; seeds 1 and 12345 over 5 iterations each passed 95 checks; hooks,security over 5 iterations passed 155 checks; adjacent security,request-lifecycle,formatting,canonical-routing,http smoke passed 80 checks; adjacent security,auth-flow,account-security,identity,admin-ajax,admin-options-submission,admin-workflows smoke passed 70 checks; latest broad run: 5085 passed, 1 skipped, 0 failed.Salts/HMACs, password and fast-hash verification filter locality, native bcrypt compatibility and migration rehash signals, nonce tick/lifetime boundaries, nonce URLs and hidden fields, raw/safe/original referer retrieval, admin/AJAX referers, generated check_ajax_referer action payloads and wp_verify_nonce_failed hook ordering/payloads for current, previous-tick, invalid, empty, custom-query, and Ajax stop=true nonce cases, generated check_admin_referer action payloads and wp_verify_nonce_failed hook ordering/payloads for current, previous-tick, custom-query, invalid, empty, and missing nonce cases, wp_nonce_ays() 403 capture, Ajax wp_die() route and 403 capture, invalid admin nonce failure paths, sanitized retry links, logout confirmation URLs, legacy -1 admin-referer bypass, auth cookie grace/session edges, redirect sanitization/validation expected outcomes, generated wp_redirect()/wp_safe_redirect() dispatch sequencing, invalid-status wp_die() capture, falsey-location cancellation before status enforcement, wp_safe_redirect_fallback and allowed_redirect_hosts dispatch boundaries, sanitized x_redirect_by payloads, and headerless redirect interruption.
    statefocused validation passedScoped worktree component-fuzz-state-cache-blog-switch; Huygens the 2nd reviewed overlap with multisite switch coverage, so this slice targets the direct object-cache wrapper.Latest focused runs: seeds 1, 224, and 57123 over 25 iterations each passed 500 checks, adjacent state,multisite,options-autoload smoke passed 250 checks with 5 existing skips, bootstrap smoke passed, and broad seed 224 smoke passed 4770 checks with 1 skip. Earlier main focused runs passed 19 checks per iteration.Object cache groups and multi-operations, direct wp_cache_switch_to_blog() local/global group prefixing without full blog-context switching, default-group blog locality, raw stored-key prefix oracles, cache-addition suspension, options, transient filters, cache-backed and option-backed site transients, timeout update and manual-expiration cleanup paths, dynamic site-transient filters, external object cache flag restoration, serialization/JSON value helpers.
    capabilitieslocal validation passedScoped component worktree component-fuzz-author-can; wrapper follow-up builds on site-scoped cap-key and meta-cap coverage.Latest focused runs: seed 224 passed 11 checks, seeds 1, 224, and 57123 over 25 iterations each passed 275 checks; adjacent capabilities,identity,content-lifecycle,query,query-loop,post-types,template-links,rest-object-controllers,admin-list-tables,user-preferences,security,auth-flow,account-security smoke passed 853 checks; bootstrap smoke passed; broad seed 224 smoke passed 4839 checks with 1 skip. Earlier site-scoped cap-key focused runs passed 1000 checks.Role registry lifecycle, associative/boundary caps, direct/user/role capability aggregation, author_can() post-author wrapper resolution for post objects and cached post IDs, missing-post/author fail-closed paths, variadic meta-cap forwarding, user mutators, WP_User::for_site() cap-key isolation, user_can_for_site() wrapper contracts, filter locality, meta-cap monotonicity.
    imagesintegrated validation passedZeno the 2nd / main worktree; rounding-drift oracle fix in 5f0eb0a4cc; content tag pipeline coverage through 93c6f19416, reviewed by McClintockMain focused runs: seed 1 and seed 224 each 1000 passed, 0 skipped, 0 failed after content tag pipeline review fixes; syntax/PHPCS/diff checks passed; latest broad run: 9075 passed, 66 skipped, 0 failed.Image helper math, synthetic metadata, responsive/filter boundaries, attachment attrs, content image/iframe tag filtering, direct auto-sizes/dimensions/srcset/sizes/loading helper gates, filetype helpers, and bounded wp_constrain_dimensions() rounding/idempotence behavior.
    discoveryvalidation passedScoped worktree component-fuzz-discovery-favicon-front-controller builds on robots front-controller, sitemap stylesheet, and built-in sitemap provider coverage.Latest focused runs: seeds 1, 224, and 57123 over 25 iterations each passed 375 checks, adjacent discovery,canonical-routing,request-lifecycle,http smoke passed 110 checks, disabled-subprocess smoke passed 14 checks with 1 skip, bootstrap smoke passed, and broad seed 224 smoke passed 4769 checks with 1 skip. Prior robots.txt front-controller runs passed 350 checks per seed.Robots meta, scoped public/private robots helper output, front-controller do_robots() robots.txt output, front-controller do_favicon() redirect-and-exit behavior through template-loader.php, favicon site-icon fallback/filter payloads, site-path admin directives, do_robotstxt/robots_txt hook order and cleanup, sitemap enablement, robots.txt injection, provider filters, query/permalink URL modes, escaped renderer XML, unsupported sitemap field boundaries, stylesheet URL filters, direct sitemap/index XSL output, LTR/RTL stylesheet CSS and filter locality, max-URL filters, and built-in posts/taxonomies/users sitemap provider subtype, lastmod, max-page, query-arg, pre-filter, and public/private gating oracles.
    import-difflocal validation passedScoped component worktree component-fuzz-importer-get-page; Nash identified the wrapper gap after upload/base helper coverage.Latest focused runs: seed 224 passed 11 checks, seeds 1 and 224 over 25 iterations each passed 275 checks, seed 57123 over 10 iterations passed 110 checks, adjacent import-diff,http smoke passed 120 checks, syntax/diff checks passed, and broad seed 224 smoke passed 5096 checks with 1 skip.Importer registry/form helpers, import upload missing-file and prefiltered-error paths, upload override and filter cleanup, import attachment cleanup, text diff rendering, post/comment imported lookup through in-memory postmeta/comment fixtures, importer chunking, duplicate permalink behavior, WP_Importer::get_page() HTTP wrapper semantics for method, timeout, safe-url args, Basic Auth, pre-HTTP response propagation, and timeout filter cleanup, base importer byte-length sorting, whitespace compaction, timeout/quota true and false branches, stop-the-insanity cleanup and WPDB restoration, WP_Error transfer and lifecycle ordering, and stub meta-query limit projection.
    navigationintegrated validation passedBernoulli the 3rd / main worktree through 9aa5167057Main focused run: 1000 passed, 0 skipped, 0 failed; latest broad run: 8497 passed, 66 skipped, 0 failed.Nav menu registry, current-tree parent/ancestor propagation, fallback/short-circuit pipeline, args/items-wrap normalization, walker filters, container and attribute escaping.
    navigation-lifecyclevalidation passedMain worktree on component-fuzzers; old component-fuzz worktrees and branches cleaned after validation.Main focused runs: seeds 1, 224, and 57123 over 25 iterations each passed 225 checks; adjacent navigation-lifecycle,navigation,customizer-nav-widgets-requests,rest-site-editor,block-templates,core-block-render,template-links,content-lifecycle,taxonomy-relationships smoke passed 490 checks; bootstrap smoke passed; latest broad run: 4766 passed, 1 skipped, 0 failed.DB-backed nav menu create/update/delete, duplicate-name failures, hook payloads, location assignment cleanup and remapping, custom/post/term menu item meta sanitization, ordering, orphan/self-parent normalization, associated object cleanup callbacks, auto-add page behavior, REST menu/menu-item/location read gates, validation errors, locations and auto_add mutation, forced-delete semantics, links, bootstrap controller includes, navigation fallback creation from classic menus, primary-location priority, parsed navigation-link/submenu block conversion, duplicate-free fallback reuse, query globals, and state restoration.
    post-embedsvalidation passedScoped worktrees component-fuzz-post-embeds, component-fuzz-post-embeds-oembed-cache, component-fuzz-post-embeds-consumer, and component-fuzz-post-embed-host-js; provider implementation landed through b958538155; REST proxy cache follow-up through 0c0debbb5e; consumer provider/cache follow-up through 6a78c3ec42; host-script gate follow-up through 57fe546d48.Latest focused runs: seeds 1, 224, and 57123 over 25 iterations each passed 200 checks; adjacent post-embeds,rest,http,content,template-links,syndication,media-remote,script-loader-runtime smoke passed 188 checks; bootstrap smoke passed; latest broad run: 4794 passed, 1 skipped, 0 failed.WordPress-as-provider oEmbed helper coverage for embeddability filters, public visibility gates, response width clamps, rich iframe/thumbnail conversion, plain/pretty/path-conflict embed URL selection, iframe/blockquote/script markup, discovery links, wp_maybe_enqueue_oembed_host_js() host-script enqueue gates, direct WP_oEmbed_Controller item responses, same-site pre_oembed_result short-circuiting, public custom provider add/remove behavior, no-network wp_oembed_get() provider fetch URLs, WP_Embed::shortcode() post-meta cache writes/hits, standalone and paragraph autoembed() replacement, inline URL preservation, REST oEmbed proxy provider fetch and transient-cache behavior, nonce-excluded cache keys, dimension-sensitive cache misses, oembed_fetch_url/oembed_remote_get_args/embed_oembed_html/oembed_ttl/oembed_result/rest_oembed_ttl filter oracles, no-network HTTP interception, no content-row mutation, and narrow wpdb stub support for core path-conflict lookup queries.
    rest-media-attachmentsvalidation passedScoped worktree component-fuzz-rest-media-leak; builds on component-fuzz-rest-media-url, component-fuzz-rest-media-attachments, component-fuzz-rest-media-sideload, and component-fuzz-rest-media-edit-postprocess.Latest focused runs: seeds 1, 224, and 57123 over 25 iterations each passed 225 checks; ordered admin-media-chrome,media-ingest,media-remote,image-metadata,rest-media-attachments repro passed 168 checks; full adjacent media-metadata,admin-media-chrome,media-ingest,media-remote,image-metadata,rest-media-attachments smoke passed 204 checks; bootstrap smoke passed; broad seed 224 smoke passed 5036 checks with 1 skip. Earlier route-dispatched edit/post-process adjacent smoke passed 860 checks.REST media attachment write coverage for raw upload parser/error contracts, raw body create_item() success, URL-based create_item() sideload downloads through no-network HTTP fixtures, missing-filename and unsupported-extension fail-closed branches, media_handle_sideload() attachment creation, REST insert action payloads, raw sideload subsize and original-image metadata updates, route-dispatched post-process and edit paths, invalid action enum and upload-permission denials before mutation, deterministic image-editor operations, edited attachment creation, parent-image metadata, non-image/PDF rejection, attachment postmeta and response headers, explicit permission gates, client-side media-processing route/argument contracts, metadata finalization filters, default and parent-date-keyed upload-directory cache isolation, _fields projection, edit-media fail-closed paths, and temp file/filter/global restoration.
    rest-widgets-sidebarsvalidation passedScoped worktree component-fuzz-rest-sidebar-fields builds on component-fuzz-rest-widgets-sidebars, component-fuzz-rest-widgets-dispatch, raw widget projection coverage, and current-core missing-sidebar diagnostics.Latest focused runs: seed 224 passed 13 checks and seed 224 over 25 iterations passed 325 checks after sidebar default-filtered route-envelope coverage; seeds 1 and 57123 over 5 iterations each passed 65 checks; adjacent rest-widgets-sidebars,widgets,default-widgets,block-widgets,customizer-nav-widgets-requests,rest,rest-controllers,block-editor-adjuncts,admin-ajax smoke passed 112 checks; syntax/diff checks passed; broad seed 224 smoke passed 5087 checks with 1 skip.REST widget, widget type, and sidebar controller route/schema contracts, public show_in_rest read gates, widget type sorting/projection and encoded instance hashes, isolated /widget-types/{id}/render iframe preview dispatch, fail-closed permission and invalid-id render paths, parent-safe IFRAME_REQUEST containment, text-widget preview hooks and mutable-state restoration, text widget create/update persistence, route-dispatched widget create/get/update/delete paths, sidebar reassignment/reorder mutation dispatch, invalid widget/sidebar schema fail-closed paths, hook payload timing, sidebar reorder/inactive semantics, legacy widget form-data updates, soft/force delete hooks, direct HEAD short-circuits, route-dispatched sidebar item/collection GET/HEAD through default REST filters, public/hidden/inactive sidebar _fields projection, item and collection widget links, target hints, Allow headers, response envelopes, prepare-filter locality, scoped cap/default-filter cleanup, update-guard reset isolation, current-core missing-sidebar update diagnostics, route-dispatched missing-sidebar current non-fail-closed behavior with warning/hook/raw-sidebar oracles, raw sidebar widget projection boundaries, duplicate/stale widget handling, and state restoration.
    maillocal validation passedScoped worktree component-fuzz-mail-emoji-wrapper; builds on address encoding and invalid-From failure coverage.Latest focused runs: seed 224 passed 12 checks, seed 224 over 25 iterations passed 300 checks, seeds 1 and 57123 over 10 iterations each passed 120 checks, adjacent mail,email,privacy smoke passed 3840 checks, syntax/diff checks passed, and broad seed 224 smoke passed 5094 checks with 1 skip.wp_mail(), PHPMailer state reuse, UTF-8 local-part and IDN-domain recipient/display-name handoff, RFC2822 display-name MIME header encoding, literal mailbox preservation, decoded header round trips, unset SMTP Sender, UseSMTPUTF8 boundaries, recipient/header parsing, attachments, embeds, multipart boundary matrix, serialized MIME body/header preservation, invalid From failure payloads, wp_staticize_emoji_for_email() content-type matrix coverage for array/string headers, filtered content types, missing-message stability, non-message field preservation, cleanup/reset behavior, and success/failure hooks.
    block-widgetsreviewed validation passedScoped component worktree component-fuzz-widget-editor-deps; editor dependency warning follow-up through 309a336a97; earlier widget ID cleanup through 32acfc1459, ID fixture fix through 4b90ae7339, retrieve follow-up through 12dcc507e5.Latest focused runs: seeds 1, 224, and 57123 over 25 iterations each passed 250 checks; adjacent block-widgets,widgets,default-widgets,rest-widgets-sidebars,customizer-nav-widgets-requests,block-editor-adjuncts,assets smoke passed 68 checks; bootstrap smoke passed; broad seed 224 smoke passed 4798 checks with 1 skip. Earlier retrieve/remap focused run passed 900 checks.WP_Widget_Block, legacy class matrix, malformed/unknown block fallbacks, update/form escaping, the_widget() display/action/content ordering, rendered block output, registered control rendering, widget ID parsing, unregistered-widget cleanup, sidebar widget mapping, retrieve_widgets() remapping, customize no-persist behavior, orphaned/inactive carryover, lost multi-widget recovery, wp_check_widget_editor_deps() script/style conflict warnings, both widget-editor handles, dependency-chain enqueued semantics, scoped _doing_it_wrong() capture, asset queue/registration stability, and state restoration.
    l10nlocal validation passedKant the 2nd / main worktree; locale-stack follow-up through 2f006c120fMain focused run: 1400 passed, 0 skipped, 0 failed; latest broad run: 8525 passed, 66 skipped, 0 failed.Translation globals, MO files, generated locale-switch stack/action payload matrices, locale switching, script translations, nooped plurals, filters/actions.
    privacyvalidation passedScoped worktree component-fuzz-privacy-export-zip; builds on export-email notification follow-up through 72f3ae35e3, review fixes through 970437d39f, comments callback follow-up through 4fd5feddc9, final erasure completion follow-up through df3be8a1aa, built-in user/media exporter follow-up through de213220ba, and privacy policy lifecycle follow-up through 8cf920b5c3.Latest focused runs: seed 224 passed 20 checks, seeds 1, 224, and 57123 over 25 iterations each passed 500 checks, adjacent privacy,privacy-admin-requests,admin-ajax,filesystem,mail smoke passed 315 checks, bootstrap smoke passed, and latest broad run passed 4751 checks with 1 skip; related PHPUnit: 143 tests, 358 assertions.User requests, request keys, missing-request and global-post fallback fail-closed behavior, exporter/eraser registries and processors, real personal data export ZIP generation with filtered temp roots, exact export.json/index.html archive inspection, report HTML escaping and KSES value handling, export-created action payloads, temp report cleanup, legacy export-file meta migration, invalid request/action/email/JSON fail-closed paths, final erasure completion status/meta/action behavior, built-in comments exporter/eraser payloads and anonymization, built-in user profile/community-location/session-token exporter payloads, additional profile filter and escaped-report contracts, built-in media exporter registration, author/type filtering, URL payloads, and 50-item pagination, export email recipient/subject/content/header filters through fake PHPMailer, invalid-request and mail-error paths, default Unicode email filter setup, temp export paths, privacy policy registration/default content, suggested-text lifecycle cache transitions, text-change cache and admin notices, and state restoration.
    privacy-admin-requestslocal validation passedScoped component worktree component-fuzz-privacy-admin-actions; bulk resend follow-up on component-fuzz-privacy-resend; original feature coverage through 0cc587f3a1Main focused runs: seed 224 passed 7 checks, seeds 1 and 224 over 25 iterations each passed 175 checks after admin form action/default processor coverage, adjacent privacy-admin-requests,privacy,mail,email,user-preferences,admin-workflows,admin-ajax smoke passed 1314 checks with 1 skip, and latest broad run passed 4707 checks with 5 skips.Privacy export and erasure request list-table view/count/status filtering, row action nonce/data attributes, checkbox/status/bulk-action markup, direct request completion/delete helpers, mixed bulk resend success/failure accounting, regenerated confirmation keys and stored hash validation, intercepted confirmation email subject/content/header filter contracts, admin add-request and retry form handlers, pending request cleanup, default export/erasure AJAX processor state transitions, AJAX export and erasure success and fail-closed branches, capability gates, selected exporter/eraser/page callbacks, malformed callback response contracts, scoped Unicode email filters, runtime cache isolation, and state restoration.
    interactivityreview fixes passedMain worktree; Copernicus the 3rd exploration and Rawls the 3rd review findings fixed through 4747a53d87Main focused run: 1000 passed, 0 skipped, 0 failed; bootstrap smoke passed; latest broad run: 7982 passed, 66 skipped, 0 failed.Interactivity API directives, context namespace stack merge/sort/restoration, derived closure tracking and fail-closed errors, state/config stores, script module hooks, router region support.
    appearance-medialocal validation passedScoped worktree component-fuzz-appearance-background-remove; custom-header print side-effect follow-up through 627a726d0e builds on site-icon attachment URL follow-up through a942ff51c7, head-callback CSS follow-up through 643ba2fd4d, Dirac the 4th fixes through 39a5bb77d1, and no-upload admin guard follow-up through 91a7645818.Latest focused runs: seed 224 passed 12 checks, seeds 1 and 224 over 25 iterations each passed 300 checks after background remove safe-redirect coverage; adjacent appearance-media,admin-media-chrome,media-ingest,media-metadata,images,filesystem,customizer smoke passed 350 checks with no skips; latest broad run: 4736 passed, 1 skipped, 0 failed.Theme mods, theme support globals, custom background/header helpers, custom background frontend head-callback CSS normalization and escaping, custom-logo hide-header-text CSS class sanitization, custom header video URL/settings/markup active gates, exact the_custom_header_markup() output/enqueue/localization side effects with isolated WP_Scripts state, synthetic custom-logo attachment markup and filter payload oracles with DB-stub cleanup, request-scheme-aware header oracles, site icon filters, real site-icon option-to-attachment metadata URL and stale-ID fallback oracles, bounded Custom_Image_Header::step(), no-upload header/background admin action guards, remove-background same-host and fallback safe redirects, invalid remove nonce fail-closed behavior, deprecated media field/tab passthroughs, content-count state restoration, and post type/status bootstrap.
    customizer-persistencevalidation passedScoped worktree component-fuzz-customizer-save builds on earlier persistence work through 8f757f9079.Latest focused runs: seed 224 passed 8 checks, seeds 1 and 224 over 25 iterations each passed 200 checks, adjacent customizer-persistence,customizer-nav-widgets-requests,customizer,admin-ajax,user-preferences smoke passed 44 checks, and broad seed 224 smoke passed 4702 checks with 5 skips.Customizer classes, changeset UUID/data parsing, changeset lock/heartbeat persistence, transactional changeset saves, captured WP_Customize_Manager::save() AJAX envelopes, nonce/status/date/JSON failure paths, successful draft save response filters, edit locks, Custom CSS settings, options/theme mods, and global/superglobal restoration.
    rest-site-editorvalidation passedScoped worktree component-fuzz-rest-site-editor-revisions-autosaves builds on mutation coverage, bounded dispatch coverage through 6002f40de7, review fixes through 63aadcb347, template collection dispatch guards through 0b57a4238a, and collection GET dispatch coverage.Latest focused runs: seeds 1, 224, and 57123 over 25 iterations each passed 400 checks; adjacent rest-site-editor,rest,rest-object-controllers,block-templates,revisions-autosaves,content-lifecycle,blocks smoke passed 435 checks; bootstrap smoke passed; broad seed 224 smoke passed 4802 checks with 1 skip.REST server/request, global styles/templates/navigation controllers, bounded template and template-part collection GET dispatch, post-type and area query propagation, _fields collection projection, bounded pre_get_block_templates fixtures, template and template-part item dispatch, template and template-part mutation lifecycles, create/update/delete/reset controller paths, REST insert hooks, origin metadata, theme and area taxonomy assignments, collection route-index contracts, denied collection/lookup dispatch before template lookup, HEAD collection short-circuits, revision/autosave collection route metadata and collection dispatch, bounded revision WP_Query shapes, revision query filters, template-id-preserving revision pagination headers/links, autosave filtering, invalid-page failures, lookup fallback retry/no-fallback dispatch, direct ZIP export generator, subprocess-isolated live export streaming, scoped template filters, streamed ZIP inspection, archive cleanup, temp fixtures, and theme-state restoration.
    media-remotereviewed validation passedMain worktree; Ptolemy implementation reviewed clean by Planck through 570897ffdfMain focused run: 700 passed, 0 skipped, 0 failed after download_url() filename derivation matrix; reviewer seed 224 run: 700 passed, 0 skipped, 0 failed; latest broad run: 8641 passed, 66 skipped, 0 failed.HTTP short-circuiting, case-insensitive download headers, Content-Disposition/content-type filename derivation and sanitization matrix, URL basename fallback, signature soft/hard failures, temp files, filetype checks, media sideload return types, sideload prefilter cleanup and override filename contracts.
    bookmark-linksreview follow-up passedMain worktree through d0c40e9079Main focused run: 900 passed, 0 skipped, 0 failed; latest broad run: 8497 passed, 66 skipped, 0 failed.Link/bookmark APIs, optional narrow wpdb link-table support, template links, and direct _walk_bookmarks() image/update/filter rendering contracts.
    template-linksreviewed validation passedScoped worktree component-fuzz-template-authors; author-template helper coverage landed in 02a49529f7 after template archive navigation, direct search-form, adjacent image attachment, post container, archive, adjacent-post, and canonical coverage.Latest focused runs: seeds 1, 224, and 57123 over 25 iterations each passed 425 checks; adjacent template-links,identity,query,query-loop,content-lifecycle,feed-rendering,discovery smoke passed 3905 checks; bootstrap smoke passed; broad seed 224 passed 4799 checks with 1 skip. Earlier archive-navigation focused runs passed 400 checks per seed, search-form focused runs passed 375 checks per seed, and adjacent-image focused runs passed 350 checks per seed.Body/language attributes, body/language filter ordering and locality, get_post_class()/post_class() base/custom/filter/taxonomy/sticky/password/thumbnail container tokens, document titles, resource hints/preloads, pagination/search/feed/site/admin URLs, archive post-list navigation wrappers, get_the_posts_pagination() argument filtering and echo parity, _navigation_markup() class/ARIA escaping, direct get_search_form() HTML5/XHTML rendering, ARIA/query escaping, echo/getter parity, legacy boolean return mode, ordered search-form hook/filter payloads, null-filter fallback, output-buffer and query-state restoration, canonical and shortlink head output, synthetic post helpers, date and author archive URL helper filter payloads, author display/modified-author/meta/link/post-count/listing/multi-author helper contracts, dirty author nicename display escaping/locality, previous/next adjacent post relation links, adjacent image attachment links, and bookmark rendering.
    image-metadatalocal validation passedMain worktree through 582c472ce7; real Core fixture replay follow-up in the current component slice.Latest focused runs: seed 224 passed 8 checks, seeds 1 and 224 over 25 iterations each passed 200 checks, adjacent media parser/chrome smoke passed 87 checks with no skips, standalone rest-media-attachments sanity passed 7 checks, and latest broad run passed 4730 checks with 1 skip. Earlier focused run: 700 passed, 0 skipped, 0 failed.Admin image metadata parser, EXIF/IPTC availability, locale-aware XMP alt selection/fallbacks, generated local binary fixtures, real Core EXIF/IPTC/XMP fixture replay for representative camera, timestamp, keyword, orientation, UTF-8 caption, and accessibility-alt metadata, parser filter payloads, and state restoration.
    utility-internalslocal validation passedScoped component worktree component-fuzz-diagnostics-error-helpers; Descartes the 2nd reviewed direct diagnostic/error helper gaps and hook-state caveats; builds on earlier component-fuzz-utility-deep and component-fuzz-token-map-export work.Latest focused runs: seeds 1, 224, and 57123 over 25 iterations each passed 300 checks; adjacent utility-internals,hooks,import-diff,registries,error-protection,site-health-debug smoke passed 305 checks; bootstrap smoke passed; broad seed 224 smoke passed 4762 checks with 1 skip.List utilities, chained filter/sort/pluck state, parse-list and array-path helpers, token-map lookup/precompute/export behavior, non-default key-length prefix reconstruction, exported-key NUL boundaries, rewrite match substitution, URL pattern prefixing, lodash-compatible kebab-case helper fixtures, hierarchy-loop detection and direct tortoise-hare probes, unique ID and UUID contracts, boolean validation semantics, direct is_wp_error() action payload/counter/context checks, wp_debug_backtrace_summary() raw/pretty/skip/ignore-class stack contracts, wp_trigger_error() hook/filter/debug-gating oracles, cloned hook snapshots, and state restoration.
    icons-connectorsreviewed validation passedMain worktree through fb5b15bc76; Rawls implementation reviewed clean by PauliMain focused run: 700 passed, 0 skipped, 0 failed; reviewer found no actionable findings; latest broad run: 8653 passed, 66 skipped, 0 failed.Connector registry, REST settings AI-key validation fail-closed behavior, key masking and file-modification policy, connector plugin install/activation module-data status, icons registry, REST icon guards, schema/escaping contracts.
    script-loader-runtimevalidation passedScoped worktree component-fuzz-script-polyfill; builds on emoji style split, concat follow-up, JIT localization, and emoji loader asset accounting coverage.Latest focused runs: seeds 1, 224, and 57123 over 25 iterations each passed 400 checks; adjacent script-loader-runtime,assets,blocks,block-supports,style,fonts,frontend-features smoke passed 410 checks; bootstrap smoke passed; latest broad run: 4803 passed, 1 skipped, 0 failed.Script/style loader helpers, conditional polyfill inline script generation for relative/content/CDN URLs, version query args, script_loader_src filter payloads and skip behavior, inline data, translations, emoji style enqueue/hook removal and inline CSS escaping, emoji detection settings/module output with generated loader-asset materialization/cleanup, isolated JIT localization for autosave, mce-view, and word-count with generated shortcode tags, JSON escaping, child/parent state restoration and row-local subprocess capability skips, strategy/fetchpriority/module interactions, generated classic-script module import-map/modulepreload graphs, concatenated load-scripts/load-styles URL chunk parsing, excluded async/defer/external tags, inline sourceURL placement, output ordering, and state restoration.
    error-protectionlocal validation passedScoped component worktree component-fuzz-error-protection-lifecycle; builds on Cicero the 3rd / main worktree through 4bc14e0ecc, pure boundary follow-up through 61e22a3455, and process-control subprocess follow-up.Latest focused runs: seed 224 passed 13 checks with no skips, seeds 1, 224, and 57123 over 25 iterations each passed 325 checks with no skips, adjacent error-protection,site-health,site-health-debug,plugin-theme,plugin-theme-lifecycle,update-install-upgrader,filesystem,request-lifecycle,environment-load smoke passed 121 checks, bootstrap smoke passed, and broad seed 224 smoke passed 4845 checks with 1 skip.Recovery mode initialization, cleanup-event scheduling, valid-cookie activation precedence, paused-extension storage active/inactive lifecycle, recovery exit cleanup, recovery-key cleanup TTL negotiation, fatal handler guards, synthetic fatal-handler handle() orchestration, recovery-link begin-link early returns and child-process redirect/die paths, recovery-mode exit redirect/die/cleanup paths, wp_register_fatal_error_handler() drop-in shutdown execution, recovery-mode handle_error() gate behavior, fatal-error email rate limits, and no real mail or parent-process exits.
    feed-parsersfocused validation passedScoped component worktree component-fuzz-feed-fetch; Archimedes read-only gap scan identified RDF parser coverage, with additional bounded fetch_feed() orchestration coverage added locally.Latest focused runs: seed 224 passed 11 checks, seeds 1 and 224 over 25 iterations each passed 275 checks, adjacent feed-parsers,feed-rendering,syndication,http,widgets smoke passed 52 checks, and broad seed 224 smoke passed 4712 checks with 5 skips.RSS/Atom/RDF parsers, Magpie RSS 1.0 rdf:about and namespace normalization, image/textinput parsing, AtomParser local-file behavior, SimplePie raw-data/KSES parsing, WP_SimplePie_File HTTP response/header/error-state normalization, fetch_feed() empty/single/multi/error orchestration, transient cache hook payloads, bounded XML fixtures, temp cache paths, preempted HTTP responses, no-network assertions, and state restoration.
    environment-loadreview fixes passedScoped component worktree component-fuzz-env-memory-limit; Averroes the 2nd reviewed isolated wp_raise_memory_limit() coverage; builds on environment type matrix follow-up through 7580167b9c and review fix through 2047262604.Latest focused runs: seeds 1, 224, and 57123 over 25 iterations each passed 450 checks; adjacent environment-load,site-health,site-health-debug,cron,media-editor,update-install-upgrader smoke passed 1520 checks; bootstrap smoke passed; broad seed 224 smoke passed 4761 checks with 1 skip.Load/compat helpers, isolated WP_RUN_CORE_TESTS environment-type matrices, env-var allowed/fail-closed behavior, WP_ENVIRONMENT_TYPE constant precedence including invalid constant fail-closed behavior, parent/child environment restoration, row-local subprocess capability skips, memory parsing and ini mutability, isolated wp_raise_memory_limit() context/filter negotiation, high-current and unlimited-current no-op branches, dynamic memory-limit hooks, SSL headers, generated JSON/XML request media matrices, and scoped superglobals.
    update-install-upgraderfocused validation passedcomponent-fuzz-upgrader-notifications worktree; Halley gap scan followed earlier McClintock/Helmholtz updater workLatest focused runs: seed 224 passed 14 checks, seeds 1 and 224 over 25 iterations each passed 350 checks, adjacent update-install-upgrader,plugin-theme,plugin-theme-lifecycle,filesystem,admin-list-tables smoke passed 58 checks with 1 existing skip, and latest broad run passed 4710 checks with 5 skips.No-network updater flows, update transient aggregation, upgrader skins, temp install-package lifecycle, temp-backup cleanup/restore default paths, package validation, auto-update filters, VCS/PHP gates, core version policy, plugin/theme auto-update notification classification and failure-cache behavior, intercepted email delivery, maintenance-mode temp writes.
    plugin-themevalidation passedScoped worktree component-fuzz-plugin-theme-admin-helpers; builds on dependency public-contract coverage through 260ccee45d.Latest focused runs: seed 224 passed 13 checks, seeds 1, 224, and 57123 over 25 iterations each passed 325 checks, adjacent plugin-theme,plugin-theme-lifecycle,update-install-upgrader,filesystem,admin-list-tables,rest-controllers smoke passed 83 checks, bootstrap smoke passed, and latest broad run passed 4827 checks with 1 skip.Plugin headers/path helpers, invalid path validation, generated real-plugin WP_PLUGIN_DIR discovery, valid and no-header validate_plugin() contracts, is_network_only_plugin(), get_plugin_files(), validate_file_to_edit(), filtered active/inactive/network option states, dependency slug/name/API-data fallbacks with fail-closed live-API guards, local active dependency option states, theme headers, parent/child relationships, active theme file helpers, screenshots, broken theme errors, and fixture/cache/filter cleanup.
    plugin-theme-lifecyclevalidation passedScoped worktree component-fuzz-rest-plugin-install builds on earlier lifecycle work through 848decfbb5 and REST write/delete coverage.Latest focused runs: seed 224 passed 14 checks, seeds 1 and 224 over 25 iterations each passed 350 checks, adjacent plugin-theme-lifecycle,rest-controllers,update-install-upgrader,filesystem smoke passed 270 checks, and broad seed 224 smoke passed 4718 checks with 4 skips.Plugin/theme lifecycle helpers, generated headers and paths, activation/deactivation hook locality, multi-plugin deactivation scope/action payload ordering, basename/path normalization, theme roots, stylesheet/template boundaries, read-only REST plugin/theme controller paths, REST plugin create/install paths with generated packages and mocked repository API responses, REST plugin status update transitions, active-delete rejection, inactive temp-plugin deletion, updater-hook isolation, and filesystem/cache cleanup.
    install-schemareview fixes passedMain worktree; Sartre implementation and Poincare review findings fixed through 224b36546dMain focused run: 1100 passed, 0 skipped, 0 failed after creation/replay review fixes; reviewer seed 224 run: 1100 passed, 0 skipped, 0 failed; latest broad run: 8639 passed, 66 skipped, 0 failed.No-DB install/schema wrappers, wp_get_db_schema() scope expansion, global-table upgrade gate filters, make_db_current() noisy/silent wrappers, dbDelta() parser/diff/index/allowlist contracts, missing-table creation messages, same-double replay no-ops, and executed/introspection table allowlists.
    translationslocal validation passedFeynman the 2nd / main worktree; controller follow-up through 89fd5f092eMain focused run: 800 passed, 0 skipped, 0 failed; latest broad run: 8523 passed, 66 skipped, 0 failed.Translation globals, MO/PO/PHP files, direct WP_Translation_Controller locale/domain/file isolation, lazy malformed-file eviction, locale switching, script translations, nooped plurals, install/API short-circuit paths, and filter/action restoration.
    community-eventslocal validation passedScoped component worktree component-fuzz-community-events-ajax; admin AJAX wrapper coverage validated before merge.Latest focused runs: seed 224 passed 10 checks, seeds 1 and 224 over 25 iterations each passed 250 checks, adjacent community-events,admin-ajax,admin-dashboard,http,user-preferences smoke passed 54 checks, and latest broad run passed 4714 checks with 5 skips.IP anonymization, fail-closed request minimization, transient/cache behavior, strict coordinate matching, cache expiration normalization, search-triggered cache refresh, event trimming, response and error contracts, and wp_ajax_get_community_events() JSON envelopes/user-location persistence.
    taxonomylocal validation passedScoped component worktree component-fuzz-taxonomy-hierarchical-term-links; builds on the no-DB taxonomy surface.Latest focused runs: seeds 1, 224, and 57123 over 25 iterations each passed 325 checks; adjacent taxonomy,taxonomy-relationships,template-links,classic-walkers,rest-controllers smoke passed 335 checks; bootstrap smoke passed; latest broad run passed 4771 checks with 1 skip. Earlier main focused run passed 1200 checks.Taxonomy registry lifecycle, registration filters/actions, object shape, REST controller creation, query/rewrite side effects, term-query short-circuit parsing, term field filters, hierarchy helper edges, cache-backed term objects, link helpers, hierarchical parent-slug expansion, get_term_parents_list() agreement, and legacy term-link filter ordering.
    admin-barvalidation passedScoped worktree component-fuzz-admin-bar-nodes; single-site callback node graph follow-up through 9211f6d1e5; earlier default callback node graph follow-up through 555495f583.Latest focused runs: seeds 1, 224, and 57123 over 25 iterations each passed 225 checks; adjacent admin-bar,post-types,assets,script-loader-runtime,update-install-upgrader,site-health smoke passed 77 checks; bootstrap smoke passed; latest broad run: 4795 passed, 1 skipped, 0 failed.Toolbar node lifecycle, root/submenu binding, raw/escaped render contracts, back-compat parent aliases, deprecation hook firing, numeric/hostile tabindex rendering, initialization hooks/theme support, show_admin_bar() filters, default menu hook priorities/action firing, default callback-produced WordPress logo, account, appearance, comments, search, and secondary-group node graphs, plus single-site site menu, new-content, updates, sidebar-toggle, and command-palette callback nodes.
    post-typeslocal validation passedScoped worktree component-fuzz-custom-hier-collections; adds generated custom post type rewrite/link matrix coverage on top of archive helper and unregister cleanup coverage.Latest focused runs: seed 224 passed 14 checks, seeds 1, 224, and 57123 over 25 iterations each passed 350 checks, seed 100 over 20 iterations passed 280 checks; adjacent posts/canonical/REST smoke passed 1494 checks with no skips; bootstrap smoke passed; latest broad run passed 5051 checks with 1 skip.Post type/status registries, sanitized post-status key storage and overwrite behavior, generated is_post_status_viewable() public/private/custom/built-in/internal/protected object-property matrices, string/object lookup parity, unsanitized-name and invalid-input fail-closed paths, strict boolean is_post_status_viewable filter contracts and cleanup, duplicate post-type replacement cleanup for query vars, rewrite rules, supports, taxonomies, hooks, and meta caps, registration filters/actions and meta-box lifecycle, isolated REST route registration, support-gated revisions/autosaves, late route ordering, capabilities, query/rewrite state, unregister cleanup, archive/feed link helpers and filters, and generated custom post type rewrite/link matrices for hierarchical pretty, query-only archive fallback, fronted custom archive, and rewrite-disabled hidden branches.
    stylelocal validation passedMendel the 3rd source-order follow-up through cc5a912e25; Erdos block-support wrapper follow-up through 11704a8287; Newton review fixes through efff07281e; global stylesheet resolver follow-up through bc27826265Latest focused runs: seeds 1, 224, and 57123 over 25 iterations each passed 400 checks after global styles user-data/getter coverage; adjacent style,fonts,block-supports,rest-site-editor smoke passed 245 checks, bootstrap smoke passed, and broad seed 224 smoke passed 4768 checks with 1 skip. Prior global stylesheet runs passed seed 1 and seed 224 at 375 checks, and block-support wrapper runs passed seed 1 and seed 224 at 1400 passed with 100 skips.Style engine preset class/CSS-var boundaries, block selector helpers, theme.json variable resolution, registered block style style_data source-order injection, block-support register/apply callbacks, direct and aggregate WP_Block_Supports wrapper attributes, final get_block_wrapper_attributes() escaping/merge/dedupe behavior, custom-only hostile style values, exact render_block hook restoration, block editor theme style filters, direct seeded wp_get_global_stylesheet() variables/styles/presets/custom-css output, cache reuse/preservation, direct wp_get_global_settings()/wp_get_global_styles() getter paths, wp_global_styles active-theme user-data lookup, safe-flag fail-closed behavior, global style post-ID creation/cache reuse, zero unintended wpdb-stub leakage, resolver/object-cache restoration, stylesheet serialization, and cleanup.
    site-health-debugskip retirement passedMain worktree; Chandrasekhar the 3rd findings fixed through e11ee9a6a7; full debug-data scan follow-up through 9f1d3f3662; database malformed-row boundary accounting through 791c426de8Latest focused runs: seeds 1 and 224 each 225 passed, 0 skipped, 0 failed over 25 iterations; adjacent site-health-debug,site-health,http,environment-load,cron smoke passed 139 checks with 0 skips; latest broad run: 4688 passed, 7 skipped, 0 failed. Prior size-helper matrix passed 700 checks with 200 skips.WP_Debug_Data formatting, debug_information filter locality, isolated full debug_data() section assembly, fake no-network WordPress.org response, bounded Ghostscript detection, path-size loading placeholders, database size including malformed SHOW TABLE STATUS row strict/omission boundary accounting, generated directory/database/total size aggregation, timeout-control skip behavior, scoped SHOW TABLE STATUS/SHOW VARIABLES wpdb doubles, MySQL variable fallbacks, and child-process cleanup/restoration.
    site-healthlocal validation passedScoped worktree component-fuzz-site-health-auto-updates; builds on Schrodinger implementation through 38d7aaaabe, Raman review fixes through 6b25026e9b, and loopback/REST/cron follow-up through a9fdacdc73.Latest focused runs: seed 224 passed 13 checks, seeds 1, 224, and 57123 over 25 iterations each passed 325 checks; adjacent site-health,site-health-debug,update-install-upgrader,plugin-theme,plugin-theme-lifecycle,admin-list-tables smoke passed 75 checks; bootstrap smoke passed; latest broad run: 4840 passed, 1 skipped, 0 failed.Site Health update and HTTPS helpers plus generated site_status_tests filter modes, direct/async test metadata preservation, plugin/theme auto-update status branches through detect_plugin_theme_auto_update_issues() and get_test_plugin_theme_auto_updates(), synthetic loopback/REST request outcomes and request-shape oracles, scheduled event missed/late/future cron classification, HTTP-blocking constant status checks, persistent object cache thresholds/filters/table-row oracles, direct callback mutation through site_status_test_result, environment/basic-auth gates, no-network guarantees, and singleton/output-buffer restoration.
    block-supportslocal validation passedScoped worktree component-fuzz-block-supports-style-callbacks; direct style callback coverage builds on base and duotone block-support coverage.Latest focused runs: seeds 1, 224, and 57123 over 25 iterations each passed 200 checks; adjacent block-supports,blocks,style,core-block-render,block-editor-adjuncts,block-widgets,interactivity,fonts smoke passed 82 checks; bootstrap smoke passed; latest broad run: 4830 passed, 1 skipped, 0 failed.WP_Block_Supports, block support registration callbacks, direct color/spacing/border/typography/dimensions/shadow callback output, wrapper attribute merging, skip-serialization gates, auto-generated control markers, background/dimensions/visibility/position/layout render filters, elements and custom CSS render-data filters, state-style selector/layout helpers, duotone support registration gates, legacy metadata migration, preset/custom/unset/global-style render paths, empty-content CSS generation, unsupported/no-attribute fail-closed paths, safe stored CSS/SVG/editor assets, WP_Block_Type_Registry, theme-json caches, scripts/styles globals, style-engine stores, and WP_Duotone static restoration.
    blockslocal validation passedScoped worktree component-fuzz-builtin-block-bindings; builds on nested attribute, render-time block bindings, and block hook follow-ups through 51db0730d0, f53cfd1bfc, d83d02f5e0, 9e4c4ee83c, and the post-object hooks slice.Latest focused runs: seeds 1, 224, and 57123 over 25 iterations each passed 250 checks; adjacent blocks,registries,block-supports,core-block-render,content-lifecycle,taxonomy,taxonomy-relationships,navigation,navigation-lifecycle smoke passed 450 checks; bootstrap smoke passed; latest broad smoke passed 4801 checks with 1 skip.Nested parse/serialize round trips, generated nested attribute round trips, has_block()/has_blocks(), dynamic render callbacks, render filters, registry/support behavior, render-time block bindings supported-attribute filters, source and value-filter payloads, context propagation, computed attribute merge before dynamic render callbacks, supported HTML replacement, unchanged unsupported attributes, missing/malformed binding skips, built-in block binding source loading/registration for core/pattern-overrides, core/post-data, core/post-meta, and core/term-data, pattern override lookups, post and navigation entity source paths, REST meta visibility/protection gates, term field escaping and non-public taxonomy gates, block hook insertion plus ignored-metadata oracles, post-object wrapper root metadata, update_ignored_hooked_blocks_postmeta(), and REST response raw/meta/rendered block hook mutation.
    admin-options-submissionlocal validation passedScoped component worktree component-fuzz-admin-options-core-pages; builds on pending admin email coverage.Latest focused runs: seeds 1, 224, and 57123 over 25 iterations each passed 200 checks; adjacent admin-options-submission,admin-workflows,admin-screen,security,options-autoload smoke passed 270 checks; bootstrap smoke passed; latest broad run passed 4773 checks with 1 skip. Earlier pending-email runs passed 150 checks per focused seed.Settings API options submission allowlists and sanitize callbacks, settings error transient persistence, General Settings date/time/timezone normalization, Reading/Discussion/Media/Writing core option-page sanitization, conditional Writing Settings allowlist gates for post-by-email, legacy DB-version options, and public-blog update services, pending new_admin_email adminhash/confirmation-mail behavior and same-current/invalid no-mail branches, legacy options.php page_options submissions, nonce/capability/unknown-page failure paths, redirect capture, slashed request semantics, and global/filter/option restoration without including the exiting admin controller.
    core-block-rendervalidation passedScoped component worktree component-fuzz-core-block-lists; frontend list-style dynamic block render coverage validated before merge.Latest focused runs: seed 224 passed 9 checks, seeds 1 and 224 over 25 iterations each passed 225 checks, adjacent core-block-render,blocks,block-supports,navigation,navigation-lifecycle,classic-walkers,query-loop smoke passed 131 checks, and latest broad run passed 4720 checks with 4 skips.Representative dynamic core block render callbacks for site title/tagline, search, loginout, post title/date/excerpt/read-more, button, file, image, server-side navigation blocks, and frontend list-style archives/categories/latest-posts/latest-comments/tag-cloud/calendar blocks; option/cache setup, current-request links, archive/calendar cache keys, posts/comments/terms pre-query filters, KSES and attribute escaping, parsed pagination-link oracles, navigation overlay/interactivity/page-list oracles, actual HTML attribute safety scans, temporary filter cleanup, and global/superglobal/option/static restoration.
    date-timelocal validation passedScoped component worktree component-fuzz-date-decline; builds on timezone-choice coverage through ead9a7ca12 and earlier DST/wp_checkdate() follow-up through 4778dea14a.Latest focused runs: seed 224 passed 16 checks, seeds 1, 224, and 57123 over 25 iterations each passed 400 checks; adjacent date-time,formatting,l10n,environment-load,site-health,syndication,feed-rendering smoke passed 99 checks; bootstrap smoke passed; latest broad run: 4841 passed, 1 skipped, 0 failed.wp_date()/date_i18n()/mysql2date(), timezone options and override offsets, direct wp_timezone_choice() markup for empty, named, deprecated BC-only, UTC, manual-offset, hostile, and locale-variant selections, l10n textdomain short-circuit/restoration, named-timezone DST/ISO8601 conversion modes, wp_checkdate() filter contracts, direct wp_maybe_decline_date() month declension and Catalan apostrophe branches, GMT/local round trips, date and human-diff filter contracts.
    emailworker validation passedDarwin the 3rd / Bacon the 3rd / main worktree through c53162cd27Unicode generated-matrix focused run: 123379 passed, 100 skipped, 0 failed; latest broad run: 8537 passed, 66 skipped, 0 failed.Unicode/ASCII filters, generated UTF-8 local/domain filter-view matrices, optional Unicode API availability skips, disabled-filter fail-closed behavior, REST user email schema validation, WP_Email_Address views, IDN/Punycode, accent-distinct local/domain user lookups, canonical Unicode-domain save/update collisions, password-reset recipients, malformed UTF-8 and boundary oracles.
    xmlrpclocal validation passedScoped component worktree component-fuzz-xmlrpc-write-methods; prior read-only content/media coverage through 50b7b3e2a3.Main focused runs: seed 224 passed 12 checks, seeds 1 and 224 over 25 iterations each passed 300 checks after authenticated write-method coverage; adjacent XML-RPC/content smoke passed 1162 checks; latest broad run passed 4708 checks with 5 skips.IXR value/request/message/server contracts, mixed success/fault multicall ordering, legacy post XML helpers, pingback fail-closed/read-only lookup coverage, authenticated wp.getPost/wp.getPosts/wp.getMediaItem/wp.getMediaLibrary field/auth/cap/media filtering, authenticated wp.newPost/wp.editPost/wp.deletePost auth/capability/mutation/trash branches, short-circuited WP_HTTP_IXR_Client transport, fault/error mapping, and scoped cleanup.
    commentsvalidation passedScoped worktree component-fuzz-comments-list-rendering; direct wp_list_comments() follow-up in this slice; count/navigation helper follow-up through 07a873186b; prior comment-form follow-up through 9bfd3cd9bf.Latest focused runs: seeds 1, 224, and 57123 over 25 iterations each passed 11575 checks; adjacent comments,template-links,core-block-render smoke passed 2445 checks; adjacent comments,comment-workflow,content,identity,template-hierarchy,classic-walkers smoke passed 2565 checks; query-oriented adjacent comments,classic-walkers,template-hierarchy,content,comment-workflow,query,query-loop,template-links smoke passed 2500 checks; bootstrap smoke passed; latest broad seed 224 run passed 4813 checks with 1 skip.Filtering, max lengths, partitioning, cookies, classes, author links and fallback author-name contract, excerpts, direct wp_list_comments() rendering orchestration, explicit type routing, echo/getter parity, list-argument filter payloads, global comment-query fallback, option-driven pagination/depth/order defaults, requery and overridden-cpage paths, public comment count/text/link/popup helpers, comment page number links, next/previous comments links, comment pagination and navigation wrappers, singular fail-closed behavior, reply/cancel link rendering gates, full comment_form() rendering branches, field/action/submit filters, comment ID fields, page-of-comment counting, permalink anchors, exact cpage matching, threaded parent resolution, and post/parent query constraints.
    ksesvalidation passedScoped worktree component-fuzz-kses-reentrant-diagnostic; builds on generated pre-hook lifecycle coverage through 4698db75ce, generated helper contract coverage through 89c7386810, recursive wp_kses_post_deep() coverage through component-fuzz-kses-post-deep, semicolon-free data URL diagnostics through component-fuzz-kses-css-protocol-fragments, semicolon-bearing data URL diagnostics through component-fuzz-kses-css-data-url, style/protocol cross-hook coverage through component-fuzz-kses-style-protocol, generated policy mutation coverage through component-fuzz-kses-policy-mutations, exact hair fixtures through component-fuzz-kses-hair-fixtures, helper/block attribute fixes through 50de103325, PDF/URI coverage through e3d43f715a, and attribute constraint coverage through 8fc00de203.Latest focused runs: seed 224 passed 1109 checks and seed 224 over 25 iterations passed 27812 checks after current-core reentrant allowed-html leakage diagnostics; seeds 1, 100, and 424242 over 5 iterations passed 5558, 5556, and 5562 checks; adjacent kses,security,markup,shortcodes,formatting,blocks,block-supports smoke passed 1207 checks; hooks,kses,security over 5 iterations passed 5703 checks; latest broad run: 5076 passed, 1 skipped, 0 failed. Optional PHPCS is unavailable because this checkout lacks vendor/squizlabs/php_codesniffer/bin/phpcs.KSES policies, protocol filtering/helper contracts, exact and generated low-level helper contract matrices for two-level policy key lowercasing, quote-slash stripping, malformed attribute recovery, byte-oriented numeric entity decoding, HTML/XML named entity normalization, valid-Unicode numeric callbacks, and helper idempotence boundaries, exact wp_kses_hair() parser fixtures for entity recoding, duplicate attributes, malformed quote/equal recovery, special attribute names, slash-separated attributes, URI protocol filtering, and custom protocols, generated policy/protocol/style mutation matrices, scoped custom safe-CSS properties, filter-aware safe CSS, cross-hook CSS URL protocol checks combining safe_style_css, permissive safecss_filter_attr_allow_css, dynamic wp_kses_uri_attributes, wp_kses(), wp_kses_hair(), and wp_kses_attr(), semicolon-bearing data: CSS URL parser diagnostics under default, all-true permissive, and guarded safecss_filter_attr_allow_css hooks, semicolon-free data:, CSS URL rejection and semicolon-fragmented non-data bad-protocol diagnostics across default/permissive/guarded allow-CSS filters, recursive wp_kses_post_deep() equivalence to map_deep()/wp_kses_post() across nested arrays, public objects, scalars, unsafe leaves, dangerous-looking array keys, idempotence, and structure preservation, generated pre_kses lifecycle coverage for normalized content, priority order, allowed-html argument locality, protocol preservation, custom string context isolation, output fixed points, hook stack locality, and hook/global restoration, current-core reentrant wp_kses_allowed_html diagnostics for $pass_allowed_html/$pass_allowed_protocols leakage with first-token outer-policy preservation, later-token inner-policy modeling, future fixed-oracle recording, sanitized unsafe tails, and exact hook/global restoration, deterministic attribute constraint matrices, serialized block attribute filtering and hook-path agreement, built-in PDF object URL/type policy, dynamic URI attribute filtering, by-reference attribute mutation behavior, required attribute fallback stripping, entity-decoded style filtering, full-tag attribute parsing, malformed HTML, block/comment boundary inputs, no-HTML filtering, scoped filter cleanup, callback-order preservation, run-level pre_kses hook-state restoration, and global restoration.
    formattinglocal validation passedScoped worktree component-fuzz-formatting-text-links; builds on deep mapping helper coverage, wptexturize() rich-text coverage, and file/user/identifier sanitizer follow-ups.Latest focused runs: seed 224 passed 18 checks, seeds 1, 224, and 57123 over 25 iterations each passed 450 checks; tight adjacent formatting,markup,shortcodes,content,template-links,syndication smoke passed 2600 checks; bootstrap smoke passed; broad seed 224 smoke passed 4849 checks with 1 skip. A broader adjacent set including kses and email was stopped after exceeding the useful validation window.Escaping, text/URL sanitizers, whitespace, wptexturize() exact quote/apostrophe/dash/ellipsis/dimension/ampersand oracles, protected tag and shortcode preservation, registered shortcode texturization, HTML comment and punycode double-hyphen guards, split/shortcode regex recomposition, direct prime classification, marker-leak/idempotence/filter-cleanup checks, clickable links, direct text/link helper contracts for wp_trim_words(), wp_html_excerpt(), wp_make_link_relative(), wp_rel_nofollow(), wp_rel_ugc(), antispambot(), and capital_P_dangit(), title/key/class identifier sanitizers, file-name sanitizer exact fixtures for multi-extension munging, Unicode-space normalization, invalid UTF-8, unnamed-file fallback, path/control/extension generated cases, username sanitizer exact fixtures for tag/entity/percent stripping and strict ASCII mode, sanitizer filter contracts and cleanup, entities, colors, sizes, time strings, UTF-8 helpers, and accents.
    network-mediareview fixes passedScoped worktree component-fuzz-network-media-form-upload; Socrates implementation and Helmholtz review findings fixed through ac5734c0c9; upload-bits follow-up through ece71f4765.Latest focused runs: seed 224 passed 1 aggregate row with 303 cases, 1671 API calls, and 2093 assertions; seeds 1, 224, and 57123 over 25 iterations each passed 25 aggregate rows; adjacent network-media,media-ingest,media-remote,media-metadata,image-metadata,images,filesystem smoke passed 61 checks; bootstrap smoke passed; and broad seed 224 smoke passed 4831 checks with 1 skip.URL parsing/sanitization/validation, set_url_scheme() aliases and filter payloads, SSL/admin globals, path normalization, filenames, filetypes, wp_get_ext_types()/wp_ext2type()/wp_get_mime_types()/wp_match_mime_types() catalog/filter contracts, upload iframe URL query/filter/global contracts, custom wp_check_filetype_and_ext() MIME allowlist oracles, wp_unique_filename() callback/filter contracts, generated collision/alternate-extension filename oracles under filtered upload roots, wp_upload_bits() exact-byte writes, virtual subsize file-list collisions, upload-bits and final upload filter payloads, filter-error and empty-name failures, dimension-like/-scaled/-rotated basename guards, reverse output-format source basename reservations, core-empty basename behavior, sideload success helpers plus form-action mismatch, prefilter short-circuit, override, MIME rejection, custom error-handler, final upload filter context, and restoration contracts, upload quota oracles, network upload MIME allowlists, direct file-too-large checks, and check_upload_size() error/state behavior.
    revisions-autosaveslocal validation passedScoped worktree component-fuzz-rest-meta-contrast; builds on REST revision/autosave negative write and malformed meta boundary coverage, REST autosave mutation coverage, batch-gate coverage, route-dispatch coverage, preview request dispatch, latest revision count/user-filtered autosave coverage through 4a2cad026c, and direct revision template output through a3c1660089.Latest focused runs: seed 224 passed 20 checks, seeds 1, 224, and 57123 over 25 iterations each passed 500 checks, and seed 100 over 20 iterations passed 400 checks after built-in post/page parent REST meta validation contrast coverage; adjacent revisions-autosaves,rest,rest-controllers,rest-object-controllers,content-lifecycle,query,post-types,security,auth-flow,request-lifecycle smoke passed 958 checks with no skips; latest broad run: 5064 passed, 1 skipped, 0 failed. Earlier REST negative/meta runs passed 475 checks per focused sweep, autosave mutation runs passed 450 checks per focused sweep, batch-gate runs passed 400 checks per focused sweep, route-dispatch runs passed 375 checks per focused sweep, and latest-count/autosave lookup matrix passed seed 1 and seed 224 at 600 checks with 50 historical guarded skips.Protected revision field filters, autosave create/update/delete, autosave-preserving retention pruning, user-filtered autosave lookup, latest revision count and URL helpers, direct wp_print_revision_templates() structure/lock-branch output, bounded _show_post_preview() request dispatch, route-dispatched REST revision collection/item/delete paths, anonymous and invalid-parent failures, relevance-ordering schema failure, pagination headers, rest_revision_query payloads, parent-mismatch errors, raw-field and parent-link projection, scoped revision delete capability mapping, rest_delete_revision hook payloads, autosave collection/item preview-link projection from seeded rows, built-in posts/pages revision and autosave route parity, orderby=include validation and ordering, revision HEAD ID-only pagination, invalid page/offset boundaries, offset-over-page precedence, current-user autosave item semantics, cross-type post/page parent rejection, parent REST meta schema/auth/null-delete validation contrast against raw autosave revisioned-meta storage, REST default-filter idempotence, nested revision/autosave /batch/v1 no-GET gate validation, normal-mode rest_batch_not_allowed envelopes before queries/callbacks/deletes/autosave writes, require-all-validate null sibling behavior, child post-dispatch locality, nonce fail-closed wp_die() capture, the_preview autosave overlays, empty/no-revision and revisions-disabled gates, revision-input edit links, post locks, restore/title/list helpers, post type support, capability/error paths, and bounded WP_Query found_posts/post_author stub semantics.
    options-autoloadlatest validation passedScoped component worktree component-fuzz-options-autoload-persistence; builds on registered option-group cache priming through d10d0fa5bf and prior review fixes through 6e806f722a.Latest focused runs: seed 224 passed 16 checks, seeds 1, 224, and 57123 over 25 iterations each passed 400 checks; adjacent options-autoload,state,multisite,admin-options-submission,admin-workflows,security smoke passed 88 checks with 1 skip; bootstrap smoke passed; latest broad run passed 4867 checks with 1 skip. Earlier main focused run passed 1300 checks.Option CRUD, alloptions/notoptions caches, direct and registered option-group cache priming, duplicate/missing/preprimed/alloptions group-member cache boundaries, bulk autoload mutators, lifecycle hooks, wpdb option SQL shapes, filtered default/pre/option cache boundaries, query-count cache-hit guards, direct alloptions mutation-state checks, exact default autoload policy filters, size-gated autoload decisions, filtered autoload membership that cannot add hostile values, generated persistence sequences, independent option table/cache/public-read models, and hook/filter restoration.
    maillocal validation passedScoped worktree component-fuzz-mail-emoji-wrapper; builds on address encoding, multipart, Unicode handoff, and invalid-From coverage.Latest focused runs: seed 224 passed 12 checks, seed 224 over 25 iterations passed 300 checks, seeds 1 and 57123 over 10 iterations each passed 120 checks, adjacent mail,email,privacy smoke passed 3840 checks, syntax/diff checks passed, and broad seed 224 smoke passed 5094 checks with 1 skip; earlier invalid-From adjacent smoke passed 2561 with 2 skips.wp_mail() filters, PHPMailer handoff, string/array headers, RFC2822 display-name MIME header encoding, literal mailbox preservation, decoded header round trips, unset SMTP Sender, UseSMTPUTF8 boundaries, attachments, embeds, reusable state reset, Unicode handoff, multipart boundary matrix, invalid From wp_mail_failed payloads including embeds, wp_staticize_emoji_for_email() HTML/plain/default/filter branch coverage, pre-send cleanup, and no-delivery failure hooks.
    hooksreviewed validation passedMain worktree through 9d0ad6c83f; Turing the 3rd implementation, Beauvoir the 3rd review fixes, Sagan the 2nd implementation, Euler the 2nd review findings, and Pasteur the 2nd re-reviewMain focused runs: seed 1 passed 12 checks, seed 224 over 5 iterations passed 60 checks, seed 20260627 over 20 iterations passed 240 checks, seed 999 over 100 iterations passed 1200 checks, and adjacent hooks,options-autoload,plugin-theme smoke passed 68 checks; latest broad run: 9151 passed, 62 skipped, 0 failed.WP_Hook, nested and same-tag reentrant dispatch, mutation during dispatch, preinitialized hook normalization, accepted args/ref arrays, deprecated filter/action wrappers, deprecated_hook_run and trigger-error side-effect hooks, all-hook visibility, current-priority stack internals, counters, and cleanup.
    shortcodesfollow-up review passedMain worktree; image-context follow-up reviewed by Mendel through e4f39081f1; HTML-attribute boundary follow-up through ec3e228603.Latest focused runs: seed 224 passed 22 checks, seeds 1 and 224 over 25 iterations each passed 550 checks, adjacent markup,blocks,block-supports,core-block-render,shortcodes,kses smoke passed 1163 checks, and broad seed 224 smoke passed 4693 checks with 5 skips.Registry, invalid registration and non-callable callback guards, exact attribute-filter locality, nested parser behavior, callback mutation, scoped wp_get_attachment_image_context hooks including priority-zero preexisting filters, escaped shortcode boundaries, accepted quoted/unquoted HTML-attribute rendering, KSES-rejected attribute placeholder restoration with callback accounting, ignore_html placeholders, comments, CDATA, strip preservation, tag discovery, aliasing, malformed cases, and prefix/punctuated tag collisions.
    cronvalidation passedScoped worktree component-fuzz-cron-pre-filter-errors; builds on private cron option persistence through component-fuzz-cron-array-persistence, public wrapper coverage through de1da7b556, spawn/request review fixes through 12bde1192d, and pre-unschedule-hook coverage through db22a88992.Latest focused runs: seed 224 passed 101 checks, seed 224 over 25 iterations passed 2525 checks, seeds 1 and 57123 over 10 iterations each passed 1010 checks, adjacent cron,hooks,http,request-lifecycle,options-autoload,state smoke passed 176 checks, bootstrap smoke passed, and latest broad run passed 5094 checks with 1 skip.In-memory cron store, private _get_cron_array()/_set_cron_array()/_upgrade_cron_array() option persistence and legacy migration contracts, schedule and next-scheduled filters, pre_schedule_event/schedule_event/pre_reschedule_event/pre_unschedule_event/pre_clear_scheduled_hook false-return fail-closed contracts, pre_unschedule_hook zero/false/WP_Error short-circuit contracts, duplicate windows, scheduled-event lookup order/exactness, ready-job partitioning, exact spawn request payloads, doing_cron lock behavior, no-network loopback short-circuiting, _wp_cron() ready delegation, public wp_cron() shutdown deferral and isolated immediate shutdown-run wrapper behavior, recurrence/unschedule/reschedule, and state restoration.
    html-apireview follow-up passedMain worktree; Huygens the 3rd semantic oracle follow-up through e41dcde903Main focused run: 700 passed, 0 skipped, 0 failed; latest broad run: 8465 passed, 66 skipped, 0 failed.HTML Tag/Processor walking, semantic parser mode probes, breadcrumb stack replay, tree-preservation normalization, token serialization, namespace/comment/rawtext boundaries, bookmarks, mutation escaping, and malformed recovery.
    syndicationlocal validation passedMain worktree; feed_links_extra() branch output through 89e00fbfadMain focused run: 900 passed, 0 skipped, 0 failed at seeds 1 and 224 after feed_links_extra() coverage and Tesla cleanup fix; syntax/PHPCS/diff checks passed. Latest clean broad run: 9079 passed, 66 skipped, 0 failed.oEmbed providers/handlers, REST proxy permission and cache-key behavior, feed helpers, automatic feed-link head output gates, feed_links_extra() singular/post-type/taxonomy/author/search branches, comment feed-link generation/filtering, XML/HTML escaping, no-network filters.
    request-lifecyclelocal validation passedScoped worktree component-fuzz-request-query-lifecycle; builds on send-header exit coverage through component-fuzz-request-send-headers-exit, Curie the 3rd, and the feed header variant follow-up.Latest focused runs: seed 224 passed 14 checks, seeds 1, 224, and 57123 over 25 iterations each passed 350 checks after generated request/query conditional and canonical guard coverage; adjacent request-lifecycle,query,query-loop,canonical-routing,rewrite,template-hierarchy,template-links smoke passed 7970 checks; bootstrap smoke passed; latest broad run: 4864 passed, 1 skipped, 0 failed.Front-controller parsing, generated query-var matrices over single/page/search/feed routes, object/global conditional tag parity, canonical redirect cancellation and preview/search bailout guards, query vars, GET/POST mismatch termination across wp_die() handlers, WP::main() sequencing/no-DB sentinel, 404/header transitions, generated status_header()/wp_get_nocache_headers() contracts, feed content-type, last-modified, ETag, stale conditional request, default-feed branch contracts, subprocess-isolated feed 304 and explicit error-status exit paths, scoped request/canonical hook cleanup, and superglobal restoration.
    template-hierarchyreview fixes passedRaman the 2nd / main worktree through 1a26db9f1d; direct helper matrix through 0afd17bb8d; comments-template follow-up through d1916878e9, review fix through 0d7f1e98c9.Main focused runs: isolated comments_template() seed 1 passed 10 checks with 0 skips, seed 224 passed 250 checks with 0 skips, disabled-proc_open guard passed 9 checks with 1 skip, and adjacent template-hierarchy,content,comments smoke passed 946 checks with 0 skips; syntax/PHPCS/diff checks and James re-review passed; latest broad run: 9166 passed, 57 skipped, 0 failed.Child/parent template lookup, direct archive/page/search/404/embed/author/date/home/front-page/privacy/singular/attachment helpers, generated category/tag/taxonomy decoded-slug and term-ID ordering, exact dynamic hierarchy/template hook payloads, no-DB author/attachment queried objects, root/path precedence, filter locality, load/no-load behavior, isolated comments_template() default/custom file loading, comment-query and $comment_args contracts, cloned child hook snapshots, and parent-safe COMMENTS_TEMPLATE coverage.
    widgetsreview fixes passedParfit the 3rd / main worktree; Mill the 4th review findings fixed through 5076b6523fMain focused run: 1000 passed, 0 skipped, 0 failed after the_widget() helper review fixes; smoke: 10 passed, 0 skipped, 0 failed; syntax/PHPCS/diff checks passed; latest broad run: 8604 passed, 68 skipped, 0 failed.Classic sidebar/widget registries, generated widget IDs, sidebars option cache/filter behavior, wp_render_widget(), the_widget() display-filter/action/cancellation behavior, dynamic_sidebar() action/filter ordering, callbacks, and state restoration.
    user-preferenceslocal validation passedMain worktree through a7993f1bb1; request-handler subprocess coverage through 5568f588fcLatest focused runs: seed 1 and seed 224 each passed 300 checks with no skips; adjacent user-preferences,admin-workflows,admin-ajax,admin-screen,admin-options-submission smoke passed 210 checks with 10 skips; latest broad run: 4671 passed, 23 skipped, 0 failed.User settings, user-option prefix precedence/delete/filter behavior, hidden columns, meta boxes, postbox classes, AJAX preference handlers, screen option registration/rendering, set_screen_options() redirect/exit behavior, Screen Options visibility caching and filters, composed Screen Options output for columns, meta boxes, layout, pagination, view modes and custom settings, screen layout radios, admin preference filters, and state restoration.
    rest-controllersvalidation passedScoped worktree component-fuzz-rest-block-renderer-fields builds on block renderer dispatch, search include/exclude coercion, plugin/theme item-route and allowed dispatch coverage, plugin/theme argument envelopes, menu-location boundaries, plugin/theme controller contract, block pattern remote/local loader, REST search controller, and settings dispatch coverage.Latest focused runs: seed 224 passed 26 checks and seed 224 over 25 iterations passed 650 checks after block-renderer default-filtered field coverage; seeds 1 and 57123 over 5 iterations each passed 130 checks; adjacent rest,rest-controllers,rest-object-controllers smoke passed 71 checks; syntax/diff checks passed; broad seed 224 smoke passed 5086 checks with 1 skip.REST controller registries, schemas, context filtering, additional fields, permissions, namespace-specific post type/taxonomy item links, settings controller direct get/update/sanitize contracts, route-dispatched settings permission denial, invalid schema payload fail-closed behavior, valid settings body-param sanitization/update/response parity, default-filtered search/settings dispatch with sanitized cross-handler subtypes, _fields projection, Allow headers, route args, and default REST filter cleanup, route-dispatched search include/exclude coercion for comma scalars, numeric-string arrays, duplicates, zero and negative IDs, HEAD coercion without preparation, invalid string/fractional/nested/associative ID failures before handlers, search _fields=_links link-only responses, nested _links.self,_links.collection rel pruning, _fields=type,subtype body projection without final IDs or links, malformed handler rest_search_handler_error envelopes for GET and HEAD before preparation, block-renderer default-filtered GET/POST _fields=rendered, JSON POST body parsing, post context, _fields=missing pruning after render, HEAD render path reachability, render block filter ordering/locality, response envelopes, and default-filter/global/post/block cleanup, menu-location route/schema/read-access/capability/_fields/link/filter cleanup contracts, menu-location invalid context schema rejection with read-access filter ordering, wp_sprintf_l enum-list support, blocked invalid-response preparation, valid override response recovery, plugin/theme route/schema/collection-parameter/sanitizer/permission contracts, plugin/theme collection/create invalid argument envelopes for status enum/type, context, search, missing slug, slug type/pattern, and create status failures, plugin/theme allowed list/create/theme dispatch boundaries through scoped caps and rest_dispatch_request short-circuits, plugin/theme item-route dispatch boundaries for generated temp plugin GET/DELETE routes, missing-plugin 404, denied PATCH/DELETE, encoded theme stylesheet decoding, preparation/lifecycle hook non-events, fixture/cache cleanup, active/inactive permission-denied contrasts, sanitized scalar status arrays, inactive create default status, option preservation, no lifecycle callback reachability, block type registry fields/links, dynamic block renderer route/schema/permission/attribute/POST/post-context/filter contracts, block pattern registry/category responses, intercepted core/featured/theme remote pattern loaders, local theme patterns/*.php scanning and header parsing, pattern file cache behavior, lazy filePath content loading, one-shot pattern loader dispatch, snake-case remote field normalization, category migration, duplicate suppression, remote-load gates, route helper visibility, REST search controller constructor/params/sanitizers, route-dispatched defaults, invalid subtype/schema validation, public search-result schema contents, custom handler result/header/link propagation, post-format search term/link pagination behavior, built-in post/term search handler subtype discovery, TYPE_ANY query expansion, include/exclude/search/page/per-page query mapping, protected-title/no-title preparation branches, REST item/about link generation, no-SQL query short-circuiting, and state restoration.
    queryoracle hardening passedScoped component worktree component-fuzz-set-post-type; callback-shape oracle hardening builds on WP_Query post-search matrix and status-OR branch fixes.Focused query sweeps: seeds 1 and 224 over 10 iterations each passed 6440 checks; regression seed 761574832 over 5 iterations passed 3220 checks after allowing only known KSES save-pre hook additions; adjacent content/query smoke passed 5895 checks; broad seed 224 smoke passed 4765 checks with 1 skip.Query arg normalization, meta/tax/date/search interactions, pagination, sticky posts, SQL-shape safety, WP_Query execution order/found rows/cache-key/cache-hit behavior, classic WP_Query post-search terms/exclusions/columns/stopwords/relevance/password/attachment filename SQL and result behavior, password contradiction coverage, literal ORDER BY/GROUP BY/LIMIT search terms, attachment filename null-left-join semantics, independent exact-plus-relevance malformed-order boundary detection, direct status-OR stub coverage including author-scoped private branches and mixed equality/IN alternatives, WP_User_Query field/order/search/role/capability/has-published-post SQL-shape behavior, hook mutation locality with narrow callback-difference accounting, typed wpdb prepare placeholders, user/comment pre-query short-circuits, and state restoration.
    query-loopreview fixes passedMain worktree; Parfit nested reset implementation and Galileo review fixes landed through bbe8f53242Main focused runs: seed 1 and seed 224 each 7700 passed, 0 skipped, 0 failed; seeds 2, 42, and 123456 each 1925 passed, 0 skipped, 0 failed; latest broad run: 8665 passed, 66 skipped, 0 failed.WP_Query, loop state, complete conditional flag vectors, nested primary/secondary loop reset behavior, global postdata restoration, offset/no-found-rows field windows, the_posts final result filtering, count oracles, cache/filter restoration.
    media-ingestlocal validation passedScoped worktree component-fuzz-media-ingest-admin-parity; builds on Boyle implementation and Copernicus review fixes through 40040cb842.Latest focused runs: seeds 1, 224, and 57123 over 25 iterations each passed 250 checks, seed 100 over 50 iterations passed 500 checks, adjacent media-ingest,admin-media-chrome,media-remote,network-media smoke passed 320 checks, bootstrap smoke passed, and broad seed 224 smoke passed 4873 checks with 1 skip. Earlier override/error review runs passed 1000 checks and broad 8633 with 66 skips.Upload/sideload helpers, MIME checks, sanitized unique filenames, parent-date upload directory/GUID routing, real handle override/error semantics, custom error-handler payloads, action-specific overrides, test_form/test_size/test_type gates, callback/filter destination shaping, postData/desc field preservation, and local temp cleanup.
    multisitelocal validation passedMain worktree from Ramanujan the 2nd; large-network follow-up through f476712903; legacy blog identity and bootstrap resolution follow-up through 179ae88db5; true multisite lifecycle child follow-up through 352a5bc4de; signup validation follow-up through 07ddf2ba58.Latest focused runs: seeds 1, 224, and 57123 over 25 iterations each passed 450 checks with 25 config/DB-gated skips; adjacent multisite,identity,email smoke passed 1266 checks with 1 skip; bootstrap smoke passed; latest broad run passed 4790 checks with 1 skip.Network/site helpers, legacy blog identity helpers, bootstrap current-site/current-network resolution, switch/restore stack, cache groups, signup validation for unsafe email domains, user/blog validation errors, pending/stale wp_signups reservations, nonce checks, large-network thresholds and filters, pre-query site/network queries, domain/path lookups, URL/path normalization, optional real-DB site lifecycle and sitemeta write child checks, and global/cache restoration.
    taxonomy-relationshipslatest validation passedScoped component worktree component-fuzz-taxonomy-relationship-mutations; builds on James the 2nd main worktree and Carver cache priming follow-up through aeb52fbf45.Latest focused runs: seed 224 passed 11 checks, seeds 1, 224, and 57123 over 25 iterations each passed 275 checks; direct taxonomy-relationships,xmlrpc adjacency passed 240 checks; taxonomy-relationships,query-loop,xmlrpc passed 1010 checks after query-loop post-format support restoration; taxonomy-relationships,content-lifecycle,xmlrpc passed 460 checks after content-lifecycle post-format support restoration; full adjacent taxonomy-relationships,taxonomy,content-lifecycle,metadata,query,query-loop,rest-object-controllers,xmlrpc smoke passed 8080 checks; bootstrap smoke passed; broad seed 224 smoke passed 4865 checks with 1 skip.Object-term assignment, append/replace, multi-object object_id maps, counts, duplicate/invalid inputs, get_the_terms() cache population/invalidation, generated multi-object update_object_term_cache()/clean_object_term_cache() prime/clean/re-prime invariants, empty cache entries, warm-cache return behavior, generated primary/secondary taxonomy mutation/removal/delete sequences, per-taxonomy cache invalidation, term-count oracles, relationship hook locality, post-type feature/meta-cap global restoration, and REST/XML-RPC adjacency.
    post-typeslocal validation passedMain worktree from Poincare the 2nd; query-operator follow-up through a514c0c42eMain focused run: 1000 passed, 0 skipped, 0 failed; latest broad run: 8511 passed, 66 skipped, 0 failed.Post type/status registries, labels, supports, capabilities, REST args, registry query operators.
    rewrite, canonical-routingintegrated validation passedMain worktree plus scoped worktree component-fuzz-rewrite-url-to-postid; latest canonical URL helper output through 53453d79ff, latest rewrite update through 7ac42d758b.Latest rewrite focused runs: seed 224 passed 19 checks, seeds 1, 224, and 490 over 25 iterations each passed 475 checks, adjacent rewrite,request-lifecycle,canonical-routing,query,post-types smoke passed 2115 checks, bootstrap smoke passed, and broad seed 224 smoke passed 4747 checks with 1 skip. Earlier canonical old-slug/date focused runs passed 400 checks per seed, attachment-page redirect focused runs passed 375 checks per seed, canonical URL helper focused runs passed 1300 checks per seed, and rewrite main focused run passed 1800 checks.Rewrite tags, tag-removal query-var boundaries, permastructs, endpoints, rule precedence, query substitution, match maps, build/parse helpers, pretty-permalink url_to_postid() rewrite matching, site-base/www URL normalization, custom post-type query-var mapping, non-public query-var filtering, short-circuited WP_Query singular oracles, canonical path/query normalization, DB-stub-backed 404 guessing, old-slug/date helper redirects, paged/embed redirect suffixes, helper cache-hit and invalidation contracts, enabled attachment-page permalink redirects, disabled attachment-page raw-file redirects, non-public parent fail-closed behavior, redirect filter cancellation, safe same-host replacement cascades, canonical URL status/pagination/comment/filter/output gates, and generated query-argument removal/fragment stripping helper matrices.
    rest-object-controllersvalidation passedScoped worktree component-fuzz-rest-object-require-all-create; require-all mixed collection-create validation abort follow-up builds on normal mixed collection-create, collection batch success/cleanup, validation/error envelopes, normal mixed batch item update, item update success/denial parity, batch item delete failure/reassign parity, envelope/options/index, projection/embed/HEAD, duplicate/reversed parent and parent-exclude request-array, private-status collection permission, edit-vs-publish parent reassignment capability split, scoped REST status-filter plus pending collection readback, direct untrash and route-dispatched custom hierarchical draft reparent collection parity, trash/delete parent reassignment lifecycle, status-transition parent reassignment, capability-denied no-mutation, preexisting-loop breakage, custom hierarchical loop-normalization, parent-assignment, collection filters, custom hierarchical permalink, page parent-slug, taxonomy term mutation, force-delete/reassign, create/delete, write, collection params, template-controller, and collection-query short-circuit coverage.Latest focused runs: seed 224 passed 29 checks and seed 224 over 25 iterations passed 725 checks after require-all mixed collection-create validation abort coverage; adjacent rest,rest-controllers,rest-object-controllers,content-lifecycle,query,capabilities smoke passed 896 checks; broad smoke passed 5070 checks with 1 skip; syntax/diff checks passed. Earlier normal mixed collection-create focused runs passed 700 checks per 25-iteration seed.Object controller schemas, context filtering, deterministic collection parameter sanitizer/validation matrices, actual WP_REST_Request::has_valid_params()/sanitize_params() pipeline oracles, batch/v1 POST child validation and error envelopes for posts/categories/comments/users, direct-dispatch error body/status/header parity for allowed invalid batch children, comments collection and item batch-gate coverage, require-all-validate null/error sibling behavior, batch/v1 successful collection creates for posts/categories/users, stored-row and count-delta parity, force-delete cleanup envelopes and content-count restoration, exact direct-dispatch success body/status/header parity via response_to_data(), normal mixed collection create ordering with valid post/user siblings around an invalid category parent create, invalid-term non-creation, valid sibling cleanup, require-all mixed collection-create validation abort with null valid siblings, invalid parent body parity, no child dispatch, and no post/term/user mutation by generated identifiers, all-valid require-all-validate success without failed or null response slots, batch/v1 item update denial parity for posts/categories/users, successful item update persistence for posts/categories/users, normal mixed item update ordering with valid post/user siblings around an invalid category parent update, invalid-term no-mutation, batch/v1 item delete denial, no-force failure, invalid-reassign require-all-validate abort, comment/media item gate, forced delete direct-envelope parity, user-owned post reassignment, denied/failure no-mutation, setup-row cleanup, unchanged content counts, post-dispatch locality excluding the parent batch route, generated email filter cleanup, route-dispatched collection GET response projection, schema context, pagination total/next-link, prepare-hook, additional-field, permission-denial, query short-circuit, no-content-mutation, collection GET envelope body/status/header parity against response_to_data(), permission-gated OPTIONS Allow headers, namespace-index route-data equality, route method/arg/schema/self-link metadata, _fields/context/_embed serialization, link filtering, author/up embedded-resource, user no-embedded, HEAD/GET pagination parity, and HEAD no-prepare oracles for posts/categories/comments/users, route-dispatched generated custom hierarchical parent assignment rejecting missing parent IDs before create/update mutation, accepting same-type and cross-type existing parents, clearing to root, draft-to-private status transition plus cross-type parent reassignment link/up-link parity, scoped rest_pre_insert_{$post_type} status coercion before pending collection parent/status readback, edit-only/no-publish_pages parent/status publish denial with no storage mutation or transition hooks, soft-trash/repeated-trash/force-delete parent, direct wp_untrash_post() hook-order, draft-status, slug-restoration, and trash-meta cleanup parity, route-dispatched draft reparent response parity, collection GET parent/status readback, collection HEAD total headers without prepare events, fallback-link, previous-response, prepare-hook, status-filter hook, and delete-hook parity, normalizing self-parent and descendant-loop updates to root through wp_insert_post_parent, breaking preexisting two-node parent loops outside the updated post while preserving the requested parent, preserving sibling hierarchy rows, projecting query-var fallback link fields, exposing parent up response links, preserving prepare-hook locality, capability-denied parent/status update no-mutation behavior, and restoring caps, default REST filters, server, actions, current user, WP, post type, query vars, status filters, untrash filters, delete filters, scoped hierarchy-loop filters, fixture rows, and scoped wpdb clone state, route-dispatched generated custom hierarchical collection parent, parent-exclude, root, and private-status filters with menu_order sorting, _fields projection, _links.up, HEAD query translation, no_found_rows totals, edit/read-private capability splits, prepare hooks, generated post cleanup, and canonical/reversed/duplicated parent/parent_exclude request-array oracles that compare semantic response equivalence, raw REST arg preservation, layer-appropriate WP_Query parent query vars, include-scoped exclusion output, and GET-only prepare deltas, short-circuited collection query translation for posts, attachments, revisions, users, comments, and terms, permissions, route registration/dispatch, route index/help-data projection, route-dispatched write denial, invalid body-param validation, update response projection and stored-row parity, route-dispatched create denial, create payload normalization, trash/not-supported delete failure preservation, force-delete/reassign hard-delete lifecycles, invalid reassignment preservation, previous-response projection, object and metadata cleanup, exact row-count deltas for posts/categories/comments/users, REST taxonomy term parent/schema/capability/meta mutation through category/tag routes, term insert/prepare/delete hook timing, previous-meta force-delete projection, term-meta cleanup, bounded WP_REST_Templates_Controller routes/schemas/sanitization/capability gates and synthetic template responses without filesystem or template CPT queries, in-memory row effects, and state restoration.
    metadatareview fixes passedMain worktree; Archimedes the 3rd worktree implementation, Lorentz the 3rd and Volta the 3rd reviews clean through 72182b7826, subtype current-API accounting through a8de6a7e5eLatest focused runs: seed 224 passed 11 checks, seeds 1 and 224 over 25 iterations each passed 275 checks, adjacent metadata/content smoke passed 2600 checks, and latest broad run passed 4684 checks with 11 skips after retiring the absent subtype-helper availability skip.Metadata registry, subtype visibility, current subtype-aware API accounting for posts/terms/comments/users, defaults, sanitize/auth/protected filters, cache lookups, by-mid short-circuit filter payloads, invalid input fail-closed behavior, and duplicate-aware lazyloader behavior.
    +
    + +
    +
    +

    Recent Committed Additions

    + Latest main-branch commits from this pass. +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    CommitSurfacesFocused ResultBroad Result
    83cf49b535auth-flowauth-flow seed 224 passed 13 checks and seed 224 over 25 iterations passed 325 checks; seeds 1 and 57123 over 5 iterations each passed 65 checks; adjacent auth-flow,account-security,security,identity,user-preferences,request-lifecycle smoke passed 68 checks; syntax/diff checks passed after adding auth_redirect() boundary coverage for secure redirects, valid-cookie returns, user use_ssl redirects, invalid-cookie referer/current-request login redirects, nocache headers, no-exit redirect capture, hook/global/force-SSL restoration, and in-memory row cleanup.5089 passed, 1 skipped, 0 failed at 1 iteration after auth redirect boundary coverage.
    e4aadb6c49account-securityaccount-security seed 224 passed 9 checks and seed 224 over 25 iterations passed 225 checks; seeds 1 and 57123 over 5 iterations each passed 45 checks; adjacent account-security,auth-flow,security,identity,mail smoke passed 53 checks; syntax/diff checks passed after adding retrieve-password request-path coverage for invalid, policy-blocked, send-gated, and successful reset requests, including checkable hashed reset keys, canonical login/key mail composition, pre_wp_mail interception, hook/global restoration, and in-memory row cleanup.5088 passed, 1 skipped, 0 failed at 1 iteration after account reset request-path coverage.
    5e47b94064rest-widgets-sidebarsrest-widgets-sidebars seed 224 passed 13 checks and seed 224 over 25 iterations passed 325 checks; seeds 1 and 57123 over 5 iterations each passed 65 checks; adjacent rest-widgets-sidebars,widgets,default-widgets,block-widgets,customizer-nav-widgets-requests,rest,rest-controllers,block-editor-adjuncts,admin-ajax smoke passed 112 checks; syntax/diff checks passed after adding sidebar default-filtered route-envelope coverage for item and collection GET/HEAD, _fields projection, links, target hints, Allow headers, response envelopes, prepare-filter locality, scoped caps, and default-filter cleanup.5087 passed, 1 skipped, 0 failed at 1 iteration after REST sidebar default-filtered route-envelope coverage.
    ed695c1fd8rest-controllersrest-controllers seed 224 passed 26 checks and seed 224 over 25 iterations passed 650 checks; seeds 1 and 57123 over 5 iterations each passed 130 checks; adjacent rest,rest-controllers,rest-object-controllers smoke passed 71 checks; syntax/diff checks passed after adding block-renderer default-filtered field coverage for GET/POST _fields=rendered, JSON POST body parsing with post context, _fields=missing pruning after render, HEAD render reachability, render block filter ordering/locality, Allow headers, response envelopes, and cleanup.5086 passed, 1 skipped, 0 failed at 1 iteration after REST block-renderer default-filtered field coverage.
    59b51de963securitysecurity seed 224 passed 19 checks and seed 224 over 25 iterations passed 475 checks; seeds 1 and 12345 over 5 iterations each passed 95 checks; hooks,security over 5 iterations passed 155 checks; adjacent security,request-lifecycle,formatting,canonical-routing,http smoke passed 80 checks; adjacent security,auth-flow,account-security,identity,admin-ajax,admin-options-submission,admin-workflows smoke passed 70 checks; syntax/diff checks passed after adding admin-referer hook-edge coverage for check_admin_referer action payloads, wp_verify_nonce_failed ordering and payloads, custom query-arg precedence, empty/missing nonce silence, wp_nonce_ays() 403 capture, and cleanup.5085 passed, 1 skipped, 0 failed at 1 iteration after security admin-referer hook-edge coverage.
    ad28b72183rest-controllersrest-controllers seed 224 passed 25 checks and seed 224 over 25 iterations passed 625 checks; seeds 1 and 57123 over 5 iterations each passed 125 checks; adjacent rest,rest-controllers,rest-object-controllers smoke passed 70 checks; syntax/diff checks passed after adding search field/link error-envelope coverage for _fields=_links, nested selected link rels, _fields=type,subtype final body pruning, malformed handler GET/HEAD rest_search_handler_error envelopes before preparation, response envelopes, headers, default filters, and global restoration.5084 passed, 1 skipped, 0 failed at 1 iteration after REST search field/link error-envelope coverage.
    8d93a54805rest-controllersrest-controllers seed 224 passed 24 checks and seed 224 over 25 iterations passed 600 checks; seeds 1 and 57123 over 5 iterations each passed 120 checks; adjacent rest,rest-controllers,rest-object-controllers smoke passed 69 checks; syntax/diff checks passed after adding search include/exclude coercion coverage for comma scalar and numeric-string array IDs, duplicate/zero/negative preservation, HEAD no-prepare behavior, invalid string/fractional/nested/associative values failing before handlers, response envelopes, headers, default filters, and global restoration.5083 passed, 1 skipped, 0 failed at 1 iteration after REST search include/exclude coercion coverage.
    45e4d1c2bfrest-controllersrest-controllers seed 224 passed 23 checks and seed 224 over 25 iterations passed 575 checks; seeds 1 and 57123 over 5 iterations each passed 115 checks; adjacent rest,rest-controllers,rest-object-controllers smoke passed 68 checks; syntax/diff checks passed after adding plugin/theme item-route dispatch boundary coverage for generated temp plugin GET/DELETE item routes, missing-plugin 404, denied PATCH/DELETE, encoded theme stylesheet decoding, preparation/lifecycle hook non-events, fixture/cache cleanup, option preservation, and filter/global restoration.5082 passed, 1 skipped, 0 failed at 1 iteration after REST plugin/theme item-route dispatch boundary coverage.
    eb57afd6f8rest-controllersrest-controllers seed 224 passed 22 checks and seed 224 over 25 iterations passed 550 checks; seeds 1 and 57123 over 5 iterations each passed 110 checks; adjacent rest,rest-controllers,rest-object-controllers smoke passed 67 checks; syntax/diff checks passed after adding plugin/theme allowed dispatch boundary coverage for scoped cap grants, rest_dispatch_request short-circuits, inactive create default status, active-plugin activation cap denial, active/inactive theme permission contrasts, sanitized scalar status arrays, no controller preparation hooks, option preservation, and filter/global restoration.5081 passed, 1 skipped, 0 failed at 1 iteration after REST plugin/theme allowed dispatch boundary coverage.
    78c2b864d0rest-controllersrest-controllers seed 224 passed 21 checks and seed 224 over 25 iterations passed 525 checks; seeds 1 and 57123 over 5 iterations each passed 105 checks; adjacent rest,rest-controllers,rest-object-controllers smoke passed 66 checks; syntax/diff checks passed after adding plugin/theme argument-envelope coverage for collection/create status enum/type failures, plugin context/search type failures, missing slug, slug type and pattern failures, create status failures, no permission/callback reachability for invalid args, scalar status array sanitization before permission denial, wp_sprintf_l enum formatting, and filter/global restoration.5080 passed, 1 skipped, 0 failed at 1 iteration after REST plugin/theme controller argument-envelope coverage.
    4da7c9d4b6rest-controllersrest-controllers seed 224 passed 20 checks and seed 224 over 25 iterations passed 500 checks; seeds 1 and 57123 over 5 iterations each passed 100 checks; adjacent rest,rest-controllers,rest-object-controllers smoke passed 65 checks; syntax/diff checks passed after adding menu-location argument/permission boundary coverage for invalid collection/item context schema errors, raw invalid-context read-access filter observation, no invalid-response preparation, valid anonymous denial, read-access override recovery, assigned menu ID preservation, and filter/global restoration.5079 passed, 1 skipped, 0 failed at 1 iteration after REST menu-location controller argument/permission boundary coverage.
    79fa9070ddrestrest seed 224 passed 16 checks and seed 224 over 25 iterations passed 400 checks; seeds 1 and 57123 over 5 iterations each passed 80 checks; adjacent rest,rest-controllers,rest-object-controllers smoke passed 64 checks; syntax/diff checks passed after adding batch-v1 request schema boundary coverage for top-level validation and requests validation, defaulted normal/POST child execution, generated child path/method/body/header shape rejection before callbacks, callback locality, and response envelope alignment.5078 passed, 1 skipped, 0 failed at 1 iteration after REST batch/v1 request schema boundary coverage.
    34e0824fcasecuritysecurity seed 224 passed 18 checks and seed 224 over 25 iterations passed 450 checks; seeds 1 and 12345 over 5 iterations each passed 90 checks; hooks,security over 5 iterations passed 150 checks; adjacent security,request-lifecycle,formatting,canonical-routing,http smoke passed 79 checks; adjacent security,auth-flow,account-security,identity,admin-ajax,admin-options-submission,admin-workflows smoke passed 69 checks; syntax/diff checks passed after adding nonce verifier hook-edge coverage for check_ajax_referer action payloads, wp_verify_nonce_failed ordering and payloads, empty nonce non-dispatch, Ajax stop=true wp_die() routing, and cleanup.5077 passed, 1 skipped, 0 failed at 1 iteration after security nonce verifier hook-edge coverage.
    4070c9cac2kseskses seed 224 passed 1109 checks and seed 224 over 25 iterations passed 27812 checks; seeds 1, 100, and 424242 over 5 iterations passed 5558, 5556, and 5562 checks; adjacent kses,security,markup,shortcodes,formatting,blocks,block-supports smoke passed 1207 checks; hooks,kses,security over 5 iterations passed 5703 checks; syntax/diff checks passed after adding a current-core reentrant wp_kses_allowed_html diagnostic that models leaked $pass_allowed_html/$pass_allowed_protocols, records the future fixed oracle, and checks sanitization plus hook/global restoration.5076 passed, 1 skipped, 0 failed at 1 iteration after KSES reentrant allowed-html leakage diagnostics.
    4698db75cekseskses seed 224 passed 1108 checks and seed 224 over 25 iterations passed 27787 checks; seeds 1, 100, and 424242 over 5 iterations passed 5553, 5551, and 5557 checks; adjacent kses,security,markup,shortcodes,formatting,blocks,block-supports smoke passed 1206 checks; hooks,kses,security over 5 iterations passed 5698 checks; syntax/diff checks passed after adding generated pre-hook lifecycle coverage for pre_kses priority ordering, normalized content delivery, allowed-html argument locality, protocol preservation, scoped custom contexts, output fixed points, hook stack locality, and hook/global restoration.5075 passed, 1 skipped, 0 failed at 1 iteration after KSES pre-hook lifecycle coverage.
    58cdd7fca9restrest seed 224 passed 15 checks and seed 224 over 25 iterations passed 375 checks; seeds 1 and 57123 over 5 iterations each passed 75 checks; adjacent rest,rest-controllers,rest-object-controllers smoke passed 63 checks; syntax/diff checks passed after adding batch-v1 parsed no-route child path alignment coverage for ordered 404 rest_no_route envelopes, valid siblings before and after no-route slots, normal child pre/post dispatch, require-all null valid-sibling slots, no child execution during aborted validation, and filter/global restoration.5074 passed, 1 skipped, 0 failed at 1 iteration after REST batch-v1 parsed no-route child path alignment coverage.
    1bedb48d40securitysecurity seed 224 passed 17 checks and seed 224 over 25 iterations passed 425 checks; seeds 1 and 12345 over 5 iterations each passed 85 checks; adjacent security,request-lifecycle,formatting,canonical-routing,http smoke passed 78 checks; adjacent security,auth-flow,account-security,identity,admin-ajax,admin-options-submission,admin-workflows smoke passed 68 checks; syntax/diff checks passed after adding generated redirect dispatch boundary coverage for wp_redirect()/wp_safe_redirect() filter order, falsey-location cancellation, invalid-status wp_die(), safe fallback and allowed-host dispatch, sanitized x_redirect_by payloads, and headerless interruption.5073 passed, 1 skipped, 0 failed at 1 iteration after generated security redirect dispatch boundary coverage.
    89c7386810kseskses seed 224 passed 1107 checks and seed 224 over 25 iterations passed 27762 checks; seeds 1, 100, and 424242 over 5 iterations passed 5548, 5546, and 5552 checks; adjacent kses,security,markup,shortcodes,formatting,blocks,block-supports smoke passed 1204 checks; syntax/diff checks passed after adding generated low-level helper contract coverage for quote-slash stripping, two-level policy key lowercasing, malformed attribute recovery, byte-oriented numeric entity decoding, HTML/XML entity normalization, valid-Unicode numeric callbacks, and helper idempotence boundaries.5072 passed, 1 skipped, 0 failed at 1 iteration after generated KSES low-level helper contract coverage.
    e2838976d6restrest seed 224 passed 14 checks and seed 224 over 25 iterations passed 350 checks; adjacent rest,rest-controllers,rest-object-controllers smoke passed 62 checks; syntax/diff checks passed after adding batch-v1 malformed child path parsing coverage for aligned parse_path_failed child envelopes, valid-sibling execution in normal mode, null valid-sibling slots under require-all-validate, no malformed-child pre/post dispatch, and filter/global restoration.5071 passed, 1 skipped, 0 failed at 1 iteration after REST batch-v1 malformed child path parsing coverage.
    ec716420a8rest-object-controllersrest-object-controllers seed 224 passed 29 checks and seed 224 over 25 iterations passed 725 checks; adjacent rest,rest-controllers,rest-object-controllers,content-lifecycle,query,capabilities smoke passed 896 checks; syntax/diff checks passed after adding require-all mixed collection-create validation abort coverage for null valid-sibling slots, invalid category parent envelope parity, no child dispatch, no post/term/user mutation by generated identifiers, defensive cleanup, and count/filter/global/wpdb restoration.5070 passed, 1 skipped, 0 failed at 1 iteration after REST object-controller require-all mixed collection-create validation abort coverage.
    a70a3d5a6crest-object-controllersrest-object-controllers seed 224 passed 28 checks and seed 224 over 25 iterations passed 700 checks; adjacent rest,rest-controllers,rest-object-controllers,content-lifecycle,query,capabilities smoke passed 895 checks; syntax/diff checks passed after adding normal mixed collection-create batch coverage for ordered valid/invalid/valid child envelopes, direct-dispatch parity, valid post/user persistence, invalid category non-creation, post-dispatch locality, cleanup, and count/filter/global restoration.5069 passed, 1 skipped, 0 failed at 1 iteration after REST object-controller normal mixed collection-create batch coverage.
    16332728bbkseskses seed 224 passed 1106 checks and seed 224 over 25 iterations passed 27737 checks; adjacent kses,security,markup,shortcodes,formatting,blocks,block-supports smoke passed 1203 checks; syntax/diff checks passed after adding wp_kses_post_deep() nested structure coverage for exact map_deep()/wp_kses_post() equivalence, key/property/shape preservation, scalar leaf conversion, unsafe-leaf sanitization, safe URL preservation, idempotence, and hook/global cleanup.5068 passed, 1 skipped, 0 failed at 1 iteration after KSES wp_kses_post_deep() nested structure coverage.
    11d7a878b4rest-object-controllersrest-object-controllers seed 224 passed 27 checks and seed 224 over 25 iterations passed 675 checks; adjacent rest,rest-controllers,rest-object-controllers,content-lifecycle,query,capabilities smoke passed 2682 checks; syntax/diff checks passed after adding normal mixed batch item update coverage for ordered valid/invalid/valid child envelopes, valid post/user persistence, invalid category no-mutation, post-dispatch locality, count stability, and cleanup.5067 passed, 1 skipped, 0 failed at 1 iteration after REST object-controller normal mixed batch update coverage.
    e76824935brevisions-autosavesrevisions-autosaves seed 224 passed 16 checks, seeds 1, 224, and 57123 over 25 iterations each passed 400 checks, seed 100 over 20 iterations passed 320 checks; adjacent revisions-autosaves,rest,rest-controllers,rest-object-controllers,content-lifecycle,query,post-types,security,auth-flow,request-lifecycle smoke passed 954 checks; syntax/diff checks passed after adding batch/v1 nested revision/autosave gate denial, direct route baselines, no-GET child-method coverage, require-all null-sibling validation behavior, no-query/callback/delete/write side-effect checks, child post-dispatch locality, and filter/current-user cleanup.5060 passed, 1 skipped, 0 failed at 1 iteration after REST revision/autosave nested batch gate coverage.
    710cac08f1rest-object-controllersrest-object-controllers seed 224 passed 27 checks, seeds 1, 224, and 57123 over 25 iterations each passed 675 checks, seed 100 over 20 iterations passed 540 checks; adjacent rest,rest-controllers,rest-object-controllers,rest-media-attachments,rest-application-passwords,rest-directory-services,rest-site-editor,content-lifecycle,query,comments,comment-workflow,capabilities,identity,security,auth-flow,request-lifecycle smoke passed 1452 checks; syntax/diff checks passed after adding batch/v1 item delete denial, no-force failure, comment/media gate, require-all invalid-reassign abort, forced delete direct-envelope parity, user-owned post reassignment, post-dispatch locality, and cleanup coverage.5059 passed, 1 skipped, 0 failed at 1 iteration after REST object batch/v1 item delete failure/reassign parity coverage.
    3c10935ca3editor-helperseditor-helpers seeds 1, 224, and 57123 over 25 iterations each passed 275 checks; adjacent editor-helpers,admin-ajax,query,content-lifecycle,template-links,script-loader-runtime,assets,block-editor-adjuncts,admin-media-chrome,media-editor smoke passed 3775 checks; bootstrap smoke passed after adding direct _WP_Editors::wp_link_query() and wp_link_dialog() coverage for generated link results, query/result filters, sanitized labels, empty-result false behavior, dialog single-print markup, and cleanup.4800 passed, 1 skipped, 0 failed at 1 iteration after editor-helper internal link query/dialog coverage.
    02a49529f7template-linkstemplate-links seeds 1, 224, and 57123 over 25 iterations each passed 425 checks; adjacent template-links,identity,query,query-loop,content-lifecycle,feed-rendering,discovery smoke passed 3905 checks; bootstrap smoke passed after adding author display, modified-author, metadata alias/filter, author link, author post count/link, wp_list_authors(), and is_multi_author() coverage with scoped cleanup.4799 passed, 1 skipped, 0 failed at 1 iteration after template-links author-template helper coverage.
    309a336a97block-widgetsblock-widgets seeds 1, 224, and 57123 over 25 iterations each passed 250 checks; adjacent block-widgets,widgets,default-widgets,rest-widgets-sidebars,customizer-nav-widgets-requests,block-editor-adjuncts,assets smoke passed 68 checks; bootstrap smoke passed after adding wp_check_widget_editor_deps() coverage for script/style conflicts, both widget editor handles, dependency-chain enqueued semantics, warning payloads, asset stability, and hook/global cleanup.4798 passed, 1 skipped, 0 failed at 1 iteration after block-widgets editor dependency warning coverage.
    component-fuzz-admin-post-save-orchestrationcontent-lifecyclecontent-lifecycle seeds 1, 224, and 57123 over 25 iterations each passed 400 checks; adjacent content-lifecycle,revisions-autosaves,admin-edit-metaboxes,admin-workflows,query,taxonomy-relationships,template-links,post-types smoke passed 2184 checks; bootstrap smoke passed after adding classic admin edit_post() orchestration coverage for translated request fields, allowed-data filtering, custom-field meta ID add/update/delete paths, taxonomy sanitizer callbacks, spoofed type/MIME and guarded GUID/meta-input preservation, visibility/password rules, edit locks, and hook cleanup.4815 passed, 1 skipped, 0 failed at 1 iteration after content-lifecycle classic admin post-save orchestration coverage.
    9f14ccc0c7content-lifecyclecontent-lifecycle seeds 1, 224, and 57123 over 25 iterations each passed 375 checks; adjacent content-lifecycle,query,query-loop,post-types,template-links,core-block-render,post-embeds,xmlrpc,fonts,default-widgets smoke passed 815 checks; bootstrap smoke passed after adding page lookup helper coverage for ancestry paths, attachment fallback and collision preference, custom hierarchical post types, salted cache invalidation, get_pages() rewrites and filters, get_children() normalization, output-shape checks, and cleanup.4797 passed, 1 skipped, 0 failed at 1 iteration after content-lifecycle page lookup helper coverage.
    07a873186bcommentscomments seeds 1, 224, and 57123 over 25 iterations each passed 11350 checks; adjacent comments,template-links,core-block-render smoke passed 2395 checks; adjacent comments,comment-workflow,content,identity,template-hierarchy,classic-walkers smoke passed 2520 checks; bootstrap smoke passed after adding count/text/link/popup, comments-page link, next/previous link, pagination, navigation wrapper, singular fail-closed, attribute-filter, echo/getter parity, and cleanup coverage.4796 passed, 1 skipped, 0 failed at 1 iteration after comments count/navigation helper coverage.
    9211f6d1e5admin-baradmin-bar seeds 1, 224, and 57123 over 25 iterations each passed 225 checks; adjacent admin-bar,post-types,assets,script-loader-runtime,update-install-upgrader,site-health smoke passed 77 checks; bootstrap smoke passed after adding single-site site-menu, new-content, updates, sidebar-toggle, command-palette, script-queue, shortcut-label, and cleanup coverage.4795 passed, 1 skipped, 0 failed at 1 iteration after single-site admin-bar callback node coverage.
    57fe546d48post-embedspost-embeds seeds 1, 224, and 57123 over 25 iterations each passed 200 checks; adjacent post-embeds,rest,http,content,template-links,syndication,media-remote,script-loader-runtime smoke passed 188 checks; bootstrap smoke passed after adding wp_maybe_enqueue_oembed_host_js() action-gate, script-queue, markup-detection, HTML-preservation, and cleanup coverage.4794 passed, 1 skipped, 0 failed at 1 iteration after host-script enqueue gate coverage.
    fb60b38f3ctemplate-linkstemplate-links seeds 1, 224, and 57123 over 25 iterations each passed 400 checks; adjacent template-links,content,query,query-loop,core-block-render,rewrite smoke passed 3900 checks; bootstrap smoke passed after adding archive navigation wrapper, pagination wrapper, echo/getter parity, edge-state, filter cleanup, and _navigation_markup() escaping coverage.4793 passed, 1 skipped, 0 failed at 1 iteration after archive navigation wrapper coverage.
    ead9a7ca12date-timedate-time seeds 1, 224, and 57123 over 25 iterations each passed 375 checks; adjacent date-time,l10n,translations,admin-options-submission,customizer,admin-screen smoke passed 310 checks; bootstrap smoke passed after adding direct wp_timezone_choice() markup, selected-state, offset-label, hostile-input, locale-invariance, and l10n cleanup coverage.4780 passed, 1 skipped, 0 failed at 1 iteration after direct wp_timezone_choice() markup coverage.
    component-fuzz-importer-get-pageimport-diffimport-diff seed 224 passed 11 checks, seeds 1 and 224 over 25 iterations each passed 275 checks, and seed 57123 over 10 iterations passed 110 checks; adjacent import-diff,http smoke passed 120 checks; syntax/diff checks passed after adding WP_Importer::get_page() wrapper coverage for no-network pre-HTTP interception, GET/HEAD method args, safe URL handling, timeout injection, Basic Auth boundaries, exact response propagation, and importer timeout filter cleanup.5096 passed, 1 skipped, 0 failed at 1 iteration after importer get_page() HTTP wrapper coverage.
    component-fuzz-feed-anchor-linksfeed-renderingfeed-rendering seed 224 passed 13 checks and seed 224 over 25 iterations passed 325 checks; seeds 1 and 57123 over 10 iterations each passed 130 checks; adjacent feed-rendering,feed-parsers,syndication,http,widgets smoke passed 56 checks; syntax/diff checks passed after adding direct the_feed_link() anchor coverage for default/Atom feeds, feed_link URL filtering, complete-anchor filtering, href escaping, raw anchor preservation, and filter cleanup.5095 passed, 1 skipped, 0 failed at 1 iteration after direct feed anchor helper coverage.
    component-fuzz-mail-emoji-wrappermailmail seed 224 passed 12 checks and seed 224 over 25 iterations passed 300 checks; seeds 1 and 57123 over 10 iterations each passed 120 checks; adjacent mail,email,privacy smoke passed 3840 checks; syntax/diff checks passed after adding wp_staticize_emoji_for_email() content-type matrix coverage for array/string headers, text/html, text/plain, missing-message, filtered content types, exact runtime staticization, non-message field preservation, and filter cleanup.5094 passed, 1 skipped, 0 failed at 1 iteration after mail emoji wrapper content-type matrix coverage.
    component-fuzz-frontend-url-prefixerfrontend-featuresfrontend-features seed 224 passed 8 checks and seed 224 over 25 iterations passed 200 checks; seeds 1 and 57123 over 10 iterations each passed 80 checks; adjacent frontend-features,script-loader-runtime,assets,blocks,block-supports,style,fonts smoke passed 83 checks; syntax/diff checks passed after adding URL pattern prefixer generated-context, idempotence, grouping-sensitive base-path, invalid-context diagnostic, and cleanup coverage.5094 passed, 1 skipped, 0 failed at 1 iteration after frontend URL pattern prefixer generated-context coverage.
    component-fuzz-cron-pre-filter-errorscroncron seed 224 passed 101 checks and seed 224 over 25 iterations passed 2525 checks; seeds 1 and 57123 over 10 iterations each passed 1010 checks; adjacent cron,hooks,http,request-lifecycle,options-autoload,state smoke passed 176 checks; syntax/diff checks passed after adding pre-filter false-return failure-contract coverage for schedule, reschedule, unschedule, and clear paths.5094 passed, 1 skipped, 0 failed at 1 iteration after cron pre-filter failure-contract coverage.
    component-fuzz-media-gallery-playlistmedia-metadatamedia-metadata seeds 1, 224, and 57123 over 25 iterations each passed 250 checks; adjacent media-metadata,shortcodes,images,media-editor,media-ingest,media-remote,admin-media-chrome,script-loader-runtime,content-lifecycle,template-links smoke passed 640 checks; bootstrap smoke passed after adding public gallery/playlist shortcode rendering, attachment-query short-circuits, protected-parent fail-closed checks, playlist JSON/script hooks, ID3 filters, and cleanup coverage.4804 passed, 1 skipped, 0 failed at 1 iteration after public gallery/playlist media shortcode rendering coverage.
    903654af08template-linkstemplate-links seeds 1, 224, and 57123 over 25 iterations each passed 375 checks; adjacent template-links,content,query,canonical-routing,rewrite,request-lifecycle,feed-rendering,syndication smoke passed 3715 checks; adjacent template-links,blocks,core-block-render,block-editor-adjuncts,template-hierarchy smoke passed 250 checks; bootstrap smoke passed after adding direct get_search_form() rendering, filter, escaping, echo/getter parity, legacy boolean, null-fallback, and query-state cleanup coverage.4778 passed, 1 skipped, 0 failed at 1 iteration after direct get_search_form() rendering coverage.
    5a6cad2fcdauth-flowauth-flow seeds 1, 224, and 57123 over 25 iterations each passed 300 checks; adjacent auth-flow,account-security,security,identity,request-lifecycle smoke passed 240 checks; adjacent auth-flow,rest-application-passwords,rest,rest-controllers,rest-object-controllers smoke passed 285 checks; bootstrap smoke passed after adding direct wp_login_form() rendering, filter, escaping, echo/getter parity, default redirect, required-field, and remember-me coverage.4777 passed, 1 skipped, 0 failed at 1 iteration after direct wp_login_form() rendering coverage.
    de1da7b556croncron seed 224 passed 91 checks, seeds 1, 224, and 57123 over 25 iterations each passed 2275 checks; adjacent cron,request-lifecycle,http,site-health,site-health-debug,update-install-upgrader smoke passed 750 checks; adjacent cron,options-autoload,state,environment-load smoke passed 710 checks; bootstrap smoke passed after adding public wp_cron() shutdown deferral and isolated immediate-run wrapper coverage.4776 passed, 1 skipped, 0 failed at 1 iteration after public wp_cron() wrapper coverage.
    69ffaf4b90auth-flowauth-flow seeds 1, 224, and 57123 over 25 iterations each passed 275 checks; adjacent auth-flow,account-security,security,identity,request-lifecycle smoke passed 235 checks; adjacent auth-flow,rest-application-passwords,rest,rest-controllers smoke passed 220 checks; bootstrap smoke passed after adding public wp_logout() lifecycle coverage.4775 passed, 1 skipped, 0 failed at 1 iteration after auth-flow logout lifecycle coverage.
    6afe48a008customizercustomizer seeds 1, 224, and 57123 over 25 iterations each passed 175 checks; adjacent customizer,customizer-persistence,customizer-nav-widgets-requests smoke passed 115 checks; adjacent plugin-theme-lifecycle,template-hierarchy,block-templates,rest-site-editor smoke passed 240 checks; bootstrap smoke passed after adding direct theme-preview filter/action lifecycle coverage.4774 passed, 1 skipped, 0 failed at 1 iteration after Customizer theme-preview lifecycle coverage.
    6070f5e381admin-ajaxadmin-ajax focused seed 224 passed 11 checks with no skips, seeds 1 and 224 over 25 iterations each passed 275 checks, and adjacent admin-ajax,admin-media-chrome,admin-workflows,admin-screen,content,post-types,query smoke passed 711 checks after adding Find Posts modal output, valid/empty/invalid AJAX search branches, exact public post-type query oracles, escaped table rows, nonce observability, and scoped state cleanup.4725 passed, 4 skipped, 0 failed at 1 iteration after Find Posts modal and AJAX search coverage.
    87b8fc9686rest-directory-servicesrest-directory-services focused seed 224 passed 6 checks with no skips, seeds 1 and 224 over 25 iterations each passed 150 checks, and adjacent rest-directory-services,rest,rest-controllers,rest-object-controllers,rest-media-attachments,http,blocks,block-templates,block-editor-adjuncts,plugin-theme-lifecycle smoke passed 97 checks with 1 existing skip after adding block-directory _fields projection, explicit title/icon mapping, installed-plugin link discovery, and no-HTTP oracles.4724 passed, 4 skipped, 0 failed at 1 iteration after REST directory block-directory field/link coverage.
    627a726d0eappearance-mediaappearance-media focused seed 224 passed 11 checks with no skips, seeds 1 and 224 over 25 iterations each passed 275 checks, and adjacent appearance-media,customizer,customizer-persistence,frontend-features,admin-media-chrome,media-editor,media-metadata,image-metadata,images,network-media,script-loader-runtime smoke passed 95 checks after adding exact the_custom_header_markup() output, enqueue, localization, and script-state restoration coverage.4723 passed, 4 skipped, 0 failed at 1 iteration after custom-header print side-effect coverage.
    a942ff51c7appearance-mediaappearance-media focused seed 224 passed 10 checks with no skips, seeds 1 and 224 over 25 iterations each passed 250 checks, and adjacent appearance-media,customizer,customizer-persistence,frontend-features,admin-media-chrome,media-editor,media-metadata,image-metadata,images,network-media,script-loader-runtime smoke passed 94 checks after adding real site-icon option-to-attachment metadata URL and stale-ID fallback coverage.4722 passed, 4 skipped, 0 failed at 1 iteration after site-icon attachment URL coverage.
    643ba2fd4dappearance-mediaappearance-media focused seed 224 passed 9 checks with no skips, seeds 1 and 224 over 25 iterations each passed 225 checks, and adjacent appearance-media,customizer,customizer-persistence,frontend-features,admin-media-chrome,media-editor,media-metadata,image-metadata,images,network-media,script-loader-runtime smoke passed 93 checks after adding custom background head-callback CSS and custom-logo hide-header-text CSS coverage.4721 passed, 4 skipped, 0 failed at 1 iteration after appearance media head-callback CSS coverage.
    98c23465dewxr-exportwxr-export focused seed 224 passed 10 checks with no skips, seeds 1 and 224 over 25 iterations each passed 250 checks, and adjacent wxr-export,import-diff,content,content-lifecycle,metadata,media-remote smoke passed 315 checks after replacing the CLI header observability skip with filtered filename/content-type intent accounting.4685 passed, 10 skipped, 0 failed at 1 iteration after WXR export header skip retirement.
    a8de6a7e5emetadatametadata focused seed 224 passed 11 checks with no skips, seeds 1 and 224 over 25 iterations each passed 275 checks, and adjacent metadata,content,content-lifecycle,post-types,taxonomy,taxonomy-relationships,comments,identity smoke passed 2600 checks after replacing the absent subtype-helper availability skip with current API accounting.4684 passed, 11 skipped, 0 failed at 1 iteration after metadata subtype-helper skip retirement.
    d9f3bf8a9frest-object-controllersrest-object-controllers focused seed 224 passed 12 checks with no skips, seeds 1 and 224 over 25 iterations each passed 300 checks, and adjacent REST-family smoke passed 365 checks after replacing the broad collection-query skip with REST/query-class short-circuit coverage for posts, attachments, revisions, users, comments, and terms.4683 passed, 12 skipped, 0 failed at 1 iteration after REST object collection query skip retirement.
    e52e60ef77script-loader-runtimescript-loader-runtime focused seed 224 passed 15 checks with no skips, seeds 1 and 224 over 25 iterations each passed 375 checks with no skips, adjacent script-loader-runtime,assets,blocks,block-supports,style,fonts smoke passed 69 checks with no skips, and cleanup verified the generated wp-emoji-loader.js asset is removed after each run.4690 passed, 5 skipped, 0 failed at 1 iteration after script-loader emoji loader asset accounting.
    d4a516e4c5script-loader-runtimescript-loader-runtime focused seed 224 passed 14 checks with 1 loader-asset skip, seeds 1 and 224 over 25 iterations each passed 350 checks with 25 loader-asset skips, and adjacent script-loader-runtime,assets,style,block-editor-adjuncts,blocks smoke passed 290 checks with 5 skips after splitting emoji style enqueue/hook coverage from asset-gated emoji detection settings output.4682 passed, 13 skipped, 0 failed at 1 iteration after script-loader emoji style coverage split.
    component-fuzz-admin-workflow-noticesadmin-workflowsadmin-workflows focused seeds 1, 224, and 57123 each passed 250 checks with no skips, and adjacent admin-workflows,admin-list-tables,admin-ajax,admin-screen,admin-options-submission,user-preferences,privacy-admin-requests,security smoke passed 86 checks after adding Settings API section/field/error rendering plus admin notice getter/echo/filter/action coverage.4829 passed, 1 skipped, 0 failed at 1 iteration after admin workflow Settings API and admin notice helper coverage.
    b59b5030c4admin-workflowsadmin-workflows focused seed 224 passed 8 checks with no skips, seeds 1 and 224 over 25 iterations each passed 200 checks with no skips, adjacent admin smoke passed 291 checks with 9 skips, and scoped admin-workflows,admin-list-tables,privacy-admin-requests smoke passed 111 checks with 9 skips after replacing the stale concrete list-table skip with scoped accounting for dedicated surface coverage and explicit not-claimed boundaries.4681 passed, 13 skipped, 0 failed at 1 iteration after admin workflow list-table coverage accounting.
    6a28aff00badmin-list-tablesadmin-list-tables focused seed 224 passed 10 checks with 1 current-core skip, seeds 1 and 224 over 25 iterations each passed 226 checks with 49 existing skips, and adjacent admin-list-tables,privacy-admin-requests,admin-workflows,admin-ajax,admin-screen smoke passed 201 checks with 14 skips after retiring the stale privacy request table skip and accounting for the dedicated privacy-admin-requests coverage.4680 passed, 14 skipped, 0 failed at 1 iteration after admin list-table privacy coverage accounting.
    10cb72a48aadmin-workflowsadmin-workflows focused seeds 1 and 224 each passed 175 checks with 25 existing core-list-table skips, and adjacent admin-workflows,admin-ajax,admin-screen,admin-options-submission,user-preferences,admin-list-tables smoke passed 256 checks with 19 skips after adding captured wp_ajax_date_format() and wp_ajax_time_format() wrapper coverage.4672 passed, 22 skipped, 0 failed at 1 iteration after admin workflow AJAX date/time format wrapper coverage.
    5568f588fcuser-preferencesuser-preferences focused seeds 1 and 224 each passed 300 checks with no skips, and adjacent user-preferences,admin-workflows,admin-ajax,admin-screen,admin-options-submission smoke passed 210 checks with 10 skips after adding subprocess-isolated AJAX preference handler and set_screen_options() redirect/exit coverage.4671 passed, 23 skipped, 0 failed at 1 iteration after user preference request handler coverage.
    4de585019dmarkupmarkup focused seeds 1 and 224 each passed 575 checks with no skips, and adjacent markup,blocks,block-supports,core-block-render,shortcodes,kses smoke passed 11652 checks with no skips after adding deterministic do_blocks() fixture rendering and state restoration.4670 passed, 24 skipped, 0 failed at 1 iteration after deterministic markup do_blocks() fixture coverage.
    791c426de8site-health-debugsite-health-debug focused seeds 1 and 224 each passed 225 checks with no skips over 25 iterations, and adjacent site-health-debug,site-health,http,environment-load,cron smoke passed 139 checks with no skips after replacing the malformed SHOW TABLE STATUS row skip with strict/omission boundary accounting.4688 passed, 7 skipped, 0 failed at 1 iteration after Site Health database malformed-row boundary accounting.
    9f1d3f3662site-health-debugsite-health-debug focused seeds 1 and 224 each passed 8 checks with 1 skip, seed 224 over 25 iterations passed 200 checks with 25 skips, and adjacent site-health-debug,site-health,http,environment-load,cron smoke passed 138 checks with 1 skip after adding bounded full WP_Debug_Data::debug_data() coverage.4668 passed, 25 skipped, 0 failed at 1 iteration after bounded Site Health debug-data full-scan coverage.
    bc09ade067rest-controllersrest-controllers focused seeds 1 and 224 each passed 12 checks, and adjacent rest-controllers,rest-widgets-sidebars,widgets,admin-ajax,admin-screen,admin-workflows smoke passed 56 checks with 2 skips after adding REST plugin/theme controller contract coverage.4667 passed, 26 skipped, 0 failed at 1 iteration after REST plugin/theme controller contract coverage.
    bd01a25986rest-widgets-sidebarsrest-widgets-sidebars passed 9 checks at seed 1, 225 checks at seed 1 over 25 iterations, 225 checks at seed 224 over 25 iterations, and adjacent REST/widget/admin smoke passed 420 checks with 10 skips after adding isolated REST widget-type render endpoint coverage.4666 passed, 28 skipped, 0 failed at 1 iteration after isolated REST widget-type render endpoint coverage.
    0c0debbb5epost-embedspost-embeds passed 6 checks at seed 1, 150 checks at seed 1 over 25 iterations, 150 checks at seed 224 over 25 iterations, and adjacent post-embeds,rest,http,content,template-links,syndication,media-remote smoke passed 350 checks after adding REST oEmbed proxy provider-fetch and transient-cache coverage.4665 passed, 28 skipped, 0 failed at 1 iteration after REST oEmbed proxy provider-fetch and transient-cache coverage.
    a9fdacdc73site-healthsite-health passed 12 checks at seed 224, 300 checks at seed 1 over 25 iterations, 300 checks at seed 224 over 25 iterations, and adjacent site-health,site-health-debug,http,cron smoke passed 600 checks with 10 skips after adding loopback, REST availability, HTTP-blocking, and scheduled-event direct-test coverage.4664 passed, 28 skipped, 0 failed at 1 iteration after Site Health loopback, REST availability, HTTP-blocking, and scheduled-event direct-test coverage.
    8cf920b5c3privacyprivacy passed 19 checks at seed 1, 475 checks at seed 1 over 25 iterations, and 475 checks at seed 224 over 25 iterations after adding privacy policy suggested-text lifecycle, policy-page cache update, text-change cache, and admin notice coverage.4661 passed, 28 skipped, 0 failed at 1 iteration across 112 surfaces after privacy policy lifecycle coverage.
    cc00dac113canonical-routingcanonical-routing passed 14 checks at seed 1, 350 checks at seed 1 over 25 iterations, 350 checks at seed 224 over 25 iterations, and adjacent canonical-routing,request-lifecycle,template-links smoke passed 190 checks after adding DB-backed 404 permalink guessing coverage.4660 passed, 28 skipped, 0 failed at 1 iteration across 112 surfaces after canonical DB-backed redirect coverage.
    0cc587f3a1privacy-admin-requestsprivacy-admin-requests passed 5 checks at seed 1, 125 checks at seed 1 over 25 iterations, 125 checks at seed 224 over 25 iterations, and adjacent privacy,admin-ajax,admin-list-tables,privacy-admin-requests smoke passed 206 checks with 14 skips after adding admin privacy request list-table and AJAX handler coverage.4659 passed, 28 skipped, 0 failed at 1 iteration across 112 surfaces after privacy admin request coverage.
    current branchappearance-mediaappearance-media passed 12 checks at seed 224 and 300 checks at seeds 1 and 224 over 25 iterations; adjacent appearance/media/customizer/filesystem smoke passed 350 checks with no skips after adding remove-background safe redirect coverage for same-host referers, unsafe referer fallback, cleared background image/thumb theme mods, invalid nonce wp_die() fail-closed behavior, and redirect/filter/superglobal cleanup.4736 passed, 1 skipped, 0 failed at 1 iteration after adding appearance background remove redirect coverage.
    cbc425b410admin-edit-metaboxesadmin-edit-metaboxes passed 6 checks at seed 1 and 150 checks at seed 224 over 25 iterations; adjacent admin/content/taxonomy/comment/link smoke passed 562 checks with 8 skips after adding classic edit-screen callback and default registration coverage.4637 passed, 27 skipped, 0 failed at 1 iteration after admin edit meta box coverage and bootstrap admin include additions.
    bb8c89fa2arest-widgets-sidebarsrest-widgets-sidebars passed 8 checks at seed 1 and 200 checks at seed 224 over 25 iterations; adjacent REST/widget/admin smoke passed 415 checks with 10 skips after adding REST widget, widget-type, sidebar mutation, legacy form-data, delete hook, HEAD, and update-guard isolation coverage.4631 passed, 28 skipped, 0 failed at 1 iteration after REST widget/sidebar controller coverage and bootstrap endpoint includes.
    f5f6f5ff23customizer-nav-widgets-requestscustomizer-nav-widgets-requests passed 8 checks at seed 1 and 200 checks at seed 224 over 25 iterations; adjacent Customizer/navigation/widget/admin smoke passed 560 checks after adding nav-menu/widget request, placeholder remap, and selective-refresh coverage.4623 passed, 28 skipped, 0 failed at 1 iteration after Customizer nav/widget request coverage.
    cfb49670d1media-image-edit-requestsmedia-image-edit-requests passed 11 checks at seed 1 and 275 checks at seed 224 over 25 iterations; adjacent media/admin smoke passed 97 checks with 4 skips after adding image-edit request save/preview/crop/sub-size coverage and REST media capability grant hardening.4615 passed, 28 skipped, 0 failed at 1 iteration after media image-edit request coverage.
    bb12296c68rest-media-attachmentsrest-media-attachments passed 6 checks at seed 1 and 150 checks at seed 224 over 25 iterations; adjacent REST/media/filesystem smoke passed 495 checks with 25 skips after adding REST attachment raw upload, metadata finalization, permission, response projection, and fail-closed edit-media coverage.4604 passed, 28 skipped, 0 failed at 1 iteration after REST media attachment write coverage.
    b958538155post-embedspost-embeds passed 5 checks at seed 1 and 125 checks at seed 224 over 25 iterations; adjacent embed/syndication/REST/content/template/media smoke passed 445 checks with 10 skips after adding WordPress-as-provider oEmbed helper and controller coverage.4598 passed, 28 skipped, 0 failed at 1 iteration after post embed provider coverage and narrow wpdb path-conflict query projection support.
    925224f77eadmin-options-submissionadmin-options-submission seeds 1, 224, and 57123 over 25 iterations each passed 200 checks; adjacent admin-options-submission,admin-workflows,admin-screen,security,options-autoload smoke passed 270 checks; bootstrap smoke passed after adding Reading/Discussion/Media/Writing core option-page sanitization plus conditional Writing Settings allowlist gate coverage.4773 passed, 1 skipped, 0 failed at 1 iteration after admin options core-page submission coverage.
    f49c489f0ftaxonomytaxonomy seeds 1, 224, and 57123 over 25 iterations each passed 325 checks; adjacent taxonomy,taxonomy-relationships,template-links,classic-walkers,rest-controllers smoke passed 335 checks; bootstrap smoke passed after adding hierarchical get_term_link() parent-slug expansion, get_term_parents_list() agreement, and legacy term-link filter ordering coverage.4771 passed, 1 skipped, 0 failed at 1 iteration after hierarchical taxonomy term-link coverage.
    23df3bbf25statestate seeds 1, 224, and 57123 over 25 iterations each passed 500 checks; adjacent state,multisite,options-autoload smoke passed 250 checks with 5 existing skips; bootstrap smoke passed after adding direct wp_cache_switch_to_blog() local/global group prefix coverage.4770 passed, 1 skipped, 0 failed at 1 iteration after direct object-cache blog-switch coverage.
    474524f187discoverydiscovery seeds 1, 224, and 57123 over 25 iterations each passed 375 checks; adjacent discovery,canonical-routing,request-lifecycle,http smoke passed 110 checks; disabled-subprocess smoke passed 14 checks with 1 skip; bootstrap smoke passed after adding favicon front-controller redirect-and-exit coverage.4769 passed, 1 skipped, 0 failed at 1 iteration after favicon front-controller redirect coverage.
    c915462831discoverydiscovery seeds 1, 224, and 57123 over 25 iterations each passed 350 checks; adjacent discovery,canonical-routing,request-lifecycle,http,rest-site-editor,core-block-render,template-links smoke passed 460 checks after adding robots.txt front-controller output and hook-order coverage.4767 passed, 1 skipped, 0 failed at 1 iteration after robots.txt front-controller output coverage.
    cd5fe64d54stylestyle seeds 1, 224, and 57123 over 25 iterations each passed 400 checks; adjacent style,fonts,block-supports,rest-site-editor smoke passed 245 checks after adding global styles user-data/getter coverage.4768 passed, 1 skipped, 0 failed at 1 iteration after global styles user-data getter coverage.
    8953de8d7fnavigation-lifecyclenavigation-lifecycle seeds 1, 224, and 57123 over 25 iterations each passed 225 checks; adjacent navigation/template/content/taxonomy smoke passed 490 checks after adding navigation fallback classic-menu conversion coverage.4766 passed, 1 skipped, 0 failed at 1 iteration after navigation fallback classic-menu conversion coverage.
    7f9abcb47dadmin-options-submission, core-block-renderadmin-options-submission passed 5 checks at seed 1 and 125 checks at seed 224 over 25 iterations; adjacent admin/options/security smoke passed 230 checks with 10 skips; combined admin/core replay passed 300 checks after core block render expectation hardening.4585 passed, 28 skipped, 0 failed at 1 iteration after admin options submission coverage and core block render seed-224 hardening.
    75ef2dfad2core-block-render5 passed with 0 skips at seed 1, 25 passed with 0 skips at seed 224 over 5 iterations, and adjacent blocks,core-block-render,query-loop smoke passed 180 checks with 0 skips after direct dynamic core block render-callback coverage for site identity, search/loginout, post context, and button/file/image markup transforms.4583 passed, 28 skipped, 0 failed at 1 iteration after core block render callback fuzzing.
    d1916878e9 / 0d7f1e98c9template-hierarchy10 passed with 0 skips at seed 1, 250 passed with 0 skips at seed 224 over 25 iterations, disabled-proc_open guard passed 9 checks with 1 skip, and adjacent template-hierarchy,content,comments smoke passed 946 checks with 0 skips after isolated comments_template() coverage for child-over-parent default files, generated custom comments files, comments_template filter paths, synthetic comment query args including orderby and no_found_rows, local $comment_args, cloned child hook snapshots, parent-safe COMMENTS_TEMPLATE, and child stderr/stray-output failures; James re-review found no remaining findings.9166 passed, 57 skipped, 0 failed at 2 iterations after comments template hierarchy fuzzing.
    7580167b9c / 2047262604environment-load17 passed with 0 skips at seed 1, 425 passed with 0 skips at seed 224 over 25 iterations, WP_RUN_CORE_TESTS plus WP_ENVIRONMENT_TYPE=staging repro passed 17 checks with 0 skips, disabled-proc_open guard passed 16 checks with 1 skip, and adjacent environment-load,http,site-health-debug smoke passed 70 checks with 4 known skips after isolated environment-type matrix coverage for env-var values, allowed constant overrides, invalid constant fail-closed behavior, parent/child restoration, row-local subprocess capability skips, and child stderr/stray-output failures; Hegel re-review found no remaining findings.9164 passed, 59 skipped, 0 failed at 2 iterations after environment type matrix fuzzing.
    12f2cd8d7f / 696162da10script-loader-runtime13 passed with 1 skip at seed 1, 325 passed with 25 skips at seed 224 over 25 iterations, disabled-proc_open guard repro passed 12 checks with 2 skips, and adjacent assets,script-loader-runtime,blocks smoke passed 70 checks with 2 skips after JIT localization coverage for autosaveL10n, mceViewL10n, wordCountL10n, generated shortcode tags, tag-shaped JSON escaping, child/parent state restoration, and row-local subprocess capability skips; Confucius re-review found no remaining findings.9162 passed, 61 skipped, 0 failed at 2 iterations after script-loader JIT localization fuzzing.
    2a84783187email1244 passed with 1 skip at seed 1, 12502 passed with 10 skips at seed 20260627 over 10 iterations, reviewer seed 424242 passed 3741 checks with 3 skips, and adjacent email,mail,identity smoke passed 1256 checks with 1 skip after UTF-8 address-model coverage for mixed-script combining local parts, Unicode/Punycode domains, WP_Email_Address getter round trips, filter-mode agreement, ASCII-mode rejection, reserved ACE-like labels, invalid xn-- labels, and malformed UTF-8 warning capture; Gauss review found no findings.9160 passed, 63 skipped, 0 failed at 2 iterations after UTF-8 email address-model fuzzing.
    3f5772e8e7 / f3527a20ceadmin-dashboard10 passed with 1 skip at seed 1, 250 passed with 25 skips at seed 224 over 25 iterations, 1000 passed with 100 skips at seed 999 over 100 iterations, and adjacent admin-dashboard,site-health,http smoke passed 64 checks with 2 skips after Browser Happy coverage for generated request payloads, HTTPS endpoint selection, one-week site-transient TTLs, cache reuse, fail-closed remote errors without raw transient writes, insecure class preservation, regular and IE nag rendering, empty-user-agent no-network behavior, and review fixes; Pascal re-review found no findings.9158 passed, 63 skipped, 0 failed at 2 iterations after dashboard Browser Happy fuzzing.
    a71e18bea4 / 09fcc5d910admin-list-tables9 passed with 2 skips at seed 1, 81 passed with 29 skips at seed 224 over 10 iterations, 801 passed with 299 skips at seed 999 over 100 iterations, and adjacent admin-list-tables,plugin-theme,update-install-upgrader smoke passed 91 checks with 8 skips after selected-mode theme install list-table coverage for generated API args, tab/view plumbing, result ordering, pagination totals, install/update/latest-installed/newer-installed states, screenshot and description escaping, fail-closed theme API behavior, theme-root option/cache cleanup, and review fixes; Kepler re-review found no remaining findings.9156 passed, 63 skipped, 0 failed at 2 iterations after theme install list-table fuzzing.
    74d7c296f3 / e95cf978f1admin-list-tables8 passed with 2 skips at seed 1, 200 passed with 50 skips at seed 224 over 25 iterations, 800 passed with 200 skips at seed 999 over 100 iterations, and adjacent admin-list-tables,plugin-theme,update-install-upgrader smoke passed 60 checks with 4 skips after plugin install list-table coverage for generated search API args, tab/view plumbing, result ordering, pagination totals, install/update/activate actions, compatibility notices, icon and description escaping, fail-closed plugin API behavior, localized update-count cleanup, and review fixes; Kierkegaard re-review found no remaining findings.9155 passed, 62 skipped, 0 failed at 2 iterations after plugin install list-table fuzzing.
    5d018bedd3email1243 passed with 1 skip at seed 1, 2492 passed with 2 skips at seed 224 over 2 iterations, 3744 passed with 3 skips at seed 20260627 over 3 iterations, and adjacent email,mail,identity smoke passed 1255 checks with 1 skip after Unicode authentication coverage for exact canonical readable address login, wrong-password failures, alias local-part isolation, machine/Punycode view lookup misses, tracked auth hooks, and stub/cache cleanup; Arendt review found no actionable findings.9153 passed, 62 skipped, 0 failed at 2 iterations after Unicode email authentication fuzzing.
    9d0ad6c83fhooks12 passed at seed 1, 60 passed at seed 224 over 5 iterations, 240 passed at seed 20260627 over 20 iterations, 1200 passed at seed 999 over 100 iterations, and adjacent hooks,options-autoload,plugin-theme smoke passed 68 checks after deprecated hook wrapper coverage for no-callback fast paths, exact deprecation payloads, trigger-error suppression, ref-array dispatch, by-reference mutation, all-hook visibility, counters, cloned hook-global restoration, and hostile preexisting callback isolation; Pasteur re-review found no actionable findings.9151 passed, 62 skipped, 0 failed at 2 iterations after deprecated hook wrapper fuzzing.
    e3d43f715akses1096 passed, 0 skipped, 0 failed at seed 1; 1093 passed, 0 skipped, 0 failed at seed 224; 5487 passed, 0 skipped, 0 failed at seed 54261 over 5 iterations; 54818 passed, 0 skipped, 0 failed at seed 65270 over 50 iterations after PDF object policy, upload host/port, bad-protocol prefilter, duplicate attribute, non-self invalid fallback, and dynamic URI attribute filter coverage. Syntax/PHPCS/diff checks passed; Bacon review found no actionable findings.9107 passed, 62 skipped, 0 failed at 2 iterations after KSES PDF object and URI attribute filter coverage.
    8fc00de203kses1094 passed, 0 skipped, 0 failed at seed 1; 1091 passed, 0 skipped, 0 failed at seed 224; 5477 passed, 0 skipped, 0 failed at seed 54261 over 5 iterations; 54718 passed, 0 skipped, 0 failed at seed 65270 over 50 iterations after deterministic attribute constraint, mutation, required-attribute, full-tag parse, style entity-decoding, and unsafe CSS allow-filter coverage. Syntax/PHPCS/diff checks passed; Harvey review found no actionable findings.9103 passed, 62 skipped, 0 failed at 2 iterations after KSES attribute constraint coverage.
    50b7b3e2a3xmlrpc550 passed, 0 skipped, 0 failed at seeds 1 and 224 after authenticated read-only wp.getPost, wp.getPosts, wp.getMediaItem, and wp.getMediaLibrary coverage for auth/capability errors, field filtering, media projection, parent/MIME filters, scoped hooks, seeded cleanup, and review fixes. Syntax/PHPCS/diff checks passed.9091 passed, 62 skipped, 0 failed at 2 iterations after XML-RPC authenticated read-only content/media coverage.
    276dc47a5bemail6235 passed, 5 skipped, 0 failed at seed 1; 6225 passed, 5 skipped, 0 failed at seed 224; 3745 passed, 3 skipped, 0 failed at seed 20260626 after UTF-8 comment-submission coverage for front-door validation, direct comment insertion sanitization, hook payloads, and state restoration. Syntax/PHPCS/diff checks passed; Maxwell re-review found no blockers.9089 passed, 62 skipped, 0 failed at 2 iterations after UTF-8 email comment-submission coverage.
    4a2cad026crevisions-autosaves600 passed, 50 guarded browser-template skips, 0 failed at seeds 1 and 224 after latest revision count/URL helper and user-filtered autosave lookup coverage; direct post_author equality/IN/status-OR SQL probes passed, Archimedes re-review found no blocker, and syntax/PHPCS/diff checks passed.9087 passed, 62 skipped, 0 failed at 2 iterations after replacing the latest-count URL and user-filtered autosave stub-limited skips.
    555495f583admin-bar800 passed, 0 skipped, 0 failed at seeds 1 and 224 after default callback node graph coverage for WordPress logo, account, appearance, comments, search, and secondary groups, with exact node parent/href/meta/title oracles plus Planck review fixes for no-DB short-circuits and wpdb runtime restoration. Syntax/PHPCS/diff checks passed.9081 passed, 66 skipped, 0 failed at 2 iterations after admin-bar default callback node graph coverage.
    89e00fbfadsyndication900 passed, 0 skipped, 0 failed at seeds 1 and 224 after feed_links_extra() branch coverage for singular comments, post type archives, category, tag, custom taxonomy, author, and search query states, with exact query args, escaping, filter-locality, cache restoration, and Tesla review cleanup. Syntax/PHPCS/diff checks passed.9079 passed, 66 skipped, 0 failed at 2 iterations after feed_links_extra() branch coverage.
    53453d79ffcanonical-routing1300 passed, 0 skipped, 0 failed at seeds 1 and 224 after canonical URL output helper coverage for status gates, paged/comment URLs, plain-permalink fallback, get_canonical_url filters, rel_canonical() output, cache restoration, and Turing review fixes. Syntax/PHPCS/diff checks passed.9077 passed, 66 skipped, 0 failed at 2 iterations after canonical URL helper output coverage.
    93c6f19416images1000 passed, 0 skipped, 0 failed at seeds 1 and 224 after content tag pipeline coverage for auto-sizes, dimensions, srcset/sizes, loading optimization, iframe loading, duplicate media replacement, and review fixes for exact generated candidate URLs plus core loading execution. Syntax/PHPCS/diff checks passed.9075 passed, 66 skipped, 0 failed at 2 iterations after image content tag pipeline coverage.
    0afd17bb8dtemplate-hierarchy900 passed, 100 guarded comments-template skips, 0 failed at seeds 1 and 224 after direct helper hierarchy coverage for author/date/home/frontpage/privacypolicy/singular/attachment, exact dynamic hook payloads, no-DB synthetic WP_User, attachment MIME subtype ordering, and child/parent path confinement. Syntax/PHPCS/diff checks passed; Hilbert review found no actionable findings.9073 passed, 66 skipped, 0 failed at 2 iterations after template hierarchy direct helper coverage.
    7790a7504dinteractivity1100 passed, 0 skipped, 0 failed at seeds 1 and 224 after directive syntax/order coverage for bind/class/style/text suffixes, unique-ID ignores, event-handler bind warnings, boolean data/ARIA conversion, false removals, class/style ordering, non-scalar text, invalid directive names, and review fixes for ignored bind unique IDs plus exact class tokens. Syntax/PHPCS/diff checks passed.13602 passed, 99 skipped, 0 failed at 3 iterations after interactivity directive syntax/order coverage.
    31260a0f66email3742 passed, 3 skipped, 0 failed at seed 20260626 over 3 iterations after UTF-8 email boundary coverage for quoted/escaped local parts, control characters and Unicode separators, local-part identity preservation, and hook restoration; reviewer seed 31260 passed 1241 with 1 skip and no actionable findings; mail smoke passed 10 checks; email PHPUnit passed 86 tests and 179 assertions with 1 skip; syntax/PHPCS/diff checks passed.4519 passed, 33 skipped, 0 failed at 1 iteration after query hardening and UTF-8 email boundaries.
    276dc47a5bemailLatest focused runs: seed 1 passed 6235 checks with 5 skips, seed 224 passed 6225 checks with 5 skips, and seed 20260626 passed 3745 checks with 3 skips after UTF-8 comment-submission coverage for wp_handle_comment_submission() valid/rejected paths and direct wp_new_comment() recovery/sanitization semantics.9089 passed, 62 skipped, 0 failed at 2 iterations after UTF-8 email comment-submission coverage.
    30d42d342femail1254 passed, 1 skipped at seed 260626; 1241 passed, 1 skipped at seed 1; 1244 passed, 1 skipped at seed 224; 3748 passed, 3 skipped at seed 20260626 over 3 iterations after generated Unicode local-part update aliases, lookup invalidation, duplicate collision rejection, and email-change notification preservation.9121 passed, 62 skipped, 0 failed at 2 iterations after Unicode email local-part update fuzzing.
    2a2ccd0aee / d93c8a7e0femail1242 passed, 1 skipped at seed 1; 1245 passed, 1 skipped at seed 224; 1255 passed, 1 skipped at seed 260626; 3751 passed, 3 skipped at seed 20260626 over 3 iterations after Unicode profile confirmation request coverage for success, duplicate, invalid, wrong-user, and same-email no-op paths, with review fixes for stale _new_email preservation and exact escaped confirmation URLs.9123 passed, 62 skipped, 0 failed at 2 iterations after Unicode email profile confirmation fuzzing.
    992c2393a5 / 729e2a5322 / 83039db582 / 8e76f34885query, wpdb query stub64400 passed, 0 skipped, 0 failed at seeds 1 and 224 after WP_Query post-search result oracle hardening, contradictory password clauses, literal clause-keyword searches, attachment filename null-left-join semantics, independent empty relevance-order boundary detection, and status-OR stub coverage for simple equality, author-scoped private, and mixed equality/IN branches; customizer-persistence regression smoke passed 7 checks; syntax/PHPCS/diff checks passed.4519 passed, 33 skipped, 0 failed at 1 iteration after query hardening and UTF-8 email boundaries.
    244b2278a8query58800 passed, 0 skipped, 0 failed at seeds 1 and 224 after classic WP_Query post-search parser/order coverage for quoted phrases, stopwords, short-term fallback, 10+ term sentence fallback, CR/LF normalization, exact matches, exclusion-prefix disabling, supported search-column allowlisting, post_search_columns, wp_search_stopwords, logged-in password gates, attachment filename SQL branches, and relevance CASE ranking. Syntax/PHPCS/diff checks passed.4459 passed, 33 skipped, 0 failed at 1 iteration after WP_Query post-search fuzzing.
    f53cfd1bfc / d83d02f5e0 / 9e4c4ee83cblocks800 passed, 0 skipped, 0 failed at seeds 1 and 224 after render-time block bindings coverage for supported-attribute global/dynamic filters, source callback and block_bindings_source_value payloads, render_block_context and source uses_context, computed attribute merge before dynamic callbacks, rich-text and attribute HTML replacement, unchanged unsupported attributes, and exact missing/malformed binding key absence. Syntax/PHPCS/diff checks passed, and Curie follow-up review found no actionable findings.4330 passed, 33 skipped, 0 failed at 1 iteration after block bindings render-pipeline review fixes.
    c93166b0ec / 50ef617a22 / 89ec6030b2content15 passed, 0 skipped, 0 failed at seeds 1 and 224 plus 750 passed over 50 iterations after post content pagination coverage for get_the_content(), the_content(), generated // branches, protected content forms, wp_link_pages() output/filter contracts, and global/cache/cookie/filter restoration; adjacent content/query/template-links smoke passed 3355 checks. Earlier post-template helper runs passed 1400 checks at seeds 1 and 224. Syntax/PHPCS/diff checks passed, and Nash follow-up review found no actionable findings.9139 passed, 62 skipped, 0 failed at 2 iterations after content pagination and KSES review-fix coverage.
    5f9d739e21 / cc0493297ccomment-workflow900 passed, 0 skipped, 0 failed at seeds 1 and 224 after hard-delete coverage for wp_delete_comment() child reparenting, comment meta deletion and hooks, approved-count refresh, missing-ID failure, delete/status transition hook payloads, type-specific transition hooks, and pending/spam/trash/note count boundaries; syntax/PHPCS/diff checks passed.4328 passed, 33 skipped, 0 failed at 1 iteration after comment hard-delete workflow review fixes; Kepler follow-up review found no actionable findings.
    60dec6d029filesystem1200 passed, 0 skipped, 0 failed at seeds 1 and 224 after recursive helper coverage for wp_mkdir_p(), list_files() hidden/exclusion/depth behavior, wp_is_writable(), and wp_is_stream(); reviewer seed 60 also passed 1200 checks, and syntax/PHPCS/diff checks passed.4326 passed, 33 skipped, 0 failed at 1 iteration after filesystem recursive helper coverage; Gibbs review found no actionable findings.
    07ddf2ba58multisiteSeeds 1, 224, and 57123 over 25 iterations each passed 450 checks with 25 existing true-multisite skips after signup validation coverage for unsafe email domains, user/blog validation errors, pending and stale wp_signups rows, and nonce branches.4790 passed, 1 skipped, 0 failed at seed 224 after multisite signup validation coverage; adjacent multisite,identity,email smoke passed 1266 checks with 1 skip and bootstrap smoke passed.
    5883479b5aformattingSeeds 1, 224, and 57123 over 25 iterations each passed 425 checks after deep helper coverage for map_deep(), URL encode/decode helpers, stripslashes_deep(), and slash/unslash round trips over generated nested arrays and objects.4789 passed, 1 skipped, 0 failed at seed 224 after deep formatting helper coverage; adjacent formatting/content/security/markup/KSES/shortcodes smoke passed 1190 checks and bootstrap smoke passed.
    9bfd3cd9bfcommentsSeeds 1, 224, and 57123 over 25 iterations each passed 11325 checks after direct comment_form() coverage for closed-post handling, title/hidden-ID helpers, anonymous HTML5/XHTML forms, cookies consent, must-log-in/logged-in branches, field/action/submit filters, no-DB sentinel execution, and cleanup.4788 passed, 1 skipped, 0 failed at seed 224 after comment form coverage; adjacent comments/content/template smoke passed 503 checks and bootstrap smoke passed.
    78b8b78390editor-helpersSeeds 1, 224, and 57123 over 25 iterations each passed 250 checks after direct wp_tinymce_inline_scripts() coverage for classic-block filter fan-out, duplicate plugin de-duplication, caption/toolbars/external-plugin merging, raw JSON/array/function settings serialization, before-script attachment, and filter cleanup.4783 passed, 1 skipped, 0 failed at seed 224 after TinyMCE inline script coverage; adjacent editor/assets/media smoke passed 67 checks and bootstrap smoke passed.
    94c539db4brestSeeds 1, 224, and 57123 over 25 iterations each passed 275 checks after /batch/v1 coverage for allowed/disallowed route gates, generated child methods, request propagation, normal partial success, require-all validation blocking, parse-path errors, max-size schema enforcement, and pre/post dispatch filter locality.4782 passed, 1 skipped, 0 failed at seed 224 after REST batch-v1 coverage; adjacent REST-family smoke passed 69 checks and bootstrap smoke passed.
    1cc604ef19 / afc95daacc / 1aecc04af2http2200 passed, 0 skipped, 0 failed at seeds 1 and 224 after Requests success-path coverage, review fixes for same-site hermeticity, actual transport header assertions, max-bytes and stream-success behavior, and finally-backed stream cleanup; forced WP_HTTP_BLOCK_EXTERNAL smoke passed 11 checks; final cleanup re-review found no actionable findings.4354 passed, 33 skipped, 0 failed at 1 iteration after HTTP Requests success-path review fixes.
    d0bbc3c4fb / e2f4e658d1query, wpdb stub, import-diff48600 passed, 0 skipped, 0 failed at seeds 1 and 224 after WP_User_Query field/order/search/role/capability/has-published-post SQL-shape coverage plus review fixes for hook cleanup and typed placeholders; reviewer follow-up found no actionable findings; related PHPUnit: 52 tests, 156 assertions.4353 passed, 33 skipped, 0 failed at 1 iteration after user-query semantic coverage and typed wpdb prepare fix.
    de213220baprivacy18 passed, 0 skipped at seed 1; 450 passed, 0 skipped at seed 224 over 25 iterations; adjacent privacy,comments,media-metadata,media-ingest,identity,admin-ajax,state smoke passed 2570 checks after built-in user/media exporter registration, profile/community-location/session-token payloads, additional profile filter contracts, media author/type filtering, URL payloads, and 50-item pagination coverage.4654 passed, 28 skipped, 0 failed at 1 iteration after privacy built-in user/media exporter fuzzing.
    df3be8a1aaprivacy17 passed, 0 skipped at seed 1; 17 passed, 0 skipped at seed 224; 85 passed, 0 skipped at seed 20260626 over 5 iterations after final personal-data erasure completion coverage for last-eraser done responses, request completion status/meta, request identity preservation, exact erased-action firing, and post-cache cleanup.9123 passed, 62 skipped, 0 failed at 2 iterations after privacy final erasure completion fuzzing.
    72f3ae35e3 / 970437d39fprivacy170 passed, 0 skipped, 0 failed at seeds 1 and 224 after export-email recipient override, subject filter, content placeholder replacement with exact expiration value, header value, fake PHPMailer send capture, invalid request/mail-error negative paths, wp_mail_succeeded/wp_mail_failed payloads, Unicode email validator setup, and global/static cleanup assertions; reviewer seed 72 passed 1700 checks, and Franklin re-review found no actionable findings.4324 passed, 33 skipped, 0 failed at 1 iteration after privacy export-email review fixes.
    c45c94ca67 / 0cfbc48c93email62043 passed, 50 skipped, 0 failed at seed 1 for the Unicode password-reset notification recipient machine/readable view matrix before review fixes; post-review checks passed 12427 with 10 skips at seed 1 and 12404 with 10 skips at seed 224 after fake PHPMailer handoff, machine-view reset rejection, and no-DB collation scope fixes. Fermat re-review found no actionable findings.8665 passed, 66 skipped, 0 failed at 2 iterations after Unicode email reset recipient review fixes.
    b63ed99053 / fa12f15d10 / bbe8f53242query-loop7700 passed, 0 skipped, 0 failed at seeds 1 and 224 after nested primary/secondary loop reset coverage, exact primary/query/global conditional flag vectors, manual postdata/global summaries, wp_reset_postdata() source-object behavior, wp_reset_query() restoration, and review fixes; seeds 2, 42, and 123456 also passed 1925 checks each.8665 passed, 66 skipped, 0 failed at 2 iterations after query-loop nested reset review fixes; final review found no actionable findings.
    98c23465dewxr-export250 passed, 0 skipped, 0 failed at seeds 1 and 224 over 25 iterations after replacing the CLI header observability skip with filtered filename/content-type intent accounting; adjacent export/import/content smoke passed 315 checks.4685 passed, 10 skipped, 0 failed at 1 iteration after WXR export header skip retirement.
    a8de6a7e5emetadata275 passed, 0 skipped, 0 failed at seeds 1 and 224 over 25 iterations after replacing the absent subtype-helper availability skip with current API accounting; adjacent metadata/content smoke passed 2600 checks.4684 passed, 11 skipped, 0 failed at 1 iteration after metadata subtype-helper skip retirement.
    d9f3bf8a9frest-object-controllers300 passed, 0 skipped, 0 failed at seeds 1 and 224 over 25 iterations after replacing the broad collection-query skip with short-circuited query translation coverage; adjacent REST-family smoke passed 365 checks.4683 passed, 12 skipped, 0 failed at 1 iteration after REST object collection query skip retirement.
    d4a516e4c5script-loader-runtime350 passed, 25 skipped, 0 failed at seeds 1 and 224 over 25 iterations after splitting always-available emoji style enqueue/hook coverage from asset-gated emoji detection settings/module output; adjacent assets/style smoke passed 290 checks with 5 skips.4682 passed, 13 skipped, 0 failed at 1 iteration after script-loader emoji style coverage split.
    b59b5030c4admin-workflows200 passed, 0 skipped, 0 failed at seeds 1 and 224 over 25 iterations after replacing stale core-list-table skip accounting with scoped ownership by admin-workflows, admin-list-tables, and privacy-admin-requests; adjacent admin smoke passed 291 checks with 9 skips.4681 passed, 13 skipped, 0 failed at 1 iteration after admin workflow list-table coverage accounting.
    6a28aff00badmin-list-tables226 passed, 49 skipped, 0 failed at seeds 1 and 224 over 25 iterations after replacing stale privacy request table skip accounting with a passed coverage-accounting invariant tied to the dedicated privacy-admin-requests surface; adjacent admin/privacy smoke passed 201 checks with 14 skips.4680 passed, 14 skipped, 0 failed at 1 iteration after admin list-table privacy coverage accounting.
    current branchprivacy500 passed, 0 skipped, 0 failed at seeds 1, 224, and 57123 over 25 iterations after adding real personal data export ZIP generation, exact archive inspection, HTML escaping/KSES report oracles, export-created action payloads, temp report cleanup, legacy export-file meta migration, and captured JSON-error negative paths; adjacent privacy/admin/filesystem/mail smoke passed 315 checks.4751 passed, 1 skipped, 0 failed at 1 iteration after privacy export ZIP generation coverage.
    0b57a4238arest-site-editor350 passed, 0 skipped, 0 failed at seeds 1 and 224 after replacing the broad template dispatch guard with bounded template/template-part collection route metadata, denied collection and lookup dispatch before template lookup, HEAD collection dispatch short-circuits, revision/autosave collection route-index contracts, and filter/server restoration.4679 passed, 15 skipped, 0 failed at 1 iteration after REST Site Editor template dispatch guard coverage.
    91a7645818appearance-media200 passed, 0 skipped, 0 failed at seeds 1 and 224 after replacing the unsafe upload/AJAX guard with bounded no-upload Custom_Image_Header::step(), header action cap/nonce/sanitization/default/remove/reset branches, background empty-upload and reset branches, deprecated field/tab passthrough callbacks, and current-user/superglobal restoration.4678 passed, 16 skipped, 0 failed at 1 iteration after appearance media no-upload admin guard coverage.
    152c90ded2rest-object-controllers275 passed, 25 skipped, 0 failed at seeds 1 and 224 after replacing the template-controller guard with bounded WP_REST_Templates_Controller route registration, route-index schema, ID sanitization, collection params, permission gates, projected synthetic template responses, HEAD behavior, and global/server restoration.4677 passed, 17 skipped, 0 failed at 1 iteration after bounded REST template-controller coverage.
    bc27826265style375 passed, 0 skipped, 0 failed at seeds 1 and 224 after replacing the global stylesheet guard with seeded WP_Theme_JSON_Resolver coverage for wp_get_global_stylesheet() variables, styles, presets, custom CSS, empty-call cache reuse, typed-call cache preservation, zero wpdb-stub query delta, unsafe-byte rejection, and exact resolver/object-cache restoration.4676 passed, 18 skipped, 0 failed at 1 iteration after seeded global stylesheet resolver/cache coverage.
    11704a8287 / 212d31f2a2 / efff07281estyle1400 passed, 100 skipped, 0 failed after block-support register/apply wrapper coverage, aggregate WP_Block_Supports::apply_block_supports() output, final get_block_wrapper_attributes() escaping/merge/dedupe oracles, skip-serialization checks, custom-only hostile style-value coverage, exact render_block hook restoration, and review fixes; reviewer seed 11704 also passed 350 checks with 25 skips and no actionable findings after fixes.8661 passed, 66 skipped, 0 failed at 2 iterations after style block-support wrapper review fixes.
    12d7260f6cadmin-media-chrome300 passed, 0 skipped, 0 failed for seed 1 and seed 224 focused runs after replacing upload-dispatch and browser-modal skips with legacy upload shell, media enqueue data, and iframe shell boundary accounting; adjacent admin/content smoke passed 569 checks with 1 existing skip.4687 passed, 8 skipped, 0 failed at 1 iteration after admin media boundary accounting.
    123d7bae88 / 758d2f7b4f / eb4ee82995 / 5c68c49525admin-media-chrome1000 passed, 200 skipped, 0 failed after direct get_image_send_to_editor(), image_add_caption(), and image_media_send_to_editor() coverage for escaped attributes, default caption filter registration, caption cleanup/early returns, attachment and non-attachment rel behavior, media delegation, non-image pass-through, and review fixes; reviewer seed 224 also passed 1000 checks with 200 skips and no actionable findings after fixes.8659 passed, 66 skipped, 0 failed at 2 iterations after admin media caption/send-to-editor review fixes.
    523349bed5 / d436abf722admin-list-tables700 passed, 200 skipped, 0 failed after base WP_List_Table per-page fallback/filter coverage, pagination arg derivation and clamping, top/bottom/infinite/empty pagination output, escaped URL/query preservation, and review fixes for attribute-order-tolerant anchor extraction, stronger href escaping/query oracles, and localized item-count assertions; reviewer seed 224 also passed 700 checks with 200 skips and no actionable findings.8657 passed, 66 skipped, 0 failed at 2 iterations after admin list table base pagination review fixes.
    4b90ae7339 / 12dcc507e5block-widgets900 passed, 0 skipped, 0 failed after stabilizing the generated widget-ID fixture and adding retrieve_widgets() remap/customizer persistence/lost-widget coverage; reviewer seed 224 also passed 900 checks with no actionable findings.8651 passed, 66 skipped, 0 failed at 2 iterations after block-widget retrieval review.
    ab6dfb94a6 / 12bde1192dcron8700 passed, 0 skipped, 0 failed after generated spawn request/lock boundary coverage plus review fixes for exact lock/request-key matching, realistic missing-transient behavior, _wp_cron() ready delegation, exact loopback URL/query payloads, SSL filter input, and object-cache restoration; reviewer seed 224 also passed 8700 checks with no actionable findings.8649 passed, 66 skipped, 0 failed at 2 iterations after cron spawn/request review fixes.
    db22a88992cron90 passed at seed 1, 450 passed at seed 224 over 5 iterations, 1800 passed at seed 20260627 over 20 iterations, and adjacent cron,options-autoload,state smoke passed 244 after pre_unschedule_hook short-circuit coverage for zero, false-with-WP_Error, and WP_Error-without-WP_Error branches, event preservation, exact filter payloads, hook cleanup, and current-filter stack restoration; Singer review found no actionable findings.9147 passed, 62 skipped, 0 failed at 2 iterations after cron pre_unschedule_hook fuzzing.
    component-fuzz-mail-emoji-wrappermail12 passed, 0 skipped, 0 failed at seed 224; 300 passed, 0 skipped, 0 failed at seed 224 over 25 iterations; seeds 1 and 57123 over 10 iterations each passed 120 checks; adjacent mail/email/privacy smoke passed 3840 checks after emoji email-wrapper content-type matrix coverage.5094 passed, 1 skipped, 0 failed at 1 iteration after mail emoji wrapper fuzzing.
    f9fb63e7aemail11 passed at seed 1, 55 passed at seed 224 over 5 iterations, 220 passed at seed 20260627 over 20 iterations, adjacent mail,email,privacy smoke passed 2561 with 2 skips, and wpMail PHPUnit passed 32 tests and 87 assertions after invalid From setFrom() failure coverage for exact wp_mail_failed payloads including embeds, no-send/no-init branches, reusable PHPMailer cleanup, and hook removal; Sartre re-review found no actionable findings.9149 passed, 62 skipped, 0 failed at 2 iterations after mail invalid-From failure fuzzing.
    6f044e8832 / d23da38599script-loader-runtime1200 passed, 200 skipped, 0 failed after generated concatenated load-scripts.php/load-styles.php runtime boundary coverage plus review fixes for exact external/delayed asset URL assertions and real WordPress load-query ordering; reviewer seed 224 also passed 1200 checks with 200 skips and no actionable findings.8647 passed, 66 skipped, 0 failed at 2 iterations after script-loader concat review fixes.
    aeb52fbf45taxonomy-relationships1000 passed, 0 skipped, 0 failed after generated multi-object update_object_term_cache()/clean_object_term_cache() prime, clean, empty-entry, warm-cache, action-locality, and exact re-prime invariants; reviewer seed 224 also passed 1000 checks with no actionable findings.8645 passed, 66 skipped, 0 failed at 2 iterations after taxonomy relationship cache priming review.
    ad64492f8d / 5daf69b00frest-object-controllers1000 passed, 200 skipped, 0 failed after deterministic collection parameter sanitizer/validation matrices for posts, terms, comments, users, revisions, and attachments plus review fixes adding real request-pipeline has_valid_params()/sanitize_params() oracles; reviewer seed 224 also passed 1000 checks with 200 skips.8643 passed, 66 skipped, 0 failed at 2 iterations after REST object collection-parameter request-pipeline review fixes.
    fc2df79179ai-client900 passed, 0 skipped, 0 failed after follow-up fixes wrapping WP HTTP adapter SDK NetworkException failures with request context through HttpTransporter, asserting previous exception/request DTO details, and restoring HTTPlug discovery strategies plus cache by reflection; smoke: 45 passed; syntax/PHPCS/diff checks passed.8627 passed, 66 skipped, 0 failed at 2 iterations after AI transport review follow-up.
    d66f101681ai-client, emailai-client: 900 passed, 0 skipped, 0 failed after review fixes for HTTPlug discovery, SDK transport WP_Error propagation, merged request option capture, and exact empty/scalar response bodies; smoke: 45 passed. email: 6205 passed, 5 skipped, 0 failed after generated mailto/rendering-context round trips for UTF-8 local parts, WHATWG delimiter local parts, IDN/punycode domains, display/raw URL escaping, and readable text; syntax/PHPCS/diff checks passed.8627 passed, 66 skipped, 0 failed at 2 iterations after AI transport review fixes and email mailto context fuzzing.
    5b7b110e98ai-client900 passed, 0 skipped, 0 failed after WordPress HTTP client discovery, PSR-7 request to wp_safe_remote_request() argument mapping, response mapping, WP_Error to NetworkException, SDK HttpTransporter option precedence, and response DTO coverage; smoke: 9 passed, 0 skipped, 0 failed; syntax/PHPCS/diff checks passed.8610 passed, 68 skipped, 0 failed at 2 iterations after AI client HTTP transport fuzzing.
    df2626ed8eidentity100 passed, 0 skipped, 0 failed after review fixes adding generated user-cache cleanup assertions, current-user global restoration assertions, hook counter and temporary-hook restoration assertions, user_level setup checks, and exception-safe cleanup/restore; smoke: 1 aggregate row passed with 22 cases and 887 checks.8608 passed, 68 skipped, 0 failed at 2 iterations after identity current-user lifecycle review fixes.
    7686237548identity100 passed, 0 skipped, 0 failed after cache-seeded wp_set_current_user()/wp_get_current_user() lifecycle coverage, logged-in/logged-out helper contracts, legacy setup globals, set_current_user action dispatch, legacy object upgrades, determine_current_user discovery, and cloned hook snapshot restoration; smoke: 1 aggregate row passed with 22 cases and 884 checks.8608 passed, 68 skipped, 0 failed at 2 iterations after identity current-user lifecycle coverage.
    15ac9a877eemail, wpdb-stub123865 passed, 100 skipped, 0 failed after review fixes replacing the circular users_pre_query search probe with real stub-backed WP_User_Query results and isolating password-reset/mail hooks; smoke: 1233 passed, 1 skipped, 0 failed; syntax/PHPCS/diff checks passed.8608 passed, 68 skipped, 0 failed at 2 iterations after the confusable UTF-8 email local-part review fixes.
    685f308f24email123865 passed, 100 skipped, 0 failed after confusable UTF-8 local-part coverage for ASCII-vs-Unicode parser/filter behavior, user insert/duplicate/lookup boundaries, hostile lookup candidates, comment filtering, byte-preserving user search, and exact password-reset mail routing; smoke: 1233 passed, 1 skipped, 0 failed; syntax/PHPCS/diff checks passed.8608 passed, 68 skipped, 0 failed at 2 iterations after the confusable UTF-8 email local-part follow-up.
    0a88e12213syndication800 passed, 0 skipped, 0 failed after direct WP_oEmbed_Controller proxy cache coverage for fail-closed permissions, nonce-free cache keys, transient cleanup, and local controller loading; smoke: 8 passed, 0 skipped, 0 failed; syntax/PHPCS/diff checks passed.8608 passed, 68 skipped, 0 failed at 2 iterations after syndication, network-media, and confusable UTF-8 email local-part follow-ups.
    957b767d46network-media100 passed, 0 skipped, 0 failed after MIME catalog coverage for extension type buckets, default MIME extensions, wildcard MIME matching, sanitize filters, and custom ext2type/mime_types/upload_mimes filter restoration; smoke: 1 aggregate row passed; syntax/PHPCS/diff checks passed.8608 passed, 68 skipped, 0 failed at 2 iterations after network-media, syndication, and confusable UTF-8 email local-part follow-ups.
    5076b6523fwidgets1000 passed, 0 skipped, 0 failed after review fixes for explicit display-filter/action/widget-callback ordering and safe output-buffer cleanup; smoke: 10 passed, 0 skipped, 0 failed; syntax/PHPCS/diff checks passed.8604 passed, 68 skipped, 0 failed at 2 iterations after the_widget() review fixes.
    b3f95cec40widgets1000 passed, 0 skipped, 0 failed after the_widget() object-registration, display-filter mutation/cancellation, action payload, wrapper substitution, and escaping coverage; smoke: 10 passed, 0 skipped, 0 failed.8619 passed, 66 skipped, 0 failed at 2 iterations after the_widget() coverage.
    fb3b2503f2admin-dashboard900 passed, 100 skipped, 0 failed after review fixes for transient cleanup and a scoped no-network HTTP tripwire; smoke: 9 passed, 1 skipped, 0 failed; syntax/PHPCS/diff checks passed.8619 passed, 66 skipped, 0 failed at 2 iterations after the_widget() coverage.
    dc3d102ad5admin-dashboard900 passed, 100 skipped, 0 failed after cached RSS helper branch coverage; smoke: 9 passed, 1 skipped, 0 failed; syntax/PHPCS/diff checks passed.8619 passed, 66 skipped, 0 failed at 2 iterations after the_widget() coverage.
    2a62dae1e6default-widgets, shared wp-stubs1000 passed, 0 skipped, 0 failed for default-widgets; 800 passed, 0 skipped, 0 failed for wpdb-sql; direct probes confirmed $wpdb runtime restoration and duplicate DISTINCT t.term_id collapse; shared stub smoke: 2555 passed, 10 skipped, 0 failed.8619 passed, 66 skipped, 0 failed at 2 iterations after the_widget() coverage.
    b8ebf13ea3default-widgets, shared wp-stubs1000 passed, 0 skipped, 0 failed after fixing cache restoration, no-live-DB guarding, and real stub-backed nav-menu item lookup/object filtering/walker output; direct cache-sentinel and non-stub $wpdb probes passed; shared stub smoke: 2555 passed, 10 skipped, 0 failed.8615 passed, 66 skipped, 0 failed at 2 iterations after the default-widgets review fixes and SQL/term selector stub corrections.
    55bf6625ccblock-editor-adjuncts700 passed, 0 skipped, 0 failed after review fixes for theme-cache restoration, strict slug__in template queries, and no-selected-post coverage inside the temp block-theme fixture; syntax/PHPCS/diff checks passed.Pending next broad rerun; latest broad remains 8615 passed, 66 skipped, 0 failed before this selected-post follow-up.
    2498b1f923block-editor-adjuncts700 passed, 0 skipped, 0 failed after generated selected-post core/post-content template coverage, first-match attrs, missing-target guard, cache/filter/global restoration, and temp block-theme fixtures.Pending next broad rerun; latest broad remains 8615 passed, 66 skipped, 0 failed before this selected-post follow-up.
    e6a146a9eaemail6182 passed, 5 skipped, 0 failed after review fixes for accent-folded DB-candidate lookup guards and byte-preserving local-part user-search result oracles; syntax/PHPCS/diff checks passed.Pending next broad rerun; latest broad remains 8615 passed, 66 skipped, 0 failed before these review fixes.
    0544644089email123765 passed, 100 skipped, 0 failed after generated Unicode local-part alias/index cleanup and byte-preserving user-email search coverage; smoke: 6182 passed, 5 skipped, 0 failed.8615 passed, 66 skipped, 0 failed at 2 iterations after the latest email follow-up.
    cc76f6455enetwork-media100 passed, 0 skipped, 0 failed after review fixes for upload iframe dynamic filter timing and media filter coverage; smoke: 5 passed, 0 skipped, 0 failed.8615 passed, 66 skipped, 0 failed at 2 iterations after the latest email follow-up.
    39a5bb77d1appearance-media700 passed, 100 skipped, 0 failed after review fixes for fetchpriority static-state leakage, synthetic attachment/postmeta cleanup, multisite-safe logo blog IDs, and content-count restoration; reviewer seed 4: 35 passed, 5 skipped, 0 failed.Latest broad rerun: 8615 passed, 66 skipped, 0 failed at 2 iterations after the latest email follow-up.
    7ca5e5d845identity100 passed, 0 skipped, 0 failed after cache-backed username_exists()/email_exists() lookups, raw-query filter payloads, override behavior, filter restoration, and generated user-cache cleanup; smoke: 5 passed, 0 skipped, 0 failed.Latest broad rerun: 8615 passed, 66 skipped, 0 failed at 2 iterations after the latest email follow-up.
    90dd0e0afdappearance-media700 passed, 100 skipped, 0 failed after synthetic custom-logo attachment, image source, image-attribute filter, final output filter, echo parity, and filter-cleanup coverage; smoke: 35 passed, 5 skipped, 0 failed.Latest broad rerun: 8615 passed, 66 skipped, 0 failed at 2 iterations after the latest email follow-up.
    51db0730d0blocks700 passed, 0 skipped, 0 failed after generated nested block attribute parser/serializer round-trip coverage; smoke: 35 passed, 0 skipped, 0 failed.Latest broad rerun: 8615 passed, 66 skipped, 0 failed at 2 iterations after the latest email follow-up.
    48cc953226default-widgets1000 passed, 0 skipped, 0 failed after WP_Nav_Menu_Widget selected-menu rendering, HTML5/XHTML argument filters, wp_nav_menu() short-circuit payloads, and empty-menu early return coverage; smoke: 50 passed, 0 skipped, 0 failed.Latest broad rerun: 8615 passed, 66 skipped, 0 failed at 2 iterations after the latest email follow-up.
    48d8e996d2email123565 passed, 100 skipped, 0 failed after generated UTF-8 local-part oracle agreement across mixed scripts, combining marks, disallowed punctuation, symbols/emoji boundaries, invalid UTF-8, ASCII fallback behavior, and IDN/punycode domain views; smoke: 6172 passed, 5 skipped, 0 failed.Latest broad rerun: 8615 passed, 66 skipped, 0 failed at 2 iterations after the latest email follow-up.
    b2101aeceecommunity-events900 passed, 0 skipped, 0 failed after invalid or absent client IP request-body minimization coverage; smoke: 45 passed, 0 skipped, 0 failed.Latest broad rerun: 8615 passed, 66 skipped, 0 failed at 2 iterations after the latest email follow-up.
    32acfc1459block-widgets800 passed, 0 skipped, 0 failed after wp_parse_widget_id() numeric-instance parsing and _wp_remove_unregistered_widgets() cleanup coverage; smoke: 40 passed, 0 skipped, 0 failed.Latest broad rerun: 8615 passed, 66 skipped, 0 failed at 2 iterations after the latest email follow-up.
    6f73d20a46network-media100 passed, 0 skipped, 0 failed after wp_unique_filename() callback argument, final filter payload, returned filename, and filter-restoration coverage; smoke: 5 passed, 0 skipped, 0 failed.Latest broad rerun: 8615 passed, 66 skipped, 0 failed at 2 iterations after the latest email follow-up.
    11f544d777classic-walkers1200 passed, 100 skipped, 0 failed after generated Walker_Nav_Menu item matrix locality, preorder/depth filter order, current/parent/ancestor state, aria-current, escaped attributes, omitted non-scalar attributes, and submenu depth-local attributes; smoke: 60 passed, 5 skipped, 0 failed.Latest broad rerun: 8615 passed, 66 skipped, 0 failed at 2 iterations after the latest email follow-up.
    d3924d2e1adiscovery1200 passed, 0 skipped, 0 failed on both seed 1 and seed 224 after built-in posts/taxonomies/users sitemap provider coverage, review fixes for public custom subtypes, GMT lastmod oracles, private-CPT user exclusion, custom taxonomy paging, and page/home sitemap entries; reviewer seeds passed; smoke: 4524 passed, 33 skipped, 0 failed.Latest broad rerun: 4524 passed, 33 skipped, 0 failed at 1 iteration after discovery sitemap provider coverage.
    4aa0ec3c2bdiscovery900 passed, 0 skipped, 0 failed after robots public/private helper and wp_robots() output matrix coverage; smoke: 45 passed, 0 skipped, 0 failed.Latest broad rerun: 8615 passed, 66 skipped, 0 failed at 2 iterations after the latest email follow-up.
    c901056b01feed-parsers900 passed, 0 skipped, 0 failed after WP_SimplePie_File HTTP response, repeated-header normalization, request-argument propagation, and error-state coverage; smoke: 45 passed, 0 skipped, 0 failed.Latest broad rerun: 8615 passed, 66 skipped, 0 failed at 2 iterations after the latest email follow-up.
    5cde283a7badmin-list-tables600 passed, 200 skipped, 0 failed after generated WP_MS_Themes_List_Table row-action, nonce, auto-update, custom-column, view partition, and capability-gate coverage; smoke: 30 passed, 10 skipped, 0 failed.Latest broad rerun: 8615 passed, 66 skipped, 0 failed at 2 iterations after the latest email follow-up.
    d088a7e89bidentity100 passed, 0 skipped, 0 failed after generated WP_User field filter, display-name, URL, nicename, edit-context escaping, and filter-restoration coverage; smoke: 5 passed, 0 skipped, 0 failed.Latest broad rerun: 8615 passed, 66 skipped, 0 failed at 2 iterations after the latest email follow-up.
    9c6f37455aadmin-workflows500 passed, 200 skipped, 0 failed after nonce field, selected attribute, submit button, and request-state restoration coverage; smoke: 25 passed, 10 skipped, 0 failed.Latest broad rerun: 8615 passed, 66 skipped, 0 failed at 2 iterations after the latest email follow-up.
    961cd4024fnetwork-media100 passed, 0 skipped, 0 failed after custom wp_check_filetype_and_ext() MIME allowlist coverage; smoke: 5 passed, 0 skipped, 0 failed.Latest broad rerun: 8615 passed, 66 skipped, 0 failed at 2 iterations after the latest email follow-up.
    7bc1abc6c2abilities800 passed, 0 skipped, 0 failed after category unregister preservation and re-registration coverage; smoke: 40 passed, 0 skipped, 0 failed.Latest broad rerun: 8615 passed, 66 skipped, 0 failed at 2 iterations after the latest email follow-up.
    e61d09f16amedia-metadata800 passed, 0 skipped, 0 failed after generated attachment metadata replacement, filtered/unfiltered read, stale-key cleanup, path/URL, and MIME/ext helper coverage; smoke: 40 passed, 0 skipped, 0 failed.Latest broad rerun: 8615 passed, 66 skipped, 0 failed at 2 iterations after the latest email follow-up.
    b88d33922cregistries600 passed, 0 skipped, 0 failed after file-backed icon retrieval failure/recovery coverage; smoke: 30 passed, 0 skipped, 0 failed.Latest broad rerun: 8615 passed, 66 skipped, 0 failed at 2 iterations after the latest email follow-up.
    0486038a3aemail123465 passed, 100 skipped, 0 failed after generated malformed Unicode/ASCII/Punycode email variant coverage; smoke: 6167 passed, 5 skipped, 0 failed.Latest broad rerun: 8615 passed, 66 skipped, 0 failed at 2 iterations after the latest email follow-up.
    ebce407494template-links1200 passed, 0 skipped, 0 failed after archive URL helper coverage and review fixes for date filter payloads, sanitized author URL contracts, and dirty author nicename escaping/locality; smoke: 60 passed, 0 skipped, 0 failed.Latest broad rerun: 8631 passed, 66 skipped, 0 failed at 2 iterations after template-links archive helper review fixes.
    09c2d3b4f8post-types1200 passed, 0 skipped, 0 failed after duplicate-registration replacement cleanup and review fixes; smoke: 60 passed, 0 skipped, 0 failed; PHPUnit: 44 tests, 133 assertions.Latest broad rerun: 8629 passed, 66 skipped, 0 failed at 2 iterations after post-type duplicate-registration cleanup.
    3cce0a5c4dpost-types1100 passed, 0 skipped, 0 failed after sanitized post-status name storage and duplicate-key overwrite coverage; smoke: 55 passed, 0 skipped, 0 failed.Latest broad rerun: 8615 passed, 66 skipped, 0 failed at 2 iterations after the latest email follow-up.
    61a0a1d628 / b1eea51e9arequest-lifecycle12 passed, 0 skipped at seed 1; 12 passed, 0 skipped at seed 224; 60 passed, 0 skipped at seed 20260626 over 5 iterations after feed header variants for default-feed alias mapping, post-only and comments-feed modified timestamp selection, stale conditional headers without 304 exit, exact filter payloads, and preserved timeinfo cache cleanup.9125 passed, 62 skipped, 0 failed at 2 iterations after request-lifecycle feed header variant fuzzing.
    3193c7f30drequest-lifecycle1100 passed, 0 skipped, 0 failed after generated status-header protocol/default-description and nocache-filter contracts; smoke: 55 passed, 0 skipped, 0 failed.Latest broad rerun: 8615 passed, 66 skipped, 0 failed at 2 iterations after the latest email follow-up.
    70b7b720a4network-media100 passed, 0 skipped, 0 failed after network upload MIME allowlist and direct file-too-large policy coverage; smoke: 5 passed, 0 skipped, 0 failed.Latest broad rerun: 8615 passed, 66 skipped, 0 failed at 2 iterations after the latest email follow-up.
    component-fuzz-feed-anchor-linksfeed-rendering13 passed, 0 skipped, 0 failed at seed 224; 325 passed, 0 skipped, 0 failed at seed 224 over 25 iterations; seeds 1 and 57123 over 10 iterations each passed 130 checks; adjacent feed/parser/syndication/http/widget smoke passed 56 checks after direct feed anchor helper coverage.5095 passed, 1 skipped, 0 failed at 1 iteration after direct feed anchor helper fuzzing.
    df556c4fff / 5136734b9bfeed-rendering275 passed, 0 skipped, 0 failed at seeds 1 and 224 over 25 iterations after legacy RDF/RSS 0.92 template and do_feed() dispatch coverage; adjacent smoke: 200 passed, 0 skipped, 0 failed.4663 passed, 28 skipped, 0 failed at 1 iteration after legacy feed template and dispatch coverage.
    017cbda0abfrontend-features700 passed, 0 skipped, 0 failed after view-transition pre-init registration and post-init inline CSS timing coverage; smoke: 35 passed, 0 skipped, 0 failed.Latest broad rerun: 8615 passed, 66 skipped, 0 failed at 2 iterations after the latest email follow-up.
    c8410135cbrevisions-autosaves1000 passed, 300 skipped, 0 failed after retention-pruning-preserves-autosaves coverage.Latest broad rerun: 8615 passed, 66 skipped, 0 failed at 2 iterations after the latest email follow-up.
    3a78724c43capabilities1000 passed, 0 skipped, 0 failed after site-scoped WP_User::for_site() cap-key isolation and user_can_for_site() wrapper coverage; smoke: 50 passed, 0 skipped, 0 failed.Latest broad rerun: 8615 passed, 66 skipped, 0 failed at 2 iterations after the latest email follow-up.
    55a25d1dc5identity100 passed, 0 skipped, 0 failed after avatar helper hash/default/URL contract coverage.Latest broad rerun: 8615 passed, 66 skipped, 0 failed at 2 iterations after the latest email follow-up.
    39bce30e74comments44800 passed, 0 skipped, 0 failed after comment reply/cancel link matrix coverage and author-link fallback oracle fix; smoke: 1344 passed, 0 skipped, 0 failed.Latest broad rerun: 8615 passed, 66 skipped, 0 failed at 2 iterations after the latest email follow-up.
    e8c7cfecf0template-links1100 passed, 0 skipped, 0 failed after previous/next adjacent post relation link coverage; smoke: 55 passed, 0 skipped, 0 failed.Latest broad rerun: 8615 passed, 66 skipped, 0 failed at 2 iterations after the latest email follow-up.
    4bc14e0eccerror-protection900 passed, 100 skipped, 0 failed after recovery-mode handle_error() gates and fatal-error email rate-limit coverage.Latest broad rerun: 8615 passed, 66 skipped, 0 failed at 2 iterations after the latest email follow-up.
    6a78c3ec42post-embedsSeed 224 passed 7 checks; seeds 1 and 224 over 25 iterations each passed 175 checks after custom provider add/remove and WP_Embed::autoembed() post-meta cache coverage; adjacent post-embeds/REST/HTTP/content/template/media smoke passed 71 checks with 0 skips.Latest broad rerun: 4696 passed, 5 skipped, 0 failed at 1 iteration.
    764df53851securitySeed 224 passed 15 checks; seeds 1 and 224 over 25 iterations each passed 375 checks after referer retrieval precedence/restoration and native bcrypt compatibility/migration coverage; adjacent security/request/formatting/routing/HTTP smoke passed 66 checks with 0 skips.Latest broad rerun: 4695 passed, 5 skipped, 0 failed at 1 iteration.
    ec3e228603shortcodesSeed 224 passed 22 checks; seeds 1 and 224 over 25 iterations each passed 550 checks after HTML-attribute shortcode KSES/placeholder boundary coverage; adjacent markup/blocks/KSES smoke passed 1163 checks with 0 skips.Latest broad rerun: 4693 passed, 5 skipped, 0 failed at 1 iteration.
    ece71f4765network-mediaSeed 224 passed 1 aggregate row with 299 cases, 1667 API calls, and 2087 assertions; seeds 1 and 224 over 25 iterations each passed 25 aggregate rows after wp_upload_bits() exact-byte writes, virtual file-list subsize collision, upload-bits, unique-filename, and final upload-context filter payload coverage; adjacent media/filesystem smoke passed 55 checks with 0 skips.Latest broad rerun: 4692 passed, 5 skipped, 0 failed at 1 iteration.
    e581555577rest-directory-servicesSeed 224 passed 5 checks; seeds 1 and 224 over 25 iterations each passed 125 checks after pattern-directory proxy allowlist/derived-argument overwrite coverage and URL-details malformed/unclosed head metadata fallback coverage; adjacent REST/HTTP/media smoke passed 56 checks with 0 skips.Latest broad rerun: 4692 passed, 5 skipped, 0 failed at 1 iteration.
    61e22a3455error-protectionSeed 224 passed 11 checks with 1 process-boundary skip; seeds 1 and 224 over 25 iterations each passed 275 checks with 25 skips after synthetic fatal-handler dispatch and recovery-link early-return coverage; adjacent error/Site Health/HTTP/filesystem smoke passed 55 checks with 1 skip.Latest broad rerun: 4692 passed, 5 skipped, 0 failed at 1 iteration.
    c923c30323admin-media-chrome900 passed, 200 skipped, 0 failed after edit attachment details form output and compat-field coverage; local smoke: 180 passed, 40 skipped, 0 failed.Latest broad rerun: 8615 passed, 66 skipped, 0 failed at 2 iterations after the latest email follow-up.
    ba524750d9canonical-routing1200 passed, 0 skipped, 0 failed after generated query-argument removal and fragment stripping helper matrices; local smoke: 60 passed, 0 skipped, 0 failed.Latest broad rerun: 8615 passed, 66 skipped, 0 failed at 2 iterations after the latest email follow-up.
    a7993f1bb1user-preferences1100 passed, 100 skipped, 0 failed after Screen Options visibility-cache and composed output coverage; local smoke: 55 passed, 5 skipped, 0 failed.Latest broad rerun: 8615 passed, 66 skipped, 0 failed at 2 iterations after the latest email follow-up.
    0c0fa09bedsite-health1000 passed, 0 skipped, 0 failed after generated site_status_tests registry/filter/direct-callback coverage.Latest broad rerun: 8615 passed, 66 skipped, 0 failed at 2 iterations after the latest email follow-up.
    90c50c8538media-metadata700 passed, 0 skipped, 0 failed after original-image metadata normalization coverage; local smoke: 140 passed, 0 skipped, 0 failed.Latest broad rerun: 8615 passed, 66 skipped, 0 failed at 2 iterations after the latest email follow-up.
    d28094b227 / ee89789187 / 8e087d55e6admin-screen900 passed, 0 skipped, 0 failed after screen-meta/help-sidebar/screen-reader lifecycle coverage and review fixes for submit-filter preservation, post-render global assertions, escaped payload checks, and priority-aware cleanup; priority-20 repro and reviewer smoke passed.8637 passed, 66 skipped, 0 failed at 2 iterations after admin-screen screen-meta review fixes.
    64bd67ecde / 224b36546dinstall-schema1100 passed, 0 skipped, 0 failed after missing-table creation/replay coverage and review fixes for same-double replay plus create/replay introspection allowlists; reviewer seed 224 run: 1100 passed, 0 skipped, 0 failed.8639 passed, 66 skipped, 0 failed at 2 iterations after install-schema creation/replay review fixes.
    570897ffdfmedia-remote700 passed, 0 skipped, 0 failed after download_url() Content-Disposition/content-type filename derivation matrix coverage; reviewer seed 224 run: 700 passed, 0 skipped, 0 failed with no actionable findings.8641 passed, 66 skipped, 0 failed at 2 iterations after media-remote filename derivation review.
    2f91761be5 / ac5734c0c9network-media100 passed, 0 skipped, 0 failed after generated wp_unique_filename() collision/alternate-extension coverage and review fixes for reverse output-format reservations plus core-empty basename behavior; reviewer seed 224 run: 100 passed, 0 skipped, 0 failed.8639 passed, 66 skipped, 0 failed at 2 iterations after network-media filename collision review fixes.
    7b87697022admin-screen800 passed, 0 skipped, 0 failed after generated add_screen_option() and WP_Screen::render_screen_options() per-page/layout rendering, wrapping, nonce, submit-filter, and cleanup coverage.8545 passed, 66 skipped, 0 failed at 2 iterations after admin-screen option rendering and script-loader module runtime graph fuzzing.
    a760942f1fscript-loader-runtime1100 passed, 200 skipped, 0 failed after generated classic-script module import-map/modulepreload graph coverage.8545 passed, 66 skipped, 0 failed at 2 iterations after script-loader module runtime graph and admin-screen option rendering fuzzing.
    f65b0c3fceadmin-dashboard800 passed, 100 skipped, 0 failed after activity recent-post query argument, editable/permalink link branch, relative-date, escaping, and restoration coverage.8541 passed, 66 skipped, 0 failed at 2 iterations after dashboard recent-post fuzzing and REST namespace route coverage.
    bc09ade067rest-controllersSeeds 1 and 224 each passed 12 checks, 0 skipped, 0 failed after adding REST plugin/theme controller route/schema, collection parameter, sanitizer, and permission-gate contract coverage.4667 passed, 26 skipped, 0 failed at 1 iteration after REST plugin/theme controller contract coverage.
    e52ab28cberest-controllersSeeds 1 and 224 each 1100 passed, 200 skipped, 0 failed after REST search route-dispatch invalid subtype rejection, public schema callback assertions, fixed custom-handler oracle, and post-format pagination review fixes.4357 passed, 33 skipped, 0 failed at 1 iteration after REST search review hardening.
    1a26db9f1dtemplate-hierarchy800 passed, 100 skipped, 0 failed after generated category/tag/taxonomy decoded-slug, raw-slug, term-ID hierarchy, selected path, and filter-payload coverage.8537 passed, 66 skipped, 0 failed at 2 iterations after term template hierarchy and Unicode email matrix fuzzing.
    c53162cd27email123379 passed, 100 skipped, 0 failed after generated Unicode email filter/view matrix coverage.8537 passed, 66 skipped, 0 failed at 2 iterations after Unicode email matrix and term template hierarchy fuzzing.
    4fd5feddc9privacy1500 passed, 0 skipped, 0 failed after built-in comments exporter/eraser registration, payload, anonymization, retention-message, and filter-locality coverage.8535 passed, 66 skipped, 0 failed at 2 iterations after privacy comment callback fuzzing.
    848decfbb5plugin-theme-lifecycle1200 passed, 0 skipped, 0 failed after multi-plugin deactivation scope, silent-mode state mutation, and action payload ordering coverage.8531 passed, 66 skipped, 0 failed at 2 iterations after plugin lifecycle and feed hook payload fuzzing.
    7eab40d97dfeed-rendering900 passed, 0 skipped, 0 failed after RSS2/Atom post/comment feed hook payload context, escaping, loop-ID, and hook cleanup coverage.8531 passed, 66 skipped, 0 failed at 2 iterations after feed hook payload and plugin lifecycle fuzzing.
    cc5a912e25style1300 passed, 100 skipped, 0 failed after registered block style style_data source-order injection, CSS serialization, selector, sanitization, and registry/metadata restoration coverage.8527 passed, 66 skipped, 0 failed at 2 iterations after style variation source-order fuzzing.
    2f006c120fl10n1400 passed, 0 skipped, 0 failed after generated locale-switch stack, action payload ordering, user ID, controller locale, and restore-sequence coverage.8525 passed, 66 skipped, 0 failed at 2 iterations after l10n locale-stack fuzzing.
    89fd5f092etranslations800 passed, 0 skipped, 0 failed after direct WP_Translation_Controller locale/domain/file isolation, lazy malformed-file eviction, lookup, header, plural, and scoped unload coverage.8523 passed, 66 skipped, 0 failed at 2 iterations after translation controller fuzzing.
    e5224d3912environment-load1600 passed, 100 skipped, 0 failed after generated JSON/XML request media matrix coverage.8523 passed, 66 skipped, 0 failed at 2 iterations after environment request media fuzzing.
    7ac42d758brewrite1800 passed, 0 skipped, 0 failed after rewrite-tag removal and query-var retention coverage.8511 passed, 66 skipped, 0 failed at 2 iterations after rewrite tag removal fuzzing.
    a514c0c42epost-types1000 passed, 0 skipped, 0 failed after post type registry query operator coverage.8511 passed, 66 skipped, 0 failed at 2 iterations after post type query fuzzing.
    4efe65c213taxonomy-relationships900 passed, 0 skipped, 0 failed after multi-object all_with_object_id mapping coverage.8509 passed, 66 skipped, 0 failed at 2 iterations after taxonomy relationship object mapping fuzzing.
    f476712903multisite1500 passed, 100 skipped, 0 failed after large-network threshold and filter-payload coverage.8509 passed, 66 skipped, 0 failed at 2 iterations after multisite large-network fuzzing.
    179ae88db5multisite17 passed, 1 skipped at seeds 1 and 224; 85 passed, 5 skipped at seed 20260626 over 5 iterations; 340 passed, 20 skipped at seed 65270 over 20 iterations after legacy blog identity and bootstrap current-site/current-network coverage.9119 passed, 62 skipped, 0 failed at 2 iterations after multisite legacy identity and bootstrap resolution fuzzing.
    352a5bc4demultisite17 passed, 1 skipped at seed 224; 425 passed, 25 skipped at seed 1 over 25 iterations; adjacent multisite/options-autoload/state passed 245 checks with 5 skips after true multisite lifecycle child coverage.4663 passed, 28 skipped, 0 failed at 1 iteration after true multisite lifecycle child fuzzing.
    component-fuzz-frontend-url-prefixerfrontend-features8 passed, 0 skipped, 0 failed at seed 224; 200 passed, 0 skipped, 0 failed at seed 224 over 25 iterations; seeds 1 and 57123 over 10 iterations each passed 80 checks; adjacent frontend/script/style smoke passed 83 checks after URL pattern prefixer generated-context coverage.5094 passed, 1 skipped, 0 failed at 1 iteration after frontend URL pattern prefixer generated-context fuzzing.
    9f24b46262 / ec0fed6a9dfrontend-features800 passed, 0 skipped, 0 failed after disabled speculation lifecycle/load-action isolation and review fix; contamination probe confirmed unrelated prefetch/prerender list URLs fail the invariant; reviewer verification: 160 passed, 0 skipped, 0 failed.8635 passed, 66 skipped, 0 failed at 2 iterations after frontend-features lifecycle review fixes.
    40040cb842media-ingest1000 passed, 0 skipped, 0 failed after upload override/error semantics and review fixes for returned error-handler payloads, type-rejection upload-dir bypass, and unique-callback sanitized-name payloads; smoke: 50 passed, 0 skipped, 0 failed.8633 passed, 66 skipped, 0 failed at 2 iterations after media-ingest override/error review fixes.
    1c15ebeda4media-ingest900 passed, 0 skipped, 0 failed after attachment post field, desc, post date, and ignored-ID coverage.8507 passed, 66 skipped, 0 failed at 2 iterations after media ingest post-field fuzzing.
    a0d21cf48fcron8600 passed, 0 skipped, 0 failed after scheduled-event lookup ordering and exactness coverage.8507 passed, 66 skipped, 0 failed at 2 iterations after cron scheduled-event lookup fuzzing.
    d3645d08fainstall-schema1000 passed, 0 skipped, 0 failed after global-table upgrade gate filter coverage.8505 passed, 66 skipped, 0 failed at 2 iterations after install/schema global-table fuzzing.
    582c472ce7image-metadata700 passed, 0 skipped, 0 failed after locale-aware XMP alt extraction and fallback coverage.8503 passed, 66 skipped, 0 failed at 2 iterations after image metadata XMP fuzzing.
    38d7aaaabe / 6b25026e9bsite-health1100 passed, 0 skipped, 0 failed after persistent object cache threshold/filter direct-test coverage and review fixes for natural below-threshold and table-list oracles.8655 passed, 66 skipped, 0 failed at 2 iterations after Site Health persistent object cache fuzzing.
    fb5b15bc76icons-connectors700 passed, 0 skipped, 0 failed after connector plugin install/activation module-data status coverage; reviewer found no actionable findings.8653 passed, 66 skipped, 0 failed at 2 iterations after connector plugin-status fuzzing.
    be1aa94ff4icons-connectors600 passed, 0 skipped, 0 failed after REST settings AI-provider API-key validation fail-closed coverage.8501 passed, 66 skipped, 0 failed at 2 iterations after connector REST key validation fuzzing.
    b44d2442adutility-internals700 passed, 0 skipped, 0 failed after parse-list, ID/slug normalization, array slicing, recursive sorting, nested get/set, and numeric-array helper coverage.8499 passed, 66 skipped, 0 failed at 2 iterations after utility helper fuzzing.
    9aa5167057navigation1000 passed, 0 skipped, 0 failed after current custom-link class propagation through parent and ancestor menu trees.8497 passed, 66 skipped, 0 failed at 2 iterations after navigation current-tree fuzzing.
    d0c40e9079bookmark-links900 passed, 0 skipped, 0 failed after direct _walk_bookmarks() image URL, updated marker, rating, and display-filter payload coverage.8495 passed, 66 skipped, 0 failed at 2 iterations after bookmark link walker fuzzing.
    38d3d79708widgets900 passed, 0 skipped, 0 failed after dynamic_sidebar() active/empty sidebar hook-order, callback-param mutation, rendered order, and no-output empty-state coverage.8493 passed, 66 skipped, 0 failed at 2 iterations after widgets dynamic sidebar fuzzing.
    d7bf4c89c3state1800 passed, 0 skipped, 0 failed after cache-addition suspension coverage for wp_cache_add(), wp_cache_add_multiple(), and wp_cache_set().8491 passed, 66 skipped, 0 failed at 2 iterations after state cache suspension fuzzing.
    259d6bd90eadmin-ajax, wpdb query stub1000 passed, 0 skipped, 0 failed at seeds 1 and 224 after direct wp_ajax_query_attachments() MIME/search/order/paging oracles, filename-search hook restoration, query capability denial, wp_ajax_save_attachment() title/caption/description/alt mutation oracles, invalid nonce and capability denial, synthetic fixture cleanup, and narrow post MIME LIKE stub support.4521 passed, 33 skipped, 0 failed at 1 iteration after admin-ajax attachment workflow review fixes.
    7d314bc82fadmin-ajax900 passed, 0 skipped, 0 failed after heartbeat valid/invalid nonce branches, hook order, JSON termination, unslashing, and restoration coverage.8489 passed, 66 skipped, 0 failed at 2 iterations after admin-ajax heartbeat fuzzing.
    30c7fcc27eformatting1400 passed, 0 skipped, 0 failed after title/key/class identifier sanitizer shape, fallback, idempotence, and filter-locality coverage.8489 passed, 66 skipped, 0 failed at 2 iterations after admin-ajax heartbeat fuzzing.
    70acf5e7c4media-remote600 passed, 0 skipped, 0 failed after case-insensitive headers, signature soft/hard failures, sideload return types, extension-filtered metadata, and prefilter cleanup coverage.8487 passed, 66 skipped, 0 failed at 2 iterations after formatting identifier sanitizer fuzzing.
    4778dea14adate-time1400 passed, 0 skipped, 0 failed after named-timezone DST/ISO8601 mode and wp_checkdate() filter-contract coverage.8485 passed, 66 skipped, 0 failed at 2 iterations after date-time, syndication, and media-remote follow-ups.
    c891e636db / 4a21f6a5easyndication700 passed, 0 skipped, 0 failed after automatic feed-link theme-support, posts/comments gate, title/href escaping, and filter locality coverage.8485 passed, 66 skipped, 0 failed at 2 iterations after date-time, syndication, and media-remote follow-ups.
    b2a7077ab8filesystem1100 passed, 0 skipped, 0 failed after unique-filename callback metadata and uppercase/lowercase image-extension collision coverage.8485 passed, 66 skipped, 0 failed at 2 iterations after date-time, syndication, and media-remote follow-ups.
    31897e4b5etaxonomy1200 passed, 0 skipped, 0 failed after registration filter/action locality, REST controller reuse, object-type association hooks, and get_terms() short-circuit contracts.8477 passed, 66 skipped, 0 failed at 2 iterations after filesystem unique filename fuzzing.
    bde139dd85community-events800 passed, 0 skipped, 0 failed after manual search cache-bypass and cache-refresh coverage.8475 passed, 66 skipped, 0 failed at 2 iterations after taxonomy and Community Events follow-ups.
    eb38b87a55 / e4f39081f1shortcodes, src/wp-includes/shortcodes.php2100 passed, 0 skipped, 0 failed at seeds 1 and 224 after scoped image-context hook coverage plus review fixes for hook-runtime restoration and priority-zero preexisting filters; reviewer seed 12345: 525 passed, 0 skipped, 0 failed.4331 passed, 33 skipped, 0 failed at 1 iteration after shortcode image-context coverage.
    7f3c1c433aadmin-bar700 passed, 0 skipped, 0 failed after back-compat parent alias, deprecation hook, and tabindex rendering coverage.8475 passed, 66 skipped, 0 failed at 2 iterations after taxonomy and Community Events follow-ups.
    5092a4032ediscovery800 passed, 0 skipped, 0 failed after sitemap renderer unsupported URL/index field boundary coverage.8469 passed, 66 skipped, 0 failed at 2 iterations after admin-bar render fuzzing.
    e41dcde903html-api700 passed, 0 skipped, 0 failed after semantic parser mode probes, breadcrumb stack replay, tree-preservation normalization, token serialization, and textarea/foreign-content boundary coverage.8467 passed, 66 skipped, 0 failed at 2 iterations after discovery sitemap field fuzzing.
    2a965fb770content-lifecycleSeeds 1 and 224 each 11 passed, 0 skipped, 0 failed; seed 20260626 at 5 iterations: 55 passed, 0 skipped, 0 failed; seed 65270 at 20 iterations: 220 passed, 0 skipped, 0 failed after status-transition hook order, direct status-storage, cache branch, GUID repair, cron cleanup, and review isolation fixes.9115 passed, 62 skipped, 0 failed at 2 iterations after content-lifecycle status-transition review fixes.
    3b335ffb7bcontent-lifecycle700 passed, 0 skipped, 0 failed at seeds 1 and 224 after post-to-term relationship set/append/replace/remove/delete helpers, object-term field modes, relationship cache invalidation, hook payloads/counts, and post-delete cleanup coverage.4520 passed, 33 skipped, 0 failed at 1 iteration after content-lifecycle term relationship review fixes.
    9d3f744ee5content-lifecycle600 passed, 0 skipped, 0 failed after post-meta add/read/update/delete, unique key, serialized value, cache invalidation, metadata hook, and post-delete cleanup coverage.8465 passed, 66 skipped, 0 failed at 2 iterations after HTML API semantic oracles.
    a971095532rest-object-controllers900 passed, 200 skipped, 0 failed after route registration, method/schema, dispatch URL-param, HEAD, and no-route error coverage.8465 passed, 66 skipped, 0 failed at 2 iterations after HTML API semantic oracles.
    9f1f64d929kses108820 passed, 0 skipped, 0 failed after protocol helper fixed-point, scheme-normalization, and filter-aware safe CSS oracle coverage.8465 passed, 66 skipped, 0 failed at 2 iterations after HTML API semantic oracles.
    31e3df4d4bquery, wpdb query stub46400 passed, 0 skipped, 0 failed after review fixes for WP_Query ordering, found-row oracles, SQL empty-condition detection, and cache-hit isolation.8208 passed, 66 skipped, 0 failed at 2 iterations after query execution coverage and oracle hardening.
    47b9b32fb5security1300 passed, 0 skipped, 0 failed after review fixes for redirect expected outcomes and auth-cookie grace-window stability.8208 passed, 66 skipped, 0 failed at 2 iterations after security boundary coverage and oracle hardening.
    b37285c933shortcodes2000 passed, 0 skipped, 0 failed after review fixes for escaped shortcode source boundaries and prefix/punctuated tag collision coverage.8208 passed, 66 skipped, 0 failed at 2 iterations after shortcode parser coverage and oracle hardening.
    d10d0fa5bfoptions-autoload, wpdb option stubSeeds 1, 224, and 57123 over 25 iterations each passed 350 checks after registered option-group cache priming coverage for duplicate members, missing members, preprimed entries, alloptions members, non-target groups, and warm-cache query stability.4781 passed, 1 skipped, 0 failed at seed 224 after options group-priming coverage; adjacent options-autoload,state,multisite,admin-options-submission,admin-workflows,security passed 82 checks with 1 skip and bootstrap smoke passed.
    af998e5de5 / 6e806f722ahooks1100 passed, 0 skipped, 0 failed after same-tag reentrant mutation and preinitialized-hook normalization coverage plus review fixes for priority-stack and extra-argument assertions.8054 passed, 66 skipped, 0 failed at 2 iterations after hooks and options review fixes.
    da90357e60 / db09e88c0aupdate-install-upgrader1300 passed, 0 skipped, 0 failed after temp-backup install cleanup/restore coverage and review fixes for default no-arg backup lists plus normalized sandbox containment.8037 passed, 67 skipped, 0 failed at 2 iterations after updater temp-backup coverage and oracle hardening.
    e06ee18528 / 260ccee45dplugin-theme900 passed, 0 skipped, 0 failed after plugin dependency public-contract coverage and review fixes for unmasked active option states plus fail-closed plugin API/HTTP guards.8035 passed, 67 skipped, 0 failed at 2 iterations after plugin dependency public-contract coverage and oracle hardening.
    46b69bb45d / 5bbf3f6c27http1000 passed, 0 skipped, 0 failed after chunk-transfer decoding, direct no-network request normalization and early-error coverage, plus review fixes for stream hermeticity, temp-parent reservation, hook/debug cardinality, and normalized stream headers.8033 passed, 67 skipped, 0 failed at 2 iterations after HTTP request-contract coverage and oracle hardening.
    0892834d10 / 96c73556dfemail123168 passed, 100 skipped, 0 failed after canonical Unicode-domain account save/update alias coverage and review fixes for lookup boundaries, no-delivery isolation, and rejected-update non-mutation.8029 passed, 67 skipped, 0 failed at 2 iterations after the domain-alias invariant and oracle hardening.
    448dc0b981 / 0674925660email61493 passed, 50 skipped, 0 failed after ASCII-vs-Unicode WP_Email_Address construction-mode coverage and explicit IDN skip review fix; related PHPUnit passed 86 tests and 179 assertions with 1 skip.8004 passed, 66 skipped, 0 failed at 2 iterations after base and IDN construction-mode invariants were added.
    263c82ef81 / e3e1c64930 / 3ad9e6487bpost-types9000 passed, 0 skipped, 0 failed after registration lifecycle and REST route coverage plus review fixes; related PHPUnit passed 94 tests and 406 assertions.8000 passed, 66 skipped, 0 failed at 2 iterations after isolated post-type REST route registration, no-diagnostic route context, cron leak check, and explicit REST meta dependency coverage.
    688a716cc8 / dccbad638cai-client8000 passed, 0 skipped, 0 failed after model-selection collision coverage and review follow-up; prompt-builder PHPUnit passed 179 tests and 456 assertions.7996 passed, 66 skipped, 0 failed at 2 iterations after shared-model collision, provider tuple, provider lock, model-instance preference, fallback, and reversed-registration checks.
    8d7efd528c / 4747a53d87interactivity1000 passed, 0 skipped, 0 failed after review fixes; bootstrap smoke passed.7982 passed, 66 skipped, 0 failed at 2 iterations after context namespace stack merge/sort/restoration and cross-namespace derived-state fail-closed coverage.
    63bdea67ec / 2bb73fae62request-lifecycle1000 passed, 0 skipped, 0 failed after review fixes; JSON-header smoke: 50 passed, 0 skipped, 0 failed; reviewer Ajax/XML handler reruns passed.7975 passed, 67 skipped, 0 failed at 2 iterations after GET/POST mismatch termination capture, WP::main() hook sequencing, consistent query request globals, and no-DB sentinel hardening.
    9f1d3f3662site-health-debugSeeds 1 and 224 each passed 8 checks with 1 skip, seed 224 over 25 iterations passed 200 checks with 25 skips, and adjacent site-health-debug,site-health,http,environment-load,cron smoke passed 138 checks with 1 skip after adding isolated full WP_Debug_Data::debug_data() section assembly coverage.4668 passed, 25 skipped, 0 failed at 1 iteration after bounded Site Health debug-data full-scan coverage.
    7d8adb7f93 / e11ee9a6a7site-health-debug700 passed, 200 skipped, 0 failed after review fixes; timeout-control smoke: 35 passed, 10 skipped, 0 failed; disabled-ini_set smoke: 30 passed, 15 skipped, 0 failed.7984 passed, 67 skipped, 0 failed at 2 iterations after bounded WP_Debug_Data::get_sizes() directory/database/total aggregation, timeout hardening, and final review.
    5f0eb0a4ccimages900 passed, 0 skipped, 0 failed; exact exposed seed: 18 passed, 0 skipped, 0 failed.7984 passed, 67 skipped, 0 failed at 2 iterations after allowing documented wp_constrain_dimensions() one-pixel rounding drift while preserving bounds and idempotence checks.
    ca8db4d687 / 8ec4cfb4aaappearance-media600 passed, 100 skipped, 0 failed after review fixes; HTTPS smoke run: 30 passed, 5 skipped, 0 failed.7971 passed, 66 skipped, 0 failed at 2 iterations after custom-header video URL/settings/markup coverage, request-scheme oracle hardening, and video-only active-gate checks.
    23cf0ded2c / e6d2b6b02dblock-widgets700 passed, 0 skipped, 0 failed after review fixes.7981 passed, 66 skipped, 0 failed at 2 iterations after the_widget() dispatch callback/action/content ordering, rendered block output, registered control escaping, and sidebars state isolation.
    90161ef07f / 0700329bbaimport-diff800 passed, 0 skipped, 0 failed after review fixes.7959 passed, 67 skipped, 0 failed at 2 iterations after imported post permalink lookup, postmeta count/projection support, meta query limit handling, chunk-boundary coverage, and duplicate permalink oracle hardening.
    6a573889ae / f7dc6d84e8admin-list-tables500 passed, 200 skipped, 0 failed after review fixes.7962 passed, 66 skipped, 0 failed at 2 iterations after application-password list table user-meta fixtures, row/template rendering, global restoration, column-cache isolation, and last-IP boundary recording.
    c7afdcbeca / 416f45a6b7rest900 passed, 0 skipped, 0 failed after review fixes.7966 passed, 64 skipped, 0 failed at 2 iterations after REST response links, CURIE compaction, embed-all rejection, envelope filtering/cleanup, automatic target hints, and response conversion coverage.
    6e314890cd / f171eac2dbblocks600 passed, 0 skipped, 0 failed after review fixes.7974 passed, 65 skipped, 0 failed at 2 iterations after block hook insertion, filter-added hooks, single-instance suppression, exact registry grouping, and ignored-metadata coverage.
    c425fca547 / f4c5664ddeemail12290 passed, 10 skipped, 0 failed after review fixes.7970 passed, 66 skipped, 0 failed at 2 iterations after make_clickable() mailto rendering, raw UTF-8 non-linkification, parse round trips, and deterministic historical punycode-TLD partial-link boundary coverage; latest follow-up retires that boundary with a Core fix and failing assertion.
    cc5f151138 / 343f3e03c1account-security600 passed, 0 skipped, 0 failed after review fixes.7953 passed, 62 skipped, 0 failed at 2 iterations after application-password authentication API gates, hook payloads, email fallback, Basic Auth validation, usage recording, and global restoration coverage.
    fa973409d2 / de5c310150identity100 passed, 0 skipped, 0 failed after review fixes.7947 passed, 62 skipped, 0 failed at 2 iterations after current-commenter cookie payload, filter override, and filter-count restoration coverage.
    11f0fa2dc9 / 23619d1ec7network-media100 passed, 0 skipped, 0 failed after review fixes.7949 passed, 64 skipped, 0 failed at 2 iterations after URL scheme normalization, alias, filter-payload, and SSL/admin state-restoration coverage.
    97ccdbedea / 92f1ea75f6query-loop7600 passed, 0 skipped, 0 failed after review fixes.7958 passed, 62 skipped, 0 failed at 2 iterations after the_posts final result filtering, post-count recalculation, loop-state, and hook cleanup coverage.
    fdf4e75843customizer600 passed, 0 skipped, 0 failed locally; reviewer smoke rerun 60 passed, 0 failed.7960 passed, 62 skipped, 0 failed at 2 iterations after slashed customized JSON ingestion, post-value merge precedence, hook ordering, and unknown validation coverage.
    9918a62fd6block-templates800 passed, 100 skipped, 0 failed locally; reviewer rerun 1600 passed, 200 skipped, 0 failed.7964 passed, 63 skipped, 0 failed at 2 iterations after public registry list, post-type filtering, and collision-enrichment coverage.
    b8cf3766f9 / 67234118adcomment-workflow700 passed, 0 skipped, 0 failed after review fixes.7951 passed, 62 skipped, 0 failed at 2 iterations after direct wp_new_comment() preprocessing, hook, parent, and user-normalization coverage.
    0f64862222 / a4d44a1d28admin-workflows400 passed, 200 skipped, 0 failed after review fixes.7953 passed, 63 skipped, 0 failed at 2 iterations after direct list-table current-action and month-dropdown helper coverage.
    34593c0811 / c20c4b262fdefault-widgets900 passed, 0 skipped, 0 failed after review fixes.7946 passed, 63 skipped, 0 failed at 2 iterations after saved-instance callback lifecycle coverage.
    76eae4e1cf / 043856be50editor-helpers900 passed, 0 skipped, 0 failed after review fixes.7958 passed, 63 skipped, 0 failed at 2 iterations after enqueue handle/dependency coverage.
    374949bd7d / c8c67895fffrontend-features700 passed, 0 skipped, 0 failed after review fixes.7953 passed, 63 skipped, 0 failed at 2 iterations after direct speculation rule validation and diagnostic oracle hardening.
    6b9565d9da / a98060aa86admin-screen700 passed, 0 skipped, 0 failed after review fixes.7938 passed, 64 skipped, 0 failed at 2 iterations after help tab and screen option coverage.
    8b3f051c05 / e1530693f1wpdb-sql800 passed, 0 skipped, 0 failed after review fixes.7938 passed, 63 skipped, 0 failed at 2 iterations after null builder format coverage.
    b691234d07 / 6b590ee976markup2173 passed, 27 skipped, 0 failed after review fixes.7953 passed, 62 skipped, 0 failed at 2 iterations after link target/nofollow attribute helper coverage.
    55387b1acfmedia-metadata500 passed, 0 skipped, 0 failed at seeds 112233 and 987654.7942 passed, 63 skipped, 0 failed at 2 iterations after attachment classification and MIME/extension disagreement coverage.
    ab3113389c / 5251e4ff47 / cdf3ecf90a / 2cb3c60aa9fonts275 passed, 0 skipped, 0 failed at seeds 1 and 224 over 25 iterations after REST font write lifecycle coverage.4662 passed, 28 skipped, 0 failed at 1 iteration after REST font-family/font-face write lifecycle coverage.
    f8a6444fd2identity100 passed, 0 skipped, 0 failed.7939 passed, 62 skipped, 0 failed at 2 iterations after sanitizer filter-contract coverage.
    c93166b0ec / 50ef617a22 / 89ec6030b2content15 passed, 0 skipped, 0 failed at seeds 1 and 224 plus 750 passed over 50 iterations after content pagination coverage; earlier post-template helper runs passed 1400 checks at seeds 1 and 224.9139 passed, 62 skipped, 0 failed at 2 iterations after content pagination and KSES review-fix coverage.
    b2b0c9cd6cstate19 passed at seed 1, 95 passed at seed 224 over 5 iterations, 380 passed at seed 20260627 over 20 iterations, and adjacent state,options-autoload smoke passed 160 after option-backed site transient coverage for option rows, timeout updates, zero-expiration storage, manual expiration cleanup, dynamic filters, hook cleanup, and external object cache restoration.9141 passed, 62 skipped, 0 failed at 2 iterations after option-backed state site-transient fuzzing.
    42a893e3d5wxr-export900 passed, 100 skipped, 0 failed.8015 passed, 67 skipped, 0 failed at 2 iterations after attachment URL and _wp_attached_file serialization coverage.
    7966c93f67auth-flow1000 passed, 0 skipped, 0 failed.8013 passed, 67 skipped, 0 failed at 2 iterations after generated auth-cookie scheme-boundary and filter-payload coverage.
    eb8f0e9d57content1300 passed, 0 skipped, 0 failed.8011 passed, 67 skipped, 0 failed at 2 iterations after whole-post sanitize_post() array/object, ordered filter-locality, and review-fix coverage.
    906aca07e2registries600 passed, 0 skipped, 0 failed; related PHPUnit: 24 tests, 47 assertions.8005 passed, 67 skipped, 0 failed at 2 iterations after block metadata collection path-boundary, dot-segment, cache, and virtual-prefix coverage.
    a4e710265babilities700 passed, 0 skipped, 0 failed.8007 passed, 67 skipped, 0 failed at 2 iterations after custom subclass query pipeline, unregister/re-register identity, and default-input execution coverage.
    187eb56d1bsecurity900 passed, 0 skipped, 0 failed.7927 passed, 63 skipped, 0 failed at 2 iterations after redirect validation matrix coverage.
    e51aa0ed46mail2000 passed, 0 skipped, 0 failed; related PHPUnit: 32 tests, 87 assertions.8001 passed, 67 skipped, 0 failed at 2 iterations after multipart boundary matrix coverage across string/array headers, LF/CRLF bodies, uppercase boundary parameters, quoted/unquoted boundaries, and serialized MIME body/header preservation.
    6719f743e3user-preferences800 passed, 100 skipped, 0 failed.7928 passed, 62 skipped, 0 failed at 2 iterations after screen layout rendering coverage.
    7914c3dd70discovery700 passed, 0 skipped, 0 failed.7936 passed, 62 skipped, 0 failed at 2 iterations after sitemap stylesheet filter coverage.
    5d0cbfc3c5navigation900 passed, 0 skipped, 0 failed.7924 passed, 63 skipped, 0 failed at 2 iterations after args filter and items-wrap coverage.
    6388f29533template-links900 passed, 0 skipped, 0 failed.7931 passed, 63 skipped, 0 failed at 2 iterations after canonical and shortlink head-output coverage.
    39d8b05f6esyndication600 passed, 0 skipped, 0 failed.7922 passed, 64 skipped, 0 failed at 2 iterations after comment feed-link generation and filter coverage.
    cf11d874fdadmin-bar600 passed, 0 skipped, 0 failed.7913 passed, 63 skipped, 0 failed at 2 iterations after initialization hook/theme-support restoration coverage.
    8004401a7dicons-connectors500 passed, 0 skipped, 0 failed.7912 passed, 63 skipped, 0 failed at 2 iterations after API-key masking and file-modification policy coverage.
    01ae929ca5media-remote500 passed, 0 skipped, 0 failed.7921 passed, 63 skipped, 0 failed at 2 iterations after sideload prefilter and override contract coverage.
    5cad5cda2autility-internals600 passed, 0 skipped, 0 failed.7913 passed, 62 skipped, 0 failed at 2 iterations after chained list-state coverage and the classic-walkers oracle fix.
    a8b3eed2efclassic-walkers1100 passed, 100 skipped, 0 failed.Fixed broad-run generated has-children oracle finding for seed 696015146.
    f3859d19eequery-loop7500 passed, 0 skipped, 0 failed.7910 passed, 62 skipped, 0 failed at 2 iterations after offset/no-found-rows field-shape coverage.
    787b89ace6mail800 passed, 0 skipped, 0 failed.7907 passed, 62 skipped, 0 failed at 2 iterations after UTF-8 local-part PHPMailer handoff coverage.
    bba81e013bcanonical-routing1100 passed, 0 skipped, 0 failed.7913 passed, 62 skipped, 0 failed at 2 iterations after same-host redirect filter replacement cascade coverage.
    2beac968c2xmlrpc900 passed, 0 skipped, 0 failed.7897 passed, 64 skipped, 0 failed at 2 iterations after mixed success/fault multicall ordering coverage.
    c2c69c4132assets1400 passed, 0 skipped, 0 failed.8021 passed, 67 skipped, 0 failed at 2 iterations after style add-data output metadata coverage.
    8fd73afc6cmedia-editor225 passed, 0 skipped, 0 failed for seeds 1 and 224 over 25 iterations after replacing unavailable real-editor skips with explicit GD/Imagick extension and mime-support accounting; adjacent media smoke passed 101 checks with no skips.4689 passed, 6 skipped, 0 failed at 1 iteration after media editor availability accounting.
    fee60b3b4a / f5aad1d00cmedia-editor800 passed, 100 skipped, 0 failed after abstract editor filename/quality/EXIF-orientation coverage and review fixes.8023 passed, 67 skipped, 0 failed at 2 iterations after abstract editor contract coverage.
    e6864cfb09filesystem1000 passed, 0 skipped, 0 failed.7898 passed, 62 skipped, 0 failed at 2 iterations after direct filesystem metadata/time/chmod coverage and media-editor integration.
    2370d0b086classic-walkers1100 passed, 100 skipped, 0 failed.7896 passed, 63 skipped, 0 failed at 2 iterations after classic walker traversal and output oracle hardening.
    62e6ac9ad4admin-ajax800 passed, 0 skipped, 0 failed.7896 passed, 63 skipped, 0 failed at 2 iterations after compression-test capability/body branch coverage and classic-walkers integration.
    8474330e39update-install-upgrader1200 passed, 0 skipped, 0 failed.7887 passed, 62 skipped, 0 failed at 2 iterations after VCS guard and theme auto-update filter/PHP gate coverage.
    38b101b44eplugin-theme-lifecycle1100 passed, 0 skipped, 0 failed.7887 passed, 62 skipped, 0 failed at 2 iterations after plugin/theme lifecycle oracle hardening.
    1e84bac01binstall-schema900 passed, 0 skipped, 0 failed.7887 passed, 62 skipped, 0 failed at 2 iterations after make_db_current() wrapper scope coverage.
    5c09c881b1translations700 passed, 0 skipped, 0 failed.7887 passed, 62 skipped, 0 failed at 2 iterations after translation install/API short-circuit coverage.
    7e02656bf4community-events700 passed, 0 skipped, 0 failed.7872 passed, 62 skipped, 0 failed at 2 iterations after strict coordinate matching and cache expiration normalization coverage.
    c4c5b7456btaxonomy1000 passed, 0 skipped, 0 failed.7885 passed, 62 skipped, 0 failed at 2 iterations after registration side-effect, term field filter, and hierarchy helper coverage.
    d5de86ecd5admin-bar500 passed, 0 skipped, 0 failed.7876 passed, 63 skipped, 0 failed at 2 iterations in a clean worktree after default menu hook registration coverage.
    5b99565f57post-types700 passed, 0 skipped, 0 failed.7853 passed, 64 skipped, 0 failed at 2 iterations after archive/feed link helper, query branch, and filter-locality coverage.
    1329011c91style1200 passed, 100 skipped, 0 failed.7871 passed, 62 skipped, 0 failed at 2 iterations after preset classname/CSS-var, block selector, theme.json variable, and editor theme-style filter coverage.
    7fbad6f455site-health-debug600 passed, 200 skipped, 0 failed.7871 passed, 62 skipped, 0 failed at 2 iterations after MySQL variable lookup and scoped SHOW VARIABLES wpdb-double coverage.
    48bfee1d39blocks500 passed, 0 skipped, 0 failed.7854 passed, 63 skipped, 0 failed at 2 iterations after nested parse/serialize, block detection, and dynamic render callback/filter coverage.
    cc1ac49ff3date-time1200 passed, 0 skipped, 0 failed.7854 passed, 63 skipped, 0 failed at 2 iterations after current datetime, timezone override, ISO8601 conversion, and date/human filter coverage.
    93c6f19416imagesLatest focused runs: 1000 passed, 0 skipped, 0 failed at seeds 1 and 224 after content tag pipeline matrix and review fixes; earlier image runs passed 900 checks plus the rounding repro.9075 passed, 66 skipped, 0 failed at 2 iterations after image content tag pipeline coverage.
    component-fuzz-kses-null-controlsksesLatest focused runs: seed 224 passed 1100 checks, seeds 1, 224, and 57123 over 25 iterations passed 27578, 27587, and 27582 checks after null/control-character and filter-lifecycle coverage for wp_kses_no_null(), null-obfuscated protocol handling, KSES save/comment/global-styles hook registration, removal, unrelated callback preservation, and unfiltered_html capability gates.4844 passed, 1 skipped, 0 failed at 1 iteration after KSES null/control and filter-lifecycle coverage.
    component-fuzz-customizer-media-controlscustomizerLatest focused runs: seed 224 passed 9 checks, seeds 1, 224, and 57123 over 25 iterations each passed 225 checks after Customizer media-control subclass coverage for default image/document attachments, upload URL resolution with cache-seeded attachments, image/cropped/site-icon JSON, button-label merging, upload capability gates, site-icon hook cleanup, and structural media templates.4842 passed, 1 skipped, 0 failed at 1 iteration after Customizer media-control subclass coverage.
    component-fuzz-customizer-control-renderingcustomizerLatest focused runs: seed 224 passed 8 checks, seeds 1, 224, and 57123 over 25 iterations each passed 200 checks after built-in control rendering coverage for text, textarea, checkbox, radio, and select output, including render hooks, capability gates, setting links, escaped labels/values/input attrs, textarea rows, checked state, selected options, and cleanup.4835 passed, 1 skipped, 0 failed at 1 iteration after Customizer control rendering coverage.
    component-fuzz-account-reset-passwordaccount-securityLatest focused runs: seed 224 passed 8 checks, seeds 1, 224, and 57123 over 25 iterations each passed 200 checks after direct reset_password() coverage for hook ordering, plaintext payloads, stored hash replacement, previous-password invalidation, activation-key and nag clearing, repeat replacement, and cleanup.4834 passed, 1 skipped, 0 failed at 1 iteration after direct reset-password composition coverage.
    component-fuzz-identity-user-dropdownsidentityLatest focused runs: seed 224 passed 1 aggregate row with 23 cases and 900 checks, seeds 1, 224, and 57123 over 25 iterations each passed 25 aggregate rows after generic wp_dropdown_users() coverage for selector markup, no-echo returns, all/none/selected options, args and final HTML filters, escaped display_name_with_login, include_selected, single-author hiding, and cleanup.4833 passed, 1 skipped, 0 failed at 1 iteration after identity user dropdown coverage.
    component-fuzz-admin-edit-author-boxadmin-edit-metaboxesLatest focused runs: seed 224 passed 7 checks, seeds 1, 224, and 57123 over 25 iterations each passed 175 checks after direct post_author_meta_box() author override dropdown coverage, including selected-author include_selected preservation, wp_dropdown_users_args payload checks, escaped display_name_with_login labels, and cleanup.4833 passed, 1 skipped, 0 failed at 1 iteration after admin edit author meta box coverage.
    component-fuzz-rest-directory-pattern-fieldsrest-directory-servicesLatest focused runs: 175 passed, 0 skipped, 0 failed at seeds 1, 224, and 57123 after pattern-directory direct prepare, remote-field sanitization, dropped extra fields, _fields projection, rest_prepare_block_pattern payload, and no-leak proxy query coverage.4832 passed, 1 skipped, 0 failed at 1 iteration after REST pattern-directory response projection coverage.
    component-fuzz-network-media-form-uploadnetwork-mediaLatest focused runs: seed 224 passed 1 aggregate row with 303 cases, 1671 API calls, and 2093 assertions; seeds 1, 224, and 57123 over 25 iterations each passed 25 aggregate rows after sideload form-action rejection, prefilter error short-circuit, overrides/custom error handler, MIME rejection, final upload filter context, move semantics, and scoped restoration coverage.4831 passed, 1 skipped, 0 failed at 1 iteration after network-media sideload error/filter contract coverage.
    component-fuzz-block-editor-stylesheetsblock-editor-adjunctsLatest focused runs: 200 passed, 0 skipped, 0 failed at seeds 1, 224, and 57123 after editor stylesheet lifecycle coverage, including generated parent/child theme fixtures, external URL sanitization/dedupe, parent-before-child file ordering, missing-file omission, filter payloads, remove idempotence, and cleanup.4831 passed, 1 skipped, 0 failed at 1 iteration after block-editor adjunct editor stylesheet coverage.
    component-fuzz-block-supports-style-callbacksblock-supportsLatest focused runs: 200 passed, 0 skipped, 0 failed at seeds 1, 224, and 57123 after direct style callback coverage, including preset color/font/border classes, inline gradient, spacing, border radius/style/width, typography, dimensions, preset shadow CSS, empty/unsupported fail-closed paths, and safe CSS output.4830 passed, 1 skipped, 0 failed at 1 iteration after block-supports style callback coverage.
    component-fuzz-admin-workflow-noticesadmin-workflowsLatest focused runs: 250 passed, 0 skipped, 0 failed at seeds 1, 224, and 57123 after Settings API/admin notice helper coverage, including generated section/field callbacks, hidden settings form inputs, settings error filtering/type mapping, admin notice getter/echo/filter/action parity, escaped markup, and cleanup.4829 passed, 1 skipped, 0 failed at 1 iteration after admin-workflows Settings API/admin notice coverage.
    component-fuzz-plugin-theme-admin-helpersplugin-themeLatest focused runs: 325 passed, 0 skipped, 0 failed at seeds 1, 224, and 57123 after plugin/theme admin helper coverage, including generated WP_PLUGIN_DIR fixtures, get_plugins(), validate_plugin(), is_network_only_plugin(), get_plugin_files(), validate_file_to_edit(), active-state option filters, and cleanup.4827 passed, 1 skipped, 0 failed at 1 iteration after plugin/theme admin helper coverage.
    component-fuzz-cron-pre-filter-errorscronLatest focused runs: seed 224 passed 101 checks, seed 224 over 25 iterations passed 2525 checks, seeds 1 and 57123 over 10 iterations each passed 1010 checks after adding generated false-return oracles for pre_schedule_event, schedule_event, pre_reschedule_event, pre_unschedule_event, and pre_clear_scheduled_hook; adjacent lifecycle/options smoke passed 176 checks.5094 passed, 1 skipped, 0 failed at 1 iteration after cron pre-filter failure-contract coverage.
    component-fuzz-cron-array-persistencecronLatest focused runs: 2400 passed, 0 skipped, 0 failed at seeds 1, 224, and 57123 after private cron option persistence coverage, including non-array normalization, version stripping, legacy arg-key migration, versioned writes, forced update-failure WP_Error returns, and filter/store cleanup.4823 passed, 1 skipped, 0 failed at 1 iteration after cron option persistence coverage.
    component-fuzz-rest-media-leakrest-media-attachmentsLatest focused runs: seeds 1, 224, and 57123 over 25 iterations each passed 225 checks after refreshing default and parent-date-keyed wp_upload_dir() cache entries around REST media runtime setup and parent-post fixture creation; the prior ordered media repro passed 168 checks and the full adjacent media+REST run passed 204 checks.5036 passed, 1 skipped, 0 failed at 1 iteration after REST media upload-directory cache isolation.
    component-fuzz-kses-reentrant-diagnosticksesLatest focused runs: seed 224 passed 1109 checks and seed 224 over 25 iterations passed 27812 checks after current-core reentrant wp_kses_allowed_html diagnostics for leaked $pass_allowed_html/$pass_allowed_protocols; asserts first-token outer-policy preservation, later-token inner-policy modeling, explicit future fixed-oracle divergence, nested-policy execution, unsafe tail sanitization, hook stack locality, scoped filter removal, and exact hook/global restoration; seeds 1, 100, and 424242 over 5 iterations passed 5558, 5556, and 5562 checks; adjacent KSES/security/markup stack passed 1207 checks and hook/KSES/security stack passed 5703 checks over 5 iterations.5076 passed, 1 skipped, 0 failed at 1 iteration after KSES reentrant allowed-html leakage diagnostics.
    component-fuzz-kses-hook-lifecycleksesLatest focused runs: seed 224 passed 1108 checks and seed 224 over 25 iterations passed 27787 checks after generated pre-hook lifecycle coverage for custom string and explicit policies; asserts normalized content reaches early/late pre_kses filters in priority order, original allowed-html argument types and protocol arrays are preserved, hook stack locality is exact, modeled hook mutations are still sanitized, outputs are fixed points when pre-hook mutation is disabled, scoped wp_kses_allowed_html contexts stay isolated, and hook/global snapshots restore; seeds 1, 100, and 424242 over 5 iterations passed 5553, 5551, and 5557 checks; adjacent KSES/security/markup stack passed 1206 checks and hook/KSES/security stack passed 5698 checks over 5 iterations.5075 passed, 1 skipped, 0 failed at 1 iteration after KSES pre-hook lifecycle coverage.
    component-fuzz-auth-redirectauth-flowLatest focused runs: seed 224 passed 13 checks and seed 224 over 25 iterations passed 325 checks after auth_redirect() boundary coverage for forced secure redirects before cookie validation, valid logged-in cookie return/action behavior, per-user use_ssl redirects, invalid-cookie options.php referer login redirects, invalid-cookie current-request login redirects, nocache headers, redirect capture before exit, hook locality, force-SSL restoration, request globals, and content cleanup; seeds 1 and 57123 over 5 iterations each passed 65 checks; adjacent auth/security/request stack passed 68 checks.5089 passed, 1 skipped, 0 failed at 1 iteration after auth redirect boundary coverage.
    component-fuzz-account-reset-requestaccount-securityLatest focused runs: seed 224 passed 9 checks and seed 224 over 25 iterations passed 225 checks after retrieve-password request-path coverage for empty, unknown-email, policy-blocked, send-gated, and successful reset requests; asserts invalid paths avoid key/mail side effects, validation and send gates are pre-mutation, POST email lookup stores a checkable timestamped fast-hash key, mail filters expose canonical login/key data, pre_wp_mail intercepts delivery, and hooks/globals/content restore; seeds 1 and 57123 over 5 iterations each passed 45 checks; adjacent auth/security/mail stack passed 53 checks.5088 passed, 1 skipped, 0 failed at 1 iteration after account reset request-path coverage.
    component-fuzz-rest-sidebar-fieldsrest-widgets-sidebarsLatest focused runs: seed 224 passed 13 checks and seed 224 over 25 iterations passed 325 checks after sidebar default-filtered route-envelope coverage for public and hidden item GET projections, collection rows for public, inactive, and hidden sidebars, widget links and target hints, Allow headers, item and collection HEAD empty bodies, response envelopes, prepare-filter locality, scoped caps, default REST filters, and cleanup; seeds 1 and 57123 over 5 iterations each passed 65 checks; adjacent widget/REST stack passed 112 checks.5087 passed, 1 skipped, 0 failed at 1 iteration after REST sidebar default-filtered route-envelope coverage.
    component-fuzz-rest-block-renderer-fieldsrest-controllersLatest focused runs: seed 224 passed 26 checks and seed 224 over 25 iterations passed 650 checks after default-filtered block-renderer route coverage for GET/POST _fields=rendered, JSON POST body parsing with post context, _fields=missing rendering before final body pruning to [], HEAD render callback reachability, Allow: GET, POST, pre_render_block/render_block_data hook ordering and locality, response envelopes, default REST filters, globals, seeded post storage, and block registration cleanup; seeds 1 and 57123 over 5 iterations each passed 130 checks; adjacent REST stack passed 71 checks.5086 passed, 1 skipped, 0 failed at 1 iteration after REST block-renderer default-filtered field coverage.
    component-fuzz-rest-search-fieldsrest-controllersLatest focused runs: seed 224 passed 25 checks and seed 224 over 25 iterations passed 625 checks after route-dispatched search field/link error-envelope coverage for _fields=_links link-only bodies, nested _links.self,_links.collection pruning of unrequested rels, _fields=type,subtype final bodies without IDs or links despite internal ID preparation, malformed handler GET/HEAD rest_search_handler_error envelopes before preparation, total/Allow headers, response envelopes, default REST filters, and global restoration; seeds 1 and 57123 over 5 iterations each passed 125 checks; adjacent REST stack passed 70 checks.5084 passed, 1 skipped, 0 failed at 1 iteration after REST search field/link error-envelope coverage.
    component-fuzz-rest-search-coercionrest-controllersLatest focused runs: seed 224 passed 24 checks and seed 224 over 25 iterations passed 600 checks after route-dispatched search include/exclude coercion coverage for comma scalar and numeric-string array IDs, duplicate preservation, zero and negative integer IDs, HEAD search without preparation, invalid string/fractional/nested/associative values failing with rest_invalid_param before handlers, _fields pruning, total/link/Allow headers, response envelopes, default REST filters, and global restoration; seeds 1 and 57123 over 5 iterations each passed 120 checks; adjacent REST stack passed 69 checks.5083 passed, 1 skipped, 0 failed at 1 iteration after REST search include/exclude coercion coverage.
    component-fuzz-rest-plugin-theme-itemsrest-controllersLatest focused runs: seed 224 passed 23 checks and seed 224 over 25 iterations passed 575 checks after plugin/theme item-route dispatch boundary coverage for generated temp plugin fixtures under WP_PLUGIN_DIR, plugin GET/DELETE item routes short-circuited at rest_dispatch_request, missing-plugin 404 and denied PATCH/DELETE contrasts, and encoded theme item stylesheet decoding; asserts plugin .php basename sanitization, no controller preparation or lifecycle hooks, option preservation, fixture/cache cleanup, and filter/global/default REST filter restoration; seeds 1 and 57123 over 5 iterations each passed 115 checks; adjacent REST stack passed 68 checks.5082 passed, 1 skipped, 0 failed at 1 iteration after REST plugin/theme item-route dispatch boundary coverage.
    component-fuzz-rest-plugin-theme-dispatchrest-controllersLatest focused runs: seed 224 passed 22 checks and seed 224 over 25 iterations passed 550 checks after plugin/theme allowed dispatch boundary coverage for valid plugin list, plugin create, active-theme list, and full theme list requests short-circuited at rest_dispatch_request; asserts inactive create default status, sanitized status arrays, active-plugin activation cap denial, theme active/inactive permission contrasts, no controller preparation hooks, option preservation, and filter/global/default REST filter restoration; seeds 1 and 57123 over 5 iterations each passed 110 checks; adjacent REST stack passed 67 checks.5081 passed, 1 skipped, 0 failed at 1 iteration after REST plugin/theme allowed dispatch boundary coverage.
    component-fuzz-rest-plugin-theme-argsrest-controllersLatest focused runs: seed 224 passed 21 checks and seed 224 over 25 iterations passed 525 checks after plugin/theme argument-envelope coverage for invalid plugin collection status enum/type, plugin context, plugin search, missing slug, slug type/pattern, plugin create status, and theme collection status enum/type cases; asserts stable REST envelopes, no permission/callback reachability for invalid args, scalar status array sanitization before permission denial, wp_sprintf_l enum formatting, and filter/global/default REST filter restoration; seeds 1 and 57123 over 5 iterations each passed 105 checks; adjacent REST stack passed 66 checks.5080 passed, 1 skipped, 0 failed at 1 iteration after REST plugin/theme controller argument-envelope coverage.
    component-fuzz-rest-controller-schemarest-controllersLatest focused runs: seed 224 passed 20 checks and seed 224 over 25 iterations passed 500 checks after menu-location argument/permission boundary coverage for invalid collection/item context schema errors, read-access filters observing raw invalid contexts before schema rejection, no invalid-response preparation, valid anonymous denial, read-access override recovery, assigned menu ID preservation, wp_sprintf_l enum formatting, and filter/global/default REST filter restoration; seeds 1 and 57123 over 5 iterations each passed 100 checks; adjacent REST stack passed 65 checks.5079 passed, 1 skipped, 0 failed at 1 iteration after REST menu-location controller argument/permission boundary coverage.
    component-fuzz-rest-batch-boundaryrestLatest focused runs: seed 224 passed 16 checks and seed 224 over 25 iterations passed 400 checks after batch-v1 request schema boundary coverage for top-level validation mode, required requests payloads, defaulted child POST execution, generated child path/method/body/header shape rejection, stable error parameter attribution, callback locality, and response envelope alignment; seeds 1 and 57123 over 5 iterations each passed 80 checks; adjacent REST stack passed 64 checks.5078 passed, 1 skipped, 0 failed at 1 iteration after REST batch-v1 request schema boundary coverage.
    component-fuzz-rest-batch-no-routerestLatest focused runs: seed 224 passed 15 checks and seed 224 over 25 iterations passed 375 checks after batch-v1 parsed no-route child path alignment coverage for generated unknown namespaces, nonmatching route regexes, unsupported methods, absolute same-host paths, encoded-space paths, and protocol-relative paths; asserts ordered 404 rest_no_route envelopes with data.status=404, valid siblings before and after no-route slots, normal child pre/post dispatch, require-all null valid-sibling slots, no child execution during aborted validation, and filter/global restoration; seeds 1 and 57123 over 5 iterations each passed 75 checks; adjacent REST stack passed 63 checks.5074 passed, 1 skipped, 0 failed at 1 iteration after REST batch-v1 parsed no-route child path alignment coverage.
    component-fuzz-security-admin-referersecurityLatest focused runs: seed 224 passed 19 checks and seed 224 over 25 iterations passed 475 checks after admin-referer hook-edge coverage; asserts generated check_admin_referer action payloads for current and previous-tick nonces, custom query-arg precedence over default nonces, invalid non-empty nonce wp_verify_nonce_failed payloads before wrapper hooks, empty/missing nonce non-dispatch, wp_nonce_ays() 403 capture, hook stack locality, scoped filter removal, and superglobal restoration; seeds 1 and 12345 over 5 iterations each passed 95 checks; hooks,security over 5 iterations passed 155 checks; adjacent request/canonical/http stack passed 80 checks and adjacent auth/admin stack passed 70 checks.5085 passed, 1 skipped, 0 failed at 1 iteration after security admin-referer hook-edge coverage.
    component-fuzz-security-hook-edgessecurityLatest focused runs: seed 224 passed 18 checks and seed 224 over 25 iterations passed 450 checks after nonce verifier hook-edge coverage; asserts generated check_ajax_referer action payloads for current and previous-tick nonces, invalid non-empty nonce wp_verify_nonce_failed payloads, empty nonce non-dispatch, verifier-before-wrapper hook ordering, custom query-arg precedence, Ajax stop=true wp_die() routing with 403 capture, hook stack locality, scoped filter removal, and superglobal restoration; seeds 1 and 12345 over 5 iterations each passed 90 checks; hooks,security over 5 iterations passed 150 checks; adjacent request/canonical/http stack passed 79 checks and adjacent auth/admin stack passed 69 checks.5077 passed, 1 skipped, 0 failed at 1 iteration after security nonce verifier hook-edge coverage.
    component-fuzz-security-redirect-dispatchsecurityLatest focused runs: seed 224 passed 17 checks and seed 224 over 25 iterations passed 425 checks after generated redirect dispatch boundary coverage for wp_redirect()/wp_safe_redirect() filter sequencing, falsey-location cancellation, invalid-status wp_die() capture, safe-redirect fallback and allowed-host dispatch, sanitized x_redirect_by payloads, and headerless interruption; seeds 1 and 12345 over 5 iterations each passed 85 checks; adjacent request/canonical/http stack passed 78 checks and adjacent auth/admin stack passed 68 checks.5073 passed, 1 skipped, 0 failed at 1 iteration after generated security redirect dispatch boundary coverage.
    component-fuzz-kses-helper-generatedksesLatest focused runs: seed 224 passed 1107 checks and seed 224 over 25 iterations passed 27762 checks after generated low-level helper contract coverage for slash-before-double-quote stripping, two-level policy key lowercasing, malformed attribute recovery, byte-oriented numeric entity decoding, HTML/XML entity normalization, named/numeric entity callbacks, and helper idempotence boundaries; seeds 1, 100, and 424242 over 5 iterations passed 5548, 5546, and 5552 checks; adjacent KSES/security/markup stack passed 1204 checks.5072 passed, 1 skipped, 0 failed at 1 iteration after generated KSES low-level helper contract coverage.
    component-fuzz-rest-batch-pathsrestLatest focused runs: seed 224 passed 14 checks and seed 224 over 25 iterations passed 350 checks after batch-v1 malformed child path parsing coverage for valid sibling execution, generated wp_parse_url() failure paths, aligned parse_path_failed envelopes, require-all null valid sibling, no malformed-child dispatch, and filter restoration; adjacent REST stack passed 62 checks.5071 passed, 1 skipped, 0 failed at 1 iteration after REST batch-v1 malformed child path parsing coverage.
    component-fuzz-rest-batch-orderrest-directory-services / restLatest focused runs: rest-directory-services seeds 1, 224, and 57123 over 25 iterations each passed 225 checks after cloned WP_Hook snapshot restoration and hook-callback fingerprinting; ordered rest-directory-services,rest seeds 1, 224, and 57123 over 3 iterations each passed 60 checks; adjacent REST stack passed 118 checks.5036 passed, 1 skipped, 0 failed at 1 iteration after REST directory-service hook isolation.
    component-fuzz-rest-object-require-all-createrest-object-controllersLatest focused runs: seed 224 passed 29 checks and seed 224 over 25 iterations passed 725 checks after require-all mixed collection-create validation abort coverage for valid post create, invalid category parent create, valid user create, null valid sibling slots, invalid parent body parity, no child post-dispatch, no post/term/user mutation by generated identifiers, defensive cleanup, and state restoration; adjacent REST/content/capability stack passed 896 checks.5070 passed, 1 skipped, 0 failed at 1 iteration after REST object-controller require-all mixed collection-create validation abort coverage.
    component-fuzz-rest-object-mixed-createrest-object-controllersLatest focused runs: seed 224 passed 28 checks and seed 224 over 25 iterations passed 700 checks after normal mixed collection-create batch coverage for valid post create, invalid category parent create, valid user create, direct-dispatch parity, valid sibling persistence, invalid term non-creation, child post-dispatch locality, cleanup, and state restoration; adjacent REST/content/capability stack passed 895 checks.5069 passed, 1 skipped, 0 failed at 1 iteration after REST object-controller normal mixed collection-create batch coverage.
    component-fuzz-kses-post-deepksesLatest focused runs: seed 224 passed 1106 checks and seed 224 over 25 iterations passed 27737 checks after recursive wp_kses_post_deep() wrapper-equivalence coverage for nested arrays, public objects, scalar leaves, dangerous-looking array keys, unsafe post-content leaves, safe URL preservation, idempotence, hook stability, and global restoration; adjacent KSES/security/markup stack passed 1203 checks.5068 passed, 1 skipped, 0 failed at 1 iteration after KSES wp_kses_post_deep() nested structure coverage.
    component-fuzz-kses-css-protocol-fragmentsksesLatest focused runs: seeds 1, 224, and 57123 over 25 iterations passed 27703, 27712, and 27707 checks after semicolon-free data:, URL rejection and semicolon-fragmented javascript/vbscript/livescript/mocha diagnostics across default, all-true permissive, and guarded safecss_filter_attr_allow_css filters; seed 100 over 20 iterations passed 22150 checks and adjacent KSES/security/markup stack passed 1202 checks.5039 passed, 1 skipped, 0 failed at 1 iteration after KSES semicolon-free data URL and bad-protocol fragment diagnostics.
    component-fuzz-kses-css-data-urlksesLatest focused runs: seeds 1, 224, and 57123 over 25 iterations passed 27678, 27687, and 27682 checks after semicolon-bearing data: CSS URL diagnostics across default, all-true permissive, and guarded safecss_filter_attr_allow_css filters; seed 100 over 20 iterations passed 22130 checks and adjacent KSES/security/markup stack passed 1201 checks.5038 passed, 1 skipped, 0 failed at 1 iteration after KSES semicolon-bearing data URL CSS parser diagnostics.
    component-fuzz-kses-style-protocolksesLatest focused runs: seeds 1, 224, and 57123 over 25 iterations passed 27653, 27662, and 27657 checks after CSS URL cross-hook coverage with safe_style_css, permissive safecss_filter_attr_allow_css, dynamic wp_kses_uri_attributes, wp_kses(), wp_kses_hair(), and wp_kses_attr(); seed 100 over 20 iterations passed 22110 checks and adjacent KSES/security/markup stack passed 1200 checks.5037 passed, 1 skipped, 0 failed at 1 iteration after KSES style/protocol cross-hook coverage.
    component-fuzz-rest-media-urlrest-media-attachmentsLatest focused runs: 200 passed, 0 skipped, 0 failed at seeds 1, 224, and 57123 after REST media URL sideload coverage, including fail-closed URL validation, no-network HTTP fixtures, temp download cleanup, media_handle_sideload() attachment creation, REST action payloads, response projection, and filter/capability cleanup.4818 passed, 1 skipped, 0 failed at 1 iteration after REST media URL sideload coverage.
    component-fuzz-media-metadata-statemedia-metadataLatest focused runs: seed 224 passed 12 checks and seeds 1, 224, and 57123 over 25 iterations each passed 300 checks after generated metadata shortcode cache-coherence coverage, including gallery second sub-size selection, audio/video playlist JSON refresh, stale first-generation rejection, attachment-file persistence, shortcode no-mutation checks, and scoped filter/global cleanup.5036 passed, 1 skipped, 0 failed at 1 iteration after media-metadata generated shortcode cache-coherence coverage.
    customizer-publish-slicecustomizer-persistenceLatest focused runs: 225 passed, 0 skipped, 0 failed at seeds 1, 224, and 57123 after publish-side changeset application coverage, including option, theme-mod, and filtered Custom CSS persistence, publish hook ordering, next changeset UUID response shape, trash cleanup, invalid transaction rollback, and request slashing.4816 passed, 1 skipped, 0 failed at 1 iteration after Customizer publish-side changeset application coverage.
    fe20f9d343 / 53453d79ffcanonical-routingLatest focused runs: 400 passed, 0 skipped, 0 failed at seeds 1, 224, and 57123 after old-slug/date redirect coverage, including post-ID filter cancellation and replacement; earlier canonical-routing runs passed 350 checks after DB-backed 404 permalink guessing, 1300 checks after canonical URL output helpers, and 1200 checks after helper-matrix smoke.4815 passed, 1 skipped, 0 failed at 1 iteration after canonical old-slug post-ID filter cancellation and replacement coverage.
    89e00fbfadsyndicationLatest focused runs: 900 passed, 0 skipped, 0 failed at seeds 1 and 224 after feed_links_extra() branch matrix and review cleanup; earlier syndication runs passed oEmbed cache and feed-link helper coverage.9079 passed, 66 skipped, 0 failed at 2 iterations after feed_links_extra() branch coverage.
    555495f583admin-barLatest focused runs: seeds 1, 224, and 57123 over 25 iterations each passed 225 checks after single-site callback node graph coverage; earlier admin-bar runs passed 800 checks after default callback node graph coverage and 700 checks after render alias/tabindex coverage.4795 passed, 1 skipped, 0 failed at 1 iteration after single-site admin-bar callback node coverage; earlier broad run passed 9081 checks with 66 skips at 2 iterations after default callback node graph coverage.
    component-fuzz-upgrader-notificationsupdate-install-upgraderLatest focused runs: seed 224 passed 14 checks, seeds 1 and 224 over 25 iterations each passed 350 checks after synthetic WP_Automatic_Updater plugin/theme notification classification, duplicate failure suppression, failure-version persistence, stale success clearing, mixed result handling, email body/filter payload, intercepted mail, and option/filter restoration coverage; adjacent plugin/theme/upgrader smoke passed 58 checks with 1 existing skip.4710 passed, 5 skipped, 0 failed at 1 iteration after updater notification coverage.
    component-fuzz-wxr-export-edgeswxr-exportLatest focused runs: seed 224 passed 11 checks, seeds 1 and 224 over 25 iterations each passed 275 checks after non-exportable/invalid content fallback, null meta, filtered-comment, nav-menu term, and associative-array prepare coverage; adjacent wxr-export,import-diff,content smoke passed 36 checks.4709 passed, 5 skipped, 0 failed at 1 iteration after WXR edge/resilience coverage.
    component-fuzz-xmlrpc-write-methodsxmlrpcLatest focused runs: 300 passed, 0 skipped, 0 failed at seeds 1 and 224 after authenticated wp.newPost, wp.editPost, and wp.deletePost write-method coverage; earlier XML-RPC runs passed 550 checks for authenticated read-only content/media, 1000 checks for pingback fail-closed/read-only lookup coverage, and 800 checks for legacy post XML helpers and short-circuited HTTP IXR client coverage.4708 passed, 5 skipped, 0 failed at 1 iteration after XML-RPC authenticated post write-method coverage.
    4a2cad026crevisions-autosavesLatest focused runs: 600 passed, 50 guarded browser-template skips, 0 failed at seeds 1 and 224 after latest revision count/URL helper and user-filtered autosave lookup coverage; earlier revisions-autosaves runs passed retention pruning, protected field filters, post-locks, restore/title/list, and support-gate coverage.9087 passed, 62 skipped, 0 failed at 2 iterations after replacing the latest-count URL and user-filtered autosave stub-limited skips.
    0b1c9674a6 / 0afd17bb8dtemplate-hierarchyLatest focused runs: 900 passed, 100 guarded comments-template skips, 0 failed at seeds 1 and 224 after direct helper matrix; earlier run passed 700 checks with 100 skips.9073 passed, 66 skipped, 0 failed at 2 iterations after template hierarchy direct helper coverage.
    63f21f64d0shortcodes1400 passed, 0 skipped, 0 failed.7853 passed, 62 skipped, 0 failed at 2 iterations after invalid registration and non-callable callback guard coverage.
    bd7fc967f2email24570 passed, 0 skipped, 0 failed at 20 iterations for the Unicode follow-up.7850 passed, 62 skipped, 0 failed at 2 iterations after Unicode-domain user lookups and password-reset recipient coverage.
    e330757e2exmlrpc800 passed, 0 skipped, 0 failed.7849 passed, 62 skipped, 0 failed at 2 iterations after legacy post XML helper and short-circuited HTTP IXR client coverage.
    4841e86db6revisions-autosaves900 passed, 300 skipped, 0 failed.7842 passed, 64 skipped, 0 failed at 2 iterations after protected field filters, autosave/post-lock, restore/title/list, and support-gate coverage.
    e77aa7c74anetwork-media100 aggregate iterations passed; sampled result had 1752 assertions and 0 failures.7838 passed, 62 skipped, 0 failed at 2 iterations after multisite upload quota and size-limit helper coverage.
    537a1945d8query7940 passed, 0 skipped, 0 failed.7840 passed, 62 skipped, 0 failed at 2 iterations after no-DB WP_Query execution SQL-shape and bounded global-state coverage.
    fa59e81908rest-controllers900 passed, 200 skipped, 0 failed.7840 passed, 62 skipped, 0 failed at 2 iterations after REST additional-field get/update/schema coverage.
    ad4062a0afwidgets800 passed, 0 skipped, 0 failed.7690 passed, 63 skipped, 0 failed at 2 iterations after sidebar option cache/filter and widget ID parsing coverage.
    22386accbdrewrite1800 passed, 0 skipped, 0 failed.7692 passed, 62 skipped, 0 failed at 2 iterations after rewrite collision, endpoint-mask, match-map, query parse, and weird-path coverage.
    cb2f19fcefcron8500 passed, 0 skipped, 0 failed.7679 passed, 64 skipped, 0 failed at 2 iterations after wp_next_scheduled filter coverage.
    9b99f0ea11shortcodes1300 passed, 0 skipped, 0 failed.7678 passed, 63 skipped, 0 failed at 2 iterations after strip_shortcodes_tagnames filter coverage.
    d3b4aa01ffemail183696 passed, 0 skipped, 0 failed.7667 passed, 62 skipped, 0 failed at 2 iterations after malformed UTF-8, Unicode local-part byte boundary, and display-name recovery integration.
    9d9434d4b5security800 passed, 0 skipped, 0 failed.7642 passed, 62 skipped, 0 failed at 2 iterations after password and fast-hash verification coverage.
    d759a80a3ehttp800 passed, 0 skipped, 0 failed.7643 passed, 62 skipped, 0 failed at 2 iterations after remote wrapper short-circuit dispatch coverage.
    89f78b4db1feed-rendering800 passed, 0 skipped, 0 failed.7639 passed, 62 skipped, 0 failed at 2 iterations after Atom comments, feed links, content-mode, and URL escaping integration.
    8e7a6a32ecstate1700 passed, 0 skipped, 0 failed.7640 passed, 62 skipped, 0 failed at 2 iterations after cache-backed site-transient branch and filter integration.
    dd69f1c879capabilities900 passed, 0 skipped, 0 failed.7630 passed, 63 skipped, 0 failed at 2 iterations after role lifecycle, user mutation, filter-locality, and meta-cap monotonicity integration.
    ec81e176e5filesystem, formattingFilesystem focused: 900 passed, 0 skipped, 0 failed; sanitizeFileName PHPUnit: 21 tests, 29 assertions.7622 passed, 63 skipped, 0 failed at 2 iterations after fixing stream/newline path normalization oracle and malformed UTF-8 filename handling.
    component-fuzz-importer-get-pageimport-diff11 passed, 0 skipped, 0 failed at seed 224; 275 passed, 0 skipped, 0 failed at seeds 1 and 224 over 25 iterations; seed 57123 over 10 iterations passed 110 checks; adjacent import/http smoke passed 120 checks after importer get_page() HTTP wrapper coverage.5096 passed, 1 skipped, 0 failed at 1 iteration after importer get_page() wrapper fuzzing.
    46237be00dimport-diff1050 passed, 0 skipped, 0 failed before the imported-post follow-up.7621 passed, 62 skipped, 0 failed at 2 iterations after WP_Error lifecycle ordering and default-code integration.
    57ddcc6ba7discovery600 passed, 0 skipped, 0 failed.7609 passed, 64 skipped, 0 failed at 2 iterations after sitemap enablement, robots.txt, provider filter, and URL-mode integration.
    8ace846176images900 passed, 0 skipped, 0 failed.7620 passed, 62 skipped, 0 failed at 2 iterations after synthetic metadata, responsive filters, and filetype helper integration.
    d744e80b6bblock-widgets600 passed, 0 skipped, 0 failed before the dispatch-control follow-up.7618 passed, 62 skipped, 0 failed at 2 iterations after legacy class matrix integration.
    0a907d9fbcmail700 passed, 0 skipped, 0 failed.7607 passed, 62 skipped, 0 failed at 2 iterations after reusable PHPMailer cleanup/reset integration.
    f267c4f0b2navigation800 passed, 0 skipped, 0 failed.7600 passed, 63 skipped, 0 failed at 2 iterations after fallback, allowlist, and filter-pipeline integration.
    5e6efd5c91l10n1300 passed, 0 skipped, 0 failed.7597 passed, 62 skipped, 0 failed at 2 iterations after locale, path, script translation, and nooped-plural integration.
    93b5eda0b5cron8400 passed, 0 skipped, 0 failed.7593 passed, 62 skipped, 0 failed at 2 iterations after duplicate-window and filter short-circuit integration.
    9335eed6e6 / 7790a7504dinteractivityLatest focused runs: 1100 passed, 0 skipped, 0 failed at seeds 1 and 224 after directive syntax/order coverage and review fixes; earlier directive edge integration passed 800 checks.13602 passed, 99 skipped, 0 failed at 3 iterations after interactivity directive syntax/order coverage.
    7c46819228privacy700 passed, 0 skipped, 0 failed.8013 passed, 66 skipped, 0 failed at 2 iterations after request-key missing-request and global-post fallback fail-closed coverage plus review fixes.
    current branch / component-fuzz-core-block-listscore-block-renderLatest focused runs: seed 224 passed 9 checks, seeds 1 and 224 over 25 iterations each passed 225 checks after frontend list-style block render coverage for archives, categories, latest posts, latest comments, tag cloud, and calendar.Latest broad run: 4720 passed, 4 skipped, 0 failed at 1 iteration after frontend list-style core block coverage; earlier metadata REST meta field broad run passed 4719 with 4 skips.
    3b5c004dd9shortcodes1200 passed, 0 skipped, 0 failed.7563 passed, 62 skipped, 0 failed at 2 iterations after shortcode filter/discovery integration.
    f46c90f1efrest-object-controllers800 passed, 200 skipped, 0 failed.7555 passed, 63 skipped, 0 failed at 2 iterations after REST object controller integration.
    4e3f7a3de1options-autoload, wpdb option stub1200 passed, 0 skipped, 0 failed.7555 passed, 63 skipped, 0 failed at 2 iterations after options/autoload integration.
    8c834c4ed4rewrite, canonical-routing2200 passed, 0 skipped, 0 failed.7551 passed, 62 skipped, 0 failed at 2 iterations after the rewrite/routing integration.
    4aa97eacbdpost-types600 passed, 0 skipped, 0 failed.7551 passed, 62 skipped, 0 failed at 2 iterations after the post-types integration.
    f5f6ca038d / 7f913eb4d5 / a2b8c3ff0e / e7f99aa17e / 50de103325ksesSeed 1 passed 1100 checks, seed 224 passed 1097 checks, and seed 54261 over 5 iterations passed 5507 checks after helper contract, serialized block attribute coverage, and hook-state review fixes.9139 passed, 62 skipped, 0 failed at 2 iterations after KSES hook-state review fixes and content pagination coverage.
    34aff69e0bnetwork-media100 passed, 0 skipped, 0 failed; repro seed 616161: 2 passed.Fixed protocol-relative URL shape finding from the formatting broad run.
    287c6d72acformatting1300 passed, 0 skipped, 0 failed.Initial broad rerun found network-media URL-shape finding, fixed by 34aff69e0b.
    41342333f0 / e05923c75c / 1f63d15d7acomments89000 passed, 0 skipped, 0 failed; comments+workflow integration: 11300 passed, 0 skipped, 0 failed.7992 passed, 66 skipped, 0 failed at 2 iterations after comment permalink pagination coverage and review fixes for exact cpage matching plus post/parent count-query constraints.
    6fe5c096eaemail120981 passed, 0 skipped, 0 failed.7112 passed, 62 skipped, 0 failed at 2 iterations after the Unicode email integration.
    b1439dba84taxonomy-relationships900 passed, 0 skipped, 0 failed.6894 passed, 62 skipped, 0 failed at 2 iterations after the rich-surface wave.
    a6adbb00dcquery-loop7400 passed, 0 skipped, 0 failed.6894 passed, 62 skipped, 0 failed at 2 iterations after the rich-surface wave.
    d5c3d20572multisite1400 passed, 100 skipped, 0 failed.6894 passed, 62 skipped, 0 failed at 2 iterations after the rich-surface wave.
    fce93ba0d1rest-controllers800 passed, 200 skipped, 0 failed; repro seed 470788260: 8 passed, 2 skipped.6894 passed, 62 skipped, 0 failed at 2 iterations after the rich-surface wave.
    afe952e27cmedia-ingest900 passed, 0 skipped, 0 failed.6894 passed, 62 skipped, 0 failed at 2 iterations after the rich-surface wave.
    4cce3d5090cron8000 passed, 0 skipped, 0 failed.Previous broad rerun found rest-controllers seed 470788260; fixed by fce93ba0d1.
    95a2e1aa81user-preferences700 passed, 100 skipped, 0 failed.6720 passed, 62 skipped, 0 failed at 2 iterations.
    ff28a0beeawpdb-stubcontent-lifecycle repro: 6 passed, 0 failed.Resolved empty quoted CSV value warning in final broad run.
    424837f7e9widgets700 passed, 0 skipped, 0 failed.6720 passed, 63 skipped, 0 failed at 2 iterations before stub rerun.
    fcee053dd5html-api600 passed, 0 skipped, 0 failed.6708 passed, 63 skipped, 0 failed at 2 iterations.
    685951079asyndication500 passed, 0 skipped, 0 failed.6715 passed, 62 skipped, 0 failed at 2 iterations.
    9c8eb1f52arequest-lifecycle800 passed, 0 skipped, 0 failed.6696 passed, 62 skipped, 0 failed at 2 iterations.
    7b5eeac0e3hooks900 passed, 0 skipped, 0 failed.6706 passed, 63 skipped, 0 failed at 2 iterations.
    b257533f03mail, privacy600 passed, 0 skipped, 0 failed for mail; privacy repro passed.6690 passed, 62 skipped, 0 failed at 2 iterations.
    1ec4959311capabilities500 passed, 0 skipped, 0 failed.6673 passed, 62 skipped, 0 failed at 2 iterations.
    ba642bf12demail110184 passed, 0 skipped, 0 failed.10012 passed, 93 skipped, 0 failed at 3 iterations.
    77c3261c5aenvironment-load1500 passed, 100 skipped, 0 failed.6673 passed, 64 skipped, 0 failed at 2 iterations.
    65cb994010icons-connectors400 passed, 0 skipped, 0 failed.6656 passed, 60 skipped, 0 failed at 2 iterations.
    2132510f82script-loader-runtime1000 passed, 200 skipped, 0 failed.6646 passed, 60 skipped, 0 failed at 2 iterations.
    fb12f51f20feed-parsers800 passed, 0 skipped, 0 failed.9922 passed, 84 skipped, 0 failed at 3 iterations.
    ef7248cd3aerror-protection800 passed, 100 skipped, 0 failed.9898 passed, 84 skipped, 0 failed at 3 iterations.
    4745b98aa9utility-internals500 passed, 0 skipped, 0 failed.9874 passed, 81 skipped, 0 failed at 3 iterations.
    fd3aca30cdimage-metadata600 passed, 0 skipped, 0 failed.6572 passed, 55 skipped, 0 failed at 2 iterations.
    aca1415156bookmark-links700 passed, 0 skipped, 0 failed.6564 passed, 54 skipped, 0 failed at 2 iterations.
    e2974861e4 / 208ba72ab2 / da8fc3eb89 / 6002f40de7 / 63aadcb347rest-site-editor13 passed, 1 skipped, 0 failed after subprocess-isolated live edit-site export coverage; seed 224 passed 65 checks with 5 skips over 5 iterations.Previous broad run: 9129 passed, 62 skipped, 0 failed at 2 iterations; REST-family smoke passed 88 checks with 10 skips.
    b64a03f1ef / 8f757f9079customizer-persistence700 passed, 0 skipped, 0 failed after changeset lock/heartbeat coverage and review fixes.8027 passed, 67 skipped, 0 failed at 2 iterations after Customizer lock persistence coverage.
    bae57b7db8media-remote400 passed, 0 skipped, 0 failed.6519 passed, 51 skipped, 0 failed at 2 iterations.
    3205dbbf0dappearance-media, dashboard500 passed, 100 skipped, 0 failed before the custom-header video follow-up.6527 passed, 50 skipped, 0 failed at 2 iterations.
    021058dbc4classic-walkers, wxr-export1150 passed, 150 skipped, 0 failed across focused runs.6504 passed, 49 skipped, 0 failed at 2 iterations.
    98fc9b211etaxonomy-relationships600 passed, 0 failed.6473 passed, 45 skipped, 0 failed at 2 iterations.
    29400ff5c3admin-dashboard, site-health-debug1200 passed, 300 skipped, 0 failed across focused runs.6466 passed, 45 skipped, 0 failed at 2 iterations.
    6d88c378d2query-loop500 passed, 0 failed.6435 passed, 39 skipped, 0 failed at 2 iterations.
    +
    + +
    +
    +

    Component Dependencies

    + High-level dependencies used for planning and integration order. +
    +
    +
    + Bootstrap Core + WpBootstrap, hooks, options, cache, roles, post types, taxonomies, content parsers, connector/icon registries. +
    +
    + In-memory DB Stub + Content lifecycle, taxonomy relationships, bookmarks, WXR, REST object controllers. +
    +
    + Next Component Slice + REST stack follow-up: turn the missing-sidebar update diagnostic into a fail-closed oracle after the controller checks sidebar existence before mutating sidebars_widgets or firing rest_save_sidebar; otherwise flip the KSES reentrant allowed-html diagnostic after core isolates $pass_allowed_html/$pass_allowed_protocols, continue security hook edges, or target another lifecycle-free REST controller boundary in plugin/theme, menu-location, settings, or search route envelopes. +
    +
    + REST Stack + REST request/response/server, capabilities, schema validation, controller registries, widget/sidebar controllers, Abilities API registries. +
    +
    + Media Stack + Filesystem temp roots, HTTP short-circuits, filetype validation, metadata parsers, fake image editors, image-edit AJAX, nonce/capability gates. +
    +
    + Theme/Editor Stack + Theme supports, template fixtures, global styles, Customizer settings, nav menu/widget request components, selective refresh, style engine. +
    +
    + Identity/Security Stack + Users, roles/caps, nonces, auth cookies, email validation, KSES, privacy exports. +
    +
    +
    + +
    +
    +

    Coverage Notes

    + Known intentional boundaries remain explicit skips, not hidden passes. +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    AreaCurrent CoverageIntentional Boundaries
    Application passwordsValidated lifecycle, chunking, generic/legacy hash checks, API-request gating, site/user availability errors, matched-password constraint hooks, success/failure hook payloads, email-identifier fallback, Basic Auth validation, usage IP/time recording, concrete list-table row/template rendering, escaped hostile Last IP output, empty Last IP fallback, REST application-password collection/item/introspection route contracts, CRUD dispatch, one-time password response and stored hash agreement, _fields projection, REST hook payloads, exact permission/availability errors, stale UUID introspection failures, password-reset key lifecycle, retrieve-password request validation/send/mail boundaries, direct reset-password storage composition, and role/REST/list-table/global restoration.No live HTTP Basic Auth request dispatch or browser profile-screen authorization flow; direct public API calls and synthetic REST dispatch cover the authentication, persistence, and controller contracts without network or process-exit paths.
    Auth flowValidated synthetic user fixtures, authenticate/password filters, login-form rendering and escaping, sign-on cookie actions without headers, clear-cookie/logout/session lifecycles, auth-cookie validation event hooks, generated cookie scheme parsing, current-user restoration, and auth_redirect() secure/scheme/cookie/login/nocache redirect boundaries.Browser login-page rendering and real process exits remain out of scope; redirect exits are intercepted before exit with scoped filters that assert targets/statuses and then restore globals.
    Unicode emailVerified latest focused make_clickable() punycode-TLD coverage at 1248 checks, adjacent email,mail,identity smoke at 1260 checks, and broad smoke at 4729 checks with 1 skip, plus earlier UTF-8 address-model replays including REST schema email validation across Unicode/ASCII filter modes, Unicode-domain user lookups, canonical account save/update collision behavior, confusable UTF-8 local-part lookup/comment/password-reset/search boundaries, password-reset recipient preservation, UTF-8 local-part PHPMailer handoff, machine/readable address views and round trips, mixed-script combining local parts, reserved ACE-like and invalid xn-- domain rejection, malformed UTF-8 warning capture, and make_clickable() mailto rendering boundaries.No real PHPMailer delivery, browser UI validation, or full REST request dispatch; direct validation, sanitization, REST schema helpers, structural views, user lookup behavior, pre-send password-reset routing, intercepted PHPMailer composition, and direct mailto rendering only. Direct punycode alias lookup miss is recorded as an explicit current-core boundary; uppercase ACE-prefix rejection is covered as a strict current-model assumption.
    Admin/browser flowsCovered through direct helpers, list tables, dashboard, user preferences, AJAX helpers.Full browser page dispatch, redirects, and process exits are skipped.
    Remote/network pathsHTTP request/response, transfer encoding, origin/CORS, capability, and redirect helpers, community events, media remote helpers, REST block-directory, REST pattern-directory, and REST URL-details controllers use short-circuited local responses with cache/error oracles.Live network access remains out of scope.
    DB-backed behaviorTargeted in-memory SQL shapes are covered where practical.The wpdb stub is intentionally not a general SQL engine.
    +
    +
    + + diff --git a/tools/component-fuzz/lib/FuzzContext.php b/tools/component-fuzz/lib/FuzzContext.php new file mode 100644 index 0000000000000..386bc10f704b7 --- /dev/null +++ b/tools/component-fuzz/lib/FuzzContext.php @@ -0,0 +1,231 @@ +seed = $seed; + $this->surface = $surface; + $this->iteration = $iteration; + $this->prng = new Prng( $seed ); + } + + public function seed(): int { + return $this->seed; + } + + public function surface(): string { + return $this->surface; + } + + public function iteration(): int { + return $this->iteration; + } + + public function fork( string $label ): self { + return new self( Prng::mix_seed( $this->seed, $label ), $this->surface, $this->iteration ); + } + + public function int( int $min, int $max ): int { + return $this->prng->int( $min, $max ); + } + + public function bool( int $true_percent = 50 ): bool { + return $this->prng->bool( $true_percent ); + } + + public function choice( array $values ) { + return $this->prng->choice( $values ); + } + + public function weightedChoice( array $weighted_values ) { + return $this->prng->weighted_choice( $weighted_values ); + } + + public function bytes( int $min = 0, int $max = 64 ): string { + $length = $this->int( $min, $max ); + $out = ''; + for ( $i = 0; $i < $length; $i++ ) { + $out .= chr( $this->int( 0, 255 ) ); + } + return $out; + } + + public function ascii( int $min = 0, int $max = 64 ): string { + $length = $this->int( $min, $max ); + $out = ''; + for ( $i = 0; $i < $length; $i++ ) { + $out .= chr( $this->int( 32, 126 ) ); + } + return $out; + } + + public function text( int $min = 0, int $max = 64 ): string { + $pieces = array( + $this->ascii( $min, $max ), + $this->choice( array( '', 'é', '☃', 'مرحبا', '中文', "line\nbreak", "tab\tvalue" ) ), + $this->bool( 25 ) ? $this->bytes( 0, min( 12, $max ) ) : '', + ); + + return substr( implode( '', $pieces ), 0, $max ); + } + + public function identifier( int $min = 1, int $max = 16 ): string { + $first = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ_'; + $rest = $first . '0123456789-:'; + $len = $this->int( $min, $max ); + $out = $first[ $this->int( 0, strlen( $first ) - 1 ) ]; + for ( $i = 1; $i < $len; $i++ ) { + $out .= $rest[ $this->int( 0, strlen( $rest ) - 1 ) ]; + } + return $out; + } + + public function url(): string { + $scheme = $this->choice( array( 'http', 'https', 'ftp', 'mailto', 'data', 'javascript', 'file', 'irc', '' ) ); + $host = $this->choice( + array( + 'example.com', + 'localhost', + '127.0.0.1', + '[::1]', + 'xn--bcher-kva.example', + $this->identifier( 3, 12 ) . '.test', + ) + ); + $segments = array(); + $count = $this->int( 0, 3 ); + for ( $i = 0; $i < $count; $i++ ) { + $segments[] = rawurlencode( $this->text( 0, 10 ) ); + } + $path = '/' . implode( '/', $segments ); + $query = $this->bool() ? '?q=' . rawurlencode( $this->text( 0, 16 ) ) : ''; + $prefix = '' === $scheme ? '' : $scheme . ':'; + + if ( in_array( $scheme, array( 'mailto', 'javascript', 'data' ), true ) ) { + return $prefix . $this->text( 0, 48 ); + } + + return $prefix . '//' . $host . $path . $query; + } + + public function filename(): string { + $base = $this->choice( array( 'image', 'archive', 'index', '../escape', '..\\escape', 'résumé', $this->identifier( 1, 12 ) ) ); + $ext = $this->choice( array( 'jpg', 'png', 'php', 'txt', 'tar.gz', 'svg', 'webp', '', 'PhP' ) ); + $name = '' === $ext ? $base : $base . '.' . $ext; + if ( $this->bool( 20 ) ) { + $name .= chr( 0 ) . '.jpg'; + } + if ( $this->bool( 20 ) ) { + $name = str_replace( '/', '\\', $name ); + } + return $name; + } + + public function htmlFragment( int $max_depth = 3 ): string { + $tags = array( 'a', 'p', 'div', 'span', 'img', 'svg', 'math', 'script', 'style', 'template', 'button', 'form' ); + $out = ''; + $open = array(); + $count = $this->int( 1, 8 ); + for ( $i = 0; $i < $count; $i++ ) { + if ( count( $open ) > 0 && $this->bool( 25 ) ) { + $out .= ''; + continue; + } + + $tag = $this->choice( $tags ); + $out .= '<' . $tag; + $attribute_count = $this->int( 0, 4 ); + for ( $j = 0; $j < $attribute_count; $j++ ) { + $name = $this->choice( array( 'href', 'src', 'style', 'class', 'id', 'onclick', 'data-x', $this->identifier( 1, 10 ) ) ); + $value = 'href' === $name || 'src' === $name ? $this->url() : $this->text( 0, 24 ); + $out .= ' ' . $name . '="' . str_replace( '"', '"', $value ) . '"'; + } + $out .= $this->bool( 15 ) ? '/>' : '>'; + if ( ! str_ends_with( $out, '/>' ) && count( $open ) < $max_depth ) { + $open[] = $tag; + } + $out .= $this->text( 0, 32 ); + } + + while ( $open && $this->bool( 70 ) ) { + $out .= ''; + } + + return $out; + } + + public function jsonValue( int $depth = 0 ) { + if ( $depth > 3 ) { + return $this->choice( array( null, true, false, $this->int( -100, 100 ), $this->text( 0, 24 ) ) ); + } + + $type = $this->choice( array( 'null', 'bool', 'int', 'float', 'string', 'array', 'object' ) ); + if ( 'null' === $type ) { + return null; + } + if ( 'bool' === $type ) { + return $this->bool(); + } + if ( 'int' === $type ) { + return $this->int( -100000, 100000 ); + } + if ( 'float' === $type ) { + return $this->int( -100000, 100000 ) / max( 1, $this->int( 1, 1000 ) ); + } + if ( 'string' === $type ) { + return $this->text( 0, 48 ); + } + if ( 'array' === $type ) { + $out = array(); + $count = $this->int( 0, 4 ); + for ( $i = 0; $i < $count; $i++ ) { + $out[] = $this->jsonValue( $depth + 1 ); + } + return $out; + } + + $out = array(); + $count = $this->int( 0, 4 ); + for ( $i = 0; $i < $count; $i++ ) { + $out[ $this->identifier( 1, 8 ) ] = $this->jsonValue( $depth + 1 ); + } + return $out; + } + + public function result( string $invariant, bool $ok, array $data = array(), ?string $status = null ): array { + return array( + 'ok' => $ok, + 'status' => $status ?? ( $ok ? 'passed' : 'failed' ), + 'surface' => $this->surface, + 'invariant' => $invariant, + 'seed' => $this->seed, + 'iteration' => $this->iteration, + 'data' => $this->compact_data( $data ), + ); + } + + public function pass( string $invariant, array $data = array() ): array { + return $this->result( $invariant, true, $data, 'passed' ); + } + + public function fail( string $invariant, array $data = array() ): array { + return $this->result( $invariant, false, $data, 'failed' ); + } + + public function skip( string $invariant, string $reason, array $data = array() ): array { + $data['reason'] = $reason; + return $this->result( $invariant, true, $data, 'skipped' ); + } + + private function compact_data( array $data ): array { + foreach ( $data as $key => $value ) { + $data[ $key ] = preview_value( $value ); + } + return $data; + } +} diff --git a/tools/component-fuzz/lib/Prng.php b/tools/component-fuzz/lib/Prng.php new file mode 100644 index 0000000000000..866856f1514c5 --- /dev/null +++ b/tools/component-fuzz/lib/Prng.php @@ -0,0 +1,75 @@ +state = 0 === $seed ? 0x9e3779b9 : ( $seed & 0x7fffffff ); + } + + public function next(): int { + $x = $this->state; + $x ^= ( $x << 13 ) & 0x7fffffff; + $x ^= ( $x >> 17 ); + $x ^= ( $x << 5 ) & 0x7fffffff; + $this->state = $x & 0x7fffffff; + + return $this->state; + } + + public function int( int $min, int $max ): int { + if ( $max < $min ) { + throw new \InvalidArgumentException( 'Invalid PRNG range.' ); + } + + if ( $max === $min ) { + return $min; + } + + return $min + ( $this->next() % ( $max - $min + 1 ) ); + } + + public function bool( int $true_percent = 50 ): bool { + return $this->int( 1, 100 ) <= $true_percent; + } + + public function choice( array $values ) { + if ( array() === $values ) { + throw new \InvalidArgumentException( 'Cannot choose from an empty array.' ); + } + + return $values[ array_keys( $values )[ $this->int( 0, count( $values ) - 1 ) ] ]; + } + + public function weighted_choice( array $weighted_values ) { + $total = 0; + foreach ( $weighted_values as $entry ) { + $total += max( 0, (int) $entry[0] ); + } + + if ( $total <= 0 ) { + throw new \InvalidArgumentException( 'Weighted choice needs positive weight.' ); + } + + $pick = $this->int( 1, $total ); + foreach ( $weighted_values as $entry ) { + $pick -= max( 0, (int) $entry[0] ); + if ( $pick <= 0 ) { + return $entry[1]; + } + } + + return $weighted_values[ array_key_last( $weighted_values ) ][1]; + } + + public function derive( string $label ): self { + return new self( self::mix_seed( $this->state, $label ) ); + } + + public static function mix_seed( int $seed, string $label ): int { + $hash = crc32( $label ); + return ( ( $seed * 1103515245 ) ^ $hash ^ 0x45d9f3b ) & 0x7fffffff; + } +} + diff --git a/tools/component-fuzz/lib/Support.php b/tools/component-fuzz/lib/Support.php new file mode 100644 index 0000000000000..299bff9b1045c --- /dev/null +++ b/tools/component-fuzz/lib/Support.php @@ -0,0 +1,131 @@ + $limit ) { + return substr( $printable, 0, $limit ) . '...'; + } + + return $printable; + } + + if ( is_array( $value ) ) { + $json = json_encode( $value, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE ); + return false === $json ? '[array]' : preview_value( $json, $limit ); + } + + if ( is_object( $value ) ) { + return '[object ' . get_class( $value ) . ']'; + } + + return $value; +} + +function cli_options( array $argv ): array { + $options = array(); + $count = count( $argv ); + + for ( $i = 1; $i < $count; $i++ ) { + $arg = $argv[ $i ]; + if ( ! str_starts_with( $arg, '--' ) ) { + continue; + } + + $arg = substr( $arg, 2 ); + if ( str_contains( $arg, '=' ) ) { + list( $key, $value ) = explode( '=', $arg, 2 ); + $options[ $key ] = $value; + continue; + } + + $next = $argv[ $i + 1 ] ?? null; + if ( null !== $next && ! str_starts_with( $next, '--' ) ) { + $options[ $arg ] = $next; + $i++; + } else { + $options[ $arg ] = true; + } + } + + return $options; +} + +function option_string( array $options, string $key, ?string $default = null ): ?string { + if ( ! array_key_exists( $key, $options ) ) { + return $default; + } + + return (string) $options[ $key ]; +} + +function option_int( array $options, string $key, int $default ): int { + if ( ! array_key_exists( $key, $options ) ) { + return $default; + } + + return (int) $options[ $key ]; +} + +function option_bool( array $options, string $key, bool $default = false ): bool { + if ( ! array_key_exists( $key, $options ) ) { + return $default; + } + + $value = $options[ $key ]; + if ( true === $value ) { + return true; + } + + return in_array( strtolower( (string) $value ), array( '1', 'true', 'yes', 'on' ), true ); +} diff --git a/tools/component-fuzz/lib/SurfaceRunner.php b/tools/component-fuzz/lib/SurfaceRunner.php new file mode 100644 index 0000000000000..e25351e29e2ae --- /dev/null +++ b/tools/component-fuzz/lib/SurfaceRunner.php @@ -0,0 +1,311 @@ + */ + private array $surfaces; + + /** + * @param array $surfaces Map of surface names to class names. + */ + public function __construct( array $surfaces ) { + $this->surfaces = $surfaces; + } + + public function surface_names(): array { + return array_keys( $this->surfaces ); + } + + public function run( array $options ): array { + WpBootstrap::load(); + + $seed = option_int( $options, 'seed', 1 ); + $iterations = max( 1, option_int( $options, 'iterations', 25 ) ); + $output_dir = option_string( $options, 'output-dir', repo_root() . DIRECTORY_SEPARATOR . 'artifacts' . DIRECTORY_SEPARATOR . 'component-fuzz' . DIRECTORY_SEPARATOR . 'run-' . gmdate( 'Ymd\THis\Z' ) ); + $fail_fast = option_bool( $options, 'fail-fast', false ); + $surface_arg = option_string( $options, 'surface', 'all' ); + $selected = $this->select_surfaces( $surface_arg ); + + ensure_dir( $output_dir ); + $results_path = $output_dir . DIRECTORY_SEPARATOR . 'results.ndjson'; + $summary_path = $output_dir . DIRECTORY_SEPARATOR . 'summary.json'; + + $summary = array( + 'kind' => 'component-fuzz-summary', + 'createdAt' => gmdate( 'c' ), + 'seed' => $seed, + 'iterations' => $iterations, + 'surfaces' => array_keys( $selected ), + 'counts' => array( + 'passed' => 0, + 'failed' => 0, + 'skipped' => 0, + 'errored' => 0, + ), + 'failures' => array(), + ); + + foreach ( $selected as $surface => $class ) { + if ( ! class_exists( $class ) ) { + $row = $this->error_row( $seed, $surface, 0, 'surface-class-missing', "Class {$class} is not loaded." ); + append_ndjson( $results_path, $row ); + $this->record_row( $summary, $row ); + continue; + } + + for ( $i = 0; $i < $iterations; $i++ ) { + $case_seed = Prng::mix_seed( $seed + $i, $surface ); + $ctx = new FuzzContext( $case_seed, $surface, $i ); + $started = hrtime( true ); + $rows = $this->run_surface_case( $class, $ctx ); + foreach ( $rows as $row ) { + $row['durationMs'] = round( ( hrtime( true ) - $started ) / 1000000, 3 ); + append_ndjson( $results_path, $row ); + $this->record_row( $summary, $row ); + if ( $fail_fast && empty( $row['ok'] ) ) { + write_json_file( $summary_path, $summary ); + return $summary; + } + } + } + } + + write_json_file( $summary_path, $summary ); + return $summary; + } + + private function select_surfaces( string $surface_arg ): array { + if ( 'all' === $surface_arg ) { + return $this->surfaces; + } + + $selected = array(); + foreach ( array_filter( array_map( 'trim', explode( ',', $surface_arg ) ) ) as $name ) { + if ( ! isset( $this->surfaces[ $name ] ) ) { + throw new \InvalidArgumentException( "Unknown surface: {$name}" ); + } + $selected[ $name ] = $this->surfaces[ $name ]; + } + return $selected; + } + + private function run_surface_case( string $class, FuzzContext $ctx ): array { + set_error_handler( + static function ( int $severity, string $message, string $file, int $line ): bool { + if ( error_reporting() & $severity ) { + throw new \ErrorException( $message, 0, $severity, $file, $line ); + } + return false; + } + ); + + try { + $rows = $class::run( $ctx ); + } catch ( \Throwable $e ) { + $rows = array( + $this->error_row( $ctx->seed(), $ctx->surface(), $ctx->iteration(), 'throwable', $e->getMessage(), $e ), + ); + } finally { + restore_error_handler(); + } + + if ( ! is_array( $rows ) ) { + return array( + $this->error_row( $ctx->seed(), $ctx->surface(), $ctx->iteration(), 'invalid-return', 'Surface did not return an array.' ), + ); + } + + if ( $this->is_aggregate_result( $rows ) ) { + return $this->aggregate_to_rows( $rows, $ctx ); + } + + $normalized = array(); + foreach ( $rows as $row ) { + if ( ! is_array( $row ) ) { + $normalized[] = $this->error_row( $ctx->seed(), $ctx->surface(), $ctx->iteration(), 'invalid-row', 'Surface returned a non-array row.' ); + continue; + } + $normalized[] = array_merge( + array( + 'ok' => true, + 'status' => 'passed', + 'surface' => $ctx->surface(), + 'invariant' => 'unspecified', + 'seed' => $ctx->seed(), + 'iteration' => $ctx->iteration(), + 'data' => array(), + ), + $row + ); + } + + return $normalized; + } + + private function is_aggregate_result( array $result ): bool { + return isset( $result['surface'] ) + && ! array_is_list( $result ) + && ( + ( isset( $result['checks'] ) && is_array( $result['checks'] ) ) + || ( isset( $result['kind'] ) && 'component-fuzz-surface-result' === $result['kind'] ) + || ( array_key_exists( 'ok', $result ) && array_key_exists( 'failures', $result ) ) + ); + } + + private function aggregate_to_rows( array $result, FuzzContext $ctx ): array { + if ( ! isset( $result['checks'] ) || ! is_array( $result['checks'] ) ) { + return $this->compact_aggregate_to_rows( $result, $ctx ); + } + + $failures_by_check = array(); + foreach ( $result['failures'] ?? array() as $failure ) { + if ( ! is_array( $failure ) ) { + continue; + } + $check = $failure['check'] ?? 'aggregate'; + $failures_by_check[ $check ][] = $failure; + } + + $rows = array(); + foreach ( $result['checks'] as $check ) { + if ( ! is_array( $check ) ) { + $rows[] = $this->error_row( $ctx->seed(), $ctx->surface(), $ctx->iteration(), 'invalid-aggregate-check', 'Aggregate surface returned a non-array check.' ); + continue; + } + + $name = (string) ( $check['name'] ?? 'aggregate-check' ); + $ok = (bool) ( $check['ok'] ?? false ); + $failures = $failures_by_check[ $name ] ?? array(); + unset( $check['name'], $check['ok'] ); + + $rows[] = array( + 'ok' => $ok, + 'status' => $ok ? 'passed' : 'failed', + 'surface' => (string) ( $result['surface'] ?? $ctx->surface() ), + 'invariant' => $name, + 'seed' => (int) ( $result['seed'] ?? $ctx->seed() ), + 'iteration' => $ctx->iteration(), + 'data' => array( + 'check' => $check, + 'failures' => array_slice( $failures, 0, 5 ), + ), + ); + } + + foreach ( $result['skipped'] ?? array() as $skipped ) { + if ( ! is_array( $skipped ) ) { + continue; + } + $rows[] = array( + 'ok' => true, + 'status' => 'skipped', + 'surface' => (string) ( $result['surface'] ?? $ctx->surface() ), + 'invariant' => (string) ( $skipped['name'] ?? 'skipped' ), + 'seed' => (int) ( $result['seed'] ?? $ctx->seed() ), + 'iteration' => $ctx->iteration(), + 'data' => array( + 'reason' => $skipped['reason'] ?? 'Skipped by surface.', + ), + ); + } + + if ( array() === $rows ) { + $rows[] = $this->error_row( $ctx->seed(), $ctx->surface(), $ctx->iteration(), 'empty-aggregate', 'Aggregate surface did not report any checks.' ); + } + + return $rows; + } + + private function compact_aggregate_to_rows( array $result, FuzzContext $ctx ): array { + $surface = (string) ( $result['surface'] ?? $ctx->surface() ); + $seed = (int) ( $result['seed'] ?? $ctx->seed() ); + $failures = array_values( array_filter( $result['failures'] ?? array(), 'is_array' ) ); + $rows = array(); + + if ( array() === $failures ) { + $rows[] = array( + 'ok' => true, + 'status' => 'passed', + 'surface' => $surface, + 'invariant' => $surface . '.aggregate', + 'seed' => $seed, + 'iteration' => $ctx->iteration(), + 'data' => array( + 'cases' => $result['cases'] ?? ( $result['caseCount'] ?? null ), + 'checks' => $result['checks'] ?? null, + 'assertions' => $result['assertions'] ?? null, + 'apiCalls' => $result['apiCalls'] ?? null, + 'failureCount' => $result['failureCount'] ?? count( $failures ), + 'coverage' => $result['coverage'] ?? ( $result['features'] ?? array() ), + ), + ); + } else { + foreach ( $failures as $failure ) { + $rows[] = array( + 'ok' => false, + 'status' => 'failed', + 'surface' => $surface, + 'invariant' => (string) ( $failure['invariant'] ?? $surface . '.aggregate-failure' ), + 'seed' => $seed, + 'iteration' => $ctx->iteration(), + 'data' => $failure, + ); + } + } + + foreach ( array( 'skips', 'skipped' ) as $skip_key ) { + foreach ( $result[ $skip_key ] ?? array() as $name => $skip ) { + $invariant = is_array( $skip ) ? (string) ( $skip['name'] ?? $surface . '.skip' ) : ( is_string( $name ) ? $name : (string) $skip ); + $reason = is_array( $skip ) ? ( $skip['reason'] ?? $skip ) : $skip; + + $rows[] = array( + 'ok' => true, + 'status' => 'skipped', + 'surface' => $surface, + 'invariant' => $invariant, + 'seed' => $seed, + 'iteration' => $ctx->iteration(), + 'data' => array( + 'reason' => $reason, + ), + ); + } + } + + return $rows; + } + + private function error_row( int $seed, string $surface, int $iteration, string $invariant, string $message, ?\Throwable $throwable = null ): array { + $data = array( 'message' => $message ); + if ( null !== $throwable ) { + $data['throwable'] = get_class( $throwable ); + $data['file'] = $throwable->getFile(); + $data['line'] = $throwable->getLine(); + } + + return array( + 'ok' => false, + 'status' => 'errored', + 'surface' => $surface, + 'invariant' => $invariant, + 'seed' => $seed, + 'iteration' => $iteration, + 'data' => $data, + ); + } + + private function record_row( array &$summary, array $row ): void { + $status = $row['status'] ?? ( empty( $row['ok'] ) ? 'failed' : 'passed' ); + if ( 'skipped' === $status ) { + $summary['counts']['skipped']++; + } elseif ( 'errored' === $status ) { + $summary['counts']['errored']++; + $summary['failures'][] = $row; + } elseif ( empty( $row['ok'] ) ) { + $summary['counts']['failed']++; + $summary['failures'][] = $row; + } else { + $summary['counts']['passed']++; + } + } +} diff --git a/tools/component-fuzz/lib/WpBootstrap.php b/tools/component-fuzz/lib/WpBootstrap.php new file mode 100644 index 0000000000000..97dbd3e50df58 --- /dev/null +++ b/tools/component-fuzz/lib/WpBootstrap.php @@ -0,0 +1,718 @@ +component_fuzz_reset_options( + array_merge( + array( + 'home' => 'http://example.test', + 'siteurl' => 'http://example.test', + ), + $GLOBALS['wpdb']->component_fuzz_get_options() + ) + ); + } + + if ( function_exists( 'wp_cache_init' ) && ! isset( $GLOBALS['wp_object_cache'] ) ) { + wp_cache_init(); + } + + if ( ! isset( $GLOBALS['blog_id'] ) ) { + $GLOBALS['blog_id'] = 1; + } + if ( ! isset( $GLOBALS['table_prefix'] ) ) { + $GLOBALS['table_prefix'] = 'wp_'; + } + if ( ! isset( $GLOBALS['wp_plugin_paths'] ) || ! is_array( $GLOBALS['wp_plugin_paths'] ) ) { + $GLOBALS['wp_plugin_paths'] = array(); + } + if ( ! isset( $GLOBALS['_wp_switched_stack'] ) || ! is_array( $GLOBALS['_wp_switched_stack'] ) ) { + $GLOBALS['_wp_switched_stack'] = array(); + } + if ( ! isset( $GLOBALS['switched'] ) ) { + $GLOBALS['switched'] = false; + } + + if ( class_exists( 'WP_Site' ) && ! isset( $GLOBALS['current_blog'] ) ) { + $default_site = (object) array( + 'blog_id' => '1', + 'domain' => 'example.test', + 'path' => '/', + 'site_id' => '1', + 'registered' => '2026-06-22 00:00:00', + 'last_updated' => '2026-06-22 00:00:00', + 'public' => '1', + 'archived' => '0', + 'mature' => '0', + 'spam' => '0', + 'deleted' => '0', + 'lang_id' => '0', + ); + + $GLOBALS['current_blog'] = new \WP_Site( $default_site ); + + if ( function_exists( 'wp_cache_set' ) ) { + wp_cache_set( 1, $default_site, 'sites' ); + } + } + + if ( class_exists( 'WP_Network' ) && ! isset( $GLOBALS['current_site'] ) ) { + $default_network = (object) array( + 'id' => '1', + 'domain' => 'example.test', + 'path' => '/', + 'blog_id' => '1', + 'cookie_domain' => 'example.test', + 'site_name' => 'Component Fuzz Network', + ); + + $GLOBALS['current_site'] = new \WP_Network( $default_network ); + + if ( function_exists( 'wp_cache_set' ) ) { + wp_cache_set( 1, $default_network, 'networks' ); + } + } + + if ( class_exists( 'WP_Textdomain_Registry' ) && ! isset( $GLOBALS['wp_textdomain_registry'] ) ) { + $GLOBALS['wp_textdomain_registry'] = new \WP_Textdomain_Registry(); + $GLOBALS['wp_textdomain_registry']->init(); + } + + if ( class_exists( 'WP_Locale' ) && ! isset( $GLOBALS['wp_locale'] ) ) { + $GLOBALS['wp_locale'] = new \WP_Locale(); + } + + if ( class_exists( 'WP_Widget_Factory' ) && ! isset( $GLOBALS['wp_widget_factory'] ) ) { + $GLOBALS['wp_widget_factory'] = new \WP_Widget_Factory(); + } + + if ( class_exists( 'WP_AI_Client_Discovery_Strategy' ) ) { + \WP_AI_Client_Discovery_Strategy::init(); + } + + if ( class_exists( 'WordPress\AiClient\AiClient' ) ) { + if ( class_exists( 'WP_AI_Client_Cache' ) ) { + \WordPress\AiClient\AiClient::setCache( new \WP_AI_Client_Cache() ); + } + if ( class_exists( 'WP_AI_Client_Event_Dispatcher' ) ) { + \WordPress\AiClient\AiClient::setEventDispatcher( new \WP_AI_Client_Event_Dispatcher() ); + } + } + + self::$loaded = true; + } + + private static function reset_temp_content_dir( string $dir ): void { + if ( file_exists( $dir ) ) { + self::remove_temp_content_dir( $dir ); + } + + foreach ( array( $dir, $dir . '/plugins', $dir . '/mu-plugins', $dir . '/themes', $dir . '/languages' ) as $path ) { + if ( ! is_dir( $path ) && ! mkdir( $path, 0777, true ) && ! is_dir( $path ) ) { + throw new \RuntimeException( 'Could not create component fuzz content directory: ' . $path ); + } + } + } + + public static function remove_temp_content_dir( string $dir ): void { + $temp_prefix = rtrim( sys_get_temp_dir(), DIRECTORY_SEPARATOR ) . DIRECTORY_SEPARATOR . 'component-fuzz-wp-content-'; + if ( ! str_starts_with( $dir, $temp_prefix ) || ! file_exists( $dir ) ) { + return; + } + + if ( ! is_dir( $dir ) ) { + @unlink( $dir ); + return; + } + + $iterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator( $dir, \FilesystemIterator::SKIP_DOTS ), + \RecursiveIteratorIterator::CHILD_FIRST + ); + + foreach ( $iterator as $item ) { + $path = $item->getPathname(); + if ( $item->isDir() && ! $item->isLink() ) { + @rmdir( $path ); + } else { + @unlink( $path ); + } + } + + @rmdir( $dir ); + } +} diff --git a/tools/component-fuzz/lib/autoload.php b/tools/component-fuzz/lib/autoload.php new file mode 100644 index 0000000000000..53e86bf58e2f3 --- /dev/null +++ b/tools/component-fuzz/lib/autoload.php @@ -0,0 +1,11 @@ +component_fuzz_reset_options( $options ); + $this->component_fuzz_reset_content(); + } + + public function component_fuzz_reset_options( array $options = array() ) { + $this->component_fuzz_options = array(); + + foreach ( $options as $option => $entry ) { + if ( is_array( $entry ) && array_key_exists( 'option_value', $entry ) ) { + $value = $entry['option_value']; + $autoload = $entry['autoload'] ?? 'auto'; + } else { + $value = function_exists( 'maybe_serialize' ) ? maybe_serialize( $entry ) : $entry; + $autoload = 'auto'; + } + + $this->component_fuzz_options[ (string) $option ] = array( + 'option_value' => $value, + 'autoload' => (string) $autoload, + ); + } + } + + public function component_fuzz_get_options() { + return $this->component_fuzz_options; + } + + public function component_fuzz_get_queries() { + return $this->component_fuzz_queries; + } + + public function component_fuzz_get_runtime_state() { + return array( + 'last_query' => $this->last_query, + 'last_error' => $this->last_error, + 'rows_affected' => $this->rows_affected, + 'insert_id' => $this->insert_id, + 'num_rows' => $this->num_rows, + 'num_queries' => $this->num_queries, + 'queries' => $this->component_fuzz_queries, + 'last_found_rows' => $this->component_fuzz_last_found_rows, + 'next_ids' => $this->component_fuzz_next_ids, + ); + } + + public function component_fuzz_restore_runtime_state( array $state ) { + $this->last_query = (string) ( $state['last_query'] ?? '' ); + $this->last_error = (string) ( $state['last_error'] ?? '' ); + $this->rows_affected = (int) ( $state['rows_affected'] ?? 0 ); + $this->insert_id = (int) ( $state['insert_id'] ?? 0 ); + $this->num_rows = (int) ( $state['num_rows'] ?? 0 ); + $this->num_queries = (int) ( $state['num_queries'] ?? 0 ); + $this->component_fuzz_queries = is_array( $state['queries'] ?? null ) ? array_values( $state['queries'] ) : array(); + $this->component_fuzz_last_found_rows = (int) ( $state['last_found_rows'] ?? 0 ); + $this->component_fuzz_next_ids = is_array( $state['next_ids'] ?? null ) ? array_map( 'intval', $state['next_ids'] ) : $this->component_fuzz_next_ids; + } + + public function component_fuzz_reset_content() { + $this->component_fuzz_posts = array(); + $this->component_fuzz_terms = array(); + $this->component_fuzz_term_taxonomy_rows = array(); + $this->component_fuzz_term_relationship_rows = array(); + $this->component_fuzz_users = array(); + $this->component_fuzz_comments = array(); + $this->component_fuzz_links = array(); + $this->component_fuzz_signups = array(); + $this->component_fuzz_meta = array( + 'post' => array(), + 'term' => array(), + 'comment' => array(), + 'user' => array(), + ); + $this->component_fuzz_next_ids = array( + 'posts' => 1, + 'terms' => 1, + 'term_taxonomy' => 1, + 'users' => 1, + 'comments' => 1, + 'links' => 1, + 'signups' => 1, + 'post_meta' => 1, + 'term_meta' => 1, + 'comment_meta' => 1, + 'user_meta' => 1, + ); + $this->insert_id = 0; + $this->rows_affected = 0; + $this->num_rows = 0; + $this->last_error = ''; + $this->component_fuzz_last_found_rows = 0; + } + + public function component_fuzz_content_counts() { + return array( + 'posts' => count( $this->component_fuzz_posts ), + 'terms' => count( $this->component_fuzz_terms ), + 'term_taxonomy' => count( $this->component_fuzz_term_taxonomy_rows ), + 'term_relationships' => count( $this->component_fuzz_term_relationship_rows ), + 'users' => count( $this->component_fuzz_users ), + 'comments' => count( $this->component_fuzz_comments ), + 'links' => count( $this->component_fuzz_links ), + 'signups' => count( $this->component_fuzz_signups ), + 'post_meta' => count( $this->component_fuzz_meta['post'] ), + 'term_meta' => count( $this->component_fuzz_meta['term'] ), + 'comment_meta' => count( $this->component_fuzz_meta['comment'] ), + 'user_meta' => count( $this->component_fuzz_meta['user'] ), + ); + } + + public function _escape( $data ) { + if ( is_array( $data ) ) { + return array_map( array( $this, '_escape' ), $data ); + } + + return addslashes( (string) $data ); + } + + public function prepare( $query, ...$args ) { + if ( 1 === count( $args ) && is_array( $args[0] ) ) { + $args = $args[0]; + } + + $index = 0; + return preg_replace_callback( + '/%[sdFfi]/', + function ( $match ) use ( $args, &$index ) { + if ( ! array_key_exists( $index, $args ) ) { + return $match[0]; + } + + $placeholder = $match[0]; + $arg = $args[ $index++ ]; + + if ( '%d' === $placeholder ) { + return (string) (int) $arg; + } + + if ( '%f' === $placeholder || '%F' === $placeholder ) { + return (string) (float) $arg; + } + + if ( '%i' === $placeholder ) { + return preg_replace( '/[^A-Za-z0-9_$\.]/', '', (string) $arg ); + } + + return "'" . $this->_escape( $arg ) . "'"; + }, + $query + ); + } + + public function esc_like( $text ) { + return addcslashes( (string) $text, '_%\\' ); + } + + public function placeholder_escape() { + static $placeholder; + + if ( ! $placeholder ) { + $placeholder = '{component-fuzz-placeholder-escape}'; + } + + if ( function_exists( 'has_filter' ) && function_exists( 'add_filter' ) && false === has_filter( 'query', array( $this, 'remove_placeholder_escape' ) ) ) { + add_filter( 'query', array( $this, 'remove_placeholder_escape' ), 0 ); + } + + return $placeholder; + } + + public function add_placeholder_escape( $query ) { + return str_replace( '%', $this->placeholder_escape(), (string) $query ); + } + + public function remove_placeholder_escape( $query ) { + return str_replace( $this->placeholder_escape(), '%', (string) $query ); + } + + public function suppress_errors( $suppress = true ) { + $previous = $this->suppress_errors; + $this->suppress_errors = (bool) $suppress; + return $previous; + } + + public function get_var( $query = null, $x = 0, $y = 0 ) { + unset( $x, $y ); + + $this->component_fuzz_record_query( $query ); + + if ( preg_match( '/\bSELECT\s+COUNT\s*\(\s*\*\s*\)/i', $this->last_query ) ) { + return $this->component_fuzz_count_for_query( $this->last_query ); + } + + if ( preg_match( '/^\s*SELECT\s+FOUND_ROWS\s*\(\s*\)/i', $this->last_query ) ) { + return $this->component_fuzz_last_found_rows; + } + + if ( preg_match( '/\bSELECT\s+MAX\(term_group\)\s+FROM\s+`?wp_terms`?/i', $this->last_query ) ) { + $groups = array_column( $this->component_fuzz_terms, 'term_group' ); + return $groups ? max( array_map( 'intval', $groups ) ) : 0; + } + + $rows = $this->component_fuzz_select_rows( $this->last_query ); + $this->num_rows = count( $rows ); + if ( array() === $rows ) { + return null; + } + + $row = reset( $rows ); + return reset( $row ); + } + + public function get_row( $query = null, $output = OBJECT, $y = 0 ) { + unset( $y ); + + $this->component_fuzz_record_query( $query ); + $rows = $this->component_fuzz_select_rows( $this->last_query ); + $this->num_rows = count( $rows ); + + if ( array() === $rows ) { + return null; + } + + return $this->component_fuzz_format_row( reset( $rows ), $output ); + } + + public function get_results( $query = null, $output = OBJECT ) { + $this->component_fuzz_record_query( $query ); + $rows = $this->component_fuzz_select_rows( $this->last_query ); + $this->num_rows = count( $rows ); + + return $this->component_fuzz_format_results( $rows, $output ); + } + + public function get_col( $query = null, $x = 0 ) { + $this->component_fuzz_record_query( $query ); + $rows = $this->component_fuzz_select_rows( $this->last_query ); + $this->num_rows = count( $rows ); + $values = array(); + + foreach ( $rows as $row ) { + $row_values = array_values( $row ); + if ( array_key_exists( $x, $row_values ) ) { + $values[] = $row_values[ $x ]; + } + } + + return $values; + } + + public function query( $query ) { + $this->component_fuzz_record_query( $query ); + $this->rows_affected = 0; + + if ( preg_match( '/\bINSERT\s+INTO\s+`?wp_options`?\b/i', $this->last_query ) ) { + return $this->component_fuzz_query_insert_option( $this->last_query ); + } + + if ( preg_match( '/\bUPDATE\s+`?wp_options`?\s+SET\s+`?autoload`?\s*=/i', $this->last_query ) ) { + return $this->component_fuzz_query_update_option_autoload( $this->last_query ); + } + + if ( preg_match( '/\bDELETE\s+FROM\s+`?wp_(post|term|comment)meta`?\s+WHERE\s+`?meta_id`?\s+IN\s*\(([^)]*)\)/i', $this->last_query, $matches ) ) { + return $this->component_fuzz_delete_meta_ids( $matches[1], $this->component_fuzz_csv_int_values( $matches[2] ) ); + } + + if ( preg_match( '/\bDELETE\s+FROM\s+`?wp_usermeta`?\s+WHERE\s+`?umeta_id`?\s+IN\s*\(([^)]*)\)/i', $this->last_query, $matches ) ) { + return $this->component_fuzz_delete_meta_ids( 'user', $this->component_fuzz_csv_int_values( $matches[1] ) ); + } + + if ( preg_match( '/\bDELETE\s+FROM\s+`?wp_term_relationships`?\s+WHERE\b/i', $this->last_query ) ) { + return $this->component_fuzz_query_delete_term_relationships( $this->last_query ); + } + + if ( preg_match( '/\bINSERT\s+INTO\s+`?wp_term_relationships`?\b/i', $this->last_query ) ) { + return $this->component_fuzz_query_insert_term_relationships( $this->last_query ); + } + + if ( preg_match( '/\bUPDATE\s+`?wp_comments`?\s+SET\s+comment_approved\s*=/i', $this->last_query ) ) { + return $this->component_fuzz_query_update_comment_statuses( $this->last_query ); + } + + if ( preg_match( '/\bUPDATE\s+`?wp_posts`?\s+SET\b/i', $this->last_query ) && preg_match( '/\bpost_parent\s*=/i', $this->last_query ) ) { + return $this->component_fuzz_query_update_attachment_parents( $this->last_query ); + } + + return 0; + } + + public function insert( $table, $data, $format = null ) { + unset( $format ); + + $this->rows_affected = 0; + $table_key = $this->component_fuzz_table_key( $table ); + $data = is_array( $data ) ? $data : array(); + + if ( 'options' === $table_key ) { + if ( ! isset( $data['option_name'] ) ) { + return false; + } + + $option = (string) $data['option_name']; + if ( isset( $this->component_fuzz_options[ $option ] ) ) { + return false; + } + + $this->component_fuzz_options[ $option ] = array( + 'option_value' => (string) ( $data['option_value'] ?? '' ), + 'autoload' => (string) ( $data['autoload'] ?? 'auto' ), + ); + $this->insert_id = $this->component_fuzz_next_id( 'options' ); + $this->rows_affected = 1; + return 1; + } + + if ( 'posts' === $table_key ) { + $id = $this->component_fuzz_row_id( $data, 'ID', 'posts' ); + $row = array_merge( $this->component_fuzz_post_defaults(), $data, array( 'ID' => $id ) ); + $this->component_fuzz_posts[ $id ] = $row; + return $this->component_fuzz_finish_insert( $id ); + } + + if ( 'terms' === $table_key ) { + $id = $this->component_fuzz_row_id( $data, 'term_id', 'terms' ); + $row = array_merge( $this->component_fuzz_term_defaults(), $data, array( 'term_id' => $id ) ); + $this->component_fuzz_terms[ $id ] = $row; + return $this->component_fuzz_finish_insert( $id ); + } + + if ( 'term_taxonomy' === $table_key ) { + $id = $this->component_fuzz_row_id( $data, 'term_taxonomy_id', 'term_taxonomy' ); + $row = array_merge( $this->component_fuzz_term_taxonomy_defaults(), $data, array( 'term_taxonomy_id' => $id ) ); + $this->component_fuzz_term_taxonomy_rows[ $id ] = $row; + return $this->component_fuzz_finish_insert( $id ); + } + + if ( 'term_relationships' === $table_key ) { + if ( ! isset( $data['object_id'], $data['term_taxonomy_id'] ) ) { + return false; + } + + $key = (int) $data['object_id'] . ':' . (int) $data['term_taxonomy_id']; + if ( isset( $this->component_fuzz_term_relationship_rows[ $key ] ) ) { + return false; + } + + $this->component_fuzz_term_relationship_rows[ $key ] = array( + 'object_id' => (int) $data['object_id'], + 'term_taxonomy_id' => (int) $data['term_taxonomy_id'], + 'term_order' => (int) ( $data['term_order'] ?? 0 ), + ); + $this->rows_affected = 1; + return 1; + } + + if ( 'users' === $table_key ) { + $id = $this->component_fuzz_row_id( $data, 'ID', 'users' ); + $row = array_merge( $this->component_fuzz_user_defaults(), $data, array( 'ID' => $id ) ); + $this->component_fuzz_users[ $id ] = $row; + return $this->component_fuzz_finish_insert( $id ); + } + + if ( 'comments' === $table_key ) { + $id = $this->component_fuzz_row_id( $data, 'comment_ID', 'comments' ); + $row = array_merge( $this->component_fuzz_comment_defaults(), $data, array( 'comment_ID' => $id ) ); + $row['comment_approved'] = (string) $row['comment_approved']; + $this->component_fuzz_comments[ $id ] = $row; + return $this->component_fuzz_finish_insert( $id ); + } + + if ( 'links' === $table_key ) { + $id = $this->component_fuzz_row_id( $data, 'link_id', 'links' ); + $row = array_merge( $this->component_fuzz_link_defaults(), $data, array( 'link_id' => $id ) ); + $this->component_fuzz_links[ $id ] = $row; + return $this->component_fuzz_finish_insert( $id ); + } + + if ( 'signups' === $table_key ) { + $id = $this->component_fuzz_row_id( $data, 'signup_id', 'signups' ); + $row = array_merge( $this->component_fuzz_signup_defaults(), $data, array( 'signup_id' => $id ) ); + $this->component_fuzz_signups[ $id ] = $row; + return $this->component_fuzz_finish_insert( $id ); + } + + $meta_type = $this->component_fuzz_meta_type_for_table_key( $table_key ); + if ( null !== $meta_type ) { + $id_column = 'user' === $meta_type ? 'umeta_id' : 'meta_id'; + $object_key = $this->component_fuzz_meta_object_column( $meta_type ); + $id = $this->component_fuzz_row_id( $data, $id_column, $meta_type . '_meta' ); + $row = array_merge( + array( + $id_column => $id, + $object_key => 0, + 'meta_key' => '', + 'meta_value' => '', + ), + $data, + array( $id_column => $id ) + ); + + $this->component_fuzz_meta[ $meta_type ][ $id ] = $row; + return $this->component_fuzz_finish_insert( $id ); + } + + return false; + } + + public function update( $table, $data, $where, $format = null, $where_format = null ) { + unset( $format, $where_format ); + + $this->rows_affected = 0; + $table_key = $this->component_fuzz_table_key( $table ); + $data = is_array( $data ) ? $data : array(); + $where = is_array( $where ) ? $where : array(); + + if ( 'options' === $table_key ) { + $option = isset( $where['option_name'] ) ? (string) $where['option_name'] : null; + + if ( null === $option || ! isset( $this->component_fuzz_options[ $option ] ) ) { + return 0; + } + + foreach ( array( 'option_value', 'autoload' ) as $column ) { + if ( array_key_exists( $column, $data ) ) { + $this->component_fuzz_options[ $option ][ $column ] = (string) $data[ $column ]; + } + } + + $this->rows_affected = 1; + return 1; + } + + if ( 'posts' === $table_key ) { + return $this->component_fuzz_update_rows( $this->component_fuzz_posts, $data, $where ); + } + + if ( 'terms' === $table_key ) { + return $this->component_fuzz_update_rows( $this->component_fuzz_terms, $data, $where ); + } + + if ( 'term_taxonomy' === $table_key ) { + return $this->component_fuzz_update_rows( $this->component_fuzz_term_taxonomy_rows, $data, $where ); + } + + if ( 'users' === $table_key ) { + return $this->component_fuzz_update_rows( $this->component_fuzz_users, $data, $where ); + } + + if ( 'comments' === $table_key ) { + return $this->component_fuzz_update_rows( $this->component_fuzz_comments, $data, $where ); + } + + if ( 'links' === $table_key ) { + return $this->component_fuzz_update_rows( $this->component_fuzz_links, $data, $where ); + } + + if ( 'signups' === $table_key ) { + return $this->component_fuzz_update_rows( $this->component_fuzz_signups, $data, $where ); + } + + $meta_type = $this->component_fuzz_meta_type_for_table_key( $table_key ); + if ( null !== $meta_type ) { + return $this->component_fuzz_update_rows( $this->component_fuzz_meta[ $meta_type ], $data, $where ); + } + + return false; + } + + public function delete( $table, $where, $where_format = null ) { + unset( $where_format ); + + $this->rows_affected = 0; + $table_key = $this->component_fuzz_table_key( $table ); + $where = is_array( $where ) ? $where : array(); + + if ( 'options' === $table_key ) { + $option = isset( $where['option_name'] ) ? (string) $where['option_name'] : null; + + if ( null === $option || ! isset( $this->component_fuzz_options[ $option ] ) ) { + return 0; + } + + unset( $this->component_fuzz_options[ $option ] ); + $this->rows_affected = 1; + return 1; + } + + if ( 'posts' === $table_key ) { + return $this->component_fuzz_delete_rows( $this->component_fuzz_posts, $where ); + } + + if ( 'terms' === $table_key ) { + return $this->component_fuzz_delete_rows( $this->component_fuzz_terms, $where ); + } + + if ( 'term_taxonomy' === $table_key ) { + return $this->component_fuzz_delete_rows( $this->component_fuzz_term_taxonomy_rows, $where ); + } + + if ( 'users' === $table_key ) { + return $this->component_fuzz_delete_rows( $this->component_fuzz_users, $where ); + } + + if ( 'comments' === $table_key ) { + return $this->component_fuzz_delete_rows( $this->component_fuzz_comments, $where ); + } + + if ( 'links' === $table_key ) { + return $this->component_fuzz_delete_rows( $this->component_fuzz_links, $where ); + } + + if ( 'signups' === $table_key ) { + return $this->component_fuzz_delete_rows( $this->component_fuzz_signups, $where ); + } + + $meta_type = $this->component_fuzz_meta_type_for_table_key( $table_key ); + if ( null !== $meta_type ) { + return $this->component_fuzz_delete_rows( $this->component_fuzz_meta[ $meta_type ], $where ); + } + + return false; + } + + public function get_blog_prefix( $blog_id = null ) { + return 'wp_'; + } + + public function get_col_charset( $table, $column ) { + unset( $table, $column ); + return 'utf8mb4'; + } + + public function get_col_length( $table, $column ) { + $lengths = array( + 'comment_author' => 245, + 'comment_author_email' => 100, + 'comment_author_url' => 200, + 'comment_content' => 65525, + 'user_login' => 60, + 'user_nicename' => 50, + 'user_email' => 100, + 'user_url' => 100, + 'post_title' => 65535, + 'post_name' => 200, + ); + + unset( $table ); + return $lengths[ $column ] ?? 255; + } + + public function strip_invalid_text_for_column( $table, $column, $value ) { + unset( $table, $column ); + return (string) $value; + } + + private function component_fuzz_query_insert_option( $query ) { + $values = $this->component_fuzz_quoted_values( $query ); + if ( count( $values ) < 3 ) { + return false; + } + + $option = (string) $values[0]; + $exists = isset( $this->component_fuzz_options[ $option ] ); + $autoload = (string) $values[2]; + + $this->component_fuzz_options[ $option ] = array( + 'option_value' => $values[1], + 'autoload' => $autoload, + ); + $this->rows_affected = $exists ? 2 : 1; + $this->insert_id++; + + return $this->rows_affected; + } + + private function component_fuzz_query_update_option_autoload( $query ) { + $autoload = $this->component_fuzz_compare_value( $query, 'autoload' ); + $names = $this->component_fuzz_in_values( $query, 'option_name' ); + + if ( null === $autoload || array() === $names ) { + return 0; + } + + foreach ( $names as $option ) { + $option = (string) $option; + if ( ! isset( $this->component_fuzz_options[ $option ] ) ) { + continue; + } + + if ( (string) $autoload === $this->component_fuzz_options[ $option ]['autoload'] ) { + continue; + } + + $this->component_fuzz_options[ $option ]['autoload'] = (string) $autoload; + ++$this->rows_affected; + } + + return $this->rows_affected; + } + + private function component_fuzz_query_update_attachment_parents( $query ) { + $parent_id = $this->component_fuzz_compare_value( $query, 'post_parent' ); + $post_type = $this->component_fuzz_compare_value( $query, 'post_type' ); + $ids = $this->component_fuzz_in_values( $query, 'ID' ); + + if ( null === $parent_id || 'attachment' !== (string) $post_type || array() === $ids ) { + return 0; + } + + $id_map = array_fill_keys( array_map( 'intval', $ids ), true ); + foreach ( $this->component_fuzz_posts as &$row ) { + if ( ! isset( $id_map[ (int) $row['ID'] ] ) || 'attachment' !== (string) $row['post_type'] ) { + continue; + } + + if ( (int) $row['post_parent'] === (int) $parent_id ) { + continue; + } + + $row['post_parent'] = (int) $parent_id; + ++$this->rows_affected; + } + unset( $row ); + + return $this->rows_affected; + } + + private function component_fuzz_record_query( $query ) { + $this->last_query = (string) $query; + $this->component_fuzz_queries[] = $this->last_query; + ++$this->num_queries; + + return $this->last_query; + } + + private function component_fuzz_finish_insert( $id ) { + $this->insert_id = (int) $id; + $this->rows_affected = 1; + return 1; + } + + private function component_fuzz_row_id( array $data, $column, $bucket ) { + if ( isset( $data[ $column ] ) && (int) $data[ $column ] > 0 ) { + $id = (int) $data[ $column ]; + $this->component_fuzz_bump_next_id( $bucket, $id ); + return $id; + } + + return $this->component_fuzz_next_id( $bucket ); + } + + private function component_fuzz_next_id( $bucket ) { + if ( ! isset( $this->component_fuzz_next_ids[ $bucket ] ) ) { + $this->component_fuzz_next_ids[ $bucket ] = 1; + } + + return $this->component_fuzz_next_ids[ $bucket ]++; + } + + private function component_fuzz_bump_next_id( $bucket, $id ) { + if ( ! isset( $this->component_fuzz_next_ids[ $bucket ] ) || $this->component_fuzz_next_ids[ $bucket ] <= $id ) { + $this->component_fuzz_next_ids[ $bucket ] = $id + 1; + } + } + + private function component_fuzz_update_rows( array &$rows, array $data, array $where ) { + foreach ( $rows as &$row ) { + if ( ! $this->component_fuzz_row_matches_where( $row, $where ) ) { + continue; + } + + foreach ( $data as $column => $value ) { + $row[ $column ] = $value; + } + ++$this->rows_affected; + } + unset( $row ); + + return $this->rows_affected; + } + + private function component_fuzz_delete_rows( array &$rows, array $where ) { + foreach ( $rows as $id => $row ) { + if ( $this->component_fuzz_row_matches_where( $row, $where ) ) { + unset( $rows[ $id ] ); + ++$this->rows_affected; + } + } + + return $this->rows_affected; + } + + private function component_fuzz_row_matches_where( array $row, array $where ) { + foreach ( $where as $column => $value ) { + if ( ! array_key_exists( $column, $row ) || (string) $row[ $column ] !== (string) $value ) { + return false; + } + } + + return true; + } + + private function component_fuzz_select_rows( $query ) { + $first_table = $this->component_fuzz_query_first_from_table( $query ); + $old_slug_rows = $this->component_fuzz_select_old_slug_redirect_rows( $query ); + if ( null !== $old_slug_rows ) { + return $old_slug_rows; + } + + if ( '' === $first_table && preg_match( '/\bAS\s+filtered_posts\b/i', (string) $query ) && preg_match( '/\bFROM\s+`?wp_posts`?\b/i', (string) $query ) ) { + return $this->component_fuzz_select_posts( $query ); + } + + if ( 'wp_options' === $first_table ) { + return $this->component_fuzz_select_options( $query ); + } + + if ( 'wp_posts' === $first_table ) { + return $this->component_fuzz_select_posts( $query ); + } + + if ( 'wp_users' === $first_table ) { + return $this->component_fuzz_select_users( $query ); + } + + if ( 'wp_comments' === $first_table ) { + return $this->component_fuzz_select_comments( $query ); + } + + if ( 'wp_links' === $first_table ) { + return $this->component_fuzz_select_links( $query ); + } + + if ( 'wp_signups' === $first_table ) { + return $this->component_fuzz_select_signups( $query ); + } + + if ( 'wp_term_relationships' === $first_table ) { + return $this->component_fuzz_select_term_relationships( $query ); + } + + if ( 'wp_terms' === $first_table || 'wp_term_taxonomy' === $first_table ) { + return $this->component_fuzz_select_terms( $query ); + } + + $table_key = $this->component_fuzz_table_key_from_query( $query ); + if ( null !== $this->component_fuzz_meta_type_for_table_key( $table_key ) ) { + return $this->component_fuzz_select_meta( $query, $this->component_fuzz_meta_type_for_table_key( $table_key ) ); + } + + return array(); + } + + private function component_fuzz_query_first_from_table( $query ) { + if ( ! preg_match( '/^\s*SELECT\b.*?\bFROM\s+`?([A-Za-z0-9_]+)`?\b/is', (string) $query, $matches ) ) { + return ''; + } + + return strtolower( $matches[1] ); + } + + private function component_fuzz_select_options( $query ) { + $rows = array(); + + if ( preg_match_all( '/\bautoload\s*!=\s*(\'{1,2}(?:\\\\.|[^\'\\\\])*\'{1,2}|"[^"]*"|-?\d+)\s+AND\s+`?option_name`?\s+IN\s*\(([^)]*)\)/i', (string) $query, $matches, PREG_SET_ORDER ) ) { + $seen = array(); + foreach ( $matches as $match ) { + $excluded_autoload = (string) $this->component_fuzz_unquote_sql_value( $match[1] ); + $names = array_fill_keys( $this->component_fuzz_csv_values( $match[2] ), true ); + foreach ( $this->component_fuzz_options as $option => $entry ) { + if ( isset( $names[ $option ] ) && $excluded_autoload !== (string) $entry['autoload'] && ! isset( $seen[ $option ] ) ) { + $rows[] = $this->component_fuzz_option_row( $option, $entry ); + $seen[ $option ] = true; + } + } + } + } elseif ( preg_match( '/\boption_name\s+IN\s*\(/i', $query ) ) { + $names = array_fill_keys( $this->component_fuzz_in_values( $query, 'option_name' ), true ); + foreach ( $this->component_fuzz_options as $option => $entry ) { + if ( isset( $names[ $option ] ) ) { + $rows[] = $this->component_fuzz_option_row( $option, $entry ); + } + } + } elseif ( preg_match( '/\bautoload\s+IN\s*\(/i', $query ) ) { + $autoload_values = array_fill_keys( $this->component_fuzz_in_values( $query, 'autoload' ), true ); + foreach ( $this->component_fuzz_options as $option => $entry ) { + if ( isset( $autoload_values[ $entry['autoload'] ] ) ) { + $rows[] = $this->component_fuzz_option_row( $option, $entry ); + } + } + } else { + $option = $this->component_fuzz_compare_value( $query, 'option_name' ); + if ( null !== $option && isset( $this->component_fuzz_options[ $option ] ) ) { + $rows[] = $this->component_fuzz_option_row( $option, $this->component_fuzz_options[ $option ] ); + } elseif ( null === $option ) { + foreach ( $this->component_fuzz_options as $option_name => $entry ) { + $rows[] = $this->component_fuzz_option_row( $option_name, $entry ); + } + } + } + + if ( preg_match( '/SELECT\s+autoload\b/i', $query ) ) { + return array_map( + static function ( $row ) { + return array( 'autoload' => $row['autoload'] ); + }, + $rows + ); + } + + if ( preg_match( '/SELECT\s+option_value\b/i', $query ) ) { + return array_map( + static function ( $row ) { + return array( 'option_value' => $row['option_value'] ); + }, + $rows + ); + } + + return $rows; + } + + private function component_fuzz_option_row( $option, array $entry ) { + return array( + 'option_name' => $option, + 'option_value' => $entry['option_value'], + 'autoload' => $entry['autoload'], + ); + } + + private function component_fuzz_select_posts( $query, $project = true ) { + $rows = array_values( $this->component_fuzz_posts ); + + if ( $project ) { + $readable_count_rows = $this->component_fuzz_select_readable_post_count_rows( $query, $rows ); + if ( null !== $readable_count_rows ) { + return $readable_count_rows; + } + } + + $id = $this->component_fuzz_compare_value( $query, 'ID' ); + if ( null !== $id ) { + $rows = array_filter( + $rows, + static function ( $row ) use ( $id ) { + return (int) $row['ID'] === (int) $id; + } + ); + } + + $ids = $this->component_fuzz_in_values( $query, 'ID' ); + if ( array() !== $ids ) { + $id_map = array_fill_keys( array_map( 'intval', $ids ), true ); + $rows = array_filter( + $rows, + static function ( $row ) use ( $id_map ) { + return isset( $id_map[ (int) $row['ID'] ] ); + } + ); + } + + $not_ids = $this->component_fuzz_not_in_values( $query, 'ID' ); + if ( array() !== $not_ids ) { + $not_id_map = array_fill_keys( array_map( 'intval', $not_ids ), true ); + $rows = array_filter( + $rows, + static function ( $row ) use ( $not_id_map ) { + return ! isset( $not_id_map[ (int) $row['ID'] ] ); + } + ); + } + + $id_not = $this->component_fuzz_not_compare_value( $query, 'ID' ); + if ( null !== $id_not ) { + $rows = array_filter( + $rows, + static function ( $row ) use ( $id_not ) { + return (int) $row['ID'] !== (int) $id_not; + } + ); + } + + $status_or_rows = $this->component_fuzz_filter_posts_by_status_or_branches( $query, array_values( $rows ) ); + $status_or_handled = null !== $status_or_rows; + if ( $status_or_handled ) { + $rows = $status_or_rows; + } + + $author_filter_sql = $status_or_handled ? $this->component_fuzz_top_level_author_filter_sql( $query ) : $query; + $rows = $this->component_fuzz_filter_posts_by_author_constraints( $author_filter_sql, $rows ); + + foreach ( array( 'post_name', 'post_title', 'post_type', 'post_parent', 'post_status', 'post_password' ) as $column ) { + if ( $status_or_handled && 'post_status' === $column ) { + continue; + } + + if ( 'post_password' === $column ) { + $values = $this->component_fuzz_compare_values( $query, $column ); + $conjunctive = true; + } elseif ( 'post_status' === $column ) { + $values = $this->component_fuzz_compare_values( $query, $column ); + $conjunctive = false; + } else { + $values = array_filter( + array( $this->component_fuzz_compare_value( $query, $column ) ), + static function ( $value ) { + return null !== $value; + } + ); + $conjunctive = true; + } + if ( array() === $values ) { + continue; + } + if ( ! $conjunctive ) { + $value_map = array_fill_keys( array_map( 'strval', $values ), true ); + $rows = array_filter( + $rows, + static function ( $row ) use ( $column, $value_map ) { + return isset( $value_map[ (string) $row[ $column ] ] ); + } + ); + continue; + } + foreach ( $values as $value ) { + $rows = array_filter( + $rows, + static function ( $row ) use ( $column, $value ) { + return (string) $row[ $column ] === (string) $value; + } + ); + } + } + + foreach ( array( 'post_status', 'post_mime_type' ) as $column ) { + $values = $this->component_fuzz_not_compare_values( $query, $column ); + if ( array() === $values ) { + continue; + } + + foreach ( $values as $value ) { + $rows = array_filter( + $rows, + static function ( $row ) use ( $column, $value ) { + return (string) $row[ $column ] !== (string) $value; + } + ); + } + } + + foreach ( array( 'post_name', 'post_parent', 'post_status' ) as $column ) { + if ( $status_or_handled && 'post_status' === $column ) { + continue; + } + + $values = $this->component_fuzz_in_values( $query, $column ); + if ( array() === $values ) { + continue; + } + $value_map = array_fill_keys( array_map( 'strval', $values ), true ); + $rows = array_filter( + $rows, + static function ( $row ) use ( $column, $value_map ) { + return isset( $value_map[ (string) $row[ $column ] ] ); + } + ); + } + + foreach ( array( 'post_name', 'post_parent', 'post_status' ) as $column ) { + if ( $status_or_handled && 'post_status' === $column ) { + continue; + } + + $values = $this->component_fuzz_not_in_values( $query, $column ); + if ( array() === $values ) { + continue; + } + $value_map = array_fill_keys( array_map( 'strval', $values ), true ); + $rows = array_filter( + $rows, + static function ( $row ) use ( $column, $value_map ) { + return ! isset( $value_map[ (string) $row[ $column ] ] ); + } + ); + } + + $post_types = $this->component_fuzz_in_values( $query, 'post_type' ); + if ( array() !== $post_types ) { + $type_map = array_fill_keys( $post_types, true ); + $rows = array_filter( + $rows, + static function ( $row ) use ( $type_map ) { + return isset( $type_map[ (string) $row['post_type'] ] ); + } + ); + } + + $rows = $this->component_fuzz_filter_posts_by_mime_constraints( $query, array_values( $rows ) ); + + $rows = $this->component_fuzz_filter_posts_by_search_like( $query, array_values( $rows ) ); + + $rows = $this->component_fuzz_filter_posts_by_datetime_bounds( $query, array_values( $rows ) ); + + $rows = $this->component_fuzz_sort_post_rows( $query, array_values( $rows ) ); + + if ( preg_match( '/\bSQL_CALC_FOUND_ROWS\b/i', $query ) ) { + $this->component_fuzz_last_found_rows = count( $rows ); + } + + $rows = $this->component_fuzz_apply_limit( $query, $rows ); + + if ( ! $project ) { + return array_values( $rows ); + } + + if ( preg_match( '/SELECT\s+COUNT\s*\(\s*\*\s*\)/i', $query ) ) { + return array( array( 'COUNT(*)' => count( $rows ) ) ); + } + + if ( preg_match( '/SELECT\s+post_status\s*,\s*COUNT\s*\(\s*\*\s*\)\s+AS\s+num_posts\b/i', $query ) && preg_match( '/\bGROUP\s+BY\s+post_status\b/i', $query ) ) { + return $this->component_fuzz_group_count_rows( $rows, 'post_status' ); + } + + if ( preg_match( '/SELECT\s+post_mime_type\s*,\s*COUNT\s*\(\s*\*\s*\)\s+AS\s+num_posts\b/i', $query ) && preg_match( '/\bGROUP\s+BY\s+post_mime_type\b/i', $query ) ) { + return $this->component_fuzz_group_count_rows( $rows, 'post_mime_type' ); + } + + if ( preg_match( '/SELECT\s+DISTINCT\s+YEAR\s*\(\s*(?:`?wp_posts`?\.)?`?post_date`?\s*\)\s+AS\s+year\s*,\s*MONTH\s*\(\s*(?:`?wp_posts`?\.)?`?post_date`?\s*\)\s+AS\s+month\b/i', $query ) ) { + return $this->component_fuzz_distinct_post_month_rows( $rows ); + } + + if ( preg_match( '/SELECT\s+DISTINCT\s+post_mime_type\b/i', $query ) ) { + $rows = $this->component_fuzz_distinct_rows( array_values( $rows ), array( 'post_mime_type' ) ); + return $this->component_fuzz_project_rows( $rows, array( 'post_mime_type' ) ); + } + + if ( preg_match( '/SELECT\s+DISTINCT\s+(?:`?[a-z_]+`?\.)?`?post_author`?\b/i', $query ) ) { + $rows = $this->component_fuzz_distinct_rows( array_values( $rows ), array( 'post_author' ) ); + return $this->component_fuzz_project_rows( $rows, array( 'post_author' ) ); + } + + if ( preg_match( '/SELECT\s+post_name\b/i', $query ) ) { + return $this->component_fuzz_project_rows( $rows, array( 'post_name' ) ); + } + + if ( preg_match( '/SELECT\s+post_author\b/i', $query ) ) { + return $this->component_fuzz_project_rows( $rows, array( 'post_author' ) ); + } + + if ( preg_match( '/SELECT\s+ID\s*,\s*post_name\s*,\s*post_parent\s*,\s*post_type\b/i', $query ) ) { + return $this->component_fuzz_project_rows( $rows, array( 'ID', 'post_name', 'post_parent', 'post_type' ) ); + } + + if ( preg_match( '/SELECT\s+ID\s*,\s*post_parent\b/i', $query ) ) { + return $this->component_fuzz_project_rows( $rows, array( 'ID', 'post_parent' ) ); + } + + if ( preg_match( '/SELECT\s+ID\b/i', $query ) ) { + return $this->component_fuzz_project_rows( $rows, array( 'ID' ) ); + } + + return $rows; + } + + private function component_fuzz_select_readable_post_count_rows( $query, array $rows ) { + if ( + ! preg_match( '/SELECT\s+post_status\s*,\s*COUNT\s*\(\s*\*\s*\)\s+AS\s+num_posts\b/is', $query ) + || ! preg_match( '/\bUNION\s+ALL\b/is', $query ) + || ! preg_match( '/\bAS\s+filtered_posts\b/is', $query ) + || ! preg_match( '/\bGROUP\s+BY\s+post_status\b/is', $query ) + ) { + return null; + } + + $post_types = $this->component_fuzz_compare_values( $query, 'post_type' ); + $author_id = $this->component_fuzz_compare_value( $query, 'post_author' ); + if ( array() === $post_types || null === $author_id ) { + return null; + } + + $post_type = (string) reset( $post_types ); + $filtered = array_filter( + $rows, + static function ( $row ) use ( $post_type, $author_id ) { + if ( (string) $row['post_type'] !== $post_type ) { + return false; + } + + if ( 'private' !== (string) $row['post_status'] ) { + return true; + } + + return (int) $row['post_author'] === (int) $author_id; + } + ); + + return $this->component_fuzz_group_count_rows( $filtered, 'post_status' ); + } + + private function component_fuzz_group_count_rows( array $rows, $column ) { + $counts = array(); + + foreach ( $rows as $row ) { + $value = (string) ( $row[ $column ] ?? '' ); + if ( ! isset( $counts[ $value ] ) ) { + $counts[ $value ] = 0; + } + ++$counts[ $value ]; + } + + $count_rows = array(); + foreach ( $counts as $value => $count ) { + $count_rows[] = array( + $column => $value, + 'num_posts' => $count, + ); + } + + return $count_rows; + } + + private function component_fuzz_select_old_slug_redirect_rows( $query ) { + if ( + ! preg_match( '/\bwp_postmeta\b/i', (string) $query ) + || ! preg_match( '/\bwp_posts\b/i', (string) $query ) + || ! preg_match( '/\b_wp_old_(?:slug|date)\b/', (string) $query ) + ) { + return null; + } + + $meta_keys = $this->component_fuzz_compare_values( $query, 'meta_key' ); + $has_slug = in_array( '_wp_old_slug', $meta_keys, true ); + $has_date = in_array( '_wp_old_date', $meta_keys, true ); + + if ( $has_slug && ! $has_date ) { + return $this->component_fuzz_select_old_slug_redirect_by_slug( $query ); + } + + if ( $has_date && ! $has_slug ) { + return $this->component_fuzz_select_old_slug_redirect_by_date( $query ); + } + + if ( $has_slug && $has_date ) { + return $this->component_fuzz_select_old_slug_redirect_by_slug_and_date( $query ); + } + + return null; + } + + private function component_fuzz_select_old_slug_redirect_by_slug( $query ) { + $post_type = $this->component_fuzz_compare_value( $query, 'post_type' ); + $old_slug = $this->component_fuzz_compare_value( $query, 'meta_value' ); + if ( null === $post_type || null === $old_slug ) { + return array(); + } + + $rows = array(); + foreach ( $this->component_fuzz_meta['post'] as $meta_row ) { + if ( '_wp_old_slug' !== (string) $meta_row['meta_key'] || (string) $old_slug !== (string) $meta_row['meta_value'] ) { + continue; + } + + $post_id = (int) $meta_row['post_id']; + $post = $this->component_fuzz_posts[ $post_id ] ?? null; + if ( ! is_array( $post ) || (string) $post_type !== (string) $post['post_type'] ) { + continue; + } + + if ( ! $this->component_fuzz_date_parts_match( (string) $post['post_date'], $query, 'post_date' ) ) { + continue; + } + + $rows[] = array( 'post_id' => $post_id ); + } + + return $rows; + } + + private function component_fuzz_select_old_slug_redirect_by_date( $query ) { + $post_type = $this->component_fuzz_compare_value( $query, 'post_type' ); + $post_name = $this->component_fuzz_compare_value( $query, 'post_name' ); + if ( null === $post_type || null === $post_name ) { + return array(); + } + + $rows = array(); + foreach ( $this->component_fuzz_meta['post'] as $meta_row ) { + if ( '_wp_old_date' !== (string) $meta_row['meta_key'] ) { + continue; + } + + $post_id = (int) $meta_row['post_id']; + $post = $this->component_fuzz_posts[ $post_id ] ?? null; + if ( + ! is_array( $post ) + || (string) $post_type !== (string) $post['post_type'] + || (string) $post_name !== (string) $post['post_name'] + ) { + continue; + } + + if ( ! $this->component_fuzz_date_parts_match( (string) $meta_row['meta_value'], $query, 'meta_value' ) ) { + continue; + } + + $rows[] = array( 'post_id' => $post_id ); + } + + return $rows; + } + + private function component_fuzz_select_old_slug_redirect_by_slug_and_date( $query ) { + $post_type = $this->component_fuzz_compare_value( $query, 'post_type' ); + $old_slug = $this->component_fuzz_compare_value( $query, 'meta_value' ); + if ( null === $post_type || null === $old_slug ) { + return array(); + } + + $rows = array(); + foreach ( $this->component_fuzz_posts as $post_id => $post ) { + if ( (string) $post_type !== (string) $post['post_type'] ) { + continue; + } + + if ( ! in_array( (string) $old_slug, $this->component_fuzz_post_meta_values( $post_id, '_wp_old_slug' ), true ) ) { + continue; + } + + foreach ( $this->component_fuzz_post_meta_values( $post_id, '_wp_old_date' ) as $old_date ) { + if ( $this->component_fuzz_date_parts_match( (string) $old_date, $query, 'meta_value' ) ) { + $rows[] = array( 'ID' => (int) $post_id ); + continue 2; + } + } + } + + return $rows; + } + + private function component_fuzz_date_parts_match( $datetime, $query, $column ) { + foreach ( + array( + 'YEAR' => (int) substr( (string) $datetime, 0, 4 ), + 'MONTH' => (int) substr( (string) $datetime, 5, 2 ), + 'DAYOFMONTH' => (int) substr( (string) $datetime, 8, 2 ), + 'HOUR' => (int) substr( (string) $datetime, 11, 2 ), + 'MINUTE' => (int) substr( (string) $datetime, 14, 2 ), + 'SECOND' => (int) substr( (string) $datetime, 17, 2 ), + 'DAYOFYEAR' => $this->component_fuzz_datetime_part( (string) $datetime, 'z' ) + 1, + 'DAYOFWEEK' => $this->component_fuzz_datetime_part( (string) $datetime, 'w' ) + 1, + ) as $function => $actual + ) { + if ( ! $this->component_fuzz_sql_date_part_matches( $query, $function, $column, $actual ) ) { + return false; + } + } + + if ( ! $this->component_fuzz_sql_weekday_iso_matches( $query, $column, $this->component_fuzz_datetime_part( (string) $datetime, 'N' ) ) ) { + return false; + } + + if ( ! $this->component_fuzz_sql_week_matches( $query, $column, (string) $datetime ) ) { + return false; + } + + if ( ! $this->component_fuzz_sql_date_format_matches( $query, $column, (string) $datetime ) ) { + return false; + } + + return true; + } + + private function component_fuzz_sql_date_part_matches( $query, $function, $column, $actual ) { + $expression = '\b' . preg_quote( $function, '/' ) . '\s*\(\s*(?:`?[a-z_][a-z0-9_]*`?\.)?`?' . preg_quote( $column, '/' ) . '`?\s*\)'; + return $this->component_fuzz_sql_numeric_expression_matches( $query, $expression, $actual ); + } + + private function component_fuzz_sql_weekday_iso_matches( $query, $column, $actual ) { + $expression = '\bWEEKDAY\s*\(\s*(?:`?[a-z_][a-z0-9_]*`?\.)?`?' . preg_quote( $column, '/' ) . '`?\s*\)\s*\+\s*1'; + return $this->component_fuzz_sql_numeric_expression_matches( $query, $expression, $actual ); + } + + private function component_fuzz_sql_week_matches( $query, $column, $datetime ) { + $column_regex = '(?:`?[a-z_][a-z0-9_]*`?\.)?`?' . preg_quote( $column, '/' ) . '`?'; + if ( ! $this->component_fuzz_sql_numeric_expression_matches( $query, '\bWEEK\s*\(\s*' . $column_regex . '\s*,\s*0\s*\)', $this->component_fuzz_mysql_week_zero( $datetime ) ) ) { + return false; + } + + if ( ! $this->component_fuzz_sql_numeric_expression_matches( $query, '\bWEEK\s*\(\s*' . $column_regex . '\s*,\s*1\s*\)', $this->component_fuzz_mysql_week_one( $datetime ) ) ) { + return false; + } + + for ( $start_of_week = 2; $start_of_week <= 6; $start_of_week++ ) { + $shifted_datetime = $this->component_fuzz_datetime_shift_days( $datetime, -$start_of_week ); + $expression = '\bWEEK\s*\(\s*DATE_SUB\s*\(\s*' . $column_regex . '\s*,\s*INTERVAL\s+' . $start_of_week . '\s+DAY\s*\)\s*,\s*0\s*\)'; + if ( ! $this->component_fuzz_sql_numeric_expression_matches( $query, $expression, $this->component_fuzz_mysql_week_zero( $shifted_datetime ) ) ) { + return false; + } + } + + return true; + } + + private function component_fuzz_sql_numeric_expression_matches( $query, $expression, $actual ) { + $value = '(\'(?:\\\\.|[^\'\\\\])*\'|"[^"]*"|-?\d+)'; + + if ( preg_match_all( '/' . $expression . '\s*(=|!=|<>|<=|>=|<|>)\s*' . $value . '/i', (string) $query, $matches, PREG_SET_ORDER ) ) { + foreach ( $matches as $match ) { + if ( ! $this->component_fuzz_compare_numeric( (float) $actual, (float) $this->component_fuzz_unquote_sql_value( $match[2] ), $match[1] ) ) { + return false; + } + } + } + + if ( preg_match_all( '/' . $expression . '\s+IN\s*\(([^)]*)\)/i', (string) $query, $matches, PREG_SET_ORDER ) ) { + foreach ( $matches as $match ) { + $value_map = array_fill_keys( array_map( 'intval', $this->component_fuzz_csv_values( $match[1] ) ), true ); + if ( ! isset( $value_map[ (int) $actual ] ) ) { + return false; + } + } + } + + if ( preg_match_all( '/' . $expression . '\s+NOT\s+IN\s*\(([^)]*)\)/i', (string) $query, $matches, PREG_SET_ORDER ) ) { + foreach ( $matches as $match ) { + $value_map = array_fill_keys( array_map( 'intval', $this->component_fuzz_csv_values( $match[1] ) ), true ); + if ( isset( $value_map[ (int) $actual ] ) ) { + return false; + } + } + } + + if ( preg_match_all( '/' . $expression . '\s+BETWEEN\s+' . $value . '\s+AND\s+' . $value . '/i', (string) $query, $matches, PREG_SET_ORDER ) ) { + foreach ( $matches as $match ) { + $lower = (int) $this->component_fuzz_unquote_sql_value( $match[1] ); + $upper = (int) $this->component_fuzz_unquote_sql_value( $match[2] ); + if ( (int) $actual < $lower || (int) $actual > $upper ) { + return false; + } + } + } + + if ( preg_match_all( '/' . $expression . '\s+NOT\s+BETWEEN\s+' . $value . '\s+AND\s+' . $value . '/i', (string) $query, $matches, PREG_SET_ORDER ) ) { + foreach ( $matches as $match ) { + $lower = (int) $this->component_fuzz_unquote_sql_value( $match[1] ); + $upper = (int) $this->component_fuzz_unquote_sql_value( $match[2] ); + if ( (int) $actual >= $lower && (int) $actual <= $upper ) { + return false; + } + } + } + + return true; + } + + private function component_fuzz_sql_date_format_matches( $query, $column, $datetime ) { + $column_regex = '(?:`?[a-z_][a-z0-9_]*`?\.)?`?' . preg_quote( $column, '/' ) . '`?'; + if ( + ! preg_match_all( + '/\bDATE_FORMAT\s*\(\s*' . $column_regex . '\s*,\s*(\'(?:\\\\.|[^\'\\\\])*\'|"[^"]*")\s*\)\s*(=|!=|<>|<=|>=|<|>)\s*(-?\d+(?:\.\d+)?)/i', + (string) $query, + $matches, + PREG_SET_ORDER + ) + ) { + return true; + } + + foreach ( $matches as $match ) { + $actual = $this->component_fuzz_datetime_format_float( $datetime, $this->component_fuzz_unquote_sql_value( $match[1] ) ); + if ( null === $actual || ! $this->component_fuzz_compare_numeric( $actual, (float) $match[3], $match[2] ) ) { + return false; + } + } + + return true; + } + + private function component_fuzz_datetime_format_float( $datetime, $format ) { + $hour = (int) substr( (string) $datetime, 11, 2 ); + $minute = (int) substr( (string) $datetime, 14, 2 ); + $second = (int) substr( (string) $datetime, 17, 2 ); + + switch ( (string) $format ) { + case '%H.%i': + return (float) sprintf( '%02d.%02d', $hour, $minute ); + case '%H.%i%s': + return (float) sprintf( '%02d.%02d%02d', $hour, $minute, $second ); + case '0.%i%s': + return (float) sprintf( '0.%02d%02d', $minute, $second ); + } + + return null; + } + + private function component_fuzz_compare_numeric( $actual, $expected, $operator ) { + switch ( $operator ) { + case '=': + return abs( (float) $actual - (float) $expected ) < 0.0000001; + case '!=': + case '<>': + return abs( (float) $actual - (float) $expected ) >= 0.0000001; + case '<': + return (float) $actual < (float) $expected; + case '<=': + return (float) $actual <= (float) $expected || abs( (float) $actual - (float) $expected ) < 0.0000001; + case '>': + return (float) $actual > (float) $expected; + case '>=': + return (float) $actual >= (float) $expected || abs( (float) $actual - (float) $expected ) < 0.0000001; + } + + return true; + } + + private function component_fuzz_datetime_part( $datetime, $format ) { + $timestamp = strtotime( (string) $datetime . ' UTC' ); + if ( false === $timestamp ) { + return 0; + } + + return (int) gmdate( $format, $timestamp ); + } + + private function component_fuzz_mysql_week_zero( $datetime ) { + $timestamp = strtotime( (string) $datetime . ' UTC' ); + if ( false === $timestamp ) { + return 0; + } + + $day_of_year = (int) gmdate( 'z', $timestamp ); + $weekday = (int) gmdate( 'w', $timestamp ); + + return (int) floor( ( $day_of_year + 7 - $weekday ) / 7 ); + } + + private function component_fuzz_mysql_week_one( $datetime ) { + $timestamp = strtotime( (string) $datetime . ' UTC' ); + if ( false === $timestamp ) { + return 0; + } + + $year = (int) gmdate( 'Y', $timestamp ); + $jan_1 = strtotime( $year . '-01-01 00:00:00 UTC' ); + $jan_1_weekday = (int) gmdate( 'N', $jan_1 ) - 1; + $first_week_monday = $jan_1 - $jan_1_weekday * 86400; + + if ( $jan_1_weekday >= 4 ) { + $first_week_monday += 7 * 86400; + } + + if ( $timestamp < $first_week_monday ) { + return 0; + } + + return (int) floor( ( $timestamp - $first_week_monday ) / ( 7 * 86400 ) ) + 1; + } + + private function component_fuzz_datetime_shift_days( $datetime, $days ) { + $timestamp = strtotime( (string) $datetime . ' UTC' ); + if ( false === $timestamp ) { + return (string) $datetime; + } + + return gmdate( 'Y-m-d H:i:s', strtotime( (int) $days . ' days', $timestamp ) ); + } + + private function component_fuzz_filter_posts_by_datetime_bounds( $query, array $rows ) { + return $this->component_fuzz_filter_rows_by_datetime_bounds( + $query, + $rows, + array( 'post_date', 'post_date_gmt', 'post_modified', 'post_modified_gmt' ) + ); + } + + private function component_fuzz_filter_users_by_datetime_bounds( $query, array $rows ) { + return $this->component_fuzz_filter_rows_by_datetime_bounds( $query, $rows, array( 'user_registered' ) ); + } + + private function component_fuzz_filter_comments_by_datetime_bounds( $query, array $rows ) { + return $this->component_fuzz_filter_rows_by_datetime_bounds( $query, $rows, array( 'comment_date', 'comment_date_gmt' ) ); + } + + private function component_fuzz_filter_rows_by_datetime_bounds( $query, array $rows, array $columns ) { + $where = $this->component_fuzz_where_clause( $query ); + if ( '' === $where ) { + return $rows; + } + + foreach ( $columns as $column ) { + if ( ! $this->component_fuzz_datetime_sql_has_predicate( $where, $column ) ) { + continue; + } + + $rows = array_filter( + $rows, + function ( $row ) use ( $where, $column ) { + return $this->component_fuzz_datetime_sql_matches( (string) ( $row[ $column ] ?? '' ), $where, $column ); + } + ); + } + + return array_values( $rows ); + } + + private function component_fuzz_datetime_sql_matches( $datetime, $sql, $column ) { + $sql = $this->component_fuzz_strip_outer_sql_parentheses( trim( (string) $sql ) ); + if ( '' === $sql || ! $this->component_fuzz_datetime_sql_has_predicate( $sql, $column ) ) { + return true; + } + + $or_terms = $this->component_fuzz_split_sql_top_level_terms( $sql, 'OR' ); + if ( count( $or_terms ) > 1 ) { + foreach ( $or_terms as $term ) { + if ( + $this->component_fuzz_datetime_sql_has_predicate( $term, $column ) + && $this->component_fuzz_datetime_sql_matches( $datetime, $term, $column ) + ) { + return true; + } + } + + return false; + } + + $and_terms = $this->component_fuzz_split_sql_top_level_terms( $sql, 'AND' ); + if ( count( $and_terms ) > 1 ) { + foreach ( $and_terms as $term ) { + if ( + $this->component_fuzz_datetime_sql_has_predicate( $term, $column ) + && ! $this->component_fuzz_datetime_sql_matches( $datetime, $term, $column ) + ) { + return false; + } + } + + return true; + } + + return $this->component_fuzz_datetime_bounds_match( $datetime, $sql, $column ) + && $this->component_fuzz_date_parts_match( $datetime, $sql, $column ); + } + + private function component_fuzz_datetime_sql_has_predicate( $sql, $column ) { + $column_regex = '(?:`?[a-z_][a-z0-9_]*`?\.)?`?' . preg_quote( $column, '/' ) . '`?'; + + return (bool) ( + preg_match( '/(?=|<|>)\s*(?:\'(?:\\\\.|[^\'\\\\])*\'|"[^"]*")/i', (string) $sql ) + || preg_match( '/\b(?:YEAR|MONTH|DAYOFMONTH|HOUR|MINUTE|SECOND|DAYOFYEAR|DAYOFWEEK|WEEKDAY|WEEK|DATE_FORMAT)\s*\([^)]*' . $column_regex . '/i', (string) $sql ) + ); + } + + private function component_fuzz_datetime_bounds_match( $datetime, $sql, $column ) { + if ( ! preg_match_all( '/(?=|<|>)\s*(\'(?:\\\\.|[^\'\\\\])*\'|"[^"]*")/i', (string) $sql, $matches, PREG_SET_ORDER ) ) { + return true; + } + + foreach ( $matches as $match ) { + $operator = $match[1]; + $bound = $this->component_fuzz_unquote_sql_value( $match[2] ); + $comparison = strcmp( (string) $datetime, (string) $bound ); + + switch ( $operator ) { + case '<': + if ( ! ( $comparison < 0 ) ) { + return false; + } + break; + case '<=': + if ( ! ( $comparison <= 0 ) ) { + return false; + } + break; + case '>': + if ( ! ( $comparison > 0 ) ) { + return false; + } + break; + case '>=': + if ( ! ( $comparison >= 0 ) ) { + return false; + } + break; + } + } + + return true; + } + + private function component_fuzz_strip_outer_sql_parentheses( $sql ) { + $sql = trim( (string) $sql ); + while ( strlen( $sql ) >= 2 && '(' === $sql[0] && ')' === $sql[ strlen( $sql ) - 1 ] && $this->component_fuzz_outer_parentheses_wrap_sql( $sql ) ) { + $sql = trim( substr( $sql, 1, -1 ) ); + } + + return $sql; + } + + private function component_fuzz_outer_parentheses_wrap_sql( $sql ) { + $length = strlen( (string) $sql ); + $depth = 0; + $in_string = false; + $quote = ''; + $escaped = false; + + for ( $i = 0; $i < $length; $i++ ) { + $char = $sql[ $i ]; + + if ( $in_string ) { + if ( '\\' === $char && ! $escaped ) { + $escaped = true; + continue; + } + + if ( $quote === $char && ! $escaped ) { + $in_string = false; + } + + $escaped = false; + continue; + } + + if ( "'" === $char || '"' === $char ) { + $in_string = true; + $quote = $char; + $escaped = false; + continue; + } + + if ( '(' === $char ) { + ++$depth; + } elseif ( ')' === $char ) { + --$depth; + if ( 0 === $depth && $i < $length - 1 ) { + return false; + } + } + } + + return 0 === $depth; + } + + private function component_fuzz_filter_posts_by_status_or_branches( $query, array $rows ) { + $where = $this->component_fuzz_where_clause( $query ); + if ( '' === $where || ! preg_match( '/\bOR\b/i', $where ) || ! preg_match( '/post_status/i', $where ) ) { + return null; + } + + $status_branches = array(); + foreach ( $this->component_fuzz_split_sql_or_terms( $where ) as $branch ) { + if ( ! preg_match( '/post_status/i', $branch ) ) { + continue; + } + + $statuses = array_values( + array_unique( + array_merge( + $this->component_fuzz_compare_values( $branch, 'post_status' ), + $this->component_fuzz_in_values( $branch, 'post_status' ) + ) + ) + ); + + if ( array() === $statuses ) { + continue; + } + + $status_branches[] = array( + 'author_equals' => $this->component_fuzz_compare_values( $branch, 'post_author' ), + 'author_in' => $this->component_fuzz_in_values( $branch, 'post_author' ), + 'statuses' => $statuses, + ); + } + + if ( array() === $status_branches ) { + return null; + } + + return array_filter( + $rows, + static function ( $row ) use ( $status_branches ) { + foreach ( $status_branches as $branch ) { + if ( ! in_array( (string) $row['post_status'], array_map( 'strval', $branch['statuses'] ), true ) ) { + continue; + } + + foreach ( $branch['author_equals'] as $author ) { + if ( (int) $row['post_author'] !== (int) $author ) { + continue 2; + } + } + + if ( array() !== $branch['author_in'] ) { + $author_map = array_fill_keys( array_map( 'intval', $branch['author_in'] ), true ); + if ( ! isset( $author_map[ (int) $row['post_author'] ] ) ) { + continue; + } + } + + return true; + } + + return false; + } + ); + } + + private function component_fuzz_filter_posts_by_author_constraints( $query, array $rows ) { + foreach ( $this->component_fuzz_compare_values( $query, 'post_author' ) as $author ) { + $rows = array_filter( + $rows, + static function ( $row ) use ( $author ) { + return (int) $row['post_author'] === (int) $author; + } + ); + } + + $authors = $this->component_fuzz_in_values( $query, 'post_author' ); + if ( array() !== $authors ) { + $author_map = array_fill_keys( array_map( 'intval', $authors ), true ); + $rows = array_filter( + $rows, + static function ( $row ) use ( $author_map ) { + return isset( $author_map[ (int) $row['post_author'] ] ); + } + ); + } + + $not_authors = $this->component_fuzz_not_in_values( $query, 'post_author' ); + if ( array() === $not_authors ) { + return $rows; + } + + $not_author_map = array_fill_keys( array_map( 'intval', $not_authors ), true ); + return array_filter( + $rows, + static function ( $row ) use ( $not_author_map ) { + return ! isset( $not_author_map[ (int) $row['post_author'] ] ); + } + ); + } + + private function component_fuzz_top_level_author_filter_sql( $query ) { + $where = $this->component_fuzz_where_clause( $query ); + if ( '' === $where ) { + $where = (string) $query; + } + + $terms = array(); + foreach ( $this->component_fuzz_split_sql_top_level_terms( $where, 'AND' ) as $term ) { + if ( ! preg_match( '/post_author/i', $term ) ) { + continue; + } + + if ( preg_match( '/\bOR\b/i', $term ) ) { + continue; + } + + $terms[] = $term; + } + + return implode( ' AND ', $terms ); + } + + private function component_fuzz_filter_posts_by_mime_constraints( $query, array $rows ) { + $where = $this->component_fuzz_where_clause( $query ); + if ( '' === $where || ! preg_match( '/post_mime_type/i', $where ) ) { + return $rows; + } + + $exact_values = $this->component_fuzz_compare_values( $where, 'post_mime_type' ); + $like_values = array(); + if ( preg_match_all( '/(?component_fuzz_sql_like_match( $mime_type, (string) $pattern ) ) { + return true; + } + } + + return false; + } + ); + } + + private function component_fuzz_filter_posts_by_search_like( $query, array $rows ) { + $where = $this->component_fuzz_where_clause( $query ); + if ( '' === $where ) { + return $rows; + } + + $patterns = array(); + if ( preg_match_all( '/(?component_fuzz_unquote_sql_value( $match[3] ); + if ( ! isset( $patterns[ $key ] ) ) { + $patterns[ $key ] = array( + 'operator' => strtoupper( preg_replace( '/\s+/', ' ', $match[2] ) ), + 'pattern' => $this->component_fuzz_unquote_sql_value( $match[3] ), + 'columns' => array(), + ); + } + $patterns[ $key ]['columns'][ $match[1] ] = true; + } + } + + if ( preg_match_all( '/(?component_fuzz_unquote_sql_value( $match[2] ); + if ( ! isset( $patterns[ $key ] ) ) { + $patterns[ $key ] = array( + 'operator' => strtoupper( preg_replace( '/\s+/', ' ', $match[1] ) ), + 'pattern' => $this->component_fuzz_unquote_sql_value( $match[2] ), + 'columns' => array(), + ); + } + $patterns[ $key ]['columns']['sq1.meta_value'] = true; + } + } + + if ( array() === $patterns ) { + return $rows; + } + + return array_filter( + $rows, + function ( $row ) use ( $patterns ) { + foreach ( $patterns as $pattern ) { + $matched = false; + $missing_meta = false; + foreach ( array_keys( $pattern['columns'] ) as $column ) { + $values = 'sq1.meta_value' === $column + ? $this->component_fuzz_post_meta_values( (int) $row['ID'], '_wp_attached_file' ) + : array( (string) ( $row[ $column ] ?? '' ) ); + + if ( 'sq1.meta_value' === $column && array() === $values ) { + $missing_meta = true; + } + + foreach ( $values as $value ) { + if ( $this->component_fuzz_sql_like_match( (string) $value, (string) $pattern['pattern'] ) ) { + $matched = true; + break 2; + } + } + } + + if ( 'LIKE' === $pattern['operator'] && ! $matched ) { + return false; + } + + if ( 'NOT LIKE' === $pattern['operator'] && ( $matched || $missing_meta ) ) { + return false; + } + } + + return true; + } + ); + } + + private function component_fuzz_sort_post_rows( $query, array $rows ) { + usort( + $rows, + function ( $a, $b ) use ( $query ) { + if ( preg_match( '/ORDER\s+BY\s+FIELD\s*\(\s*(?:`?wp_posts`?\.)?`?ID`?\s*,\s*([^)]+)\)/i', $query, $matches ) ) { + $ordered_ids = array_values( array_unique( array_map( 'intval', $this->component_fuzz_csv_values( $matches[1] ) ) ) ); + $positions = array_flip( $ordered_ids ); + $a_position = $positions[ (int) $a['ID'] ] ?? PHP_INT_MAX; + $b_position = $positions[ (int) $b['ID'] ] ?? PHP_INT_MAX; + + if ( $a_position !== $b_position ) { + return $a_position <=> $b_position; + } + } + + if ( preg_match( '/ORDER\s+BY\s+FIELD\s*\(\s*(?:`?wp_posts`?\.)?`?post_parent`?\s*,\s*([^)]+)\)/i', $query, $matches ) ) { + $ordered_parents = array_values( array_unique( array_map( 'intval', $this->component_fuzz_csv_values( $matches[1] ) ) ) ); + $positions = array_flip( $ordered_parents ); + $a_position = $positions[ (int) $a['post_parent'] ] ?? PHP_INT_MAX; + $b_position = $positions[ (int) $b['post_parent'] ] ?? PHP_INT_MAX; + + if ( $a_position !== $b_position ) { + return $a_position <=> $b_position; + } + } + + if ( preg_match( '/ORDER\s+BY\s+FIELD\s*\(\s*(?:`?wp_posts`?\.)?`?post_name`?\s*,\s*([^)]+)\)/i', $query, $matches ) ) { + $ordered_slugs = array_values( array_unique( array_map( 'strval', $this->component_fuzz_csv_values( $matches[1] ) ) ) ); + $positions = array_flip( $ordered_slugs ); + $a_position = $positions[ (string) $a['post_name'] ] ?? PHP_INT_MAX; + $b_position = $positions[ (string) $b['post_name'] ] ?? PHP_INT_MAX; + + if ( $a_position !== $b_position ) { + return $a_position <=> $b_position; + } + } + + if ( preg_match( '/ORDER\s+BY\s+(?:`?wp_posts`?\.)?`?post_date`?\s+DESC/i', $query ) ) { + $comparison = strcmp( (string) $b['post_date'], (string) $a['post_date'] ); + if ( 0 !== $comparison ) { + return $comparison; + } + return (int) $b['ID'] <=> (int) $a['ID']; + } + + if ( preg_match( '/ORDER\s+BY\s+(?:`?wp_posts`?\.)?`?ID`?\s+DESC/i', $query ) ) { + return (int) $b['ID'] <=> (int) $a['ID']; + } + + return (int) $a['ID'] <=> (int) $b['ID']; + } + ); + + return $rows; + } + + private function component_fuzz_select_users( $query ) { + $rows = array_values( $this->component_fuzz_users ); + $rows = $this->component_fuzz_filter_users_by_published_posts_subquery( $query, $rows ); + + foreach ( array( 'ID', 'user_login', 'user_nicename', 'user_email' ) as $column ) { + $value = $this->component_fuzz_compare_value( $query, $column ); + if ( null === $value ) { + continue; + } + + $rows = array_filter( + $rows, + static function ( $row ) use ( $column, $value ) { + if ( 'user_email' === $column ) { + return 0 === strcasecmp( (string) $row[ $column ], (string) $value ); + } + return (string) $row[ $column ] === (string) $value; + } + ); + } + + $not_login = $this->component_fuzz_not_compare_value( $query, 'user_login' ); + if ( null !== $not_login ) { + $rows = array_filter( + $rows, + static function ( $row ) use ( $not_login ) { + return (string) $row['user_login'] !== (string) $not_login; + } + ); + } + + $rows = $this->component_fuzz_filter_users_by_search_like( $query, $rows ); + $rows = $this->component_fuzz_filter_users_by_datetime_bounds( $query, array_values( $rows ) ); + + $rows = $this->component_fuzz_sort_user_rows( $query, array_values( $rows ) ); + + if ( preg_match( '/\bSQL_CALC_FOUND_ROWS\b/i', $query ) ) { + $this->component_fuzz_last_found_rows = count( $rows ); + } + + $rows = $this->component_fuzz_apply_limit( $query, $rows ); + + if ( preg_match( '/SELECT\s+(?:SQL_CALC_FOUND_ROWS\s+)?(?:`?wp_users`?\.)?`?ID`?\b/i', $query ) ) { + return $this->component_fuzz_project_rows( $rows, array( 'ID' ) ); + } + + return $rows; + } + + private function component_fuzz_select_signups( $query ) { + $rows = array_values( $this->component_fuzz_signups ); + + foreach ( array( 'signup_id', 'domain', 'path', 'user_login', 'user_email', 'activation_key' ) as $column ) { + $value = $this->component_fuzz_compare_value( $query, $column ); + if ( null === $value ) { + continue; + } + + $rows = array_filter( + $rows, + static function ( $row ) use ( $column, $value ) { + return (string) ( $row[ $column ] ?? '' ) === (string) $value; + } + ); + } + + $rows = $this->component_fuzz_apply_limit( $query, array_values( $rows ) ); + + if ( preg_match( '/SELECT\s+activation_key\b/i', $query ) ) { + return $this->component_fuzz_project_rows( $rows, array( 'activation_key' ) ); + } + + if ( preg_match( '/SELECT\s+user_login\b/i', $query ) ) { + return $this->component_fuzz_project_rows( $rows, array( 'user_login' ) ); + } + + if ( preg_match( '/SELECT\s+signup_id\b/i', $query ) ) { + return $this->component_fuzz_project_rows( $rows, array( 'signup_id' ) ); + } + + return $rows; + } + + private function component_fuzz_filter_users_by_published_posts_subquery( $query, array $rows ) { + if ( ! preg_match( '/\b(?:`?wp_users`?\.)?`?ID`?\s+IN\s*\(\s*SELECT\s+DISTINCT\s+(?:`?wp_posts`?\.)?`?post_author`?\s+FROM\s+`?wp_posts`?/is', (string) $query ) ) { + return $rows; + } + + $post_types = $this->component_fuzz_in_values( $query, 'post_type' ); + $type_map = array_fill_keys( array_map( 'strval', $post_types ), true ); + $status = $this->component_fuzz_compare_value( $query, 'post_status' ); + $status = null === $status ? 'publish' : (string) $status; + $authors = array(); + + foreach ( $this->component_fuzz_posts as $post ) { + if ( (string) $post['post_status'] !== $status ) { + continue; + } + if ( array() !== $post_types && ! isset( $type_map[ (string) $post['post_type'] ] ) ) { + continue; + } + + $authors[ (int) $post['post_author'] ] = true; + } + + return array_filter( + $rows, + static function ( $row ) use ( $authors ) { + return isset( $authors[ (int) $row['ID'] ] ); + } + ); + } + + private function component_fuzz_sort_user_rows( $query, array $rows ) { + usort( + $rows, + static function ( $a, $b ) use ( $query ) { + $column = 'ID'; + $direction = 'ASC'; + + if ( preg_match( '/ORDER\s+BY\s+(?:`?wp_users`?\.)?`?(ID|user_login|user_nicename|user_email|display_name)`?(?:\s+(ASC|DESC))?/i', (string) $query, $matches ) ) { + $column = $matches[1]; + $direction = strtoupper( $matches[2] ?? 'ASC' ); + } + + if ( 'ID' === $column ) { + $comparison = (int) $a['ID'] <=> (int) $b['ID']; + } else { + $comparison = strcasecmp( (string) ( $a[ $column ] ?? '' ), (string) ( $b[ $column ] ?? '' ) ); + if ( 0 === $comparison ) { + $comparison = (int) $a['ID'] <=> (int) $b['ID']; + } + } + + return 'DESC' === $direction ? -$comparison : $comparison; + } + ); + + return $rows; + } + + private function component_fuzz_filter_users_by_search_like( $query, array $rows ) { + if ( ! preg_match_all( '/(? $match[1], + 'pattern' => $this->component_fuzz_unquote_sql_value( $match[2] ), + ); + } + + return array_filter( + $rows, + function ( $row ) use ( $patterns ) { + foreach ( $patterns as $pattern ) { + $column = $pattern['column']; + if ( array_key_exists( $column, $row ) && $this->component_fuzz_sql_like_match( (string) $row[ $column ], (string) $pattern['pattern'] ) ) { + return true; + } + } + + return false; + } + ); + } + + private function component_fuzz_select_comments( $query ) { + $rows = array_values( $this->component_fuzz_comments ); + + foreach ( array( 'comment_ID', 'comment_post_ID', 'comment_parent', 'comment_author', 'comment_author_email', 'comment_author_url', 'comment_content', 'comment_approved', 'user_id' ) as $column ) { + $value = $this->component_fuzz_compare_value( $query, $column ); + if ( null === $value ) { + continue; + } + $rows = array_filter( + $rows, + static function ( $row ) use ( $column, $value ) { + return (string) $row[ $column ] === (string) $value; + } + ); + } + + $ids = $this->component_fuzz_in_values( $query, 'comment_ID' ); + if ( array() !== $ids ) { + $id_map = array_fill_keys( array_map( 'intval', $ids ), true ); + $rows = array_filter( + $rows, + static function ( $row ) use ( $id_map ) { + return isset( $id_map[ (int) $row['comment_ID'] ] ); + } + ); + } + + if ( preg_match( '/comment_approved\s*!=\s*([^\s)]+)/i', $query, $matches ) ) { + $not_approved = $this->component_fuzz_unquote_sql_value( $matches[1] ); + $rows = array_filter( + $rows, + static function ( $row ) use ( $not_approved ) { + return (string) $row['comment_approved'] !== (string) $not_approved; + } + ); + } + + if ( preg_match( '/comment_type\s*!=\s*([^\s)]+)/i', $query, $matches ) ) { + $not_type = $this->component_fuzz_unquote_sql_value( $matches[1] ); + $rows = array_filter( + $rows, + static function ( $row ) use ( $not_type ) { + return (string) $row['comment_type'] !== (string) $not_type; + } + ); + } + + $rows = $this->component_fuzz_filter_comments_by_datetime_bounds( $query, array_values( $rows ) ); + + usort( + $rows, + static function ( $a, $b ) use ( $query ) { + if ( preg_match( '/ORDER\s+BY\s+comment_ID\s+DESC/i', $query ) ) { + return (int) $b['comment_ID'] <=> (int) $a['comment_ID']; + } + return (int) $a['comment_ID'] <=> (int) $b['comment_ID']; + } + ); + + $rows = $this->component_fuzz_apply_limit( $query, $rows ); + + if ( preg_match( '/SELECT\s+comment_ID\s*,\s*comment_approved\b/i', $query ) ) { + return $this->component_fuzz_project_rows( $rows, array( 'comment_ID', 'comment_approved' ) ); + } + + if ( preg_match( '/SELECT\s+comment_ID\s*,\s*comment_agent\b/i', $query ) ) { + return $this->component_fuzz_project_rows( $rows, array( 'comment_ID', 'comment_agent' ) ); + } + + if ( preg_match( '/SELECT\s+comment_author_url\s*,\s*comment_content\s*,\s*comment_author_IP\s*,\s*comment_type\b/i', $query ) ) { + return $this->component_fuzz_project_rows( $rows, array( 'comment_author_url', 'comment_content', 'comment_author_IP', 'comment_type' ) ); + } + + if ( preg_match( '/SELECT\s+comment_ID\b/i', $query ) ) { + return $this->component_fuzz_project_rows( $rows, array( 'comment_ID' ) ); + } + + if ( preg_match( '/SELECT\s+comment_date_gmt\b/i', $query ) ) { + return $this->component_fuzz_project_rows( $rows, array( 'comment_date_gmt' ) ); + } + + return $rows; + } + + private function component_fuzz_select_links( $query ) { + $rows = array_values( $this->component_fuzz_links ); + $rows = $this->component_fuzz_maybe_join_links_to_link_categories( $query, $rows ); + + if ( preg_match( '/\bCHAR_LENGTH\s*\(\s*link_name\s*\)\s+AS\s+length\b/i', $query ) ) { + foreach ( $rows as &$row ) { + $row['length'] = function_exists( 'mb_strlen' ) ? mb_strlen( (string) $row['link_name'], 'UTF-8' ) : strlen( (string) $row['link_name'] ); + } + unset( $row ); + } + + if ( preg_match( '/\brecently_updated\b/i', $query ) || preg_match( '/\blink_updated_f\b/i', $query ) ) { + foreach ( $rows as &$row ) { + $updated_timestamp = strtotime( (string) $row['link_updated'] ); + $row['link_updated_f'] = false === $updated_timestamp ? '0' : (string) $updated_timestamp; + $row['recently_updated'] = false !== $updated_timestamp && $updated_timestamp + 120 * MINUTE_IN_SECONDS >= time() ? '1' : '0'; + } + unset( $row ); + } + + foreach ( array( 'link_id', 'link_owner', 'link_visible' ) as $column ) { + $values = $this->component_fuzz_all_compare_values( $query, $column, '=' ); + if ( array() === $values ) { + continue; + } + + $value_map = array_fill_keys( array_map( 'strval', $values ), true ); + $rows = array_filter( + $rows, + static function ( $row ) use ( $column, $value_map ) { + return isset( $value_map[ (string) $row[ $column ] ] ); + } + ); + } + + $excluded_ids = $this->component_fuzz_all_compare_values( $query, 'link_id', '<>' ); + if ( array() === $excluded_ids ) { + $excluded_ids = $this->component_fuzz_all_compare_values( $query, 'link_id', '!=' ); + } + if ( array() !== $excluded_ids ) { + $excluded_map = array_fill_keys( array_map( 'intval', $excluded_ids ), true ); + $rows = array_filter( + $rows, + static function ( $row ) use ( $excluded_map ) { + return ! isset( $excluded_map[ (int) $row['link_id'] ] ); + } + ); + } + + $term_ids = $this->component_fuzz_all_compare_values( $query, 'term_id', '=' ); + if ( array() !== $term_ids ) { + $term_map = array_fill_keys( array_map( 'intval', $term_ids ), true ); + $rows = array_filter( + $rows, + static function ( $row ) use ( $term_map ) { + return isset( $row['term_id'] ) && isset( $term_map[ (int) $row['term_id'] ] ); + } + ); + } + + $taxonomy = $this->component_fuzz_compare_value( $query, 'taxonomy' ); + if ( null !== $taxonomy ) { + $rows = array_filter( + $rows, + static function ( $row ) use ( $taxonomy ) { + return isset( $row['taxonomy'] ) && (string) $row['taxonomy'] === (string) $taxonomy; + } + ); + } + + $rows = $this->component_fuzz_filter_links_by_search_like( $query, $rows ); + $rows = $this->component_fuzz_sort_link_rows( $query, array_values( $rows ) ); + $rows = $this->component_fuzz_apply_limit( $query, $rows ); + + if ( preg_match( '/SELECT\s+link_id\b/i', $query ) ) { + return $this->component_fuzz_project_rows( $rows, array( 'link_id' ) ); + } + + return $rows; + } + + private function component_fuzz_maybe_join_links_to_link_categories( $query, array $link_rows ) { + if ( ! preg_match( '/\bwp_term_relationships\b/i', $query ) && ! preg_match( '/\bwp_term_taxonomy\b/i', $query ) ) { + return $link_rows; + } + + $joined = array(); + foreach ( $link_rows as $link_row ) { + foreach ( $this->component_fuzz_term_relationship_rows as $relationship ) { + if ( (int) $relationship['object_id'] !== (int) $link_row['link_id'] ) { + continue; + } + + $tt_id = (int) $relationship['term_taxonomy_id']; + if ( ! isset( $this->component_fuzz_term_taxonomy_rows[ $tt_id ] ) ) { + continue; + } + + $joined[] = array_merge( $link_row, $relationship, $this->component_fuzz_term_taxonomy_rows[ $tt_id ] ); + } + } + + return $joined; + } + + private function component_fuzz_filter_links_by_search_like( $query, array $rows ) { + if ( ! preg_match( '/\blink_url\s+LIKE\s+(\'(?:\\\\.|[^\'\\\\])*\')/i', $query, $matches ) ) { + return $rows; + } + + $needle = $this->component_fuzz_unquote_sql_value( $matches[1] ); + $needle = str_replace( array( '\\%', '\\_' ), array( '%', '_' ), trim( $needle, '%' ) ); + + return array_filter( + $rows, + static function ( $row ) use ( $needle ) { + foreach ( array( 'link_url', 'link_name', 'link_description' ) as $column ) { + if ( false !== stripos( (string) $row[ $column ], $needle ) ) { + return true; + } + } + return false; + } + ); + } + + private function component_fuzz_sql_like_match( $value, $pattern ) { + $regex = ''; + $length = strlen( (string) $pattern ); + for ( $i = 0; $i < $length; $i++ ) { + $char = $pattern[ $i ]; + if ( '\\' === $char && $i + 1 < $length ) { + $regex .= preg_quote( $pattern[ ++$i ], '/' ); + continue; + } + + if ( '%' === $char ) { + $regex .= '.*'; + continue; + } + + if ( '_' === $char ) { + $regex .= '.'; + continue; + } + + $regex .= preg_quote( $char, '/' ); + } + + return 1 === preg_match( '/\A' . $regex . '\z/is', (string) $value ); + } + + private function component_fuzz_sort_link_rows( $query, array $rows ) { + if ( ! preg_match( '/\bORDER\s+BY\s+(.+?)(?:\s+LIMIT\s+\d+|\z)/is', $query, $matches ) ) { + return array_values( $rows ); + } + + $order_expression = trim( $matches[1] ); + if ( preg_match( '/\brand\s*\(\s*\)/i', $order_expression ) ) { + usort( + $rows, + static function ( $a, $b ) use ( $query ) { + return strcmp( + md5( $query . ':' . (string) $a['link_id'] ), + md5( $query . ':' . (string) $b['link_id'] ) + ); + } + ); + return $rows; + } + + $order = 'ASC'; + if ( preg_match( '/\s+(ASC|DESC)\s*$/i', $order_expression, $order_match ) ) { + $order = strtoupper( $order_match[1] ); + $order_expression = trim( substr( $order_expression, 0, -strlen( $order_match[0] ) ) ); + } + + $columns = array_filter( + array_map( + array( $this, 'component_fuzz_normalize_link_orderby_column' ), + explode( ',', $order_expression ) + ) + ); + + if ( array() === $columns ) { + $columns = array( 'link_name' ); + } + + usort( + $rows, + static function ( $a, $b ) use ( $columns, $order ) { + foreach ( $columns as $column ) { + if ( in_array( $column, array( 'link_id', 'link_owner', 'link_rating', 'length' ), true ) ) { + $comparison = (int) ( $a[ $column ] ?? 0 ) <=> (int) ( $b[ $column ] ?? 0 ); + } else { + $comparison = strcasecmp( (string) ( $a[ $column ] ?? '' ), (string) ( $b[ $column ] ?? '' ) ); + } + + if ( 0 !== $comparison ) { + return 'DESC' === $order ? -$comparison : $comparison; + } + } + + $comparison = (int) $a['link_id'] <=> (int) $b['link_id']; + return 'DESC' === $order ? -$comparison : $comparison; + } + ); + + return $rows; + } + + private function component_fuzz_normalize_link_orderby_column( $column ) { + $column = trim( (string) $column, "` \t\n\r\0\x0B" ); + $column = preg_replace( '/^`?wp_links`?\./i', '', $column ); + $column = trim( (string) $column, "` \t\n\r\0\x0B" ); + + if ( 'length' === $column ) { + return 'length'; + } + + $allowed = array( + 'link_id', + 'link_name', + 'link_url', + 'link_visible', + 'link_rating', + 'link_owner', + 'link_updated', + 'link_notes', + 'link_description', + ); + + return in_array( $column, $allowed, true ) ? $column : ''; + } + + private function component_fuzz_select_term_relationships( $query ) { + $rows = array_values( $this->component_fuzz_term_relationship_rows ); + + if ( + preg_match( '/\bwp_term_taxonomy\b/i', $query ) + || preg_match( '/\btt\./i', $query ) + || preg_match( '/\bterm_id\b/i', $query ) + || preg_match( '/\btaxonomy\b/i', $query ) + ) { + $joined = array(); + foreach ( $rows as $row ) { + $tt_id = (int) $row['term_taxonomy_id']; + if ( ! isset( $this->component_fuzz_term_taxonomy_rows[ $tt_id ] ) ) { + continue; + } + $joined[] = array_merge( $this->component_fuzz_term_taxonomy_rows[ $tt_id ], $row ); + } + $rows = $joined; + } + + $rows = $this->component_fuzz_filter_relationships_by_posts( $query, $rows ); + + foreach ( array( 'object_id', 'term_taxonomy_id', 'term_id', 'taxonomy' ) as $column ) { + $value = $this->component_fuzz_compare_value( $query, $column ); + if ( null === $value ) { + continue; + } + + $rows = array_filter( + $rows, + static function ( $row ) use ( $column, $value ) { + return array_key_exists( $column, $row ) && (string) $row[ $column ] === (string) $value; + } + ); + } + + foreach ( array( 'object_id', 'term_taxonomy_id', 'term_id', 'taxonomy' ) as $column ) { + $values = $this->component_fuzz_in_values( $query, $column ); + if ( array() === $values ) { + continue; + } + + $value_map = array_fill_keys( array_map( 'strval', $values ), true ); + $rows = array_filter( + $rows, + static function ( $row ) use ( $column, $value_map ) { + return array_key_exists( $column, $row ) && isset( $value_map[ (string) $row[ $column ] ] ); + } + ); + } + + usort( + $rows, + static function ( $a, $b ) use ( $query ) { + $comparison = (int) $a['object_id'] <=> (int) $b['object_id']; + if ( 0 === $comparison ) { + $comparison = (int) $a['term_taxonomy_id'] <=> (int) $b['term_taxonomy_id']; + } + + if ( preg_match( '/ORDER\s+BY\s+(?:`?tr`?\.)?`?object_id`?\s+DESC/i', $query ) ) { + return -$comparison; + } + + return $comparison; + } + ); + + $rows = $this->component_fuzz_apply_limit( $query, $rows ); + + if ( preg_match( '/SELECT\s+(?:`?[a-z_]+`?\.)?`?object_id`?\s*,\s*(?:`?[a-z_]+`?\.)?`?term_taxonomy_id`?\b/i', $query ) ) { + return $this->component_fuzz_project_rows( $rows, array( 'object_id', 'term_taxonomy_id' ) ); + } + + if ( preg_match( '/SELECT\s+(?:DISTINCT\s+)?(?:`?[a-z_]+`?\.)?`?object_id`?\b/i', $query ) ) { + return $this->component_fuzz_project_rows( $rows, array( 'object_id' ) ); + } + + if ( preg_match( '/SELECT\s+(?:DISTINCT\s+)?(?:`?[a-z_]+`?\.)?`?term_taxonomy_id`?\b/i', $query ) ) { + return $this->component_fuzz_project_rows( $rows, array( 'term_taxonomy_id' ) ); + } + + return $rows; + } + + private function component_fuzz_filter_relationships_by_posts( $query, array $rows ) { + if ( ! preg_match( '/\bwp_posts\b/i', $query ) ) { + return $rows; + } + + $post_statuses = $this->component_fuzz_in_values( $query, 'post_status' ); + $post_types = $this->component_fuzz_in_values( $query, 'post_type' ); + $post_status = $this->component_fuzz_compare_value( $query, 'post_status' ); + $post_type = $this->component_fuzz_compare_value( $query, 'post_type' ); + $status_map = array_fill_keys( array_map( 'strval', $post_statuses ), true ); + $type_map = array_fill_keys( array_map( 'strval', $post_types ), true ); + + return array_values( + array_filter( + $rows, + function ( $row ) use ( $post_status, $post_type, $post_statuses, $post_types, $status_map, $type_map ) { + $object_id = (int) $row['object_id']; + if ( ! isset( $this->component_fuzz_posts[ $object_id ] ) ) { + return false; + } + + $post = $this->component_fuzz_posts[ $object_id ]; + if ( null !== $post_status && (string) $post['post_status'] !== (string) $post_status ) { + return false; + } + if ( null !== $post_type && (string) $post['post_type'] !== (string) $post_type ) { + return false; + } + if ( array() !== $post_statuses && ! isset( $status_map[ (string) $post['post_status'] ] ) ) { + return false; + } + if ( array() !== $post_types && ! isset( $type_map[ (string) $post['post_type'] ] ) ) { + return false; + } + + return true; + } + ) + ); + } + + private function component_fuzz_select_terms( $query ) { + $rows = $this->component_fuzz_joined_term_rows(); + + $taxonomy = $this->component_fuzz_compare_value( $query, 'taxonomy' ); + if ( null !== $taxonomy ) { + $rows = array_filter( + $rows, + static function ( $row ) use ( $taxonomy ) { + return (string) $row['taxonomy'] === (string) $taxonomy; + } + ); + } + + $taxonomies = $this->component_fuzz_in_values( $query, 'taxonomy' ); + if ( array() !== $taxonomies ) { + $taxonomy_map = array_fill_keys( $taxonomies, true ); + $rows = array_filter( + $rows, + static function ( $row ) use ( $taxonomy_map ) { + return isset( $taxonomy_map[ (string) $row['taxonomy'] ] ); + } + ); + } + + foreach ( array( 'term_id', 'term_taxonomy_id', 'name', 'slug', 'parent' ) as $column ) { + $value = $this->component_fuzz_compare_value( $query, $column ); + if ( null === $value ) { + continue; + } + $rows = array_filter( + $rows, + static function ( $row ) use ( $column, $value ) { + return (string) $row[ $column ] === (string) $value; + } + ); + } + + foreach ( array( 'term_id', 'term_taxonomy_id', 'name', 'slug' ) as $column ) { + $values = $this->component_fuzz_in_values( $query, $column ); + if ( array() === $values ) { + continue; + } + $value_map = array_fill_keys( array_map( 'strval', $values ), true ); + $rows = array_filter( + $rows, + static function ( $row ) use ( $column, $value_map ) { + return isset( $value_map[ (string) $row[ $column ] ] ); + } + ); + } + + $term_id_less_than = $this->component_fuzz_less_than_value( $query, 'term_id' ); + if ( null !== $term_id_less_than ) { + $rows = array_filter( + $rows, + static function ( $row ) use ( $term_id_less_than ) { + return (int) $row['term_id'] < (int) $term_id_less_than; + } + ); + } + + $tt_not = $this->component_fuzz_not_compare_value( $query, 'term_taxonomy_id' ); + if ( null !== $tt_not ) { + $rows = array_filter( + $rows, + static function ( $row ) use ( $tt_not ) { + return (int) $row['term_taxonomy_id'] !== (int) $tt_not; + } + ); + } + + if ( preg_match( '/tt\.count\s*>\s*0/i', $query ) ) { + $rows = array_filter( + $rows, + static function ( $row ) { + return (int) $row['count'] > 0; + } + ); + } + + $object_ids = $this->component_fuzz_in_values( $query, 'object_id' ); + if ( array() !== $object_ids ) { + $object_map = array_fill_keys( array_map( 'intval', $object_ids ), true ); + $tt_map = array(); + foreach ( $this->component_fuzz_term_relationship_rows as $relationship ) { + if ( isset( $object_map[ (int) $relationship['object_id'] ] ) ) { + $tt_map[ (int) $relationship['term_taxonomy_id'] ][] = (int) $relationship['object_id']; + } + } + + $expanded_rows = array(); + foreach ( $rows as $row ) { + $tt_id = (int) $row['term_taxonomy_id']; + if ( ! isset( $tt_map[ $tt_id ] ) ) { + continue; + } + + foreach ( array_unique( $tt_map[ $tt_id ] ) as $object_id ) { + $row['object_id'] = $object_id; + $expanded_rows[] = $row; + } + } + $rows = $expanded_rows; + } + + $rows = $this->component_fuzz_sort_term_rows( $query, array_values( $rows ) ); + if ( preg_match( '/SELECT\s+DISTINCT\s+t\.term_id\b/i', $query ) + && ! preg_match( '/SELECT\s+DISTINCT\s+t\.term_id\s*,\s*tr\.object_id\b/i', $query ) + ) { + $rows = $this->component_fuzz_distinct_rows( $rows, array( 'term_id' ) ); + } + $rows = $this->component_fuzz_apply_limit( $query, $rows ); + + if ( preg_match( '/SELECT\s+tt\.term_id\b/i', $query ) ) { + return $this->component_fuzz_project_rows( $rows, array( 'term_id' ) ); + } + + if ( preg_match( '/SELECT\s+t\.term_id\s*,\s*t\.slug\s*,\s*tt\.term_taxonomy_id\s*,\s*tt\.taxonomy/i', $query ) ) { + return $this->component_fuzz_project_rows( $rows, array( 'term_id', 'slug', 'term_taxonomy_id', 'taxonomy' ) ); + } + + if ( preg_match( '/SELECT\s+(?:DISTINCT\s+)?t\.term_id\s*,\s*tr\.object_id\b/i', $query ) ) { + return $this->component_fuzz_project_rows( + $rows, + array( + 'term_id', + 'name', + 'slug', + 'term_group', + 'term_taxonomy_id', + 'taxonomy', + 'description', + 'parent', + 'count', + 'object_id', + ) + ); + } + + if ( preg_match( '/SELECT\s+t\.term_id\b/i', $query ) || preg_match( '/SELECT\s+DISTINCT\s+t\.term_id\b/i', $query ) ) { + return $this->component_fuzz_project_rows( $rows, array( 'term_id' ) ); + } + + if ( preg_match( '/SELECT\s+tt\.term_taxonomy_id\b/i', $query ) ) { + return $this->component_fuzz_project_rows( $rows, array( 'term_taxonomy_id' ) ); + } + + return $rows; + } + + private function component_fuzz_select_meta( $query, $meta_type ) { + $rows = array_values( $this->component_fuzz_meta[ $meta_type ] ); + $id_column = 'user' === $meta_type ? 'umeta_id' : 'meta_id'; + $object_key = $this->component_fuzz_meta_object_column( $meta_type ); + $meta_key = $this->component_fuzz_compare_value( $query, 'meta_key' ); + $object_id = $this->component_fuzz_compare_value( $query, $object_key ); + $meta_id = $this->component_fuzz_compare_value( $query, $id_column ); + $meta_value = $this->component_fuzz_compare_value( $query, 'meta_value' ); + $object_ids = $this->component_fuzz_in_values( $query, $object_key ); + $meta_ids = $this->component_fuzz_in_values( $query, $id_column ); + $filter_spec = array( + 'meta_key' => $meta_key, + $object_key => $object_id, + $id_column => $meta_id, + 'meta_value' => $meta_value, + ); + + foreach ( $filter_spec as $column => $value ) { + if ( null === $value ) { + continue; + } + $rows = array_filter( + $rows, + static function ( $row ) use ( $column, $value ) { + return (string) $row[ $column ] === (string) $value; + } + ); + } + + if ( array() !== $object_ids ) { + $object_map = array_fill_keys( array_map( 'intval', $object_ids ), true ); + $rows = array_filter( + $rows, + static function ( $row ) use ( $object_key, $object_map ) { + return isset( $object_map[ (int) $row[ $object_key ] ] ); + } + ); + } + + if ( array() !== $meta_ids ) { + $meta_map = array_fill_keys( array_map( 'intval', $meta_ids ), true ); + $rows = array_filter( + $rows, + static function ( $row ) use ( $id_column, $meta_map ) { + return isset( $meta_map[ (int) $row[ $id_column ] ] ); + } + ); + } + + usort( + $rows, + static function ( $a, $b ) use ( $id_column ) { + return (int) $a[ $id_column ] <=> (int) $b[ $id_column ]; + } + ); + + if ( preg_match( '/SELECT\s+COUNT\(\s*' . preg_quote( $object_key, '/' ) . '\s*\)\s+AS\s+cnt\b/i', $query ) ) { + return array( array( 'cnt' => count( $rows ) ) ); + } + + $rows = $this->component_fuzz_apply_limit( $query, $rows ); + + if ( preg_match( '/SELECT\s+' . preg_quote( $id_column, '/' ) . '\b/i', $query ) ) { + return $this->component_fuzz_project_rows( $rows, array( $id_column ) ); + } + + if ( preg_match( '/SELECT\s+' . preg_quote( $object_key, '/' ) . '\s*,\s*meta_key\s*,\s*meta_value\b/i', $query ) ) { + return $this->component_fuzz_project_rows( $rows, array( $object_key, 'meta_key', 'meta_value' ) ); + } + + if ( preg_match( '/SELECT\s+' . preg_quote( $object_key, '/' ) . '\s*,\s*meta_value\b/i', $query ) ) { + return $this->component_fuzz_project_rows( $rows, array( $object_key, 'meta_value' ) ); + } + + if ( preg_match( '/SELECT\s+' . preg_quote( $object_key, '/' ) . '\b/i', $query ) ) { + return $this->component_fuzz_project_rows( $rows, array( $object_key ) ); + } + + return $rows; + } + + private function component_fuzz_count_for_query( $query ) { + if ( preg_match( '/\bFROM\s+`?wp_comments`?\b/i', $query ) ) { + return count( $this->component_fuzz_select_comments( $query ) ); + } + + if ( preg_match( '/\bFROM\s+`?wp_links`?\b/i', $query ) ) { + return count( $this->component_fuzz_select_links( $query ) ); + } + + if ( preg_match( '/\bFROM\s+`?wp_signups`?\b/i', $query ) ) { + return count( $this->component_fuzz_select_signups( $query ) ); + } + + if ( preg_match( '/\bFROM\s+`?wp_term_relationships`?\b/i', $query ) ) { + return count( $this->component_fuzz_select_term_relationships( $query ) ); + } + + if ( preg_match( '/\bFROM\s+`?wp_posts`?\b/i', $query ) ) { + return count( $this->component_fuzz_select_posts( $this->component_fuzz_without_limit_clause( $query ), false ) ); + } + + if ( preg_match( '/\bFROM\s+`?wp_terms`?\b/i', $query ) || preg_match( '/\bFROM\s+`?wp_term_taxonomy`?\b/i', $query ) ) { + return count( $this->component_fuzz_select_terms( $this->component_fuzz_without_limit_clause( $query ) ) ); + } + + $table_key = $this->component_fuzz_table_key_from_query( $query ); + $meta_type = $this->component_fuzz_meta_type_for_table_key( $table_key ); + if ( null !== $meta_type ) { + return count( $this->component_fuzz_select_meta( $query, $meta_type ) ); + } + + return 0; + } + + private function component_fuzz_joined_term_rows() { + $rows = array(); + + foreach ( $this->component_fuzz_term_taxonomy_rows as $tt_row ) { + $term_id = (int) $tt_row['term_id']; + if ( ! isset( $this->component_fuzz_terms[ $term_id ] ) ) { + continue; + } + $rows[] = array_merge( $this->component_fuzz_terms[ $term_id ], $tt_row ); + } + + return $rows; + } + + private function component_fuzz_sort_term_rows( $query, array $rows ) { + usort( + $rows, + static function ( $a, $b ) use ( $query ) { + if ( preg_match( '/ORDER\s+BY\s+t\.name\b/i', $query ) ) { + $comparison = strcasecmp( (string) $a['name'], (string) $b['name'] ); + } elseif ( preg_match( '/ORDER\s+BY\s+t\.slug\b/i', $query ) ) { + $comparison = strcmp( (string) $a['slug'], (string) $b['slug'] ); + } elseif ( preg_match( '/ORDER\s+BY\s+tt\.term_taxonomy_id\b/i', $query ) ) { + $comparison = (int) $a['term_taxonomy_id'] <=> (int) $b['term_taxonomy_id']; + } else { + $comparison = (int) $a['term_id'] <=> (int) $b['term_id']; + } + + if ( preg_match( '/\bDESC\b/i', $query ) ) { + return -$comparison; + } + + return $comparison; + } + ); + + return $rows; + } + + private function component_fuzz_project_rows( array $rows, array $columns ) { + $projected = array(); + + foreach ( $rows as $row ) { + $out = array(); + foreach ( $columns as $column ) { + $out[ $column ] = $row[ $column ] ?? null; + } + $projected[] = $out; + } + + return $projected; + } + + private function component_fuzz_distinct_post_month_rows( array $rows ) { + $projected = array(); + + foreach ( $rows as $row ) { + $date = (string) ( $row['post_date'] ?? '' ); + if ( ! preg_match( '/^(\d{4})-(\d{2})-\d{2}/', $date, $matches ) ) { + continue; + } + + $projected[] = array( + 'year' => (int) $matches[1], + 'month' => (int) $matches[2], + ); + } + + return $this->component_fuzz_distinct_rows( $projected, array( 'year', 'month' ) ); + } + + private function component_fuzz_distinct_rows( array $rows, array $columns ) { + $seen = array(); + $distinct = array(); + + foreach ( $rows as $row ) { + $key_values = array(); + foreach ( $columns as $column ) { + $key_values[] = $row[ $column ] ?? null; + } + $key = serialize( $key_values ); + if ( isset( $seen[ $key ] ) ) { + continue; + } + + $seen[ $key ] = true; + $distinct[] = $row; + } + + return $distinct; + } + + private function component_fuzz_apply_limit( $query, array $rows ) { + if ( preg_match( '/\bLIMIT\s+[\'"]?(\d+)[\'"]?\s*,\s*[\'"]?(\d+)[\'"]?/i', $query, $matches ) ) { + return array_slice( array_values( $rows ), (int) $matches[1], (int) $matches[2] ); + } + + if ( preg_match( '/\bLIMIT\s+[\'"]?(\d+)[\'"]?/i', $query, $matches ) ) { + return array_slice( array_values( $rows ), 0, (int) $matches[1] ); + } + + return array_values( $rows ); + } + + private function component_fuzz_without_limit_clause( $query ) { + return preg_replace( '/\s+LIMIT\s+[\'"]?\d+[\'"]?(?:\s*,\s*[\'"]?\d+[\'"]?)?\s*$/i', '', (string) $query ); + } + + private function component_fuzz_table_key( $table ) { + $table = trim( (string) $table, "` \t\n\r\0\x0B" ); + + $map = array( + $this->options => 'options', + $this->posts => 'posts', + $this->terms => 'terms', + $this->term_taxonomy => 'term_taxonomy', + $this->term_relationships => 'term_relationships', + $this->users => 'users', + $this->comments => 'comments', + $this->links => 'links', + $this->signups => 'signups', + $this->postmeta => 'post_meta', + $this->termmeta => 'term_meta', + $this->commentmeta => 'comment_meta', + $this->usermeta => 'user_meta', + ); + + if ( isset( $map[ $table ] ) ) { + return $map[ $table ]; + } + + if ( str_starts_with( $table, $this->prefix ) ) { + return str_replace( 'wp_', '', $table ); + } + + return $table; + } + + private function component_fuzz_table_key_from_query( $query ) { + if ( preg_match( '/\bFROM\s+`?(wp_[a-z_]+)`?/i', $query, $matches ) ) { + return $this->component_fuzz_table_key( $matches[1] ); + } + + return ''; + } + + private function component_fuzz_where_clause( $query ) { + $sql = (string) $query; + if ( ! preg_match( '/\bWHERE\b/i', $sql, $matches, PREG_OFFSET_CAPTURE ) ) { + return ''; + } + + $start = $matches[0][1] + strlen( $matches[0][0] ); + $end = $this->component_fuzz_sql_clause_boundary( $sql, $start, array( 'GROUP BY', 'ORDER BY', 'LIMIT' ) ); + + return substr( $sql, $start, $end - $start ); + } + + private function component_fuzz_sql_clause_boundary( $sql, $start, array $keywords ) { + $length = strlen( (string) $sql ); + $in_string = false; + $quote = ''; + $escaped = false; + + for ( $i = (int) $start; $i < $length; $i++ ) { + $char = $sql[ $i ]; + + if ( $in_string ) { + if ( '\\' === $char && ! $escaped ) { + $escaped = true; + continue; + } + + if ( $quote === $char && ! $escaped ) { + $in_string = false; + } + + $escaped = false; + continue; + } + + if ( "'" === $char || '"' === $char ) { + $in_string = true; + $quote = $char; + $escaped = false; + continue; + } + + foreach ( $keywords as $keyword ) { + if ( null !== $this->component_fuzz_sql_keyword_match_end_at( $sql, $i, $keyword ) ) { + return $i; + } + } + } + + return $length; + } + + private function component_fuzz_split_sql_or_terms( $sql ) { + $terms = array(); + $current = ''; + $length = strlen( (string) $sql ); + $in_string = false; + $quote = ''; + $escaped = false; + + for ( $i = 0; $i < $length; $i++ ) { + $char = $sql[ $i ]; + + if ( $in_string ) { + $current .= $char; + if ( '\\' === $char && ! $escaped ) { + $escaped = true; + continue; + } + + if ( $quote === $char && ! $escaped ) { + $in_string = false; + } + + $escaped = false; + continue; + } + + if ( "'" === $char || '"' === $char ) { + $in_string = true; + $quote = $char; + $escaped = false; + $current .= $char; + continue; + } + + $end = $this->component_fuzz_sql_keyword_match_end_at( $sql, $i, 'OR' ); + if ( null !== $end ) { + $terms[] = $current; + $current = ''; + $i = $end - 1; + continue; + } + + $current .= $char; + } + + $terms[] = $current; + return $terms; + } + + private function component_fuzz_split_sql_top_level_terms( $sql, $keyword ) { + $terms = array(); + $current = ''; + $length = strlen( (string) $sql ); + $depth = 0; + $in_string = false; + $quote = ''; + $escaped = false; + + for ( $i = 0; $i < $length; $i++ ) { + $char = $sql[ $i ]; + + if ( $in_string ) { + $current .= $char; + if ( '\\' === $char && ! $escaped ) { + $escaped = true; + continue; + } + + if ( $quote === $char && ! $escaped ) { + $in_string = false; + } + + $escaped = false; + continue; + } + + if ( "'" === $char || '"' === $char ) { + $in_string = true; + $quote = $char; + $escaped = false; + $current .= $char; + continue; + } + + if ( '(' === $char ) { + ++$depth; + $current .= $char; + continue; + } + + if ( ')' === $char ) { + $depth = max( 0, $depth - 1 ); + $current .= $char; + continue; + } + + $end = 0 === $depth ? $this->component_fuzz_sql_keyword_match_end_at( $sql, $i, $keyword ) : null; + if ( null !== $end ) { + if ( 'AND' === strtoupper( (string) $keyword ) && $this->component_fuzz_sql_term_awaits_between_upper_bound( $current ) ) { + $current .= substr( (string) $sql, $i, $end - $i ); + $i = $end - 1; + continue; + } + + $terms[] = $current; + $current = ''; + $i = $end - 1; + continue; + } + + $current .= $char; + } + + $terms[] = $current; + return $terms; + } + + private function component_fuzz_sql_term_awaits_between_upper_bound( $sql ) { + $value = '(?:\'(?:\\\\.|[^\'\\\\])*\'|"[^"]*"|-?\d+(?:\.\d+)?)'; + return (bool) preg_match( '/\bBETWEEN\s+' . $value . '\s*$/i', (string) $sql ); + } + + private function component_fuzz_sql_keyword_match_end_at( $sql, $offset, $keyword ) { + if ( $offset > 0 && preg_match( '/[A-Za-z0-9_]/', $sql[ $offset - 1 ] ) ) { + return null; + } + + $parts = preg_split( '/\s+/', trim( (string) $keyword ) ); + $pattern = '/\A' . implode( '\s+', array_map( 'preg_quote', $parts ) ) . '\b/i'; + if ( ! preg_match( $pattern, substr( (string) $sql, (int) $offset ), $matches ) ) { + return null; + } + + return (int) $offset + strlen( $matches[0] ); + } + + private function component_fuzz_meta_type_for_table_key( $table_key ) { + $map = array( + 'post_meta' => 'post', + 'term_meta' => 'term', + 'comment_meta' => 'comment', + 'user_meta' => 'user', + 'postmeta' => 'post', + 'termmeta' => 'term', + 'commentmeta' => 'comment', + 'usermeta' => 'user', + ); + + return $map[ $table_key ] ?? null; + } + + private function component_fuzz_meta_object_column( $meta_type ) { + return $meta_type . '_id'; + } + + private function component_fuzz_post_meta_values( $post_id, $meta_key ) { + $values = array(); + foreach ( $this->component_fuzz_meta['post'] as $row ) { + if ( (int) $row['post_id'] === (int) $post_id && (string) $row['meta_key'] === (string) $meta_key ) { + $values[] = (string) $row['meta_value']; + } + } + + return $values; + } + + private function component_fuzz_compare_value( $query, $column ) { + $values = $this->component_fuzz_compare_values( $query, $column ); + return $values[0] ?? null; + } + + private function component_fuzz_compare_values( $query, $column ) { + $column = preg_quote( $column, '/' ); + if ( ! preg_match_all( '/(?component_fuzz_not_compare_values( $query, $column ); + return $values[0] ?? null; + } + + private function component_fuzz_not_compare_values( $query, $column ) { + return array_merge( + $this->component_fuzz_all_compare_values( $query, $column, '!=' ), + $this->component_fuzz_all_compare_values( $query, $column, '<>' ) + ); + } + + private function component_fuzz_less_than_value( $query, $column ) { + $column = preg_quote( $column, '/' ); + if ( preg_match( '/(?component_fuzz_unquote_sql_value( $matches[1] ); + } + + return null; + } + + private function component_fuzz_in_values( $query, $column ) { + $column = preg_quote( $column, '/' ); + if ( ! preg_match( '/(?component_fuzz_csv_values( $matches[1] ); + } + + private function component_fuzz_not_in_values( $query, $column ) { + $column = preg_quote( $column, '/' ); + if ( ! preg_match( '/(?component_fuzz_csv_values( $matches[1] ); + } + + private function component_fuzz_all_compare_values( $query, $column, $operator ) { + $column = preg_quote( $column, '/' ); + $operator = preg_quote( $operator, '/' ); + + if ( ! preg_match_all( '/(?component_fuzz_csv_values( $csv ) ); + } + + private function component_fuzz_unquote_sql_value( $value ) { + $value = trim( (string) $value ); + if ( strlen( $value ) >= 4 && "''" === substr( $value, 0, 2 ) && "''" === substr( $value, -2 ) ) { + return stripslashes( substr( $value, 2, -2 ) ); + } + if ( strlen( $value ) >= 2 && "'" === $value[0] && "'" === $value[ strlen( $value ) - 1 ] ) { + return stripslashes( substr( $value, 1, -1 ) ); + } + if ( strlen( $value ) >= 2 && '"' === $value[0] && '"' === $value[ strlen( $value ) - 1 ] ) { + return substr( $value, 1, -1 ); + } + + return $value; + } + + private function component_fuzz_quoted_values( $query ) { + if ( ! preg_match_all( '/\'((?:\\\\.|[^\'\\\\])*)\'/s', (string) $query, $matches ) ) { + return array(); + } + + return array_map( array( $this, 'component_fuzz_unescape_addslashes_sql_string' ), $matches[1] ); + } + + private function component_fuzz_unescape_addslashes_sql_string( $value ) { + $value = (string) $value; + $out = ''; + $length = strlen( $value ); + + for ( $i = 0; $i < $length; $i++ ) { + if ( '\\' !== $value[ $i ] || $i + 1 >= $length ) { + $out .= $value[ $i ]; + continue; + } + + $next = $value[ ++$i ]; + if ( '0' === $next ) { + $out .= "\0"; + } elseif ( in_array( $next, array( '\\', "'", '"' ), true ) ) { + $out .= $next; + } else { + $out .= '\\' . $next; + } + } + + return $out; + } + + private function component_fuzz_delete_meta_ids( $meta_type, array $ids ) { + if ( ! isset( $this->component_fuzz_meta[ $meta_type ] ) ) { + return 0; + } + + $id_map = array_fill_keys( array_map( 'intval', $ids ), true ); + foreach ( $this->component_fuzz_meta[ $meta_type ] as $id => $row ) { + if ( isset( $id_map[ (int) $id ] ) ) { + unset( $this->component_fuzz_meta[ $meta_type ][ $id ] ); + ++$this->rows_affected; + } + } + + return $this->rows_affected; + } + + private function component_fuzz_query_delete_term_relationships( $query ) { + $object_id = $this->component_fuzz_compare_value( $query, 'object_id' ); + $tt_ids = $this->component_fuzz_in_values( $query, 'term_taxonomy_id' ); + $tt_map = array_fill_keys( array_map( 'intval', $tt_ids ), true ); + + foreach ( $this->component_fuzz_term_relationship_rows as $id => $relationship ) { + if ( null !== $object_id && (int) $relationship['object_id'] !== (int) $object_id ) { + continue; + } + if ( array() !== $tt_ids && ! isset( $tt_map[ (int) $relationship['term_taxonomy_id'] ] ) ) { + continue; + } + + unset( $this->component_fuzz_term_relationship_rows[ $id ] ); + ++$this->rows_affected; + } + + return $this->rows_affected; + } + + private function component_fuzz_query_insert_term_relationships( $query ) { + if ( ! preg_match( '/\bVALUES\s+(.+?)(?:\s+ON\s+DUPLICATE|\z)/is', $query, $matches ) ) { + return false; + } + + if ( ! preg_match_all( '/\(\s*\'?(\d+)\'?\s*,\s*\'?(\d+)\'?\s*,\s*\'?(\d+)\'?\s*\)/', $matches[1], $rows, PREG_SET_ORDER ) ) { + return false; + } + + foreach ( $rows as $row ) { + $key = (int) $row[1] . ':' . (int) $row[2]; + $this->component_fuzz_term_relationship_rows[ $key ] = array( + 'object_id' => (int) $row[1], + 'term_taxonomy_id' => (int) $row[2], + 'term_order' => (int) $row[3], + ); + ++$this->rows_affected; + } + + return $this->rows_affected; + } + + private function component_fuzz_query_update_comment_statuses( $query ) { + $status = $this->component_fuzz_compare_value( $query, 'comment_approved' ); + $ids = $this->component_fuzz_in_values( $query, 'comment_ID' ); + $id_map = array_fill_keys( array_map( 'intval', $ids ), true ); + + foreach ( $this->component_fuzz_comments as &$comment ) { + if ( array() !== $ids && ! isset( $id_map[ (int) $comment['comment_ID'] ] ) ) { + continue; + } + + $comment['comment_approved'] = $status; + ++$this->rows_affected; + } + unset( $comment ); + + return $this->rows_affected; + } + + private function component_fuzz_post_defaults() { + return array( + 'ID' => 0, + 'post_author' => 0, + 'post_date' => '0000-00-00 00:00:00', + 'post_date_gmt' => '0000-00-00 00:00:00', + 'post_content' => '', + 'post_title' => '', + 'post_excerpt' => '', + 'post_status' => 'draft', + 'comment_status' => 'closed', + 'ping_status' => 'closed', + 'post_password' => '', + 'post_name' => '', + 'to_ping' => '', + 'pinged' => '', + 'post_modified' => '0000-00-00 00:00:00', + 'post_modified_gmt' => '0000-00-00 00:00:00', + 'post_content_filtered' => '', + 'post_parent' => 0, + 'guid' => '', + 'menu_order' => 0, + 'post_type' => 'post', + 'post_mime_type' => '', + 'comment_count' => '0', + ); + } + + private function component_fuzz_term_defaults() { + return array( + 'term_id' => 0, + 'name' => '', + 'slug' => '', + 'term_group' => 0, + ); + } + + private function component_fuzz_term_taxonomy_defaults() { + return array( + 'term_taxonomy_id' => 0, + 'term_id' => 0, + 'taxonomy' => '', + 'description' => '', + 'parent' => 0, + 'count' => 0, + ); + } + + private function component_fuzz_user_defaults() { + return array( + 'ID' => 0, + 'user_login' => '', + 'user_pass' => '', + 'user_nicename' => '', + 'user_email' => '', + 'user_url' => '', + 'user_registered' => '0000-00-00 00:00:00', + 'user_activation_key' => '', + 'user_status' => 0, + 'display_name' => '', + 'spam' => '0', + 'deleted' => '0', + ); + } + + private function component_fuzz_signup_defaults() { + return array( + 'signup_id' => 0, + 'domain' => '', + 'path' => '', + 'title' => '', + 'user_login' => '', + 'user_email' => '', + 'registered' => '0000-00-00 00:00:00', + 'activated' => '0000-00-00 00:00:00', + 'active' => 0, + 'activation_key' => '', + 'meta' => '', + ); + } + + private function component_fuzz_comment_defaults() { + return array( + 'comment_ID' => 0, + 'comment_post_ID' => 0, + 'comment_author' => '', + 'comment_author_email' => '', + 'comment_author_url' => '', + 'comment_author_IP' => '', + 'comment_date' => '0000-00-00 00:00:00', + 'comment_date_gmt' => '0000-00-00 00:00:00', + 'comment_content' => '', + 'comment_karma' => 0, + 'comment_approved' => '1', + 'comment_agent' => '', + 'comment_type' => 'comment', + 'comment_parent' => 0, + 'user_id' => 0, + ); + } + + private function component_fuzz_link_defaults() { + return array( + 'link_id' => 0, + 'link_url' => '', + 'link_name' => '', + 'link_image' => '', + 'link_target' => '', + 'link_description' => '', + 'link_visible' => 'Y', + 'link_owner' => 0, + 'link_rating' => 0, + 'link_updated' => '0000-00-00 00:00:00', + 'link_rel' => '', + 'link_notes' => '', + 'link_rss' => '', + ); + } + + private function component_fuzz_format_row( array $row, $output ) { + if ( ARRAY_A === $output ) { + return $row; + } + + if ( ARRAY_N === $output ) { + return array_values( $row ); + } + + return (object) $row; + } + + private function component_fuzz_format_results( array $rows, $output ) { + if ( ARRAY_A === $output || ARRAY_N === $output ) { + return array_map( + function ( $row ) use ( $output ) { + return $this->component_fuzz_format_row( $row, $output ); + }, + $rows + ); + } + + $objects = array_map( + function ( $row ) { + return (object) $row; + }, + $rows + ); + + if ( OBJECT_K === $output ) { + $keyed = array(); + foreach ( $objects as $object ) { + $values = get_object_vars( $object ); + $key = reset( $values ); + if ( ! isset( $keyed[ $key ] ) ) { + $keyed[ $key ] = $object; + } + } + return $keyed; + } + + return $objects; + } + } +} + +$GLOBALS['wpdb'] = $GLOBALS['wpdb'] ?? new Component_Fuzz_WPDB_Stub(); diff --git a/tools/component-fuzz/notes/identity.md b/tools/component-fuzz/notes/identity.md new file mode 100644 index 0000000000000..ef67d06c41033 --- /dev/null +++ b/tools/component-fuzz/notes/identity.md @@ -0,0 +1,18 @@ +# Identity surface + +`IdentitySurface` is a pure-PHP, DB-free fuzz surface for identity-adjacent WordPress helpers. + +It exercises: + +- usernames: `sanitize_user()` and `validate_username()` +- email: `sanitize_email()` and `is_email()` +- capability-like keys: `sanitize_key()` +- author URL parsing/sanitization: `sanitize_url()`, `esc_url_raw()`, `wp_parse_url()` +- comment-ish text helpers: `sanitize_text_field()`, `sanitize_textarea_field()`, `wp_strip_all_tags()`, `wp_filter_nohtml_kses()` +- comment cookie/comment array filtering: `sanitize_comment_cookies()` and `wp_filter_comment()` +- current-user lifecycle: cache-seeded `wp_set_current_user()`, `wp_get_current_user()`, `get_current_user_id()`, `is_user_logged_in()`, legacy setup globals, `set_current_user` action dispatch, legacy object upgrades, and `determine_current_user` discovery +- scalar option sanitization branches that avoid live DB fallback on success +- auth/password helpers: `wp_generate_password()`, `wp_hash_password()`, `wp_check_password()` +- parsing: `wp_parse_str()` + +The surface uses a deterministic SHA-256 PRNG seeded from common `FuzzContext` accessors when available. It installs scoped `pre_option_blog_charset`/`WPLANG` short-circuits when the filter API is available so formatting helpers do not need a live options table. Each case snapshots and restores touched global state, notably `$_COOKIE` and the current filter stack marker, while current-user lifecycle coverage also restores current-user/setup globals and hook counters locally. The outer run restores `wp_filter` after temporary filters are removed, with cloned hook snapshots so existing `WP_Hook` instances are not mutated through shallow snapshot references. diff --git a/tools/component-fuzz/notes/markup.md b/tools/component-fuzz/notes/markup.md new file mode 100644 index 0000000000000..05187f7603074 --- /dev/null +++ b/tools/component-fuzz/notes/markup.md @@ -0,0 +1,20 @@ +# Markup surface notes + +`MarkupSurface` covers pure-PHP block, shortcode, and markup helper behavior with bounded generated inputs. + +Primary invariants: + +- `parse_blocks()` -> `serialize_blocks()` -> `parse_blocks()` preserves parsed block structure. +- `serialize_blocks()` equals the concatenation of `serialize_block()` for top-level parsed blocks. +- `has_blocks()` must be true for parser-confirmed named blocks; malformed `'; + }; + + \add_filter( 'image_add_caption_text', $caption_text_filter, 10, 2 ); + \add_filter( 'image_add_caption_shortcode', $caption_shortcode_filter, 10, 2 ); + try { + $captioned_html = \image_add_caption( $caption_html, $attachment->ID, $caption_text, $title, 'right', $url, 'medium', $alt ); + } finally { + \remove_filter( 'image_add_caption_text', $caption_text_filter, 10 ); + \remove_filter( 'image_add_caption_shortcode', $caption_shortcode_filter, 10 ); + } + + self::collect_failure( + $failures, + is_string( $captioned_html ) + && str_starts_with( $captioned_html, '[caption id="attachment_' . $attachment->ID . '" align="alignright" width="456"]' ) + && str_contains( $captioned_html, 'class="size-medium wp-image-' . $attachment->ID . ' component-fuzz"' ) + && ! str_contains( $captioned_html, 'class="alignright' ) + && str_contains( $captioned_html, 'tag
    Second line
    Third abbr
    Filtered tag' ) + && str_ends_with( $captioned_html, '' ) + && self::html_has_no_raw_script( $captioned_html ), + 'image_add_caption() extracts width, strips image align class, and normalizes caption tag and line breaks', + array( + 'html' => self::describe_string( $captioned_html ), + 'events' => $caption_events, + ) + ); + + self::collect_failure( + $failures, + array( 'image_add_caption_text', 'image_add_caption_shortcode' ) === array_column( $caption_events, 'filter' ) + && (int) $attachment->ID === (int) ( $caption_events[0]['id'] ?? 0 ) + && isset( $caption_events[1]['html'] ) + && ! str_contains( (string) $caption_events[1]['html'], 'class="alignright' ) + && false === \has_filter( 'image_add_caption_text', $caption_text_filter ) + && false === \has_filter( 'image_add_caption_shortcode', $caption_shortcode_filter ), + 'image_add_caption_text and image_add_caption_shortcode filters fire in order and remain local', + array( 'events' => $caption_events ) + ); + + $early_shortcode_events = array(); + $early_shortcode_filter = static function ( string $shortcode, string $html ) use ( &$early_shortcode_events ): string { + $early_shortcode_events[] = array( + 'shortcode' => $shortcode, + 'html' => $html, + ); + + return $shortcode; + }; + + $disabled_events = array(); + $disable_captions_filter = static function ( $disabled ) use ( &$disabled_events ): bool { + $disabled_events[] = $disabled; + return true; + }; + + \add_filter( 'disable_captions', $disable_captions_filter ); + \add_filter( 'image_add_caption_shortcode', $early_shortcode_filter, 10, 2 ); + try { + $disabled_caption_html = \image_add_caption( $caption_html, $attachment->ID, 'Disabled caption', $title, 'right', $url, 'medium', $alt ); + } finally { + \remove_filter( 'disable_captions', $disable_captions_filter ); + \remove_filter( 'image_add_caption_shortcode', $early_shortcode_filter, 10 ); + } + + $empty_text_events = array(); + $empty_disable_events = array(); + $empty_text_filter = static function ( string $filtered_caption, int $id ) use ( &$empty_text_events ): string { + $empty_text_events[] = array( + 'id' => $id, + 'caption' => $filtered_caption, + ); + + return ''; + }; + $empty_disable_filter = static function ( $disabled ) use ( &$empty_disable_events ): bool { + $empty_disable_events[] = $disabled; + return false; + }; + + \add_filter( 'image_add_caption_text', $empty_text_filter, 10, 2 ); + \add_filter( 'disable_captions', $empty_disable_filter ); + \add_filter( 'image_add_caption_shortcode', $early_shortcode_filter, 10, 2 ); + try { + $empty_caption_html = \image_add_caption( $caption_html, $attachment->ID, 'Will be emptied', $title, 'right', $url, 'medium', $alt ); + } finally { + \remove_filter( 'image_add_caption_text', $empty_text_filter, 10 ); + \remove_filter( 'disable_captions', $empty_disable_filter ); + \remove_filter( 'image_add_caption_shortcode', $early_shortcode_filter, 10 ); + } + + self::collect_failure( + $failures, + $caption_html === $disabled_caption_html + && $caption_html === $empty_caption_html + && array( '' ) === $disabled_events + && 1 === count( $empty_text_events ) + && array() === $empty_disable_events + && array() === $early_shortcode_events + && false === \has_filter( 'disable_captions', $disable_captions_filter ) + && false === \has_filter( 'image_add_caption_text', $empty_text_filter ) + && false === \has_filter( 'disable_captions', $empty_disable_filter ) + && false === \has_filter( 'image_add_caption_shortcode', $early_shortcode_filter ), + 'image_add_caption() returns original HTML for disabled or empty captions before shortcode filters', + array( + 'disabledEvents' => $disabled_events, + 'emptyTextEvents' => $empty_text_events, + 'emptyDisableEvents' => $empty_disable_events, + 'earlyShortcodeEvents' => $early_shortcode_events, + ) + ); + + $media_events = array(); + $media_send_filter = static function ( string $html, int $id, string $captured_caption, string $captured_title, string $captured_align, string $captured_url, $captured_size, string $captured_alt, string $captured_rel ) use ( &$media_events ): string { + $media_events[] = array( + 'html' => $html, + 'id' => $id, + 'caption' => $captured_caption, + 'title' => $captured_title, + 'align' => $captured_align, + 'url' => $captured_url, + 'size' => $captured_size, + 'alt' => $captured_alt, + 'rel' => $captured_rel, + ); + + return $html; + }; + + $media_permalink_url = \get_attachment_link( $attachment->ID ); + $media_query_url = \add_query_arg( 'attachment_id', (string) $attachment->ID, 'http://example.test/component-fuzz/media-send' ); + $media_plain_url = 'http://example.test/component-fuzz/media-send-plain?raw=' . rawurlencode( '' ); + $media_alt = 'Media alt "' . $ctx->identifier( 3, 8 ); + $media_payload = array( + 'url' => $media_permalink_url, + 'align' => 'left', + 'image-size' => 'medium', + 'image_alt' => $media_alt, + 'post_excerpt' => $attachment->post_excerpt, + 'post_title' => $attachment->post_title, + ); + $media_query_payload = array_merge( + $media_payload, + array( + 'url' => $media_query_url, + 'align' => 'center', + 'image-size' => 'thumbnail', + ) + ); + $media_plain_payload = array_merge( + $media_payload, + array( + 'url' => $media_plain_url, + 'align' => 'none', + 'image-size' => 'medium', + ) + ); + $unchanged_document_html = 'Document HTML'; + $media_caption_payload = array_merge( + $media_payload, + array( + 'align' => 'right', + 'image-size' => 'medium', + 'image_alt' => 'Media caption alt "' . $ctx->identifier( 3, 8 ), + 'post_excerpt' => "Media integrated caption\nSecond line", + 'post_title' => 'Media integrated title', + 'url' => $media_permalink_url, + ) + ); + $media_caption_html = \image_media_send_to_editor( 'input', $attachment->ID, $media_caption_payload ); + + \add_filter( 'image_send_to_editor', $media_send_filter, 10, 9 ); + \add_filter( 'disable_captions', $disable_captions_filter ); + try { + $media_permalink_html = \image_media_send_to_editor( 'input', $attachment->ID, $media_payload ); + $media_query_html = \image_media_send_to_editor( 'input', $attachment->ID, $media_query_payload ); + $media_plain_html = \image_media_send_to_editor( 'input', $attachment->ID, $media_plain_payload ); + $document_html = \image_media_send_to_editor( $unchanged_document_html, $document->ID, array( 'url' => \wp_get_attachment_url( $document->ID ) ) ); + } finally { + \remove_filter( 'image_send_to_editor', $media_send_filter, 10 ); + \remove_filter( 'disable_captions', $disable_captions_filter ); + } + + self::collect_failure( + $failures, + is_string( $media_permalink_html ) + && is_string( $media_query_html ) + && is_string( $media_plain_html ) + && str_contains( $media_permalink_html, 'href="' . \esc_url( $media_permalink_url ) . '"' ) + && str_contains( $media_query_html, 'href="' . \esc_url( $media_query_url ) . '"' ) + && str_contains( $media_plain_html, 'href="' . \esc_url( $media_plain_url ) . '"' ) + && str_contains( $media_permalink_html, 'rel="attachment wp-att-' . $attachment->ID . '"' ) + && str_contains( $media_query_html, 'rel="attachment wp-att-' . $attachment->ID . '"' ) + && ! str_contains( $media_plain_html, ' rel=' ) + && str_contains( $media_permalink_html, 'alt="' . \esc_attr( $media_alt ) . '"' ) + && str_contains( $media_query_html, 'alt="' . \esc_attr( $media_alt ) . '"' ) + && str_contains( $media_plain_html, 'alt="' . \esc_attr( $media_alt ) . '"' ) + && self::html_has_no_raw_script( $media_permalink_html . $media_query_html . $media_plain_html ) + && $unchanged_document_html === $document_html, + 'image_media_send_to_editor() delegates image attachments and leaves non-image HTML unchanged', + array( + 'permalinkHtml' => self::describe_string( $media_permalink_html ), + 'queryHtml' => self::describe_string( $media_query_html ), + 'plainHtml' => self::describe_string( $media_plain_html ), + 'documentHtml' => self::describe_string( $document_html ), + ) + ); + + self::collect_failure( + $failures, + 20 === \has_filter( 'image_send_to_editor', 'image_add_caption' ) + && is_string( $media_caption_html ) + && str_starts_with( $media_caption_html, '[caption id="attachment_' . $attachment->ID . '" align="alignright" width="300"]' ) + && str_contains( $media_caption_html, 'href="' . \esc_url( $media_permalink_url ) . '"' ) + && str_contains( $media_caption_html, 'rel="attachment wp-att-' . $attachment->ID . '"' ) + && str_contains( $media_caption_html, 'alt="' . \esc_attr( $media_caption_payload['image_alt'] ) . '"' ) + && str_contains( $media_caption_html, 'Media integrated caption
    Second line[/caption]' ) + && self::html_has_no_raw_script( $media_caption_html ), + 'image_media_send_to_editor() observes the default image_send_to_editor caption wrapping path', + array( 'html' => self::describe_string( $media_caption_html ) ) + ); + + self::collect_failure( + $failures, + 3 === count( $media_events ) + && (int) $attachment->ID === (int) ( $media_events[0]['id'] ?? 0 ) + && (int) $attachment->ID === (int) ( $media_events[1]['id'] ?? 0 ) + && (int) $attachment->ID === (int) ( $media_events[2]['id'] ?? 0 ) + && $attachment->post_excerpt === ( $media_events[0]['caption'] ?? null ) + && $attachment->post_title === ( $media_events[0]['title'] ?? null ) + && 'left' === ( $media_events[0]['align'] ?? null ) + && 'medium' === ( $media_events[0]['size'] ?? null ) + && $media_permalink_url === ( $media_events[0]['url'] ?? null ) + && $media_alt === ( $media_events[0]['alt'] ?? null ) + && 'center' === ( $media_events[1]['align'] ?? null ) + && 'thumbnail' === ( $media_events[1]['size'] ?? null ) + && $media_query_url === ( $media_events[1]['url'] ?? null ) + && 'none' === ( $media_events[2]['align'] ?? null ) + && 'medium' === ( $media_events[2]['size'] ?? null ) + && $media_plain_url === ( $media_events[2]['url'] ?? null ) + && ' rel="attachment wp-att-' . $attachment->ID . '"' === ( $media_events[0]['rel'] ?? null ) + && ' rel="attachment wp-att-' . $attachment->ID . '"' === ( $media_events[1]['rel'] ?? null ) + && '' === ( $media_events[2]['rel'] ?? null ) + && false === \has_filter( 'image_send_to_editor', $media_send_filter ) + && false === \has_filter( 'disable_captions', $disable_captions_filter ), + 'image_media_send_to_editor() forwards attachment fields into get_image_send_to_editor() payloads with scoped filters', + array( 'events' => $media_events ) + ); + + self::restore_state( $filter_snapshot ); + + return self::row( + $ctx, + 'admin-media-chrome.image-caption-editor-output', + array() === $failures, + array( 'failures' => $failures ) + ); + } + + private static function check_legacy_upload_shell_helpers( \ComponentFuzz\FuzzContext $ctx ): array { + $failures = array(); + $snapshot = self::snapshot_state(); + $token = $ctx->identifier( 3, 8 ); + $post_id = self::seed_parent_post( $ctx->fork( 'post' ) ); + $unsafe_type = 'image-' . $token . ''; + $unsafe_tab = 'type-' . $token . ''; + $unsafe_post_id = (string) $post_id . ''; + $action_counts = array(); + $tab_events = array(); + $form_url_events = array(); + $post_events = array(); + $plupload_events = array(); + $direct_tabs = array(); + $header_html = ''; + $chromeless_html = ''; + $type_form_html = ''; + + $tabs_filter = static function ( array $tabs ) use ( &$tab_events, $token ): array { + $tab_events[] = array_keys( $tabs ); + $tabs['component_fuzz'] = 'Component Fuzz ' . $token; + return $tabs; + }; + $form_url_filter = static function ( string $url, string $type ) use ( &$form_url_events, $token ): string { + $form_url_events[] = array( + 'url' => $url, + 'type' => $type, + ); + return \add_query_arg( 'cfz_upload_marker', $token, $url ); + }; + $post_params_filter = static function ( array $params ) use ( &$post_events, $token ): array { + $post_events[] = $params; + $params['component_fuzz_param'] = $token; + return $params; + }; + $plupload_filter = static function ( array $init ) use ( &$plupload_events, $token ): array { + $plupload_events[] = $init; + $init['component_fuzz_init'] = $token; + return $init; + }; + $tracked_actions = array( + 'pre-upload-ui', + 'pre-plupload-upload-ui', + 'post-plupload-upload-ui', + 'pre-html-upload-ui', + 'post-html-upload-ui', + 'post-upload-ui', + ); + $action_callbacks = array(); + foreach ( $tracked_actions as $hook ) { + $action_counts[ $hook ] = 0; + $action_callbacks[ $hook ] = static function () use ( &$action_counts, $hook ): void { + ++$action_counts[ $hook ]; + }; + } + + \add_filter( 'media_upload_tabs', $tabs_filter ); + \add_filter( 'media_upload_form_url', $form_url_filter, 10, 2 ); + \add_filter( 'upload_post_params', $post_params_filter ); + \add_filter( 'plupload_init', $plupload_filter ); + foreach ( $action_callbacks as $hook => $callback ) { + \add_action( $hook, $callback ); + } + + try { + $GLOBALS['type'] = $unsafe_type; + $GLOBALS['tab'] = $unsafe_tab; + $_SERVER['HTTP_USER_AGENT'] = 'ComponentFuzz Desktop'; + $_GET = array( + 'tab' => 'component_fuzz', + ); + $_POST = array(); + $_REQUEST = array( + 'post_id' => $unsafe_post_id, + ); + + $direct_tabs = \media_upload_tabs(); + $header_html = self::capture_output( + static function (): void { + \media_upload_header(); + } + ); + + $_GET['chromeless'] = '1'; + $chromeless_html = self::capture_output( + static function (): void { + \media_upload_header(); + } + ); + + unset( $_GET['chromeless'] ); + $type_form_html = self::capture_output( + static function (): void { + \media_upload_type_form( 'image', null, null ); + } + ); + } finally { + \remove_filter( 'media_upload_tabs', $tabs_filter ); + \remove_filter( 'media_upload_form_url', $form_url_filter, 10 ); + \remove_filter( 'upload_post_params', $post_params_filter ); + \remove_filter( 'plupload_init', $plupload_filter ); + foreach ( $action_callbacks as $hook => $callback ) { + \remove_action( $hook, $callback ); + } + self::restore_state( $snapshot ); + } + + self::collect_failure( + $failures, + isset( $direct_tabs['type'], $direct_tabs['type_url'], $direct_tabs['gallery'], $direct_tabs['library'], $direct_tabs['component_fuzz'] ) + && 'Component Fuzz ' . $token === $direct_tabs['component_fuzz'], + 'media_upload_tabs() exposes default legacy tabs and scoped filter additions', + array( 'directTabs' => $direct_tabs ) + ); + + self::collect_failure( + $failures, + str_contains( $header_html, '' ) + && str_contains( $header_html, '
    ' ) + && str_contains( $header_html, "id='tab-component_fuzz'" ) + && str_contains( $header_html, "class='current'" ) + && str_contains( $chromeless_html, '' ) + && ! str_contains( $chromeless_html, '
    ' ) + && ! str_contains( $header_html . $chromeless_html, $unsafe_post_id ), + 'media_upload_header() casts request post IDs and honors chromeless legacy tab rendering', + array( + 'header' => self::describe_string( $header_html ), + 'chromeless' => self::describe_string( $chromeless_html ), + ) + ); + + self::collect_failure( + $failures, + str_contains( $type_form_html, 'enctype="multipart/form-data"' ) + && str_contains( $type_form_html, 'id="image-form"' ) + && str_contains( $type_form_html, 'name="_wpnonce"' ) + && str_contains( $type_form_html, 'id="post_id" value="' . $post_id . '"' ) + && str_contains( $type_form_html, 'cfz_upload_marker=' . $token ) + && str_contains( $type_form_html, 'wpUploaderInit = ' ) + && str_contains( $type_form_html, 'component_fuzz_param' ) + && str_contains( $type_form_html, 'component_fuzz_init' ) + && str_contains( $type_form_html, '\\u003C/script\\u003E' ) + && ! str_contains( $type_form_html, $unsafe_type ) + && ! str_contains( $type_form_html, $unsafe_tab ) + && ! str_contains( $type_form_html, $unsafe_post_id ), + 'media_upload_type_form() renders the non-dispatch upload shell with nonce, filtered action URL, and JSON-escaped uploader settings', + array( 'html' => self::describe_string( $type_form_html ) ) + ); + + self::collect_failure( + $failures, + 1 === count( $form_url_events ) + && 'image' === ( $form_url_events[0]['type'] ?? null ) + && 1 === count( $post_events ) + && $post_id === (int) ( $post_events[0]['post_id'] ?? 0 ) + && $unsafe_type === ( $post_events[0]['type'] ?? null ) + && $unsafe_tab === ( $post_events[0]['tab'] ?? null ) + && 1 === count( $plupload_events ) + && $token === ( $plupload_events[0]['multipart_params']['component_fuzz_param'] ?? null ) + && array() === array_filter( + $action_counts, + static function ( int $count ): bool { + return 1 !== $count; + } + ), + 'legacy upload shell fires documented form filters and upload UI hooks exactly once without dispatching uploads', + array( + 'formUrlEvents' => $form_url_events, + 'postEvents' => $post_events, + 'pluploadEvents' => $plupload_events, + 'actionCounts' => $action_counts, + 'tabEvents' => $tab_events, + ) + ); + + self::collect_failure( + $failures, + false === \has_filter( 'media_upload_tabs', $tabs_filter ) + && false === \has_filter( 'media_upload_form_url', $form_url_filter ) + && false === \has_filter( 'upload_post_params', $post_params_filter ) + && false === \has_filter( 'plupload_init', $plupload_filter ), + 'legacy upload shell filters are scoped to the invariant', + array() + ); + + return self::row( + $ctx, + 'admin-media-chrome.legacy-upload-shell-server-output', + array() === $failures, + array( + 'failures' => $failures, + 'notClaimed' => array( + 'SAPI-marked successful browser uploads through wp_media_upload_handler(); direct no-network media_handle_upload()/media_handle_sideload() ingest is owned by media-ingest.', + ), + ) + ); + } + + private static function check_media_enqueue_and_iframe_shell( \ComponentFuzz\FuzzContext $ctx ): array { + $failures = array(); + $snapshot = self::snapshot_state(); + $token = $ctx->identifier( 3, 8 ); + $post_id = self::seed_parent_post( $ctx->fork( 'post' ) ); + $iframe_payload = 'iframe ' . $token; + $settings_events = array(); + $strings_events = array(); + $filter_events = array(); + $enqueue_actions = 0; + $localized = ''; + $script_status = array(); + $style_status = array(); + $template_hooks = array(); + $did_enqueue = 0; + $iframe_html = ''; + + $tabs_filter = static function ( array $tabs ) use ( &$filter_events, $token ): array { + $filter_events['tabs'][] = array_keys( $tabs ); + $tabs['component_fuzz_modal'] = 'Component Fuzz Modal ' . $token; + return $tabs; + }; + $audio_filter = static function ( $show ) use ( &$filter_events ): bool { + $filter_events['audio'][] = $show; + return false; + }; + $video_filter = static function ( $show ) use ( &$filter_events ): bool { + $filter_events['video'][] = $show; + return true; + }; + $months_filter = static function ( $months ) use ( &$filter_events ): array { + $filter_events['months'][] = $months; + return array( + (object) array( + 'month' => 6, + 'year' => 2026, + ), + ); + }; + $infinite_filter = static function ( bool $infinite ) use ( &$filter_events ): bool { + $filter_events['infinite'][] = $infinite; + return true; + }; + $captions_filter = static function ( $disabled ) use ( &$filter_events ): bool { + $filter_events['captions'][] = $disabled; + return true; + }; + $settings_filter = static function ( array $settings, $post ) use ( &$settings_events, $token ): array { + $settings['componentFuzzSetting'] = $token; + $settings_events[] = array( + 'postId' => $post instanceof \WP_Post ? $post->ID : null, + 'settings' => $settings, + ); + return $settings; + }; + $strings_filter = static function ( array $strings, $post ) use ( &$strings_events, $token ): array { + $strings['componentFuzzString'] = $token; + $strings_events[] = array( + 'postId' => $post instanceof \WP_Post ? $post->ID : null, + 'strings' => $strings, + ); + return $strings; + }; + $enqueue_action = static function () use ( &$enqueue_actions ): void { + ++$enqueue_actions; + }; + + \add_filter( 'media_upload_tabs', $tabs_filter ); + \add_filter( 'media_library_show_audio_playlist', $audio_filter ); + \add_filter( 'media_library_show_video_playlist', $video_filter ); + \add_filter( 'media_library_months_with_files', $months_filter ); + \add_filter( 'media_library_infinite_scrolling', $infinite_filter ); + \add_filter( 'disable_captions', $captions_filter ); + \add_filter( 'media_view_settings', $settings_filter, 10, 2 ); + \add_filter( 'media_view_strings', $strings_filter, 10, 2 ); + \add_action( 'wp_enqueue_media', $enqueue_action ); + + try { + $GLOBALS['content_width'] = 733; + $GLOBALS['body_id'] = 'component-fuzz-iframe'; + + \wp_enqueue_media( array( 'post' => $post_id ) ); + $localized = (string) \wp_scripts()->get_data( 'media-views', 'data' ); + $script_status = array( + 'media-editor' => \wp_script_is( 'media-editor', 'enqueued' ), + 'media-audiovideo' => \wp_script_is( 'media-audiovideo', 'enqueued' ), + ); + $style_status = array( + 'media-views' => \wp_style_is( 'media-views', 'enqueued' ), + 'imgareaselect' => \wp_style_is( 'imgareaselect', 'enqueued' ), + ); + $template_hooks = array( + 'admin_footer' => \has_action( 'admin_footer', 'wp_print_media_templates' ), + 'wp_footer' => \has_action( 'wp_footer', 'wp_print_media_templates' ), + 'customize_controls_print_footer_scripts' => \has_action( 'customize_controls_print_footer_scripts', 'wp_print_media_templates' ), + ); + $did_enqueue = \did_action( 'wp_enqueue_media' ); + + $iframe_html = self::capture_output( + static function () use ( $iframe_payload ): void { + \wp_iframe( 'esc_html_e', $iframe_payload ); + } + ); + } finally { + \remove_filter( 'media_upload_tabs', $tabs_filter ); + \remove_filter( 'media_library_show_audio_playlist', $audio_filter ); + \remove_filter( 'media_library_show_video_playlist', $video_filter ); + \remove_filter( 'media_library_months_with_files', $months_filter ); + \remove_filter( 'media_library_infinite_scrolling', $infinite_filter ); + \remove_filter( 'disable_captions', $captions_filter ); + \remove_filter( 'media_view_settings', $settings_filter, 10 ); + \remove_filter( 'media_view_strings', $strings_filter, 10 ); + \remove_action( 'wp_enqueue_media', $enqueue_action ); + self::restore_state( $snapshot ); + } + + $settings = $settings_events[0]['settings'] ?? array(); + $strings = $strings_events[0]['strings'] ?? array(); + $month = $settings['months'][0] ?? null; + + self::collect_failure( + $failures, + 1 === count( $settings_events ) + && $post_id === (int) ( $settings_events[0]['postId'] ?? 0 ) + && isset( $settings['tabs']['component_fuzz_modal'] ) + && 'Component Fuzz Modal ' . $token === $settings['tabs']['component_fuzz_modal'] + && false === ( $settings['captions'] ?? null ) + && 0 === (int) ( $settings['attachmentCounts']['audio'] ?? -1 ) + && 1 === (int) ( $settings['attachmentCounts']['video'] ?? -1 ) + && 1 === (int) ( $settings['infiniteScrolling'] ?? 0 ) + && 733 === (int) ( $settings['contentWidth'] ?? 0 ) + && $month instanceof \stdClass + && 6 === (int) $month->month + && 2026 === (int) $month->year + && isset( $month->text ), + 'wp_enqueue_media() builds filterable media-view settings without querying media months or playlist counts', + array( + 'settingsEvents' => $settings_events, + 'filterEvents' => $filter_events, + ) + ); + + self::collect_failure( + $failures, + 1 === count( $strings_events ) + && $post_id === (int) ( $strings_events[0]['postId'] ?? 0 ) + && isset( $strings['componentFuzzString'] ) + && $token === $strings['componentFuzzString'], + 'wp_enqueue_media() filters media strings before attaching the filtered settings payload for localization', + array( 'stringsEvents' => $strings_events ) + ); + + self::collect_failure( + $failures, + '' === $localized + || ( + str_contains( $localized, '_wpMediaViewsL10n' ) + && str_contains( $localized, 'componentFuzzSetting' ) + && str_contains( $localized, 'componentFuzzString' ) + && str_contains( $localized, $token ) + ), + 'wp_enqueue_media() localizes filtered media strings when the media-views handle is registered', + array( + 'localized' => self::describe_string( $localized ), + 'scripts' => $script_status, + 'styles' => $style_status, + ) + ); + + self::collect_failure( + $failures, + 1 === $did_enqueue + && 1 === $enqueue_actions + && ! in_array( false, $template_hooks, true ), + 'wp_enqueue_media() completes its action and registers media templates once', + array( + 'templateHooks' => $template_hooks, + 'didAction' => $did_enqueue, + 'actionCount' => $enqueue_actions, + ) + ); + + self::collect_failure( + $failures, + str_contains( $iframe_html, 'pagenow = \'media-upload-popup\'' ) + && str_contains( $iframe_html, 'alert(1) ' . $ctx->identifier( 3, 8 ); + $events = array(); + $months_filter = static function ( $months ) use ( &$events ): array { + $events[] = $months; + return array( + (object) array( + 'month' => 6, + 'year' => 2026, + ), + ); + }; + + \add_filter( 'media_library_months_with_files', $months_filter ); + try { + $button = self::capture_output( + static function () use ( $editor_id ): void { + \media_buttons( $editor_id ); + } + ); + } finally { + \remove_filter( 'media_library_months_with_files', $months_filter ); + } + + $html_bypass = self::capture_output( + static function (): void { + \media_upload_html_bypass(); + } + ); + $flash_bypass = self::capture_output( + static function (): void { + \media_upload_flash_bypass(); + } + ); + + self::collect_failure( + $failures, + str_contains( $button, 'class="button insert-media add_media"' ) + && str_contains( $button, 'aria-controls="wp-media-modal"' ) + && str_contains( $button, 'data-editor="' . \esc_attr( $editor_id ) . '"' ) + && ! str_contains( $button, $editor_id ) + && self::html_has_no_raw_script( $button ) + && false === \has_filter( 'media_library_months_with_files', $months_filter ), + 'media_buttons() escapes the editor ID while rendering the Add Media button', + array( + 'button' => self::describe_string( $button ), + 'events' => $events, + ) + ); + + self::collect_failure( + $failures, + str_contains( $html_bypass, 'upload-html-bypass' ) + && str_contains( $html_bypass, 'type="button" class="button-link"' ) + && str_contains( $flash_bypass, 'upload-flash-bypass' ) + && str_contains( $flash_bypass, 'type="button" class="button-link"' ) + && self::html_has_no_raw_script( $html_bypass . $flash_bypass ), + 'legacy uploader bypass helpers emit fixed button markup without upload dispatch', + array( + 'htmlBypass' => self::describe_string( $html_bypass ), + 'flashBypass' => self::describe_string( $flash_bypass ), + ) + ); + + return self::row( + $ctx, + 'admin-media-chrome.media-buttons-bypass-output', + array() === $failures, + array( 'failures' => $failures ) + ); + } + + private static function check_media_upload_dispatch_exits( \ComponentFuzz\FuzzContext $ctx ): array { + $missing = self::media_attach_action_child_missing_requirements(); + if ( array() !== $missing ) { + return self::row( + $ctx, + 'admin-media-chrome.legacy-upload-dispatch-exits', + true, + array( + 'missing' => $missing, + 'reason' => 'Required local subprocess APIs are unavailable.', + ), + 'skipped' + ); + } + + $failures = array(); + $runs = array(); + + foreach ( self::media_upload_dispatch_cases( $ctx ) as $case ) { + $run = self::run_media_upload_dispatch_child_process( $case ); + $result = is_array( $run['result'] ?? null ) ? $run['result'] : array(); + + $runs[ $case['label'] ] = array( + 'ok' => $run['ok'] ?? false, + 'exitCode' => $run['exitCode'] ?? null, + 'stderr' => self::describe_string( (string) ( $run['stderr'] ?? '' ) ), + 'stdout' => self::describe_string( (string) ( $run['stdout'] ?? '' ) ), + 'result' => array( + 'returned' => $result['returned'] ?? null, + 'output' => self::describe_string( (string) ( $result['output'] ?? '' ) ), + 'dieCalls' => $result['dieCalls'] ?? array(), + 'saveEventCount' => is_array( $result['saveEvents'] ?? null ) ? count( $result['saveEvents'] ) : null, + 'sendEventCount' => is_array( $result['sendEvents'] ?? null ) ? count( $result['sendEvents'] ) : null, + ), + ); + + self::collect_failure( + $failures, + true === ( $run['ok'] ?? false ) && self::media_upload_dispatch_child_result_has_expected_shape( $result ), + "{$case['label']} child exits cleanly and reports structured JSON", + array( + 'run' => $run, + 'result' => $result, + ) + ); + + if ( ! self::media_upload_dispatch_child_result_has_expected_shape( $result ) ) { + continue; + } + + self::collect_failure( + $failures, + false === (bool) ( $result['returned'] ?? true ) + && null === ( $result['throwable'] ?? null ) + && array() === ( $result['dieCalls'] ?? array() ), + "{$case['label']} reaches the intended legacy exit without wp_die or unexpected exceptions", + array( + 'returned' => $result['returned'] ?? null, + 'throwable' => $result['throwable'] ?? null, + 'dieCalls' => $result['dieCalls'] ?? array(), + ) + ); + + if ( 'send' === $case['scenario'] ) { + self::collect_media_upload_send_failures( $failures, $case, $result ); + } elseif ( 'insert-gallery' === $case['scenario'] ) { + self::collect_media_upload_gallery_exit_failures( $failures, $case, $result ); + } else { + self::collect_media_upload_type_error_failures( $failures, $case, $result ); + } + } + + return self::row( + $ctx, + 'admin-media-chrome.legacy-upload-dispatch-exits', + array() === $failures, + array( + 'failures' => $failures, + 'runs' => $runs, + ) + ); + } + + private static function media_upload_dispatch_cases( \ComponentFuzz\FuzzContext $ctx ): array { + $build = static function ( string $label, string $scenario, \ComponentFuzz\FuzzContext $case_ctx ): array { + $token = self::media_upload_dispatch_token( 'upload_' . $case_ctx->identifier( 4, 9 ) ); + + return array( + 'label' => $label, + 'scenario' => $scenario, + 'seed' => $case_ctx->seed(), + 'iteration' => $case_ctx->iteration(), + 'token' => $token, + ); + }; + + return array( + $build( 'send-to-editor', 'send', $ctx->fork( 'send' ) ), + $build( 'insert-gallery', 'insert-gallery', $ctx->fork( 'insert-gallery' ) ), + $build( 'type-form-error', 'type-error', $ctx->fork( 'type-error' ) ), + ); + } + + private static function media_upload_dispatch_token( string $token ): string { + $safe = preg_replace( '/[^A-Za-z0-9_-]/', '', $token ); + if ( ! is_string( $safe ) || '' === $safe ) { + return 'upload_token'; + } + + return $safe; + } + + private static function run_media_upload_dispatch_child_process( array $case ): array { + $payload = json_encode( + array( 'case' => $case ), + JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE + ); + + if ( false === $payload ) { + return array( + 'ok' => false, + 'exitCode' => -1, + 'stdout' => '', + 'stderr' => 'json_encode failed', + 'result' => null, + ); + } + + $descriptors = array( + 0 => array( 'pipe', 'r' ), + 1 => array( 'pipe', 'w' ), + 2 => array( 'pipe', 'w' ), + ); + + // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.system_calls_proc_open -- Isolates legacy media upload send/gallery/error exit paths in a local PHP subprocess. + $process = proc_open( array( PHP_BINARY, '-r', self::media_upload_dispatch_child_program() ), $descriptors, $pipes, \ComponentFuzz\repo_root() ); + if ( ! is_resource( $process ) ) { + return array( + 'ok' => false, + 'exitCode' => -1, + 'stdout' => '', + 'stderr' => 'proc_open failed', + 'result' => null, + ); + } + + fwrite( $pipes[0], $payload ); + fclose( $pipes[0] ); + + $stdout = stream_get_contents( $pipes[1] ); + $stderr = stream_get_contents( $pipes[2] ); + fclose( $pipes[1] ); + fclose( $pipes[2] ); + + $exit_code = proc_close( $process ); + $result = json_decode( (string) $stdout, true ); + + return array( + 'ok' => 0 === $exit_code && is_array( $result ) && true === ( $result['ok'] ?? null ), + 'exitCode' => $exit_code, + 'stdout' => (string) $stdout, + 'stderr' => (string) $stderr, + 'result' => is_array( $result ) ? $result : null, + ); + } + + private static function media_upload_dispatch_child_program(): string { + return <<<'PHP' +$component_fuzz_admin_media_raw = stream_get_contents( STDIN ); +$component_fuzz_admin_media_payload = json_decode( $component_fuzz_admin_media_raw, true ); +$case = is_array( $component_fuzz_admin_media_payload['case'] ?? null ) ? $component_fuzz_admin_media_payload['case'] : array(); + +require_once getcwd() . '/tools/component-fuzz/lib/autoload.php'; +\ComponentFuzz\WpBootstrap::load(); + +\ComponentFuzz\Surfaces\AdminMediaChromeSurface::run_media_upload_dispatch_child( $case ); +PHP; + } + + public static function run_media_upload_dispatch_child( array $case ): void { + ini_set( 'display_errors', '0' ); + self::prepare_runtime(); + + $ctx = new \ComponentFuzz\FuzzContext( (int) ( $case['seed'] ?? 1 ), self::NAME, (int) ( $case['iteration'] ?? 0 ) ); + $scenario = (string) ( $case['scenario'] ?? 'send' ); + $token = self::media_upload_dispatch_token( (string) ( $case['token'] ?? $ctx->identifier( 4, 9 ) ) ); + $state = array( + 'ok' => false, + 'label' => (string) ( $case['label'] ?? 'upload-dispatch' ), + 'scenario' => $scenario, + 'token' => $token, + 'parentId' => 0, + 'newParentId' => 0, + 'allowedId' => 0, + 'deniedId' => 0, + 'allTrackedIds' => array(), + 'postsBefore' => array(), + 'postsAfter' => array(), + 'metaBefore' => array(), + 'metaAfter' => array(), + 'queryDelta' => array(), + 'saveEvents' => array(), + 'sendEvents' => array(), + 'capEvents' => array(), + 'dieCalls' => array(), + 'returned' => false, + 'throwable' => null, + 'output' => '', + 'errorMessage' => '', + ); + + $buffer_level = ob_get_level(); + ob_start(); + + register_shutdown_function( + static function () use ( &$state, $buffer_level ): void { + $output = ''; + while ( ob_get_level() > $buffer_level ) { + $chunk = ob_get_clean(); + if ( is_string( $chunk ) ) { + $output = $chunk . $output; + } + } + + $state['output'] = $output; + $state['postsAfter'] = self::media_upload_dispatch_post_state( array_map( 'intval', $state['allTrackedIds'] ) ); + $state['metaAfter'] = self::media_upload_dispatch_alt_state( array_map( 'intval', $state['allTrackedIds'] ) ); + + $wpdb = $GLOBALS['wpdb'] ?? null; + if ( $wpdb instanceof \Component_Fuzz_WPDB_Stub ) { + $queries = $wpdb->component_fuzz_get_queries(); + $state['queryDelta'] = array_values( array_slice( $queries, (int) ( $state['queryCountBefore'] ?? 0 ) ) ); + } + + $state['ok'] = null === $state['throwable']; + echo json_encode( $state, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE ) . "\n"; + } + ); + + try { + $parent_id = self::seed_parent_post( $ctx->fork( 'parent' ) ); + $new_parent = self::seed_parent_post( $ctx->fork( 'new-parent' ) ); + $allowed = self::seed_attachment( $ctx->fork( 'allowed' ), 'image/jpeg', array( 'parent_id' => $parent_id ) ); + $denied = self::seed_attachment( $ctx->fork( 'denied' ), 'image/png', array( 'parent_id' => $parent_id ) ); + $allowed_id = (int) $allowed->ID; + $denied_id = (int) $denied->ID; + $tracked_ids = array( $parent_id, $new_parent, $allowed_id, $denied_id ); + $allowed_caps = array_fill_keys( array( $allowed_id ), true ); + + $state['parentId'] = $parent_id; + $state['newParentId'] = $new_parent; + $state['allowedId'] = $allowed_id; + $state['deniedId'] = $denied_id; + $state['allTrackedIds'] = $tracked_ids; + $state['postsBefore'] = self::media_upload_dispatch_post_state( $tracked_ids ); + $state['metaBefore'] = self::media_upload_dispatch_alt_state( $tracked_ids ); + + $GLOBALS['pagenow'] = 'media-upload.php'; + $_SERVER['HTTP_HOST'] = 'example.test'; + $_SERVER['HTTPS'] = 'off'; + $_SERVER['PHP_SELF'] = '/wp-admin/media-upload.php'; + $_SERVER['REQUEST_METHOD'] = 'POST'; + $_SERVER['REQUEST_URI'] = '/wp-admin/media-upload.php?type=image&tab=type'; + $_SERVER['HTTP_REFERER'] = 'http://example.test/wp-admin/media-upload.php?type=image&tab=type'; + $_SERVER['HTTP_USER_AGENT'] = 'component-fuzz/admin-media-upload-dispatch'; + $_SERVER['REMOTE_ADDR'] = '198.51.100.43'; + $_SERVER['SERVER_PORT'] = '80'; + + $map_meta_cap_filter = static function ( array $caps, string $cap, int $user_id, array $args ) use ( &$state, $allowed_caps ): array { + if ( 'edit_post' !== $cap || ! isset( $args[0] ) ) { + return $caps; + } + + $post_id = (int) $args[0]; + $allowed = isset( $allowed_caps[ $post_id ] ); + $state['capEvents'][] = array( + 'postId' => $post_id, + 'allowed' => $allowed, + ); + + return $allowed ? array( 'exist' ) : array( 'do_not_allow' ); + }; + $user_has_cap_filter = static function ( array $allcaps, array $caps, array $args, $user = null ): array { + unset( $args, $user ); + foreach ( $caps as $cap ) { + $allcaps[ $cap ] = 'do_not_allow' !== $cap; + } + return $allcaps; + }; + $die_handler_filter = static function () use ( &$state ): callable { + return static function ( $message = '', $title = '', $args = array() ) use ( &$state ): void { + $state['dieCalls'][] = array( + 'message' => self::media_attach_action_die_message( $message ), + 'title' => self::media_attach_action_die_message( $title ), + 'args' => is_array( $args ) ? $args : array(), + ); + exit; + }; + }; + $save_filter = static function ( array $post, array $attachment ) use ( &$state, $token, $allowed_id ): array { + $state['saveEvents'][] = array( + 'id' => (int) ( $post['ID'] ?? 0 ), + 'title' => (string) ( $attachment['post_title'] ?? '' ), + 'hasAlt' => array_key_exists( 'image_alt', $attachment ), + 'postParent' => (int) ( $attachment['post_parent'] ?? 0 ), + ); + + if ( $allowed_id === (int) ( $post['ID'] ?? 0 ) ) { + $post['post_content'] = (string) ( $post['post_content'] ?? '' ) . ' filtered-' . $token; + } + + return $post; + }; + $send_filter = static function ( string $html, int $send_id, array $attachment ) use ( &$state, $token ): string { + $state['sendEvents'][] = array( + 'id' => $send_id, + 'html' => $html, + 'title' => (string) ( $attachment['post_title'] ?? '' ), + 'url' => (string) ( $attachment['url'] ?? '' ), + 'hasRel' => str_contains( $html, "rel='attachment wp-att-" . $send_id . "'" ), + ); + + return $html . 'filtered'; + }; + + \add_filter( 'map_meta_cap', $map_meta_cap_filter, 10, 4 ); + \add_filter( 'user_has_cap', $user_has_cap_filter, 10, 4 ); + \add_filter( 'wp_die_handler', $die_handler_filter, PHP_INT_MAX ); + \add_filter( 'attachment_fields_to_save', $save_filter, 10, 2 ); + \add_filter( 'media_send_to_editor', $send_filter, 10, 3 ); + + \wp_set_current_user( 1 ); + if ( isset( $GLOBALS['current_user'] ) && $GLOBALS['current_user'] instanceof \WP_User ) { + $GLOBALS['current_user']->allcaps = array( 'exist' => true ); + } + + $nonce = \wp_create_nonce( 'media-form' ); + $wpdb = $GLOBALS['wpdb'] ?? null; + if ( $wpdb instanceof \Component_Fuzz_WPDB_Stub ) { + $wpdb->rows_affected = 0; + $state['queryCountBefore'] = count( $wpdb->component_fuzz_get_queries() ); + } + + if ( 'type-error' === $scenario ) { + $error_message = 'Upload failed ' . $token; + $state['errorMessage'] = $error_message; + $_GET = array(); + $_POST = array(); + $_REQUEST = array( 'post_id' => (string) $parent_id ); + $_COOKIE = array(); + + \media_upload_type_form( 'image', null, new \WP_Error( 'component_fuzz_upload', $error_message ) ); + $state['returned'] = true; + return; + } + + $_GET = array(); + $_POST = array( + '_wpnonce' => $nonce, + ); + $_COOKIE = array(); + + if ( 'insert-gallery' === $scenario ) { + $_POST['insert-gallery'] = '1'; + $_REQUEST = $_POST; + \media_upload_form_handler(); + $state['returned'] = true; + return; + } + + $allowed_title = 'Dispatch title ' . $token; + $denied_title = 'Denied title ' . $token; + $_POST['send'] = array( $allowed_id => 'Send' ); + $_POST['attachments'] = array( + $allowed_id => array( + 'post_title' => $allowed_title, + 'post_content' => 'Dispatch content ' . $token, + 'post_excerpt' => 'Dispatch excerpt ' . $token, + 'menu_order' => '7', + 'post_parent' => (string) $new_parent, + 'image_alt' => 'Alt ' . $token . '', + 'url' => 'http://example.test/?attachment_id=' . $allowed_id . '&cfz=' . rawurlencode( $token ), + ), + $denied_id => array( + 'post_title' => $denied_title, + 'post_content' => 'Denied content ' . $token, + 'post_excerpt' => 'Denied excerpt ' . $token, + 'menu_order' => '9', + 'post_parent' => (string) $new_parent, + 'image_alt' => 'Denied alt ' . $token, + 'url' => 'http://example.test/denied-' . rawurlencode( $token ), + ), + ); + $_REQUEST = $_POST; + + \media_upload_form_handler(); + $state['returned'] = true; + } catch ( \Throwable $e ) { + $state['throwable'] = self::describe_throwable( $e ); + } + } + + private static function media_upload_dispatch_child_result_has_expected_shape( array $result ): bool { + return array_key_exists( 'ok', $result ) + && array_key_exists( 'returned', $result ) + && is_string( $result['output'] ?? null ) + && is_array( $result['postsBefore'] ?? null ) + && is_array( $result['postsAfter'] ?? null ) + && is_array( $result['metaBefore'] ?? null ) + && is_array( $result['metaAfter'] ?? null ) + && is_array( $result['saveEvents'] ?? null ) + && is_array( $result['sendEvents'] ?? null ) + && is_array( $result['dieCalls'] ?? null ); + } + + private static function collect_media_upload_send_failures( array &$failures, array $case, array $result ): void { + $token = self::media_upload_dispatch_token( (string) ( $case['token'] ?? '' ) ); + $allowed_id = (int) ( $result['allowedId'] ?? 0 ); + $denied_id = (int) ( $result['deniedId'] ?? 0 ); + $new_parent = (int) ( $result['newParentId'] ?? 0 ); + $output = (string) ( $result['output'] ?? '' ); + $before_posts = $result['postsBefore']; + $after_posts = $result['postsAfter']; + $after_meta = $result['metaAfter']; + + self::collect_failure( + $failures, + str_contains( $output, 'win.send_to_editor(' ) + && str_contains( $output, 'data-cfz-upload' ) + && str_contains( $output, $token ) + && ! str_contains( $output, 'Denied title ' . $token ) + && 1 === count( $result['sendEvents'] ) + && $allowed_id === (int) ( $result['sendEvents'][0]['id'] ?? 0 ) + && true === ( $result['sendEvents'][0]['hasRel'] ?? null ), + 'media_upload_form_handler() send branch exits through media_send_to_editor() with filtered allowed attachment HTML only', + array( + 'output' => self::describe_string( $output ), + 'sendEvents' => $result['sendEvents'], + ) + ); + + self::collect_failure( + $failures, + 1 === count( $result['saveEvents'] ) + && $allowed_id === (int) ( $result['saveEvents'][0]['id'] ?? 0 ) + && self::media_upload_dispatch_post_matches( + $after_posts, + $allowed_id, + array( + 'post_title' => 'Dispatch title ' . $token, + 'post_content' => 'Dispatch content ' . $token . ' filtered-' . $token, + 'post_excerpt' => 'Dispatch excerpt ' . $token, + 'menu_order' => 7, + 'post_parent' => $new_parent, + ) + ) + && ( $after_meta[ (string) $allowed_id ] ?? null ) === 'Alt ' . $token, + 'allowed send attachment fields, parent, menu order, filtered content, and stripped alt meta are persisted', + array( + 'saveEvents' => $result['saveEvents'], + 'afterPost' => $after_posts[ (string) $allowed_id ] ?? null, + 'afterMeta' => $after_meta[ (string) $allowed_id ] ?? null, + ) + ); + + self::collect_failure( + $failures, + self::media_upload_dispatch_post_unchanged( $before_posts, $after_posts, array( $denied_id ) ) + && ( $result['metaBefore'][ (string) $denied_id ] ?? null ) === ( $after_meta[ (string) $denied_id ] ?? null ) + && array( $allowed_id, $denied_id ) === array_map( + static function ( array $event ): int { + return (int) ( $event['postId'] ?? 0 ); + }, + $result['capEvents'] + ), + 'denied attachment is capability-checked but not saved, sent, or meta-mutated', + array( + 'capEvents' => $result['capEvents'], + 'beforePost' => $before_posts[ (string) $denied_id ] ?? null, + 'afterPost' => $after_posts[ (string) $denied_id ] ?? null, + ) + ); + } + + private static function collect_media_upload_gallery_exit_failures( array &$failures, array $case, array $result ): void { + unset( $case ); + $output = (string) ( $result['output'] ?? '' ); + + self::collect_failure( + $failures, + str_contains( $output, 'win.tb_remove();' ) + && array() === ( $result['saveEvents'] ?? array() ) + && array() === ( $result['sendEvents'] ?? array() ) + && self::media_upload_dispatch_post_unchanged( $result['postsBefore'], $result['postsAfter'], array_map( 'intval', $result['allTrackedIds'] ?? array() ) ), + 'insert-gallery branch exits after closing Thickbox without saving or sending attachments', + array( + 'output' => self::describe_string( $output ), + 'saveEvents' => $result['saveEvents'] ?? array(), + 'sendEvents' => $result['sendEvents'] ?? array(), + ) + ); + } + + private static function collect_media_upload_type_error_failures( array &$failures, array $case, array $result ): void { + unset( $case ); + $output = (string) ( $result['output'] ?? '' ); + $error_message = (string) ( $result['errorMessage'] ?? '' ); + + self::collect_failure( + $failures, + str_contains( $output, 'id="media-upload-error"' ) + && str_contains( $output, 'Upload failed' ) + && str_contains( $output, '<script>alert(1)</script>' ) + && ! str_contains( $output, $error_message ) + && array() === ( $result['saveEvents'] ?? array() ) + && array() === ( $result['sendEvents'] ?? array() ), + 'media_upload_type_form() renders escaped WP_Error upload failure and exits before media items', + array( + 'output' => self::describe_string( $output ), + 'errorMessage' => $error_message, + ) + ); + } + + private static function media_upload_dispatch_post_matches( array $posts, int $id, array $expected ): bool { + $key = (string) $id; + foreach ( $expected as $field => $value ) { + if ( (string) $value !== (string) ( $posts[ $key ][ $field ] ?? null ) ) { + return false; + } + } + + return true; + } + + private static function media_upload_dispatch_post_unchanged( array $before, array $after, array $ids ): bool { + foreach ( $ids as $id ) { + $key = (string) (int) $id; + if ( ( $before[ $key ] ?? null ) !== ( $after[ $key ] ?? null ) ) { + return false; + } + } + + return true; + } + + private static function media_upload_dispatch_post_state( array $ids ): array { + $out = array(); + foreach ( array_values( array_unique( array_map( 'intval', $ids ) ) ) as $id ) { + $post = \get_post( $id ); + if ( ! $post instanceof \WP_Post ) { + $out[ (string) $id ] = null; + continue; + } + + $out[ (string) $id ] = array( + 'post_title' => (string) $post->post_title, + 'post_content' => (string) $post->post_content, + 'post_excerpt' => (string) $post->post_excerpt, + 'menu_order' => (int) $post->menu_order, + 'post_parent' => (int) $post->post_parent, + 'post_type' => (string) $post->post_type, + ); + } + + return $out; + } + + private static function media_upload_dispatch_alt_state( array $ids ): array { + $out = array(); + foreach ( array_values( array_unique( array_map( 'intval', $ids ) ) ) as $id ) { + $out[ (string) $id ] = \get_post_meta( $id, '_wp_attachment_image_alt', true ); + } + + return $out; + } + + private static function check_media_url_insert_dispatch_exits( \ComponentFuzz\FuzzContext $ctx ): array { + $missing = self::media_attach_action_child_missing_requirements(); + if ( array() !== $missing ) { + return self::row( + $ctx, + 'admin-media-chrome.legacy-url-insert-dispatch-exits', + true, + array( + 'missing' => $missing, + 'reason' => 'Required local subprocess APIs are unavailable.', + ), + 'skipped' + ); + } + + $failures = array(); + $runs = array(); + + foreach ( self::media_url_insert_cases( $ctx ) as $case ) { + $run = self::run_media_url_insert_child_process( $case ); + $result = is_array( $run['result'] ?? null ) ? $run['result'] : array(); + + $runs[ $case['label'] ] = array( + 'ok' => $run['ok'] ?? false, + 'exitCode' => $run['exitCode'] ?? null, + 'stderr' => self::describe_string( (string) ( $run['stderr'] ?? '' ) ), + 'stdout' => self::describe_string( (string) ( $run['stdout'] ?? '' ) ), + 'result' => array( + 'returned' => $result['returned'] ?? null, + 'output' => self::describe_string( (string) ( $result['output'] ?? '' ) ), + 'eventCount' => is_array( $result['events'] ?? null ) ? count( $result['events'] ) : null, + 'before' => $result['contentBefore'] ?? array(), + 'after' => $result['contentAfter'] ?? array(), + ), + ); + + self::collect_failure( + $failures, + true === ( $run['ok'] ?? false ) && self::media_url_insert_child_result_has_expected_shape( $result ), + "{$case['label']} child exits cleanly and reports structured JSON", + array( + 'run' => $run, + 'result' => $result, + ) + ); + + if ( ! self::media_url_insert_child_result_has_expected_shape( $result ) ) { + continue; + } + + self::collect_failure( + $failures, + false === (bool) ( $result['returned'] ?? true ) + && null === ( $result['throwable'] ?? null ), + "{$case['label']} reaches media_send_to_editor() exit without unexpected exceptions", + array( + 'returned' => $result['returned'] ?? null, + 'throwable' => $result['throwable'] ?? null, + ) + ); + + self::collect_media_url_insert_failures( $failures, $case, $result ); + } + + return self::row( + $ctx, + 'admin-media-chrome.legacy-url-insert-dispatch-exits', + array() === $failures, + array( + 'failures' => $failures, + 'runs' => $runs, + ) + ); + } + + private static function media_url_insert_cases( \ComponentFuzz\FuzzContext $ctx ): array { + $token = self::media_upload_dispatch_token( 'url_' . $ctx->identifier( 4, 9 ) ); + + return array( + array( + 'label' => 'image-relative-src', + 'seed' => $ctx->fork( 'image' )->seed(), + 'iteration' => $ctx->iteration(), + 'token' => $token . '_image', + 'mediaType' => 'image', + 'src' => 'media.example.test/uploads/image-' . $token . '.jpg?caption=', + 'alt' => 'Alt "' . $token, + 'align' => 'left" data-cfz="' . $token, + 'title' => '', + 'expectedHook' => 'image_send_to_editor_url', + 'expectedType' => 'image', + 'expectedTag' => 'img', + 'expectsHttp' => true, + ), + array( + 'label' => 'image-default-media-type', + 'seed' => $ctx->fork( 'image-default' )->seed(), + 'iteration' => $ctx->iteration(), + 'token' => $token . '_image_default', + 'mediaType' => 'image', + 'omitMediaType' => true, + 'src' => 'https://images.example.test/default-' . $token . '.png', + 'alt' => 'Default image alt ' . $token, + 'align' => 'center', + 'title' => '', + 'expectedHook' => 'image_send_to_editor_url', + 'expectedType' => 'image', + 'expectedTag' => 'img', + 'expectsHttp' => false, + ), + array( + 'label' => 'file-explicit-title', + 'seed' => $ctx->fork( 'file' )->seed(), + 'iteration' => $ctx->iteration(), + 'token' => $token . '_file', + 'mediaType' => 'file', + 'src' => 'https://files.example.test/report-' . $token . '.pdf?', + 'alt' => '', + 'align' => '', + 'title' => 'Report "' . $token, + 'expectedHook' => 'file_send_to_editor_url', + 'expectedType' => 'file', + 'expectedTag' => 'a', + 'expectsHttp' => false, + ), + array( + 'label' => 'audio-basename-title', + 'seed' => $ctx->fork( 'audio' )->seed(), + 'iteration' => $ctx->iteration(), + 'token' => $token . '_audio', + 'mediaType' => 'file', + 'src' => 'audio.example.test/tracks/song-' . $token . '.MP3', + 'alt' => '', + 'align' => '', + 'title' => '', + 'expectedHook' => 'audio_send_to_editor_url', + 'expectedType' => 'audio', + 'expectedTag' => 'a', + 'expectsHttp' => true, + ), + array( + 'label' => 'video-normalized-type', + 'seed' => $ctx->fork( 'video' )->seed(), + 'iteration' => $ctx->iteration(), + 'token' => $token . '_video', + 'mediaType' => 'not-image', + 'src' => 'https://video.example.test/clips/movie-' . $token . '.mp4', + 'alt' => '', + 'align' => '', + 'title' => 'Movie ' . $token, + 'expectedHook' => 'video_send_to_editor_url', + 'expectedType' => 'video', + 'expectedTag' => 'a', + 'expectsHttp' => false, + ), + array( + 'label' => 'misleading-video-pdf-stays-file', + 'seed' => $ctx->fork( 'misleading-video' )->seed(), + 'iteration' => $ctx->iteration(), + 'token' => $token . '_video_pdf', + 'mediaType' => 'video', + 'src' => 'https://files.example.test/not-video-' . $token . '.pdf', + 'alt' => '', + 'align' => '', + 'title' => 'Not video ' . $token, + 'expectedHook' => 'file_send_to_editor_url', + 'expectedType' => 'file', + 'expectedTag' => 'a', + 'expectsHttp' => false, + ), + ); + } + + private static function run_media_url_insert_child_process( array $case ): array { + $payload = json_encode( + array( 'case' => $case ), + JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE + ); + + if ( false === $payload ) { + return array( + 'ok' => false, + 'exitCode' => -1, + 'stdout' => '', + 'stderr' => 'json_encode failed', + 'result' => null, + ); + } + + $descriptors = array( + 0 => array( 'pipe', 'r' ), + 1 => array( 'pipe', 'w' ), + 2 => array( 'pipe', 'w' ), + ); + + // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.system_calls_proc_open -- Isolates wp_media_upload_handler() URL insert media_send_to_editor() exits in a local PHP subprocess. + $process = proc_open( array( PHP_BINARY, '-r', self::media_url_insert_child_program() ), $descriptors, $pipes, \ComponentFuzz\repo_root() ); + if ( ! is_resource( $process ) ) { + return array( + 'ok' => false, + 'exitCode' => -1, + 'stdout' => '', + 'stderr' => 'proc_open failed', + 'result' => null, + ); + } + + fwrite( $pipes[0], $payload ); + fclose( $pipes[0] ); + + $stdout = stream_get_contents( $pipes[1] ); + $stderr = stream_get_contents( $pipes[2] ); + fclose( $pipes[1] ); + fclose( $pipes[2] ); + + $exit_code = proc_close( $process ); + $result = json_decode( (string) $stdout, true ); + + return array( + 'ok' => 0 === $exit_code && is_array( $result ) && true === ( $result['ok'] ?? null ), + 'exitCode' => $exit_code, + 'stdout' => (string) $stdout, + 'stderr' => (string) $stderr, + 'result' => is_array( $result ) ? $result : null, + ); + } + + private static function media_url_insert_child_program(): string { + return <<<'PHP' +$component_fuzz_admin_media_raw = stream_get_contents( STDIN ); +$component_fuzz_admin_media_payload = json_decode( $component_fuzz_admin_media_raw, true ); +$case = is_array( $component_fuzz_admin_media_payload['case'] ?? null ) ? $component_fuzz_admin_media_payload['case'] : array(); + +require_once getcwd() . '/tools/component-fuzz/lib/autoload.php'; +\ComponentFuzz\WpBootstrap::load(); + +\ComponentFuzz\Surfaces\AdminMediaChromeSurface::run_media_url_insert_child( $case ); +PHP; + } + + public static function run_media_url_insert_child( array $case ): void { + ini_set( 'display_errors', '0' ); + self::prepare_runtime(); + + $state = array( + 'ok' => false, + 'label' => (string) ( $case['label'] ?? 'url-insert' ), + 'token' => self::media_upload_dispatch_token( (string) ( $case['token'] ?? 'url_token' ) ), + 'events' => array(), + 'contentBefore' => self::media_url_insert_content_counts(), + 'contentAfter' => array(), + 'returned' => false, + 'throwable' => null, + 'output' => '', + ); + + $buffer_level = ob_get_level(); + ob_start(); + + register_shutdown_function( + static function () use ( &$state, $buffer_level ): void { + $output = ''; + while ( ob_get_level() > $buffer_level ) { + $chunk = ob_get_clean(); + if ( is_string( $chunk ) ) { + $output = $chunk . $output; + } + } + + $state['output'] = $output; + $state['contentAfter'] = self::media_url_insert_content_counts(); + $state['ok'] = null === $state['throwable']; + echo json_encode( $state, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE ) . "\n"; + } + ); + + try { + $GLOBALS['pagenow'] = 'media-upload.php'; + $_SERVER['HTTP_HOST'] = 'example.test'; + $_SERVER['HTTPS'] = 'off'; + $_SERVER['PHP_SELF'] = '/wp-admin/media-upload.php'; + $_SERVER['REQUEST_METHOD'] = 'POST'; + $_SERVER['REQUEST_URI'] = '/wp-admin/media-upload.php?type=' . rawurlencode( (string) ( $case['mediaType'] ?? 'image' ) ) . '&tab=type_url'; + $_SERVER['HTTP_REFERER'] = 'http://example.test/wp-admin/media-upload.php?type=image&tab=type_url'; + $_SERVER['HTTP_USER_AGENT'] = 'component-fuzz/admin-media-url-insert'; + $_SERVER['REMOTE_ADDR'] = '198.51.100.45'; + $_SERVER['SERVER_PORT'] = '80'; + + $_GET = array( + 'type' => (string) ( $case['mediaType'] ?? 'image' ), + 'tab' => 'type_url', + ); + $_POST = array( + 'insertonlybutton' => '1', + 'src' => (string) ( $case['src'] ?? '' ), + 'title' => (string) ( $case['title'] ?? '' ), + 'alt' => (string) ( $case['alt'] ?? '' ), + 'align' => (string) ( $case['align'] ?? '' ), + ); + if ( empty( $case['omitMediaType'] ) ) { + $_POST['media_type'] = (string) ( $case['mediaType'] ?? 'image' ); + } + $_REQUEST = $_GET + $_POST; + $_FILES = array(); + $_COOKIE = array(); + + $image_filter = static function ( string $html, string $src, string $alt, string $align ) use ( &$state ): string { + $state['events'][] = array( + 'hook' => 'image_send_to_editor_url', + 'html' => $html, + 'src' => $src, + 'alt' => $alt, + 'align' => $align, + ); + return $html . 'image'; + }; + $file_filter = static function ( string $html, string $src, string $title ) use ( &$state ): string { + $state['events'][] = array( + 'hook' => current_filter(), + 'html' => $html, + 'src' => $src, + 'title' => $title, + ); + return $html . 'file'; + }; + + \add_filter( 'image_send_to_editor_url', $image_filter, 10, 4 ); + \add_filter( 'file_send_to_editor_url', $file_filter, 10, 3 ); + \add_filter( 'audio_send_to_editor_url', $file_filter, 10, 3 ); + \add_filter( 'video_send_to_editor_url', $file_filter, 10, 3 ); + + try { + \wp_media_upload_handler(); + $state['returned'] = true; + } finally { + \remove_filter( 'video_send_to_editor_url', $file_filter, 10 ); + \remove_filter( 'audio_send_to_editor_url', $file_filter, 10 ); + \remove_filter( 'file_send_to_editor_url', $file_filter, 10 ); + \remove_filter( 'image_send_to_editor_url', $image_filter, 10 ); + } + } catch ( \Throwable $e ) { + $state['throwable'] = self::describe_throwable( $e ); + } + } + + private static function media_url_insert_content_counts(): array { + if ( isset( $GLOBALS['wpdb'] ) && $GLOBALS['wpdb'] instanceof \Component_Fuzz_WPDB_Stub ) { + return $GLOBALS['wpdb']->component_fuzz_content_counts(); + } + + return array(); + } + + private static function media_url_insert_child_result_has_expected_shape( array $result ): bool { + return array_key_exists( 'ok', $result ) + && array_key_exists( 'returned', $result ) + && is_string( $result['output'] ?? null ) + && is_array( $result['events'] ?? null ) + && is_array( $result['contentBefore'] ?? null ) + && is_array( $result['contentAfter'] ?? null ); + } + + private static function collect_media_url_insert_failures( array &$failures, array $case, array $result ): void { + $output = (string) ( $result['output'] ?? '' ); + $events = is_array( $result['events'] ?? null ) ? $result['events'] : array(); + $event = $events[0] ?? array(); + $html = (string) ( $event['html'] ?? '' ); + $src = (string) ( $event['src'] ?? '' ); + $hook = (string) ( $event['hook'] ?? '' ); + + self::collect_failure( + $failures, + 1 === count( $events ) + && (string) ( $case['expectedHook'] ?? '' ) === $hook + && str_contains( $output, 'win.send_to_editor(' ) + && str_contains( $output, 'data-cfz-url' ) + && str_contains( $output, (string) ( $result['token'] ?? '' ) ) + && ! str_contains( $output, '' ) + && ! str_contains( $output, '' ) + && '' !== (string) ( $event['title'] ?? '' ), + 'non-image URL insert renders escaped link HTML with explicit or basename-derived title text', + array( + 'event' => $event, + 'html' => self::describe_string( $html ), + ) + ); + } + } + + private static function check_media_gallery_save_iframe_dispatch( \ComponentFuzz\FuzzContext $ctx ): array { + $missing = self::media_attach_action_child_missing_requirements(); + if ( array() !== $missing ) { + return self::row( + $ctx, + 'admin-media-chrome.legacy-gallery-save-iframe-dispatch', + true, + array( + 'missing' => $missing, + 'reason' => 'Required local subprocess APIs are unavailable.', + ), + 'skipped' + ); + } + + $failures = array(); + $runs = array(); + + foreach ( self::media_gallery_save_cases( $ctx ) as $case ) { + $run = self::run_media_gallery_save_child_process( $case ); + $result = is_array( $run['result'] ?? null ) ? $run['result'] : array(); + + $runs[ $case['label'] ] = array( + 'ok' => $run['ok'] ?? false, + 'exitCode' => $run['exitCode'] ?? null, + 'stderr' => self::describe_string( (string) ( $run['stderr'] ?? '' ) ), + 'stdout' => self::describe_string( (string) ( $run['stdout'] ?? '' ) ), + 'result' => array( + 'returned' => $result['returned'] ?? null, + 'output' => self::describe_string( (string) ( $result['output'] ?? '' ) ), + 'expectedIds' => $result['expectedIds'] ?? array(), + 'fieldEventIds' => array_map( + static function ( array $event ): int { + return (int) ( $event['id'] ?? 0 ); + }, + is_array( $result['fieldEvents'] ?? null ) ? $result['fieldEvents'] : array() + ), + 'formUrlEvents' => $result['formUrlEvents'] ?? array(), + 'scriptStatus' => $result['scriptStatus'] ?? array(), + 'saveEventCount' => is_array( $result['saveEvents'] ?? null ) ? count( $result['saveEvents'] ) : null, + 'sendEventCount' => is_array( $result['sendEvents'] ?? null ) ? count( $result['sendEvents'] ) : null, + 'dieCalls' => $result['dieCalls'] ?? array(), + ), + ); + + self::collect_failure( + $failures, + true === ( $run['ok'] ?? false ) && self::media_gallery_save_child_result_has_expected_shape( $result ), + "{$case['label']} child renders gallery save iframe and reports structured JSON", + array( + 'run' => $run, + 'result' => $result, + ) + ); + + if ( ! self::media_gallery_save_child_result_has_expected_shape( $result ) ) { + continue; + } + + self::collect_failure( + $failures, + true === (bool) ( $result['returned'] ?? false ) + && null === ( $result['throwable'] ?? null ) + && array() === ( $result['dieCalls'] ?? array() ), + "{$case['label']} returns normally after wp_iframe() without wp_die or unexpected exceptions", + array( + 'returned' => $result['returned'] ?? null, + 'throwable' => $result['throwable'] ?? null, + 'dieCalls' => $result['dieCalls'] ?? array(), + ) + ); + + self::collect_media_gallery_save_failures( $failures, $case, $result ); + } + + return self::row( + $ctx, + 'admin-media-chrome.legacy-gallery-save-iframe-dispatch', + array() === $failures, + array( + 'failures' => $failures, + 'runs' => $runs, + ) + ); + } + + private static function media_gallery_save_cases( \ComponentFuzz\FuzzContext $ctx ): array { + $build = static function ( string $label, string $scenario, bool $chromeless, \ComponentFuzz\FuzzContext $case_ctx ): array { + $token = self::media_upload_dispatch_token( 'gallery_' . $case_ctx->identifier( 4, 9 ) ); + + return array( + 'label' => $label, + 'scenario' => $scenario, + 'chromeless' => $chromeless, + 'seed' => $case_ctx->seed(), + 'iteration' => $case_ctx->iteration(), + 'token' => $token, + ); + }; + + return array( + $build( 'parent-gallery-save', 'parent', false, $ctx->fork( 'parent-gallery' ) ), + $build( 'attachment-gallery-save-chromeless', 'attachment', true, $ctx->fork( 'attachment-gallery' ) ), + ); + } + + private static function run_media_gallery_save_child_process( array $case ): array { + $payload = json_encode( + array( 'case' => $case ), + JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE + ); + + if ( false === $payload ) { + return array( + 'ok' => false, + 'exitCode' => -1, + 'stdout' => '', + 'stderr' => 'json_encode failed', + 'result' => null, + ); + } + + $descriptors = array( + 0 => array( 'pipe', 'r' ), + 1 => array( 'pipe', 'w' ), + 2 => array( 'pipe', 'w' ), + ); + + // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.system_calls_proc_open -- Isolates wp_media_upload_handler() gallery save iframe output in a local PHP subprocess. + $process = proc_open( array( PHP_BINARY, '-r', self::media_gallery_save_child_program() ), $descriptors, $pipes, \ComponentFuzz\repo_root() ); + if ( ! is_resource( $process ) ) { + return array( + 'ok' => false, + 'exitCode' => -1, + 'stdout' => '', + 'stderr' => 'proc_open failed', + 'result' => null, + ); + } + + fwrite( $pipes[0], $payload ); + fclose( $pipes[0] ); + + $stdout = stream_get_contents( $pipes[1] ); + $stderr = stream_get_contents( $pipes[2] ); + fclose( $pipes[1] ); + fclose( $pipes[2] ); + + $exit_code = proc_close( $process ); + $result = json_decode( (string) $stdout, true ); + + return array( + 'ok' => 0 === $exit_code && is_array( $result ) && true === ( $result['ok'] ?? null ), + 'exitCode' => $exit_code, + 'stdout' => (string) $stdout, + 'stderr' => (string) $stderr, + 'result' => is_array( $result ) ? $result : null, + ); + } + + private static function media_gallery_save_child_program(): string { + return <<<'PHP' +$component_fuzz_admin_media_raw = stream_get_contents( STDIN ); +$component_fuzz_admin_media_payload = json_decode( $component_fuzz_admin_media_raw, true ); +$case = is_array( $component_fuzz_admin_media_payload['case'] ?? null ) ? $component_fuzz_admin_media_payload['case'] : array(); + +require_once getcwd() . '/tools/component-fuzz/lib/autoload.php'; +\ComponentFuzz\WpBootstrap::load(); + +\ComponentFuzz\Surfaces\AdminMediaChromeSurface::run_media_gallery_save_child( $case ); +PHP; + } + + public static function run_media_gallery_save_child( array $case ): void { + ini_set( 'display_errors', '0' ); + self::prepare_runtime(); + + $ctx = new \ComponentFuzz\FuzzContext( (int) ( $case['seed'] ?? 1 ), self::NAME, (int) ( $case['iteration'] ?? 0 ) ); + $scenario = (string) ( $case['scenario'] ?? 'parent' ); + $token = self::media_upload_dispatch_token( (string) ( $case['token'] ?? $ctx->identifier( 4, 9 ) ) ); + $state = array( + 'ok' => false, + 'label' => (string) ( $case['label'] ?? 'gallery-save' ), + 'scenario' => $scenario, + 'token' => $token, + 'postId' => 0, + 'parentId' => 0, + 'expectedIds' => array(), + 'absentIds' => array(), + 'allTrackedIds' => array(), + 'postsBefore' => array(), + 'postsAfter' => array(), + 'metaBefore' => array(), + 'metaAfter' => array(), + 'fieldEvents' => array(), + 'formUrlEvents' => array(), + 'saveEvents' => array(), + 'sendEvents' => array(), + 'dieCalls' => array(), + 'scriptStatus' => array(), + 'returned' => false, + 'returnType' => null, + 'throwable' => null, + 'output' => '', + ); + + $buffer_level = ob_get_level(); + ob_start(); + + register_shutdown_function( + static function () use ( &$state, $buffer_level ): void { + $output = ''; + while ( ob_get_level() > $buffer_level ) { + $chunk = ob_get_clean(); + if ( is_string( $chunk ) ) { + $output = $chunk . $output; + } + } + + $tracked_ids = array_map( 'intval', $state['allTrackedIds'] ); + $state['output'] = $output; + $state['postsAfter'] = self::media_upload_dispatch_post_state( $tracked_ids ); + $state['metaAfter'] = self::media_upload_dispatch_alt_state( $tracked_ids ); + $state['scriptStatus'] = self::media_gallery_save_script_status( 'admin-gallery' ); + $state['ok'] = null === $state['throwable']; + echo json_encode( $state, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE ) . "\n"; + } + ); + + try { + $parent_id = self::seed_parent_post( $ctx->fork( 'parent' ) ); + $image_one = self::seed_attachment( + $ctx->fork( 'image-one' ), + 'image/jpeg', + array( + 'parent_id' => $parent_id, + 'post_title' => 'Gallery one ' . $token, + 'alt' => 'Gallery alt one ' . $token, + ) + ); + $image_two = self::seed_attachment( + $ctx->fork( 'image-two' ), + 'image/png', + array( + 'parent_id' => $parent_id, + 'post_title' => 'Gallery two ' . $token, + 'alt' => 'Gallery alt two ' . $token, + ) + ); + $outside = self::seed_attachment( + $ctx->fork( 'outside' ), + 'application/pdf', + array( + 'post_title' => 'Outside gallery ' . $token, + ) + ); + + $image_one_id = (int) $image_one->ID; + $image_two_id = (int) $image_two->ID; + $outside_id = (int) $outside->ID; + $post_id = 'attachment' === $scenario ? $image_one_id : $parent_id; + $expected_ids = 'attachment' === $scenario ? array( $image_one_id ) : array( $image_one_id, $image_two_id ); + $absent_ids = 'attachment' === $scenario ? array( $image_two_id, $outside_id ) : array( $outside_id ); + $tracked_ids = array( $parent_id, $image_one_id, $image_two_id, $outside_id ); + + $state['postId'] = $post_id; + $state['parentId'] = $parent_id; + $state['expectedIds'] = $expected_ids; + $state['absentIds'] = $absent_ids; + $state['allTrackedIds'] = $tracked_ids; + $state['postsBefore'] = self::media_upload_dispatch_post_state( $tracked_ids ); + $state['metaBefore'] = self::media_upload_dispatch_alt_state( $tracked_ids ); + + $GLOBALS['pagenow'] = 'media-upload.php'; + $GLOBALS['type'] = 'image'; + $GLOBALS['tab'] = 'gallery'; + $GLOBALS['body_id'] = 'component-fuzz-gallery-save'; + + $_SERVER['HTTP_HOST'] = 'example.test'; + $_SERVER['HTTPS'] = 'off'; + $_SERVER['PHP_SELF'] = '/wp-admin/media-upload.php'; + $_SERVER['REQUEST_METHOD'] = 'POST'; + $_SERVER['REQUEST_URI'] = '/wp-admin/media-upload.php?type=image&tab=gallery&post_id=' . rawurlencode( (string) $post_id ); + $_SERVER['HTTP_REFERER'] = 'http://example.test/wp-admin/media-upload.php?type=image&tab=gallery&post_id=' . rawurlencode( (string) $post_id ); + $_SERVER['HTTP_USER_AGENT'] = 'component-fuzz/admin-media-gallery-save'; + $_SERVER['REMOTE_ADDR'] = '198.51.100.46'; + $_SERVER['SERVER_PORT'] = '80'; + + $_GET = array( + 'type' => 'image', + 'tab' => 'gallery', + 'post_id' => (string) $post_id, + ); + if ( ! empty( $case['chromeless'] ) ) { + $_GET['chromeless'] = '1'; + } + $_POST = array( + '_wpnonce' => \wp_create_nonce( 'media-form' ), + 'save' => 'Save all changes', + 'post_id' => (string) $post_id, + 'type' => 'image', + 'tab' => 'gallery', + 'attachments' => array( + $image_one_id => array( + 'post_title' => 'Submitted title ' . $token, + 'post_content' => 'Submitted content ' . $token, + 'post_excerpt' => 'Submitted excerpt ' . $token, + 'menu_order' => '99', + 'image_alt' => 'Submitted alt ' . $token, + ), + $image_two_id => array( + 'post_title' => 'Submitted sibling title ' . $token, + 'post_content' => 'Submitted sibling content ' . $token, + 'post_excerpt' => 'Submitted sibling excerpt ' . $token, + 'menu_order' => '88', + 'image_alt' => 'Submitted sibling alt ' . $token, + ), + ), + ); + $_REQUEST = $_GET + $_POST; + $_FILES = array(); + $_COOKIE = array(); + + \wp_register_script( 'admin-gallery', '/wp-admin/js/gallery.js', array(), false ); + + $form_url_filter = static function ( string $url, string $type ) use ( &$state, $token ): string { + $state['formUrlEvents'][] = array( + 'url' => $url, + 'type' => $type, + ); + return \add_query_arg( 'cfz_gallery', $token, $url ); + }; + $fields_filter = static function ( array $fields, \WP_Post $post ) use ( &$state, $token ): array { + $state['fieldEvents'][] = array( + 'id' => (int) $post->ID, + 'title' => (string) $post->post_title, + ); + $fields['component_fuzz_gallery'] = array( + 'label' => 'Component Fuzz Gallery', + 'value' => 'Gallery custom field ' . $token, + ); + return $fields; + }; + $save_filter = static function ( array $post, array $attachment ) use ( &$state ): array { + $state['saveEvents'][] = array( + 'id' => (int) ( $post['ID'] ?? 0 ), + 'title' => (string) ( $attachment['post_title'] ?? '' ), + ); + return $post; + }; + $send_filter = static function ( string $html, int $send_id, array $attachment ) use ( &$state ): string { + $state['sendEvents'][] = array( + 'id' => $send_id, + 'html' => $html, + 'title' => (string) ( $attachment['post_title'] ?? '' ), + ); + return $html; + }; + $user_has_cap_filter = static function ( array $allcaps, array $caps, array $args, $user = null ): array { + unset( $args, $user ); + foreach ( $caps as $cap ) { + $allcaps[ $cap ] = 'do_not_allow' !== $cap; + } + return $allcaps; + }; + $die_handler_filter = static function () use ( &$state ): callable { + return static function ( $message = '', $title = '', $args = array() ) use ( &$state ): void { + $state['dieCalls'][] = array( + 'message' => self::media_attach_action_die_message( $message ), + 'title' => self::media_attach_action_die_message( $title ), + 'args' => is_array( $args ) ? $args : array(), + ); + exit; + }; + }; + + \add_filter( 'media_upload_form_url', $form_url_filter, 10, 2 ); + \add_filter( 'attachment_fields_to_edit', $fields_filter, 11, 2 ); + \add_filter( 'attachment_fields_to_save', $save_filter, 10, 2 ); + \add_filter( 'media_send_to_editor', $send_filter, 10, 3 ); + \add_filter( 'user_has_cap', $user_has_cap_filter, 10, 4 ); + \add_filter( 'wp_die_handler', $die_handler_filter, PHP_INT_MAX ); + + \wp_set_current_user( 1 ); + if ( isset( $GLOBALS['current_user'] ) && $GLOBALS['current_user'] instanceof \WP_User ) { + $GLOBALS['current_user']->allcaps = array( 'exist' => true ); + } + + try { + $return_value = \wp_media_upload_handler(); + $state['returnType'] = gettype( $return_value ); + $state['returned'] = true; + } finally { + \remove_filter( 'wp_die_handler', $die_handler_filter, PHP_INT_MAX ); + \remove_filter( 'user_has_cap', $user_has_cap_filter, 10 ); + \remove_filter( 'media_send_to_editor', $send_filter, 10 ); + \remove_filter( 'attachment_fields_to_save', $save_filter, 10 ); + \remove_filter( 'attachment_fields_to_edit', $fields_filter, 11 ); + \remove_filter( 'media_upload_form_url', $form_url_filter, 10 ); + } + } catch ( \Throwable $e ) { + $state['throwable'] = self::describe_throwable( $e ); + } + } + + private static function media_gallery_save_child_result_has_expected_shape( array $result ): bool { + return array_key_exists( 'ok', $result ) + && array_key_exists( 'returned', $result ) + && is_string( $result['output'] ?? null ) + && is_array( $result['expectedIds'] ?? null ) + && is_array( $result['absentIds'] ?? null ) + && is_array( $result['postsBefore'] ?? null ) + && is_array( $result['postsAfter'] ?? null ) + && is_array( $result['metaBefore'] ?? null ) + && is_array( $result['metaAfter'] ?? null ) + && is_array( $result['fieldEvents'] ?? null ) + && is_array( $result['formUrlEvents'] ?? null ) + && is_array( $result['saveEvents'] ?? null ) + && is_array( $result['sendEvents'] ?? null ) + && is_array( $result['dieCalls'] ?? null ) + && is_array( $result['scriptStatus'] ?? null ); + } + + private static function media_gallery_save_script_status( string $handle ): array { + $status = array(); + foreach ( array( 'registered', 'enqueued', 'queue', 'to_do', 'done' ) as $state ) { + $status[ $state ] = \wp_script_is( $handle, $state ); + } + return $status; + } + + private static function media_type_iframe_style_status(): array { + $status = array(); + foreach ( array( 'colors', 'deprecated-media' ) as $handle ) { + $status[ $handle ] = array(); + foreach ( array( 'registered', 'enqueued', 'queue', 'to_do', 'done' ) as $state ) { + $status[ $handle ][ $state ] = \wp_style_is( $handle, $state ); + } + } + return $status; + } + + private static function collect_media_gallery_save_failures( array &$failures, array $case, array $result ): void { + $output = (string) ( $result['output'] ?? '' ); + $token = self::media_upload_dispatch_token( (string) ( $result['token'] ?? $case['token'] ?? '' ) ); + $expected_ids = array_values( array_map( 'intval', $result['expectedIds'] ?? array() ) ); + $absent_ids = array_values( array_map( 'intval', $result['absentIds'] ?? array() ) ); + $field_ids = array_map( + static function ( array $event ): int { + return (int) ( $event['id'] ?? 0 ); + }, + $result['fieldEvents'] ?? array() + ); + sort( $expected_ids ); + sort( $field_ids ); + + $expected_media_markup = true; + foreach ( $expected_ids as $id ) { + $expected_media_markup = $expected_media_markup + && str_contains( $output, "id='media-item-$id'" ) + && str_contains( $output, "attachments[$id][menu_order]" ) + && str_contains( $output, "attachments[$id][component_fuzz_gallery]" ); + } + + $absent_media_markup = true; + foreach ( $absent_ids as $id ) { + $absent_media_markup = $absent_media_markup && ! str_contains( $output, "id='media-item-$id'" ); + } + + self::collect_failure( + $failures, + str_contains( $output, 'pagenow = \'media-upload-popup\'' ) + && str_contains( $output, ' self::describe_string( $output ), + 'expectedIds' => $expected_ids, + 'absentIds' => $absent_ids, + ) + ); + + self::collect_failure( + $failures, + ! str_contains( $output, 'Submitted title ' . $token ) + && ! str_contains( $output, 'Gallery one ' . $token ) + && ! str_contains( $output, 'Gallery custom field ' . $token ) + && str_contains( $output, 'Gallery custom field <script>alert(1)</script> ' . $token ), + 'gallery save iframe escapes generated attachment titles and custom field values', + array( 'output' => self::describe_string( $output ) ) + ); + + self::collect_failure( + $failures, + $expected_ids === $field_ids + && 1 === count( $result['formUrlEvents'] ?? array() ) + && 'image' === (string) ( $result['formUrlEvents'][0]['type'] ?? '' ) + && str_contains( (string) ( $result['formUrlEvents'][0]['url'] ?? '' ), 'tab=gallery' ) + && str_contains( (string) ( $result['formUrlEvents'][0]['url'] ?? '' ), 'post_id=' . (string) ( $result['postId'] ?? 0 ) ), + 'gallery save iframe routes the gallery form URL and edit fields through the expected filters for rendered attachments only', + array( + 'fieldEvents' => $result['fieldEvents'] ?? array(), + 'formUrlEvents' => $result['formUrlEvents'] ?? array(), + 'expectedIds' => $expected_ids, + ) + ); + + $script_status = $result['scriptStatus'] ?? array(); + self::collect_failure( + $failures, + true === ( $script_status['enqueued'] ?? false ) + || true === ( $script_status['to_do'] ?? false ) + || true === ( $script_status['done'] ?? false ), + 'save branch enqueues the legacy admin-gallery script handle before rendering the iframe', + array( 'scriptStatus' => $script_status ) + ); + + self::collect_failure( + $failures, + empty( $case['chromeless'] ) + ? str_contains( $output, 'id="media-upload-header"' ) + : ! str_contains( $output, 'id="media-upload-header"' ), + 'media_upload_header() honors the chromeless request flag inside the gallery iframe', + array( + 'chromeless' => $case['chromeless'] ?? false, + 'output' => self::describe_string( $output ), + ) + ); + + self::collect_failure( + $failures, + ( $result['postsBefore'] ?? array() ) === ( $result['postsAfter'] ?? array() ) + && ( $result['metaBefore'] ?? array() ) === ( $result['metaAfter'] ?? array() ) + && array() === ( $result['saveEvents'] ?? array() ) + && array() === ( $result['sendEvents'] ?? array() ), + 'save branch does not fall through to attachment field persistence or send-to-editor handling', + array( + 'postsBefore' => $result['postsBefore'] ?? array(), + 'postsAfter' => $result['postsAfter'] ?? array(), + 'metaBefore' => $result['metaBefore'] ?? array(), + 'metaAfter' => $result['metaAfter'] ?? array(), + 'saveEvents' => $result['saveEvents'] ?? array(), + 'sendEvents' => $result['sendEvents'] ?? array(), + ) + ); + } + + private static function check_media_type_iframe_dispatch( \ComponentFuzz\FuzzContext $ctx ): array { + $missing = self::media_attach_action_child_missing_requirements(); + if ( array() !== $missing ) { + return self::row( + $ctx, + 'admin-media-chrome.legacy-type-iframe-dispatch', + true, + array( + 'missing' => $missing, + 'reason' => 'Required local subprocess APIs are unavailable.', + ), + 'skipped' + ); + } + + $failures = array(); + $runs = array(); + + foreach ( self::media_type_iframe_cases( $ctx ) as $case ) { + $run = self::run_media_type_iframe_child_process( $case ); + $result = is_array( $run['result'] ?? null ) ? $run['result'] : array(); + + $runs[ $case['label'] ] = array( + 'ok' => $run['ok'] ?? false, + 'exitCode' => $run['exitCode'] ?? null, + 'stderr' => self::describe_string( (string) ( $run['stderr'] ?? '' ) ), + 'stdout' => self::describe_string( (string) ( $run['stdout'] ?? '' ) ), + 'result' => array( + 'returned' => $result['returned'] ?? null, + 'returnType' => $result['returnType'] ?? null, + 'output' => self::describe_string( (string) ( $result['output'] ?? '' ) ), + 'contentBefore' => $result['contentBefore'] ?? array(), + 'contentAfter' => $result['contentAfter'] ?? array(), + 'formUrlEvents' => $result['formUrlEvents'] ?? array(), + 'typeUrlEventCount' => is_array( $result['typeUrlEvents'] ?? null ) ? count( $result['typeUrlEvents'] ) : null, + 'uploadParamCount' => is_array( $result['uploadPostParamEvents'] ?? null ) ? count( $result['uploadPostParamEvents'] ) : null, + 'pluploadCount' => is_array( $result['pluploadEvents'] ?? null ) ? count( $result['pluploadEvents'] ) : null, + 'iframeActions' => $result['iframeActionCounts'] ?? array(), + 'styleStatus' => $result['styleStatus'] ?? array(), + 'saveEventCount' => is_array( $result['saveEvents'] ?? null ) ? count( $result['saveEvents'] ) : null, + 'sendEventCount' => is_array( $result['sendEvents'] ?? null ) ? count( $result['sendEvents'] ) : null, + 'dieCalls' => $result['dieCalls'] ?? array(), + ), + ); + + self::collect_failure( + $failures, + true === ( $run['ok'] ?? false ) && self::media_type_iframe_child_result_has_expected_shape( $result ), + "{$case['label']} child renders type iframe and reports structured JSON", + array( + 'run' => $run, + 'result' => $result, + ) + ); + + if ( ! self::media_type_iframe_child_result_has_expected_shape( $result ) ) { + continue; + } + + self::collect_failure( + $failures, + true === (bool) ( $result['returned'] ?? false ) + && 'NULL' === (string) ( $result['returnType'] ?? '' ) + && null === ( $result['throwable'] ?? null ) + && array() === ( $result['dieCalls'] ?? array() ), + "{$case['label']} returns normally after wp_iframe() without wp_die or unexpected exceptions", + array( + 'returned' => $result['returned'] ?? null, + 'returnType' => $result['returnType'] ?? null, + 'throwable' => $result['throwable'] ?? null, + 'dieCalls' => $result['dieCalls'] ?? array(), + ) + ); + + self::collect_media_type_iframe_failures( $failures, $case, $result ); + } + + return self::row( + $ctx, + 'admin-media-chrome.legacy-type-iframe-dispatch', + array() === $failures, + array( + 'failures' => $failures, + 'runs' => $runs, + ) + ); + } + + private static function media_type_iframe_cases( \ComponentFuzz\FuzzContext $ctx ): array { + $build = static function ( string $label, string $scenario, array $args, \ComponentFuzz\FuzzContext $case_ctx ): array { + $token = self::media_upload_dispatch_token( 'type_' . $case_ctx->identifier( 4, 9 ) ); + + return array_merge( + array( + 'label' => $label, + 'scenario' => $scenario, + 'seed' => $case_ctx->seed(), + 'iteration' => $case_ctx->iteration(), + 'token' => $token, + ), + $args + ); + }; + + return array( + $build( + 'type-url-default-image', + 'type-url', + array( + 'requestType' => null, + 'expectedType' => 'image', + 'disableCaptions' => false, + 'chromeless' => false, + ), + $ctx->fork( 'type-url-default-image' ) + ), + $build( + 'type-url-video', + 'type-url', + array( + 'requestType' => 'video', + 'expectedType' => 'video', + 'disableCaptions' => true, + 'chromeless' => false, + ), + $ctx->fork( 'type-url-video' ) + ), + $build( + 'type-url-invalid-coerces-image', + 'type-url', + array( + 'requestType' => 'svg', + 'expectedType' => 'image', + 'disableCaptions' => true, + 'chromeless' => true, + ), + $ctx->fork( 'type-url-invalid' ) + ), + $build( + 'default-ignores-request-type', + 'default', + array( + 'requestType' => 'audio', + 'requestTab' => 'library', + 'expectedType' => 'image', + 'disableCaptions' => false, + 'chromeless' => false, + ), + $ctx->fork( 'default-type-form' ) + ), + ); + } + + private static function run_media_type_iframe_child_process( array $case ): array { + $payload = json_encode( + array( 'case' => $case ), + JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE + ); + + if ( false === $payload ) { + return array( + 'ok' => false, + 'exitCode' => -1, + 'stdout' => '', + 'stderr' => 'json_encode failed', + 'result' => null, + ); + } + + $descriptors = array( + 0 => array( 'pipe', 'r' ), + 1 => array( 'pipe', 'w' ), + 2 => array( 'pipe', 'w' ), + ); + + // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.system_calls_proc_open -- Isolates wp_media_upload_handler() no-POST iframe branches in a local PHP subprocess. + $process = proc_open( array( PHP_BINARY, '-r', self::media_type_iframe_child_program() ), $descriptors, $pipes, \ComponentFuzz\repo_root() ); + if ( ! is_resource( $process ) ) { + return array( + 'ok' => false, + 'exitCode' => -1, + 'stdout' => '', + 'stderr' => 'proc_open failed', + 'result' => null, + ); + } + + fwrite( $pipes[0], $payload ); + fclose( $pipes[0] ); + + $stdout = stream_get_contents( $pipes[1] ); + $stderr = stream_get_contents( $pipes[2] ); + fclose( $pipes[1] ); + fclose( $pipes[2] ); + + $exit_code = proc_close( $process ); + $result = json_decode( (string) $stdout, true ); + + return array( + 'ok' => 0 === $exit_code && is_array( $result ) && true === ( $result['ok'] ?? null ), + 'exitCode' => $exit_code, + 'stdout' => (string) $stdout, + 'stderr' => (string) $stderr, + 'result' => is_array( $result ) ? $result : null, + ); + } + + private static function media_type_iframe_child_program(): string { + return <<<'PHP' +$component_fuzz_admin_media_raw = stream_get_contents( STDIN ); +$component_fuzz_admin_media_payload = json_decode( $component_fuzz_admin_media_raw, true ); +$case = is_array( $component_fuzz_admin_media_payload['case'] ?? null ) ? $component_fuzz_admin_media_payload['case'] : array(); + +require_once getcwd() . '/tools/component-fuzz/lib/autoload.php'; +\ComponentFuzz\WpBootstrap::load(); + +\ComponentFuzz\Surfaces\AdminMediaChromeSurface::run_media_type_iframe_child( $case ); +PHP; + } + + public static function run_media_type_iframe_child( array $case ): void { + ini_set( 'display_errors', '0' ); + self::prepare_runtime(); + + $ctx = new \ComponentFuzz\FuzzContext( (int) ( $case['seed'] ?? 1 ), self::NAME, (int) ( $case['iteration'] ?? 0 ) ); + $scenario = (string) ( $case['scenario'] ?? 'type-url' ); + $token = self::media_upload_dispatch_token( (string) ( $case['token'] ?? $ctx->identifier( 4, 9 ) ) ); + $state = array( + 'ok' => false, + 'label' => (string) ( $case['label'] ?? 'type-iframe' ), + 'scenario' => $scenario, + 'token' => $token, + 'postId' => 0, + 'expectedType' => (string) ( $case['expectedType'] ?? 'image' ), + 'requestType' => $case['requestType'] ?? null, + 'requestTab' => $case['requestTab'] ?? null, + 'contentBefore' => array(), + 'contentAfter' => array(), + 'formUrlEvents' => array(), + 'typeUrlEvents' => array(), + 'disableCaptionEvents' => array(), + 'uploadPostParamEvents' => array(), + 'pluploadEvents' => array(), + 'uploadActionCounts' => array(), + 'iframeActionCounts' => array(), + 'adminEnqueueArgs' => array(), + 'styleStatus' => array(), + 'saveEvents' => array(), + 'sendEvents' => array(), + 'dieCalls' => array(), + 'returned' => false, + 'returnType' => null, + 'throwable' => null, + 'output' => '', + ); + + $buffer_level = ob_get_level(); + ob_start(); + + register_shutdown_function( + static function () use ( &$state, $buffer_level ): void { + $output = ''; + while ( ob_get_level() > $buffer_level ) { + $chunk = ob_get_clean(); + if ( is_string( $chunk ) ) { + $output = $chunk . $output; + } + } + + $state['output'] = $output; + $state['contentAfter'] = self::media_url_insert_content_counts(); + $state['styleStatus'] = self::media_type_iframe_style_status(); + $state['ok'] = null === $state['throwable']; + echo json_encode( $state, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE ) . "\n"; + } + ); + + try { + $post_id = self::seed_parent_post( $ctx->fork( 'post' ) ); + $state['postId'] = $post_id; + $state['contentBefore'] = self::media_url_insert_content_counts(); + $request_type = $case['requestType'] ?? null; + $request_tab = (string) ( $case['requestTab'] ?? ( 'type-url' === $scenario ? 'type_url' : 'type' ) ); + $unsafe_post_id = (string) $post_id . ''; + $GLOBALS['pagenow'] = 'media-upload.php'; + $GLOBALS['type'] = is_string( $request_type ) ? $request_type : 'image'; + $GLOBALS['tab'] = $request_tab; + $GLOBALS['body_id'] = 'component-fuzz-type-iframe'; + + $_SERVER['HTTP_HOST'] = 'example.test'; + $_SERVER['HTTPS'] = 'off'; + $_SERVER['PHP_SELF'] = '/wp-admin/media-upload.php'; + $_SERVER['REQUEST_METHOD'] = 'GET'; + $_SERVER['REQUEST_URI'] = '/wp-admin/media-upload.php?tab=' . rawurlencode( $request_tab ) . '&post_id=' . rawurlencode( $unsafe_post_id ); + $_SERVER['HTTP_REFERER'] = 'http://example.test/wp-admin/media-upload.php?tab=' . rawurlencode( $request_tab ); + $_SERVER['HTTP_USER_AGENT'] = 'component-fuzz/admin-media-type-iframe'; + $_SERVER['REMOTE_ADDR'] = '198.51.100.47'; + $_SERVER['SERVER_PORT'] = '80'; + + $_GET = array( + 'tab' => $request_tab, + 'post_id' => $unsafe_post_id, + ); + if ( null !== $request_type ) { + $_GET['type'] = (string) $request_type; + } + if ( ! empty( $case['chromeless'] ) ) { + $_GET['chromeless'] = '1'; + } + $_POST = array(); + $_REQUEST = $_GET; + $_FILES = array(); + $_COOKIE = array(); + + $form_url_filter = static function ( string $url, string $type ) use ( &$state, $token ): string { + $state['formUrlEvents'][] = array( + 'url' => $url, + 'type' => $type, + ); + return \add_query_arg( 'cfz_type_iframe', $token, $url ); + }; + $type_url_filter = static function ( string $form_html ) use ( &$state, $token ): string { + $state['typeUrlEvents'][] = array( + 'hasImageOnly' => str_contains( $form_html, 'id="image-only"' ), + 'hasNotImage' => str_contains( $form_html, 'id="not-image"' ), + 'hasCaption' => str_contains( $form_html, 'id="caption"' ), + 'bytes' => strlen( $form_html ), + ); + return $form_html . ''; + }; + $disable_captions_filter = static function ( $disabled ) use ( &$state, $case ): bool { + $state['disableCaptionEvents'][] = $disabled; + return ! empty( $case['disableCaptions'] ); + }; + $upload_post_params_filter = static function ( array $params ) use ( &$state, $token ): array { + $state['uploadPostParamEvents'][] = $params; + $params['component_fuzz_type_iframe'] = $token; + return $params; + }; + $plupload_filter = static function ( array $init ) use ( &$state, $token ): array { + $state['pluploadEvents'][] = $init; + $init['component_fuzz_type_iframe'] = $token; + return $init; + }; + $save_filter = static function ( array $post, array $attachment ) use ( &$state ): array { + $state['saveEvents'][] = array( + 'id' => (int) ( $post['ID'] ?? 0 ), + 'title' => (string) ( $attachment['post_title'] ?? '' ), + ); + return $post; + }; + $send_filter = static function ( string $html, int $send_id, array $attachment ) use ( &$state ): string { + $state['sendEvents'][] = array( + 'id' => $send_id, + 'html' => $html, + 'title' => (string) ( $attachment['post_title'] ?? '' ), + ); + return $html; + }; + $user_has_cap_filter = static function ( array $allcaps, array $caps, array $args, $user = null ): array { + unset( $args, $user ); + foreach ( $caps as $cap ) { + $allcaps[ $cap ] = 'do_not_allow' !== $cap; + } + return $allcaps; + }; + $die_handler_filter = static function () use ( &$state ): callable { + return static function ( $message = '', $title = '', $args = array() ) use ( &$state ): void { + $state['dieCalls'][] = array( + 'message' => self::media_attach_action_die_message( $message ), + 'title' => self::media_attach_action_die_message( $title ), + 'args' => is_array( $args ) ? $args : array(), + ); + exit; + }; + }; + $tracked_actions = array( + 'pre-upload-ui', + 'pre-plupload-upload-ui', + 'post-plupload-upload-ui', + 'pre-html-upload-ui', + 'post-html-upload-ui', + 'post-upload-ui', + ); + $action_callbacks = array(); + foreach ( $tracked_actions as $hook ) { + $state['uploadActionCounts'][ $hook ] = 0; + $action_callbacks[ $hook ] = static function () use ( &$state, $hook ): void { + ++$state['uploadActionCounts'][ $hook ]; + }; + } + $iframe_hooks = array( + 'admin_enqueue_scripts', + 'admin_print_styles-media-upload-popup', + 'admin_print_styles', + 'admin_print_scripts-media-upload-popup', + 'admin_print_scripts', + 'admin_head-media-upload-popup', + 'admin_head', + 'admin_print_footer_scripts', + 'type-url' === $scenario ? 'admin_head_media_upload_type_url_form' : 'admin_head_media_upload_type_form', + ); + $iframe_action_callbacks = array(); + foreach ( $iframe_hooks as $hook ) { + $state['iframeActionCounts'][ $hook ] = 0; + $iframe_action_callbacks[ $hook ] = static function ( $arg = null ) use ( &$state, $hook ): void { + ++$state['iframeActionCounts'][ $hook ]; + if ( 'admin_enqueue_scripts' === $hook ) { + $state['adminEnqueueArgs'][] = $arg; + } + }; + } + + \add_filter( 'media_upload_form_url', $form_url_filter, 10, 2 ); + \add_filter( 'type_url_form_media', $type_url_filter, 10, 1 ); + \add_filter( 'disable_captions', $disable_captions_filter, 10, 1 ); + \add_filter( 'upload_post_params', $upload_post_params_filter, 10, 1 ); + \add_filter( 'plupload_init', $plupload_filter, 10, 1 ); + \add_filter( 'attachment_fields_to_save', $save_filter, 10, 2 ); + \add_filter( 'media_send_to_editor', $send_filter, 10, 3 ); + \add_filter( 'user_has_cap', $user_has_cap_filter, 10, 4 ); + \add_filter( 'wp_die_handler', $die_handler_filter, PHP_INT_MAX ); + foreach ( $iframe_action_callbacks as $hook => $callback ) { + \add_action( $hook, $callback, 10, 1 ); + } + foreach ( $action_callbacks as $hook => $callback ) { + \add_action( $hook, $callback ); + } + + \wp_set_current_user( 1 ); + if ( isset( $GLOBALS['current_user'] ) && $GLOBALS['current_user'] instanceof \WP_User ) { + $GLOBALS['current_user']->allcaps = array( 'exist' => true ); + } + + try { + $return_value = \wp_media_upload_handler(); + $state['returnType'] = gettype( $return_value ); + $state['returned'] = true; + } finally { + foreach ( $action_callbacks as $hook => $callback ) { + \remove_action( $hook, $callback ); + } + foreach ( $iframe_action_callbacks as $hook => $callback ) { + \remove_action( $hook, $callback, 10 ); + } + \remove_filter( 'wp_die_handler', $die_handler_filter, PHP_INT_MAX ); + \remove_filter( 'user_has_cap', $user_has_cap_filter, 10 ); + \remove_filter( 'media_send_to_editor', $send_filter, 10 ); + \remove_filter( 'attachment_fields_to_save', $save_filter, 10 ); + \remove_filter( 'plupload_init', $plupload_filter, 10 ); + \remove_filter( 'upload_post_params', $upload_post_params_filter, 10 ); + \remove_filter( 'disable_captions', $disable_captions_filter, 10 ); + \remove_filter( 'type_url_form_media', $type_url_filter, 10 ); + \remove_filter( 'media_upload_form_url', $form_url_filter, 10 ); + } + } catch ( \Throwable $e ) { + $state['throwable'] = self::describe_throwable( $e ); + } + } + + private static function media_type_iframe_child_result_has_expected_shape( array $result ): bool { + return array_key_exists( 'ok', $result ) + && array_key_exists( 'returned', $result ) + && is_string( $result['output'] ?? null ) + && is_array( $result['contentBefore'] ?? null ) + && is_array( $result['contentAfter'] ?? null ) + && is_array( $result['formUrlEvents'] ?? null ) + && is_array( $result['typeUrlEvents'] ?? null ) + && is_array( $result['disableCaptionEvents'] ?? null ) + && is_array( $result['uploadPostParamEvents'] ?? null ) + && is_array( $result['pluploadEvents'] ?? null ) + && is_array( $result['uploadActionCounts'] ?? null ) + && is_array( $result['iframeActionCounts'] ?? null ) + && is_array( $result['adminEnqueueArgs'] ?? null ) + && is_array( $result['styleStatus'] ?? null ) + && is_array( $result['saveEvents'] ?? null ) + && is_array( $result['sendEvents'] ?? null ) + && is_array( $result['dieCalls'] ?? null ); + } + + private static function collect_media_type_iframe_failures( array &$failures, array $case, array $result ): void { + $output = (string) ( $result['output'] ?? '' ); + $token = self::media_upload_dispatch_token( (string) ( $result['token'] ?? $case['token'] ?? '' ) ); + $expected_type = (string) ( $case['expectedType'] ?? 'image' ); + $is_type_url = 'type-url' === (string) ( $case['scenario'] ?? '' ); + $post_id = (int) ( $result['postId'] ?? 0 ); + $request_type = is_string( $case['requestType'] ?? null ) ? (string) $case['requestType'] : ''; + $dynamic_hook = $is_type_url ? 'admin_head_media_upload_type_url_form' : 'admin_head_media_upload_type_form'; + + self::collect_failure( + $failures, + str_contains( $output, 'pagenow = \'media-upload-popup\'' ) + && str_contains( $output, ''; + + return array( + $build( + 'default-filters-select-type-action', + array( + 'defaultType' => 'video', + 'defaultTab' => 'type', + 'expectedHook' => 'media_upload_video', + 'expectedType' => 'video', + 'expectedTab' => 'type', + 'expectDefaultTypeFilter' => true, + 'expectDefaultTabFilter' => true, + ), + $ctx->fork( 'default-filters' ) + ), + $build( + 'registered-library-tab-dispatch', + array( + 'requestType' => 'image', + 'requestTab' => 'library', + 'localPostId' => '77', + 'expectedHook' => 'media_upload_library', + 'expectedType' => 'image', + 'expectedTab' => 'library', + 'expectTabsFilter' => true, + 'expectedResolvedPostId' => 77, + ), + $ctx->fork( 'library-tab' ) + ), + $build( + 'unknown-tab-falls-back-to-type', + array( + 'requestType' => 'audio', + 'requestTab' => 'missing_component_tab', + 'expectedHook' => 'media_upload_audio', + 'expectedType' => 'audio', + 'expectedTab' => 'missing_component_tab', + 'expectTabsFilter' => true, + ), + $ctx->fork( 'unknown-tab' ) + ), + $build( + 'custom-tab-dispatches-tab-action', + array( + 'requestType' => 'file', + 'requestTab' => 'cfz_entry_tab', + 'registerTab' => 'cfz_entry_tab', + 'expectedHook' => 'media_upload_cfz_entry_tab', + 'expectedType' => 'file', + 'expectedTab' => 'cfz_entry_tab', + 'expectTabsFilter' => true, + ), + $ctx->fork( 'custom-tab' ) + ), + $build( + 'type-url-dispatches-type-action', + array( + 'requestType' => 'file', + 'requestTab' => 'type_url', + 'expectedHook' => 'media_upload_file', + 'expectedType' => 'file', + 'expectedTab' => 'type_url', + ), + $ctx->fork( 'type-url' ) + ), + $build( + 'inline-request-skips-iframe-constant', + array( + 'requestType' => 'image', + 'requestTab' => 'type', + 'requestInline' => true, + 'expectedHook' => 'media_upload_image', + 'expectedType' => 'image', + 'expectedTab' => 'type', + 'expectedIframe' => false, + ), + $ctx->fork( 'inline' ) + ), + $build( + 'unsafe-type-unknown-tab-uses-raw-dynamic-hook', + array( + 'requestType' => $unsafe_type, + 'requestTab' => 'unknown-unsafe-tab', + 'expectedHook' => 'media_upload_' . $unsafe_type, + 'expectedType' => $unsafe_type, + 'expectedTab' => 'unknown-unsafe-tab', + 'expectTabsFilter' => true, + ), + $ctx->fork( 'unsafe-type' ) + ), + $build( + 'gallery-without-post-id-falls-back-to-type', + array( + 'requestType' => 'image', + 'requestTab' => 'gallery', + 'applyUpdateGalleryTab' => true, + 'expectedHook' => 'media_upload_image', + 'expectedType' => 'image', + 'expectedTab' => 'gallery', + 'expectTabsFilter' => true, + 'expectedTabsMissing' => array( 'gallery' ), + ), + $ctx->fork( 'gallery-no-post' ) + ), + $build( + 'gallery-with-attachments-dispatches-gallery-tab', + array( + 'requestType' => 'image', + 'requestTab' => 'gallery', + 'applyUpdateGalleryTab' => true, + 'seedGalleryAttachment' => true, + 'expectedHook' => 'media_upload_gallery', + 'expectedType' => 'image', + 'expectedTab' => 'gallery', + 'expectTabsFilter' => true, + 'expectPostCapabilityGate' => true, + 'expectedPostCapabilityAllowed' => true, + 'expectedTabsContain' => array( 'gallery' ), + ), + $ctx->fork( 'gallery-with-attachment' ) + ), + $build( + 'upload-capability-denied', + array( + 'allowUpload' => false, + 'expectedReturned' => false, + 'expectedDieText' => 'not allowed to upload files', + 'expectedEnqueued' => false, + 'expectedBodyId' => null, + 'expectedIframe' => true, + ), + $ctx->fork( 'upload-denied' ) + ), + $build( + 'post-edit-capability-denied', + array( + 'requestType' => 'image', + 'requestTab' => 'type', + 'requestPostId' => '123', + 'allowPostEdit' => false, + 'expectedReturned' => false, + 'expectedDieText' => 'not allowed to edit this item', + 'expectedType' => null, + 'expectedTab' => null, + 'expectedBodyId' => null, + 'expectPostCapabilityGate' => true, + 'expectedPostCapabilityAllowed' => false, + 'expectedPostCapabilityPostId' => 123, + ), + $ctx->fork( 'post-denied' ) + ), + $build( + 'edit-action-missing-id-denied', + array( + 'requestType' => 'image', + 'requestTab' => 'type', + 'localAction' => 'edit', + 'expectedReturned' => false, + 'expectedDieText' => 'Invalid item ID', + 'expectedType' => null, + 'expectedTab' => null, + 'expectedBodyId' => null, + ), + $ctx->fork( 'edit-missing-id' ) + ), + ); + } + + private static function run_media_upload_entry_child_process( array $case ): array { + $payload = json_encode( + array( 'case' => $case ), + JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE + ); + + if ( false === $payload ) { + return array( + 'ok' => false, + 'exitCode' => -1, + 'stdout' => '', + 'stderr' => 'json_encode failed', + 'result' => null, + ); + } + + $descriptors = array( + 0 => array( 'pipe', 'r' ), + 1 => array( 'pipe', 'w' ), + 2 => array( 'pipe', 'w' ), + ); + + // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.system_calls_proc_open -- Isolates media-upload.php top-level dispatch and wp_die branches in a local PHP subprocess. + $process = proc_open( array( PHP_BINARY, '-r', self::media_upload_entry_child_program() ), $descriptors, $pipes, \ComponentFuzz\repo_root() ); + if ( ! is_resource( $process ) ) { + return array( + 'ok' => false, + 'exitCode' => -1, + 'stdout' => '', + 'stderr' => 'proc_open failed', + 'result' => null, + ); + } + + fwrite( $pipes[0], $payload ); + fclose( $pipes[0] ); + + $stdout = stream_get_contents( $pipes[1] ); + $stderr = stream_get_contents( $pipes[2] ); + fclose( $pipes[1] ); + fclose( $pipes[2] ); + + $exit_code = proc_close( $process ); + $result = json_decode( (string) $stdout, true ); + + return array( + 'ok' => 0 === $exit_code && is_array( $result ) && true === ( $result['ok'] ?? null ), + 'exitCode' => $exit_code, + 'stdout' => (string) $stdout, + 'stderr' => (string) $stderr, + 'result' => is_array( $result ) ? $result : null, + ); + } + + private static function media_upload_entry_child_program(): string { + return <<<'PHP' +$component_fuzz_admin_media_raw = stream_get_contents( STDIN ); +$component_fuzz_admin_media_payload = json_decode( $component_fuzz_admin_media_raw, true ); +$case = is_array( $component_fuzz_admin_media_payload['case'] ?? null ) ? $component_fuzz_admin_media_payload['case'] : array(); + +require_once getcwd() . '/tools/component-fuzz/lib/autoload.php'; +\ComponentFuzz\WpBootstrap::load(); + +\ComponentFuzz\Surfaces\AdminMediaChromeSurface::run_media_upload_entry_child( $case ); +PHP; + } + + public static function run_media_upload_entry_child( array $case ): void { + ini_set( 'display_errors', '0' ); + self::prepare_runtime(); + + if ( function_exists( 'update_option' ) ) { + \update_option( 'html_type', 'text/html' ); + } + + $ctx = new \ComponentFuzz\FuzzContext( (int) ( $case['seed'] ?? 1 ), self::NAME, (int) ( $case['iteration'] ?? 0 ) ); + $token = self::media_upload_dispatch_token( (string) ( $case['token'] ?? $ctx->identifier( 4, 9 ) ) ); + $state = array( + 'ok' => false, + 'label' => (string) ( $case['label'] ?? 'entry-dispatch' ), + 'token' => $token, + 'sourceBootstrapRemovals' => 0, + 'returned' => false, + 'throwable' => null, + 'output' => '', + 'contentBefore' => self::media_url_insert_content_counts(), + 'contentAfter' => array(), + 'assetStatus' => array(), + 'actionEvents' => array(), + 'defaultTypeEvents' => array(), + 'defaultTabEvents' => array(), + 'tabsEvents' => array(), + 'uploadCapEvents' => array(), + 'postCapEvents' => array(), + 'dieCalls' => array(), + 'resolvedType' => null, + 'resolvedTab' => null, + 'resolvedBodyId' => null, + 'resolvedId' => null, + 'resolvedPostId' => null, + 'iframeRequestDefined' => false, + 'headers' => array(), + 'galleryParentId' => 0, + 'galleryAttachmentId' => 0, + ); + + $buffer_level = ob_get_level(); + ob_start(); + + register_shutdown_function( + static function () use ( &$state, $buffer_level ): void { + $output = ''; + while ( ob_get_level() > $buffer_level ) { + $chunk = ob_get_clean(); + if ( is_string( $chunk ) ) { + $output = $chunk . $output; + } + } + + $state['output'] = $output; + $state['contentAfter'] = self::media_url_insert_content_counts(); + $state['assetStatus'] = self::media_upload_entry_asset_status(); + $state['iframeRequestDefined'] = defined( 'IFRAME_REQUEST' ); + $state['headers'] = function_exists( 'headers_list' ) ? headers_list() : array(); + $state['ok'] = null === $state['throwable']; + echo json_encode( $state, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE ) . "\n"; + } + ); + + try { + $GLOBALS['pagenow'] = 'media-upload.php'; + $_SERVER['HTTP_HOST'] = 'example.test'; + $_SERVER['HTTPS'] = 'off'; + $_SERVER['PHP_SELF'] = '/wp-admin/media-upload.php'; + $_SERVER['REQUEST_METHOD'] = 'GET'; + $_SERVER['HTTP_REFERER'] = 'http://example.test/wp-admin/media-upload.php'; + $_SERVER['HTTP_USER_AGENT'] = 'component-fuzz/admin-media-upload-entry'; + $_SERVER['REMOTE_ADDR'] = '198.51.100.49'; + $_SERVER['SERVER_PORT'] = '80'; + + $request = array(); + if ( null !== ( $case['requestType'] ?? null ) ) { + $request['type'] = (string) $case['requestType']; + } + if ( null !== ( $case['requestTab'] ?? null ) ) { + $request['tab'] = (string) $case['requestTab']; + } + if ( null !== ( $case['requestPostId'] ?? null ) ) { + $request['post_id'] = (string) $case['requestPostId']; + } + if ( ! empty( $case['requestInline'] ) ) { + $request['inline'] = '1'; + } + if ( ! empty( $case['seedGalleryAttachment'] ) ) { + $gallery_parent = self::seed_parent_post( $ctx->fork( 'gallery-parent' ) ); + $gallery_attachment = self::seed_attachment( $ctx->fork( 'gallery-attachment' ), 'image/jpeg', array( 'parent_id' => $gallery_parent ) ); + + $state['galleryParentId'] = $gallery_parent; + $state['galleryAttachmentId'] = (int) $gallery_attachment->ID; + $request['post_id'] = (string) $gallery_parent; + } + + $query = http_build_query( $request, '', '&', PHP_QUERY_RFC3986 ); + $_SERVER['REQUEST_URI'] = '/wp-admin/media-upload.php' . ( '' === $query ? '' : '?' . $query ); + $_GET = $request; + $_POST = array(); + $_REQUEST = $request; + $_FILES = array(); + $_COOKIE = array(); + + if ( null !== ( $case['localAction'] ?? null ) ) { + $action = (string) $case['localAction']; + } + if ( array_key_exists( 'localId', $case ) && null !== $case['localId'] ) { + $ID = $case['localId']; // phpcs:ignore WordPress.NamingConventions.ValidVariableName + } + if ( array_key_exists( 'localPostId', $case ) && null !== $case['localPostId'] ) { + $post_id = $case['localPostId']; + } + + $default_type_filter = static function ( string $type ) use ( &$state, $case ): string { + $state['defaultTypeEvents'][] = array( 'input' => $type ); + return (string) ( $case['defaultType'] ?? $type ); + }; + $default_tab_filter = static function ( string $tab ) use ( &$state, $case ): string { + $state['defaultTabEvents'][] = array( 'input' => $tab ); + return (string) ( $case['defaultTab'] ?? $tab ); + }; + $tabs_filter = static function ( array $tabs ) use ( &$state, $case ): array { + $before = array_keys( $tabs ); + if ( null !== ( $case['registerTab'] ?? null ) ) { + $tabs[ (string) $case['registerTab'] ] = 'Component Fuzz'; + } + if ( ! empty( $case['applyUpdateGalleryTab'] ) ) { + $tabs = \update_gallery_tab( $tabs ); + } + $state['tabsEvents'][] = array( + 'before' => $before, + 'after' => array_keys( $tabs ), + ); + return $tabs; + }; + $map_meta_cap_filter = static function ( array $caps, string $cap, int $user_id, array $args ) use ( &$state, $case ): array { + if ( 'edit_post' !== $cap ) { + return $caps; + } + + $post_id = (int) ( $args[0] ?? 0 ); + $allowed = ! empty( $case['allowPostEdit'] ); + $state['postCapEvents'][] = array( + 'postId' => $post_id, + 'allowed' => $allowed, + ); + + return $allowed ? array( 'exist' ) : array( 'do_not_allow' ); + }; + $user_has_cap_filter = static function ( array $allcaps, array $caps, array $args, $user = null ) use ( &$state, $case ): array { + unset( $user ); + if ( in_array( 'upload_files', $caps, true ) ) { + $allowed = ! empty( $case['allowUpload'] ); + $state['uploadCapEvents'][] = array( + 'allowed' => $allowed, + 'args' => $args, + ); + $allcaps['upload_files'] = $allowed; + } + + foreach ( $caps as $cap ) { + if ( 'upload_files' === $cap ) { + continue; + } + $allcaps[ $cap ] = 'do_not_allow' !== $cap; + } + + return $allcaps; + }; + $die_handler_filter = static function () use ( &$state ): callable { + return static function ( $message = '', $title = '', $args = array() ) use ( &$state ): void { + $state['dieCalls'][] = array( + 'message' => self::media_attach_action_die_message( $message ), + 'title' => self::media_attach_action_die_message( $title ), + 'args' => is_array( $args ) ? $args : array(), + ); + exit; + }; + }; + $entry_action = static function () use ( &$state ): void { + $state['actionEvents'][] = array( + 'hook' => current_filter(), + 'didCount' => did_action( current_filter() ), + ); + }; + + \add_filter( 'media_upload_default_type', $default_type_filter, 10, 1 ); + \add_filter( 'media_upload_default_tab', $default_tab_filter, 10, 1 ); + \add_filter( 'media_upload_tabs', $tabs_filter, 10, 1 ); + \add_filter( 'map_meta_cap', $map_meta_cap_filter, 10, 4 ); + \add_filter( 'user_has_cap', $user_has_cap_filter, 10, 4 ); + \add_filter( 'wp_die_handler', $die_handler_filter, PHP_INT_MAX ); + if ( is_string( $case['expectedHook'] ?? null ) && '' !== $case['expectedHook'] ) { + \add_action( (string) $case['expectedHook'], $entry_action, 10, 0 ); + } + + \wp_set_current_user( 1 ); + if ( isset( $GLOBALS['current_user'] ) && $GLOBALS['current_user'] instanceof \WP_User ) { + $GLOBALS['current_user']->allcaps = array( + 'exist' => true, + 'upload_files' => ! empty( $case['allowUpload'] ), + ); + } + + foreach ( array( 'plupload-handlers', 'image-edit', 'set-post-thumbnail', 'media-gallery' ) as $handle ) { + \wp_register_script( $handle, '/wp-admin/js/' . $handle . '.js', array(), false ); + } + \wp_register_style( 'imgareaselect', '/wp-includes/js/imgareaselect/imgareaselect.css', array(), false ); + $state['contentBefore'] = self::media_url_insert_content_counts(); + + $source_path = \ComponentFuzz\repo_root() . DIRECTORY_SEPARATOR . 'src' . DIRECTORY_SEPARATOR . 'wp-admin' . DIRECTORY_SEPARATOR . 'media-upload.php'; + $source = file_get_contents( $source_path ); + if ( ! is_string( $source ) ) { + throw new \RuntimeException( 'Could not read media-upload.php entry source.' ); + } + + $source = str_replace( + "require_once __DIR__ . '/admin.php';", + '/* component-fuzz skips the normal admin bootstrap; WpBootstrap already loaded a no-DB runtime. */', + $source, + $removals + ); + $state['sourceBootstrapRemovals'] = $removals; + if ( 1 !== $removals ) { + throw new \RuntimeException( 'Could not isolate media-upload.php admin bootstrap include.' ); + } + + try { + // phpcs:ignore Squiz.PHP.Eval.Discouraged -- Evaluates the real media-upload.php entry source after removing only the normal admin bootstrap include. + eval( '?>' . $source ); + $state['returned'] = true; + } finally { + if ( isset( $type ) ) { + $state['resolvedType'] = (string) $type; + } + if ( isset( $tab ) ) { + $state['resolvedTab'] = (string) $tab; + } + if ( isset( $body_id ) ) { + $state['resolvedBodyId'] = (string) $body_id; + } + if ( isset( $ID ) ) { // phpcs:ignore WordPress.NamingConventions.ValidVariableName + $state['resolvedId'] = (int) $ID; // phpcs:ignore WordPress.NamingConventions.ValidVariableName + } + if ( isset( $post_id ) ) { + $state['resolvedPostId'] = (int) $post_id; + } + + if ( is_string( $case['expectedHook'] ?? null ) && '' !== $case['expectedHook'] ) { + \remove_action( (string) $case['expectedHook'], $entry_action, 10 ); + } + \remove_filter( 'wp_die_handler', $die_handler_filter, PHP_INT_MAX ); + \remove_filter( 'user_has_cap', $user_has_cap_filter, 10 ); + \remove_filter( 'map_meta_cap', $map_meta_cap_filter, 10 ); + \remove_filter( 'media_upload_tabs', $tabs_filter, 10 ); + \remove_filter( 'media_upload_default_tab', $default_tab_filter, 10 ); + \remove_filter( 'media_upload_default_type', $default_type_filter, 10 ); + } + } catch ( \Throwable $e ) { + $state['throwable'] = self::describe_throwable( $e ); + } + } + + private static function media_upload_entry_asset_status(): array { + $wp_scripts = function_exists( 'wp_scripts' ) ? \wp_scripts() : null; + $wp_styles = function_exists( 'wp_styles' ) ? \wp_styles() : null; + + $scripts = array(); + foreach ( array( 'plupload-handlers', 'image-edit', 'set-post-thumbnail', 'media-gallery' ) as $handle ) { + $scripts[ $handle ] = array( + 'enqueued' => function_exists( 'wp_script_is' ) ? \wp_script_is( $handle, 'enqueued' ) : null, + 'registered' => function_exists( 'wp_script_is' ) ? \wp_script_is( $handle, 'registered' ) : null, + 'queued' => is_object( $wp_scripts ) && property_exists( $wp_scripts, 'queue' ) && in_array( $handle, $wp_scripts->queue, true ), + ); + } + + $styles = array(); + foreach ( array( 'imgareaselect' ) as $handle ) { + $styles[ $handle ] = array( + 'enqueued' => function_exists( 'wp_style_is' ) ? \wp_style_is( $handle, 'enqueued' ) : null, + 'registered' => function_exists( 'wp_style_is' ) ? \wp_style_is( $handle, 'registered' ) : null, + 'queued' => is_object( $wp_styles ) && property_exists( $wp_styles, 'queue' ) && in_array( $handle, $wp_styles->queue, true ), + ); + } + + return array( + 'scripts' => $scripts, + 'styles' => $styles, + ); + } + + private static function media_upload_entry_assets_match( array $asset_status, bool $expected_enqueued ): bool { + foreach ( array( 'plupload-handlers', 'image-edit', 'set-post-thumbnail', 'media-gallery' ) as $handle ) { + $observed = (bool) ( $asset_status['scripts'][ $handle ]['enqueued'] ?? false ) + || (bool) ( $asset_status['scripts'][ $handle ]['queued'] ?? false ); + if ( $expected_enqueued !== $observed ) { + return false; + } + } + + $style_observed = (bool) ( $asset_status['styles']['imgareaselect']['enqueued'] ?? false ) + || (bool) ( $asset_status['styles']['imgareaselect']['queued'] ?? false ); + + return $expected_enqueued === $style_observed; + } + + private static function media_upload_entry_child_result_has_expected_shape( array $result ): bool { + return array_key_exists( 'ok', $result ) + && array_key_exists( 'returned', $result ) + && is_string( $result['output'] ?? null ) + && is_array( $result['contentBefore'] ?? null ) + && is_array( $result['contentAfter'] ?? null ) + && is_array( $result['assetStatus'] ?? null ) + && is_array( $result['actionEvents'] ?? null ) + && is_array( $result['defaultTypeEvents'] ?? null ) + && is_array( $result['defaultTabEvents'] ?? null ) + && is_array( $result['tabsEvents'] ?? null ) + && is_array( $result['uploadCapEvents'] ?? null ) + && is_array( $result['postCapEvents'] ?? null ) + && is_array( $result['dieCalls'] ?? null ); + } + + private static function collect_media_upload_entry_failures( array &$failures, array $case, array $result ): void { + $output = (string) ( $result['output'] ?? '' ); + $expected_hook = (string) ( $case['expectedHook'] ?? '' ); + $expects_die = is_string( $case['expectedDieText'] ?? null ); + + self::collect_failure( + $failures, + 1 === (int) ( $result['sourceBootstrapRemovals'] ?? 0 ) + && null === ( $result['throwable'] ?? null ), + 'media-upload.php entry source is evaluated after removing exactly the normal admin bootstrap include', + array( + 'sourceBootstrapRemovals' => $result['sourceBootstrapRemovals'] ?? null, + 'throwable' => $result['throwable'] ?? null, + ) + ); + + self::collect_failure( + $failures, + self::media_upload_entry_assets_match( + is_array( $result['assetStatus'] ?? null ) ? $result['assetStatus'] : array(), + ! empty( $case['expectedEnqueued'] ) + ), + 'entry file enqueues legacy media assets only after the upload_files gate passes', + array( + 'expectedEnqueued' => ! empty( $case['expectedEnqueued'] ), + 'assetStatus' => $result['assetStatus'] ?? array(), + ) + ); + + if ( $expects_die ) { + $die_calls = is_array( $result['dieCalls'] ?? null ) ? $result['dieCalls'] : array(); + $die_text = (string) ( $case['expectedDieText'] ?? '' ); + self::collect_failure( + $failures, + false === (bool) ( $result['returned'] ?? true ) + && 1 === count( $die_calls ) + && str_contains( (string) ( $die_calls[0]['message'] ?? '' ), $die_text ) + && array() === ( $result['actionEvents'] ?? array() ), + 'entry capability and edit gates stop before dynamic media_upload_* dispatch', + array( + 'expectedDieText' => $die_text, + 'dieCalls' => $die_calls, + 'actionEvents' => $result['actionEvents'] ?? array(), + 'returned' => $result['returned'] ?? null, + ) + ); + } else { + self::collect_failure( + $failures, + true === (bool) ( $result['returned'] ?? false ) + && array() === ( $result['dieCalls'] ?? array() ) + && 1 === count( $result['actionEvents'] ?? array() ) + && $expected_hook === (string) ( $result['actionEvents'][0]['hook'] ?? '' ), + 'entry dispatch reaches exactly the expected dynamic media_upload_* hook without wp_die', + array( + 'expectedHook' => $expected_hook, + 'actionEvents' => $result['actionEvents'] ?? array(), + 'dieCalls' => $result['dieCalls'] ?? array(), + 'returned' => $result['returned'] ?? null, + ) + ); + } + + self::collect_failure( + $failures, + (bool) ( $case['expectedIframe'] ?? true ) === (bool) ( $result['iframeRequestDefined'] ?? false ), + 'entry file defines IFRAME_REQUEST unless the inline request flag is present', + array( + 'expectedIframe' => $case['expectedIframe'] ?? true, + 'iframeRequestDefined' => $result['iframeRequestDefined'] ?? null, + ) + ); + + self::collect_failure( + $failures, + ( $case['expectedType'] ?? null ) === ( $result['resolvedType'] ?? null ) + && ( $case['expectedTab'] ?? null ) === ( $result['resolvedTab'] ?? null ) + && ( $case['expectedBodyId'] ?? null ) === ( $result['resolvedBodyId'] ?? null ), + 'entry file resolves type, tab, and body ID according to request/default filter routing', + array( + 'expectedType' => $case['expectedType'] ?? null, + 'resolvedType' => $result['resolvedType'] ?? null, + 'expectedTab' => $case['expectedTab'] ?? null, + 'resolvedTab' => $result['resolvedTab'] ?? null, + 'expectedBodyId' => $case['expectedBodyId'] ?? null, + 'resolvedBodyId' => $result['resolvedBodyId'] ?? null, + ) + ); + + if ( array_key_exists( 'expectedResolvedId', $case ) && null !== $case['expectedResolvedId'] ) { + self::collect_failure( + $failures, + (int) $case['expectedResolvedId'] === (int) ( $result['resolvedId'] ?? -1 ), + 'entry edit gate casts the local ID before validating edit requests', + array( + 'expectedResolvedId' => $case['expectedResolvedId'], + 'resolvedId' => $result['resolvedId'] ?? null, + ) + ); + } + + if ( array_key_exists( 'expectedResolvedPostId', $case ) && null !== $case['expectedResolvedPostId'] ) { + self::collect_failure( + $failures, + (int) $case['expectedResolvedPostId'] === (int) ( $result['resolvedPostId'] ?? -1 ), + 'entry file casts the local post_id variable without leaking hostile bytes', + array( + 'expectedResolvedPostId' => $case['expectedResolvedPostId'], + 'resolvedPostId' => $result['resolvedPostId'] ?? null, + ) + ); + } + + self::collect_failure( + $failures, + ! empty( $case['expectDefaultTypeFilter'] ) === ( 1 === count( $result['defaultTypeEvents'] ?? array() ) ) + && ! empty( $case['expectDefaultTabFilter'] ) === ( 1 === count( $result['defaultTabEvents'] ?? array() ) ) + && ! empty( $case['expectTabsFilter'] ) === ( 1 === count( $result['tabsEvents'] ?? array() ) ), + 'entry routing applies default type/tab filters only when request values are absent and tab registry only when needed', + array( + 'expectDefaultTypeFilter' => ! empty( $case['expectDefaultTypeFilter'] ), + 'defaultTypeEvents' => $result['defaultTypeEvents'] ?? array(), + 'expectDefaultTabFilter' => ! empty( $case['expectDefaultTabFilter'] ), + 'defaultTabEvents' => $result['defaultTabEvents'] ?? array(), + 'expectTabsFilter' => ! empty( $case['expectTabsFilter'] ), + 'tabsEvents' => $result['tabsEvents'] ?? array(), + ) + ); + + if ( null !== ( $case['registerTab'] ?? null ) && isset( $result['tabsEvents'][0]['after'] ) ) { + self::collect_failure( + $failures, + in_array( (string) $case['registerTab'], $result['tabsEvents'][0]['after'], true ), + 'custom media_upload_tabs entries participate in registered-tab dispatch', + array( + 'registerTab' => $case['registerTab'], + 'tabsEvents' => $result['tabsEvents'] ?? array(), + ) + ); + } + + if ( isset( $result['tabsEvents'][0]['after'] ) ) { + $tabs_after = is_array( $result['tabsEvents'][0]['after'] ) ? $result['tabsEvents'][0]['after'] : array(); + foreach ( (array) ( $case['expectedTabsContain'] ?? array() ) as $tab ) { + self::collect_failure( + $failures, + in_array( (string) $tab, $tabs_after, true ), + 'media_upload_tabs filtering keeps expected registered tabs available for dispatch', + array( + 'expectedTab' => $tab, + 'tabsEvents' => $result['tabsEvents'] ?? array(), + ) + ); + } + foreach ( (array) ( $case['expectedTabsMissing'] ?? array() ) as $tab ) { + self::collect_failure( + $failures, + ! in_array( (string) $tab, $tabs_after, true ), + 'media_upload_tabs filtering removes unavailable registered tabs before dispatch fallback', + array( + 'removedTab' => $tab, + 'tabsEvents' => $result['tabsEvents'] ?? array(), + ) + ); + } + } + + $post_cap_events = is_array( $result['postCapEvents'] ?? null ) ? $result['postCapEvents'] : array(); + $post_cap_ok = array() === $post_cap_events; + if ( ! empty( $case['expectPostCapabilityGate'] ) ) { + $post_cap_allowed = $case['expectedPostCapabilityAllowed'] ?? null; + $post_cap_id = $case['expectedPostCapabilityPostId'] ?? null; + if ( null === $post_cap_id && ! empty( $result['galleryParentId'] ) ) { + $post_cap_id = (int) $result['galleryParentId']; + } + $post_cap_ok = 1 === count( $post_cap_events ) + && ( null === $post_cap_allowed || (bool) $post_cap_allowed === (bool) ( $post_cap_events[0]['allowed'] ?? null ) ) + && ( null === $post_cap_id || (int) $post_cap_id === (int) ( $post_cap_events[0]['postId'] ?? 0 ) ); + } + + self::collect_failure( + $failures, + 1 === count( $result['uploadCapEvents'] ?? array() ) + && ! empty( $case['allowUpload'] ) === (bool) ( $result['uploadCapEvents'][0]['allowed'] ?? false ) + && $post_cap_ok, + 'entry file checks upload_files first and checks edit_post only for non-empty request post_id', + array( + 'allowUpload' => $case['allowUpload'] ?? null, + 'uploadCapEvents' => $result['uploadCapEvents'] ?? array(), + 'expectPostCapabilityGate' => ! empty( $case['expectPostCapabilityGate'] ), + 'expectedPostCapabilityAllowed' => $case['expectedPostCapabilityAllowed'] ?? null, + 'expectedPostCapabilityPostId' => $case['expectedPostCapabilityPostId'] ?? null, + 'galleryParentId' => $result['galleryParentId'] ?? null, + 'postCapEvents' => $post_cap_events, + ) + ); + + self::collect_failure( + $failures, + '' === $output + && ! str_contains( $output, '', + ), + ), + $ctx->fork( 'library-page-two' ) + ), + $build( + 'library-invalid-paged-all-types', + 'library', + array( + 'type' => 'file', + 'requestArgs' => array( + 'post_mime_type' => 'all', + 'paged' => '-9', + 's' => 'Library', + ), + ), + $ctx->fork( 'library-invalid-paged' ) + ), + $build( + 'gallery-parent-get', + 'gallery-parent', + array(), + $ctx->fork( 'gallery-parent' ) + ), + $build( + 'gallery-attachment-get-chromeless', + 'gallery-attachment', + array( 'chromeless' => true ), + $ctx->fork( 'gallery-attachment' ) + ), + ); + } + + private static function run_media_library_gallery_child_process( array $case ): array { + $payload = json_encode( + array( 'case' => $case ), + JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE + ); + + if ( false === $payload ) { + return array( + 'ok' => false, + 'exitCode' => -1, + 'stdout' => '', + 'stderr' => 'json_encode failed', + 'result' => null, + ); + } + + $descriptors = array( + 0 => array( 'pipe', 'r' ), + 1 => array( 'pipe', 'w' ), + 2 => array( 'pipe', 'w' ), + ); + + // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.system_calls_proc_open -- Isolates legacy media library/gallery iframe output in a local PHP subprocess. + $process = proc_open( array( PHP_BINARY, '-r', self::media_library_gallery_child_program() ), $descriptors, $pipes, \ComponentFuzz\repo_root() ); + if ( ! is_resource( $process ) ) { + return array( + 'ok' => false, + 'exitCode' => -1, + 'stdout' => '', + 'stderr' => 'proc_open failed', + 'result' => null, + ); + } + + fwrite( $pipes[0], $payload ); + fclose( $pipes[0] ); + + $stdout = stream_get_contents( $pipes[1] ); + $stderr = stream_get_contents( $pipes[2] ); + fclose( $pipes[1] ); + fclose( $pipes[2] ); + + $exit_code = proc_close( $process ); + $result = json_decode( (string) $stdout, true ); + + return array( + 'ok' => 0 === $exit_code && is_array( $result ) && true === ( $result['ok'] ?? null ), + 'exitCode' => $exit_code, + 'stdout' => (string) $stdout, + 'stderr' => (string) $stderr, + 'result' => is_array( $result ) ? $result : null, + ); + } + + private static function media_library_gallery_child_program(): string { + return <<<'PHP' +$component_fuzz_admin_media_raw = stream_get_contents( STDIN ); +$component_fuzz_admin_media_payload = json_decode( $component_fuzz_admin_media_raw, true ); +$case = is_array( $component_fuzz_admin_media_payload['case'] ?? null ) ? $component_fuzz_admin_media_payload['case'] : array(); + +require_once getcwd() . '/tools/component-fuzz/lib/autoload.php'; +\ComponentFuzz\WpBootstrap::load(); + +\ComponentFuzz\Surfaces\AdminMediaChromeSurface::run_media_library_gallery_child( $case ); +PHP; + } + + public static function run_media_library_gallery_child( array $case ): void { + ini_set( 'display_errors', '0' ); + self::prepare_runtime(); + + $ctx = new \ComponentFuzz\FuzzContext( (int) ( $case['seed'] ?? 1 ), self::NAME, (int) ( $case['iteration'] ?? 0 ) ); + $scenario = (string) ( $case['scenario'] ?? 'library' ); + $token = self::media_upload_dispatch_token( (string) ( $case['token'] ?? $ctx->identifier( 4, 9 ) ) ); + $type = (string) ( $case['type'] ?? 'image' ); + $tab = 'library' === $scenario ? 'library' : 'gallery'; + $state = array( + 'ok' => false, + 'label' => (string) ( $case['label'] ?? 'library-gallery' ), + 'scenario' => $scenario, + 'token' => $token, + 'type' => $type, + 'tab' => $tab, + 'postId' => 0, + 'parentId' => 0, + 'libraryImageIds' => array(), + 'libraryOtherIds' => array(), + 'expectedIds' => array(), + 'absentIds' => array(), + 'queryPostIds' => array(), + 'queryVars' => array(), + 'foundPosts' => null, + 'contentBefore' => array(), + 'contentAfter' => array(), + 'formUrlEvents' => array(), + 'fieldEvents' => array(), + 'mimeLinkEvents' => array(), + 'getMediaItemArgsEvents' => array(), + 'saveEvents' => array(), + 'sendEvents' => array(), + 'dieCalls' => array(), + 'iframeActionCounts' => array(), + 'adminEnqueueArgs' => array(), + 'scriptStatus' => array(), + 'returned' => false, + 'returnType' => null, + 'throwable' => null, + 'output' => '', + ); + + $buffer_level = ob_get_level(); + ob_start(); + + register_shutdown_function( + static function () use ( &$state, $buffer_level ): void { + $output = ''; + while ( ob_get_level() > $buffer_level ) { + $chunk = ob_get_clean(); + if ( is_string( $chunk ) ) { + $output = $chunk . $output; + } + } + + $wp_the_query = $GLOBALS['wp_the_query'] ?? null; + if ( is_object( $wp_the_query ) ) { + $posts = is_array( $wp_the_query->posts ?? null ) ? $wp_the_query->posts : array(); + $state['queryPostIds'] = array_map( + static function ( $post ): int { + return (int) ( $post->ID ?? 0 ); + }, + $posts + ); + $state['queryVars'] = is_array( $wp_the_query->query_vars ?? null ) ? self::media_library_gallery_query_summary( $wp_the_query->query_vars ) : array(); + $state['foundPosts'] = isset( $wp_the_query->found_posts ) ? (int) $wp_the_query->found_posts : null; + } + + $state['output'] = $output; + $state['contentAfter'] = self::media_url_insert_content_counts(); + $state['scriptStatus'] = self::media_gallery_save_script_status( 'admin-gallery' ); + $state['ok'] = null === $state['throwable']; + echo json_encode( $state, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE ) . "\n"; + } + ); + + try { + $parent_id = self::seed_parent_post( $ctx->fork( 'parent' ) ); + $image_ids = array(); + for ( $i = 0; $i < 12; ++$i ) { + $image = self::seed_attachment( + $ctx->fork( 'library-image-' . $i ), + 'image/jpeg', + array( + 'parent_id' => $parent_id, + 'post_title' => 'Library image ' . $i . ' ' . $token, + 'alt' => 'Library alt ' . $i . ' ' . $token, + ) + ); + $image_ids[] = (int) $image->ID; + } + + $pdf = self::seed_attachment( + $ctx->fork( 'library-pdf' ), + 'application/pdf', + array( + 'parent_id' => $parent_id, + 'post_title' => 'Library PDF ' . $token, + ) + ); + $audio = self::seed_attachment( + $ctx->fork( 'library-audio' ), + 'audio/mpeg', + array( + 'parent_id' => $parent_id, + 'post_title' => 'Library audio ' . $token, + ) + ); + $other_ids = array( (int) $pdf->ID, (int) $audio->ID ); + + $post_id = $parent_id; + if ( 'gallery-attachment' === $scenario ) { + $post_id = $image_ids[0]; + } + + $state['postId'] = $post_id; + $state['parentId'] = $parent_id; + $state['libraryImageIds'] = $image_ids; + $state['libraryOtherIds'] = $other_ids; + $state['contentBefore'] = self::media_url_insert_content_counts(); + + $GLOBALS['pagenow'] = 'media-upload.php'; + $GLOBALS['type'] = $type; + $GLOBALS['tab'] = $tab; + $GLOBALS['body_id'] = 'component-fuzz-library-gallery'; + $GLOBALS['wp'] = new \WP(); + $GLOBALS['wp_query'] = new \WP_Query(); + $GLOBALS['wp_the_query'] = $GLOBALS['wp_query']; + + $_SERVER['HTTP_HOST'] = 'example.test'; + $_SERVER['HTTPS'] = 'off'; + $_SERVER['PHP_SELF'] = '/wp-admin/media-upload.php'; + $_SERVER['REQUEST_METHOD'] = 'GET'; + $_SERVER['HTTP_REFERER'] = 'http://example.test/wp-admin/media-upload.php?type=' . rawurlencode( $type ) . '&tab=' . rawurlencode( $tab ); + $_SERVER['HTTP_USER_AGENT'] = 'component-fuzz/admin-media-library-gallery'; + $_SERVER['REMOTE_ADDR'] = '198.51.100.50'; + $_SERVER['SERVER_PORT'] = '80'; + + $request = array_merge( + array( + 'type' => $type, + 'tab' => $tab, + 'post_id' => (string) $post_id, + ), + is_array( $case['requestArgs'] ?? null ) ? $case['requestArgs'] : array() + ); + if ( ! empty( $case['chromeless'] ) ) { + $request['chromeless'] = '1'; + } + $query = http_build_query( $request, '', '&', PHP_QUERY_RFC3986 ); + $_SERVER['REQUEST_URI'] = '/wp-admin/media-upload.php?' . $query; + $_GET = $request; + $_POST = array(); + $_REQUEST = $request; + $_FILES = array(); + $_COOKIE = array(); + + \wp_register_script( 'admin-gallery', '/wp-admin/js/gallery.js', array(), false ); + + $form_url_filter = static function ( string $url, string $url_type ) use ( &$state, $token ): string { + $state['formUrlEvents'][] = array( + 'url' => $url, + 'type' => $url_type, + ); + return \add_query_arg( 'cfz_library_gallery', $token, $url ); + }; + $fields_filter = static function ( array $fields, \WP_Post $post ) use ( &$state, $token ): array { + $state['fieldEvents'][] = array( + 'id' => (int) $post->ID, + 'title' => (string) $post->post_title, + 'mimeType' => (string) $post->post_mime_type, + ); + $fields['component_fuzz_library_gallery'] = array( + 'label' => 'Component Fuzz Library Gallery', + 'value' => 'Library gallery field ' . $token, + ); + return $fields; + }; + $mime_links_filter = static function ( array $links ) use ( &$state, $token ): array { + $state['mimeLinkEvents'][] = array( + 'count' => count( $links ), + 'html' => implode( '|', $links ), + ); + $links[] = '
  • Component Fuzz'; + return $links; + }; + $item_args_filter = static function ( array $args ) use ( &$state ): array { + $state['getMediaItemArgsEvents'][] = $args; + return $args; + }; + $upload_per_page_filter = static function (): int { + return 10; + }; + $save_filter = static function ( array $post, array $attachment ) use ( &$state ): array { + $state['saveEvents'][] = array( + 'id' => (int) ( $post['ID'] ?? 0 ), + 'title' => (string) ( $attachment['post_title'] ?? '' ), + ); + return $post; + }; + $send_filter = static function ( string $html, int $send_id, array $attachment ) use ( &$state ): string { + $state['sendEvents'][] = array( + 'id' => $send_id, + 'html' => $html, + 'title' => (string) ( $attachment['post_title'] ?? '' ), + ); + return $html; + }; + $user_has_cap_filter = static function ( array $allcaps, array $caps, array $args, $user = null ): array { + unset( $args, $user ); + foreach ( $caps as $cap ) { + $allcaps[ $cap ] = 'do_not_allow' !== $cap; + } + return $allcaps; + }; + $die_handler_filter = static function () use ( &$state ): callable { + return static function ( $message = '', $title = '', $args = array() ) use ( &$state ): void { + $state['dieCalls'][] = array( + 'message' => self::media_attach_action_die_message( $message ), + 'title' => self::media_attach_action_die_message( $title ), + 'args' => is_array( $args ) ? $args : array(), + ); + exit; + }; + }; + $iframe_hooks = array( + 'admin_enqueue_scripts', + 'admin_print_styles-media-upload-popup', + 'admin_print_styles', + 'admin_print_scripts-media-upload-popup', + 'admin_print_scripts', + 'admin_head-media-upload-popup', + 'admin_head', + 'admin_print_footer_scripts', + 'library' === $scenario ? 'admin_head_media_upload_library_form' : 'admin_head_media_upload_gallery_form', + ); + $iframe_action_callbacks = array(); + foreach ( $iframe_hooks as $hook ) { + $state['iframeActionCounts'][ $hook ] = 0; + $iframe_action_callbacks[ $hook ] = static function ( $arg = null ) use ( &$state, $hook ): void { + ++$state['iframeActionCounts'][ $hook ]; + if ( 'admin_enqueue_scripts' === $hook ) { + $state['adminEnqueueArgs'][] = $arg; + } + }; + } + + \add_filter( 'media_upload_form_url', $form_url_filter, 10, 2 ); + \add_filter( 'attachment_fields_to_edit', $fields_filter, 11, 2 ); + \add_filter( 'media_upload_mime_type_links', $mime_links_filter, 10, 1 ); + \add_filter( 'get_media_item_args', $item_args_filter, 10, 1 ); + \add_filter( 'upload_per_page', $upload_per_page_filter, 10, 0 ); + \add_filter( 'attachment_fields_to_save', $save_filter, 10, 2 ); + \add_filter( 'media_send_to_editor', $send_filter, 10, 3 ); + \add_filter( 'user_has_cap', $user_has_cap_filter, 10, 4 ); + \add_filter( 'wp_die_handler', $die_handler_filter, PHP_INT_MAX ); + foreach ( $iframe_action_callbacks as $hook => $callback ) { + \add_action( $hook, $callback, 10, 1 ); + } + + \wp_set_current_user( 1 ); + if ( isset( $GLOBALS['current_user'] ) && $GLOBALS['current_user'] instanceof \WP_User ) { + $GLOBALS['current_user']->allcaps = array( 'exist' => true ); + } + + try { + $return_value = 'library' === $scenario ? \media_upload_library() : \media_upload_gallery(); + $state['returnType'] = gettype( $return_value ); + $state['returned'] = true; + } finally { + foreach ( $iframe_action_callbacks as $hook => $callback ) { + \remove_action( $hook, $callback, 10 ); + } + \remove_filter( 'wp_die_handler', $die_handler_filter, PHP_INT_MAX ); + \remove_filter( 'user_has_cap', $user_has_cap_filter, 10 ); + \remove_filter( 'media_send_to_editor', $send_filter, 10 ); + \remove_filter( 'attachment_fields_to_save', $save_filter, 10 ); + \remove_filter( 'upload_per_page', $upload_per_page_filter, 10 ); + \remove_filter( 'get_media_item_args', $item_args_filter, 10 ); + \remove_filter( 'media_upload_mime_type_links', $mime_links_filter, 10 ); + \remove_filter( 'attachment_fields_to_edit', $fields_filter, 11 ); + \remove_filter( 'media_upload_form_url', $form_url_filter, 10 ); + } + } catch ( \Throwable $e ) { + $state['throwable'] = self::describe_throwable( $e ); + } + } + + private static function media_library_gallery_query_summary( array $query_vars ): array { + $keys = array( 'post_type', 'post_status', 'post_mime_type', 'posts_per_page', 'paged', 'offset', 's', 'm', 'post_parent', 'author', 'date_query' ); + $out = array(); + foreach ( $keys as $key ) { + if ( array_key_exists( $key, $query_vars ) ) { + $out[ $key ] = $query_vars[ $key ]; + } + } + return $out; + } + + private static function media_library_gallery_child_result_has_expected_shape( array $result ): bool { + return array_key_exists( 'ok', $result ) + && array_key_exists( 'returned', $result ) + && is_string( $result['output'] ?? null ) + && is_array( $result['contentBefore'] ?? null ) + && is_array( $result['contentAfter'] ?? null ) + && is_array( $result['formUrlEvents'] ?? null ) + && is_array( $result['fieldEvents'] ?? null ) + && is_array( $result['mimeLinkEvents'] ?? null ) + && is_array( $result['getMediaItemArgsEvents'] ?? null ) + && is_array( $result['queryPostIds'] ?? null ) + && is_array( $result['queryVars'] ?? null ) + && is_array( $result['saveEvents'] ?? null ) + && is_array( $result['sendEvents'] ?? null ) + && is_array( $result['dieCalls'] ?? null ) + && is_array( $result['iframeActionCounts'] ?? null ) + && is_array( $result['adminEnqueueArgs'] ?? null ) + && is_array( $result['scriptStatus'] ?? null ); + } + + private static function collect_media_library_gallery_failures( array &$failures, array $case, array $result ): void { + $output = (string) ( $result['output'] ?? '' ); + $scenario = (string) ( $case['scenario'] ?? '' ); + $is_library = 'library' === $scenario; + $token = self::media_upload_dispatch_token( (string) ( $result['token'] ?? $case['token'] ?? '' ) ); + $dynamic_hook = $is_library ? 'admin_head_media_upload_library_form' : 'admin_head_media_upload_gallery_form'; + + self::collect_failure( + $failures, + str_contains( $output, 'pagenow = \'media-upload-popup\'' ) + && str_contains( $output, '' ) + && str_contains( $output, 'id="cfz-mime-' . $token . '"' ) + && ! str_contains( $output, 'id="gallery-form"' ) + && ! str_contains( $output, 'id="gallery-settings"' ), + 'library tab renders search/filter chrome, MIME links, and library form without gallery-only controls', + array( 'output' => self::describe_string( $output ) ) + ); + + self::collect_failure( + $failures, + 1 === count( $result['formUrlEvents'] ?? array() ) + && (string) ( $result['type'] ?? '' ) === (string) ( $result['formUrlEvents'][0]['type'] ?? '' ) + && str_contains( (string) ( $result['formUrlEvents'][0]['url'] ?? '' ), 'tab=library' ) + && str_contains( (string) ( $result['formUrlEvents'][0]['url'] ?? '' ), 'post_id=' . (string) ( $result['postId'] ?? 0 ) ), + 'library form action receives the current media type and cast post ID through media_upload_form_url', + array( 'formUrlEvents' => $result['formUrlEvents'] ?? array() ) + ); + + self::collect_failure( + $failures, + $query_ids !== array() + && $query_ids === $field_ids + && count( $query_ids ) <= 10 + && $rendered_query_ids + && $other_ids_absent, + 'library tab renders exactly the paged attachment query result and excludes non-matching MIME rows', + array( + 'queryPostIds' => $query_ids, + 'fieldIds' => $field_ids, + 'otherIds' => $other_ids, + 'output' => self::describe_string( $output ), + ) + ); + + if ( 'image' === ( $request['post_mime_type'] ?? null ) ) { + self::collect_failure( + $failures, + array_diff( $query_ids, $image_ids ) === array() + && ! array_intersect( $query_ids, $other_ids ), + 'library image MIME filter confines the query result to seeded image attachments', + array( + 'queryPostIds' => $query_ids, + 'imageIds' => $image_ids, + 'otherIds' => $other_ids, + ) + ); + } + + self::collect_failure( + $failures, + 10 === (int) ( $query_vars['posts_per_page'] ?? 0 ) + && ( isset( $request['paged'] ) && (int) $request['paged'] < 1 ? 1 : max( 1, (int) ( $request['paged'] ?? 1 ) ) ) === (int) ( $query_vars['paged'] ?? 0 ) + && 'attachment' === (string) ( $query_vars['post_type'] ?? '' ) + && str_contains( $output, 'tablenav-pages' ), + 'library request normalizes pagination/query vars and emits pagination controls for the generated attachment set', + array( + 'requestArgs' => $request, + 'queryVars' => $query_vars, + 'foundPosts' => $result['foundPosts'] ?? null, + 'output' => self::describe_string( $output ), + ) + ); + + $context_ok = ! isset( $request['context'] ) + || ( + ! str_contains( $output, 'display' : ''; + $file_base = 'component-fuzz-' . $post_id . $unsafe_suffix . '.' . $extension; + $relative_file = (string) ( $args['relative_file'] ?? ( '2026/06/' . $file_base ) ); + $width = (int) ( $args['width'] ?? $ctx->int( 640, 1600 ) ); + $height = (int) ( $args['height'] ?? $ctx->int( 480, 1200 ) ); + $metadata = array( + 'width' => max( 0, $width ), + 'height' => max( 0, $height ), + 'file' => $relative_file, + 'filesize' => $ctx->int( 1024, 1048576 ), + 'sizes' => array(), + 'image_meta' => array( + 'caption' => '', + 'credit' => '', + 'created_timestamp' => 0, + 'copyright' => '', + 'title' => '', + ), + ); + + if ( str_starts_with( $mime, 'image/' ) ) { + $metadata['sizes'] = array( + 'thumbnail' => array( + 'file' => 'component-fuzz-' . $post_id . '-150x150.' . $extension, + 'width' => 150, + 'height' => 150, + 'mime-type' => $mime, + 'filesize' => 4096, + ), + 'medium' => array( + 'file' => 'component-fuzz-' . $post_id . '-300x225.' . $extension, + 'width' => 300, + 'height' => 225, + 'mime-type' => $mime, + 'filesize' => 8192, + ), + ); + } + + \update_post_meta( $post_id, '_wp_attached_file', $relative_file ); + \update_post_meta( $post_id, '_wp_attachment_metadata', $metadata ); + \update_post_meta( + $post_id, + '_wp_attachment_image_alt', + (string) ( $args['alt'] ?? 'Alt text "' . $ctx->identifier( 3, 8 ) ) + ); + + $post = \get_post( $post_id ); + if ( ! $post instanceof \WP_Post ) { + throw new \RuntimeException( 'Seeded attachment could not be read.' ); + } + + return $post; + } + + private static function seed_parent_post( \ComponentFuzz\FuzzContext $ctx ): int { + $post_id = \wp_insert_post( + array( + 'post_content' => 'Component fuzz parent content', + 'post_date' => '2026-06-01 11:00:00', + 'post_date_gmt' => '2026-06-01 11:00:00', + 'post_name' => 'component-fuzz-parent-' . substr( hash( 'crc32b', (string) $ctx->seed() ), 0, 8 ), + 'post_status' => 'publish', + 'post_title' => 'Component fuzz parent ' . $ctx->identifier( 3, 8 ), + 'post_type' => 'post', + ), + true + ); + + if ( ! is_int( $post_id ) || $post_id <= 0 ) { + throw new \RuntimeException( 'Could not seed parent post.' ); + } + + return $post_id; + } + + private static function extension_for_mime( string $mime ): string { + $map = array( + 'application/pdf' => 'pdf', + 'image/gif' => 'gif', + 'image/jpeg' => 'jpg', + 'image/png' => 'png', + 'image/webp' => 'webp', + 'text/plain' => 'txt', + ); + + return $map[ $mime ] ?? 'bin'; + } + + private static function prepare_runtime(): void { + if ( isset( $GLOBALS['wpdb'] ) && $GLOBALS['wpdb'] instanceof \Component_Fuzz_WPDB_Stub ) { + $GLOBALS['wpdb']->component_fuzz_reset_content(); + $GLOBALS['wpdb']->component_fuzz_reset_options( + array( + 'admin_email' => 'admin@example.test', + 'blog_charset' => 'UTF-8', + 'blogname' => 'Component Fuzz', + 'default_category' => 0, + 'default_comment_status' => 'closed', + 'default_ping_status' => 'closed', + 'gmt_offset' => 0, + 'home' => 'http://example.test', + 'image_default_align' => 'none', + 'image_default_link_type' => 'file', + 'image_default_size' => 'medium', + 'large_size_h' => 1024, + 'large_size_w' => 1024, + 'medium_size_h' => 300, + 'medium_size_w' => 300, + 'permalink_structure' => '', + 'siteurl' => 'http://example.test', + 'thumbnail_crop' => 1, + 'thumbnail_size_h' => 150, + 'thumbnail_size_w' => 150, + 'timezone_string' => '', + 'upload_path' => '', + 'upload_url_path' => '', + 'uploads_use_yearmonth_folders' => 1, + ) + ); + } + + if ( function_exists( 'wp_cache_flush' ) ) { + \wp_cache_flush(); + } + + $GLOBALS['wp_post_types'] = array(); + $GLOBALS['wp_post_statuses'] = array(); + $GLOBALS['wp_taxonomies'] = array(); + $GLOBALS['post'] = null; + $GLOBALS['post_ID'] = 0; + $GLOBALS['pagenow'] = 'upload.php'; + $GLOBALS['wp_rewrite'] = new \WP_Rewrite(); + + \create_initial_post_types(); + \create_initial_taxonomies(); + \wp_set_current_user( 0 ); + } + + private static function load_default_filters(): void { + $wpdb = $GLOBALS['wpdb'] ?? null; + if ( ! is_object( $wpdb ) ) { + $wpdb = new \stdClass(); + $wpdb->charset = 'utf8mb4'; + $GLOBALS['wpdb'] = $wpdb; + } + + require \ComponentFuzz\repo_root() . DIRECTORY_SEPARATOR . 'src' . DIRECTORY_SEPARATOR . 'wp-includes' . DIRECTORY_SEPARATOR . 'default-filters.php'; + } + + private static function cleanup_runtime(): void { + if ( isset( $GLOBALS['wpdb'] ) && $GLOBALS['wpdb'] instanceof \Component_Fuzz_WPDB_Stub ) { + $GLOBALS['wpdb']->component_fuzz_reset_content(); + } + + if ( function_exists( 'wp_cache_flush' ) ) { + \wp_cache_flush(); + } + } + + private static function capture_output( callable $callback ): string { + ob_start(); + try { + $callback(); + } catch ( \Throwable $e ) { + ob_end_clean(); + throw $e; + } + + return (string) ob_get_clean(); + } + + private static function collect_failure( array &$failures, bool $condition, string $label, array $details = array() ): void { + if ( $condition ) { + return; + } + + $failures[] = array( + 'label' => $label, + 'details' => self::describe_value( $details ), + ); + } + + private static function html_has_no_raw_script( string $html ): bool { + return ! str_contains( strtolower( $html ), ' $ok, + 'status' => $status ?? ( $ok ? 'passed' : 'failed' ), + 'surface' => self::NAME, + 'invariant' => $invariant, + 'seed' => $ctx->seed(), + 'iteration' => $ctx->iteration(), + 'data' => self::describe_value( $data ), + ); + } + + private static function describe_throwable( \Throwable $e ): array { + return array( + 'class' => get_class( $e ), + 'message' => self::escape_bytes( $e->getMessage() ), + 'file' => $e->getFile(), + 'line' => $e->getLine(), + ); + } + + private static function describe_value( $value, int $depth = 0 ) { + if ( is_string( $value ) ) { + return self::describe_string( $value ); + } + + if ( is_array( $value ) ) { + if ( $depth >= 4 ) { + return array( + 'type' => 'array', + 'count' => count( $value ), + ); + } + + $out = array(); + $i = 0; + foreach ( $value as $key => $item ) { + if ( $i >= 16 ) { + $out['...'] = count( $value ) - $i; + break; + } + $out[ is_int( $key ) ? $key : self::escape_bytes( (string) $key ) ] = self::describe_value( $item, $depth + 1 ); + ++$i; + } + return $out; + } + + if ( is_object( $value ) ) { + if ( $value instanceof \Throwable ) { + return self::describe_throwable( $value ); + } + + return array( + 'type' => 'object', + 'class' => get_class( $value ), + ); + } + + return $value; + } + + private static function describe_string( string $value ): array { + return array( + 'type' => 'string', + 'bytes' => strlen( $value ), + 'sha1' => sha1( $value ), + 'preview' => self::escape_bytes( $value ), + ); + } + + private static function escape_bytes( string $value, int $limit = self::PREVIEW_BYTES ): string { + $out = ''; + $length = strlen( $value ); + $shown = min( $length, $limit ); + + for ( $i = 0; $i < $shown; ++$i ) { + $byte = ord( $value[ $i ] ); + if ( 0x5C === $byte ) { + $out .= '\\\\'; + } elseif ( $byte >= 0x20 && $byte <= 0x7E ) { + $out .= chr( $byte ); + } elseif ( 0x0A === $byte ) { + $out .= '\\n'; + } elseif ( 0x0D === $byte ) { + $out .= '\\r'; + } elseif ( 0x09 === $byte ) { + $out .= '\\t'; + } else { + $out .= sprintf( '\\x%02X', $byte ); + } + } + + if ( $length > $shown ) { + $out .= '...'; + } + + return $out; + } + + private static function snapshot_state(): array { + $snapshot = array(); + foreach ( + array( + '_GET', + '_POST', + '_REQUEST', + '_SERVER', + 'body_id', + 'content_width', + 'pagenow', + 'post', + 'post_ID', + 'redir_tab', + 'tab', + 'type', + 'wp_actions', + 'wp_current_filter', + 'wp_filter', + 'wp_filters', + 'wp_post_statuses', + 'wp_post_types', + 'wp_rewrite', + 'wp_scripts', + 'wp_styles', + 'wp_taxonomies', + ) as $name + ) { + $snapshot[ $name ] = array( + 'exists' => array_key_exists( $name, $GLOBALS ), + 'value' => array_key_exists( $name, $GLOBALS ) ? self::clone_value( $GLOBALS[ $name ] ) : null, + ); + } + + return $snapshot; + } + + private static function snapshot_globals( array $names ): array { + $snapshot = array(); + foreach ( $names as $name ) { + $snapshot[ $name ] = array( + 'exists' => array_key_exists( $name, $GLOBALS ), + 'value' => array_key_exists( $name, $GLOBALS ) ? self::clone_value( $GLOBALS[ $name ] ) : null, + ); + } + + return $snapshot; + } + + private static function restore_state( array $snapshot ): void { + foreach ( $snapshot as $name => $entry ) { + if ( $entry['exists'] ) { + $GLOBALS[ $name ] = $entry['value']; + } else { + unset( $GLOBALS[ $name ] ); + } + } + } + + private static function load_editor_class(): void { + if ( ! class_exists( '_WP_Editors', false ) && defined( 'ABSPATH' ) && defined( 'WPINC' ) ) { + require_once ABSPATH . WPINC . '/class-wp-editor.php'; + } + } + + private static function snapshot_editor_statics(): array { + $statics = array(); + foreach ( self::editor_static_property_names() as $property ) { + $statics[ $property ] = self::clone_value( self::get_editor_static_property( $property ) ); + } + + return $statics; + } + + private static function restore_editor_statics( array $snapshot ): void { + foreach ( $snapshot as $property => $value ) { + self::set_editor_static_property( (string) $property, $value ); + } + } + + private static function editor_static_property_names(): array { + return array( + 'mce_locale', + 'mce_settings', + 'qt_settings', + 'plugins', + 'qt_buttons', + 'ext_plugins', + 'baseurl', + 'first_init', + 'this_tinymce', + 'this_quicktags', + 'has_tinymce', + 'has_quicktags', + 'has_medialib', + 'editor_buttons_css', + 'drag_drop_upload', + 'translation', + 'tinymce_scripts_printed', + 'link_dialog_printed', + ); + } + + private static function get_editor_static_property( string $property ) { + $reflection = new \ReflectionProperty( '_WP_Editors', $property ); + return $reflection->getValue(); + } + + private static function set_editor_static_property( string $property, $value ): void { + $reflection = new \ReflectionProperty( '_WP_Editors', $property ); + $reflection->setValue( null, $value ); + } + + private static function clone_value( $value ) { + if ( is_object( $value ) ) { + return clone $value; + } + + if ( is_array( $value ) ) { + $copy = array(); + foreach ( $value as $key => $item ) { + $copy[ $key ] = self::clone_value( $item ); + } + return $copy; + } + + return $value; + } +} diff --git a/tools/component-fuzz/surfaces/AdminOptionsSubmissionSurface.php b/tools/component-fuzz/surfaces/AdminOptionsSubmissionSurface.php new file mode 100644 index 0000000000000..bdd0504e76137 --- /dev/null +++ b/tools/component-fuzz/surfaces/AdminOptionsSubmissionSurface.php @@ -0,0 +1,2002 @@ +skip( + 'admin-options-submission.bootstrap-apis-available', + 'Required admin settings submission APIs are unavailable.', + array( 'missing' => $missing ) + ), + ); + } + + $snapshot = self::snapshot_state(); + $rows = array(); + + try { + $rows[] = self::check_registered_settings_submission( $ctx->fork( 'registered' ) ); + $rows[] = self::check_general_options_submission_branches( $ctx->fork( 'general' ) ); + $rows[] = self::check_core_options_page_sanitization( $ctx->fork( 'core-pages' ) ); + $rows[] = self::check_writing_options_allowlist_gates( $ctx->fork( 'writing-gates' ) ); + $rows[] = self::check_new_admin_email_pending_change( $ctx->fork( 'new-admin-email' ) ); + $rows[] = self::check_legacy_options_page_submission( $ctx->fork( 'legacy' ) ); + $rows[] = self::check_failure_paths( $ctx->fork( 'failures' ) ); + } catch ( \Throwable $e ) { + $rows[] = $ctx->fail( + 'admin-options-submission.surface-no-throw', + array( 'throwable' => self::describe_throwable( $e ) ) + ); + } finally { + self::restore_state( $snapshot ); + } + + $rows[] = $ctx->result( + 'admin-options-submission.global-state-restored', + self::state_matches( $snapshot ), + array( 'trackedGlobals' => array_keys( $snapshot['globals'] ) ) + ); + + return $rows; + } + + private static function load_new_admin_email_support(): void { + $misc_file = defined( 'ABSPATH' ) ? ABSPATH . 'wp-admin/includes/misc.php' : ''; + if ( ! function_exists( 'update_option_new_admin_email' ) && $misc_file && file_exists( $misc_file ) ) { + require_once $misc_file; + } + } + + private static function missing_requirements(): array { + $missing = array(); + + foreach ( + array( + 'add_filter', + 'add_query_arg', + 'add_action', + 'add_option', + 'add_settings_error', + 'admin_url', + 'apply_filters', + 'apply_filters_deprecated', + 'check_admin_referer', + 'current_user_can', + 'delete_transient', + 'esc_url', + 'esc_html', + 'get_current_user_id', + 'get_settings_errors', + 'get_site_option', + 'get_transient', + 'get_user_locale', + 'get_option', + 'has_action', + 'has_filter', + 'home_url', + 'is_email', + 'is_multisite', + 'is_utf8_charset', + 'is_wp_error', + 'load_default_textdomain', + 'option_update_filter', + 'restore_previous_locale', + 'remove_action', + 'register_setting', + 'remove_filter', + 'sanitize_key', + 'sanitize_email', + 'sanitize_text_field', + 'sanitize_textarea_field', + 'sanitize_option', + 'set_transient', + 'unregister_setting', + 'update_option', + 'update_option_new_admin_email', + 'wp_cache_flush', + 'wp_create_nonce', + 'wp_die', + 'wp_get_current_user', + 'wp_get_referer', + 'wp_insert_user', + 'wp_mail', + 'wp_rand', + 'wp_redirect', + 'wp_set_current_user', + 'wp_slash', + 'wp_specialchars_decode', + 'wp_unslash', + 'self_admin_url', + 'switch_to_user_locale', + ) as $function + ) { + if ( ! function_exists( $function ) ) { + $missing[] = "function {$function}"; + } + } + + if ( ! class_exists( 'Component_Fuzz_WPDB_Stub', false ) ) { + $missing[] = 'class Component_Fuzz_WPDB_Stub'; + } + if ( ! class_exists( 'WP_User', false ) ) { + $missing[] = 'class WP_User'; + } + + return $missing; + } + + private static function check_registered_settings_submission( \ComponentFuzz\FuzzContext $ctx ): array { + self::reset_runtime(); + + $failures = array(); + $group = self::id( $ctx->fork( 'group' ), 'cfz_settings_group', 40 ); + $text_option = self::id( $ctx->fork( 'text' ), 'cfz_settings_text', 40 ); + $array_option = self::id( $ctx->fork( 'array' ), 'cfz_settings_array', 40 ); + $missing_option = self::id( $ctx->fork( 'missing' ), 'cfz_settings_missing', 40 ); + $error_option = self::id( $ctx->fork( 'error' ), 'cfz_settings_error', 40 ); + $intruder = self::id( $ctx->fork( 'intruder' ), 'cfz_settings_intruder', 40 ); + $raw_text = " Raw " . $ctx->identifier( 4, 8 ) . " \\\"quoted\\\" "; + $raw_error = 'Needs review '; + $array_raw = array( + ' First Value ', + '' . $ctx->identifier( 3, 7 ) . '', + 'third\\value', + ); + $sanitize_calls = array(); + + \add_option( $text_option, 'previous text' ); + \add_option( $array_option, array( 'previous-array' ) ); + \add_option( $missing_option, 'previous missing' ); + \add_option( $error_option, 'previous error' ); + + $text_sanitize = static function ( $value ) use ( &$sanitize_calls ): string { + $sanitize_calls['text'][] = $value; + return 'text:' . \sanitize_text_field( (string) $value ); + }; + $array_sanitize = static function ( $value ) use ( &$sanitize_calls ): array { + $sanitize_calls['array'][] = $value; + return array_map( + static fn( $item ): string => \sanitize_key( \sanitize_text_field( (string) $item ) ), + (array) $value + ); + }; + $missing_sanitize = static function ( $value ) use ( &$sanitize_calls ): string { + $sanitize_calls['missing'][] = $value; + return null === $value ? 'missing-was-null' : 'missing:' . \sanitize_text_field( (string) $value ); + }; + $error_sanitize = static function ( $value ) use ( &$sanitize_calls, $error_option ): string { + $sanitize_calls['error'][] = $value; + \add_settings_error( $error_option, 'component_fuzz_rejected', 'Generated setting requires review.', 'error' ); + return 'error:' . \sanitize_text_field( (string) $value ); + }; + + \register_setting( $group, $text_option, array( 'sanitize_callback' => $text_sanitize ) ); + \register_setting( $group, $array_option, array( 'sanitize_callback' => $array_sanitize ) ); + \register_setting( $group, $missing_option, array( 'sanitize_callback' => $missing_sanitize ) ); + \register_setting( $group, $error_option, array( 'sanitize_callback' => $error_sanitize ) ); + + $cap_events = array(); + $cap_filter = self::install_cap_filter( + array( 'manage_options', 'manage_component_fuzz_settings' ), + $cap_events + ); + $capability_filter = static function ( string $capability ) use ( &$cap_events ): string { + $cap_events[] = array( + 'type' => 'option-page-capability', + 'capability' => $capability, + ); + return 'manage_component_fuzz_settings'; + }; + + \add_filter( "option_page_capability_{$group}", $capability_filter ); + \add_filter( 'allowed_options', 'option_update_filter' ); + + try { + $result = self::dispatch_options_update( + array( + 'post' => array( + 'action' => 'update', + 'option_page' => $group, + '_wpnonce' => \wp_create_nonce( $group . '-options' ), + $text_option => $raw_text, + $array_option => $array_raw, + $error_option => $raw_error, + $intruder => 'posted but not allowlisted', + ), + 'referer' => 'http://example.test/wp-admin/options-general.php?page=' . rawurlencode( $group ) . '&tab=main', + ) + ); + } finally { + \remove_filter( 'allowed_options', 'option_update_filter' ); + \remove_filter( "option_page_capability_{$group}", $capability_filter ); + \remove_filter( 'user_has_cap', $cap_filter, 10 ); + \unregister_setting( $group, $text_option ); + \unregister_setting( $group, $array_option ); + \unregister_setting( $group, $missing_option ); + \unregister_setting( $group, $error_option ); + } + + $errors = $result['settingsErrors'] ?? array(); + $transient = $result['settingsTransient'] ?? array(); + $expected_text = 'text:' . \sanitize_text_field( (string) trim( $raw_text ) ); + $expected_array = array_map( + static fn( $item ): string => \sanitize_key( \sanitize_text_field( (string) $item ) ), + $array_raw + ); + $expected_error = 'error:' . \sanitize_text_field( (string) trim( $raw_error ) ); + + self::collect_failure( + $failures, + 'completed' === ( $result['status'] ?? null ) + && false === ( $result['redirectResult'] ?? null ) + && self::redirect_has_settings_updated( $result['redirect']['location'] ?? '' ) + && 302 === ( $result['redirect']['status'] ?? null ) + && array( trim( $raw_text ) ) === ( $sanitize_calls['text'] ?? null ) + && array( $array_raw ) === ( $sanitize_calls['array'] ?? null ) + && array( null ) === ( $sanitize_calls['missing'] ?? null ) + && array( trim( $raw_error ) ) === ( $sanitize_calls['error'] ?? null ) + && $expected_text === \get_option( $text_option ) + && $expected_array === \get_option( $array_option ) + && 'missing-was-null' === \get_option( $missing_option ) + && $expected_error === \get_option( $error_option ) + && '__missing__' === \get_option( $intruder, '__missing__' ) + && in_array( $text_option, $result['allowedOptions'][ $group ] ?? array(), true ) + && in_array( $array_option, $result['updatedOptions'] ?? array(), true ) + && self::has_settings_error( $errors, $error_option, 'component_fuzz_rejected', 'error' ) + && $errors === $transient + && ! self::has_settings_error( $errors, 'general', 'settings_updated', 'success' ) + && self::nonce_event_seen( $result, $group . '-options', 1 ) + && self::capability_event_seen( $cap_events, 'manage_component_fuzz_settings' ) + && false === \has_filter( 'allowed_options', 'option_update_filter' ) + && false === \has_filter( "option_page_capability_{$group}", $capability_filter ) + && false === \has_filter( 'user_has_cap', $cap_filter ), + 'registered Settings API submission updates only allowlisted options, runs sanitize callbacks, persists errors, and redirects back', + array( + 'group' => $group, + 'result' => self::summarize_dispatch_result( $result ), + 'sanitizeCalls' => self::describe_value( $sanitize_calls ), + 'capEvents' => array_slice( $cap_events, 0, 8 ), + 'stored' => array( + 'text' => \get_option( $text_option ), + 'array' => \get_option( $array_option ), + 'missing' => \get_option( $missing_option ), + 'error' => \get_option( $error_option ), + 'intruder' => \get_option( $intruder, '__missing__' ), + ), + 'errors' => $errors, + ) + ); + + return $ctx->result( + 'admin-options-submission.registered-settings-update', + array() === $failures, + array( 'failures' => array_slice( $failures, 0, 3 ) ) + ); + } + + private static function check_general_options_submission_branches( \ComponentFuzz\FuzzContext $ctx ): array { + $failures = array(); + $cap_events = array(); + $cap_filter = self::install_cap_filter( array( 'manage_options' ), $cap_events ); + $email_filter = static function ( $is_email, string $email ) { + return 'admin@example.com' === $email ? $email : $is_email; + }; + $sanitize_email_filter = static function ( string $sanitized, string $email ): string { + return 'admin@example.com' === $email ? $email : $sanitized; + }; + \add_filter( 'is_email', $email_filter, 10, 2 ); + \add_filter( 'sanitize_email', $sanitize_email_filter, 10, 2 ); + + try { + self::reset_runtime( + array( + 'timezone_string' => 'Europe/Madrid', + 'gmt_offset' => '1', + 'date_format' => 'Y-m-d', + 'time_format' => 'H:i', + ) + ); + + $invalid_tz = self::dispatch_options_update( + array( + 'post' => array_merge( + self::general_post_defaults(), + array( + 'action' => 'update', + 'option_page' => 'general', + '_wpnonce' => \wp_create_nonce( 'general-options' ), + 'date_format' => '\c\u\s\t\o\m', + 'date_format_custom' => 'Y/m/d ' . $ctx->identifier( 3, 6 ) . '', + 'time_format' => '\c\u\s\t\o\m', + 'time_format_custom' => 'H:i:s ' . $ctx->identifier( 3, 6 ) . '', + 'timezone_string' => 'Mars/Olympus Mons', + 'gmt_offset' => '1', + 'blogname' => 'General Branch', + 'blogdescription' => 'Description Branch', + ) + ), + 'referer' => 'http://example.test/wp-admin/options-general.php', + ) + ); + + $invalid_values = array( + 'date_format' => \get_option( 'date_format' ), + 'time_format' => \get_option( 'time_format' ), + 'timezone_string' => \get_option( 'timezone_string' ), + 'gmt_offset' => \get_option( 'gmt_offset' ), + 'blogname' => \get_option( 'blogname' ), + 'blogdescription' => \get_option( 'blogdescription' ), + ); + + self::reset_runtime( + array( + 'timezone_string' => 'Europe/Madrid', + 'gmt_offset' => '1', + ) + ); + $utc_offset = self::dispatch_options_update( + array( + 'post' => array_merge( + self::general_post_defaults(), + array( + 'action' => 'update', + 'option_page' => 'general', + '_wpnonce' => \wp_create_nonce( 'general-options' ), + 'timezone_string' => 'UTC+5.5', + 'gmt_offset' => '1', + 'date_format' => 'Y-m-d', + 'time_format' => 'H:i', + 'blogname' => 'UTC Branch', + 'blogdescription' => 'UTC Description', + ) + ), + 'referer' => 'http://example.test/wp-admin/options-general.php', + ) + ); + $utc_values = array( + 'timezone_string' => \get_option( 'timezone_string' ), + 'gmt_offset' => \get_option( 'gmt_offset' ), + ); + } finally { + \remove_filter( 'sanitize_email', $sanitize_email_filter, 10 ); + \remove_filter( 'is_email', $email_filter, 10 ); + \remove_filter( 'user_has_cap', $cap_filter, 10 ); + } + + self::collect_failure( + $failures, + 'completed' === ( $invalid_tz['status'] ?? null ) + && self::redirect_has_settings_updated( $invalid_tz['redirect']['location'] ?? '' ) + && str_contains( $invalid_values['date_format'], 'Y/m/d' ) + && ! str_contains( $invalid_values['date_format'], '<' ) + && str_contains( $invalid_values['time_format'], 'H:i:s' ) + && ! str_contains( $invalid_values['time_format'], '<' ) + && 'Europe/Madrid' === $invalid_values['timezone_string'] + && '1' === (string) $invalid_values['gmt_offset'] + && 'General <b>Branch</b>' === $invalid_values['blogname'] + && 'Description <i>Branch</i>' === $invalid_values['blogdescription'] + && self::has_settings_error( $invalid_tz['settingsErrors'] ?? array(), 'general', 'settings_updated', 'error' ) + && ! self::has_settings_error( $invalid_tz['settingsErrors'] ?? array(), 'general', 'settings_updated', 'success' ) + && self::nonce_event_seen( $invalid_tz, 'general-options', 1 ), + 'general options submission applies custom date/time formats, rejects invalid timezones, preserves current timezone, and stores error transient', + array( + 'result' => self::summarize_dispatch_result( $invalid_tz ), + 'values' => $invalid_values, + ) + ); + + self::collect_failure( + $failures, + 'completed' === ( $utc_offset['status'] ?? null ) + && '' === $utc_values['timezone_string'] + && '5.5' === (string) $utc_values['gmt_offset'] + && self::has_settings_error( $utc_offset['settingsErrors'] ?? array(), 'general', 'settings_updated', 'success' ) + && ! self::has_settings_error( $utc_offset['settingsErrors'] ?? array(), 'general', 'settings_updated', 'error' ) + && self::nonce_event_seen( $utc_offset, 'general-options', 1 ) + && false === \has_filter( 'sanitize_email', $sanitize_email_filter ) + && false === \has_filter( 'is_email', $email_filter ) + && false === \has_filter( 'user_has_cap', $cap_filter ), + 'general options submission maps UTC offsets to gmt_offset and records the default success settings error', + array( + 'result' => self::summarize_dispatch_result( $utc_offset ), + 'values' => $utc_values, + 'capEvents' => array_slice( $cap_events, 0, 8 ), + ) + ); + + return $ctx->result( + 'admin-options-submission.general-date-timezone-branches', + array() === $failures, + array( 'failures' => array_slice( $failures, 0, 4 ) ) + ); + } + + private static function check_core_options_page_sanitization( \ComponentFuzz\FuzzContext $ctx ): array { + $failures = array(); + $suffix = strtolower( preg_replace( '/[^a-z0-9]+/', '', $ctx->identifier( 5, 12 ) ) ); + $suffix = '' === $suffix ? 'cfz' . substr( md5( (string) $ctx->seed() ), 0, 8 ) : $suffix; + + $reading = self::dispatch_core_page_update( + 'reading', + array( + 'posts_per_page' => '-7 posts', + 'posts_per_rss' => '0', + 'rss_use_excerpt' => ' 1 ', + 'show_on_front' => 'page', + 'page_on_front' => '-42front', + 'page_for_posts' => '77posts', + ), + array( + 'posts_per_page' => 10, + 'posts_per_rss' => 10, + 'blog_public' => '0', + ) + ); + + self::collect_failure( + $failures, + self::core_page_completed( $reading, 'reading' ) + && array( 'posts_per_page', 'posts_per_rss', 'rss_use_excerpt', 'show_on_front', 'page_on_front', 'page_for_posts', 'blog_public' ) === ( $reading['result']['updatedOptions'] ?? null ) + && 7 === \get_option( 'posts_per_page' ) + && 1 === \get_option( 'posts_per_rss' ) + && '1' === \get_option( 'rss_use_excerpt' ) + && 'page' === \get_option( 'show_on_front' ) + && 42 === \get_option( 'page_on_front' ) + && 77 === \get_option( 'page_for_posts' ) + && 1 === \get_option( 'blog_public' ) + && self::pre_update_event_seen( $reading['preUpdateEvents'], 'blog_public', '0', 1 ) + && self::pre_update_event_seen( $reading['preUpdateEvents'], 'posts_per_rss', 10, 1 ), + 'reading options page sanitizes numeric pagination/front-page values and applies the missing blog_public checkbox default', + array( + 'result' => self::summarize_dispatch_result( $reading['result'] ), + 'stored' => self::stored_options( + array( 'posts_per_page', 'posts_per_rss', 'rss_use_excerpt', 'show_on_front', 'page_on_front', 'page_for_posts', 'blog_public' ) + ), + 'preUpdateEvents' => $reading['preUpdateEvents'], + 'capEvents' => array_slice( $reading['capEvents'], 0, 8 ), + ) + ); + + $discussion = self::dispatch_core_page_update( + 'discussion', + array( + 'default_pingback_flag' => '1', + 'default_ping_status' => '0', + 'default_comment_status' => '', + 'comments_notify' => '1', + 'moderation_notify' => '0', + 'comment_moderation' => '1', + 'require_name_email' => '1', + 'comment_previously_approved' => '0', + 'comment_max_links' => '-11 links', + 'moderation_keys' => "hold-{$suffix}\n hold-{$suffix} \nreview-{$suffix}\n\n", + 'disallowed_keys' => "spam-{$suffix}\n\nspam-{$suffix}\ntrash-{$suffix} ", + 'show_avatars' => '1', + 'avatar_rating' => 'pg', + 'avatar_default' => 'mystery', + 'close_comments_for_old_posts' => '1', + 'close_comments_days_old' => '-30 days', + 'thread_comments' => '1', + 'thread_comments_depth' => '-4', + 'page_comments' => '1', + 'comments_per_page' => '-50', + 'default_comments_page' => 'newest', + 'comment_order' => 'desc', + 'comment_registration' => '1', + 'show_comments_cookies_opt_in' => '1', + 'wp_notes_notify' => '0', + ) + ); + + $expected_moderation = "hold-{$suffix}\nreview-{$suffix}"; + $expected_disallowed = "spam-{$suffix}\ntrash-{$suffix}"; + self::collect_failure( + $failures, + self::core_page_completed( $discussion, 'discussion' ) + && ( $discussion['result']['allowedOptions']['discussion'] ?? array() ) === ( $discussion['result']['updatedOptions'] ?? null ) + && 'closed' === \get_option( 'default_ping_status' ) + && 'closed' === \get_option( 'default_comment_status' ) + && 11 === \get_option( 'comment_max_links' ) + && $expected_moderation === \get_option( 'moderation_keys' ) + && $expected_disallowed === \get_option( 'disallowed_keys' ) + && 30 === \get_option( 'close_comments_days_old' ) + && 4 === \get_option( 'thread_comments_depth' ) + && 50 === \get_option( 'comments_per_page' ) + && self::pre_update_event_seen( $discussion['preUpdateEvents'], 'default_ping_status', false, 'closed' ) + && self::pre_update_event_seen( $discussion['preUpdateEvents'], 'moderation_keys', false, $expected_moderation ), + 'discussion options page normalizes closed statuses, absolute integer fields, and unique keyword lists', + array( + 'result' => self::summarize_dispatch_result( $discussion['result'] ), + 'stored' => self::stored_options( + array( 'default_ping_status', 'default_comment_status', 'comment_max_links', 'moderation_keys', 'disallowed_keys', 'close_comments_days_old', 'thread_comments_depth', 'comments_per_page' ) + ), + 'preUpdateEvents' => $discussion['preUpdateEvents'], + 'capEvents' => array_slice( $discussion['capEvents'], 0, 8 ), + ) + ); + + $media = self::dispatch_core_page_update( + 'media', + array( + 'thumbnail_size_w' => '-150px', + 'thumbnail_size_h' => '90px', + 'thumbnail_crop' => '1', + 'medium_size_w' => '-640', + 'medium_size_h' => '0', + 'large_size_w' => '-2048', + 'large_size_h' => '1024', + 'image_default_size' => 'large', + 'image_default_align' => 'left', + 'image_default_link_type' => 'file', + 'uploads_use_yearmonth_folders' => '1', + ) + ); + + self::collect_failure( + $failures, + self::core_page_completed( $media, 'media' ) + && ( $media['result']['allowedOptions']['media'] ?? array() ) === ( $media['result']['updatedOptions'] ?? null ) + && 150 === \get_option( 'thumbnail_size_w' ) + && 90 === \get_option( 'thumbnail_size_h' ) + && '1' === \get_option( 'thumbnail_crop' ) + && 640 === \get_option( 'medium_size_w' ) + && 0 === \get_option( 'medium_size_h' ) + && 2048 === \get_option( 'large_size_w' ) + && 1024 === \get_option( 'large_size_h' ) + && 'large' === \get_option( 'image_default_size' ) + && 'left' === \get_option( 'image_default_align' ) + && 'file' === \get_option( 'image_default_link_type' ) + && self::pre_update_event_seen( $media['preUpdateEvents'], 'thumbnail_size_w', false, 150 ) + && self::pre_update_event_seen( $media['preUpdateEvents'], 'large_size_w', false, 2048 ), + 'media options page applies absint dimensions while preserving enumerated media defaults', + array( + 'result' => self::summarize_dispatch_result( $media['result'] ), + 'stored' => self::stored_options( + array( 'thumbnail_size_w', 'thumbnail_size_h', 'thumbnail_crop', 'medium_size_w', 'medium_size_h', 'large_size_w', 'large_size_h', 'image_default_size', 'image_default_align', 'image_default_link_type', 'uploads_use_yearmonth_folders' ) + ), + 'preUpdateEvents' => $media['preUpdateEvents'], + 'capEvents' => array_slice( $media['capEvents'], 0, 8 ), + ) + ); + + $writing = self::dispatch_core_page_update( + 'writing', + array( + 'default_category' => '-12', + 'default_email_category' => '34cats', + 'default_link_category' => '-56links', + 'default_post_format' => 'aside', + 'mailserver_url' => " mail.{$suffix}.example.test ", + 'mailserver_port' => '-110', + 'mailserver_login' => " login{$suffix} ", + 'mailserver_pass' => " pass{$suffix} ", + ), + array(), + static function (): callable { + $initial_db_filter = static function () { + return 32453; + }; + \add_filter( 'pre_site_option_initial_db_version', $initial_db_filter ); + + return static function () use ( $initial_db_filter ): void { + \remove_filter( 'pre_site_option_initial_db_version', $initial_db_filter ); + }; + } + ); + + self::collect_failure( + $failures, + self::core_page_completed( $writing, 'writing' ) + && ( $writing['result']['allowedOptions']['writing'] ?? array() ) === ( $writing['result']['updatedOptions'] ?? null ) + && 12 === \get_option( 'default_category' ) + && 34 === \get_option( 'default_email_category' ) + && 56 === \get_option( 'default_link_category' ) + && 'aside' === \get_option( 'default_post_format' ) + && "mail.{$suffix}.example.test" === \get_option( 'mailserver_url' ) + && 110 === \get_option( 'mailserver_port' ) + && "login{$suffix}" === \get_option( 'mailserver_login' ) + && "pass{$suffix}" === \get_option( 'mailserver_pass' ) + && self::pre_update_event_seen( $writing['preUpdateEvents'], 'mailserver_url', false, "mail.{$suffix}.example.test" ) + && self::pre_update_event_seen( $writing['preUpdateEvents'], 'mailserver_port', false, 110 ), + 'writing options page sanitizes category IDs, mail server port, and stripped mail server text fields', + array( + 'result' => self::summarize_dispatch_result( $writing['result'] ), + 'stored' => self::stored_options( + array( 'default_category', 'default_email_category', 'default_link_category', 'default_post_format', 'mailserver_url', 'mailserver_port', 'mailserver_login', 'mailserver_pass' ) + ), + 'preUpdateEvents' => $writing['preUpdateEvents'], + 'capEvents' => array_slice( $writing['capEvents'], 0, 8 ), + ) + ); + + self::collect_failure( + $failures, + false === \has_filter( 'pre_update_option' ) + && false === \has_filter( 'user_has_cap' ), + 'core page submission filters are removed after the matrix', + array( + 'preUpdate' => \has_filter( 'pre_update_option' ), + 'cap' => \has_filter( 'user_has_cap' ), + ) + ); + + return $ctx->result( + 'admin-options-submission.core-page-sanitization-matrix', + array() === $failures, + array( 'failures' => array_slice( $failures, 0, 6 ) ) + ); + } + + private static function check_writing_options_allowlist_gates( \ComponentFuzz\FuzzContext $ctx ): array { + $failures = array(); + $suffix = strtolower( preg_replace( '/[^a-z0-9]+/', '', $ctx->identifier( 5, 12 ) ) ); + $suffix = '' === $suffix ? 'cfz' . substr( md5( (string) $ctx->seed() ), 0, 8 ) : $suffix; + + $enabled_ping_sites = " https://updates.example/{$suffix}\n\nhttp://example.test/{$suffix}?a=1 "; + $enabled = self::dispatch_core_page_update( + 'writing', + array( + 'default_category' => '-12', + 'default_email_category' => '34cats', + 'default_link_category' => '-56links', + 'default_post_format' => 'quote', + 'mailserver_url' => " mail.{$suffix}.example.test ", + 'mailserver_port' => '-110', + 'mailserver_login' => " login{$suffix} ", + 'mailserver_pass' => " pass{$suffix} ", + 'use_smilies' => '1', + 'use_balanceTags' => '0', + 'ping_sites' => $enabled_ping_sites, + 'not_a_writing_option' => 'do-not-store', + ), + array( + 'blog_public' => '1', + ), + self::writing_gate_filter_installer( true, 32452 ) + ); + $enabled_expected = array( + 'default_category', + 'default_email_category', + 'default_link_category', + 'default_post_format', + 'mailserver_url', + 'mailserver_port', + 'mailserver_login', + 'mailserver_pass', + 'use_smilies', + 'use_balanceTags', + 'ping_sites', + ); + $expected_ping_sites = "https://updates.example/{$suffix}\nhttp://example.test/{$suffix}?a=1"; + + self::collect_failure( + $failures, + self::core_page_completed( $enabled, 'writing' ) + && $enabled_expected === ( $enabled['result']['allowedOptions']['writing'] ?? null ) + && $enabled_expected === ( $enabled['result']['updatedOptions'] ?? null ) + && 12 === \get_option( 'default_category' ) + && 34 === \get_option( 'default_email_category' ) + && 56 === \get_option( 'default_link_category' ) + && 'quote' === \get_option( 'default_post_format' ) + && "mail.{$suffix}.example.test" === \get_option( 'mailserver_url' ) + && 110 === \get_option( 'mailserver_port' ) + && "login{$suffix}" === \get_option( 'mailserver_login' ) + && "pass{$suffix}" === \get_option( 'mailserver_pass' ) + && '1' === \get_option( 'use_smilies' ) + && '0' === \get_option( 'use_balanceTags' ) + && $expected_ping_sites === \get_option( 'ping_sites' ) + && '__missing__' === \get_option( 'not_a_writing_option', '__missing__' ) + && self::pre_update_event_seen( $enabled['preUpdateEvents'], 'ping_sites', false, $expected_ping_sites ) + && self::pre_update_event_seen( $enabled['preUpdateEvents'], 'mailserver_port', false, 110 ), + 'enabled legacy/public writing submission includes post-by-email, legacy formatting, and update-service allowlist branches', + array( + 'result' => self::summarize_dispatch_result( $enabled['result'] ), + 'stored' => self::stored_options( + array( 'default_category', 'default_email_category', 'default_link_category', 'default_post_format', 'mailserver_url', 'mailserver_port', 'mailserver_login', 'mailserver_pass', 'use_smilies', 'use_balanceTags', 'ping_sites', 'not_a_writing_option' ) + ), + 'preUpdateEvents' => $enabled['preUpdateEvents'], + 'capEvents' => array_slice( $enabled['capEvents'], 0, 8 ), + ) + ); + + $disabled = self::dispatch_core_page_update( + 'writing', + array( + 'default_category' => '9', + 'default_email_category' => '8', + 'default_link_category' => '7', + 'default_post_format' => 'status', + 'mailserver_url' => 'posted.example.test', + 'mailserver_port' => '995', + 'mailserver_login' => 'posted-login', + 'mailserver_pass' => 'posted-pass', + 'use_smilies' => '1', + 'use_balanceTags' => '1', + 'ping_sites' => 'https://posted.example.test/', + ), + array( + 'blog_public' => '0', + 'mailserver_url' => 'keep.mail.example.test', + 'mailserver_port' => 143, + 'mailserver_login' => 'keep-login', + 'mailserver_pass' => 'keep-pass', + 'use_smilies' => '0', + 'use_balanceTags' => '0', + 'ping_sites' => 'https://old.example.test/', + ), + self::writing_gate_filter_installer( false, 32453 ) + ); + $disabled_expected = array( + 'default_category', + 'default_email_category', + 'default_link_category', + 'default_post_format', + ); + + self::collect_failure( + $failures, + self::core_page_completed( $disabled, 'writing' ) + && $disabled_expected === ( $disabled['result']['allowedOptions']['writing'] ?? null ) + && $disabled_expected === ( $disabled['result']['updatedOptions'] ?? null ) + && 9 === \get_option( 'default_category' ) + && 8 === \get_option( 'default_email_category' ) + && 7 === \get_option( 'default_link_category' ) + && 'status' === \get_option( 'default_post_format' ) + && 'keep.mail.example.test' === \get_option( 'mailserver_url' ) + && 143 === \get_option( 'mailserver_port' ) + && 'keep-login' === \get_option( 'mailserver_login' ) + && 'keep-pass' === \get_option( 'mailserver_pass' ) + && '0' === \get_option( 'use_smilies' ) + && '0' === \get_option( 'use_balanceTags' ) + && 'https://old.example.test/' === \get_option( 'ping_sites' ) + && ! self::pre_update_option_seen( $disabled['preUpdateEvents'], 'mailserver_url' ) + && ! self::pre_update_option_seen( $disabled['preUpdateEvents'], 'ping_sites' ), + 'disabled modern/private writing submission ignores posted gated fields and updates only base writing options', + array( + 'result' => self::summarize_dispatch_result( $disabled['result'] ), + 'stored' => self::stored_options( + array( 'default_category', 'default_email_category', 'default_link_category', 'default_post_format', 'mailserver_url', 'mailserver_port', 'mailserver_login', 'mailserver_pass', 'use_smilies', 'use_balanceTags', 'ping_sites' ) + ), + 'preUpdateEvents' => $disabled['preUpdateEvents'], + 'capEvents' => array_slice( $disabled['capEvents'], 0, 8 ), + ) + ); + + self::collect_failure( + $failures, + false === \has_filter( 'enable_post_by_email_configuration' ) + && false === \has_filter( 'pre_site_option_initial_db_version' ) + && false === \has_filter( 'pre_update_option' ) + && false === \has_filter( 'user_has_cap' ), + 'writing allowlist gate filters are removed after enabled and disabled dispatches', + array( + 'postByEmail' => \has_filter( 'enable_post_by_email_configuration' ), + 'dbVersion' => \has_filter( 'pre_site_option_initial_db_version' ), + 'preUpdate' => \has_filter( 'pre_update_option' ), + 'cap' => \has_filter( 'user_has_cap' ), + ) + ); + + return $ctx->result( + 'admin-options-submission.writing-allowlist-gates', + array() === $failures, + array( 'failures' => array_slice( $failures, 0, 4 ) ) + ); + } + + private static function check_new_admin_email_pending_change( \ComponentFuzz\FuzzContext $ctx ): array { + $failures = array(); + $token = strtolower( preg_replace( '/[^a-z0-9]+/', '', $ctx->identifier( 6, 12 ) ) ); + $token = '' === $token ? 'cfz' . substr( md5( (string) $ctx->seed() ), 0, 8 ) : $token; + $current_email = 'current-admin-' . $token . '@example.com'; + $pending_email = 'pending-admin-' . $token . '@example.com'; + $invalid_email = 'not-an-email-' . $token; + $site_title = 'Admin Email ' . $token; + $site_url = 'https://example.com/component-fuzz-' . $token; + $user_login = 'cfz_admin_email_' . $token; + $user_email = $user_login . '@example.com'; + $mail_events = array(); + $content_events = array(); + $subject_events = array(); + $cap_events = array(); + $cap_filter = self::install_cap_filter( array( 'manage_options' ), $cap_events ); + $accepted_emails = array_fill_keys( array( $current_email, $pending_email, $user_email ), true ); + $email_filter = static function ( $is_email, string $email ) use ( $accepted_emails ) { + return isset( $accepted_emails[ $email ] ) ? $email : $is_email; + }; + $sanitize_email_filter = static function ( string $sanitized, string $email ) use ( $accepted_emails ): string { + return isset( $accepted_emails[ $email ] ) ? $email : $sanitized; + }; + $pre_mail_filter = static function ( $pre, array $atts ) use ( &$mail_events ) { + unset( $pre ); + $mail_events[] = $atts; + return true; + }; + $content_filter = static function ( string $content, array $new_admin_email ) use ( &$content_events ): string { + $content_events[] = $new_admin_email; + return $content; + }; + $subject_filter = static function ( string $subject ) use ( &$subject_events ): string { + $subject_events[] = $subject; + return '[cfz] ' . $subject; + }; + $added_add_hook = false; + $added_update_hook = false; + + \add_filter( 'is_email', $email_filter, 10, 2 ); + \add_filter( 'sanitize_email', $sanitize_email_filter, 10, 2 ); + \add_filter( 'pre_wp_mail', $pre_mail_filter, 10, 2 ); + \add_filter( 'new_admin_email_content', $content_filter, 10, 2 ); + \add_filter( 'new_admin_email_subject', $subject_filter, 10 ); + + if ( false === \has_action( 'add_option_new_admin_email', 'update_option_new_admin_email' ) ) { + \add_action( 'add_option_new_admin_email', 'update_option_new_admin_email', 10, 2 ); + $added_add_hook = true; + } + if ( false === \has_action( 'update_option_new_admin_email', 'update_option_new_admin_email' ) ) { + \add_action( 'update_option_new_admin_email', 'update_option_new_admin_email', 10, 2 ); + $added_update_hook = true; + } + + try { + $user = self::prepare_admin_email_runtime( $current_email, $site_title, $site_url, $user_login ); + $valid = self::dispatch_options_update( + array( + 'post' => self::general_admin_email_post( $pending_email, $site_title, $site_url ), + 'referer' => 'http://example.test/wp-admin/options-general.php', + ) + ); + $valid_adminhash = \get_option( 'adminhash', '__missing__' ); + $valid_mail = $mail_events[0] ?? null; + $valid_message = is_array( $valid_mail ) ? (string) ( $valid_mail['message'] ?? '' ) : ''; + $valid_subject = is_array( $valid_mail ) ? (string) ( $valid_mail['subject'] ?? '' ) : ''; + + self::collect_failure( + $failures, + 'completed' === ( $valid['status'] ?? null ) + && self::redirect_has_settings_updated( $valid['redirect']['location'] ?? '' ) + && self::nonce_event_seen( $valid, 'general-options', 1 ) + && self::has_settings_error( $valid['settingsErrors'] ?? array(), 'general', 'settings_updated', 'success' ) + && $current_email === \get_option( 'admin_email' ) + && $pending_email === \get_option( 'new_admin_email' ) + && is_array( $valid_adminhash ) + && $pending_email === ( $valid_adminhash['newemail'] ?? null ) + && is_string( $valid_adminhash['hash'] ?? null ) + && 1 === preg_match( '/^[a-f0-9]{32}$/', (string) ( $valid_adminhash['hash'] ?? '' ) ) + && in_array( 'new_admin_email', $valid['updatedOptions'] ?? array(), true ) + && 1 === count( $mail_events ) + && is_array( $valid_mail ) + && $pending_email === ( $valid_mail['to'] ?? null ) + && str_starts_with( $valid_subject, '[cfz] ' ) + && str_contains( $subject_events[0] ?? '', $site_title ) + && isset( $content_events[0]['hash'], $content_events[0]['newemail'] ) + && ( $valid_adminhash['hash'] ?? null ) === $content_events[0]['hash'] + && $pending_email === $content_events[0]['newemail'] + && str_contains( $valid_message, $pending_email ) + && str_contains( $valid_message, (string) $user->user_login ) + && str_contains( $valid_message, 'options.php?adminhash=' . (string) ( $valid_adminhash['hash'] ?? '' ) ) + && str_contains( $valid_message, (string) ( $valid_adminhash['hash'] ?? '' ) ) + && ! str_contains( $valid_message, '###' ) + && self::capability_event_seen( $cap_events, 'manage_options' ), + 'changed General Settings new_admin_email stores pending adminhash and sends one confirmation email without changing admin_email', + array( + 'result' => self::summarize_dispatch_result( $valid ), + 'adminhash' => $valid_adminhash, + 'mailEvents' => array_slice( $mail_events, 0, 3 ), + 'contentEvents' => $content_events, + 'subjectEvents' => $subject_events, + 'capEvents' => array_slice( $cap_events, 0, 8 ), + ) + ); + + $mail_events = array(); + $content_events = array(); + $subject_events = array(); + self::prepare_admin_email_runtime( $current_email, $site_title, $site_url, $user_login ); + $same_current = self::dispatch_options_update( + array( + 'post' => self::general_admin_email_post( $current_email, $site_title, $site_url ), + 'referer' => 'http://example.test/wp-admin/options-general.php', + ) + ); + $same_adminhash = \get_option( 'adminhash', '__missing__' ); + + self::collect_failure( + $failures, + 'completed' === ( $same_current['status'] ?? null ) + && self::nonce_event_seen( $same_current, 'general-options', 1 ) + && self::has_settings_error( $same_current['settingsErrors'] ?? array(), 'general', 'settings_updated', 'success' ) + && $current_email === \get_option( 'admin_email' ) + && $current_email === \get_option( 'new_admin_email' ) + && '__missing__' === $same_adminhash + && array() === $mail_events + && array() === $content_events + && array() === $subject_events, + 'same-current new_admin_email persists the submitted option but does not create adminhash or send confirmation mail', + array( + 'result' => self::summarize_dispatch_result( $same_current ), + 'adminhash' => $same_adminhash, + 'mailEvents' => $mail_events, + ) + ); + + $mail_events = array(); + $content_events = array(); + $subject_events = array(); + self::prepare_admin_email_runtime( $current_email, $site_title, $site_url, $user_login ); + $invalid = self::dispatch_options_update( + array( + 'post' => self::general_admin_email_post( $invalid_email, $site_title, $site_url ), + 'referer' => 'http://example.test/wp-admin/options-general.php', + ) + ); + $invalid_adminhash = \get_option( 'adminhash', '__missing__' ); + + self::collect_failure( + $failures, + 'completed' === ( $invalid['status'] ?? null ) + && self::nonce_event_seen( $invalid, 'general-options', 1 ) + && self::has_settings_error( $invalid['settingsErrors'] ?? array(), 'new_admin_email', 'invalid_new_admin_email', 'error' ) + && $current_email === \get_option( 'admin_email' ) + && '__missing__' === $invalid_adminhash + && array() === $mail_events + && array() === $content_events + && array() === $subject_events, + 'invalid new_admin_email records the sanitization error but does not create adminhash or send confirmation mail', + array( + 'result' => self::summarize_dispatch_result( $invalid ), + 'adminhash' => $invalid_adminhash, + 'mailEvents' => $mail_events, + ) + ); + } finally { + if ( $added_update_hook ) { + \remove_action( 'update_option_new_admin_email', 'update_option_new_admin_email', 10 ); + } + if ( $added_add_hook ) { + \remove_action( 'add_option_new_admin_email', 'update_option_new_admin_email', 10 ); + } + \remove_filter( 'new_admin_email_subject', $subject_filter, 10 ); + \remove_filter( 'new_admin_email_content', $content_filter, 10 ); + \remove_filter( 'pre_wp_mail', $pre_mail_filter, 10 ); + \remove_filter( 'sanitize_email', $sanitize_email_filter, 10 ); + \remove_filter( 'is_email', $email_filter, 10 ); + \remove_filter( 'user_has_cap', $cap_filter, 10 ); + } + + self::collect_failure( + $failures, + false === \has_filter( 'pre_wp_mail', $pre_mail_filter ) + && false === \has_filter( 'new_admin_email_content', $content_filter ) + && false === \has_filter( 'new_admin_email_subject', $subject_filter ) + && false === \has_filter( 'sanitize_email', $sanitize_email_filter ) + && false === \has_filter( 'is_email', $email_filter ) + && false === \has_filter( 'user_has_cap', $cap_filter ) + && ( ! $added_add_hook || false === \has_action( 'add_option_new_admin_email', 'update_option_new_admin_email' ) ) + && ( ! $added_update_hook || false === \has_action( 'update_option_new_admin_email', 'update_option_new_admin_email' ) ), + 'new_admin_email pending-change filters and locally installed dynamic option hooks are removed after dispatches', + array( + 'preMail' => \has_filter( 'pre_wp_mail', $pre_mail_filter ), + 'content' => \has_filter( 'new_admin_email_content', $content_filter ), + 'subject' => \has_filter( 'new_admin_email_subject', $subject_filter ), + 'sanitize' => \has_filter( 'sanitize_email', $sanitize_email_filter ), + 'isEmail' => \has_filter( 'is_email', $email_filter ), + 'capability' => \has_filter( 'user_has_cap', $cap_filter ), + 'addHook' => \has_action( 'add_option_new_admin_email', 'update_option_new_admin_email' ), + 'updateHook' => \has_action( 'update_option_new_admin_email', 'update_option_new_admin_email' ), + ) + ); + + return $ctx->result( + 'admin-options-submission.new-admin-email-pending-change', + array() === $failures, + array( 'failures' => array_slice( $failures, 0, 5 ) ) + ); + } + + private static function check_legacy_options_page_submission( \ComponentFuzz\FuzzContext $ctx ): array { + self::reset_runtime(); + + $failures = array(); + $first = self::id( $ctx->fork( 'first' ), 'cfz_legacy_first', 36 ); + $second = self::id( $ctx->fork( 'second' ), 'cfz_legacy_second', 36 ); + $missing = self::id( $ctx->fork( 'missing' ), 'cfz_legacy_missing', 36 ); + $intruder = self::id( $ctx->fork( 'intruder' ), 'cfz_legacy_intruder', 36 ); + $cap_events = array(); + $cap_filter = self::install_cap_filter( array( 'manage_options' ), $cap_events ); + + try { + $result = self::dispatch_options_update( + array( + 'post' => array( + 'action' => 'update', + '_wpnonce' => \wp_create_nonce( 'update-options' ), + 'page_options' => implode( ',', array( $first, ' ' . $second . ' ', $missing ) ), + $first => " Alpha " . $ctx->identifier( 3, 8 ) . " ", + $second => array( ' one ', 'two\\three' ), + $intruder => 'not-listed', + ), + 'referer' => 'http://example.test/wp-admin/options.php', + ) + ); + } finally { + \remove_filter( 'user_has_cap', $cap_filter, 10 ); + } + + self::collect_failure( + $failures, + 'completed' === ( $result['status'] ?? null ) + && self::redirect_has_settings_updated( $result['redirect']['location'] ?? '' ) + && str_starts_with( \get_option( $first ), 'Alpha ' ) + && array( ' one ', 'two\\three' ) === \get_option( $second ) + && '' === \get_option( $missing, '__not-null__' ) + && '__missing__' === \get_option( $intruder, '__missing__' ) + && array( $first, $second, $missing ) === ( $result['updatedOptions'] ?? null ) + && self::has_settings_error( $result['settingsErrors'] ?? array(), 'general', 'settings_updated', 'success' ) + && self::nonce_event_seen( $result, 'update-options', 1 ) + && self::capability_event_seen( $cap_events, 'manage_options' ) + && false === \has_filter( 'user_has_cap', $cap_filter ), + 'legacy options page submission updates only page_options, trims scalar values, preserves array values after wp_unslash, and stores success transient', + array( + 'result' => self::summarize_dispatch_result( $result ), + 'stored' => array( + 'first' => \get_option( $first ), + 'second' => \get_option( $second ), + 'missing' => \get_option( $missing, '__not-null__' ), + 'intruder'=> \get_option( $intruder, '__missing__' ), + ), + 'capEvents' => array_slice( $cap_events, 0, 8 ), + ) + ); + + return $ctx->result( + 'admin-options-submission.legacy-options-page', + array() === $failures, + array( 'failures' => array_slice( $failures, 0, 3 ) ) + ); + } + + private static function check_failure_paths( \ComponentFuzz\FuzzContext $ctx ): array { + $failures = array(); + + self::reset_runtime(); + $unknown_group = self::id( $ctx->fork( 'unknown' ), 'cfz_unknown_group', 36 ); + $unknown_option = self::id( $ctx->fork( 'unknown-option' ), 'cfz_unknown_option', 36 ); + $unknown_cap_events = array(); + $unknown_cap_filter = self::install_cap_filter( array( 'manage_options' ), $unknown_cap_events ); + try { + $unknown = self::dispatch_options_update( + array( + 'post' => array( + 'action' => 'update', + 'option_page' => $unknown_group, + '_wpnonce' => \wp_create_nonce( $unknown_group . '-options' ), + $unknown_option => 'should not persist', + ), + 'referer' => 'http://example.test/wp-admin/options-general.php?page=unknown', + ) + ); + } finally { + \remove_filter( 'user_has_cap', $unknown_cap_filter, 10 ); + } + $unknown_stored = \get_option( $unknown_option, '__missing__' ); + + self::reset_runtime(); + $denied_group = self::id( $ctx->fork( 'denied' ), 'cfz_denied_group', 36 ); + $denied_option = self::id( $ctx->fork( 'denied-option' ), 'cfz_denied_option', 36 ); + $denied_cap_filter = static function ( string $capability ): string { + unset( $capability ); + return 'manage_component_fuzz_denied'; + }; + \register_setting( $denied_group, $denied_option, array( 'sanitize_callback' => 'sanitize_text_field' ) ); + \add_filter( 'allowed_options', 'option_update_filter' ); + \add_filter( "option_page_capability_{$denied_group}", $denied_cap_filter ); + try { + $denied = self::dispatch_options_update( + array( + 'post' => array( + 'action' => 'update', + 'option_page' => $denied_group, + '_wpnonce' => \wp_create_nonce( $denied_group . '-options' ), + $denied_option => 'denied', + ), + 'referer' => 'http://example.test/wp-admin/options-general.php?page=denied', + ) + ); + } finally { + \remove_filter( "option_page_capability_{$denied_group}", $denied_cap_filter ); + \remove_filter( 'allowed_options', 'option_update_filter' ); + \unregister_setting( $denied_group, $denied_option ); + } + $denied_stored = \get_option( $denied_option, '__missing__' ); + + self::reset_runtime(); + $nonce_group = self::id( $ctx->fork( 'nonce' ), 'cfz_nonce_group', 36 ); + $nonce_option = self::id( $ctx->fork( 'nonce-option' ), 'cfz_nonce_option', 36 ); + $nonce_cap_events = array(); + $nonce_cap_filter = self::install_cap_filter( array( 'manage_options' ), $nonce_cap_events ); + \register_setting( $nonce_group, $nonce_option, array( 'sanitize_callback' => 'sanitize_text_field' ) ); + \add_filter( 'allowed_options', 'option_update_filter' ); + try { + $bad_nonce = self::dispatch_options_update( + array( + 'post' => array( + 'action' => 'update', + 'option_page' => $nonce_group, + '_wpnonce' => 'not-a-valid-nonce-' . $ctx->identifier( 3, 8 ), + $nonce_option => 'bad nonce', + ), + 'referer' => 'http://example.test/wp-admin/options-general.php?page=nonce', + ) + ); + } finally { + \remove_filter( 'allowed_options', 'option_update_filter' ); + \remove_filter( 'user_has_cap', $nonce_cap_filter, 10 ); + \unregister_setting( $nonce_group, $nonce_option ); + } + $nonce_stored = \get_option( $nonce_option, '__missing__' ); + + self::collect_failure( + $failures, + 'died' === ( $unknown['status'] ?? null ) + && str_contains( (string) ( $unknown['die']['message'] ?? '' ), 'options page is not in the allowed options list' ) + && self::nonce_event_seen( $unknown, $unknown_group . '-options', 1 ) + && '__missing__' === $unknown_stored + && null === ( $unknown['redirect'] ?? null ) + && false === \has_filter( 'user_has_cap', $unknown_cap_filter ), + 'unknown option_page dies after nonce validation and before mutating submitted options', + array( + 'result' => self::summarize_dispatch_result( $unknown ), + 'stored' => $unknown_stored, + ) + ); + + self::collect_failure( + $failures, + 'died' === ( $denied['status'] ?? null ) + && str_contains( (string) ( $denied['die']['message'] ?? '' ), 'higher level of permission' ) + && array() === ( $denied['nonceEvents'] ?? array() ) + && '__missing__' === $denied_stored + && null === ( $denied['redirect'] ?? null ) + && false === \has_filter( "option_page_capability_{$denied_group}", $denied_cap_filter ) + && false === \has_filter( 'allowed_options', 'option_update_filter' ), + 'denied option_page capability dies before nonce validation and option mutation', + array( + 'result' => self::summarize_dispatch_result( $denied ), + 'stored' => $denied_stored, + ) + ); + + self::collect_failure( + $failures, + 'died' === ( $bad_nonce['status'] ?? null ) + && self::nonce_event_seen( $bad_nonce, $nonce_group . '-options', false ) + && '__missing__' === $nonce_stored + && null === ( $bad_nonce['redirect'] ?? null ) + && false === \has_filter( 'user_has_cap', $nonce_cap_filter ) + && false === \has_filter( 'allowed_options', 'option_update_filter' ), + 'invalid nonce dies before allowed-options lookup and option mutation', + array( + 'result' => self::summarize_dispatch_result( $bad_nonce ), + 'stored' => $nonce_stored, + 'capEvents' => array_slice( $nonce_cap_events, 0, 8 ), + ) + ); + + return $ctx->result( + 'admin-options-submission.failure-paths', + array() === $failures, + array( 'failures' => array_slice( $failures, 0, 5 ) ) + ); + } + + private static function dispatch_core_page_update( string $page, array $post_values, array $initial_options = array(), ?callable $install_extra_filters = null ): array { + self::reset_runtime( $initial_options ); + + $pre_update_events = array(); + $cap_events = array(); + $cleanup_extra = null; + $cap_filter = self::install_cap_filter( array( 'manage_options' ), $cap_events ); + $pre_update_filter = static function ( $value, string $option, $old_value ) use ( &$pre_update_events ) { + $pre_update_events[] = array( + 'option' => $option, + 'old' => $old_value, + 'value' => $value, + ); + return $value; + }; + + \add_filter( 'pre_update_option', $pre_update_filter, 10, 3 ); + try { + if ( null !== $install_extra_filters ) { + $cleanup_extra = $install_extra_filters(); + } + $result = self::dispatch_options_update( + array( + 'post' => array_merge( + array( + 'action' => 'update', + 'option_page' => $page, + '_wpnonce' => \wp_create_nonce( $page . '-options' ), + ), + $post_values + ), + 'referer' => 'http://example.test/wp-admin/options-' . $page . '.php', + ) + ); + } finally { + if ( is_callable( $cleanup_extra ) ) { + $cleanup_extra(); + } + \remove_filter( 'pre_update_option', $pre_update_filter, 10 ); + \remove_filter( 'user_has_cap', $cap_filter, 10 ); + } + + return array( + 'result' => $result, + 'preUpdateEvents' => $pre_update_events, + 'capEvents' => $cap_events, + ); + } + + private static function core_page_completed( array $dispatch, string $page ): bool { + $result = $dispatch['result'] ?? array(); + + return is_array( $result ) + && 'completed' === ( $result['status'] ?? null ) + && false === ( $result['redirectResult'] ?? null ) + && self::redirect_has_settings_updated( $result['redirect']['location'] ?? '' ) + && 302 === ( $result['redirect']['status'] ?? null ) + && self::nonce_event_seen( $result, $page . '-options', 1 ) + && self::has_settings_error( $result['settingsErrors'] ?? array(), 'general', 'settings_updated', 'success' ) + && ( $result['settingsErrors'] ?? array() ) === ( $result['settingsTransient'] ?? null ) + && self::capability_event_seen( $dispatch['capEvents'] ?? array(), 'manage_options' ); + } + + private static function pre_update_event_seen( array $events, string $option, $old_value, $new_value ): bool { + foreach ( $events as $event ) { + if ( + is_array( $event ) + && $option === ( $event['option'] ?? null ) + && $old_value === ( $event['old'] ?? null ) + && $new_value === ( $event['value'] ?? null ) + ) { + return true; + } + } + + return false; + } + + private static function pre_update_option_seen( array $events, string $option ): bool { + foreach ( $events as $event ) { + if ( is_array( $event ) && $option === ( $event['option'] ?? null ) ) { + return true; + } + } + + return false; + } + + private static function writing_gate_filter_installer( bool $post_by_email_enabled, int $initial_db_version ): callable { + return static function () use ( $post_by_email_enabled, $initial_db_version ): callable { + $post_by_email_filter = static function () use ( $post_by_email_enabled ): bool { + return $post_by_email_enabled; + }; + $initial_db_filter = static function () use ( $initial_db_version ): int { + return $initial_db_version; + }; + + \add_filter( 'enable_post_by_email_configuration', $post_by_email_filter ); + \add_filter( 'pre_site_option_initial_db_version', $initial_db_filter ); + + return static function () use ( $post_by_email_filter, $initial_db_filter ): void { + \remove_filter( 'enable_post_by_email_configuration', $post_by_email_filter ); + \remove_filter( 'pre_site_option_initial_db_version', $initial_db_filter ); + }; + }; + } + + private static function stored_options( array $options ): array { + $stored = array(); + foreach ( $options as $option ) { + $stored[ $option ] = \get_option( $option, '__missing__' ); + } + + return $stored; + } + + private static function dispatch_options_update( array $request ): array { + $post = is_array( $request['post'] ?? null ) ? $request['post'] : array(); + $get = is_array( $request['get'] ?? null ) ? $request['get'] : array(); + $referer = (string) ( $request['referer'] ?? 'http://example.test/wp-admin/options-general.php' ); + $nonce_events = array(); + $redirect = null; + $updated = array(); + $slashed_get = \wp_slash( $get ); + $slashed_post = \wp_slash( $post ); + + $_GET = $slashed_get; + $_POST = $slashed_post; + $_REQUEST = array_merge( $slashed_get, $slashed_post ); + $_SERVER = array_merge( + $_SERVER, + array( + 'REQUEST_METHOD' => 'POST', + 'REQUEST_URI' => '/wp-admin/options.php', + 'PHP_SELF' => '/wp-admin/options.php', + 'SCRIPT_NAME' => '/wp-admin/options.php', + 'HTTP_HOST' => 'example.test', + 'HTTP_REFERER' => $referer, + ) + ); + + $nonce_action = static function ( string $action, $result ) use ( &$nonce_events ): void { + $nonce_events[] = array( + 'action' => $action, + 'result' => $result, + ); + }; + $redirect_filter = static function ( $location, int $status ) use ( &$redirect ) { + $redirect = array( + 'location' => (string) $location, + 'status' => $status, + ); + return false; + }; + $die_filter = static function (): callable { + return static function ( $message = '', $title = '', $args = array() ): void { + throw new AdminOptionsSubmissionSurface_DieCaptured( $message, $title, $args ); + }; + }; + + \add_action( 'check_admin_referer', $nonce_action, 10, 2 ); + \add_filter( 'wp_redirect', $redirect_filter, 10, 2 ); + \add_filter( 'wp_die_handler', $die_filter, PHP_INT_MAX ); + + $result = array( + 'status' => 'completed', + 'redirect' => null, + 'redirectResult' => null, + 'nonceEvents' => array(), + 'allowedOptions' => array(), + 'updatedOptions' => array(), + 'settingsErrors' => array(), + 'settingsTransient' => false, + 'die' => null, + ); + + try { + $action = ! empty( $_REQUEST['action'] ) ? \sanitize_text_field( $_REQUEST['action'] ) : ''; + $option_page = ! empty( $_REQUEST['option_page'] ) ? \sanitize_text_field( $_REQUEST['option_page'] ) : ''; + if ( empty( $option_page ) ) { + $option_page = 'options'; + } + + $capability = \apply_filters( "option_page_capability_{$option_page}", 'manage_options' ); + if ( ! \current_user_can( $capability ) ) { + \wp_die( + '

    ' . __( 'You need a higher level of permission.' ) . '

    ' . + '

    ' . __( 'Sorry, you are not allowed to manage options for this site.' ) . '

    ', + 403 + ); + } + + $allowed_options = self::allowed_options_for_submission(); + $result['allowedOptions'] = $allowed_options; + + if ( 'update' === $action ) { + if ( 'options' === $option_page && ! isset( $_POST['option_page'] ) ) { + $unregistered = true; + \check_admin_referer( 'update-options' ); + } else { + $unregistered = false; + \check_admin_referer( $option_page . '-options' ); + } + + if ( ! isset( $allowed_options[ $option_page ] ) ) { + \wp_die( + sprintf( + __( 'Error: The %s options page is not in the allowed options list.' ), + '' . esc_html( $option_page ) . '' + ) + ); + } + + if ( 'options' === $option_page ) { + $options = isset( $_POST['page_options'] ) ? explode( ',', \wp_unslash( $_POST['page_options'] ) ) : null; + } else { + $options = $allowed_options[ $option_page ]; + } + + if ( 'general' === $option_page ) { + self::normalize_general_post_values(); + } + + if ( $options ) { + $user_language_old = \get_user_locale(); + + foreach ( $options as $option ) { + if ( $unregistered ) { + _deprecated_argument( + 'options.php', + '2.7.0', + sprintf( + __( 'The %1$s setting is unregistered. See %2$s.' ), + '' . esc_html( (string) $option ) . '', + 'https://developer.wordpress.org/plugins/settings/settings-api/' + ) + ); + } + + $option = trim( (string) $option ); + $value = null; + if ( isset( $_POST[ $option ] ) ) { + $value = $_POST[ $option ]; + if ( ! is_array( $value ) ) { + $value = trim( (string) $value ); + } + $value = \wp_unslash( $value ); + } + + \update_option( $option, $value ); + $updated[] = $option; + } + + unset( $GLOBALS['locale'] ); + $user_language_new = \get_user_locale(); + if ( $user_language_old !== $user_language_new ) { + \load_default_textdomain( $user_language_new ); + } + } else { + \add_settings_error( 'general', 'settings_updated', __( 'Settings save failed.' ), 'error' ); + } + + if ( ! count( \get_settings_errors() ) ) { + \add_settings_error( 'general', 'settings_updated', __( 'Settings saved.' ), 'success' ); + } + + \set_transient( 'settings_errors', \get_settings_errors(), 30 ); + $goback = \add_query_arg( 'settings-updated', 'true', \wp_get_referer() ); + $result['redirectResult'] = \wp_redirect( $goback ); + } + } catch ( AdminOptionsSubmissionSurface_DieCaptured $e ) { + $result['status'] = 'died'; + $result['die'] = $e->payload; + } finally { + $result['redirect'] = $redirect; + $result['nonceEvents'] = $nonce_events; + $result['updatedOptions'] = $updated; + $result['settingsErrors'] = \get_settings_errors(); + $result['settingsTransient'] = \get_transient( 'settings_errors' ); + + \remove_action( 'check_admin_referer', $nonce_action, 10 ); + \remove_filter( 'wp_redirect', $redirect_filter, 10 ); + \remove_filter( 'wp_die_handler', $die_filter, PHP_INT_MAX ); + } + + return $result; + } + + private static function allowed_options_for_submission(): array { + $allowed_options = array( + 'general' => array( + 'blogname', + 'blogdescription', + 'site_icon', + 'gmt_offset', + 'date_format', + 'time_format', + 'start_of_week', + 'timezone_string', + 'WPLANG', + 'new_admin_email', + ), + 'discussion' => array( + 'default_pingback_flag', + 'default_ping_status', + 'default_comment_status', + 'comments_notify', + 'moderation_notify', + 'comment_moderation', + 'require_name_email', + 'comment_previously_approved', + 'comment_max_links', + 'moderation_keys', + 'disallowed_keys', + 'show_avatars', + 'avatar_rating', + 'avatar_default', + 'close_comments_for_old_posts', + 'close_comments_days_old', + 'thread_comments', + 'thread_comments_depth', + 'page_comments', + 'comments_per_page', + 'default_comments_page', + 'comment_order', + 'comment_registration', + 'show_comments_cookies_opt_in', + 'wp_notes_notify', + ), + 'media' => array( + 'thumbnail_size_w', + 'thumbnail_size_h', + 'thumbnail_crop', + 'medium_size_w', + 'medium_size_h', + 'large_size_w', + 'large_size_h', + 'image_default_size', + 'image_default_align', + 'image_default_link_type', + ), + 'reading' => array( + 'posts_per_page', + 'posts_per_rss', + 'rss_use_excerpt', + 'show_on_front', + 'page_on_front', + 'page_for_posts', + 'blog_public', + ), + 'writing' => array( + 'default_category', + 'default_email_category', + 'default_link_category', + 'default_post_format', + ), + 'misc' => array(), + 'options' => array(), + 'privacy' => array(), + ); + + if ( \apply_filters( 'enable_post_by_email_configuration', true ) ) { + $allowed_options['writing'][] = 'mailserver_url'; + $allowed_options['writing'][] = 'mailserver_port'; + $allowed_options['writing'][] = 'mailserver_login'; + $allowed_options['writing'][] = 'mailserver_pass'; + } + + if ( ! \is_utf8_charset() ) { + $allowed_options['reading'][] = 'blog_charset'; + } + + if ( \get_site_option( 'initial_db_version' ) < 32453 ) { + $allowed_options['writing'][] = 'use_smilies'; + $allowed_options['writing'][] = 'use_balanceTags'; + } + + if ( ! \is_multisite() ) { + if ( ! defined( 'WP_SITEURL' ) ) { + $allowed_options['general'][] = 'siteurl'; + } + if ( ! defined( 'WP_HOME' ) ) { + $allowed_options['general'][] = 'home'; + } + + $allowed_options['general'][] = 'users_can_register'; + $allowed_options['general'][] = 'default_role'; + + if ( '1' === (string) \get_option( 'blog_public' ) ) { + $allowed_options['writing'][] = 'ping_sites'; + } + + $allowed_options['media'][] = 'uploads_use_yearmonth_folders'; + + if ( \get_option( 'upload_url_path' ) + || \get_option( 'upload_path' ) && 'wp-content/uploads' !== \get_option( 'upload_path' ) + ) { + $allowed_options['media'][] = 'upload_path'; + $allowed_options['media'][] = 'upload_url_path'; + } + } + + $allowed_options = \apply_filters_deprecated( + 'whitelist_options', + array( $allowed_options ), + '5.5.0', + 'allowed_options', + __( 'Please consider writing more inclusive code.' ) + ); + + return \apply_filters( 'allowed_options', $allowed_options ); + } + + private static function general_post_defaults(): array { + return array( + 'blogname' => 'Component Fuzz', + 'blogdescription' => 'Component Fuzz Settings', + 'site_icon' => '0', + 'gmt_offset' => '0', + 'date_format' => 'Y-m-d', + 'time_format' => 'H:i', + 'start_of_week' => '1', + 'timezone_string' => '', + 'WPLANG' => '', + 'new_admin_email' => 'admin@example.com', + 'siteurl' => 'http://example.test', + 'home' => 'http://example.test', + 'users_can_register' => '0', + 'default_role' => 'subscriber', + ); + } + + private static function general_admin_email_post( string $new_admin_email, string $site_title, string $site_url ): array { + return array_merge( + self::general_post_defaults(), + array( + 'action' => 'update', + 'option_page' => 'general', + '_wpnonce' => \wp_create_nonce( 'general-options' ), + 'blogname' => $site_title, + 'blogdescription' => 'Admin email pending change', + 'gmt_offset' => '1', + 'new_admin_email' => $new_admin_email, + 'siteurl' => $site_url, + 'home' => $site_url, + 'timezone_string' => 'Europe/Madrid', + ) + ); + } + + private static function normalize_general_post_values(): void { + if ( ! empty( $_POST['date_format'] ) && isset( $_POST['date_format_custom'] ) + && '\c\u\s\t\o\m' === \wp_unslash( $_POST['date_format'] ) + ) { + $_POST['date_format'] = $_POST['date_format_custom']; + } + + if ( ! empty( $_POST['time_format'] ) && isset( $_POST['time_format_custom'] ) + && '\c\u\s\t\o\m' === \wp_unslash( $_POST['time_format'] ) + ) { + $_POST['time_format'] = $_POST['time_format_custom']; + } + + if ( ! empty( $_POST['timezone_string'] ) && preg_match( '/^UTC[+-]/', (string) $_POST['timezone_string'] ) ) { + $_POST['gmt_offset'] = $_POST['timezone_string']; + $_POST['gmt_offset'] = preg_replace( '/UTC\+?/', '', (string) $_POST['gmt_offset'] ); + $_POST['timezone_string'] = ''; + } elseif ( isset( $_POST['timezone_string'] ) && ! in_array( $_POST['timezone_string'], timezone_identifiers_list( \DateTimeZone::ALL_WITH_BC ), true ) ) { + $current_timezone_string = \get_option( 'timezone_string' ); + + if ( ! empty( $current_timezone_string ) ) { + $_POST['timezone_string'] = $current_timezone_string; + } else { + $_POST['gmt_offset'] = \get_option( 'gmt_offset' ); + $_POST['timezone_string'] = ''; + } + + \add_settings_error( + 'general', + 'settings_updated', + __( 'The timezone you have entered is not valid. Please select a valid timezone.' ), + 'error' + ); + } + } + + private static function install_cap_filter( array $granted_caps, array &$events ): callable { + $grant_map = array_fill_keys( $granted_caps, true ); + $filter = static function ( array $allcaps, array $caps, array $args = array(), $user = null ) use ( $grant_map, &$events ): array { + $events[] = array( + 'type' => 'user-has-cap', + 'caps' => $caps, + 'args' => $args, + 'userId' => is_object( $user ) ? (int) ( $user->ID ?? 0 ) : null, + ); + foreach ( $grant_map as $cap => $grant ) { + $allcaps[ $cap ] = $grant; + } + return $allcaps; + }; + + \add_filter( 'user_has_cap', $filter, 10, 4 ); + return $filter; + } + + private static function reset_runtime( array $options = array() ): void { + if ( isset( $GLOBALS['wpdb'] ) && method_exists( $GLOBALS['wpdb'], 'component_fuzz_reset_content' ) ) { + $GLOBALS['wpdb']->component_fuzz_reset_content(); + } + + $defaults = array( + 'admin_email' => 'admin@example.test', + 'blog_charset' => 'UTF-8', + 'blogdescription' => 'Component Fuzz Settings', + 'blogname' => 'Component Fuzz', + 'blog_public' => '0', + 'date_format' => 'Y-m-d', + 'default_role' => 'subscriber', + 'gmt_offset' => '0', + 'home' => 'http://example.test', + 'html_type' => 'text/html', + 'siteurl' => 'http://example.test', + 'start_of_week' => '1', + 'time_format' => 'H:i', + 'timezone_string' => '', + 'users_can_register' => '0', + ); + + if ( isset( $GLOBALS['wpdb'] ) && method_exists( $GLOBALS['wpdb'], 'component_fuzz_reset_options' ) ) { + $GLOBALS['wpdb']->component_fuzz_reset_options( array_merge( $defaults, $options ) ); + } + + \wp_cache_flush(); + + $GLOBALS['new_allowed_options'] = array(); + $GLOBALS['new_whitelist_options'] = &$GLOBALS['new_allowed_options']; + $GLOBALS['current_user'] = new \WP_User( 0 ); + $GLOBALS['wp_registered_settings'] = array(); + $GLOBALS['wp_settings_errors'] = array(); + unset( $GLOBALS['locale'] ); + + $_GET = array(); + $_POST = array(); + $_REQUEST = array(); + $_SERVER = array_merge( + $_SERVER, + array( + 'REQUEST_METHOD' => 'GET', + 'REQUEST_URI' => '/wp-admin/options.php', + 'PHP_SELF' => '/wp-admin/options.php', + 'SCRIPT_NAME' => '/wp-admin/options.php', + 'HTTP_HOST' => 'example.test', + 'HTTP_REFERER' => 'http://example.test/wp-admin/options-general.php', + ) + ); + } + + private static function prepare_admin_email_runtime( string $admin_email, string $site_title, string $site_url, string $user_login ): \WP_User { + self::reset_runtime( + array( + 'admin_email' => $admin_email, + 'blogname' => $site_title, + 'home' => $site_url, + 'siteurl' => $site_url, + ) + ); + + $user_id = \wp_insert_user( + array( + 'user_login' => $user_login, + 'user_pass' => 'component-fuzz-admin-email-pass', + 'user_email' => $user_login . '@example.com', + 'user_nicename' => $user_login, + 'display_name' => 'Component Fuzz Admin Email', + 'locale' => '', + ) + ); + if ( \is_wp_error( $user_id ) ) { + throw new \RuntimeException( 'Unable to seed admin email current user: ' . $user_id->get_error_message() ); + } + + \wp_set_current_user( (int) $user_id ); + return \wp_get_current_user(); + } + + private static function snapshot_state(): array { + return array( + 'globals' => self::snapshot_globals( + array( + '_GET', + '_POST', + '_REQUEST', + '_SERVER', + 'current_user', + 'locale', + 'new_allowed_options', + 'new_whitelist_options', + 'wp_actions', + 'wp_current_filter', + 'wp_filter', + 'wp_filters', + 'wp_registered_settings', + 'wp_settings_errors', + ) + ), + 'options' => self::option_store_snapshot(), + ); + } + + private static function restore_state( array $snapshot ): void { + if ( isset( $GLOBALS['wpdb'] ) && method_exists( $GLOBALS['wpdb'], 'component_fuzz_reset_options' ) ) { + $GLOBALS['wpdb']->component_fuzz_reset_options( $snapshot['options'] ); + } + + self::restore_globals( $snapshot['globals'] ); + + if ( array_key_exists( 'new_allowed_options', $GLOBALS ) ) { + $GLOBALS['new_whitelist_options'] = &$GLOBALS['new_allowed_options']; + } + + \wp_cache_flush(); + } + + private static function state_matches( array $snapshot ): bool { + if ( self::option_store_snapshot() !== $snapshot['options'] ) { + return false; + } + + foreach ( $snapshot['globals'] as $name => $entry ) { + $exists = array_key_exists( $name, $GLOBALS ); + if ( $exists !== $entry['exists'] ) { + return false; + } + if ( $exists && $GLOBALS[ $name ] != $entry['value'] ) { + return false; + } + } + + return true; + } + + private static function snapshot_globals( array $names ): array { + $snapshot = array(); + foreach ( $names as $name ) { + $snapshot[ $name ] = array( + 'exists' => array_key_exists( $name, $GLOBALS ), + 'value' => array_key_exists( $name, $GLOBALS ) ? self::clone_value( $GLOBALS[ $name ] ) : null, + ); + } + + return $snapshot; + } + + private static function restore_globals( array $snapshot ): void { + foreach ( $snapshot as $name => $entry ) { + if ( $entry['exists'] ) { + $GLOBALS[ $name ] = self::clone_value( $entry['value'] ); + } else { + unset( $GLOBALS[ $name ] ); + } + } + } + + private static function option_store_snapshot(): array { + if ( isset( $GLOBALS['wpdb'] ) && method_exists( $GLOBALS['wpdb'], 'component_fuzz_get_options' ) ) { + return $GLOBALS['wpdb']->component_fuzz_get_options(); + } + + return array(); + } + + private static function id( \ComponentFuzz\FuzzContext $ctx, string $prefix, int $max_len ): string { + $suffix = strtolower( preg_replace( '/[^a-zA-Z0-9_]+/', '_', $ctx->identifier( 4, 14 ) ) ); + $id = $prefix . '_' . $suffix; + + return substr( $id, 0, $max_len ); + } + + private static function redirect_has_settings_updated( string $location ): bool { + return str_contains( $location, 'settings-updated=true' ); + } + + private static function has_settings_error( array $errors, string $setting, string $code, string $type ): bool { + foreach ( $errors as $error ) { + if ( + is_array( $error ) + && $setting === ( $error['setting'] ?? null ) + && $code === ( $error['code'] ?? null ) + && $type === ( $error['type'] ?? null ) + ) { + return true; + } + } + + return false; + } + + private static function nonce_event_seen( array $result, string $action, $expected_result ): bool { + foreach ( $result['nonceEvents'] ?? array() as $event ) { + if ( is_array( $event ) && $action === ( $event['action'] ?? null ) && $expected_result === ( $event['result'] ?? null ) ) { + return true; + } + } + + return false; + } + + private static function capability_event_seen( array $events, string $capability ): bool { + foreach ( $events as $event ) { + if ( ! is_array( $event ) ) { + continue; + } + if ( in_array( $capability, $event['caps'] ?? array(), true ) ) { + return true; + } + if ( $capability === ( $event['capability'] ?? null ) ) { + return true; + } + } + + return false; + } + + private static function summarize_dispatch_result( array $result ): array { + if ( isset( $result['allowedOptions'] ) && is_array( $result['allowedOptions'] ) ) { + $result['allowedOptions'] = array_map( 'array_values', $result['allowedOptions'] ); + } + + return $result; + } + + private static function collect_failure( array &$failures, bool $ok, string $message, array $data ): void { + if ( $ok ) { + return; + } + + $failures[] = array( + 'message' => $message, + 'data' => $data, + ); + } + + private static function describe_value( $value ) { + if ( is_string( $value ) && strlen( $value ) > 180 ) { + return substr( $value, 0, 180 ) . '...'; + } + + if ( is_array( $value ) ) { + $out = array(); + foreach ( array_slice( $value, 0, 8, true ) as $key => $item ) { + $out[ $key ] = self::describe_value( $item ); + } + if ( count( $value ) > 8 ) { + $out['__truncated__'] = count( $value ) - 8; + } + return $out; + } + + return $value; + } + + private static function describe_throwable( \Throwable $e ): array { + return array( + 'class' => get_class( $e ), + 'message' => $e->getMessage(), + 'file' => $e->getFile(), + 'line' => $e->getLine(), + ); + } + + private static function clone_value( $value ) { + if ( is_array( $value ) ) { + $copy = array(); + foreach ( $value as $key => $item ) { + $copy[ $key ] = self::clone_value( $item ); + } + return $copy; + } + + if ( is_object( $value ) ) { + return clone $value; + } + + return $value; + } +} + +final class AdminOptionsSubmissionSurface_DieCaptured extends \RuntimeException { + public array $payload; + + public function __construct( $message, $title, $args ) { + $this->payload = array( + 'message' => is_scalar( $message ) ? (string) $message : get_debug_type( $message ), + 'title' => is_scalar( $title ) ? (string) $title : get_debug_type( $title ), + 'args' => $args, + ); + + parent::__construct( $this->payload['message'] ); + } +} diff --git a/tools/component-fuzz/surfaces/AdminScreenSurface.php b/tools/component-fuzz/surfaces/AdminScreenSurface.php new file mode 100644 index 0000000000000..d18fef594941a --- /dev/null +++ b/tools/component-fuzz/surfaces/AdminScreenSurface.php @@ -0,0 +1,1887 @@ +skip( + 'admin-screen.bootstrap-apis-available', + 'Required admin screen APIs are unavailable.', + array( 'missing' => $missing ) + ), + ); + } + + $snapshot = self::snapshot_state(); + $rows = array(); + + try { + $rows[] = self::check_screen_normalization( $ctx->fork( 'screen-normalization' ) ); + $rows[] = self::check_help_tabs_and_screen_options( $ctx->fork( 'help-screen-options' ) ); + $rows[] = self::check_screen_options_rendering( $ctx->fork( 'screen-options-rendering' ) ); + $rows[] = self::check_screen_meta_rendering_lifecycle( $ctx->fork( 'screen-meta-rendering-lifecycle' ) ); + $rows[] = self::check_column_headers( $ctx->fork( 'column-headers' ) ); + $rows[] = self::check_settings_registry( $ctx->fork( 'settings-registry' ) ); + $rows[] = self::check_settings_rendering( $ctx->fork( 'settings-rendering' ) ); + $rows[] = self::check_settings_errors_and_admin_notices( $ctx->fork( 'settings-errors-notices' ) ); + $rows[] = self::check_meta_boxes( $ctx->fork( 'meta-boxes' ) ); + $rows[] = self::check_accordion_sections( $ctx->fork( 'accordion-sections' ) ); + } catch ( \Throwable $e ) { + $rows[] = $ctx->fail( + 'admin-screen.surface-no-throw', + array( 'throwable' => self::describe_throwable( $e ) ) + ); + } finally { + self::restore_state( $snapshot ); + } + + return $rows; + } + + private static function missing_requirements(): array { + $missing = array(); + + foreach ( array( 'WP_Screen' ) as $class ) { + if ( ! class_exists( $class ) ) { + $missing[] = "class {$class}"; + } + } + + foreach ( + array( + 'add_action', + 'add_filter', + 'add_meta_box', + 'add_screen_option', + 'add_settings_error', + 'add_settings_field', + 'add_settings_section', + 'convert_to_screen', + 'delete_transient', + 'do_action', + 'do_accordion_sections', + 'do_meta_boxes', + 'do_settings_fields', + 'do_settings_sections', + 'esc_attr', + 'esc_html', + 'get_column_headers', + 'get_current_screen', + 'get_option', + 'get_registered_settings', + 'get_settings_errors', + 'get_transient', + 'has_filter', + 'post_type_exists', + 'register_post_type', + 'register_setting', + 'register_taxonomy', + 'remove_action', + 'remove_filter', + 'remove_meta_box', + 'sanitize_key', + 'sanitize_html_class', + 'sanitize_option', + 'sanitize_text_field', + 'set_current_screen', + 'set_transient', + 'settings_errors', + 'settings_fields', + 'taxonomy_exists', + 'unregister_setting', + 'update_option', + 'wp_admin_notice', + 'wp_get_admin_notice', + 'wp_kses_post', + 'wp_parse_args', + ) as $function + ) { + if ( ! function_exists( $function ) ) { + $missing[] = "function {$function}"; + } + } + + return $missing; + } + + private static function check_screen_normalization( \ComponentFuzz\FuzzContext $ctx ): array { + $failures = array(); + $post_type = self::post_type_key( $ctx->fork( 'post-type' ) ); + $taxonomy = self::taxonomy_key( $ctx->fork( 'taxonomy' ) ); + $tool_page = self::id( $ctx->fork( 'tool-page' ), 'cfz_tool_page', 36 ); + + \register_post_type( + $post_type, + array( + 'label' => 'Component Fuzz ' . self::fuzz_label( $ctx->fork( 'post-label' ) ), + 'public' => false, + 'show_ui' => true, + 'show_in_rest' => false, + 'supports' => array( 'title' ), + ) + ); + \register_taxonomy( + $taxonomy, + $post_type, + array( + 'label' => 'Component Fuzz Tax ' . self::fuzz_label( $ctx->fork( 'tax-label' ) ), + 'public' => false, + 'show_ui' => true, + 'show_in_rest' => false, + ) + ); + + $cases = array( + array( + 'hook' => $post_type, + 'id' => $post_type, + 'base' => 'post', + 'post_type' => $post_type, + 'taxonomy' => '', + 'action' => '', + 'in_admin' => 'site', + ), + array( + 'hook' => 'edit-' . $post_type, + 'id' => 'edit-' . $post_type, + 'base' => 'edit', + 'post_type' => $post_type, + 'taxonomy' => '', + 'action' => '', + 'in_admin' => 'site', + ), + array( + 'hook' => 'edit-' . $taxonomy, + 'id' => 'edit-' . $taxonomy, + 'base' => 'edit-tags', + 'post_type' => 'post', + 'taxonomy' => $taxonomy, + 'action' => '', + 'in_admin' => 'site', + ), + array( + 'hook' => 'post-new.php', + 'id' => 'post', + 'base' => 'post', + 'post_type' => 'post', + 'taxonomy' => '', + 'action' => 'add', + 'in_admin' => 'site', + ), + array( + 'hook' => 'index.php', + 'id' => 'dashboard', + 'base' => 'dashboard', + 'post_type' => '', + 'taxonomy' => '', + 'action' => '', + 'in_admin' => 'site', + ), + array( + 'hook' => 'front', + 'id' => 'front', + 'base' => 'front', + 'post_type' => '', + 'taxonomy' => '', + 'action' => '', + 'in_admin' => false, + ), + array( + 'hook' => $tool_page . '.php', + 'id' => \sanitize_key( $tool_page ), + 'base' => \sanitize_key( $tool_page ), + 'post_type' => '', + 'taxonomy' => '', + 'action' => '', + 'in_admin' => 'site', + ), + array( + 'hook' => 'edit-' . $post_type . '-network', + 'id' => 'edit-' . $post_type . '-network', + 'base' => 'edit-network', + 'post_type' => $post_type, + 'taxonomy' => '', + 'action' => '', + 'in_admin' => 'network', + ), + ); + + foreach ( $cases as $case ) { + $screen = \WP_Screen::get( $case['hook'] ); + $converted = \convert_to_screen( $case['hook'] ); + + self::collect_failure( + $failures, + $screen instanceof \WP_Screen + && $converted === $screen + && $case['id'] === $screen->id + && $case['base'] === $screen->base + && $case['post_type'] === $screen->post_type + && $case['taxonomy'] === $screen->taxonomy + && $case['action'] === $screen->action + && ( false === $case['in_admin'] ? false === $screen->in_admin() : $screen->in_admin( $case['in_admin'] ) ), + 'WP_Screen::get() and convert_to_screen() normalize hook names consistently', + array( + 'case' => $case, + 'screen' => self::describe_screen( $screen ), + ) + ); + } + + $screen_snapshot = self::snapshot_globals( array( 'current_screen', 'typenow', 'taxnow' ) ); + $current_calls = array(); + $listener = static function ( \WP_Screen $screen ) use ( &$current_calls ): void { + $current_calls[] = array( + 'id' => $screen->id, + 'post_type' => $screen->post_type, + 'taxonomy' => $screen->taxonomy, + ); + }; + + \add_action( 'current_screen', $listener, 10, 1 ); + $edit_screen = \WP_Screen::get( 'edit-' . $post_type ); + \set_current_screen( $edit_screen ); + $current = \get_current_screen(); + $typenow_after_set = $GLOBALS['typenow'] ?? null; + $taxnow_after_set = $GLOBALS['taxnow'] ?? null; + \remove_action( 'current_screen', $listener, 10 ); + self::restore_globals( $screen_snapshot ); + + self::collect_failure( + $failures, + $current === $edit_screen + && $edit_screen->post_type === $typenow_after_set + && $edit_screen->taxonomy === $taxnow_after_set + && array( $edit_screen->id ) === array_column( $current_calls, 'id' ) + && self::globals_match( $screen_snapshot, array( 'current_screen', 'typenow', 'taxnow' ) ), + 'set_current_screen() updates globals, fires current_screen once, and can be restored', + array( + 'current' => self::describe_screen( $current ), + 'calls' => $current_calls, + 'globals' => array( + 'typenow' => $typenow_after_set, + 'taxnow' => $taxnow_after_set, + ), + ) + ); + + return self::row( + $ctx, + 'admin-screen.screen.normalization-and-current', + array() === $failures, + array( 'failures' => array_slice( $failures, 0, 5 ) ) + ); + } + + private static function check_help_tabs_and_screen_options( \ComponentFuzz\FuzzContext $ctx ): array { + $failures = array(); + $screen = \convert_to_screen( self::id( $ctx->fork( 'screen' ), 'cfz_help_screen', 40 ) ); + $tab_high = self::id( $ctx->fork( 'high-tab' ), 'cfz_help_high', 32 ); + $tab_mid = self::id( $ctx->fork( 'mid-tab' ), 'cfz_help_mid', 32 ); + $tab_low = self::id( $ctx->fork( 'low-tab' ), 'cfz_help_low', 32 ); + + $screen->add_help_tab( + array( + 'id' => $tab_low, + 'title' => 'Low ' . self::fuzz_label( $ctx->fork( 'low-title' ) ), + 'content' => '

    Low ' . \esc_html( self::fuzz_label( $ctx->fork( 'low-content' ) ) ) . '

    ', + 'priority' => 30, + ) + ); + $screen->add_help_tab( + array( + 'id' => $tab_high, + 'title' => 'High ' . self::fuzz_label( $ctx->fork( 'high-title' ) ), + 'content' => '

    High ' . \esc_html( self::fuzz_label( $ctx->fork( 'high-content' ) ) ) . '

    ', + 'priority' => 5, + ) + ); + $screen->add_help_tab( + array( + 'id' => $tab_mid, + 'title' => 'Original ' . self::fuzz_label( $ctx->fork( 'mid-title-old' ) ), + 'content' => '

    Original

    ', + 'priority' => 20, + ) + ); + $screen->add_help_tab( + array( + 'id' => $tab_mid, + 'title' => 'Override ' . self::fuzz_label( $ctx->fork( 'mid-title-new' ) ), + 'content' => '

    Override ' . \esc_html( self::fuzz_label( $ctx->fork( 'mid-content' ) ) ) . '

    ', + 'priority' => 15, + ) + ); + $screen->add_help_tab( + array( + 'id' => self::id( $ctx->fork( 'invalid-tab' ), 'cfz_help_invalid', 32 ), + 'content' => '

    Missing title

    ', + 'priority' => 1, + ) + ); + + $tabs_before_remove = $screen->get_help_tabs(); + $mid_tab = $screen->get_help_tab( $tab_mid ); + $missing_tab = $screen->get_help_tab( 'cfz_missing_tab' ); + $screen->remove_help_tab( $tab_high ); + $tabs_after_remove = $screen->get_help_tabs(); + $screen->remove_help_tabs(); + $tabs_after_clear = $screen->get_help_tabs(); + + self::collect_failure( + $failures, + array( $tab_high, $tab_mid, $tab_low ) === array_keys( $tabs_before_remove ) + && is_array( $mid_tab ) + && 15 === ( $mid_tab['priority'] ?? null ) + && str_starts_with( (string) ( $mid_tab['title'] ?? '' ), 'Override ' ) + && null === $missing_tab + && array( $tab_mid, $tab_low ) === array_keys( $tabs_after_remove ) + && array() === $tabs_after_clear, + 'WP_Screen help tabs sort by priority, override duplicate IDs, and remove cleanly', + array( + 'screen' => self::describe_screen( $screen ), + 'tabsBeforeRemove' => $tabs_before_remove, + 'midTab' => $mid_tab, + 'tabsAfterRemove' => $tabs_after_remove, + 'tabsAfterClear' => $tabs_after_clear, + ) + ); + + $per_page = array( + 'label' => 'Per page ' . self::fuzz_label( $ctx->fork( 'per-page-label' ) ), + 'default' => $ctx->int( 5, 99 ), + 'option' => self::id( $ctx->fork( 'per-page-option' ), 'cfz_per_page', 32 ), + ); + $layout = array( + 'max' => $ctx->int( 2, 6 ), + 'default' => $ctx->int( 1, 2 ), + ); + + $screen->add_option( 'per_page', $per_page ); + $screen->add_option( 'layout_columns', $layout ); + $options_before_remove = $screen->get_options(); + $per_page_default = $screen->get_option( 'per_page', 'default' ); + $missing_option = $screen->get_option( 'cfz_missing_option' ); + $screen->remove_option( 'layout_columns' ); + $options_after_remove = $screen->get_options(); + + $options_settings_calls = array(); + $options_show_calls = array(); + $options_settings_filter = static function ( string $settings, \WP_Screen $seen_screen ) use ( &$options_settings_calls, $screen ): string { + $options_settings_calls[] = array( + 'sameScreen' => $seen_screen === $screen, + 'incoming' => $settings, + ); + + return $settings; + }; + $options_show_filter = static function ( bool $show_screen, \WP_Screen $seen_screen ) use ( &$options_show_calls, $screen ): bool { + $options_show_calls[] = array( + 'sameScreen' => $seen_screen === $screen, + 'incoming' => $show_screen, + ); + + return $show_screen; + }; + + \add_filter( 'screen_settings', $options_settings_filter, 10, 2 ); + \add_filter( 'screen_options_show_screen', $options_show_filter, 10, 2 ); + try { + $show_first = $screen->show_screen_options(); + $show_second = $screen->show_screen_options(); + } finally { + \remove_filter( 'screen_options_show_screen', $options_show_filter, 10 ); + \remove_filter( 'screen_settings', $options_settings_filter, 10 ); + self::reset_screen_options_cache( $screen ); + } + + $screen->remove_options(); + $options_after_clear = $screen->get_options(); + + $settings_screen = \convert_to_screen( self::id( $ctx->fork( 'settings-screen' ), 'cfz_settings_screen', 40 ) ); + $settings_calls = array(); + $settings_show_calls = array(); + $settings_filter = static function ( string $settings, \WP_Screen $seen_screen ) use ( &$settings_calls, $settings_screen ): string { + $settings_calls[] = array( + 'sameScreen' => $seen_screen === $settings_screen, + 'incoming' => $settings, + ); + + return $settings . '

    settings

    '; + }; + $settings_show_filter = static function ( bool $show_screen, \WP_Screen $seen_screen ) use ( &$settings_show_calls, $settings_screen ): bool { + $settings_show_calls[] = array( + 'sameScreen' => $seen_screen === $settings_screen, + 'incoming' => $show_screen, + ); + + return $show_screen; + }; + + \add_filter( 'screen_settings', $settings_filter, 10, 2 ); + \add_filter( 'screen_options_show_screen', $settings_show_filter, 10, 2 ); + try { + $settings_show_first = $settings_screen->show_screen_options(); + $settings_show_second = $settings_screen->show_screen_options(); + } finally { + \remove_filter( 'screen_options_show_screen', $settings_show_filter, 10 ); + \remove_filter( 'screen_settings', $settings_filter, 10 ); + self::reset_screen_options_cache( $settings_screen ); + } + + self::collect_failure( + $failures, + isset( $options_before_remove['per_page'], $options_before_remove['layout_columns'] ) + && $per_page_default === $per_page['default'] + && null === $missing_option + && isset( $options_after_remove['per_page'] ) + && ! isset( $options_after_remove['layout_columns'] ) + && array() === $options_after_clear + && true === $show_first + && true === $show_second + && 1 === count( $options_settings_calls ) + && 1 === count( $options_show_calls ) + && true === ( $options_settings_calls[0]['sameScreen'] ?? null ) + && '' === ( $options_settings_calls[0]['incoming'] ?? null ) + && true === ( $options_show_calls[0]['sameScreen'] ?? null ) + && true === ( $options_show_calls[0]['incoming'] ?? null ) + && array() === $settings_screen->get_options() + && true === $settings_show_first + && true === $settings_show_second + && 1 === count( $settings_calls ) + && 1 === count( $settings_show_calls ) + && true === ( $settings_calls[0]['sameScreen'] ?? null ) + && '' === ( $settings_calls[0]['incoming'] ?? null ) + && true === ( $settings_show_calls[0]['sameScreen'] ?? null ) + && true === ( $settings_show_calls[0]['incoming'] ?? null ) + && false === \has_filter( 'screen_settings', $options_settings_filter ) + && false === \has_filter( 'screen_options_show_screen', $options_show_filter ) + && false === \has_filter( 'screen_settings', $settings_filter ) + && false === \has_filter( 'screen_options_show_screen', $settings_show_filter ), + 'WP_Screen options store values, remove cleanly, show options once, and restore filters', + array( + 'perPage' => $per_page, + 'layout' => $layout, + 'optionsBeforeRemove' => $options_before_remove, + 'optionsAfterRemove' => $options_after_remove, + 'optionsAfterClear' => $options_after_clear, + 'showFirst' => $show_first, + 'showSecond' => $show_second, + 'optionsSettingsCalls'=> $options_settings_calls, + 'optionsShowCalls' => $options_show_calls, + 'settingsScreen' => self::describe_screen( $settings_screen ), + 'settingsShowFirst' => $settings_show_first, + 'settingsShowSecond' => $settings_show_second, + 'settingsCalls' => $settings_calls, + 'settingsShowCalls' => $settings_show_calls, + 'optionsSettingsHasFilter' => \has_filter( 'screen_settings', $options_settings_filter ), + 'optionsShowHasFilter' => \has_filter( 'screen_options_show_screen', $options_show_filter ), + 'settingsHasFilter' => \has_filter( 'screen_settings', $settings_filter ), + 'settingsShowHasFilter' => \has_filter( 'screen_options_show_screen', $settings_show_filter ), + ) + ); + + return self::row( + $ctx, + 'admin-screen.help-tabs-and-screen-options', + array() === $failures, + array( 'failures' => array_slice( $failures, 0, 6 ) ) + ); + } + + private static function check_screen_options_rendering( \ComponentFuzz\FuzzContext $ctx ): array { + $failures = array(); + + $current_snapshot = self::snapshot_globals( array( 'current_screen' ) ); + unset( $GLOBALS['current_screen'] ); + \add_screen_option( + 'per_page', + array( + 'label' => 'No screen', + 'default' => 17, + 'option' => 'cfz_no_screen_per_page', + ) + ); + $no_screen_result = \get_current_screen(); + self::restore_globals( $current_snapshot ); + + $screen = \convert_to_screen( self::id( $ctx->fork( 'screen' ), 'cfz_render_screen', 40 ) ); + $option = self::id( $ctx->fork( 'option' ), 'cfz_render_per_page', 32 ); + $default_per_page = $ctx->int( 7, 55 ); + $filtered_per_page = $default_per_page + $ctx->int( 3, 25 ); + $columns = $ctx->int( 2, 5 ); + $label = 'Items per page: ' . \esc_html( self::fuzz_label( $ctx->fork( 'label' ) ) ); + $per_page_calls = array(); + $submit_calls = array(); + + \set_current_screen( $screen ); + \add_screen_option( + 'per_page', + array( + 'label' => $label, + 'default' => $default_per_page, + 'option' => $option, + ) + ); + \add_screen_option( + 'layout_columns', + array( + 'max' => $columns, + 'default' => 1, + ) + ); + + $per_page_filter = static function ( int $per_page ) use ( &$per_page_calls, $filtered_per_page, $option ): int { + $per_page_calls[] = array( + 'option' => $option, + 'incoming' => $per_page, + ); + + return $filtered_per_page; + }; + $submit_filter = static function ( bool $show, \WP_Screen $seen_screen ) use ( &$submit_calls, $screen ): bool { + $submit_calls[] = array( + 'incoming' => $show, + 'sameScreen' => $seen_screen === $screen, + ); + + return $show; + }; + + \add_filter( $option, $per_page_filter, 10, 1 ); + \add_filter( 'screen_options_show_submit', $submit_filter, 20, 2 ); + $ob_level = ob_get_level(); + try { + ob_start(); + $screen->render_screen_options( array( 'wrap' => true ) ); + $wrapped_html = (string) ob_get_clean(); + } finally { + while ( ob_get_level() > $ob_level ) { + ob_end_clean(); + } + \remove_filter( 'screen_options_show_submit', '__return_true' ); + \remove_filter( 'screen_options_show_submit', $submit_filter, 20 ); + \remove_filter( $option, $per_page_filter, 10 ); + } + + \add_filter( $option, $per_page_filter, 10, 1 ); + \add_filter( 'screen_options_show_submit', $submit_filter, 20, 2 ); + $ob_level = ob_get_level(); + try { + ob_start(); + $screen->render_screen_options( array( 'wrap' => false ) ); + $unwrapped_html = (string) ob_get_clean(); + } finally { + while ( ob_get_level() > $ob_level ) { + ob_end_clean(); + } + \remove_filter( 'screen_options_show_submit', '__return_true' ); + \remove_filter( 'screen_options_show_submit', $submit_filter, 20 ); + \remove_filter( $option, $per_page_filter, 10 ); + } + + self::collect_failure( + $failures, + null === $no_screen_result + && $screen === \get_current_screen() + && $screen->get_option( 'per_page', 'option' ) === $option + && $screen->get_option( 'per_page', 'default' ) === $default_per_page + && $screen->get_option( 'layout_columns', 'max' ) === $columns, + 'add_screen_option() no-ops without a current screen and attaches options to the current WP_Screen', + array( + 'noScreenResult' => $no_screen_result, + 'screen' => self::describe_screen( $screen ), + 'options' => $screen->get_options(), + ) + ); + + self::collect_failure( + $failures, + str_contains( $wrapped_html, 'id="screen-options-wrap"' ) + && str_contains( $wrapped_html, "form id='adv-settings' method='post'" ) + && str_contains( $wrapped_html, 'name="screenoptionnonce"' ) + && str_contains( $wrapped_html, 'class="screen-options"' ) + && str_contains( $wrapped_html, 'name="wp_screen_options[value]"' ) + && str_contains( $wrapped_html, 'id="' . \esc_attr( $option ) . '"' ) + && str_contains( $wrapped_html, 'value="' . \esc_attr( (string) $filtered_per_page ) . '"' ) + && str_contains( $wrapped_html, 'name="wp_screen_options[option]" value="' . \esc_attr( $option ) . '"' ) + && substr_count( $wrapped_html, "name='screen_columns'" ) === $columns + && str_contains( $wrapped_html, 'id="screen-options-apply"' ) + && self::html_has_no_unsafe_raw_markup( $wrapped_html ), + 'WP_Screen::render_screen_options() emits wrapped form, nonce, generated per-page option, column radios, and submit button safely', + array( 'html' => self::describe_string( $wrapped_html ) ) + ); + + self::collect_failure( + $failures, + ! str_contains( $unwrapped_html, 'id="screen-options-wrap"' ) + && str_contains( $unwrapped_html, "form id='adv-settings' method='post'" ) + && str_contains( $unwrapped_html, 'name="screenoptionnonce"' ) + && str_contains( $unwrapped_html, 'name="wp_screen_options[option]" value="' . \esc_attr( $option ) . '"' ) + && self::html_has_no_unsafe_raw_markup( $unwrapped_html ), + 'WP_Screen::render_screen_options() honors wrap=false while preserving form controls', + array( 'html' => self::describe_string( $unwrapped_html ) ) + ); + + self::collect_failure( + $failures, + array( + array( 'option' => $option, 'incoming' => $default_per_page ), + array( 'option' => $option, 'incoming' => $default_per_page ), + ) === $per_page_calls + && 2 === count( $submit_calls ) + && self::all_call_values( $submit_calls, 'incoming', true ) + && self::all_call_values( $submit_calls, 'sameScreen', true ) + && false === \has_filter( $option, $per_page_filter ) + && false === \has_filter( 'screen_options_show_submit', $submit_filter ), + 'per-page and submit filters receive generated screen context and are removed after rendering', + array( + 'perPageCalls' => $per_page_calls, + 'submitCalls' => $submit_calls, + 'perPageHook' => \has_filter( $option, $per_page_filter ), + 'submitHook' => \has_filter( 'screen_options_show_submit', $submit_filter ), + ) + ); + + self::restore_globals( $current_snapshot ); + + return self::row( + $ctx, + 'admin-screen.screen-options.rendering-controls', + array() === $failures, + array( 'failures' => array_slice( $failures, 0, 6 ) ) + ); + } + + private static function check_screen_meta_rendering_lifecycle( \ComponentFuzz\FuzzContext $ctx ): array { + $failures = array(); + $screen = \convert_to_screen( self::id( $ctx->fork( 'screen' ), 'cfz_screen_meta', 40 ) ); + + $reader_heading = \esc_html( 'Views ' . self::fuzz_label( $ctx->fork( 'reader-heading' ) ) ); + $reader_list = \esc_html( 'List " onclick="bad ' . self::fuzz_label( $ctx->fork( 'reader-list' ) ) ); + $reader_custom = \esc_html( 'Custom reader ' . self::fuzz_label( $ctx->fork( 'reader-custom' ) ) ); + $reader_content = array( + 'heading_views' => $reader_heading, + 'heading_list' => $reader_list, + 'cfz_custom' => $reader_custom, + ); + + $screen->set_screen_reader_content( $reader_content ); + $reader_after_set = $screen->get_screen_reader_content(); + $reader_heading_text = $screen->get_screen_reader_text( 'heading_views' ); + $reader_missing_text = $screen->get_screen_reader_text( 'cfz_missing_reader' ); + $reader_ob_level = ob_get_level(); + $reader_html = ''; + $missing_reader_html = ''; + $removed_reader_html = ''; + try { + ob_start(); + $screen->render_screen_reader_content( 'heading_views', 'h3' ); + $reader_html = (string) ob_get_clean(); + + ob_start(); + $screen->render_screen_reader_content( 'cfz_missing_reader', 'h3' ); + $missing_reader_html = (string) ob_get_clean(); + + $screen->remove_screen_reader_content(); + $reader_after_remove = $screen->get_screen_reader_content(); + $reader_after_remove_text = $screen->get_screen_reader_text( 'heading_views' ); + + ob_start(); + $screen->render_screen_reader_content( 'heading_views', 'h3' ); + $removed_reader_html = (string) ob_get_clean(); + } finally { + while ( ob_get_level() > $reader_ob_level ) { + ob_end_clean(); + } + } + + self::collect_failure( + $failures, + $reader_heading === ( $reader_after_set['heading_views'] ?? null ) + && $reader_list === ( $reader_after_set['heading_list'] ?? null ) + && $reader_custom === ( $reader_after_set['cfz_custom'] ?? null ) + && isset( $reader_after_set['heading_pagination'] ) + && $reader_heading === $reader_heading_text + && null === $reader_missing_text + && str_contains( $reader_html, "

    " ) + && str_contains( $reader_html, $reader_heading ) + && '' === $missing_reader_html + && array() === $reader_after_remove + && null === $reader_after_remove_text + && '' === $removed_reader_html + && self::html_has_no_unsafe_raw_markup( $reader_html ), + 'Screen reader content stores generated labels, renders requested keys, no-ops missing keys, and removes cleanly', + array( + 'screen' => self::describe_screen( $screen ), + 'readerAfterSet' => $reader_after_set, + 'readerHtml' => self::describe_string( $reader_html ), + 'missingReaderHtml' => self::describe_string( $missing_reader_html ), + 'readerAfterRemove' => $reader_after_remove, + 'readerAfterRemoveText' => $reader_after_remove_text, + 'removedReaderHtml' => self::describe_string( $removed_reader_html ), + ) + ); + + $primary_tab = self::id( $ctx->fork( 'primary-tab' ), 'cfz_help_meta_primary', 32 ); + $secondary_tab = self::id( $ctx->fork( 'secondary-tab' ), 'cfz_help_meta_secondary', 32 ); + $primary_title = 'Primary ' . self::fuzz_label( $ctx->fork( 'primary-title' ) ); + $secondary_title = 'Secondary " onclick="bad ' . self::fuzz_label( $ctx->fork( 'secondary-title' ) ); + $primary_payload = 'Primary body ' . self::fuzz_label( $ctx->fork( 'primary-content' ) ); + $secondary_payload = 'Secondary body " onclick="bad ' . self::fuzz_label( $ctx->fork( 'secondary-content' ) ); + $callback_payload = 'Callback body ' . self::fuzz_label( $ctx->fork( 'callback-content' ) ); + $sidebar_payload = 'Sidebar body ' . self::fuzz_label( $ctx->fork( 'sidebar-content' ) ); + $escaped_payloads = array( + 'primary' => \esc_html( $primary_payload ), + 'secondary' => \esc_html( $secondary_payload ), + 'callback' => \esc_html( $callback_payload ), + 'sidebar' => \esc_html( $sidebar_payload ), + ); + $primary_content = '

    ' . $escaped_payloads['primary'] . '

    '; + $secondary_content = '

    ' . $escaped_payloads['secondary'] . '

    '; + $sidebar_html = ''; + $callback_calls = array(); + $callback = static function ( \WP_Screen $seen_screen, array $tab ) use ( &$callback_calls, $screen, $escaped_payloads ): void { + $callback_calls[] = array( + 'sameScreen' => $seen_screen === $screen, + 'id' => $tab['id'] ?? null, + 'title' => $tab['title'] ?? null, + ); + echo ''; + echo $escaped_payloads['callback']; + echo ''; + }; + + $screen->add_help_tab( + array( + 'id' => $secondary_tab, + 'title' => $secondary_title, + 'content' => $secondary_content, + 'priority' => 30, + ) + ); + $screen->add_help_tab( + array( + 'id' => $primary_tab, + 'title' => $primary_title, + 'content' => $primary_content, + 'callback' => $callback, + 'priority' => 5, + ) + ); + $screen->set_help_sidebar( $sidebar_html ); + + $per_page_option = self::id( $ctx->fork( 'per-page-option' ), 'cfz_meta_per_page', 32 ); + $default_per_page = $ctx->int( 5, 40 ); + $filtered_per_page = $default_per_page + $ctx->int( 3, 25 ); + $layout_columns_max = $ctx->int( 2, 5 ); + $layout_default = $ctx->int( 1, $layout_columns_max ); + $layout_calls = array(); + $per_page_calls = array(); + $submit_filter_before = \has_filter( 'screen_options_show_submit', '__return_true' ); + $globals_snapshot = self::snapshot_globals( array( 'current_screen', 'screen_layout_columns', 'taxnow', 'typenow' ) ); + $meta_html = ''; + $columns_during = null; + $globals_after_render = array(); + + $layout_filter = static function ( array $columns, string $screen_id, \WP_Screen $seen_screen ) use ( &$layout_calls, $screen ): array { + $layout_calls[] = array( + 'sameScreen' => $seen_screen === $screen, + 'screenId' => $screen_id, + 'incoming' => $columns, + ); + + return $columns; + }; + $per_page_filter = static function ( int $per_page ) use ( &$per_page_calls, $filtered_per_page, $per_page_option ): int { + $per_page_calls[] = array( + 'option' => $per_page_option, + 'incoming' => $per_page, + ); + + return $filtered_per_page; + }; + + \set_current_screen( $screen ); + \add_screen_option( + 'per_page', + array( + 'label' => 'Meta per page ' . \esc_html( self::fuzz_label( $ctx->fork( 'per-page-label' ) ) ), + 'default' => $default_per_page, + 'option' => $per_page_option, + ) + ); + \add_screen_option( + 'layout_columns', + array( + 'max' => $layout_columns_max, + 'default' => $layout_default, + ) + ); + + \add_filter( 'screen_layout_columns', $layout_filter, 10, 3 ); + \add_filter( $per_page_option, $per_page_filter, 10, 1 ); + $meta_ob_level = ob_get_level(); + try { + ob_start(); + $screen->render_screen_meta(); + $meta_html = (string) ob_get_clean(); + $columns_during = $GLOBALS['screen_layout_columns'] ?? null; + $globals_after_render = array( + 'currentScreenSame' => ( $GLOBALS['current_screen'] ?? null ) === $screen, + 'screenLayoutColumns' => $GLOBALS['screen_layout_columns'] ?? null, + 'taxnow' => $GLOBALS['taxnow'] ?? null, + 'typenow' => $GLOBALS['typenow'] ?? null, + ); + } finally { + while ( ob_get_level() > $meta_ob_level ) { + ob_end_clean(); + } + if ( 10 !== $submit_filter_before ) { + \remove_filter( 'screen_options_show_submit', '__return_true', 10 ); + } + \remove_filter( $per_page_option, $per_page_filter, 10 ); + \remove_filter( 'screen_layout_columns', $layout_filter, 10 ); + self::restore_globals( $globals_snapshot ); + } + $globals_restored_after_cleanup = self::globals_match( $globals_snapshot, array( 'current_screen', 'screen_layout_columns', 'taxnow', 'typenow' ) ); + + self::collect_failure( + $failures, + $sidebar_html === $screen->get_help_sidebar() + && str_contains( $meta_html, 'id="screen-meta" class="metabox-prefs"' ) + && str_contains( $meta_html, 'id="contextual-help-wrap" class="hidden"' ) + && ! str_contains( $meta_html, 'no-sidebar' ) + && str_contains( $meta_html, 'class="contextual-help-tabs"' ) + && str_contains( $meta_html, 'id="tab-link-' . \esc_attr( $primary_tab ) . '" class="active"' ) + && str_contains( $meta_html, 'id="tab-panel-' . \esc_attr( $primary_tab ) . '" class="help-tab-content active"' ) + && str_contains( $meta_html, \esc_html( $primary_title ) ) + && str_contains( $meta_html, \esc_html( $secondary_title ) ) + && str_contains( $meta_html, 'class="contextual-help-sidebar"' ) + && str_contains( $meta_html, 'cfz-help-sidebar' ) + && str_contains( $meta_html, 'cfz-help-content' ) + && str_contains( $meta_html, 'cfz-help-callback' ) + && str_contains( $meta_html, $escaped_payloads['primary'] ) + && str_contains( $meta_html, $escaped_payloads['secondary'] ) + && str_contains( $meta_html, $escaped_payloads['callback'] ) + && str_contains( $meta_html, $escaped_payloads['sidebar'] ) + && 1 === count( $callback_calls ) + && true === ( $callback_calls[0]['sameScreen'] ?? null ) + && $primary_tab === ( $callback_calls[0]['id'] ?? null ) + && str_contains( $meta_html, 'id="screen-options-wrap" class="hidden"' ) + && str_contains( $meta_html, "form id='adv-settings' method='post'" ) + && str_contains( $meta_html, 'name="screenoptionnonce"' ) + && str_contains( $meta_html, 'class="screen-options"' ) + && str_contains( $meta_html, 'id="' . \esc_attr( $per_page_option ) . '"' ) + && str_contains( $meta_html, 'value="' . \esc_attr( (string) $filtered_per_page ) . '"' ) + && str_contains( $meta_html, 'name="wp_screen_options[option]" value="' . \esc_attr( $per_page_option ) . '"' ) + && substr_count( $meta_html, "name='screen_columns'" ) === $layout_columns_max + && str_contains( $meta_html, "value='" . \esc_attr( (string) $layout_default ) . "'" ) + && str_contains( $meta_html, 'id="screen-options-link-wrap"' ) + && str_contains( $meta_html, 'id="show-settings-link"' ) + && str_contains( $meta_html, 'aria-controls="screen-options-wrap"' ) + && str_contains( $meta_html, 'id="contextual-help-link-wrap"' ) + && str_contains( $meta_html, 'id="contextual-help-link"' ) + && str_contains( $meta_html, 'aria-controls="contextual-help-wrap"' ) + && self::html_has_no_unsafe_raw_markup( $meta_html ), + 'WP_Screen::render_screen_meta() combines help tabs, sidebar, callbacks, screen options, and toggle links safely', + array( + 'screen' => self::describe_screen( $screen ), + 'helpTabs' => array_keys( $screen->get_help_tabs() ), + 'callbackCalls' => $callback_calls, + 'options' => $screen->get_options(), + 'columnsDuring' => $columns_during, + 'escapedPayloads' => $escaped_payloads, + 'metaHtml' => self::describe_string( $meta_html ), + ) + ); + + self::collect_failure( + $failures, + $layout_default === $columns_during + && true === ( $globals_after_render['currentScreenSame'] ?? null ) + && $layout_default === ( $globals_after_render['screenLayoutColumns'] ?? null ) + && '' === ( $globals_after_render['taxnow'] ?? null ) + && '' === ( $globals_after_render['typenow'] ?? null ) + && array( + array( + 'sameScreen' => true, + 'screenId' => $screen->id, + 'incoming' => array(), + ), + ) === $layout_calls + && array( + array( + 'option' => $per_page_option, + 'incoming' => $default_per_page, + ), + ) === $per_page_calls + && false === \has_filter( 'screen_layout_columns', $layout_filter ) + && false === \has_filter( $per_page_option, $per_page_filter ) + && $submit_filter_before === \has_filter( 'screen_options_show_submit', '__return_true' ) + && $globals_restored_after_cleanup, + 'render_screen_meta() applies scoped option/layout filters, sets the legacy layout global, and restores filters/globals', + array( + 'layoutDefault' => $layout_default, + 'columnsDuring' => $columns_during, + 'globalsAfterRender' => $globals_after_render, + 'layoutCalls' => $layout_calls, + 'perPageCalls' => $per_page_calls, + 'layoutHasFilter' => \has_filter( 'screen_layout_columns', $layout_filter ), + 'perPageHasFilter' => \has_filter( $per_page_option, $per_page_filter ), + 'submitFilterBefore' => $submit_filter_before, + 'submitFilterAfter' => \has_filter( 'screen_options_show_submit', '__return_true' ), + 'globalsRestored' => $globals_restored_after_cleanup, + ) + ); + + return self::row( + $ctx, + 'admin-screen.screen-meta.rendering-lifecycle', + array() === $failures, + array( 'failures' => array_slice( $failures, 0, 6 ) ) + ); + } + + private static function check_column_headers( \ComponentFuzz\FuzzContext $ctx ): array { + $failures = array(); + $screen_a = \convert_to_screen( self::id( $ctx->fork( 'screen-a' ), 'cfz_columns_a', 40 ) ); + $screen_b = \convert_to_screen( self::id( $ctx->fork( 'screen-b' ), 'cfz_columns_b', 40 ) ); + $column = self::id( $ctx->fork( 'column' ), 'cfz_column', 30 ); + $label = 'Column ' . self::fuzz_label( $ctx->fork( 'label' ) ); + $calls = 0; + $filter = static function ( array $columns ) use ( &$calls, $column, $label ): array { + ++$calls; + $columns[ $column ] = $label; + return $columns; + }; + + \add_filter( "manage_{$screen_a->id}_columns", $filter ); + $first = \get_column_headers( $screen_a ); + $second = \get_column_headers( $screen_a ); + \remove_filter( "manage_{$screen_a->id}_columns", $filter ); + $other = \get_column_headers( $screen_b ); + + self::collect_failure( + $failures, + 1 === $calls + && $first === $second + && isset( $first[ $column ] ) + && $label === $first[ $column ] + && array() === $other + && false === \has_filter( "manage_{$screen_a->id}_columns", $filter ), + 'get_column_headers() applies the dynamic filter once per screen id and caches locally', + array( + 'screenA' => $screen_a->id, + 'screenB' => $screen_b->id, + 'column' => $column, + 'calls' => $calls, + 'first' => $first, + 'second' => $second, + 'other' => $other, + ) + ); + + return self::row( + $ctx, + 'admin-screen.columns.filter-locality', + array() === $failures, + array( 'failures' => $failures ) + ); + } + + private static function check_settings_registry( \ComponentFuzz\FuzzContext $ctx ): array { + global $new_allowed_options; + + $failures = array(); + $group = self::id( $ctx->fork( 'group' ), 'cfz_group', 36 ); + $name = self::id( $ctx->fork( 'name' ), 'cfz_option', 40 ); + $raw = " Raw \xE2\x98\x83 " . $ctx->text( 0, 28 ); + $calls = array(); + $sanitize = static function ( $value ) use ( &$calls ): string { + $calls[] = $value; + return 'sanitized:' . \sanitize_key( \sanitize_text_field( (string) $value ) ); + }; + $args = array( + 'type' => 'string', + 'label' => 'Label ' . self::fuzz_label( $ctx->fork( 'label' ) ), + 'description' => 'Description ' . self::fuzz_label( $ctx->fork( 'description' ) ), + 'sanitize_callback' => $sanitize, + 'default' => 'default-' . $ctx->int( 100, 999 ), + 'show_in_rest' => false, + ); + + \register_setting( $group, $name, $args ); + + $registered = \get_registered_settings(); + $allowed_before = $new_allowed_options[ $group ] ?? array(); + $sanitize_filter_before = \has_filter( "sanitize_option_{$name}", $sanitize ); + $default_filter_before = \has_filter( "default_option_{$name}", 'filter_default_option' ); + $sanitized = \sanitize_option( $name, $raw ); + $default = \get_option( $name ); + $registered_before_drop = $registered[ $name ] ?? null; + + \unregister_setting( $group, $name ); + + $registered_after = \get_registered_settings(); + $allowed_after = $new_allowed_options[ $group ] ?? array(); + $sanitize_filter_after = \has_filter( "sanitize_option_{$name}", $sanitize ); + $default_filter_after = \has_filter( "default_option_{$name}", 'filter_default_option' ); + + self::collect_failure( + $failures, + is_array( $registered_before_drop ) + && in_array( $name, $allowed_before, true ) + && 10 === $sanitize_filter_before + && 10 === $default_filter_before + && $args['label'] === $registered_before_drop['label'] + && $args['description'] === $registered_before_drop['description'] + && $args['default'] === $default + && array( $raw ) === $calls + && 'sanitized:' . \sanitize_key( \sanitize_text_field( (string) $raw ) ) === $sanitized + && ! isset( $registered_after[ $name ] ) + && ! in_array( $name, $allowed_after, true ) + && false === $sanitize_filter_after + && false === $default_filter_after, + 'register_setting() wires registry, allowed options, defaults, sanitize callbacks, and unregister cleanup', + array( + 'group' => $group, + 'name' => $name, + 'registeredBeforeDrop' => $registered_before_drop, + 'allowedBefore' => $allowed_before, + 'sanitizeFilterBefore' => $sanitize_filter_before, + 'defaultFilterBefore' => $default_filter_before, + 'sanitized' => $sanitized, + 'default' => $default, + 'calls' => $calls, + 'allowedAfter' => $allowed_after, + 'sanitizeFilterAfter' => $sanitize_filter_after, + 'defaultFilterAfter' => $default_filter_after, + ) + ); + + return self::row( + $ctx, + 'admin-screen.settings.registry-sanitize-unregister', + array() === $failures, + array( 'failures' => $failures ) + ); + } + + private static function check_settings_rendering( \ComponentFuzz\FuzzContext $ctx ): array { + $failures = array(); + $page = self::id( $ctx->fork( 'page' ), 'cfz_settings_page', 40 ); + $section = self::id( $ctx->fork( 'section' ), 'cfz_section', 32 ); + $field = self::id( $ctx->fork( 'field' ), 'cfz_field', 32 ); + $label_for = $field . '" onclick="bad' . $ctx->int( 10, 99 ); + $class = 'cfz-field-class " onclick="bad ' . $ctx->identifier( 3, 8 ); + $group = self::id( $ctx->fork( 'fields-group' ), 'cfz_fields', 28 ) . '"'; + $section_calls = array(); + $field_calls = array(); + + $section_callback = static function ( array $section_args ) use ( &$section_calls ): void { + $section_calls[] = $section_args; + echo '

    '; + echo \esc_html( (string) ( $section_args['section_class'] ?? '' ) ); + echo '

    '; + }; + $field_callback = static function ( array $field_args ) use ( &$field_calls ): void { + $field_calls[] = $field_args; + echo ''; + }; + + \add_settings_section( + $section, + 'Section ' . \esc_html( self::fuzz_label( $ctx->fork( 'section-title' ) ) ), + $section_callback, + $page, + array( + 'before_section' => '

    lead

    ', + 'after_section' => '

    tail

    ', + 'section_class' => 'cfz-section " onclick="bad ' . $ctx->identifier( 3, 8 ), + ) + ); + \add_settings_field( + $field, + 'Field ' . \esc_html( self::fuzz_label( $ctx->fork( 'field-title' ) ) ), + $field_callback, + $page, + $section, + array( + 'label_for' => $label_for, + 'class' => $class, + 'payload' => self::fuzz_label( $ctx->fork( 'payload' ) ), + ) + ); + + ob_start(); + \do_settings_sections( $page ); + $sections_html = (string) ob_get_clean(); + + ob_start(); + \do_settings_fields( $page, $section ); + $fields_html = (string) ob_get_clean(); + + ob_start(); + \settings_fields( $group ); + $settings_fields_html = (string) ob_get_clean(); + + self::collect_failure( + $failures, + 1 === count( $section_calls ) + && 2 === count( $field_calls ) + && $section === ( $section_calls[0]['id'] ?? null ) + && $label_for === ( $field_calls[0]['label_for'] ?? null ) + && str_contains( $sections_html, 'cfz-section-marker' ) + && str_contains( $sections_html, 'class="form-table"' ) + && str_contains( $sections_html, 'cfz-field-input' ) + && str_contains( $fields_html, 'cfz-field-input' ) + && str_contains( $settings_fields_html, "name='option_page'" ) + && str_contains( $settings_fields_html, 'name="action" value="update"' ) + && str_contains( $settings_fields_html, 'name="_wpnonce"' ) + && str_contains( $settings_fields_html, "value='" . \esc_attr( $group ) . "'" ) + && self::html_has_no_unsafe_raw_markup( $sections_html ) + && self::html_has_no_unsafe_raw_markup( $fields_html ) + && self::html_has_no_unsafe_raw_markup( $settings_fields_html ), + 'Settings section, field, and nonce renderers call callbacks and escape hostile args', + array( + 'page' => $page, + 'section' => $section, + 'field' => $field, + 'sectionCalls' => $section_calls, + 'fieldCalls' => $field_calls, + 'sectionsHtml' => self::describe_string( $sections_html ), + 'fieldsHtml' => self::describe_string( $fields_html ), + 'settingsFieldsHtml' => self::describe_string( $settings_fields_html ), + ) + ); + + return self::row( + $ctx, + 'admin-screen.settings.rendering-and-escaping', + array() === $failures, + array( 'failures' => $failures ) + ); + } + + private static function check_settings_errors_and_admin_notices( \ComponentFuzz\FuzzContext $ctx ): array { + global $wp_settings_errors; + + $failures = array(); + $setting = self::id( $ctx->fork( 'setting' ), 'cfz_notice_setting', 40 ); + $other = self::id( $ctx->fork( 'other-setting' ), 'cfz_other_notice_setting', 40 ); + $code = 'code-"quoted-' . $ctx->identifier( 3, 8 ); + $message = 'Capital P dangit! ' . \esc_html( self::fuzz_label( $ctx->fork( 'message' ) ) ) . ''; + $updated = array( + 'setting' => $setting, + 'code' => $code, + 'message' => $message, + 'type' => 'updated', + ); + $custom = array( + 'setting' => $setting, + 'code' => 'custom-class', + 'message' => 'Custom class notice', + 'type' => 'error my-own-css-class hello world', + ); + $other_error = array( + 'setting' => $other, + 'code' => 'other-code', + 'message' => 'Other setting notice', + 'type' => 'error', + ); + + $wp_settings_errors = array(); + unset( $_GET['settings-updated'] ); + \delete_transient( 'settings_errors' ); + + \add_settings_error( $updated['setting'], $updated['code'], $updated['message'], $updated['type'] ); + \add_settings_error( $custom['setting'], $custom['code'], $custom['message'], $custom['type'] ); + \add_settings_error( $other_error['setting'], $other_error['code'], $other_error['message'], $other_error['type'] ); + + $setting_errors = \get_settings_errors( $setting ); + $all_errors = \get_settings_errors(); + + ob_start(); + \settings_errors( $setting ); + $settings_html = (string) ob_get_clean(); + + $_GET['settings-updated'] = '1'; + ob_start(); + \settings_errors( $setting, false, true ); + $hidden_html = (string) ob_get_clean(); + + $transient_error = array( + 'setting' => $setting, + 'code' => 'transient-code', + 'message' => 'Transient replay notice', + 'type' => 'info', + ); + $wp_settings_errors = array(); + \set_transient( 'settings_errors', array( $transient_error ) ); + $transient_errors = \get_settings_errors( $setting ); + $transient_after = \get_transient( 'settings_errors' ); + + unset( $_GET['settings-updated'] ); + $sanitize_setting = self::id( $ctx->fork( 'sanitize-setting' ), 'cfz_notice_sanitize', 40 ); + $sanitize_calls = array(); + $sanitize_error = array( + 'setting' => $sanitize_setting, + 'code' => 'sanitize-code', + 'message' => 'Sanitize side-effect notice', + 'type' => 'warning', + ); + \register_setting( + 'cfz_notice_group', + $sanitize_setting, + array( + 'sanitize_callback' => static function ( $value ) use ( &$sanitize_calls, $sanitize_error ): string { + $sanitize_calls[] = $value; + \add_settings_error( $sanitize_error['setting'], $sanitize_error['code'], $sanitize_error['message'], $sanitize_error['type'] ); + return (string) $value; + }, + ) + ); + \update_option( $sanitize_setting, 'stored notice value' ); + $sanitize_calls_before_replay = count( $sanitize_calls ); + $wp_settings_errors = array(); + $sanitize_errors = \get_settings_errors( $sanitize_setting, true ); + \unregister_setting( 'cfz_notice_group', $sanitize_setting ); + + self::collect_failure( + $failures, + array( $updated, $custom ) === $setting_errors + && array( $updated, $custom, $other_error ) === $all_errors + && str_contains( $settings_html, "id='setting-error-" . \esc_attr( $code ) . "'" ) + && str_contains( $settings_html, "class='notice notice-success settings-error is-dismissible'" ) + && str_contains( $settings_html, "class='notice error my-own-css-class hello world settings-error is-dismissible'" ) + && str_contains( $settings_html, '' . $message . '' ) + && ! str_contains( $settings_html, 'notice-notice-' ) + && ! str_contains( $settings_html, $other_error['message'] ) + && '' === $hidden_html + && array( $transient_error ) === $transient_errors + && false === $transient_after + && array( $sanitize_error ) === $sanitize_errors + && count( $sanitize_calls ) > $sanitize_calls_before_replay + && in_array( 'stored notice value', $sanitize_calls, true ), + 'settings errors preserve source arrays, filter by setting, map legacy classes, hide on update, replay/delete transients, and run sanitize side effects', + array( + 'settingErrors' => $setting_errors, + 'allErrors' => $all_errors, + 'settingsHtml' => self::describe_string( $settings_html ), + 'hiddenHtml' => self::describe_string( $hidden_html ), + 'transientErrors' => $transient_errors, + 'transientAfter' => $transient_after, + 'sanitizeErrors' => $sanitize_errors, + 'sanitizeCalls' => $sanitize_calls, + 'sanitizeBefore' => $sanitize_calls_before_replay, + ) + ); + + unset( $_GET['settings-updated'] ); + $wp_settings_errors = array(); + + $notice_message = 'Generated notice ' . \esc_html( self::fuzz_label( $ctx->fork( 'notice-message' ) ) ) . ''; + $notice_id = 'cfz-notice-' . substr( hash( 'crc32b', (string) $ctx->seed() ), 0, 8 ); + $notice_args = array( + 'type' => 'warning', + 'dismissible' => true, + 'id' => $notice_id, + 'additional_classes' => array( 'inline', 'notice-alt' ), + 'attributes' => array( + 'role' => 'alert', + 'data-cfz' => '', + 'data-skip' => '', + 'hidden' => true, + ), + 'paragraph_wrap' => false, + ); + $notice_markup = \wp_get_admin_notice( $notice_message, $notice_args ); + + $arg_events = array(); + $markup_events = array(); + $args_filter = static function ( array $args, string $message ) use ( &$arg_events ): array { + $arg_events[] = array( + 'message' => $message, + 'type' => $args['type'] ?? null, + ); + $args['type'] = 'success'; + $args['additional_classes'][] = 'filtered-class'; + return $args; + }; + $markup_filter = static function ( string $markup, string $message, array $args ) use ( &$markup_events ): string { + $markup_events[] = array( + 'message' => $message, + 'type' => $args['type'] ?? null, + 'markup' => $markup, + ); + return $markup . 'filtered'; + }; + + \add_filter( 'wp_admin_notice_args', $args_filter, 10, 2 ); + \add_filter( 'wp_admin_notice_markup', $markup_filter, 10, 3 ); + try { + $filtered_markup = \wp_get_admin_notice( 'Filtered notice.', array( 'type' => 'error' ) ); + } finally { + \remove_filter( 'wp_admin_notice_args', $args_filter, 10 ); + \remove_filter( 'wp_admin_notice_markup', $markup_filter, 10 ); + } + + $action_events = array(); + $notice_action = static function ( string $message, array $args ) use ( &$action_events ): void { + $action_events[] = array( + 'message' => $message, + 'type' => $args['type'] ?? null, + ); + }; + \add_action( 'wp_admin_notice', $notice_action, 10, 2 ); + try { + ob_start(); + \wp_admin_notice( + 'Output bold', + array( + 'type' => 'info', + 'dismissible' => true, + 'attributes' => array( 'role' => 'status' ), + ) + ); + $admin_notice_output = (string) ob_get_clean(); + } finally { + \remove_action( 'wp_admin_notice', $notice_action, 10 ); + } + + self::collect_failure( + $failures, + '' === $notice_markup + && str_contains( $filtered_markup, 'notice notice-success filtered-class' ) + && str_contains( $filtered_markup, 'cfz-notice-filtered' ) + && 1 === count( $arg_events ) + && 1 === count( $markup_events ) + && false === \has_filter( 'wp_admin_notice_args', $args_filter ) + && false === \has_filter( 'wp_admin_notice_markup', $markup_filter ) + && 1 === count( $action_events ) + && 'info' === ( $action_events[0]['type'] ?? null ) + && str_contains( $admin_notice_output, 'notice notice-info is-dismissible' ) + && str_contains( $admin_notice_output, 'role="status"' ) + && str_contains( $admin_notice_output, 'bold' ) + && ! str_contains( $admin_notice_output, ' self::describe_string( $notice_markup ), + 'filteredMarkup' => self::describe_string( $filtered_markup ), + 'argEvents' => $arg_events, + 'markupEvents' => $markup_events, + 'actionEvents' => $action_events, + 'adminNoticeOutput' => self::describe_string( $admin_notice_output ), + 'argsFilter' => \has_filter( 'wp_admin_notice_args', $args_filter ), + 'markupFilter' => \has_filter( 'wp_admin_notice_markup', $markup_filter ), + 'noticeAction' => \has_filter( 'wp_admin_notice', $notice_action ), + ) + ); + + return self::row( + $ctx, + 'admin-screen.settings-errors-and-admin-notices.rendering-filters-state', + array() === $failures, + array( 'failures' => $failures ) + ); + } + + private static function check_meta_boxes( \ComponentFuzz\FuzzContext $ctx ): array { + global $wp_meta_boxes; + + $failures = array(); + $screen = \convert_to_screen( self::id( $ctx->fork( 'screen' ), 'cfz_meta_screen', 40 ) ); + $context = $ctx->choice( array( 'normal', 'side', 'advanced' ) ); + $alt_context = 'side' === $context ? 'normal' : 'side'; + $high_id = self::id( $ctx->fork( 'high' ), 'cfz_box_high', 32 ); + $moved_id = self::id( $ctx->fork( 'moved' ), 'cfz_box_moved', 32 ); + $default_id = self::id( $ctx->fork( 'default' ), 'cfz_box_default', 32 ); + $removed_id = self::id( $ctx->fork( 'removed' ), 'cfz_box_removed', 32 ); + $data_object = (object) array( + 'ID' => $ctx->int( 1000, 9999 ), + 'post_type' => 'component_fuzz', + ); + $calls = array(); + $callback = static function ( $object, array $box ) use ( &$calls, $data_object ): void { + $calls[] = array( + 'id' => $box['id'], + 'sameObject' => $object === $data_object, + 'payload' => $box['args']['payload'] ?? null, + 'argKeys' => array_keys( (array) ( $box['args'] ?? array() ) ), + ); + echo ''; + echo \esc_html( (string) ( $box['args']['payload'] ?? '' ) ); + echo ''; + }; + + \add_meta_box( + $high_id, + 'High ' . \esc_html( self::fuzz_label( $ctx->fork( 'high-title' ) ) ), + $callback, + $screen, + $context, + 'high', + array( 'payload' => self::fuzz_label( $ctx->fork( 'high-payload' ) ) ) + ); + \add_meta_box( + $moved_id, + 'Moved ' . \esc_html( self::fuzz_label( $ctx->fork( 'moved-title' ) ) ), + $callback, + $screen, + $alt_context, + 'high', + array( 'payload' => self::fuzz_label( $ctx->fork( 'moved-payload-initial' ) ) ) + ); + \add_meta_box( + $moved_id, + 'Moved Updated ' . \esc_html( self::fuzz_label( $ctx->fork( 'moved-title-updated' ) ) ), + $callback, + $screen, + $context, + '', + array( 'payload' => self::fuzz_label( $ctx->fork( 'moved-payload' ) ) ) + ); + \add_meta_box( + $default_id, + 'Default ' . \esc_html( self::fuzz_label( $ctx->fork( 'default-title' ) ) ), + $callback, + $screen, + $context, + 'default', + array( 'payload' => self::fuzz_label( $ctx->fork( 'default-payload' ) ) ) + ); + \add_meta_box( + $removed_id, + 'Removed ' . \esc_html( self::fuzz_label( $ctx->fork( 'removed-title' ) ) ), + $callback, + $screen, + $context, + 'low', + array( 'payload' => self::fuzz_label( $ctx->fork( 'removed-payload' ) ) ) + ); + \remove_meta_box( $removed_id, array( $screen ), $context ); + + ob_start(); + $count = \do_meta_boxes( $screen, $context, $data_object ); + $html = (string) ob_get_clean(); + + $call_ids = array_column( $calls, 'id' ); + $expected_order = array( $high_id, $moved_id, $default_id ); + $page = $screen->id; + + self::collect_failure( + $failures, + 3 === $count + && $expected_order === $call_ids + && ! isset( $wp_meta_boxes[ $page ][ $alt_context ]['high'][ $moved_id ] ) + && isset( $wp_meta_boxes[ $page ][ $context ]['high'][ $moved_id ] ) + && false === ( $wp_meta_boxes[ $page ][ $context ]['low'][ $removed_id ] ?? null ) + && self::all_call_values( $calls, 'sameObject', true ) + && str_contains( $html, 'id="' . $context . '-sortables"' ) + && str_contains( $html, 'cfz-meta-callback' ) + && ! str_contains( $html, $removed_id ) + && self::strings_in_order( $html, $expected_order ) + && self::html_has_no_unsafe_raw_markup( $html ), + 'Meta boxes render in priority order, move duplicate IDs, pass args, and honor removals', + array( + 'screen' => self::describe_screen( $screen ), + 'context' => $context, + 'altContext' => $alt_context, + 'expectedOrder' => $expected_order, + 'callIds' => $call_ids, + 'count' => $count, + 'calls' => $calls, + 'html' => self::describe_string( $html ), + ) + ); + + return self::row( + $ctx, + 'admin-screen.meta-boxes.order-removal-callbacks', + array() === $failures, + array( 'failures' => $failures ) + ); + } + + private static function check_accordion_sections( \ComponentFuzz\FuzzContext $ctx ): array { + $failures = array(); + $screen = \convert_to_screen( self::id( $ctx->fork( 'screen' ), 'cfz_accordion_screen', 40 ) ); + $context = 'side'; + $box_id = self::id( $ctx->fork( 'box' ), 'cfz_accordion_box', 32 ); + $removed_id = self::id( $ctx->fork( 'removed' ), 'cfz_accordion_removed', 32 ); + $data_object = (object) array( 'kind' => 'accordion-data' ); + $calls = array(); + $title = 'Accordion ' . self::fuzz_label( $ctx->fork( 'title' ) ); + $callback = static function ( $object, array $box ) use ( &$calls, $data_object ): void { + $calls[] = array( + 'id' => $box['id'], + 'sameObject' => $object === $data_object, + 'payload' => $box['args']['payload'] ?? null, + ); + echo ''; + echo \esc_html( (string) ( $box['args']['payload'] ?? '' ) ); + echo ''; + }; + + \add_meta_box( + $box_id, + $title, + $callback, + $screen, + $context, + 'high', + array( 'payload' => self::fuzz_label( $ctx->fork( 'payload' ) ) ) + ); + \add_meta_box( + $removed_id, + 'Removed Accordion ' . self::fuzz_label( $ctx->fork( 'removed-title' ) ), + $callback, + $screen, + $context, + 'low', + array( 'payload' => self::fuzz_label( $ctx->fork( 'removed-payload' ) ) ) + ); + \remove_meta_box( $removed_id, $screen, $context ); + + ob_start(); + $count = \do_accordion_sections( $screen, $context, $data_object ); + $html = (string) ob_get_clean(); + + self::collect_failure( + $failures, + 1 === $count + && array( $box_id ) === array_column( $calls, 'id' ) + && self::all_call_values( $calls, 'sameObject', true ) + && str_contains( $html, 'accordion-container' ) + && str_contains( $html, 'cfz-accordion-callback' ) + && str_contains( $html, \esc_html( $title ) ) + && ! str_contains( $html, $removed_id ) + && self::html_has_no_unsafe_raw_markup( $html ), + 'do_accordion_sections() renders escaped accordion titles and skips removed boxes', + array( + 'screen' => self::describe_screen( $screen ), + 'boxId' => $box_id, + 'count' => $count, + 'calls' => $calls, + 'html' => self::describe_string( $html ), + ) + ); + + return self::row( + $ctx, + 'admin-screen.meta-boxes.accordion-rendering', + array() === $failures, + array( 'failures' => $failures ) + ); + } + + private static function post_type_key( \ComponentFuzz\FuzzContext $ctx ): string { + return 'cfzpt' . substr( hash( 'crc32b', (string) $ctx->seed() ), 0, 8 ); + } + + private static function taxonomy_key( \ComponentFuzz\FuzzContext $ctx ): string { + return 'cfztax' . substr( hash( 'crc32b', (string) $ctx->seed() ), 0, 8 ); + } + + private static function id( \ComponentFuzz\FuzzContext $ctx, string $prefix, int $max = 32 ): string { + return strtolower( substr( $prefix . '_' . hash( 'crc32b', (string) $ctx->seed() ), 0, $max ) ); + } + + private static function fuzz_label( \ComponentFuzz\FuzzContext $ctx ): string { + return '☃ ' . $ctx->text( 0, 36 ) . ' HTML'; + } + + private static function html_has_no_unsafe_raw_markup( string $html ): bool { + $lower = strtolower( $html ); + + return ! str_contains( $lower, ' $label, + 'details' => self::describe_value( $details ), + ); + } + + private static function row( + \ComponentFuzz\FuzzContext $ctx, + string $invariant, + bool $ok, + array $data = array(), + ?string $status = null + ): array { + return array( + 'ok' => $ok, + 'status' => $status ?? ( $ok ? 'passed' : 'failed' ), + 'surface' => self::NAME, + 'invariant' => $invariant, + 'seed' => $ctx->seed(), + 'iteration' => $ctx->iteration(), + 'data' => self::describe_value( $data ), + ); + } + + private static function describe_screen( $screen ): array { + if ( ! $screen instanceof \WP_Screen ) { + return array( + 'type' => is_object( $screen ) ? get_class( $screen ) : gettype( $screen ), + ); + } + + return array( + 'id' => $screen->id, + 'base' => $screen->base, + 'post_type' => $screen->post_type, + 'taxonomy' => $screen->taxonomy, + 'action' => $screen->action, + 'in_admin' => $screen->in_admin(), + 'is_block_editor' => $screen->is_block_editor(), + ); + } + + private static function describe_value( $value, int $depth = 0 ) { + if ( is_string( $value ) ) { + return self::describe_string( $value ); + } + + if ( is_array( $value ) ) { + if ( $depth >= 4 ) { + return array( + 'type' => 'array', + 'count' => count( $value ), + ); + } + + $out = array(); + $i = 0; + foreach ( $value as $key => $item ) { + if ( $i >= 16 ) { + $out['...'] = count( $value ) - $i; + break; + } + $out[ is_int( $key ) ? $key : self::escape_bytes( (string) $key ) ] = self::describe_value( $item, $depth + 1 ); + ++$i; + } + return $out; + } + + if ( is_object( $value ) ) { + if ( $value instanceof \Throwable ) { + return self::describe_throwable( $value ); + } + + return array( + 'type' => 'object', + 'class' => get_class( $value ), + ); + } + + return $value; + } + + private static function describe_string( string $value ): array { + return array( + 'type' => 'string', + 'bytes' => strlen( $value ), + 'sha1' => sha1( $value ), + 'preview' => self::escape_bytes( $value ), + ); + } + + private static function describe_throwable( \Throwable $e ): array { + return array( + 'class' => get_class( $e ), + 'message' => self::escape_bytes( $e->getMessage() ), + 'file' => $e->getFile(), + 'line' => $e->getLine(), + ); + } + + private static function escape_bytes( string $value, int $limit = self::PREVIEW_BYTES ): string { + $out = ''; + $length = strlen( $value ); + $shown = min( $length, $limit ); + + for ( $i = 0; $i < $shown; ++$i ) { + $byte = ord( $value[ $i ] ); + if ( 0x5C === $byte ) { + $out .= '\\\\'; + } elseif ( $byte >= 0x20 && $byte <= 0x7E ) { + $out .= chr( $byte ); + } elseif ( 0x0A === $byte ) { + $out .= '\\n'; + } elseif ( 0x0D === $byte ) { + $out .= '\\r'; + } elseif ( 0x09 === $byte ) { + $out .= '\\t'; + } else { + $out .= sprintf( '\\x%02X', $byte ); + } + } + + if ( $length > $shown ) { + $out .= '...'; + } + + return $out; + } + + private static function snapshot_state(): array { + $snapshot = array( + 'globals' => self::snapshot_globals( + array( + '_GET', + '_POST', + '_REQUEST', + 'current_screen', + 'hook_suffix', + 'new_allowed_options', + 'new_whitelist_options', + 'pagenow', + 'taxnow', + 'typenow', + 'wp_actions', + 'wp_current_filter', + 'wp_filter', + 'wp_filters', + 'wp_meta_boxes', + 'wp_post_types', + 'wp_registered_settings', + 'wp_scripts', + 'wp_settings_errors', + 'wp_settings_fields', + 'wp_settings_sections', + 'wp_taxonomies', + ) + ), + 'options' => null, + ); + + if ( isset( $GLOBALS['wpdb'] ) && method_exists( $GLOBALS['wpdb'], 'component_fuzz_get_options' ) ) { + $snapshot['options'] = $GLOBALS['wpdb']->component_fuzz_get_options(); + } + + return $snapshot; + } + + private static function restore_state( array $snapshot ): void { + if ( null !== $snapshot['options'] && isset( $GLOBALS['wpdb'] ) && method_exists( $GLOBALS['wpdb'], 'component_fuzz_reset_options' ) ) { + $GLOBALS['wpdb']->component_fuzz_reset_options( $snapshot['options'] ); + } + + self::restore_globals( $snapshot['globals'] ); + + if ( array_key_exists( 'new_allowed_options', $GLOBALS ) ) { + $GLOBALS['new_whitelist_options'] = &$GLOBALS['new_allowed_options']; + } + } + + private static function snapshot_globals( array $names ): array { + $snapshot = array(); + + foreach ( $names as $name ) { + $snapshot[ $name ] = array( + 'exists' => array_key_exists( $name, $GLOBALS ), + 'value' => array_key_exists( $name, $GLOBALS ) ? self::clone_value( $GLOBALS[ $name ] ) : null, + ); + } + + return $snapshot; + } + + private static function restore_globals( array $snapshot ): void { + foreach ( $snapshot as $name => $entry ) { + if ( $entry['exists'] ) { + $GLOBALS[ $name ] = self::clone_value( $entry['value'] ); + } else { + unset( $GLOBALS[ $name ] ); + } + } + } + + private static function globals_match( array $snapshot, array $names ): bool { + foreach ( $names as $name ) { + $exists = array_key_exists( $name, $GLOBALS ); + if ( $exists !== $snapshot[ $name ]['exists'] ) { + return false; + } + if ( $exists && $GLOBALS[ $name ] != $snapshot[ $name ]['value'] ) { + return false; + } + } + + return true; + } + + private static function clone_value( $value ) { + if ( is_array( $value ) ) { + $copy = array(); + foreach ( $value as $key => $item ) { + $copy[ $key ] = self::clone_value( $item ); + } + return $copy; + } + + if ( is_object( $value ) ) { + return clone $value; + } + + return $value; + } +} diff --git a/tools/component-fuzz/surfaces/AdminWorkflowsSurface.php b/tools/component-fuzz/surfaces/AdminWorkflowsSurface.php new file mode 100644 index 0000000000000..882431f5488e0 --- /dev/null +++ b/tools/component-fuzz/surfaces/AdminWorkflowsSurface.php @@ -0,0 +1,3107 @@ +skip( + 'admin-workflows.bootstrap-apis-available', + 'Required admin workflow APIs are unavailable.', + array( 'missing' => $missing ) + ), + ); + } + + $snapshot = self::snapshot_state(); + $server_snapshot = self::snapshot_server( array( 'HTTP_HOST', 'REQUEST_URI', 'PHP_SELF' ) ); + $rows = array(); + + try { + $rows[] = self::check_menu_globals( $ctx->fork( 'menu-globals' ) ); + $rows[] = self::check_synthetic_list_table( $ctx->fork( 'list-table' ) ); + $rows[] = self::check_list_table_action_and_month_helpers( $ctx->fork( 'list-table-helpers' ) ); + $rows[] = self::check_list_table_request_action_matrix( $ctx->fork( 'list-table-action-matrix' ) ); + $rows[] = self::check_referer_helpers( $ctx->fork( 'referer-helpers' ) ); + $rows[] = self::check_referer_field_helpers( $ctx->fork( 'referer-field-helpers' ) ); + $rows[] = self::check_admin_form_controls( $ctx->fork( 'form-controls' ) ); + $rows[] = self::check_settings_api_rendering_and_errors( $ctx->fork( 'settings-api' ) ); + $rows[] = self::check_admin_notice_helpers( $ctx->fork( 'admin-notices' ) ); + $rows[] = self::check_core_list_table_coverage_accounting( $ctx->fork( 'core-list-table-accounting' ) ); + $rows[] = self::check_exiting_ajax_wrappers( $ctx->fork( 'ajax-wrappers' ) ); + } catch ( \Throwable $e ) { + $rows[] = $ctx->fail( + 'admin-workflows.surface-no-throw', + array( 'throwable' => self::describe_throwable( $e ) ) + ); + } finally { + self::restore_state( $snapshot ); + self::restore_server( $server_snapshot ); + } + + return $rows; + } + + private static function missing_requirements(): array { + $missing = array(); + + foreach ( array( 'WP_List_Table', 'WP_Screen' ) as $class ) { + if ( ! class_exists( $class ) ) { + $missing[] = "class {$class}"; + } + } + + foreach ( + array( + 'add_action', + 'add_filter', + 'add_management_page', + 'add_menu_page', + 'add_query_arg', + 'add_settings_error', + 'add_settings_field', + 'add_settings_section', + 'add_submenu_page', + 'admin_url', + 'apply_filters', + 'check_admin_referer', + 'check_ajax_referer', + 'convert_to_screen', + 'current_user_can', + 'do_settings_fields', + 'do_settings_sections', + 'esc_attr', + 'esc_html', + 'esc_url', + 'get_admin_page_parent', + 'get_admin_page_title', + 'get_column_headers', + 'get_current_screen', + 'get_hidden_columns', + 'home_url', + 'get_plugin_page_hook', + 'get_plugin_page_hookname', + 'get_settings_errors', + 'has_action', + 'has_filter', + 'menu_page_url', + 'plugin_basename', + 'remove_action', + 'remove_filter', + 'remove_menu_page', + 'remove_query_arg', + 'remove_submenu_page', + 'sanitize_key', + 'sanitize_option', + 'sanitize_title', + 'selected', + 'set_url_scheme', + 'set_current_screen', + 'settings_errors', + 'settings_fields', + 'submit_button', + 'date_i18n', + 'wp_create_nonce', + 'wp_admin_notice', + 'wp_get_original_referer', + 'wp_get_admin_notice', + 'wp_get_raw_referer', + 'wp_get_referer', + 'wp_nonce_field', + 'wp_nonce_url', + 'wp_original_referer_field', + 'wp_referer_field', + 'wp_redirect', + 'wp_safe_redirect', + 'wp_strip_all_tags', + 'wp_ajax_date_format', + 'wp_ajax_time_format', + 'wp_unslash', + 'wp_validate_redirect', + 'wp_verify_nonce', + ) as $function + ) { + if ( ! function_exists( $function ) ) { + $missing[] = "function {$function}"; + } + } + + return $missing; + } + + private static function maybe_load_optional_ajax_support(): void { + if ( defined( 'ABSPATH' ) && ! function_exists( 'wp_ajax_date_format' ) ) { + $ajax_actions = ABSPATH . 'wp-admin/includes/ajax-actions.php'; + if ( file_exists( $ajax_actions ) ) { + require_once $ajax_actions; + } + } + } + + private static function check_menu_globals( \ComponentFuzz\FuzzContext $ctx ): array { + $failures = array(); + $local_snapshot = self::snapshot_globals( self::menu_global_names() ); + $capability = self::capability( $ctx->fork( 'capability' ), 'manage' ); + $denied_cap = self::capability( $ctx->fork( 'denied-capability' ), 'denied' ); + $top_slug = self::menu_slug( $ctx->fork( 'top-slug' ), 'top' ) . '.php'; + $second_slug = self::menu_slug( $ctx->fork( 'second-slug' ), 'second' ) . '.php'; + $sub_slug = self::menu_slug( $ctx->fork( 'sub-slug' ), 'sub' ); + $tools_slug = self::menu_slug( $ctx->fork( 'tools-slug' ), 'tools' ); + $denied_slug = self::menu_slug( $ctx->fork( 'denied-slug' ), 'denied' ); + $alias_slug = self::menu_slug( $ctx->fork( 'alias-slug' ), 'alias' ) . '.php'; + $top_title = 'Top Page ' . self::hostile_label( $ctx->fork( 'top-title' ) ); + $top_menu_title = 'Top Menu ' . self::hostile_label( $ctx->fork( 'top-menu-title' ) ); + $sub_title = 'Sub Page ' . self::hostile_label( $ctx->fork( 'sub-title' ) ); + $sub_menu_title = 'Sub Menu ' . self::hostile_label( $ctx->fork( 'sub-menu-title' ) ); + $callback_calls = 0; + $callback = static function () use ( &$callback_calls ): void { + ++$callback_calls; + echo 'callback'; + }; + $result = array(); + $restored = false; + + self::reset_menu_globals(); + + try { + $result = self::with_capabilities( + array( $capability ), + static function () use ( + $alias_slug, + $callback, + $capability, + $denied_cap, + $denied_slug, + $second_slug, + $sub_menu_title, + $sub_slug, + $sub_title, + $tools_slug, + $top_menu_title, + $top_slug, + $top_title + ): array { + global $admin_page_hooks, $menu, $submenu, $_parent_pages, $_registered_pages, + $_wp_real_parent_file, $_wp_submenu_nopriv; + + $top_hook = \add_menu_page( + $top_title, + $top_menu_title, + $capability, + $top_slug, + $callback, + 'dashicons-admin-generic', + 65 + ); + $second_hook = \add_menu_page( + 'Second Page', + 'Second Menu', + $capability, + $second_slug, + $callback, + 'none', + 65 + ); + + $_wp_real_parent_file[ $alias_slug ] = $top_slug; + + $sub_hook = \add_submenu_page( + $alias_slug, + $sub_title, + $sub_menu_title, + $capability, + $sub_slug, + $callback, + 1 + ); + $denied_hook = \add_submenu_page( + $top_slug, + 'Denied Page', + 'Denied Menu', + $denied_cap, + $denied_slug, + $callback, + 2 + ); + $tools_hook = \add_management_page( + 'Tools Page', + 'Tools Menu', + $capability, + $tools_slug, + $callback, + 3 + ); + + $sub_url = \menu_page_url( $sub_slug, false ); + ob_start(); + $tools_url = \menu_page_url( $tools_slug, true ); + $tools_url_display = (string) ob_get_clean(); + + $GLOBALS['plugin_page'] = $sub_slug; + $GLOBALS['pagenow'] = 'admin.php'; + $GLOBALS['parent_file'] = ''; + $GLOBALS['typenow'] = ''; + $GLOBALS['title'] = ''; + + $resolved_parent = \get_admin_page_parent(); + $resolved_title = \get_admin_page_title(); + $hook_lookup = \get_plugin_page_hook( $sub_slug, $top_slug ); + + $top_entry = self::find_menu_entry( $top_slug ); + $second_entry = self::find_menu_entry( $second_slug ); + $top_position = self::find_menu_position( $top_slug ); + $second_position = self::find_menu_position( $second_slug ); + $sub_entries = $submenu[ $top_slug ] ?? array(); + $sub_entry = self::find_submenu_entry( $top_slug, $sub_slug ); + $auto_parent = $sub_entries[0] ?? null; + $action_priorites = array( + 'top' => \has_action( $top_hook, $callback ), + 'second' => \has_action( $second_hook, $callback ), + 'sub' => \has_action( $sub_hook, $callback ), + 'tools' => \has_action( $tools_hook, $callback ), + ); + + \remove_action( $top_hook, $callback, 10 ); + \remove_action( $second_hook, $callback, 10 ); + \remove_action( $sub_hook, $callback, 10 ); + \remove_action( $tools_hook, $callback, 10 ); + + $action_cleanup = array( + 'top' => \has_action( $top_hook, $callback ), + 'second' => \has_action( $second_hook, $callback ), + 'sub' => \has_action( $sub_hook, $callback ), + 'tools' => \has_action( $tools_hook, $callback ), + ); + + $removed_sub = \remove_submenu_page( $top_slug, $sub_slug ); + $missing_sub = \remove_submenu_page( $top_slug, $sub_slug ); + $removed_top = \remove_menu_page( $top_slug ); + $missing_top = \remove_menu_page( $top_slug ); + $tools_alt_hook = \get_plugin_page_hookname( $tools_slug, 'edit.php' ); + $expected_values = array( + 'denied_hook' => \get_plugin_page_hookname( $denied_slug, $top_slug ), + 'top_hook' => \get_plugin_page_hookname( $top_slug, '' ), + 'second_hook' => \get_plugin_page_hookname( $second_slug, '' ), + 'sub_hook' => \get_plugin_page_hookname( $sub_slug, $top_slug ), + 'tools_hook' => \get_plugin_page_hookname( $tools_slug, 'tools.php' ), + ); + + return compact( + 'action_cleanup', + 'action_priorites', + 'admin_page_hooks', + 'auto_parent', + 'denied_hook', + 'denied_slug', + 'expected_values', + 'hook_lookup', + 'menu', + 'missing_sub', + 'missing_top', + 'removed_sub', + 'removed_top', + 'resolved_parent', + 'resolved_title', + 'second_entry', + 'second_hook', + 'second_position', + 'sub_entry', + 'sub_hook', + 'sub_url', + 'submenu', + 'tools_alt_hook', + 'tools_hook', + 'tools_slug', + 'tools_url', + 'tools_url_display', + 'top_entry', + 'top_hook', + 'top_position', + '_parent_pages', + '_registered_pages', + '_wp_submenu_nopriv' + ); + } + ); + } finally { + self::restore_globals( $local_snapshot ); + $restored = self::globals_match( $local_snapshot, self::menu_global_names() ); + } + + self::collect_failure( + $failures, + isset( $result['top_hook'], $result['sub_hook'], $result['second_hook'], $result['tools_hook'] ) + && $result['top_hook'] === ( $result['expected_values']['top_hook'] ?? null ) + && $result['second_hook'] === ( $result['expected_values']['second_hook'] ?? null ) + && $result['sub_hook'] === ( $result['expected_values']['sub_hook'] ?? null ) + && $result['tools_hook'] === ( $result['expected_values']['tools_hook'] ?? null ) + && $result['top_hook'] !== $result['second_hook'] + && $result['sub_hook'] === $result['hook_lookup'], + 'menu helpers generate deterministic distinct hook suffixes', + $result + ); + + self::collect_failure( + $failures, + is_array( $result['top_entry'] ?? null ) + && is_array( $result['second_entry'] ?? null ) + && $top_menu_title === ( $result['top_entry'][0] ?? null ) + && $capability === ( $result['top_entry'][1] ?? null ) + && $top_slug === ( $result['top_entry'][2] ?? null ) + && $top_title === ( $result['top_entry'][3] ?? null ) + && str_contains( (string) ( $result['top_entry'][4] ?? '' ), (string) $result['top_hook'] ) + && false !== $result['top_position'] + && false !== $result['second_position'] + && $result['top_position'] !== $result['second_position'] + && (float) $result['second_position'] > (float) $result['top_position'], + 'top-level menu globals preserve title/capability fields and resolve position collisions monotonically', + $result + ); + + self::collect_failure( + $failures, + is_array( $result['sub_entry'] ?? null ) + && is_array( $result['auto_parent'] ?? null ) + && $top_slug === ( $result['auto_parent'][2] ?? null ) + && $sub_menu_title === ( $result['sub_entry'][0] ?? null ) + && $capability === ( $result['sub_entry'][1] ?? null ) + && $sub_slug === ( $result['sub_entry'][2] ?? null ) + && $sub_title === ( $result['sub_entry'][3] ?? null ) + && $top_slug === ( $result['_parent_pages'][ $sub_slug ] ?? null ) + && false === ( $result['_parent_pages'][ $top_slug ] ?? null ) + && true === ( $result['_registered_pages'][ $result['sub_hook'] ] ?? null ), + 'submenu globals normalize real parents, add parent back-links, and register page hooks', + $result + ); + + self::collect_failure( + $failures, + false === ( $result['denied_hook'] ?? null ) + && true === ( $result['_wp_submenu_nopriv'][ $top_slug ][ $denied_slug ] ?? null ) + && ! isset( $result['_registered_pages'][ $result['expected_values']['denied_hook'] ?? '' ] ), + 'submenu capability failures mark no-priv globals without registering pages', + $result + ); + + self::collect_failure( + $failures, + $top_slug === ( $result['resolved_parent'] ?? null ) + && $sub_title === ( $result['resolved_title'] ?? null ) + && is_string( $result['sub_url'] ?? null ) + && str_contains( $result['sub_url'], 'admin.php?page=' . $sub_slug ) + && is_string( $result['tools_url'] ?? null ) + && str_contains( $result['tools_url'], 'tools.php?page=' . $tools_slug ) + && $result['tools_url'] === ( $result['tools_url_display'] ?? null ) + && true === ( $result['_registered_pages'][ $result['tools_alt_hook'] ] ?? null ) + && self::html_has_no_unsafe_raw_markup( $result['sub_url'] . $result['tools_url'] ), + 'menu page URLs, current parent, and current title resolve through normalized parent files', + $result + ); + + self::collect_failure( + $failures, + is_array( $result['removed_sub'] ?? null ) + && $sub_slug === ( $result['removed_sub'][2] ?? null ) + && false === ( $result['missing_sub'] ?? null ) + && is_array( $result['removed_top'] ?? null ) + && $top_slug === ( $result['removed_top'][2] ?? null ) + && false === ( $result['missing_top'] ?? null ) + && self::all_values( $result['action_priorites'] ?? array(), 10 ) + && self::all_values( $result['action_cleanup'] ?? array(), false ) + && 0 === $callback_calls, + 'remove_menu_page(), remove_submenu_page(), and callback action cleanup are deterministic', + $result + ); + + self::collect_failure( + $failures, + $restored, + 'admin menu globals are restored after the generated case', + array( 'restored' => $restored ) + ); + + return self::row( + $ctx, + 'admin-workflows.menu.globals-hooks-urls-removal', + array() === $failures, + array( + 'failures' => array_slice( $failures, 0, 8 ), + 'slugs' => compact( 'top_slug', 'second_slug', 'sub_slug', 'tools_slug', 'denied_slug', 'alias_slug' ), + ) + ); + } + + private static function check_synthetic_list_table( \ComponentFuzz\FuzzContext $ctx ): array { + $failures = array(); + $local_snapshot = self::snapshot_globals( + array( + '_COOKIE', + '_GET', + '_POST', + '_REQUEST', + 'current_screen', + 'hook_suffix', + 'pagenow', + 'wp_actions', + 'wp_current_filter', + 'wp_filter', + 'wp_filters', + ) + ); + $server_snapshot = self::snapshot_server( array( 'HTTP_HOST', 'REQUEST_URI', 'PHP_SELF' ) ); + $screen_id = self::screen_id( $ctx->fork( 'screen' ) ); + $screen = \convert_to_screen( $screen_id ); + $hidden_column = 'status'; + $primary_column = 'title'; + $hostile_label = self::hostile_label( $ctx->fork( 'labels' ) ); + $columns = array( + 'cb' => '' . \esc_html( 'Select ' . $hostile_label ) . '', + 'title' => \esc_html( 'Title ' . $hostile_label ), + 'status' => \esc_html( 'Status ' . $hostile_label ), + 'notes' => \esc_html( 'Notes ' . $hostile_label ), + ); + $sortable = array( + 'title' => array( 'cfz_title', false, 'Title abbr "' . $ctx->identifier( 3, 8 ), 'Title order text', 'asc' ), + 'status' => array( 'cfz_status', 'desc' ), + ); + $bulk_actions = array( + 'trash' => \esc_html( 'Trash ' . $hostile_label ), + 'Change State ' . $ctx->identifier( 3, 8 ) => array( + 'feature' => \esc_html( 'Feature ' . $hostile_label ), + 'archive' => \esc_html( 'Archive ' . $hostile_label ), + ), + ); + $item_id = 'cfz-' . substr( hash( 'crc32b', (string) $ctx->seed() ), 0, 8 ); + $item_title = 'Item ' . self::hostile_label( $ctx->fork( 'item-title' ) ); + $views = array( + 'all' => self::view_link( '/wp-admin/admin.php?page=' . $screen_id . '&view=all', 'All ' . $hostile_label, true ), + 'featured' => self::view_link( '/wp-admin/admin.php?page=' . $screen_id . '&view=featured', 'Featured ' . $hostile_label, false ), + ); + $config = array( + 'bulk_actions' => $bulk_actions, + 'columns' => $columns, + 'extra_label' => 'Filter ' . $hostile_label, + 'items' => array( + array( + 'delete_url' => \admin_url( 'admin.php?page=' . $screen_id . '&action=delete&item=' . rawurlencode( $item_id ) ), + 'edit_url' => \admin_url( 'admin.php?page=' . $screen_id . '&action=edit&item=' . rawurlencode( $item_id ) ), + 'id' => $item_id, + 'notes' => 'Note ' . self::hostile_label( $ctx->fork( 'notes' ) ), + 'status' => 'Draft ' . self::hostile_label( $ctx->fork( 'status' ) ), + 'title' => $item_title, + 'url' => 'https://example.test/admin-workflows/?q=' . rawurlencode( $hostile_label ), + ), + array( + 'delete_url' => \admin_url( 'admin.php?page=' . $screen_id . '&action=delete&item=secondary' ), + 'edit_url' => \admin_url( 'admin.php?page=' . $screen_id . '&action=edit&item=secondary' ), + 'id' => $item_id . '-secondary', + 'notes' => 'Secondary', + 'status' => 'Published', + 'title' => 'Secondary ' . $ctx->identifier( 3, 8 ), + 'url' => 'https://example.test/admin-workflows/secondary/', + ), + ), + 'pagination' => array( + 'per_page' => 2, + 'total_items' => 5, + ), + 'plural' => 'cfz_items', + 'primary' => $primary_column, + 'screen' => $screen, + 'singular' => 'cfz_item', + 'sortable' => $sortable, + 'views' => $views, + ); + $table = null; + $hidden_filter = static function ( array $hidden, \WP_Screen $filter_screen ) use ( $hidden_column, $screen ): array { + if ( $filter_screen->id === $screen->id ) { + return array( $hidden_column ); + } + + return $hidden; + }; + $sortable_filter = static function ( array $sortable_columns ) use ( $screen ): array { + if ( $screen instanceof \WP_Screen ) { + $sortable_columns['notes'] = array( 'cfz_notes', false, 'Notes', 'Notes order text', false ); + } + + return $sortable_columns; + }; + $primary_filter = static function ( string $default, string $context ) use ( $primary_column, $screen ): string { + if ( $context === $screen->id ) { + return $primary_column; + } + + return $default; + }; + $views_filter = static function ( array $views_arg ) use ( $screen_id ): array { + $views_arg['mine'] = self::view_link( '/wp-admin/admin.php?page=' . $screen_id . '&view=mine', 'Mine ', false ); + return $views_arg; + }; + $bulk_filter = static function ( array $actions ): array { + $actions['export'] = \esc_html( 'Export ' ); + return $actions; + }; + $result = array(); + $filters_removed = false; + $restored = false; + + $_GET = array( + 'order' => 'asc', + 'orderby' => 'cfz_title', + 'page' => $screen_id, + 'paged' => 2, + ); + $_POST = array(); + $_REQUEST = $_GET; + + $_SERVER['HTTP_HOST'] = 'example.test'; + $_SERVER['PHP_SELF'] = '/wp-admin/admin.php'; + $_SERVER['REQUEST_URI'] = '/wp-admin/admin.php?page=' . rawurlencode( $screen_id ) + . '&orderby=cfz_title&order=asc' + . '&bad=%22%3E%3Cscript%3Ealert(1)%3C/script%3E' + . '&paged=2'; + + \add_filter( 'hidden_columns', $hidden_filter, 10, 3 ); + \add_filter( "manage_{$screen->id}_sortable_columns", $sortable_filter, 10, 1 ); + \add_filter( 'list_table_primary_column', $primary_filter, 10, 2 ); + \add_filter( "views_{$screen->id}", $views_filter, 10, 1 ); + \add_filter( "bulk_actions-{$screen->id}", $bulk_filter, 10, 1 ); + + try { + $table = self::new_synthetic_list_table( $config ); + $table->prepare_items(); + + $column_info = $table->get_column_info(); + $column_count = $table->get_column_count(); + $page_number = $table->get_pagenum(); + $total_pages = $table->get_pagination_arg( 'total_pages' ); + $total_items = $table->get_pagination_arg( 'total_items' ); + $per_page = $table->get_pagination_arg( 'per_page' ); + $current_action = $table->current_action(); + + ob_start(); + $table->views(); + $views_html = (string) ob_get_clean(); + + ob_start(); + $table->display_tablenav( 'top' ); + $tablenav_html = (string) ob_get_clean(); + + ob_start(); + $table->display(); + $display_html = (string) ob_get_clean(); + + $result = compact( + 'column_count', + 'column_info', + 'current_action', + 'display_html', + 'page_number', + 'per_page', + 'tablenav_html', + 'total_items', + 'total_pages', + 'views_html' + ); + } finally { + if ( $table instanceof \WP_List_Table ) { + \remove_filter( "manage_{$screen->id}_columns", array( $table, 'get_columns' ), 0 ); + } + \remove_filter( 'hidden_columns', $hidden_filter, 10 ); + \remove_filter( "manage_{$screen->id}_sortable_columns", $sortable_filter, 10 ); + \remove_filter( 'list_table_primary_column', $primary_filter, 10 ); + \remove_filter( "views_{$screen->id}", $views_filter, 10 ); + \remove_filter( "bulk_actions-{$screen->id}", $bulk_filter, 10 ); + + $filters_removed = false === \has_filter( 'hidden_columns', $hidden_filter ) + && false === \has_filter( "manage_{$screen->id}_sortable_columns", $sortable_filter ) + && false === \has_filter( 'list_table_primary_column', $primary_filter ) + && false === \has_filter( "views_{$screen->id}", $views_filter ) + && false === \has_filter( "bulk_actions-{$screen->id}", $bulk_filter ) + && ( ! $table instanceof \WP_List_Table || false === \has_filter( "manage_{$screen->id}_columns", array( $table, 'get_columns' ) ) ); + + self::restore_server( $server_snapshot ); + self::restore_globals( $local_snapshot ); + $restored = self::globals_match( + $local_snapshot, + array( '_COOKIE', '_GET', '_POST', '_REQUEST', 'current_screen', 'hook_suffix', 'pagenow' ) + ); + } + + $column_info = $result['column_info'] ?? array(); + $columns_out = $column_info[0] ?? array(); + $hidden_out = $column_info[1] ?? array(); + $sortable_out = $column_info[2] ?? array(); + $primary_out = $column_info[3] ?? null; + $display_html = (string) ( $result['display_html'] ?? '' ); + $tablenav_html = (string) ( $result['tablenav_html'] ?? '' ); + $views_html = (string) ( $result['views_html'] ?? '' ); + + self::collect_failure( + $failures, + $columns === $columns_out + && array( $hidden_column ) === $hidden_out + && $primary_column === $primary_out + && isset( $sortable_out['title'], $sortable_out['status'], $sortable_out['notes'] ) + && 'cfz_title' === ( $sortable_out['title'][0] ?? null ) + && false === ( $sortable_out['title'][1] ?? null ) + && str_starts_with( (string) ( $sortable_out['title'][2] ?? '' ), 'Title abbr "' ) + && 'Title order text' === ( $sortable_out['title'][3] ?? null ) + && 'asc' === ( $sortable_out['title'][4] ?? null ) + && 'cfz_status' === ( $sortable_out['status'][0] ?? null ) + && 'desc' === ( $sortable_out['status'][1] ?? null ) + && 'cfz_notes' === ( $sortable_out['notes'][0] ?? null ), + 'list table column info is populated', + array( + 'columnInfo' => $column_info, + 'expected' => array( $columns, array( $hidden_column ), $sortable, $primary_column ), + ) + ); + + self::collect_failure( + $failures, + 3 === ( $result['total_pages'] ?? null ) + && 5 === ( $result['total_items'] ?? null ) + && 2 === ( $result['per_page'] ?? null ) + && 2 === ( $result['page_number'] ?? null ) + && 3 === ( $result['column_count'] ?? null ) + && false === ( $result['current_action'] ?? null ), + 'list table pagination args, current page, column counts, and current action are deterministic', + $result + ); + + self::collect_failure( + $failures, + str_contains( $display_html, 'wp-list-table' ) + && str_contains( $display_html, 'column-status hidden' ) + && str_contains( $display_html, 'aria-sort="ascending"' ) + && str_contains( $display_html, 'row-actions visible' ) + && str_contains( $display_html, 'name="cfz_item[]"' ) + && str_contains( $display_html, 'data-wp-lists=\'list:cfz_item\'' ) + && str_contains( $display_html, 'bulk-action-selector-top' ) + && str_contains( $display_html, 'bulk-action-selector-bottom' ) + && str_contains( $display_html, 'name="action2"' ) + && str_contains( $display_html, 'name="_wpnonce"' ), + 'list table display renders rows, hidden/sortable headers, row actions, bulk controls, and nonces', + array( + 'display' => self::describe_string( $display_html ), + 'tablenav' => self::describe_string( $tablenav_html ), + ) + ); + + self::collect_failure( + $failures, + str_contains( $views_html, "class='subsubsub'" ) + && str_contains( $views_html, 'aria-current="page"' ) + && str_contains( $views_html, 'view=mine' ) + && str_contains( $tablenav_html, 'class="cfz-extra-filter"' ) + && str_contains( $tablenav_html, 'name="_wpnonce"' ) + && self::html_has_no_unsafe_raw_markup( $views_html . $tablenav_html . $display_html ), + 'views and display_tablenav output are escaped for generated labels and URLs', + array( + 'views' => self::describe_string( $views_html ), + 'tablenav' => self::describe_string( $tablenav_html ), + 'display' => self::describe_string( $display_html ), + ) + ); + + self::collect_failure( + $failures, + $filters_removed && $restored, + 'list table filters, request globals, screen globals, and server metadata are restored', + array( + 'filtersRemoved' => $filters_removed, + 'restored' => $restored, + ) + ); + + return self::row( + $ctx, + 'admin-workflows.list-table.synthetic-rendering', + array() === $failures, + array( + 'failures' => array_slice( $failures, 0, 8 ), + 'screen' => $screen->id, + ) + ); + } + + private static function check_list_table_action_and_month_helpers( \ComponentFuzz\FuzzContext $ctx ): array { + $failures = array(); + $local_snapshot = self::snapshot_globals( + array( + '_GET', + '_POST', + '_REQUEST', + '_wp_post_type_features', + 'current_screen', + 'post_type_meta_caps', + 'wp_actions', + 'wp_current_filter', + 'wp_filter', + 'wp_filters', + 'wp_post_types', + ) + ); + $screen_id = self::screen_id( $ctx->fork( 'screen' ) ); + $screen = \convert_to_screen( $screen_id ); + $hostile_label = self::hostile_label( $ctx->fork( 'bulk-label' ) ); + $month_seen = array(); + $disable_seen = array(); + $table = self::new_helper_list_table( + array( + 'bulk_actions' => array( + 'trash' => \esc_html( 'Trash ' . $hostile_label ), + 'Change ' . $hostile_label => array( + 'feature' => \esc_html( 'Feature ' . $hostile_label ), + 'archive' => \esc_html( 'Archive ' . $hostile_label ), + ), + ), + 'screen' => $screen, + ) + ); + $pre_months = static function ( $months, string $post_type ) use ( &$month_seen ): array { + $month_seen[] = array( + 'filter' => 'pre', + 'postType' => $post_type, + 'input' => $months, + ); + + return array( + (object) array( + 'year' => 2026, + 'month' => 6, + ), + (object) array( + 'year' => 2025, + 'month' => 12, + ), + (object) array( + 'year' => 0, + 'month' => 0, + ), + ); + }; + $month_results = static function ( array $months, string $post_type ) use ( &$month_seen ): array { + $month_seen[] = array( + 'filter' => 'results', + 'postType' => $post_type, + 'count' => count( $months ), + ); + + return $months; + }; + $disable_months = static function ( bool $disabled, string $post_type ) use ( &$disable_seen ): bool { + $disable_seen[] = array( + 'disabled' => $disabled, + 'postType' => $post_type, + ); + + return 'page' === $post_type; + }; + $result = array(); + $filters_removed = false; + $restored = false; + + if ( function_exists( 'create_initial_post_types' ) ) { + \create_initial_post_types(); + } + + \add_filter( 'pre_months_dropdown_query', $pre_months, 10, 2 ); + \add_filter( 'months_dropdown_results', $month_results, 10, 2 ); + \add_filter( 'disable_months_dropdown', $disable_months, 10, 2 ); + + try { + $_REQUEST = array( + 'action' => 'trash', + 'action2' => 'archive', + 'filter_action' => 'Filter', + ); + $filter_action = $table->current_action(); + + $_REQUEST = array( + 'action' => 'trash', + 'action2' => 'archive', + ); + $top_action = $table->current_action(); + + $_REQUEST = array( + 'action' => '-1', + 'action2' => 'archive', + ); + $bottom_ignored = $table->current_action(); + + $bulk_top_html = $table->expose_bulk_actions( 'top' ); + $bulk_bottom_html = $table->expose_bulk_actions( 'bottom' ); + + $_GET = array( 'm' => '202606' ); + $_POST = array(); + $_REQUEST = $_GET; + $months_html = $table->expose_months_dropdown( 'post' ); + + $_GET = array(); + $_REQUEST = array(); + $disabled_months_html = $table->expose_months_dropdown( 'page' ); + + $result = compact( + 'bulk_bottom_html', + 'bulk_top_html', + 'bottom_ignored', + 'disabled_months_html', + 'disable_seen', + 'filter_action', + 'month_seen', + 'months_html', + 'top_action' + ); + } finally { + \remove_filter( 'disable_months_dropdown', $disable_months, 10 ); + \remove_filter( 'months_dropdown_results', $month_results, 10 ); + \remove_filter( 'pre_months_dropdown_query', $pre_months, 10 ); + + $filters_removed = false === \has_filter( 'disable_months_dropdown', $disable_months ) + && false === \has_filter( 'months_dropdown_results', $month_results ) + && false === \has_filter( 'pre_months_dropdown_query', $pre_months ); + + self::restore_globals( $local_snapshot ); + $restored = self::globals_match( + $local_snapshot, + array( '_GET', '_POST', '_REQUEST', '_wp_post_type_features', 'current_screen', 'post_type_meta_caps', 'wp_post_types' ) + ); + } + + self::collect_failure( + $failures, + false === ( $result['filter_action'] ?? null ) + && 'trash' === ( $result['top_action'] ?? null ) + && false === ( $result['bottom_ignored'] ?? null ), + 'current_action honors filter_action suppression and top bulk action precedence', + $result + ); + + self::collect_failure( + $failures, + str_contains( (string) ( $result['bulk_top_html'] ?? '' ), 'name="action"' ) + && str_contains( (string) ( $result['bulk_top_html'] ?? '' ), 'bulk-action-selector-top' ) + && str_contains( (string) ( $result['bulk_top_html'] ?? '' ), ' self::describe_string( (string) ( $result['bulk_top_html'] ?? '' ) ), + 'bottom' => self::describe_string( (string) ( $result['bulk_bottom_html'] ?? '' ) ), + ) + ); + + self::collect_failure( + $failures, + str_contains( (string) ( $result['months_html'] ?? '' ), 'id="filter-by-date"' ) + && str_contains( (string) ( $result['months_html'] ?? '' ), "value='202606'" ) + && str_contains( (string) ( $result['months_html'] ?? '' ), "selected='selected'" ) + && str_contains( (string) ( $result['months_html'] ?? '' ), 'June 2026' ) + && str_contains( (string) ( $result['months_html'] ?? '' ), 'December 2025' ) + && ! str_contains( (string) ( $result['months_html'] ?? '' ), "value='000000'" ) + && '' === (string) ( $result['disabled_months_html'] ?? '' ) + && array( 'post' ) === array_column( array_filter( $result['month_seen'] ?? array(), static fn( array $entry ): bool => 'pre' === $entry['filter'] ), 'postType' ) + && array( 'post' ) === array_column( array_filter( $result['month_seen'] ?? array(), static fn( array $entry ): bool => 'results' === $entry['filter'] ), 'postType' ) + && array( 'post', 'page' ) === array_column( $result['disable_seen'] ?? array(), 'postType' ), + 'months_dropdown uses filter-provided months, selected request state, zero-year skipping, and disable short-circuit', + array( + 'months' => self::describe_string( (string) ( $result['months_html'] ?? '' ) ), + 'disabled' => self::describe_string( (string) ( $result['disabled_months_html'] ?? '' ) ), + 'monthSeen' => $result['month_seen'] ?? array(), + 'disableSeen' => $result['disable_seen'] ?? array(), + ) + ); + + self::collect_failure( + $failures, + $filters_removed && $restored, + 'list table helper filters and request globals are restored', + array( + 'filtersRemoved' => $filters_removed, + 'restored' => $restored, + ) + ); + + return self::row( + $ctx, + 'admin-workflows.list-table.actions-and-month-filters', + array() === $failures, + array( 'failures' => array_slice( $failures, 0, 8 ) ) + ); + } + + private static function check_list_table_request_action_matrix( \ComponentFuzz\FuzzContext $ctx ): array { + $failures = array(); + $local_snapshot = self::snapshot_globals( + array( + '_GET', + '_POST', + '_REQUEST', + 'current_screen', + 'current_user', + 'hook_suffix', + 'pagenow', + 'taxnow', + 'typenow', + 'wp_actions', + 'wp_current_filter', + 'wp_filter', + 'wp_filters', + ) + ); + $server_snapshot = self::snapshot_server( array( 'HTTP_HOST', 'HTTP_REFERER', 'REQUEST_URI', 'PHP_SELF' ) ); + $screen_id = self::screen_id( $ctx->fork( 'screen' ) ); + $screen = \convert_to_screen( $screen_id ); + $hostile_label = self::hostile_label( $ctx->fork( 'label' ) ); + $bulk_cap = self::capability( $ctx->fork( 'bulk-cap' ), 'bulk' ); + $delete_cap = self::capability( $ctx->fork( 'delete-cap' ), 'delete' ); + $item_id = 'cfz-action-' . substr( hash( 'crc32b', (string) $ctx->fork( 'item' )->seed() ), 0, 8 ); + $second_id = $item_id . '-second'; + $top_action = 'feature-' . substr( hash( 'crc32b', (string) $ctx->fork( 'top-action' )->seed() ), 0, 8 ); + $bottom_action = 'archive-' . substr( hash( 'crc32b', (string) $ctx->fork( 'bottom-action' )->seed() ), 0, 8 ); + $delete_action = 'delete-' . substr( hash( 'crc32b', (string) $ctx->fork( 'delete-action' )->seed() ), 0, 8 ); + $base_url = \admin_url( 'admin.php?page=' . rawurlencode( $screen_id ) ); + $trusted_host = 'trusted-' . substr( hash( 'crc32b', (string) $ctx->fork( 'trusted-host' )->seed() ), 0, 8 ) . '.example'; + $tables = array(); + $view_events = array(); + $bulk_events = array(); + $allowed_hosts = array(); + $result = array(); + $filters_removed = false; + $restored = false; + + $config = array( + 'bulk_cap' => $bulk_cap, + 'delete_action' => $delete_action, + 'delete_cap' => $delete_cap, + 'items' => array( + array( + 'delete_url' => \admin_url( 'admin.php?page=' . rawurlencode( $screen_id ) . '&action=delete&item=' . rawurlencode( $item_id ) ), + 'edit_url' => \admin_url( 'admin.php?page=' . rawurlencode( $screen_id ) . '&action=edit&item=' . rawurlencode( $item_id ) ), + 'id' => $item_id, + 'status' => 'Pending ' . $hostile_label, + 'title' => 'Matrix ' . $hostile_label, + 'url' => 'https://example.test/admin-workflows/matrix/?label=' . rawurlencode( $hostile_label ), + ), + array( + 'delete_url' => \admin_url( 'admin.php?page=' . rawurlencode( $screen_id ) . '&action=delete&item=' . rawurlencode( $second_id ) ), + 'edit_url' => \admin_url( 'admin.php?page=' . rawurlencode( $screen_id ) . '&action=edit&item=' . rawurlencode( $second_id ) ), + 'id' => $second_id, + 'status' => 'Published', + 'title' => 'Second ' . $ctx->identifier( 3, 8 ), + 'url' => 'https://example.test/admin-workflows/matrix/second/', + ), + ), + 'screen' => $screen, + 'top_action' => $top_action, + 'bottom_action' => $bottom_action, + 'view_links' => array( + 'all' => array( + 'url' => $base_url . '&view=all', + 'label' => \esc_html( 'All ' . $hostile_label ), + 'current' => true, + ), + 'filtered' => array( + 'url' => $base_url . '&view=filtered&raw=' . rawurlencode( $hostile_label ), + 'label' => \esc_html( 'Filtered ' . $hostile_label ), + 'current' => false, + ), + ), + ); + + $views_filter = static function ( array $views ) use ( &$view_events, $base_url, $hostile_label, $screen ): array { + $view_events[] = array( + 'screen' => $screen->id, + 'keys' => array_keys( $views ), + ); + $views['mine'] = self::view_link( $base_url . '&view=mine&raw=' . rawurlencode( $hostile_label ), 'Mine ' . $hostile_label, false ); + return $views; + }; + $bulk_filter = static function ( array $actions ) use ( &$bulk_events, $hostile_label, $screen ): array { + $bulk_events[] = array( + 'screen' => $screen->id, + 'keys' => array_keys( $actions ), + ); + $actions['review'] = \esc_html( 'Review ' . $hostile_label ); + return $actions; + }; + $allowed_hosts_filter = static function ( array $hosts, string $host ) use ( &$allowed_hosts, $trusted_host ): array { + $allowed_hosts[] = array( + 'incoming' => $host, + 'hosts' => $hosts, + ); + $hosts[] = $trusted_host; + return $hosts; + }; + + $_SERVER['HTTP_HOST'] = 'example.test'; + $_SERVER['PHP_SELF'] = '/wp-admin/admin.php'; + $_SERVER['REQUEST_URI'] = '/wp-admin/admin.php?page=' . rawurlencode( $screen_id ) + . '&action=' . rawurlencode( $top_action ) + . '&ids%5B%5D=' . rawurlencode( $item_id ); + + \add_filter( "views_{$screen->id}", $views_filter, 10, 1 ); + \add_filter( "bulk_actions-{$screen->id}", $bulk_filter, 10, 1 ); + \add_filter( 'allowed_redirect_hosts', $allowed_hosts_filter, 10, 2 ); + + try { + $matrix_table = self::new_action_matrix_list_table( $config ); + $tables[] = $matrix_table; + $matrix_table->prepare_items(); + + $action_cases = array( + 'filter-suppresses' => array( + 'request' => array( + 'action' => $top_action, + 'action2' => $bottom_action, + 'filter_action' => 'Filter', + ), + 'expected' => false, + ), + 'top-precedence' => array( + 'request' => array( + 'action' => $top_action, + 'action2' => $bottom_action, + ), + 'expected' => $top_action, + ), + 'bottom-ignored' => array( + 'request' => array( + 'action' => '-1', + 'action2' => $bottom_action, + ), + 'expected' => false, + ), + 'empty-action' => array( + 'request' => array( + 'action2' => $bottom_action, + ), + 'expected' => false, + ), + ); + $action_results = array(); + foreach ( $action_cases as $name => $case ) { + $_GET = $case['request']; + $_POST = array(); + $_REQUEST = $case['request']; + $action_results[ $name ] = $matrix_table->current_action(); + } + + $denied_table = self::new_action_matrix_list_table( $config ); + $tables[] = $denied_table; + $denied_table->prepare_items(); + $denied_top_html = $denied_table->expose_bulk_actions( 'top' ); + $denied_row_html = $denied_table->expose_display_rows(); + + $allowed = self::with_capabilities( + array( $bulk_cap, $delete_cap ), + static function () use ( $config, &$tables ): array { + $table = self::new_action_matrix_list_table( $config ); + $tables[] = $table; + $table->prepare_items(); + + return array( + 'top' => $table->expose_bulk_actions( 'top' ), + 'bottom' => $table->expose_bulk_actions( 'bottom' ), + 'rows' => $table->expose_display_rows(), + 'views' => $table->expose_views(), + 'tablenav' => $table->expose_display_tablenav( 'top' ), + ); + } + ); + + $safe_target = $base_url . '&action=' . rawurlencode( $top_action ) . '&ids=' . rawurlencode( $item_id . ',' . $second_id ); + $unsafe_target = 'https://evil.example/wp-admin/admin.php?page=' . rawurlencode( $screen_id ); + $trusted_target = 'https://' . $trusted_host . '/wp-admin/admin.php?page=' . rawurlencode( $screen_id ); + $redirects = array( + 'sameHost' => \wp_validate_redirect( $safe_target, $base_url ), + 'unsafe' => \wp_validate_redirect( $unsafe_target, $base_url ), + 'trusted' => \wp_validate_redirect( $trusted_target, $base_url ), + ); + $bulk_redirects = array( + 'denied' => self::run_action_matrix_bulk_redirect_case( $ctx->fork( 'bulk-denied' ), $screen, $config, $base_url, $top_action, $bottom_action, $item_id, $second_id, $bulk_cap, false ), + 'allowed' => self::with_capabilities( + array( $bulk_cap ), + static function () use ( $ctx, $screen, $config, $base_url, $top_action, $bottom_action, $item_id, $second_id, $bulk_cap ): array { + return self::run_action_matrix_bulk_redirect_case( $ctx->fork( 'bulk-allowed' ), $screen, $config, $base_url, $top_action, $bottom_action, $item_id, $second_id, $bulk_cap, false ); + } + ), + 'unsafe' => self::with_capabilities( + array( $bulk_cap ), + static function () use ( $ctx, $screen, $config, $base_url, $top_action, $bottom_action, $item_id, $second_id, $bulk_cap ): array { + return self::run_action_matrix_bulk_redirect_case( $ctx->fork( 'bulk-unsafe' ), $screen, $config, $base_url, $top_action, $bottom_action, $item_id, $second_id, $bulk_cap, true ); + } + ), + ); + + $result = compact( + 'action_cases', + 'action_results', + 'allowed', + 'allowed_hosts', + 'bulk_redirects', + 'denied_row_html', + 'denied_top_html', + 'redirects', + 'safe_target', + 'trusted_target', + 'view_events', + 'bulk_events' + ); + } finally { + foreach ( $tables as $table ) { + if ( $table instanceof \WP_List_Table ) { + \remove_filter( "manage_{$screen->id}_columns", array( $table, 'get_columns' ), 0 ); + } + } + \remove_filter( "views_{$screen->id}", $views_filter, 10 ); + \remove_filter( "bulk_actions-{$screen->id}", $bulk_filter, 10 ); + \remove_filter( 'allowed_redirect_hosts', $allowed_hosts_filter, 10 ); + + $filters_removed = false === \has_filter( "views_{$screen->id}", $views_filter ) + && false === \has_filter( "bulk_actions-{$screen->id}", $bulk_filter ) + && false === \has_filter( 'allowed_redirect_hosts', $allowed_hosts_filter ); + foreach ( $tables as $table ) { + if ( $table instanceof \WP_List_Table && false !== \has_filter( "manage_{$screen->id}_columns", array( $table, 'get_columns' ) ) ) { + $filters_removed = false; + } + } + + self::restore_server( $server_snapshot ); + self::restore_globals( $local_snapshot ); + $restored = self::globals_match( + $local_snapshot, + array( '_GET', '_POST', '_REQUEST', 'current_screen', 'current_user', 'hook_suffix', 'pagenow', 'taxnow', 'typenow' ) + ); + } + + $allowed_top = (string) ( $result['allowed']['top'] ?? '' ); + $allowed_bottom = (string) ( $result['allowed']['bottom'] ?? '' ); + $allowed_rows = (string) ( $result['allowed']['rows'] ?? '' ); + $allowed_views = (string) ( $result['allowed']['views'] ?? '' ); + $allowed_tablenav = (string) ( $result['allowed']['tablenav'] ?? '' ); + $denied_top = (string) ( $result['denied_top_html'] ?? '' ); + $denied_rows = (string) ( $result['denied_row_html'] ?? '' ); + $delete_nonce = \wp_create_nonce( 'delete-cfz_' . $item_id ); + + self::collect_failure( + $failures, + array( + 'filter-suppresses' => false, + 'top-precedence' => $top_action, + 'bottom-ignored' => false, + 'empty-action' => false, + ) === ( $result['action_results'] ?? array() ), + 'generated request action matrix follows current_action precedence and bottom-action boundary', + array( + 'cases' => $result['action_cases'] ?? array(), + 'results' => $result['action_results'] ?? array(), + ) + ); + + self::collect_failure( + $failures, + str_contains( $denied_top, 'value="review"' ) + && ! str_contains( $denied_top, 'value="' . $top_action . '"' ) + && ! str_contains( $denied_top, 'value="' . $delete_action . '"' ) + && ! str_contains( $denied_rows, $delete_nonce ) + && str_contains( $allowed_top, 'value="' . $top_action . '"' ) + && str_contains( $allowed_top, 'value="' . $delete_action . '"' ) + && str_contains( $allowed_top, ' self::describe_string( $denied_top ), + 'deniedRows' => self::describe_string( $denied_rows ), + 'allowedTop' => self::describe_string( $allowed_top ), + 'allowedBottom' => self::describe_string( $allowed_bottom ), + 'allowedRows' => self::describe_string( $allowed_rows ), + ) + ); + + self::collect_failure( + $failures, + str_contains( $allowed_views, "class='subsubsub'" ) + && str_contains( $allowed_views, 'aria-current="page"' ) + && str_contains( $allowed_views, 'view=mine' ) + && str_contains( $allowed_tablenav, 'name="_wpnonce"' ) + && str_contains( $allowed_tablenav, 'bulk-action-selector-top' ) + && 0 < count( $result['view_events'] ?? array() ) + && 0 < count( $result['bulk_events'] ?? array() ) + && self::html_has_no_unsafe_raw_markup( $allowed_views . $allowed_tablenav ), + 'views and tablenav run screen-local filters, include nonces, and escape generated labels', + array( + 'views' => self::describe_string( $allowed_views ), + 'tablenav' => self::describe_string( $allowed_tablenav ), + 'viewEvents' => $result['view_events'] ?? array(), + 'bulkEvents' => $result['bulk_events'] ?? array(), + ) + ); + + self::collect_failure( + $failures, + ( $result['safe_target'] ?? null ) === ( $result['redirects']['sameHost'] ?? null ) + && $base_url === ( $result['redirects']['unsafe'] ?? null ) + && ( $result['trusted_target'] ?? null ) === ( $result['redirects']['trusted'] ?? null ) + && 0 < count( $result['allowed_hosts'] ?? array() ), + 'admin action redirect targets keep same-host URLs, reject unsafe hosts, and honor scoped allowed-host filters', + array( + 'redirects' => $result['redirects'] ?? array(), + 'safeTarget' => $result['safe_target'] ?? null, + 'trustedTarget' => $result['trusted_target'] ?? null, + 'allowedHosts' => $result['allowed_hosts'] ?? array(), + ) + ); + + $bulk_denied = $result['bulk_redirects']['denied'] ?? array(); + $bulk_allowed = $result['bulk_redirects']['allowed'] ?? array(); + $bulk_unsafe = $result['bulk_redirects']['unsafe'] ?? array(); + $allowed_query = array(); + $allowed_location = (string) ( $bulk_allowed['redirectEvents'][0]['location'] ?? '' ); + $unsafe_location = (string) ( $bulk_unsafe['redirectEvents'][0]['location'] ?? '' ); + $unsafe_fallback = (string) ( $bulk_unsafe['fallbackEvents'][0]['fallback'] ?? '' ); + $allowed_query_part = parse_url( $allowed_location, PHP_URL_QUERY ); + if ( is_string( $allowed_query_part ) ) { + parse_str( $allowed_query_part, $allowed_query ); + } + + self::collect_failure( + $failures, + false === ( $bulk_denied['dispatched'] ?? null ) + && array() === ( $bulk_denied['nonceEvents'] ?? array() ) + && array() === ( $bulk_denied['handlerEvents'] ?? array() ) + && array() === ( $bulk_denied['redirectEvents'] ?? array() ) + && true === ( $bulk_allowed['dispatched'] ?? null ) + && true === ( $bulk_allowed['nonceAccepted'] ?? null ) + && $screen->id === ( $bulk_allowed['currentScreenId'] ?? null ) + && $top_action === ( $bulk_allowed['currentAction'] ?? null ) + && array( $item_id, $second_id ) === ( $bulk_allowed['handlerEvents'][0]['selected'] ?? array() ) + && $top_action === ( $bulk_allowed['handlerEvents'][0]['action'] ?? null ) + && array() === ( $bulk_allowed['neighborEvents'] ?? array() ) + && false === ( $bulk_allowed['redirectResult'] ?? null ) + && '2' === ( $allowed_query['cfz_done'] ?? null ) + && '1' === ( $allowed_query['keep'] ?? null ) + && '3' === ( $allowed_query['paged'] ?? null ) + && ! array_key_exists( 'action', $allowed_query ) + && ! array_key_exists( 'action2', $allowed_query ) + && ! array_key_exists( '_wpnonce', $allowed_query ) + && ! array_key_exists( '_wp_http_referer', $allowed_query ) + && ! array_key_exists( 'cfz_matrix_item', $allowed_query ) + && true === ( $bulk_unsafe['dispatched'] ?? null ) + && false === ( $bulk_unsafe['redirectResult'] ?? null ) + && '' !== $unsafe_fallback + && $unsafe_fallback === $unsafe_location + && array() === ( $bulk_unsafe['neighborEvents'] ?? array() ) + && ( $bulk_denied['filtersRemoved'] ?? false ) + && ( $bulk_allowed['filtersRemoved'] ?? false ) + && ( $bulk_unsafe['filtersRemoved'] ?? false ), + 'custom bulk-action dispatch checks nonce, current screen hook, selected IDs, safe redirect cleanup, and denied capability boundary', + array( + 'denied' => $bulk_denied, + 'allowed' => $bulk_allowed, + 'allowedQuery' => $allowed_query, + 'unsafe' => $bulk_unsafe, + 'unsafeTarget' => $unsafe_location, + 'unsafeFallback' => $unsafe_fallback, + ) + ); + + self::collect_failure( + $failures, + $filters_removed && $restored, + 'request/action matrix filters, request globals, screen globals, and server metadata are restored', + array( + 'filtersRemoved' => $filters_removed, + 'restored' => $restored, + ) + ); + + return self::row( + $ctx, + 'admin-workflows.list-table.request-action-matrix', + array() === $failures, + array( + 'failures' => array_slice( $failures, 0, 8 ), + 'screen' => $screen->id, + ) + ); + } + + private static function run_action_matrix_bulk_redirect_case( + \ComponentFuzz\FuzzContext $ctx, + \WP_Screen $screen, + array $config, + string $base_url, + string $top_action, + string $bottom_action, + string $item_id, + string $second_id, + string $bulk_cap, + bool $handler_returns_unsafe + ): array { + $table = self::new_action_matrix_list_table( $config ); + $neighbor_screen_id = self::screen_id( $ctx->fork( 'neighbor' ) ); + if ( $neighbor_screen_id === $screen->id ) { + $neighbor_screen_id .= '-neighbor'; + } + + $nonce_action = 'bulk-cfz_matrix_items'; + $nonce = \wp_create_nonce( $nonce_action ); + $referer = \add_query_arg( + array( + '_wp_http_referer' => '/wp-admin/admin.php?page=' . rawurlencode( $screen->id ), + '_wpnonce' => $nonce, + 'action' => $top_action, + 'action2' => $bottom_action, + 'cfz_matrix_item' => $item_id, + 'keep' => '1', + 'paged' => '3', + ), + $base_url + ); + $request = array( + '_wp_http_referer' => $referer, + '_wpnonce' => $nonce, + 'action' => $top_action, + 'action2' => $bottom_action, + 'cfz_matrix_item' => array( $item_id, $second_id ), + 'keep' => '1', + 'paged' => '1', + ); + $handler_events = array(); + $neighbor_events = array(); + $nonce_events = array(); + $redirect_events = array(); + $fallback_events = array(); + $result = array( + 'currentAction' => null, + 'currentScreenId' => null, + 'dispatched' => false, + 'filtersRemoved' => false, + 'handlerEvents' => array(), + 'neighborEvents' => array(), + 'nonceAccepted' => false, + 'nonceEvents' => array(), + 'redirectEvents' => array(), + 'fallbackEvents' => array(), + 'redirectResult' => null, + ); + + $handler = static function ( string $sendback, string $action, array $selected ) use ( &$handler_events, $base_url, $handler_returns_unsafe ): string { + $selected = array_values( array_map( 'strval', $selected ) ); + $handler_events[] = array( + 'action' => $action, + 'selected' => $selected, + 'sendback' => $sendback, + ); + + if ( $handler_returns_unsafe ) { + return 'https://evil.example/wp-admin/admin.php?cfz_done=' . count( $selected ); + } + + return \add_query_arg( 'cfz_done', (string) count( $selected ), $sendback ); + }; + $neighbor_handler = static function ( string $sendback, string $action, array $selected ) use ( &$neighbor_events ): string { + $neighbor_events[] = array( + 'action' => $action, + 'selected' => array_values( array_map( 'strval', $selected ) ), + ); + return $sendback; + }; + $nonce_listener = static function ( string $action, $nonce_result ) use ( &$nonce_events ): void { + $nonce_events[] = array( + 'action' => $action, + 'result' => $nonce_result, + ); + }; + $redirect_filter = static function ( $location, int $status ) use ( &$redirect_events ) { + $redirect_events[] = array( + 'location' => $location, + 'status' => $status, + ); + return false; + }; + $fallback_filter = static function ( string $fallback, int $status ) use ( &$fallback_events, $base_url ): string { + $fallback = \add_query_arg( 'fallback', '1', $base_url ); + $fallback_events[] = array( + 'fallback' => $fallback, + 'status' => $status, + ); + return $fallback; + }; + + \add_filter( "handle_bulk_actions-{$screen->id}", $handler, 10, 3 ); + \add_filter( "handle_bulk_actions-{$neighbor_screen_id}", $neighbor_handler, 10, 3 ); + \add_action( 'check_admin_referer', $nonce_listener, 10, 2 ); + \add_filter( 'wp_redirect', $redirect_filter, 10, 2 ); + \add_filter( 'wp_safe_redirect_fallback', $fallback_filter, 10, 2 ); + + try { + $_GET = $request; + $_POST = array(); + $_REQUEST = $request; + $_SERVER['HTTP_REFERER'] = $referer; + $table->prepare_items(); + \set_current_screen( $screen ); + + $current_screen = \get_current_screen(); + $result['currentScreenId'] = $current_screen instanceof \WP_Screen ? $current_screen->id : null; + $result['currentAction'] = $table->current_action(); + $result['capabilityPassed'] = \current_user_can( $bulk_cap ); + + if ( $result['currentAction'] && $result['capabilityPassed'] ) { + $nonce_result = \check_admin_referer( $nonce_action ); + $result['nonceAccepted'] = false !== $nonce_result; + $result['checkAdminNonce'] = $nonce_result; + $selected = array_values( array_map( 'strval', (array) ( $_REQUEST['cfz_matrix_item'] ?? array() ) ) ); + $sendback = \apply_filters( "handle_bulk_actions-{$result['currentScreenId']}", $referer, (string) $result['currentAction'], $selected ); + $sendback = \remove_query_arg( array( 'action', 'action2', '_wpnonce', '_wp_http_referer', 'cfz_matrix_item' ), $sendback ); + $result['sendback'] = $sendback; + $result['redirectResult'] = \wp_safe_redirect( $sendback, 303, 'ComponentFuzz' ); + $result['dispatched'] = true; + } + } finally { + \remove_filter( "handle_bulk_actions-{$screen->id}", $handler, 10 ); + \remove_filter( "handle_bulk_actions-{$neighbor_screen_id}", $neighbor_handler, 10 ); + \remove_action( 'check_admin_referer', $nonce_listener, 10 ); + \remove_filter( 'wp_redirect', $redirect_filter, 10 ); + \remove_filter( 'wp_safe_redirect_fallback', $fallback_filter, 10 ); + \remove_filter( "manage_{$screen->id}_columns", array( $table, 'get_columns' ), 0 ); + + $result['handlerEvents'] = $handler_events; + $result['neighborEvents'] = $neighbor_events; + $result['nonceEvents'] = $nonce_events; + $result['redirectEvents'] = $redirect_events; + $result['fallbackEvents'] = $fallback_events; + $result['filtersRemoved'] = false === \has_filter( "handle_bulk_actions-{$screen->id}", $handler ) + && false === \has_filter( "handle_bulk_actions-{$neighbor_screen_id}", $neighbor_handler ) + && false === \has_action( 'check_admin_referer', $nonce_listener ) + && false === \has_filter( 'wp_redirect', $redirect_filter ) + && false === \has_filter( 'wp_safe_redirect_fallback', $fallback_filter ) + && false === \has_filter( "manage_{$screen->id}_columns", array( $table, 'get_columns' ) ); + } + + return $result; + } + + private static function check_referer_helpers( \ComponentFuzz\FuzzContext $ctx ): array { + $failures = array(); + $local_snapshot = self::snapshot_globals( + array( + '_GET', + '_POST', + '_REQUEST', + 'wp_actions', + 'wp_current_filter', + 'wp_filter', + 'wp_filters', + ) + ); + $action = 'cfz-admin-' . substr( hash( 'sha256', (string) $ctx->seed() ), 0, 12 ); + $ajax_action = $action . '-ajax'; + $query_arg = 'cfz_nonce_' . \sanitize_key( $ctx->identifier( 3, 8 ) ); + $admin_nonce = \wp_create_nonce( $action ); + $ajax_nonce = \wp_create_nonce( $ajax_action ); + $admin_calls = array(); + $ajax_calls = array(); + $admin_listener = static function ( $seen_action, $result ) use ( &$admin_calls ): void { + $admin_calls[] = array( + 'action' => $seen_action, + 'result' => $result, + ); + }; + $ajax_listener = static function ( $seen_action, $result ) use ( &$ajax_calls ): void { + $ajax_calls[] = array( + 'action' => $seen_action, + 'result' => $result, + ); + }; + $result = array(); + $actions_removed = false; + $restored = false; + + \add_action( 'check_admin_referer', $admin_listener, 10, 2 ); + \add_action( 'check_ajax_referer', $ajax_listener, 10, 2 ); + + try { + $_GET = array( $query_arg => $admin_nonce ); + $_POST = array(); + $_REQUEST = $_GET; + + $admin_result = \check_admin_referer( $action, $query_arg ); + + $_GET = array(); + $_POST = array( 'nonce' => $ajax_nonce ); + $_REQUEST = $_POST; + + $ajax_result = \check_ajax_referer( $ajax_action, 'nonce', false ); + + $_GET = array(); + $_POST = array( 'nonce' => 'not-a-valid-nonce' ); + $_REQUEST = $_POST; + + $invalid_ajax_result = \check_ajax_referer( $ajax_action, 'nonce', false ); + + $nonce_target_url = \admin_url( + 'admin-post.php?action=' . rawurlencode( $action ) + . '&label=' . rawurlencode( self::hostile_label( $ctx->fork( 'url-label' ) ) ) + ); + $nonce_url = \wp_nonce_url( $nonce_target_url, $action, $query_arg ); + + $result = compact( + 'admin_calls', + 'admin_result', + 'ajax_calls', + 'ajax_result', + 'invalid_ajax_result', + 'nonce_url', + 'query_arg' + ); + } finally { + \remove_action( 'check_admin_referer', $admin_listener, 10 ); + \remove_action( 'check_ajax_referer', $ajax_listener, 10 ); + $actions_removed = false === \has_action( 'check_admin_referer', $admin_listener ) + && false === \has_action( 'check_ajax_referer', $ajax_listener ); + self::restore_globals( $local_snapshot ); + $restored = self::globals_match( $local_snapshot, array( '_GET', '_POST', '_REQUEST' ) ); + } + + self::collect_failure( + $failures, + in_array( $result['admin_result'] ?? null, array( 1, 2 ), true ) + && in_array( $result['ajax_result'] ?? null, array( 1, 2 ), true ) + && false === ( $result['invalid_ajax_result'] ?? null ) + && array( $action ) === array_column( $result['admin_calls'] ?? array(), 'action' ) + && array( $ajax_action, $ajax_action ) === array_column( $result['ajax_calls'] ?? array(), 'action' ), + 'admin and ajax referer helpers verify valid nonces and return false without exiting when stop is disabled', + $result + ); + + self::collect_failure( + $failures, + is_string( $result['nonce_url'] ?? null ) + && str_contains( $result['nonce_url'], 'admin-post.php' ) + && str_contains( $result['nonce_url'], $query_arg . '=' ) + && self::html_has_no_unsafe_raw_markup( $result['nonce_url'] ), + 'admin-post nonce URLs include escaped custom nonce names and generated query labels', + $result + ); + + self::collect_failure( + $failures, + $actions_removed && $restored, + 'referer helper actions and request globals are restored', + array( + 'actionsRemoved' => $actions_removed, + 'restored' => $restored, + ) + ); + + return self::row( + $ctx, + 'admin-workflows.referers.nonce-admin-post-ajax', + array() === $failures, + array( 'failures' => array_slice( $failures, 0, 6 ) ) + ); + } + + private static function check_referer_field_helpers( \ComponentFuzz\FuzzContext $ctx ): array { + $failures = array(); + $local_snapshot = self::snapshot_globals( array( '_GET', '_POST', '_REQUEST' ) ); + $server_snapshot = self::snapshot_server( array( 'HTTP_HOST', 'HTTP_REFERER', 'REQUEST_URI' ) ); + $page = \sanitize_key( 'cfz_referer_' . $ctx->identifier( 3, 8 ) ); + $token = \sanitize_key( $ctx->fork( 'token' )->identifier( 3, 8 ) ); + $current_path = '/wp-admin/admin.php?page=' . rawurlencode( $page ) + . '&_wp_http_referer=/wp-admin/old.php?drop="e="bad"'; + + $result['nonce_with_referer'] = \wp_nonce_field( $action, $nonce_name, true, false ); + $result['nonce_without_referer'] = \wp_nonce_field( $action, $nonce_name, false, false ); + + ob_start(); + \wp_nonce_field( $action, $nonce_name, true, true ); + $result['nonce_echo'] = (string) ob_get_clean(); + + $result['selected_match'] = \selected( $action, $action, false ); + $result['selected_miss'] = \selected( $action, $action . '-miss', false ); + + ob_start(); + \selected( $action, $action, true ); + $result['selected_echo'] = (string) ob_get_clean(); + + ob_start(); + \submit_button( + $label, + 'primary large', + $submit_name, + false, + array( + 'id' => $button_id, + 'data-cfz' => $label, + ) + ); + $result['submit_button'] = (string) ob_get_clean(); + } finally { + while ( ob_get_level() > $buffer_level ) { + ob_end_clean(); + } + self::restore_server( $server_snapshot ); + } + + self::collect_failure( + $failures, + is_string( $result['nonce_with_referer'] ?? null ) + && str_contains( $result['nonce_with_referer'], 'name="' . $nonce_name . '"' ) + && str_contains( $result['nonce_with_referer'], 'name="_wp_http_referer"' ) + && is_string( $result['nonce_without_referer'] ?? null ) + && str_contains( $result['nonce_without_referer'], 'name="' . $nonce_name . '"' ) + && ! str_contains( $result['nonce_without_referer'], '_wp_http_referer' ) + && $result['nonce_echo'] === $result['nonce_with_referer'] + && self::html_has_no_unsafe_raw_markup( $result['nonce_with_referer'] ), + 'nonce fields include custom names, optional referer fields, and escaped referer state', + array( + 'withReferer' => $result['nonce_with_referer'] ?? null, + 'withoutReferer' => $result['nonce_without_referer'] ?? null, + 'echo' => $result['nonce_echo'] ?? null, + ) + ); + + self::collect_failure( + $failures, + " selected='selected'" === ( $result['selected_match'] ?? null ) + && '' === ( $result['selected_miss'] ?? null ) + && ( $result['selected_echo'] ?? null ) === ( $result['selected_match'] ?? null ), + 'selected helper returns and echoes only exact-match selection attributes', + array( + 'match' => $result['selected_match'] ?? null, + 'miss' => $result['selected_miss'] ?? null, + 'echo' => $result['selected_echo'] ?? null, + ) + ); + + self::collect_failure( + $failures, + is_string( $result['submit_button'] ?? null ) + && str_contains( $result['submit_button'], 'type="submit"' ) + && str_contains( $result['submit_button'], 'name="' . $submit_name . '"' ) + && str_contains( $result['submit_button'], 'id="' . $button_id . '"' ) + && str_contains( $result['submit_button'], 'class="button button-primary button-large"' ) + && str_contains( $result['submit_button'], 'data-cfz=' ) + && self::html_has_no_unsafe_raw_markup( $result['submit_button'] ), + 'submit_button renders escaped generated labels, ids, names, classes, and custom attributes', + array( 'submitButton' => $result['submit_button'] ?? null ) + ); + + self::collect_failure( + $failures, + self::server_matches( $server_snapshot, array( 'REQUEST_URI' ) ), + 'admin form control helper request URI state is restored', + array( 'requestUri' => $_SERVER['REQUEST_URI'] ?? null ) + ); + + return self::row( + $ctx, + 'admin-workflows.form-controls.nonce-selected-submit', + array() === $failures, + array( + 'action' => $action, + 'failures' => array_slice( $failures, 0, 6 ), + ) + ); + } + + private static function check_settings_api_rendering_and_errors( \ComponentFuzz\FuzzContext $ctx ): array { + $failures = array(); + $local_snapshot = self::snapshot_globals( + array( + '_GET', + 'wp_settings_sections', + 'wp_settings_fields', + 'wp_settings_errors', + ) + ); + $page = 'cfz_settings_' . \sanitize_key( $ctx->identifier( 3, 8 ) ); + $section = 'cfz_section_' . \sanitize_key( $ctx->fork( 'section' )->identifier( 3, 8 ) ); + $field = 'cfz_field_' . \sanitize_key( $ctx->fork( 'field' )->identifier( 3, 8 ) ); + $group = 'cfz_group_' . \sanitize_key( $ctx->fork( 'group' )->identifier( 3, 8 ) ); + $label_for = 'cfz-input-' . \sanitize_key( $ctx->fork( 'label-for' )->identifier( 3, 8 ) ); + $row_class = 'cfz-row-' . \sanitize_key( $ctx->fork( 'row-class' )->identifier( 3, 8 ) ); + $section_class = 'cfz-section-' . \sanitize_key( $ctx->fork( 'section-class' )->identifier( 3, 8 ) ); + $hostile = self::hostile_label( $ctx->fork( 'hostile' ) ); + $safe_title = \esc_html( 'Settings ' . $hostile ); + $safe_field = \esc_html( 'Field ' . $hostile ); + $safe_message = \esc_html( 'Message ' . $hostile ); + $section_calls = array(); + $field_calls = array(); + $result = array(); + $restored = false; + + $section_callback = static function ( array $section_args ) use ( &$section_calls, $hostile ): void { + $section_calls[] = $section_args; + echo '

    ' . \esc_html( 'Section callback ' . $hostile ) . '

    '; + }; + $field_callback = static function ( array $field_args ) use ( &$field_calls, $hostile ): void { + $field_calls[] = $field_args; + echo ''; + }; + + try { + $GLOBALS['wp_settings_sections'] = array(); + $GLOBALS['wp_settings_fields'] = array(); + $GLOBALS['wp_settings_errors'] = array(); + $_GET = array(); + + \add_settings_section( + $section, + $safe_title, + $section_callback, + $page, + array( + 'before_section' => '
    ', + 'after_section' => '
    ', + 'section_class' => $section_class, + ) + ); + \add_settings_field( + $field, + $safe_field, + $field_callback, + $page, + $section, + array( + 'label_for' => $label_for, + 'class' => $row_class, + 'cfz_payload' => $hostile, + ) + ); + + $result['sections'] = self::capture_output( static fn() => \do_settings_sections( $page ) ); + $result['fields'] = self::capture_output( static fn() => \do_settings_fields( $page, $section ) ); + $result['unknownPage'] = self::capture_output( static fn() => \do_settings_sections( $page . '-missing' ) ); + $result['unknownFields'] = self::capture_output( static fn() => \do_settings_fields( $page, $section . '-missing' ) ); + $result['settingsFields'] = self::capture_output( static fn() => \settings_fields( $group ) ); + $result['sectionCalls'] = $section_calls; + $result['fieldCalls'] = $field_calls; + + $setting_a = 'cfz_setting_' . \sanitize_key( $ctx->fork( 'setting-a' )->identifier( 3, 8 ) ); + $setting_b = 'cfz_setting_' . \sanitize_key( $ctx->fork( 'setting-b' )->identifier( 3, 8 ) ); + $code_a = 'cfz_code_' . \sanitize_key( $ctx->fork( 'code-a' )->identifier( 3, 8 ) ); + $code_b = 'cfz_code_' . \sanitize_key( $ctx->fork( 'code-b' )->identifier( 3, 8 ) ); + $code_c = 'cfz_code_' . \sanitize_key( $ctx->fork( 'code-c' )->identifier( 3, 8 ) ); + + \add_settings_error( $setting_a, $code_a, $safe_message . ' success', 'updated' ); + \add_settings_error( $setting_a, $code_b, $safe_message . ' warning', 'warning' ); + \add_settings_error( $setting_b, $code_c, $safe_message . ' info', 'info' ); + + $result['allErrors'] = \get_settings_errors(); + $result['settingAErrors'] = \get_settings_errors( $setting_a ); + $result['settingBErrors'] = \get_settings_errors( $setting_b ); + $result['settingAOutput'] = self::capture_output( static fn() => \settings_errors( $setting_a ) ); + $result['settingBOutput'] = self::capture_output( static fn() => \settings_errors( $setting_b ) ); + $_GET['settings-updated'] = '1'; + $result['hiddenOnUpdate'] = self::capture_output( static fn() => \settings_errors( $setting_a, false, true ) ); + unset( $_GET['settings-updated'] ); + $result['settingACodes'] = array_column( $result['settingAErrors'], 'code' ); + $result['settingBCode'] = $result['settingBErrors'][0]['code'] ?? null; + $result['settingACodeA'] = $code_a; + $result['settingACodeB'] = $code_b; + $result['settingBCodeWant'] = $code_c; + } finally { + self::restore_globals( $local_snapshot ); + $restored = self::globals_match( $local_snapshot, array( '_GET', 'wp_settings_sections', 'wp_settings_fields', 'wp_settings_errors' ) ); + } + + self::collect_failure( + $failures, + 1 === count( $result['sectionCalls'] ?? array() ) + && 2 === count( $result['fieldCalls'] ?? array() ) + && $section === ( $result['sectionCalls'][0]['id'] ?? null ) + && $label_for === ( $result['fieldCalls'][0]['label_for'] ?? null ) + && $row_class === ( $result['fieldCalls'][0]['class'] ?? null ), + 'settings sections and fields invoke callbacks with exact generated arguments', + $result + ); + + self::collect_failure( + $failures, + is_string( $result['sections'] ?? null ) + && str_contains( $result['sections'], '' ) + && str_contains( $result['sections'], 'class="cfz-settings-section ' . $section_class . '"' ) + && str_contains( $result['sections'], 'for="' . $label_for . '"' ) + && str_contains( $result['sections'], 'class="' . $row_class . '"' ) + && str_contains( $result['sections'], $safe_title ) + && str_contains( $result['sections'], $safe_field ) + && '' === ( $result['unknownPage'] ?? null ) + && '' === ( $result['unknownFields'] ?? null ) + && self::html_has_no_unsafe_raw_markup( $result['sections'] ), + 'do_settings_sections renders registered section/field markup and unknown pages stay silent', + $result + ); + + self::collect_failure( + $failures, + is_string( $result['fields'] ?? null ) + && str_contains( $result['fields'], '' ) + && str_contains( $result['fields'], 'id="' . $label_for . '"' ) + && is_string( $result['settingsFields'] ?? null ) + && str_contains( $result['settingsFields'], "name='option_page' value='" . $group . "'" ) + && str_contains( $result['settingsFields'], 'name="action" value="update"' ) + && self::html_has_no_unsafe_raw_markup( $result['fields'] . $result['settingsFields'] ), + 'do_settings_fields and settings_fields render expected field rows and hidden option form inputs', + $result + ); + + self::collect_failure( + $failures, + 3 === count( $result['allErrors'] ?? array() ) + && 2 === count( $result['settingAErrors'] ?? array() ) + && 1 === count( $result['settingBErrors'] ?? array() ) + && array( $result['settingACodeA'], $result['settingACodeB'] ) === ( $result['settingACodes'] ?? array() ) + && $result['settingBCodeWant'] === ( $result['settingBCode'] ?? null ) + && str_contains( $result['settingAOutput'] ?? '', 'notice-success' ) + && str_contains( $result['settingAOutput'] ?? '', 'notice-warning' ) + && ! str_contains( $result['settingAOutput'] ?? '', (string) ( $result['settingBCodeWant'] ?? '' ) ) + && str_contains( $result['settingBOutput'] ?? '', 'notice-info' ) + && '' === ( $result['hiddenOnUpdate'] ?? null ) + && self::html_has_no_unsafe_raw_markup( ( $result['settingAOutput'] ?? '' ) . ( $result['settingBOutput'] ?? '' ) ), + 'settings errors filter by setting, map legacy/update types to notice classes, and hide on update', + $result + ); + + self::collect_failure( + $failures, + $restored, + 'settings API globals and request state are restored', + array( 'restored' => $restored ) + ); + + return self::row( + $ctx, + 'admin-workflows.settings-api.sections-fields-errors', + array() === $failures, + array( + 'page' => $page, + 'section' => $section, + 'field' => $field, + 'failures' => array_slice( $failures, 0, 8 ), + ) + ); + } + + private static function check_admin_notice_helpers( \ComponentFuzz\FuzzContext $ctx ): array { + $failures = array(); + $local_snapshot = self::snapshot_globals( array( 'wp_actions', 'wp_current_filter', 'wp_filter', 'wp_filters' ) ); + $hostile = self::hostile_label( $ctx->fork( 'message' ) ); + $message = \esc_html( 'Notice ' . $hostile ); + $type = $ctx->choice( array( 'error', 'success', 'warning', 'info' ) ); + $id = 'cfz-notice-' . \sanitize_key( $ctx->fork( 'id' )->identifier( 3, 8 ) ); + $class = 'cfz-extra-' . \sanitize_key( $ctx->fork( 'class' )->identifier( 3, 8 ) ); + $attr_value = 'attr ' . $hostile; + $paragraph_wrap = $ctx->bool(); + $args = array( + 'type' => $type, + 'dismissible' => true, + 'id' => $id, + 'additional_classes' => array( $class ), + 'attributes' => array( + 'data-cfz' => $attr_value, + 'data-present' => true, + 'data-empty' => '', + ), + 'paragraph_wrap' => $paragraph_wrap, + ); + $args_events = array(); + $markup_events = array(); + $action_events = array(); + $result = array(); + $restored = false; + + $args_filter = static function ( array $filtered_args, string $filtered_message ) use ( &$args_events ): array { + $args_events[] = array( + 'message' => $filtered_message, + 'args' => $filtered_args, + ); + $filtered_args['additional_classes'][] = 'cfz-filtered'; + $filtered_args['attributes']['data-filter'] = 'applied'; + return $filtered_args; + }; + $markup_filter = static function ( string $markup, string $filtered_message, array $filtered_args ) use ( &$markup_events ): string { + $markup_events[] = array( + 'markup' => $markup, + 'message' => $filtered_message, + 'args' => $filtered_args, + ); + return $markup; + }; + $notice_action = static function ( string $action_message, array $action_args ) use ( &$action_events ): void { + $action_events[] = array( + 'message' => $action_message, + 'args' => $action_args, + ); + }; + + \add_filter( 'wp_admin_notice_args', $args_filter, 10, 2 ); + \add_filter( 'wp_admin_notice_markup', $markup_filter, 10, 3 ); + \add_action( 'wp_admin_notice', $notice_action, 10, 2 ); + + try { + $result['getter'] = \wp_get_admin_notice( $message, $args ); + $result['echo'] = self::capture_output( static fn() => \wp_admin_notice( $message, $args ) ); + $result['argsEvents'] = $args_events; + $result['markupEvents'] = $markup_events; + $result['actionEvents'] = $action_events; + } finally { + \remove_action( 'wp_admin_notice', $notice_action, 10 ); + \remove_filter( 'wp_admin_notice_markup', $markup_filter, 10 ); + \remove_filter( 'wp_admin_notice_args', $args_filter, 10 ); + self::restore_globals( $local_snapshot ); + $restored = self::globals_match( $local_snapshot, array( 'wp_actions', 'wp_current_filter', 'wp_filter', 'wp_filters' ) ); + } + + $combined = ( $result['getter'] ?? '' ) . ( $result['echo'] ?? '' ); + self::collect_failure( + $failures, + is_string( $result['getter'] ?? null ) + && $result['getter'] === ( $result['echo'] ?? null ) + && str_contains( $result['getter'], 'id="' . $id . '"' ) + && str_contains( $result['getter'], 'class="notice notice-' . $type . ' is-dismissible ' . $class . ' cfz-filtered"' ) + && str_contains( $result['getter'], 'data-cfz="' . \esc_attr( $attr_value ) . '"' ) + && str_contains( $result['getter'], 'data-present' ) + && str_contains( $result['getter'], 'data-filter="applied"' ) + && ! str_contains( $result['getter'], 'data-empty' ) + && self::html_has_no_unsafe_raw_markup( $combined ), + 'admin notice getter and echo output match with escaped id/classes/attributes and filtered args', + $result + ); + + self::collect_failure( + $failures, + ( $paragraph_wrap && str_contains( $result['getter'] ?? '', '

    ' . $message . '

    ' ) ) + || ( ! $paragraph_wrap && str_contains( $result['getter'] ?? '', $message ) && ! str_contains( $result['getter'] ?? '', '

    ' . $message . '

    ' ) ), + 'admin notice paragraph wrapping follows generated argument', + array( + 'paragraphWrap' => $paragraph_wrap, + 'getter' => $result['getter'] ?? null, + ) + ); + + self::collect_failure( + $failures, + 2 === count( $result['argsEvents'] ?? array() ) + && 2 === count( $result['markupEvents'] ?? array() ) + && 1 === count( $result['actionEvents'] ?? array() ) + && $message === ( $result['actionEvents'][0]['message'] ?? null ) + && false === \has_filter( 'wp_admin_notice_args', $args_filter ) + && false === \has_filter( 'wp_admin_notice_markup', $markup_filter ) + && false === \has_action( 'wp_admin_notice', $notice_action ), + 'admin notice filters/actions receive payloads and are removed after capture', + $result + ); + + self::collect_failure( + $failures, + $restored, + 'admin notice hook globals are restored', + array( 'restored' => $restored ) + ); + + return self::row( + $ctx, + 'admin-workflows.notices.getter-echo-filters-actions', + array() === $failures, + array( + 'type' => $type, + 'id' => $id, + 'paragraphWrap' => $paragraph_wrap, + 'failures' => array_slice( $failures, 0, 8 ), + ) + ); + } + + private static function check_core_list_table_coverage_accounting( \ComponentFuzz\FuzzContext $ctx ): array { + $admin_list_surface = AdminListTablesSurface::NAME; + $privacy_surface = PrivacyAdminRequestsSurface::NAME; + + return self::row( + $ctx, + 'admin-workflows.list-table.scoped-coverage-accounted', + class_exists( AdminListTablesSurface::class ) && class_exists( PrivacyAdminRequestsSurface::class ), + array( + 'retired_skip' => 'admin-workflows.list-table.core-subclasses-skipped', + 'admin_workflows_direct_coverage' => array( + 'synthetic WP_List_Table rendering', + 'columns, hidden columns, sortable columns, and primary column filters', + 'views and tablenav output', + 'bulk controls and row actions', + 'current action and month dropdown helpers', + 'referer and form-control helpers', + 'request/filter/global restoration', + ), + 'dedicated_surface_coverage' => array( + $admin_list_surface => array( + 'WP_Posts_List_Table', + 'WP_Media_List_Table', + 'WP_Terms_List_Table', + 'WP_Users_List_Table', + 'WP_Comments_List_Table', + 'WP_Plugins_List_Table', + 'WP_Themes_List_Table', + 'WP_Plugin_Install_List_Table', + 'WP_Theme_Install_List_Table', + 'WP_Application_Passwords_List_Table', + 'WP_MS_Themes_List_Table', + 'WP_MS_Sites_List_Table', + 'WP_MS_Users_List_Table', + ), + $privacy_surface => array( + 'WP_Privacy_Data_Export_Requests_List_Table', + 'WP_Privacy_Data_Removal_Requests_List_Table', + ), + ), + 'not_claimed' => array( + 'full admin.php/admin-ajax.php request dispatch', + 'destructive plugin/theme lifecycle operations', + 'real uploads', + 'true multisite write paths', + 'direct WP_Links_List_Table subclass-specific coverage', + 'direct WP_Post_Comments_List_Table subclass-specific coverage', + ), + ) + ); + } + + private static function check_exiting_ajax_wrappers( \ComponentFuzz\FuzzContext $ctx ): array { + $failures = array(); + $local_snapshot = self::snapshot_globals( array( '_GET', '_POST', '_REQUEST', '_COOKIE' ) ); + $server_snapshot = self::snapshot_server( array( 'REQUEST_METHOD', 'REQUEST_URI', 'PHP_SELF' ) ); + $token = \sanitize_key( $ctx->identifier( 3, 8 ) ); + $date_format = 'Y-m-d \D\a\t\e "' . $token . '" '; + $time_format = 'H:i \T\i\m\e "' . $token . '" '; + $cases = array( + 'date' => array( + 'option' => 'date_format', + 'format' => $date_format, + 'function' => 'wp_ajax_date_format', + ), + 'time' => array( + 'option' => 'time_format', + 'format' => $time_format, + 'function' => 'wp_ajax_time_format', + ), + ); + $results = array(); + $restored = false; + + try { + $_SERVER['REQUEST_METHOD'] = 'POST'; + $_SERVER['REQUEST_URI'] = '/wp-admin/admin-ajax.php'; + $_SERVER['PHP_SELF'] = '/wp-admin/admin-ajax.php'; + + foreach ( $cases as $name => $case ) { + $_GET = array(); + $_COOKIE = array(); + $_POST = array( 'date' => addslashes( $case['format'] ) ); + $_REQUEST = $_POST; + + $capture = self::capture_ajax_call( + static function () use ( $case ): void { + $case['function'](); + } + ); + + $results[ $name ] = array( + 'capture' => $capture, + 'expected' => \date_i18n( \sanitize_option( $case['option'], \wp_unslash( $_POST['date'] ) ) ), + 'sanitized_format' => \sanitize_option( $case['option'], \wp_unslash( $_POST['date'] ) ), + 'raw_format' => $case['format'], + ); + } + } finally { + self::restore_globals( $local_snapshot ); + self::restore_server( $server_snapshot ); + $restored = self::globals_match( $local_snapshot, array( '_GET', '_POST', '_REQUEST', '_COOKIE' ) ) + && self::server_matches( $server_snapshot, array( 'REQUEST_METHOD', 'REQUEST_URI', 'PHP_SELF' ) ); + } + + foreach ( $results as $name => $result ) { + $body = self::ajax_body( $result['capture'] ?? array() ); + self::collect_failure( + $failures, + ! empty( $result['capture']['captured'] ) + && null === ( $result['capture']['threw'] ?? null ) + && ! empty( $result['capture']['bufferBalanced'] ) + && ! empty( $result['capture']['filtersRestored'] ) + && $body === ( $result['expected'] ?? null ) + && ! str_contains( strtolower( $body ), ' $result ) + ); + } + + self::collect_failure( + $failures, + $restored + && self::all_ajax_captures_restored( array_column( $results, 'capture' ) ), + 'date/time AJAX wrapper coverage restores request globals, server globals, filters, and output buffers', + array( + 'restored' => $restored, + 'results' => $results, + ) + ); + + return self::row( + $ctx, + 'admin-workflows.ajax.date-time-format-wrappers', + array() === $failures, + array( + 'token' => $token, + 'failures' => array_slice( $failures, 0, 6 ), + ) + ); + } + + private static function new_synthetic_list_table( array $config ): \WP_List_Table { + return new class( $config ) extends \WP_List_Table { + private array $config; + + public function __construct( array $config ) { + $this->config = $config; + parent::__construct( + array( + 'ajax' => false, + 'plural' => $config['plural'], + 'screen' => $config['screen'], + 'singular' => $config['singular'], + ) + ); + } + + public function get_columns(): array { + return $this->config['columns']; + } + + public function prepare_items(): void { + $this->items = $this->config['items']; + $this->set_pagination_args( $this->config['pagination'] ); + } + + protected function get_sortable_columns(): array { + return $this->config['sortable']; + } + + protected function get_bulk_actions(): array { + return $this->config['bulk_actions']; + } + + protected function get_views(): array { + return $this->config['views']; + } + + protected function column_cb( $item ): string { + return ''; + } + + public function column_title( $item ): string { + return '' . \esc_html( $item['title'] ) . ''; + } + + public function column_status( $item ): string { + return '' . \esc_html( $item['status'] ) . ''; + } + + protected function column_default( $item, $column_name ): string { + return \esc_html( (string) ( $item[ $column_name ] ?? '' ) ); + } + + protected function handle_row_actions( $item, $column_name, $primary ): string { + if ( $column_name !== $primary ) { + return ''; + } + + $actions = array( + 'edit' => '' . \esc_html__( 'Edit' ) . '', + 'delete' => '' . \esc_html__( 'Delete' ) . '', + ); + + return $this->row_actions( $actions, true ); + } + + protected function extra_tablenav( $which ): void { + echo '
    '; + echo ''; + echo ''; + echo '
    '; + } + }; + } + + private static function new_action_matrix_list_table( array $config ): \WP_List_Table { + return new class( $config ) extends \WP_List_Table { + private array $config; + + public function __construct( array $config ) { + $this->config = $config; + parent::__construct( + array( + 'ajax' => false, + 'plural' => 'cfz_matrix_items', + 'screen' => $config['screen'], + 'singular' => 'cfz_matrix_item', + ) + ); + } + + public function get_columns(): array { + return array( + 'cb' => '' . \esc_html__( 'Select item' ) . '', + 'title' => \esc_html__( 'Title' ), + 'status' => \esc_html__( 'Status' ), + ); + } + + public function prepare_items(): void { + $this->items = $this->config['items']; + $this->set_pagination_args( + array( + 'per_page' => 2, + 'total_items' => count( $this->items ), + ) + ); + } + + protected function get_bulk_actions(): array { + $actions = array(); + + if ( \current_user_can( $this->config['bulk_cap'] ) ) { + $actions[ $this->config['top_action'] ] = \esc_html__( 'Feature' ); + } + + if ( \current_user_can( $this->config['delete_cap'] ) ) { + $actions[ 'Danger ' . $this->config['delete_action'] ] = array( + $this->config['delete_action'] => \esc_html__( 'Delete permanently' ), + ); + } + + return $actions; + } + + protected function get_views(): array { + return $this->get_views_links( $this->config['view_links'] ); + } + + protected function column_cb( $item ): string { + return ''; + } + + public function column_title( $item ): string { + return '' . \esc_html( $item['title'] ) . ''; + } + + public function column_status( $item ): string { + return '' . \esc_html( $item['status'] ) . ''; + } + + protected function column_default( $item, $column_name ): string { + return \esc_html( (string) ( $item[ $column_name ] ?? '' ) ); + } + + protected function handle_row_actions( $item, $column_name, $primary ): string { + if ( $column_name !== $primary ) { + return ''; + } + + $actions = array( + 'view' => '' . \esc_html__( 'View' ) . '', + ); + + if ( \current_user_can( $this->config['bulk_cap'] ) ) { + $actions['edit'] = '' . \esc_html__( 'Edit' ) . ''; + } + + if ( \current_user_can( $this->config['delete_cap'] ) ) { + $actions['delete'] = '' . \esc_html__( 'Delete' ) . ''; + } + + return $this->row_actions( $actions, true ); + } + + public function expose_bulk_actions( string $which ): string { + $level = ob_get_level(); + ob_start(); + try { + $this->bulk_actions( $which ); + return (string) ob_get_clean(); + } catch ( \Throwable $e ) { + while ( ob_get_level() > $level ) { + ob_end_clean(); + } + throw $e; + } + } + + public function expose_display_rows(): string { + $level = ob_get_level(); + ob_start(); + try { + $this->display_rows(); + return (string) ob_get_clean(); + } catch ( \Throwable $e ) { + while ( ob_get_level() > $level ) { + ob_end_clean(); + } + throw $e; + } + } + + public function expose_views(): string { + $level = ob_get_level(); + ob_start(); + try { + $this->views(); + return (string) ob_get_clean(); + } catch ( \Throwable $e ) { + while ( ob_get_level() > $level ) { + ob_end_clean(); + } + throw $e; + } + } + + public function expose_display_tablenav( string $which ): string { + $level = ob_get_level(); + ob_start(); + try { + $this->display_tablenav( $which ); + return (string) ob_get_clean(); + } catch ( \Throwable $e ) { + while ( ob_get_level() > $level ) { + ob_end_clean(); + } + throw $e; + } + } + }; + } + + private static function new_helper_list_table( array $config ): \WP_List_Table { + return new class( $config ) extends \WP_List_Table { + private array $config; + + public function __construct( array $config ) { + $this->config = $config; + parent::__construct( + array( + 'ajax' => false, + 'plural' => 'cfz_helper_items', + 'screen' => $config['screen'], + 'singular' => 'cfz_helper_item', + ) + ); + } + + protected function get_bulk_actions(): array { + return $this->config['bulk_actions']; + } + + public function expose_bulk_actions( string $which ): string { + $level = ob_get_level(); + ob_start(); + try { + $this->bulk_actions( $which ); + return (string) ob_get_clean(); + } catch ( \Throwable $e ) { + while ( ob_get_level() > $level ) { + ob_end_clean(); + } + throw $e; + } + } + + public function expose_months_dropdown( string $post_type ): string { + $level = ob_get_level(); + ob_start(); + try { + $this->months_dropdown( $post_type ); + return (string) ob_get_clean(); + } catch ( \Throwable $e ) { + while ( ob_get_level() > $level ) { + ob_end_clean(); + } + throw $e; + } + } + }; + } + + private static function with_capabilities( array $capabilities, callable $callback ) { + $cap_filter = static function ( array $allcaps ) use ( $capabilities ): array { + foreach ( $capabilities as $capability ) { + $allcaps[ $capability ] = true; + } + + return $allcaps; + }; + + \add_filter( 'user_has_cap', $cap_filter, 10, 4 ); + try { + return $callback(); + } finally { + \remove_filter( 'user_has_cap', $cap_filter, 10 ); + } + } + + private static function reset_menu_globals(): void { + $GLOBALS['menu'] = array(); + $GLOBALS['submenu'] = array(); + $GLOBALS['admin_page_hooks'] = array(); + $GLOBALS['_registered_pages'] = array(); + $GLOBALS['_parent_pages'] = array(); + $GLOBALS['_wp_real_parent_file'] = array(); + $GLOBALS['_wp_menu_nopriv'] = array(); + $GLOBALS['_wp_submenu_nopriv'] = array(); + $GLOBALS['parent_file'] = ''; + $GLOBALS['plugin_page'] = null; + $GLOBALS['pagenow'] = 'admin.php'; + $GLOBALS['typenow'] = ''; + $GLOBALS['title'] = ''; + } + + private static function menu_global_names(): array { + return array( + '_GET', + '_POST', + '_REQUEST', + 'admin_page_hooks', + 'current_user', + 'hook_suffix', + 'menu', + 'pagenow', + 'parent_file', + 'plugin_page', + 'submenu', + 'title', + 'typenow', + 'wp_actions', + 'wp_current_filter', + 'wp_filter', + 'wp_filters', + '_parent_pages', + '_registered_pages', + '_wp_menu_nopriv', + '_wp_real_parent_file', + '_wp_submenu_nopriv', + ); + } + + private static function find_menu_entry( string $slug ): ?array { + foreach ( (array) ( $GLOBALS['menu'] ?? array() ) as $entry ) { + if ( isset( $entry[2] ) && $slug === $entry[2] ) { + return $entry; + } + } + + return null; + } + + private static function find_menu_position( string $slug ) { + foreach ( (array) ( $GLOBALS['menu'] ?? array() ) as $position => $entry ) { + if ( isset( $entry[2] ) && $slug === $entry[2] ) { + return $position; + } + } + + return false; + } + + private static function find_submenu_entry( string $parent_slug, string $slug ): ?array { + foreach ( (array) ( $GLOBALS['submenu'][ $parent_slug ] ?? array() ) as $entry ) { + if ( isset( $entry[2] ) && $slug === $entry[2] ) { + return $entry; + } + } + + return null; + } + + private static function view_link( string $url, string $label, bool $current ): string { + return sprintf( + '%s', + \esc_url( $url ), + $current ? ' class="current" aria-current="page"' : '', + \esc_html( $label ) + ); + } + + private static function capability( \ComponentFuzz\FuzzContext $ctx, string $prefix ): string { + return \sanitize_key( 'cfz_' . $prefix . '_' . substr( hash( 'crc32b', (string) $ctx->seed() ), 0, 8 ) ); + } + + private static function menu_slug( \ComponentFuzz\FuzzContext $ctx, string $prefix ): string { + return \sanitize_key( 'cfz-' . $prefix . '-' . substr( hash( 'crc32b', (string) $ctx->seed() ), 0, 8 ) ); + } + + private static function screen_id( \ComponentFuzz\FuzzContext $ctx ): string { + return \sanitize_key( 'cfz-list-' . substr( hash( 'crc32b', (string) $ctx->seed() ), 0, 8 ) ); + } + + private static function hostile_label( \ComponentFuzz\FuzzContext $ctx ): string { + return 'label "' . $ctx->identifier( 3, 8 ) . '" onclick="bad" & value'; + } + + private static function html_has_no_unsafe_raw_markup( string $html ): bool { + $lower = strtolower( $html ); + + return ! str_contains( $lower, ' $label, + 'details' => self::describe_value( $details ), + ); + } + + private static function capture_output( callable $callback ): string { + $level = ob_get_level(); + ob_start(); + + try { + $callback(); + return (string) ob_get_clean(); + } catch ( \Throwable $e ) { + while ( ob_get_level() > $level ) { + ob_end_clean(); + } + + throw $e; + } + } + + private static function capture_ajax_call( callable $callback ): array { + $die_calls = array(); + $doing_ajax = static fn (): bool => true; + $handler_filter = static function () use ( &$die_calls ): callable { + return static function ( $message = '', $title = '', $args = array() ) use ( &$die_calls ): void { + $die_calls[] = array( + 'message' => $message, + 'title' => $title, + 'args' => is_array( $args ) ? $args : array( 'raw' => $args ), + ); + throw new AdminWorkflowsSurface_DieCaptured( 'Captured ajax wp_die.' ); + }; + }; + + $level = ob_get_level(); + $output = ''; + $captured = false; + $threw = null; + + if ( ! headers_sent() ) { + header_remove(); + } + + \add_filter( 'wp_doing_ajax', $doing_ajax, 1 ); + \add_filter( 'wp_die_ajax_handler', $handler_filter, 1 ); + ob_start(); + try { + $callback(); + } catch ( AdminWorkflowsSurface_DieCaptured $e ) { + $captured = true; + } catch ( \Throwable $e ) { + $threw = self::describe_throwable( $e ); + } finally { + $output = (string) ob_get_clean(); + while ( ob_get_level() > $level ) { + ob_end_clean(); + } + \remove_filter( 'wp_die_ajax_handler', $handler_filter, 1 ); + \remove_filter( 'wp_doing_ajax', $doing_ajax, 1 ); + if ( ! headers_sent() ) { + header_remove(); + } + } + + return array( + 'captured' => $captured, + 'threw' => $threw, + 'output' => $output, + 'dieCalls' => $die_calls, + 'filtersRestored' => false === \has_filter( 'wp_die_ajax_handler', $handler_filter ) + && false === \has_filter( 'wp_doing_ajax', $doing_ajax ), + 'bufferBalanced' => ob_get_level() === $level, + ); + } + + private static function ajax_body( array $capture ): string { + $output = (string) ( $capture['output'] ?? '' ); + if ( '' !== $output ) { + return $output; + } + + $message = $capture['dieCalls'][0]['message'] ?? ''; + return is_scalar( $message ) ? (string) $message : gettype( $message ); + } + + private static function all_ajax_captures_restored( array $captures ): bool { + foreach ( $captures as $capture ) { + if ( + empty( $capture['captured'] ) + || ! empty( $capture['threw'] ) + || empty( $capture['bufferBalanced'] ) + || empty( $capture['filtersRestored'] ) + ) { + return false; + } + } + + return true; + } + + private static function row( + \ComponentFuzz\FuzzContext $ctx, + string $invariant, + bool $ok, + array $data = array(), + ?string $status = null + ): array { + return array( + 'ok' => $ok, + 'status' => $status ?? ( $ok ? 'passed' : 'failed' ), + 'surface' => self::NAME, + 'invariant' => $invariant, + 'seed' => $ctx->seed(), + 'iteration' => $ctx->iteration(), + 'data' => self::describe_value( $data ), + ); + } + + private static function describe_value( $value, int $depth = 0 ) { + if ( is_string( $value ) ) { + return self::describe_string( $value ); + } + + if ( is_array( $value ) ) { + if ( $depth >= 4 ) { + return array( + 'type' => 'array', + 'count' => count( $value ), + ); + } + + $out = array(); + $i = 0; + foreach ( $value as $key => $item ) { + if ( $i >= 16 ) { + $out['...'] = count( $value ) - $i; + break; + } + $out[ is_int( $key ) ? $key : self::escape_bytes( (string) $key ) ] = self::describe_value( $item, $depth + 1 ); + ++$i; + } + return $out; + } + + if ( is_object( $value ) ) { + if ( $value instanceof \Throwable ) { + return self::describe_throwable( $value ); + } + + return array( + 'type' => 'object', + 'class' => get_class( $value ), + ); + } + + return $value; + } + + private static function describe_string( string $value ): array { + return array( + 'type' => 'string', + 'bytes' => strlen( $value ), + 'sha1' => sha1( $value ), + 'preview' => self::escape_bytes( $value ), + ); + } + + private static function describe_throwable( \Throwable $e ): array { + return array( + 'class' => get_class( $e ), + 'message' => self::escape_bytes( $e->getMessage() ), + 'file' => $e->getFile(), + 'line' => $e->getLine(), + ); + } + + private static function escape_bytes( string $value, int $limit = self::PREVIEW_BYTES ): string { + $out = ''; + $length = strlen( $value ); + $shown = min( $length, $limit ); + + for ( $i = 0; $i < $shown; ++$i ) { + $byte = ord( $value[ $i ] ); + if ( 0x5C === $byte ) { + $out .= '\\\\'; + } elseif ( $byte >= 0x20 && $byte <= 0x7E ) { + $out .= chr( $byte ); + } elseif ( 0x0A === $byte ) { + $out .= '\\n'; + } elseif ( 0x0D === $byte ) { + $out .= '\\r'; + } elseif ( 0x09 === $byte ) { + $out .= '\\t'; + } else { + $out .= sprintf( '\\x%02X', $byte ); + } + } + + if ( $length > $shown ) { + $out .= '...'; + } + + return $out; + } + + private static function snapshot_state(): array { + $snapshot = array( + 'globals' => self::snapshot_globals( + array( + '_COOKIE', + '_GET', + '_POST', + '_REQUEST', + 'admin_page_hooks', + 'current_screen', + 'current_user', + 'hook_suffix', + 'menu', + 'pagenow', + 'parent_file', + 'plugin_page', + 'submenu', + 'title', + 'typenow', + 'wp_actions', + 'wp_current_filter', + 'wp_filter', + 'wp_filters', + 'wp_settings_errors', + 'wp_settings_fields', + 'wp_settings_sections', + '_parent_pages', + '_registered_pages', + '_wp_menu_nopriv', + '_wp_real_parent_file', + '_wp_submenu_nopriv', + ) + ), + 'options' => null, + ); + + if ( isset( $GLOBALS['wpdb'] ) && method_exists( $GLOBALS['wpdb'], 'component_fuzz_get_options' ) ) { + $snapshot['options'] = $GLOBALS['wpdb']->component_fuzz_get_options(); + } + + return $snapshot; + } + + private static function restore_state( array $snapshot ): void { + if ( null !== $snapshot['options'] && isset( $GLOBALS['wpdb'] ) && method_exists( $GLOBALS['wpdb'], 'component_fuzz_reset_options' ) ) { + $GLOBALS['wpdb']->component_fuzz_reset_options( $snapshot['options'] ); + } + + self::restore_globals( $snapshot['globals'] ); + } + + private static function snapshot_globals( array $names ): array { + $snapshot = array(); + + foreach ( $names as $name ) { + $snapshot[ $name ] = array( + 'exists' => array_key_exists( $name, $GLOBALS ), + 'value' => array_key_exists( $name, $GLOBALS ) ? self::clone_value( $GLOBALS[ $name ] ) : null, + ); + } + + return $snapshot; + } + + private static function restore_globals( array $snapshot ): void { + foreach ( $snapshot as $name => $entry ) { + if ( $entry['exists'] ) { + $GLOBALS[ $name ] = self::clone_value( $entry['value'] ); + } else { + unset( $GLOBALS[ $name ] ); + } + } + } + + private static function globals_match( array $snapshot, array $names ): bool { + foreach ( $names as $name ) { + $exists = array_key_exists( $name, $GLOBALS ); + if ( $exists !== $snapshot[ $name ]['exists'] ) { + return false; + } + if ( $exists && $GLOBALS[ $name ] != $snapshot[ $name ]['value'] ) { + return false; + } + } + + return true; + } + + private static function snapshot_server( array $names ): array { + $snapshot = array(); + + foreach ( $names as $name ) { + $snapshot[ $name ] = array( + 'exists' => array_key_exists( $name, $_SERVER ), + 'value' => $_SERVER[ $name ] ?? null, + ); + } + + return $snapshot; + } + + private static function restore_server( array $snapshot ): void { + foreach ( $snapshot as $name => $entry ) { + if ( $entry['exists'] ) { + $_SERVER[ $name ] = $entry['value']; + } else { + unset( $_SERVER[ $name ] ); + } + } + } + + private static function server_matches( array $snapshot, array $names ): bool { + foreach ( $names as $name ) { + $exists = array_key_exists( $name, $_SERVER ); + if ( $exists !== $snapshot[ $name ]['exists'] ) { + return false; + } + if ( $exists && $_SERVER[ $name ] !== $snapshot[ $name ]['value'] ) { + return false; + } + } + + return true; + } + + private static function clone_value( $value ) { + if ( is_array( $value ) ) { + $copy = array(); + foreach ( $value as $key => $item ) { + $copy[ $key ] = self::clone_value( $item ); + } + return $copy; + } + + if ( is_object( $value ) ) { + return clone $value; + } + + return $value; + } +} + +final class AdminWorkflowsSurface_DieCaptured extends \RuntimeException {} diff --git a/tools/component-fuzz/surfaces/AiClientSurface.php b/tools/component-fuzz/surfaces/AiClientSurface.php new file mode 100644 index 0000000000000..0afb2ca476ac2 --- /dev/null +++ b/tools/component-fuzz/surfaces/AiClientSurface.php @@ -0,0 +1,2879 @@ +skip( + 'ai-client.bootstrap-apis-available', + 'Required WordPress AI Client APIs are unavailable.', + array( 'missing' => $missing ) + ), + ); + } + + $snapshot = self::snapshot_state(); + $rows = array(); + + try { + AiClientSurface_FakeProvider::reset(); + + $rows[] = self::check_dto_round_trips( $ctx ); + $rows[] = self::check_invalid_value_rejection( $ctx ); + $rows[] = self::check_enum_strictness( $ctx ); + $rows[] = self::check_provider_registry_isolation( $ctx ); + $rows[] = self::check_model_selection_preferences_and_provider_collisions( $ctx ); + $rows[] = self::check_prompt_builder_and_events( $ctx ); + $rows[] = self::check_ability_resolver_integration( $ctx ); + $rows[] = self::check_cache_and_dispatcher_adapters( $ctx ); + $rows[] = self::check_http_transport_bridge( $ctx ); + } catch ( \Throwable $e ) { + $rows[] = $ctx->fail( + 'ai-client.surface-no-throw', + array( 'throwable' => self::describe_throwable( $e ) ) + ); + } finally { + self::restore_state( $snapshot ); + AiClientSurface_FakeProvider::reset(); + } + + return $rows; + } + + private static function missing_requirements(): array { + $missing = array(); + + foreach ( + array( + 'WordPress\AiClient\AiClient', + 'WordPress\AiClient\Messages\DTO\Message', + 'WordPress\AiClient\Messages\DTO\MessagePart', + 'WordPress\AiClient\Files\DTO\File', + 'WordPress\AiClient\Tools\DTO\FunctionCall', + 'WordPress\AiClient\Tools\DTO\FunctionDeclaration', + 'WordPress\AiClient\Tools\DTO\FunctionResponse', + 'WordPress\AiClient\Providers\ProviderRegistry', + 'WordPress\AiClient\Providers\Http\HttpTransporter', + 'WordPress\AiClient\Providers\Http\DTO\Request', + 'WordPress\AiClient\Providers\Http\DTO\RequestOptions', + 'WordPress\AiClient\Providers\Http\DTO\Response', + 'WordPress\AiClient\Providers\Http\Enums\HttpMethodEnum', + 'WordPress\AiClient\Providers\Http\Exception\NetworkException', + 'WordPress\AiClient\Providers\Models\DTO\ModelConfig', + 'WordPress\AiClient\Results\DTO\GenerativeAiResult', + 'WordPress\AiClientDependencies\Http\Discovery\ClassDiscovery', + 'WordPress\AiClientDependencies\Http\Discovery\Psr18ClientDiscovery', + 'WordPress\AiClientDependencies\Nyholm\Psr7\Factory\Psr17Factory', + 'WP_AI_Client_Ability_Function_Resolver', + 'WP_AI_Client_Cache', + 'WP_AI_Client_Discovery_Strategy', + 'WP_AI_Client_Event_Dispatcher', + 'WP_AI_Client_HTTP_Client', + 'WP_AI_Client_Prompt_Builder', + 'WP_Ability', + 'WP_Abilities_Registry', + 'WP_Ability_Categories_Registry', + 'WP_Error', + ) as $class + ) { + if ( ! class_exists( $class ) ) { + $missing[] = "class {$class}"; + } + } + + foreach ( + array( + 'WordPress\AiClient\Providers\Http\Contracts\ClientWithOptionsInterface', + 'WordPress\AiClientDependencies\Psr\Http\Client\ClientInterface', + 'WordPress\AiClientDependencies\Psr\Http\Message\RequestFactoryInterface', + 'WordPress\AiClientDependencies\Psr\Http\Message\ResponseFactoryInterface', + 'WordPress\AiClientDependencies\Psr\Http\Message\StreamFactoryInterface', + ) as $interface + ) { + if ( ! interface_exists( $interface ) ) { + $missing[] = "interface {$interface}"; + } + } + + foreach ( + array( + '__', + 'add_action', + 'add_filter', + 'apply_filters', + 'do_action', + 'is_wp_error', + 'remove_action', + 'remove_filter', + 'wp_ai_client_prompt', + 'wp_cache_get', + 'wp_cache_set', + 'wp_cache_delete', + 'wp_cache_get_multiple', + 'wp_cache_set_multiple', + 'wp_cache_delete_multiple', + 'wp_cache_supports', + 'wp_remote_retrieve_body', + 'wp_remote_retrieve_headers', + 'wp_remote_retrieve_response_code', + 'wp_remote_retrieve_response_message', + 'wp_safe_remote_request', + 'wp_get_ability', + 'wp_register_ability', + 'wp_register_ability_category', + 'wp_prepare_json_schema_for_client', + 'wp_supports_ai', + ) as $function + ) { + if ( ! function_exists( $function ) ) { + $missing[] = "function {$function}"; + } + } + + return $missing; + } + + private static function check_dto_round_trips( \ComponentFuzz\FuzzContext $ctx ): array { + $failures = array(); + $cases = self::dto_cases( $ctx->fork( 'dto-cases' ) ); + + foreach ( $cases as $case ) { + $class = $case['class']; + + try { + $first = $class::fromArray( $case['input'] ); + $canonical = $first->toArray(); + $second = $class::fromArray( $canonical ); + $round_trip = $second->toArray(); + $json = json_decode( + wp_json_encode( + $first, + JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PRESERVE_ZERO_FRACTION + ), + true + ); + + self::collect_failure( + $failures, + self::same_value( $canonical, $round_trip ), + "{$case['label']} canonical fromArray/toArray is idempotent", + array( + 'canonical' => $canonical, + 'roundTrip' => $round_trip, + ) + ); + + self::collect_failure( + $failures, + self::same_value( $canonical, $json ), + "{$case['label']} json serialization matches canonical array", + array( + 'canonical' => $canonical, + 'json' => $json, + ) + ); + + self::collect_failure( + $failures, + true === $class::isArrayShape( $canonical ), + "{$case['label']} reports its canonical array as valid shape", + array( 'canonical' => $canonical ) + ); + + foreach ( $case['canonicalKeys'] as $key ) { + self::collect_failure( + $failures, + array_key_exists( $key, $canonical ), + "{$case['label']} canonical array includes {$key}", + array( 'canonical' => $canonical ) + ); + } + + $schema = $class::getJsonSchema(); + foreach ( $case['schemaRequiredSets'] as $required_set ) { + self::collect_failure( + $failures, + self::schema_declares_required_set( $schema, $required_set ), + "{$case['label']} schema declares required keys", + array( + 'requiredSet' => $required_set, + 'schema' => $schema, + ) + ); + } + } catch ( \Throwable $e ) { + $failures[] = array( + 'label' => "{$case['label']} round trip threw", + 'throwable' => self::describe_throwable( $e ), + 'input' => $case['input'], + ); + } + } + + return self::result( + $ctx, + 'ai-client.dto-round-trips-and-schema-required-keys', + array() === $failures, + array( + 'cases' => count( $cases ), + 'failures' => array_slice( $failures, 0, 8 ), + ) + ); + } + + private static function check_invalid_value_rejection( \ComponentFuzz\FuzzContext $ctx ): array { + $failures = array(); + $invalids = array( + 'message.invalid-role' => static function (): void { + \WordPress\AiClient\Messages\DTO\Message::fromArray( + array( + 'role' => 'administrator', + 'parts' => array(), + ) + ); + }, + 'message-part.missing-content' => static function (): void { + \WordPress\AiClient\Messages\DTO\MessagePart::fromArray( + array( + 'channel' => 'content', + 'type' => 'text', + ) + ); + }, + 'user-message.function-call' => static function (): void { + new \WordPress\AiClient\Messages\DTO\UserMessage( + array( + new \WordPress\AiClient\Messages\DTO\MessagePart( + new \WordPress\AiClient\Tools\DTO\FunctionCall( 'call', 'tool', array() ) + ), + ) + ); + }, + 'model-message.function-response' => static function (): void { + new \WordPress\AiClient\Messages\DTO\ModelMessage( + array( + new \WordPress\AiClient\Messages\DTO\MessagePart( + new \WordPress\AiClient\Tools\DTO\FunctionResponse( 'call', 'tool', array() ) + ), + ) + ); + }, + 'candidate.user-message' => static function (): void { + new \WordPress\AiClient\Results\DTO\Candidate( + new \WordPress\AiClient\Messages\DTO\UserMessage( + array( new \WordPress\AiClient\Messages\DTO\MessagePart( 'not a model reply' ) ) + ), + \WordPress\AiClient\Results\Enums\FinishReasonEnum::stop() + ); + }, + 'file.base64-without-mime' => static function () use ( $ctx ): void { + new \WordPress\AiClient\Files\DTO\File( base64_encode( $ctx->fork( 'bad-file' )->ascii( 3, 8 ) ) ); + }, + 'function-call.no-id-or-name' => static function (): void { + new \WordPress\AiClient\Tools\DTO\FunctionCall(); + }, + 'function-response.no-id-or-name' => static function (): void { + \WordPress\AiClient\Tools\DTO\FunctionResponse::fromArray( array( 'response' => true ) ); + }, + 'provider.invalid-id' => static function (): void { + new \WordPress\AiClient\Providers\DTO\ProviderMetadata( + 'Bad Provider', + 'Bad', + \WordPress\AiClient\Providers\Enums\ProviderTypeEnum::server() + ); + }, + 'model-config.bad-aspect-ratio' => static function (): void { + \WordPress\AiClient\Providers\Models\DTO\ModelConfig::fromArray( + array( + 'outputMediaOrientation' => 'square', + 'outputMediaAspectRatio' => '16:9', + ) + ); + }, + 'model-config.non-list-modalities' => static function (): void { + $config = new \WordPress\AiClient\Providers\Models\DTO\ModelConfig(); + $config->setOutputModalities( + array( 'text' => \WordPress\AiClient\Messages\Enums\ModalityEnum::text() ) + ); + }, + 'supported-option.non-list-values' => static function (): void { + new \WordPress\AiClient\Providers\Models\DTO\SupportedOption( + \WordPress\AiClient\Providers\Models\Enums\OptionEnum::temperature(), + array( 'low' => 0.1 ) + ); + }, + 'model-metadata.non-list-capabilities' => static function (): void { + new \WordPress\AiClient\Providers\Models\DTO\ModelMetadata( + 'bad', + 'Bad', + array( 'text' => \WordPress\AiClient\Providers\Models\Enums\CapabilityEnum::textGeneration() ), + array() + ); + }, + 'result.empty-candidates' => static function (): void { + new \WordPress\AiClient\Results\DTO\GenerativeAiResult( + 'empty', + array(), + new \WordPress\AiClient\Results\DTO\TokenUsage( 1, 1, 2 ), + AiClientSurface_FakeProvider::metadata(), + AiClientSurface_FakeProvider::modelMetadata() + ); + }, + 'operation.succeeded-without-result' => static function (): void { + \WordPress\AiClient\Operations\DTO\GenerativeAiOperation::fromArray( + array( + 'id' => 'operation', + 'state' => 'succeeded', + ) + ); + }, + 'request-options.negative-timeout' => static function (): void { + \WordPress\AiClient\Providers\Http\DTO\RequestOptions::fromArray( array( 'timeout' => -0.1 ) ); + }, + 'request-options.negative-redirects' => static function (): void { + \WordPress\AiClient\Providers\Http\DTO\RequestOptions::fromArray( array( 'maxRedirects' => -1 ) ); + }, + 'request.invalid-method' => static function (): void { + \WordPress\AiClient\Providers\Http\DTO\Request::fromArray( + array( + 'method' => 'FETCH', + 'uri' => 'https://example.test/api', + 'headers' => array(), + ) + ); + }, + 'response.invalid-status' => static function (): void { + \WordPress\AiClient\Providers\Http\DTO\Response::fromArray( + array( + 'statusCode' => 99, + 'headers' => array(), + ) + ); + }, + 'registry.invalid-provider-class' => static function (): void { + $registry = new \WordPress\AiClient\Providers\ProviderRegistry(); + $registry->registerProvider( \stdClass::class ); + }, + ); + + foreach ( $invalids as $label => $callback ) { + self::collect_failure( + $failures, + self::throws( $callback ), + "{$label} rejects invalid input", + array( 'label' => $label ) + ); + } + + return self::result( + $ctx, + 'ai-client.invalid-inputs-are-rejected', + array() === $failures, + array( + 'cases' => count( $invalids ), + 'failures' => array_slice( $failures, 0, 8 ), + ) + ); + } + + private static function check_enum_strictness( \ComponentFuzz\FuzzContext $ctx ): array { + $failures = array(); + $enum_classes = array( + 'WordPress\AiClient\Files\Enums\FileTypeEnum', + 'WordPress\AiClient\Files\Enums\MediaOrientationEnum', + 'WordPress\AiClient\Messages\Enums\MessagePartChannelEnum', + 'WordPress\AiClient\Messages\Enums\MessagePartTypeEnum', + 'WordPress\AiClient\Messages\Enums\MessageRoleEnum', + 'WordPress\AiClient\Messages\Enums\ModalityEnum', + 'WordPress\AiClient\Operations\Enums\OperationStateEnum', + 'WordPress\AiClient\Providers\Enums\ProviderTypeEnum', + 'WordPress\AiClient\Providers\Enums\ToolTypeEnum', + 'WordPress\AiClient\Providers\Http\Enums\HttpMethodEnum', + 'WordPress\AiClient\Providers\Http\Enums\RequestAuthenticationMethod', + 'WordPress\AiClient\Providers\Models\Enums\CapabilityEnum', + 'WordPress\AiClient\Providers\Models\Enums\OptionEnum', + 'WordPress\AiClient\Results\Enums\FinishReasonEnum', + ); + + foreach ( $enum_classes as $class ) { + $values = $class::getValues(); + $cases = $class::cases(); + + self::collect_failure( + $failures, + array() !== $values && count( $values ) === count( $cases ), + "{$class} values and cases agree", + array( + 'values' => $values, + 'cases' => count( $cases ), + ) + ); + + foreach ( $cases as $case ) { + self::collect_failure( + $failures, + $class::from( $case->value ) === $case + && $class::tryFrom( $case->value ) === $case + && true === $class::isValidValue( $case->value ) + && $case->equals( $case->value ) + && (string) $case === $case->value + && json_decode( wp_json_encode( $case ), true ) === $case->value, + "{$class} case {$case->name} is strict and serializable", + array( + 'name' => $case->name, + 'value' => $case->value, + ) + ); + } + + $invalid = '__invalid_' . dechex( $ctx->seed() ) . '_' . str_replace( '\\', '_', strtolower( $class ) ); + self::collect_failure( + $failures, + null === $class::tryFrom( $invalid ) + && false === $class::isValidValue( $invalid ) + && self::throws( static fn() => $class::from( $invalid ) ), + "{$class} rejects invalid backing values", + array( 'invalid' => $invalid ) + ); + } + + self::collect_failure( + $failures, + \WordPress\AiClient\Providers\Models\Enums\OptionEnum::maxTokens()->value + === \WordPress\AiClient\Providers\Models\DTO\ModelConfig::KEY_MAX_TOKENS + && \WordPress\AiClient\Providers\Models\Enums\OptionEnum::maxTokens()->isMaxTokens(), + 'OptionEnum dynamically exposes ModelConfig KEY_* constants', + array( + 'maxTokens' => \WordPress\AiClient\Providers\Models\Enums\OptionEnum::maxTokens()->value, + ) + ); + + return self::result( + $ctx, + 'ai-client.enums-are-strict-singleton-values', + array() === $failures, + array( + 'classes' => count( $enum_classes ), + 'failures' => array_slice( $failures, 0, 8 ), + ) + ); + } + + private static function check_provider_registry_isolation( \ComponentFuzz\FuzzContext $ctx ): array { + $failures = array(); + + self::set_static_property( \WordPress\AiClient\AiClient::class, 'defaultRegistry', null ); + AiClientSurface_FakeProvider::set_configured( true ); + + $registry_a = new \WordPress\AiClient\Providers\ProviderRegistry(); + $registry_b = new \WordPress\AiClient\Providers\ProviderRegistry(); + + $registry_a->registerProvider( AiClientSurface_FakeProvider::class ); + + $default_registry_1 = \WordPress\AiClient\AiClient::defaultRegistry(); + $default_registry_2 = \WordPress\AiClient\AiClient::defaultRegistry(); + $default_registry_1->registerProvider( AiClientSurface_FakeProvider::class ); + + $requirements = new \WordPress\AiClient\Providers\Models\DTO\ModelRequirements( + array( \WordPress\AiClient\Providers\Models\Enums\CapabilityEnum::textGeneration() ), + array() + ); + + $model = $registry_a->getProviderModel( self::PROVIDER_ID, self::MODEL_ID, self::model_config( $ctx ) ); + $matches = $registry_a->findModelsMetadataForSupport( $requirements ); + $configured = $registry_a->isProviderConfigured( self::PROVIDER_ID ); + + AiClientSurface_FakeProvider::set_configured( false ); + $unconfigured_matches = $registry_a->findModelsMetadataForSupport( $requirements ); + $unconfigured = $registry_a->isProviderConfigured( self::PROVIDER_ID ); + AiClientSurface_FakeProvider::set_configured( true ); + + self::collect_failure( + $failures, + $registry_a->hasProvider( self::PROVIDER_ID ) + && $registry_a->hasProvider( AiClientSurface_FakeProvider::class ) + && AiClientSurface_FakeProvider::class === $registry_a->getProviderClassName( self::PROVIDER_ID ) + && self::PROVIDER_ID === $registry_a->getProviderId( AiClientSurface_FakeProvider::class ) + && array( self::PROVIDER_ID ) === $registry_a->getRegisteredProviderIds(), + 'registered provider is discoverable by ID and class', + array( 'ids' => $registry_a->getRegisteredProviderIds() ) + ); + + self::collect_failure( + $failures, + ! $registry_b->hasProvider( self::PROVIDER_ID ) + && self::throws( static fn() => $registry_b->getProviderClassName( self::PROVIDER_ID ) ), + 'separate ProviderRegistry instances do not share registered providers', + array( 'registryBIds' => $registry_b->getRegisteredProviderIds() ) + ); + + self::collect_failure( + $failures, + $default_registry_1 === $default_registry_2 + && $default_registry_1 !== $registry_a + && $default_registry_1->hasProvider( self::PROVIDER_ID ) + && ! $registry_b->hasProvider( self::PROVIDER_ID ), + 'AiClient default registry is singleton state but isolated from custom registries', + array( + 'defaultIds' => $default_registry_1->getRegisteredProviderIds(), + 'customIds' => $registry_b->getRegisteredProviderIds(), + ) + ); + + self::collect_failure( + $failures, + $model instanceof AiClientSurface_FakeTextModel + && self::same_value( self::model_config( $ctx )->toArray(), $model->getConfig()->toArray() ), + 'registry returns configured model instances without network dependencies', + array( + 'modelClass' => is_object( $model ) ? get_class( $model ) : gettype( $model ), + 'config' => $model instanceof AiClientSurface_FakeTextModel ? $model->getConfig()->toArray() : null, + ) + ); + + self::collect_failure( + $failures, + $configured + && ! $unconfigured + && 1 === count( $matches ) + && array() === $unconfigured_matches, + 'provider availability gates metadata lookup without mutating registration', + array( + 'matches' => count( $matches ), + 'unconfiguredMatches' => count( $unconfigured_matches ), + 'stillRegisteredAfter' => $registry_a->hasProvider( self::PROVIDER_ID ), + ) + ); + + return self::result( + $ctx, + 'ai-client.provider-registry-registration-lookup-and-isolation', + array() === $failures, + array( 'failures' => array_slice( $failures, 0, 8 ) ) + ); + } + + private static function check_model_selection_preferences_and_provider_collisions( + \ComponentFuzz\FuzzContext $ctx + ): array { + $failures = array(); + $case = $ctx->fork( 'model-selection' ); + $registry = new \WordPress\AiClient\Providers\ProviderRegistry(); + + $registry->registerProvider( AiClientSurface_CollisionProviderA::class ); + $registry->registerProvider( AiClientSurface_CollisionProviderB::class ); + + $reversed_registry = new \WordPress\AiClient\Providers\ProviderRegistry(); + $reversed_registry->registerProvider( AiClientSurface_CollisionProviderB::class ); + $reversed_registry->registerProvider( AiClientSurface_CollisionProviderA::class ); + + $prompt = 'Choose model ' . self::safe_text( $case->fork( 'prompt' ), 5, 24 ); + $history = new \WordPress\AiClient\Messages\DTO\UserMessage( + array( + new \WordPress\AiClient\Messages\DTO\MessagePart( + 'History ' . self::safe_text( $case->fork( 'history' ), 4, 18 ) + ), + ) + ); + + $model_only = ( new \WP_AI_Client_Prompt_Builder( $registry, $prompt ) ) + ->with_history( $history ) + ->using_model_preference( self::COLLISION_SHARED_MODEL ) + ->generate_text_result(); + + $provider_tuple = ( new \WP_AI_Client_Prompt_Builder( $registry, $prompt ) ) + ->with_history( $history ) + ->using_model_preference( + array( self::COLLISION_PROVIDER_B_ID, self::COLLISION_SHARED_MODEL ) + ) + ->generate_text_result(); + + $model_instance = AiClientSurface_CollisionProviderB::model( + self::COLLISION_SHARED_MODEL, + new \WordPress\AiClient\Providers\Models\DTO\ModelConfig() + ); + $model_instance_preference = ( new \WP_AI_Client_Prompt_Builder( $registry, $prompt ) ) + ->with_history( $history ) + ->using_model_preference( $model_instance ) + ->generate_text_result(); + + $provider_locked_by_id = ( new \WP_AI_Client_Prompt_Builder( $registry, $prompt ) ) + ->with_history( $history ) + ->using_provider( self::COLLISION_PROVIDER_B_ID ) + ->using_model_preference( self::COLLISION_SHARED_MODEL ) + ->generate_text_result(); + + $provider_locked_by_class = ( new \WP_AI_Client_Prompt_Builder( $registry, $prompt ) ) + ->with_history( $history ) + ->using_provider( AiClientSurface_CollisionProviderB::class ) + ->using_model_preference( self::COLLISION_SHARED_MODEL ) + ->generate_text_result(); + + $fallback = ( new \WP_AI_Client_Prompt_Builder( $registry, $prompt ) ) + ->with_history( $history ) + ->using_model_preference( + 'missing-' . self::slug_piece( $case->fork( 'missing' ), 'model' ), + array( self::COLLISION_PROVIDER_B_ID, self::COLLISION_B_ONLY_MODEL ) + ) + ->generate_text_result(); + + $discovery_order = ( new \WP_AI_Client_Prompt_Builder( $registry, $prompt ) ) + ->with_history( $history ) + ->generate_text_result(); + + $reversed_model_only = ( new \WP_AI_Client_Prompt_Builder( $reversed_registry, $prompt ) ) + ->with_history( $history ) + ->using_model_preference( self::COLLISION_SHARED_MODEL ) + ->generate_text_result(); + + self::collect_failure( + $failures, + self::result_selects_provider_model( + $model_only, + self::COLLISION_PROVIDER_A_ID, + self::COLLISION_SHARED_MODEL + ), + 'model-only preference for shared model preserves first registered provider', + array( 'selection' => self::describe_ai_result_selection( $model_only ) ) + ); + + self::collect_failure( + $failures, + self::result_selects_provider_model( + $provider_tuple, + self::COLLISION_PROVIDER_B_ID, + self::COLLISION_SHARED_MODEL + ), + 'provider/model tuple preference overrides shared model collision order', + array( 'selection' => self::describe_ai_result_selection( $provider_tuple ) ) + ); + + self::collect_failure( + $failures, + self::result_selects_provider_model( + $model_instance_preference, + self::COLLISION_PROVIDER_B_ID, + self::COLLISION_SHARED_MODEL + ), + 'model-instance preference carries its provider/model pair through collisions', + array( 'selection' => self::describe_ai_result_selection( $model_instance_preference ) ) + ); + + self::collect_failure( + $failures, + self::result_selects_provider_model( + $provider_locked_by_id, + self::COLLISION_PROVIDER_B_ID, + self::COLLISION_SHARED_MODEL + ), + 'provider lock by ID narrows shared model lookup to that provider', + array( 'selection' => self::describe_ai_result_selection( $provider_locked_by_id ) ) + ); + + self::collect_failure( + $failures, + self::result_selects_provider_model( + $provider_locked_by_class, + self::COLLISION_PROVIDER_B_ID, + self::COLLISION_SHARED_MODEL + ), + 'provider lock by class narrows shared model lookup to that provider', + array( 'selection' => self::describe_ai_result_selection( $provider_locked_by_class ) ) + ); + + self::collect_failure( + $failures, + self::result_selects_provider_model( + $fallback, + self::COLLISION_PROVIDER_B_ID, + self::COLLISION_B_ONLY_MODEL + ), + 'missing model preference falls through to the first matching later preference', + array( 'selection' => self::describe_ai_result_selection( $fallback ) ) + ); + + self::collect_failure( + $failures, + self::result_selects_provider_model( + $discovery_order, + self::COLLISION_PROVIDER_A_ID, + self::COLLISION_SHARED_MODEL + ), + 'no model preference falls back to first matching provider/model discovery order', + array( 'selection' => self::describe_ai_result_selection( $discovery_order ) ) + ); + + self::collect_failure( + $failures, + self::result_selects_provider_model( + $reversed_model_only, + self::COLLISION_PROVIDER_B_ID, + self::COLLISION_SHARED_MODEL + ), + 'reversed registration order changes the model-only collision winner', + array( 'selection' => self::describe_ai_result_selection( $reversed_model_only ) ) + ); + + return self::result( + $ctx, + 'ai-client.model-selection-preferences-and-provider-collisions', + array() === $failures, + array( 'failures' => array_slice( $failures, 0, 8 ) ) + ); + } + + private static function check_prompt_builder_and_events( \ComponentFuzz\FuzzContext $ctx ): array { + $failures = array(); + $registry = new \WordPress\AiClient\Providers\ProviderRegistry(); + $registry->registerProvider( AiClientSurface_FakeProvider::class ); + + $events = array( + 'before' => array(), + 'after' => array(), + ); + + $before_listener = static function ( object $event ) use ( &$events ): void { + $events['before'][] = get_class( $event ); + }; + $after_listener = static function ( object $event ) use ( &$events ): void { + $events['after'][] = get_class( $event ); + }; + + \WordPress\AiClient\AiClient::setEventDispatcher( new \WP_AI_Client_Event_Dispatcher() ); + \add_action( 'wp_ai_client_before_generate_result', $before_listener ); + \add_action( 'wp_ai_client_after_generate_result', $after_listener ); + + try { + $prompt = 'Summarize ' . self::safe_text( $ctx->fork( 'prompt' ), 6, 20 ); + $result = ( new \WP_AI_Client_Prompt_Builder( $registry, $prompt ) ) + ->using_provider( AiClientSurface_FakeProvider::class ) + ->using_model_preference( self::MODEL_ID ) + ->using_temperature( self::small_float( $ctx->fork( 'temperature' ), 0, 20 ) ) + ->using_max_tokens( $ctx->fork( 'tokens' )->int( 8, 64 ) ) + ->using_system_instruction( 'System ' . self::safe_text( $ctx->fork( 'system' ), 4, 16 ) ) + ->as_json_response( self::simple_json_schema() ) + ->generate_text_result(); + } finally { + \remove_action( 'wp_ai_client_before_generate_result', $before_listener ); + \remove_action( 'wp_ai_client_after_generate_result', $after_listener ); + } + + self::collect_failure( + $failures, + $result instanceof \WordPress\AiClient\Results\DTO\GenerativeAiResult + && 1 === $result->getCandidateCount() + && str_starts_with( $result->toText(), 'component-fuzz:' ), + 'WordPress prompt builder proxies snake_case configuration and generates with fake model', + array( + 'result' => $result instanceof \WordPress\AiClient\Results\DTO\GenerativeAiResult + ? $result->toArray() + : self::describe_value( $result ), + ) + ); + + self::collect_failure( + $failures, + array( 'WordPress\AiClient\Events\BeforeGenerateResultEvent' ) === $events['before'] + && array( 'WordPress\AiClient\Events\AfterGenerateResultEvent' ) === $events['after'], + 'WordPress event dispatcher maps SDK events to expected action hooks once', + array( 'events' => $events ) + ); + + $bad_builder = new \WP_AI_Client_Prompt_Builder( $registry, '' ); + $chain_after_error = $bad_builder->with_text( 'ignored after constructor error' ); + $supported_after_error = $bad_builder->is_supported_for_text_generation(); + $error_after_error = $bad_builder->generate_text_result(); + + self::collect_failure( + $failures, + $chain_after_error === $bad_builder + && false === $supported_after_error + && \is_wp_error( $error_after_error ) + && 'prompt_invalid_argument' === $error_after_error->get_error_code(), + 'WP prompt builder preserves error state for fluent and terminal calls', + array( + 'supported' => $supported_after_error, + 'error' => self::describe_error( $error_after_error ), + ) + ); + + $disable_filter = static fn(): bool => false; + \add_filter( 'wp_supports_ai', $disable_filter ); + try { + $prevented = ( new \WP_AI_Client_Prompt_Builder( $registry, 'Prevented prompt' ) ) + ->generate_text_result(); + } finally { + \remove_filter( 'wp_supports_ai', $disable_filter ); + } + + self::collect_failure( + $failures, + \is_wp_error( $prevented ) && 'prompt_prevented' === $prevented->get_error_code(), + 'wp_supports_ai filter prevents generation before model execution', + array( 'prevented' => self::describe_error( $prevented ) ) + ); + + return self::result( + $ctx, + 'ai-client.prompt-builder-events-and-wp-error-state', + array() === $failures, + array( 'failures' => array_slice( $failures, 0, 8 ) ) + ); + } + + private static function check_ability_resolver_integration( \ComponentFuzz\FuzzContext $ctx ): array { + $failures = array(); + $case = $ctx->fork( 'ability' ); + $category = self::slug_piece( $case, 'ai-cat' ); + $name = 'cfuzz-ai/' . self::slug_piece( $case->fork( 'ability-name' ), 'resolve' ); + $value = 'value-' . self::slug_piece( $case->fork( 'value' ), 'v' ); + + self::ensure_init_fired(); + self::reset_ability_registries(); + + $input_schema = array( + 'type' => 'object', + 'required' => array( 'value' ), + 'properties' => array( + 'value' => array( 'type' => 'string' ), + ), + ); + $output_schema = array( + 'type' => 'object', + 'required' => array( 'echo', 'ability' ), + 'properties' => array( + 'echo' => array( 'type' => 'string' ), + 'ability' => array( 'type' => 'string' ), + ), + ); + + $category_action = static function () use ( $category ): void { + \wp_register_ability_category( + $category, + array( + 'label' => 'AI Client Fuzz', + 'description' => 'AI client fuzzer abilities.', + ) + ); + }; + $ability_action = static function () use ( $name, $category, $input_schema, $output_schema ): void { + \wp_register_ability( + $name, + array( + 'label' => 'Resolve AI function call', + 'description' => 'Echoes deterministic fuzzer input.', + 'category' => $category, + 'input_schema' => $input_schema, + 'output_schema' => $output_schema, + 'execute_callback' => static fn( array $input ): array => array( + 'echo' => $input['value'], + 'ability' => $name, + ), + 'permission_callback' => static fn(): bool => true, + 'meta' => array( + 'annotations' => array( + 'readonly' => true, + 'idempotent' => true, + ), + ), + ) + ); + }; + + \add_action( 'wp_abilities_api_categories_init', $category_action ); + \add_action( 'wp_abilities_api_init', $ability_action ); + try { + \WP_Ability_Categories_Registry::get_instance(); + \WP_Abilities_Registry::get_instance(); + } finally { + \remove_action( 'wp_abilities_api_categories_init', $category_action ); + \remove_action( 'wp_abilities_api_init', $ability_action ); + } + + $ability = \wp_get_ability( $name ); + $function_name = \WP_AI_Client_Ability_Function_Resolver::ability_name_to_function_name( $name ); + $call = new \WordPress\AiClient\Tools\DTO\FunctionCall( + 'call-' . $case->iteration(), + $function_name, + array( 'value' => $value ) + ); + $resolver = new \WP_AI_Client_Ability_Function_Resolver( $ability ); + $response = $resolver->execute_ability( $call ); + $denied = ( new \WP_AI_Client_Ability_Function_Resolver( 'cfuzz-ai/other' ) )->execute_ability( $call ); + $message = new \WordPress\AiClient\Messages\DTO\ModelMessage( + array( new \WordPress\AiClient\Messages\DTO\MessagePart( $call ) ) + ); + $responses = $resolver->execute_abilities( $message ); + + self::collect_failure( + $failures, + $ability instanceof \WP_Ability + && $function_name === 'wpab__' . str_replace( '/', '__', $name ) + && \WP_AI_Client_Ability_Function_Resolver::function_name_to_ability_name( $function_name ) === $name, + 'ability registration and function-name mapping are reversible', + array( + 'ability' => self::describe_ability( $ability ), + 'functionName' => $function_name, + ) + ); + + self::collect_failure( + $failures, + $resolver->is_ability_call( $call ) + && array( + 'echo' => $value, + 'ability' => $name, + ) === $response->getResponse(), + 'allowed resolver executes registered ability and returns exact response payload', + array( 'response' => $response->toArray() ) + ); + + self::collect_failure( + $failures, + 'ability_not_allowed' === ( $denied->getResponse()['code'] ?? null ), + 'resolver rejects ability calls outside the allowed set', + array( 'denied' => $denied->toArray() ) + ); + + $response_parts = $responses->getParts(); + self::collect_failure( + $failures, + $resolver->has_ability_calls( $message ) + && $responses->getRole()->isUser() + && 1 === count( $response_parts ) + && $response_parts[0]->getFunctionResponse() instanceof \WordPress\AiClient\Tools\DTO\FunctionResponse + && $response_parts[0]->getFunctionResponse()->getResponse()['echo'] === $value, + 'resolver converts model function calls to user function response messages', + array( 'responses' => $responses->toArray() ) + ); + + $builder = new \WP_AI_Client_Prompt_Builder( new \WordPress\AiClient\Providers\ProviderRegistry(), 'Use ability' ); + $same = $builder->using_abilities( $name ); + $config = self::get_wrapped_prompt_builder_model_config( $builder ); + $tools = $config instanceof \WordPress\AiClient\Providers\Models\DTO\ModelConfig + ? $config->getFunctionDeclarations() + : null; + $tool = is_array( $tools ) ? ( $tools[0] ?? null ) : null; + + self::collect_failure( + $failures, + $same === $builder + && $tool instanceof \WordPress\AiClient\Tools\DTO\FunctionDeclaration + && $function_name === $tool->getName() + && $input_schema === $tool->getParameters(), + 'WP prompt builder converts registered abilities into function declarations', + array( + 'tool' => $tool instanceof \WordPress\AiClient\Tools\DTO\FunctionDeclaration ? $tool->toArray() : $tool, + ) + ); + + return self::result( + $ctx, + 'ai-client.ability-function-resolver-and-prompt-builder-integration', + array() === $failures, + array( 'failures' => array_slice( $failures, 0, 8 ) ) + ); + } + + private static function check_cache_and_dispatcher_adapters( \ComponentFuzz\FuzzContext $ctx ): array { + $failures = array(); + $cache = new \WP_AI_Client_Cache(); + $key_a = 'ai-client-' . dechex( $ctx->seed() ) . '-a'; + $key_b = 'ai-client-' . dechex( $ctx->seed() ) . '-b'; + $value_a = array( + 'seed' => $ctx->seed(), + 'text' => self::safe_text( $ctx->fork( 'cache-a' ), 4, 20 ), + 'false' => false, + ); + $value_b = false; + + \WordPress\AiClient\AiClient::setCache( $cache ); + + $missing = $cache->get( $key_a, 'fallback' ); + $set = $cache->setMultiple( + array( + $key_a => $value_a, + $key_b => $value_b, + ) + ); + $multi = $cache->getMultiple( array( $key_a, $key_b, 'missing-' . $key_a ), 'fallback' ); + $has_a = $cache->has( $key_a ); + $delete = $cache->deleteMultiple( array( $key_a, $key_b ) ); + $after = $cache->getMultiple( array( $key_a, $key_b ), 'fallback' ); + + self::collect_failure( + $failures, + $cache === \WordPress\AiClient\AiClient::getCache() + && 'fallback' === $missing + && true === $set + && true === $has_a + && self::same_value( $value_a, $multi[ $key_a ] ?? null ) + && false === ( $multi[ $key_b ] ?? null ) + && 'fallback' === ( $multi[ 'missing-' . $key_a ] ?? null ) + && true === $delete + && array( $key_a => 'fallback', $key_b => 'fallback' ) === $after, + 'WP AI cache adapter preserves PSR-16 get/set/delete semantics including stored false', + array( + 'missing' => $missing, + 'multi' => $multi, + 'after' => $after, + ) + ); + + $clear_key = $key_a . '-clear'; + $cache->set( $clear_key, 'clear-value' ); + $clear = $cache->clear(); + $clear_supported = function_exists( 'wp_cache_supports' ) && \wp_cache_supports( 'flush_group' ); + $after_clear = $cache->get( $clear_key, 'fallback' ); + $cache->delete( $clear_key ); + + self::collect_failure( + $failures, + ( $clear_supported && true === $clear && 'fallback' === $after_clear ) + || ( ! $clear_supported && false === $clear && 'clear-value' === $after_clear ), + 'WP AI cache clear mirrors object-cache flush_group support', + array( + 'supported' => $clear_supported, + 'clear' => $clear, + 'afterClear' => $after_clear, + ) + ); + + $dispatcher = new \WP_AI_Client_Event_Dispatcher(); + $seen = array(); + $listener = static function ( object $event ) use ( &$seen ): void { + $seen[] = $event; + }; + $event = new \stdClass(); + + \WordPress\AiClient\AiClient::setEventDispatcher( $dispatcher ); + \add_action( 'wp_ai_client_std_class', $listener ); + try { + $dispatched = $dispatcher->dispatch( $event ); + } finally { + \remove_action( 'wp_ai_client_std_class', $listener ); + } + + self::collect_failure( + $failures, + $dispatcher === \WordPress\AiClient\AiClient::getEventDispatcher() + && $dispatched === $event + && array( $event ) === $seen, + 'WP AI event dispatcher returns the original event and fires the derived hook', + array( + 'seenCount' => count( $seen ), + 'eventClass' => get_class( $event ), + 'returnedSame' => $dispatched === $event, + ) + ); + + return self::result( + $ctx, + 'ai-client.cache-and-event-dispatcher-adapters-are-in-memory', + array() === $failures, + array( 'failures' => array_slice( $failures, 0, 8 ) ) + ); + } + + private static function check_http_transport_bridge( \ComponentFuzz\FuzzContext $ctx ): array { + $failures = array(); + $case = $ctx->fork( 'http-transport' ); + $factory = new \WordPress\AiClientDependencies\Nyholm\Psr7\Factory\Psr17Factory(); + + $candidates = \WP_AI_Client_Discovery_Strategy::getCandidates( + \WordPress\AiClientDependencies\Psr\Http\Client\ClientInterface::class + ); + $candidate_factory = $candidates[0]['class'] ?? null; + $discovered_client = is_callable( $candidate_factory ) ? $candidate_factory() : null; + $client = $discovered_client instanceof \WP_AI_Client_HTTP_Client + ? $discovered_client + : new \WP_AI_Client_HTTP_Client( $factory, $factory ); + + self::collect_failure( + $failures, + $discovered_client instanceof \WP_AI_Client_HTTP_Client, + 'WP AI client discovery strategy creates the WordPress HTTP client adapter', + array( + 'candidateCount' => count( $candidates ), + 'clientClass' => is_object( $discovered_client ) ? get_class( $discovered_client ) : gettype( $discovered_client ), + ) + ); + + $discovery_state = self::snapshot_class_discovery_state(); + + $discovery_url = 'https://' . self::domain( $case->fork( 'discovery-domain' ) ) . '/discovered'; + $discovery_body = (string) wp_json_encode( array( 'ping' => self::safe_text( $case->fork( 'discovery-body' ), 3, 10 ) ) ); + $discovery_reply = (string) wp_json_encode( array( 'ok' => true, 'source' => 'discovery' ) ); + $discovery_seen = array(); + $discovery_response = null; + $discovery_throwable = null; + $discovery_request = new \WordPress\AiClient\Providers\Http\DTO\Request( + \WordPress\AiClient\Providers\Http\Enums\HttpMethodEnum::from( 'POST' ), + $discovery_url, + array( 'Content-Type' => array( 'application/json' ) ), + $discovery_body + ); + $discovery_filter = static function ( $_pre, array $args, string $url ) use ( &$discovery_seen, $discovery_reply ) { + $discovery_seen[] = array( + 'url' => $url, + 'args' => $args, + ); + + return self::wp_http_response( + 202, + 'Accepted', + array( 'Content-Type' => 'application/json' ), + $discovery_reply + ); + }; + + \add_filter( 'pre_http_request', $discovery_filter, 10, 3 ); + try { + self::restore_class_discovery_state( + array( + 'strategies' => array( \WP_AI_Client_Discovery_Strategy::class ), + 'cache' => array(), + ) + ); + $discovery_transporter = new \WordPress\AiClient\Providers\Http\HttpTransporter( null, $factory, $factory ); + $discovery_response = $discovery_transporter->send( $discovery_request ); + } catch ( \Throwable $e ) { + $discovery_throwable = $e; + } finally { + \remove_filter( 'pre_http_request', $discovery_filter, 10 ); + self::restore_class_discovery_state( $discovery_state ); + } + + $discovery_args = $discovery_seen[0]['args'] ?? array(); + $restored_state = self::snapshot_class_discovery_state(); + self::collect_failure( + $failures, + null === $discovery_throwable + && 1 === count( $discovery_seen ) + && $discovery_url === ( $discovery_seen[0]['url'] ?? null ) + && 'POST' === ( $discovery_args['method'] ?? null ) + && $discovery_body === ( $discovery_args['body'] ?? null ) + && $discovery_response instanceof \WordPress\AiClient\Providers\Http\DTO\Response + && 202 === $discovery_response->getStatusCode() + && $discovery_reply === $discovery_response->getBody() + && $discovery_state === $restored_state, + 'HTTPlug discovery finds the WordPress HTTP client for HttpTransporter', + array( + 'throwable' => $discovery_throwable ? self::describe_throwable( $discovery_throwable ) : null, + 'request' => $discovery_seen[0] ?? null, + 'response' => $discovery_response instanceof \WordPress\AiClient\Providers\Http\DTO\Response + ? $discovery_response->toArray() + : self::describe_value( $discovery_response ), + 'stateRestored' => $discovery_state === $restored_state, + ) + ); + + $direct_url = 'https://' . self::domain( $case->fork( 'direct-domain' ) ) . '/v1/responses'; + $direct_body = (string) wp_json_encode( + array( + 'prompt' => self::safe_text( $case->fork( 'direct-body' ), 6, 24 ), + 'seed' => $ctx->seed(), + ) + ); + $direct_protocol = $case->bool() ? '1.0' : '1.1'; + $direct_timeout = self::small_float( $case->fork( 'direct-timeout' ), 10, 90 ); + $direct_redirection = $case->fork( 'direct-redirection' )->int( 0, 4 ); + $direct_response = null; + $direct_throwable = null; + $direct_requests = array(); + $direct_header_extra = 'branch-' . self::slug_piece( $case->fork( 'direct-header' ), 'branch' ); + $direct_reply_body = (string) wp_json_encode( + array( + 'id' => 'resp-' . self::slug_piece( $case->fork( 'direct-response' ), 'resp' ), + 'output' => array( 'text' => self::safe_text( $case->fork( 'direct-output' ), 4, 18 ) ), + ) + ); + $direct_request = $factory->createRequest( 'PATCH', $direct_url ) + ->withProtocolVersion( $direct_protocol ) + ->withHeader( 'Content-Type', 'application/json' ) + ->withHeader( 'X-Fuzz', array( 'seed-' . $ctx->seed(), $direct_header_extra ) ) + ->withHeader( 'X-Trace', 'trace-' . self::slug_piece( $case->fork( 'direct-trace' ), 'trace' ) ) + ->withBody( $factory->createStream( $direct_body ) ); + $direct_options = \WordPress\AiClient\Providers\Http\DTO\RequestOptions::fromArray( + array( + 'timeout' => $direct_timeout, + 'maxRedirects' => $direct_redirection, + ) + ); + $direct_filter = static function ( $_pre, array $args, string $url ) use ( &$direct_requests, $direct_reply_body ) { + $direct_requests[] = array( + 'url' => $url, + 'args' => $args, + ); + + return self::wp_http_response( + 207, + 'Multi-Status', + array( + 'Content-Type' => 'application/json', + 'X-Reply' => array( 'reply-a', 'reply-b' ), + ), + $direct_reply_body + ); + }; + + \add_filter( 'pre_http_request', $direct_filter, 10, 3 ); + try { + $direct_response = $client->sendRequestWithOptions( $direct_request, $direct_options ); + } catch ( \Throwable $e ) { + $direct_throwable = $e; + } finally { + \remove_filter( 'pre_http_request', $direct_filter, 10 ); + } + + $direct_args = $direct_requests[0]['args'] ?? array(); + self::collect_failure( + $failures, + null === $direct_throwable + && 1 === count( $direct_requests ) + && $direct_url === ( $direct_requests[0]['url'] ?? null ) + && 'PATCH' === ( $direct_args['method'] ?? null ) + && $direct_protocol === ( $direct_args['httpversion'] ?? null ) + && true === ( $direct_args['blocking'] ?? null ) + && $direct_body === ( $direct_args['body'] ?? null ) + && $direct_timeout === ( $direct_args['timeout'] ?? null ) + && $direct_redirection === ( $direct_args['redirection'] ?? null ) + && 'application/json' === ( $direct_args['headers']['Content-Type'] ?? null ) + && 'seed-' . $ctx->seed() . ', ' . $direct_header_extra === ( $direct_args['headers']['X-Fuzz'] ?? null ), + 'WP HTTP client maps PSR-7 request fields and transport options to WordPress HTTP args', + array( + 'throwable' => $direct_throwable ? self::describe_throwable( $direct_throwable ) : null, + 'request' => $direct_requests[0] ?? null, + ) + ); + + self::collect_failure( + $failures, + $direct_response instanceof \WordPress\AiClientDependencies\Psr\Http\Message\ResponseInterface + && 207 === $direct_response->getStatusCode() + && 'Multi-Status' === $direct_response->getReasonPhrase() + && 'application/json' === $direct_response->getHeaderLine( 'Content-Type' ) + && 'reply-a, reply-b' === $direct_response->getHeaderLine( 'X-Reply' ) + && $direct_reply_body === (string) $direct_response->getBody(), + 'WP HTTP client maps WordPress HTTP responses back to PSR-7 responses', + array( + 'status' => $direct_response instanceof \WordPress\AiClientDependencies\Psr\Http\Message\ResponseInterface + ? $direct_response->getStatusCode() + : null, + 'headers' => $direct_response instanceof \WordPress\AiClientDependencies\Psr\Http\Message\ResponseInterface + ? $direct_response->getHeaders() + : null, + 'body' => $direct_response instanceof \WordPress\AiClientDependencies\Psr\Http\Message\ResponseInterface + ? (string) $direct_response->getBody() + : null, + ) + ); + + $body_edge_cases = array( + array( 'label' => 'empty', 'body' => '' ), + array( 'label' => 'zero', 'body' => '0' ), + array( 'label' => 'scalar-json-false', 'body' => 'false' ), + array( 'label' => 'scalar-json-string', 'body' => '"scalar"' ), + ); + $body_edge_failures = array(); + $body_edge_seen = array(); + + foreach ( $body_edge_cases as $edge_case ) { + $edge_seen = array(); + $edge_response = null; + $edge_throwable = null; + $edge_url = 'https://' . self::domain( $case->fork( 'body-edge-' . $edge_case['label'] ) ) . '/edge'; + $edge_request = $factory->createRequest( 'GET', $edge_url ); + $edge_filter = static function ( $_pre, array $args, string $url ) use ( &$edge_seen, $edge_case ) { + $edge_seen[] = array( + 'url' => $url, + 'args' => $args, + ); + + return self::wp_http_response( + 200, + 'OK', + array( 'Content-Type' => 'application/json' ), + $edge_case['body'] + ); + }; + + \add_filter( 'pre_http_request', $edge_filter, 10, 3 ); + try { + $edge_response = $client->sendRequestWithOptions( $edge_request, $direct_options ); + } catch ( \Throwable $e ) { + $edge_throwable = $e; + } finally { + \remove_filter( 'pre_http_request', $edge_filter, 10 ); + } + + $edge_actual = $edge_response instanceof \WordPress\AiClientDependencies\Psr\Http\Message\ResponseInterface + ? (string) $edge_response->getBody() + : null; + $body_edge_seen[] = array( + 'label' => $edge_case['label'], + 'body' => self::describe_value( $edge_case['body'] ), + 'actual' => self::describe_value( $edge_actual ), + 'request' => $edge_seen[0] ?? null, + 'throwable' => $edge_throwable ? self::describe_throwable( $edge_throwable ) : null, + ); + + if ( + null !== $edge_throwable || + 1 !== count( $edge_seen ) || + ! $edge_response instanceof \WordPress\AiClientDependencies\Psr\Http\Message\ResponseInterface || + $edge_case['body'] !== $edge_actual + ) { + $body_edge_failures[] = end( $body_edge_seen ); + } + } + + self::collect_failure( + $failures, + array() === $body_edge_failures, + 'WP HTTP client preserves exact empty and scalar response bodies', + array( + 'cases' => $body_edge_seen, + 'failures' => $body_edge_failures, + ) + ); + + $error_requests = array(); + $error_throwable = null; + $error_filter = static function ( $_pre, array $args, string $url ) use ( &$error_requests ) { + $error_requests[] = array( + 'url' => $url, + 'args' => $args, + ); + + return new \WP_Error( '429', 'Synthetic AI transport timeout' ); + }; + + \add_filter( 'pre_http_request', $error_filter, 10, 3 ); + try { + $client->sendRequestWithOptions( $direct_request, $direct_options ); + } catch ( \Throwable $e ) { + $error_throwable = $e; + } finally { + \remove_filter( 'pre_http_request', $error_filter, 10 ); + } + + self::collect_failure( + $failures, + 1 === count( $error_requests ) + && $error_throwable instanceof \WordPress\AiClient\Providers\Http\Exception\NetworkException + && 429 === $error_throwable->getCode() + && str_contains( $error_throwable->getMessage(), $direct_url ) + && str_contains( $error_throwable->getMessage(), 'Synthetic AI transport timeout' ), + 'WP HTTP client converts WP_Error failures to SDK NetworkException', + array( + 'throwable' => $error_throwable ? self::describe_throwable( $error_throwable ) : null, + 'requests' => $error_requests, + ) + ); + + $request_options = \WordPress\AiClient\Providers\Http\DTO\RequestOptions::fromArray( + array( + 'timeout' => 1.2, + 'connectTimeout' => 0.7, + 'maxRedirects' => 5, + ) + ); + $parameter_timeout = self::small_float( $case->fork( 'parameter-timeout' ), 20, 80 ); + $parameter_options = \WordPress\AiClient\Providers\Http\DTO\RequestOptions::fromArray( + array( + 'timeout' => $parameter_timeout, + 'maxRedirects' => 0, + ) + ); + $transport_url = 'https://' . self::domain( $case->fork( 'transport-domain' ) ) . '/chat/completions'; + $transport_body = (string) wp_json_encode( + array( + 'model' => self::MODEL_ID, + 'input' => self::safe_text( $case->fork( 'transport-body' ), 5, 18 ), + ) + ); + $transport_request = new \WordPress\AiClient\Providers\Http\DTO\Request( + \WordPress\AiClient\Providers\Http\Enums\HttpMethodEnum::from( 'POST' ), + $transport_url, + array( + 'Content-Type' => array( 'application/json' ), + 'Authorization' => array( 'Bearer local-' . self::slug_piece( $case->fork( 'token' ), 'token' ) ), + 'X-Client' => array( 'ai-client-fuzz', 'transport' ), + ), + $transport_body, + $request_options + ); + $transport_reply = (string) wp_json_encode( + array( + 'choices' => array( + array( + 'message' => array( + 'content' => self::safe_text( $case->fork( 'transport-reply' ), 6, 18 ), + ), + ), + ), + ) + ); + $transporter = new \WordPress\AiClient\Providers\Http\HttpTransporter( $client, $factory, $factory ); + $capture_client = new AiClientSurface_CapturingHttpClient( $factory, $factory, 202, 'Accepted', $transport_reply ); + $capture_transport = new \WordPress\AiClient\Providers\Http\HttpTransporter( $capture_client, $factory, $factory ); + $capture_response = null; + $capture_throwable = null; + try { + $capture_response = $capture_transport->send( $transport_request, $parameter_options ); + } catch ( \Throwable $e ) { + $capture_throwable = $e; + } + + $captured_options = $capture_client->last_options(); + $captured_request = $capture_client->last_request(); + self::collect_failure( + $failures, + null === $capture_throwable + && $capture_response instanceof \WordPress\AiClient\Providers\Http\DTO\Response + && 202 === $capture_response->getStatusCode() + && 0 === $capture_client->send_request_count() + && 1 === $capture_client->send_request_with_options_count() + && $captured_request instanceof \WordPress\AiClientDependencies\Psr\Http\Message\RequestInterface + && 'POST' === $captured_request->getMethod() + && $transport_url === (string) $captured_request->getUri() + && $captured_options instanceof \WordPress\AiClient\Providers\Http\DTO\RequestOptions + && $parameter_timeout === $captured_options->getTimeout() + && 0.7 === $captured_options->getConnectTimeout() + && 0 === $captured_options->getMaxRedirects(), + 'HttpTransporter preserves request-only SDK options while parameter options override matching fields', + array( + 'throwable' => $capture_throwable ? self::describe_throwable( $capture_throwable ) : null, + 'request' => $captured_request instanceof \WordPress\AiClientDependencies\Psr\Http\Message\RequestInterface + ? array( + 'method' => $captured_request->getMethod(), + 'uri' => (string) $captured_request->getUri(), + ) + : self::describe_value( $captured_request ), + 'options' => $captured_options instanceof \WordPress\AiClient\Providers\Http\DTO\RequestOptions + ? $captured_options->toArray() + : self::describe_value( $captured_options ), + 'counts' => array( + 'sendRequest' => $capture_client->send_request_count(), + 'sendRequestWithOptions' => $capture_client->send_request_with_options_count(), + ), + ) + ); + + $transport_seen = array(); + $transport_response = null; + $transport_throwable = null; + $transport_filter = static function ( $_pre, array $args, string $url ) use ( &$transport_seen, $transport_reply ) { + $transport_seen[] = array( + 'url' => $url, + 'args' => $args, + ); + + return self::wp_http_response( + 201, + 'Created', + array( + 'Content-Type' => 'application/json', + 'X-SDK' => array( 'mapped', 'response' ), + ), + $transport_reply + ); + }; + + \add_filter( 'pre_http_request', $transport_filter, 10, 3 ); + try { + $transport_response = $transporter->send( $transport_request, $parameter_options ); + } catch ( \Throwable $e ) { + $transport_throwable = $e; + } finally { + \remove_filter( 'pre_http_request', $transport_filter, 10 ); + } + + $transport_args = $transport_seen[0]['args'] ?? array(); + self::collect_failure( + $failures, + null === $transport_throwable + && 1 === count( $transport_seen ) + && $transport_url === ( $transport_seen[0]['url'] ?? null ) + && 'POST' === ( $transport_args['method'] ?? null ) + && '1.1' === ( $transport_args['httpversion'] ?? null ) + && $transport_body === ( $transport_args['body'] ?? null ) + && $parameter_timeout === ( $transport_args['timeout'] ?? null ) + && 0 === ( $transport_args['redirection'] ?? null ) + && ! array_key_exists( 'connect_timeout', $transport_args ) + && ! array_key_exists( 'connectTimeout', $transport_args ) + && 'application/json' === ( $transport_args['headers']['Content-Type'] ?? null ) + && 'ai-client-fuzz, transport' === ( $transport_args['headers']['X-Client'] ?? null ), + 'HttpTransporter maps SDK requests through WP HTTP and lets parameter options override request options', + array( + 'throwable' => $transport_throwable ? self::describe_throwable( $transport_throwable ) : null, + 'request' => $transport_seen[0] ?? null, + ) + ); + + self::collect_failure( + $failures, + $transport_response instanceof \WordPress\AiClient\Providers\Http\DTO\Response + && 201 === $transport_response->getStatusCode() + && $transport_reply === $transport_response->getBody() + && array( 'application/json' ) === self::header_values( $transport_response->getHeaders(), 'Content-Type' ) + && array( 'mapped', 'response' ) === self::header_values( $transport_response->getHeaders(), 'X-SDK' ) + && is_array( $transport_response->getData() ) + && isset( $transport_response->getData()['choices'][0]['message']['content'] ), + 'HttpTransporter maps PSR-7 responses back to SDK response DTOs', + array( + 'response' => $transport_response instanceof \WordPress\AiClient\Providers\Http\DTO\Response + ? $transport_response->toArray() + : self::describe_value( $transport_response ), + ) + ); + + $transport_error_seen = array(); + $transport_error_throwable = null; + $transport_error_filter = static function ( $_pre, array $args, string $url ) use ( &$transport_error_seen ) { + $transport_error_seen[] = array( + 'url' => $url, + 'args' => $args, + ); + + return new \WP_Error( '503', 'Synthetic SDK transport outage' ); + }; + + \add_filter( 'pre_http_request', $transport_error_filter, 10, 3 ); + try { + $transporter->send( $transport_request, $parameter_options ); + } catch ( \Throwable $e ) { + $transport_error_throwable = $e; + } finally { + \remove_filter( 'pre_http_request', $transport_error_filter, 10 ); + } + + $transport_error_request = null; + $transport_error_request_throwable = null; + if ( $transport_error_throwable instanceof \WordPress\AiClient\Providers\Http\Exception\NetworkException ) { + try { + $transport_error_request = $transport_error_throwable->getRequest(); + } catch ( \Throwable $e ) { + $transport_error_request_throwable = $e; + } + } + $transport_error_previous = $transport_error_throwable instanceof \Throwable + ? $transport_error_throwable->getPrevious() + : null; + + self::collect_failure( + $failures, + 1 === count( $transport_error_seen ) + && $transport_error_throwable instanceof \WordPress\AiClient\Providers\Http\Exception\NetworkException + && str_contains( $transport_error_throwable->getMessage(), $transport_url ) + && str_contains( $transport_error_throwable->getMessage(), 'Synthetic SDK transport outage' ) + && $transport_error_previous instanceof \WordPress\AiClient\Providers\Http\Exception\NetworkException + && 503 === $transport_error_previous->getCode() + && null === $transport_error_request_throwable + && $transport_error_request instanceof \WordPress\AiClient\Providers\Http\DTO\Request + && $transport_url === $transport_error_request->getUri() + && 'POST' === $transport_error_request->getMethod()->value, + 'HttpTransporter wraps WP HTTP adapter NetworkException failures with request context', + array( + 'throwable' => $transport_error_throwable ? self::describe_throwable( $transport_error_throwable ) : null, + 'previous' => $transport_error_previous ? self::describe_throwable( $transport_error_previous ) : null, + 'requestThrowable' => $transport_error_request_throwable ? self::describe_throwable( $transport_error_request_throwable ) : null, + 'request' => $transport_error_request instanceof \WordPress\AiClient\Providers\Http\DTO\Request + ? $transport_error_request->toArray() + : self::describe_value( $transport_error_request ), + 'requests' => $transport_error_seen, + ) + ); + + return self::result( + $ctx, + 'ai-client.wp-http-transport-mapping-and-errors', + array() === $failures, + array( + 'failures' => array_slice( $failures, 0, 8 ), + 'requests' => array( + 'discovery' => count( $discovery_seen ), + 'direct' => count( $direct_requests ), + 'bodyEdges' => count( $body_edge_seen ), + 'error' => count( $error_requests ), + 'optionsCapture' => $capture_client->send_request_with_options_count(), + 'transport' => count( $transport_seen ), + 'transportError' => count( $transport_error_seen ), + ), + ) + ); + } + + private static function dto_cases( \ComponentFuzz\FuzzContext $ctx ): array { + $text_part = self::message_part_text_array( $ctx->fork( 'text-part' ) ); + $file_part = self::message_part_file_array( $ctx->fork( 'file-part' ) ); + $function_call = self::function_call_array( $ctx->fork( 'function-call' ) ); + $function_resp = self::function_response_array( $ctx->fork( 'function-response' ) ); + $model_message = self::model_message_array( $ctx->fork( 'model-message' ) ); + $user_message = self::user_message_array( $ctx->fork( 'user-message' ) ); + $provider = self::provider_metadata_array( $ctx->fork( 'provider' ) ); + $supported = self::supported_option_array(); + $model = self::model_metadata_array( $ctx->fork( 'model' ), array( $supported ) ); + $token_usage = self::token_usage_array( $ctx->fork( 'token-usage' ) ); + $candidate = self::candidate_array( $model_message ); + $result = self::result_array( $ctx->fork( 'result' ), $candidate, $token_usage, $provider, $model ); + $operation_state = $ctx->bool() ? 'succeeded' : 'processing'; + $operation = 'succeeded' === $operation_state + ? array( + 'id' => 'op-' . self::slug_piece( $ctx->fork( 'operation' ), 'op' ), + 'state' => 'succeeded', + 'result' => $result, + ) + : array( + 'id' => 'op-' . self::slug_piece( $ctx->fork( 'operation' ), 'op' ), + 'state' => $operation_state, + ); + + return array( + self::dto_case( + 'file.inline', + \WordPress\AiClient\Files\DTO\File::class, + self::file_array( $ctx->fork( 'file' ) ), + array( 'fileType', 'mimeType', 'base64Data' ), + array( array( 'fileType', 'mimeType', 'base64Data' ) ) + ), + self::dto_case( + 'message-part.text', + \WordPress\AiClient\Messages\DTO\MessagePart::class, + $text_part, + array( 'channel', 'type', 'text' ), + array( array( 'type', 'text' ) ) + ), + self::dto_case( + 'message-part.file', + \WordPress\AiClient\Messages\DTO\MessagePart::class, + $file_part, + array( 'channel', 'type', 'file' ), + array( array( 'type', 'file' ) ) + ), + self::dto_case( + 'message.user', + \WordPress\AiClient\Messages\DTO\Message::class, + $user_message, + array( 'role', 'parts' ), + array( array( 'role', 'parts' ) ) + ), + self::dto_case( + 'message.model', + \WordPress\AiClient\Messages\DTO\Message::class, + $model_message, + array( 'role', 'parts' ), + array( array( 'role', 'parts' ) ) + ), + self::dto_case( + 'function-declaration', + \WordPress\AiClient\Tools\DTO\FunctionDeclaration::class, + self::function_declaration_array( $ctx->fork( 'function-declaration' ) ), + array( 'name', 'description', 'parameters' ), + array( array( 'name', 'description' ) ) + ), + self::dto_case( + 'function-call', + \WordPress\AiClient\Tools\DTO\FunctionCall::class, + $function_call, + array( 'id', 'name', 'args' ), + array( array( 'id' ), array( 'name' ) ) + ), + self::dto_case( + 'function-response', + \WordPress\AiClient\Tools\DTO\FunctionResponse::class, + $function_resp, + array( 'id', 'name', 'response' ), + array( array( 'response', 'id' ), array( 'response', 'name' ) ) + ), + self::dto_case( + 'web-search', + \WordPress\AiClient\Tools\DTO\WebSearch::class, + self::web_search_array( $ctx->fork( 'web-search' ) ), + array( 'allowedDomains', 'disallowedDomains' ), + array() + ), + self::dto_case( + 'model-config', + \WordPress\AiClient\Providers\Models\DTO\ModelConfig::class, + self::model_config_array( $ctx->fork( 'model-config' ) ), + array( 'outputModalities', 'systemInstruction', 'outputMimeType', 'outputSchema' ), + array() + ), + self::dto_case( + 'supported-option', + \WordPress\AiClient\Providers\Models\DTO\SupportedOption::class, + $supported, + array( 'name', 'supportedValues' ), + array( array( 'name' ) ) + ), + self::dto_case( + 'required-option', + \WordPress\AiClient\Providers\Models\DTO\RequiredOption::class, + self::required_option_array(), + array( 'name', 'value' ), + array( array( 'name', 'value' ) ) + ), + self::dto_case( + 'model-metadata', + \WordPress\AiClient\Providers\Models\DTO\ModelMetadata::class, + $model, + array( 'id', 'name', 'supportedCapabilities', 'supportedOptions' ), + array( array( 'id', 'name', 'supportedCapabilities', 'supportedOptions' ) ) + ), + self::dto_case( + 'model-requirements', + \WordPress\AiClient\Providers\Models\DTO\ModelRequirements::class, + self::model_requirements_array(), + array( 'requiredCapabilities', 'requiredOptions' ), + array( array( 'requiredCapabilities', 'requiredOptions' ) ) + ), + self::dto_case( + 'provider-metadata', + \WordPress\AiClient\Providers\DTO\ProviderMetadata::class, + $provider, + array( 'id', 'name', 'description', 'type', 'credentialsUrl', 'authenticationMethod', 'logoPath' ), + array( array( 'id', 'name', 'type' ) ) + ), + self::dto_case( + 'provider-models-metadata', + \WordPress\AiClient\Providers\DTO\ProviderModelsMetadata::class, + array( + 'provider' => $provider, + 'models' => array( $model ), + ), + array( 'provider', 'models' ), + array( array( 'provider', 'models' ) ) + ), + self::dto_case( + 'token-usage', + \WordPress\AiClient\Results\DTO\TokenUsage::class, + $token_usage, + array( 'promptTokens', 'completionTokens', 'totalTokens', 'thoughtTokens' ), + array( array( 'promptTokens', 'completionTokens', 'totalTokens' ) ) + ), + self::dto_case( + 'candidate', + \WordPress\AiClient\Results\DTO\Candidate::class, + $candidate, + array( 'message', 'finishReason' ), + array( array( 'message', 'finishReason' ) ) + ), + self::dto_case( + 'generative-result', + \WordPress\AiClient\Results\DTO\GenerativeAiResult::class, + $result, + array( 'id', 'candidates', 'tokenUsage', 'providerMetadata', 'modelMetadata', 'additionalData' ), + array( array( 'id', 'candidates', 'tokenUsage', 'providerMetadata', 'modelMetadata' ) ) + ), + self::dto_case( + 'operation', + \WordPress\AiClient\Operations\DTO\GenerativeAiOperation::class, + $operation, + array_keys( $operation ), + 'succeeded' === $operation_state + ? array( array( 'id', 'state', 'result' ) ) + : array( array( 'id', 'state' ) ) + ), + self::dto_case( + 'request-options', + \WordPress\AiClient\Providers\Http\DTO\RequestOptions::class, + self::request_options_array( $ctx->fork( 'request-options' ) ), + array( 'timeout', 'connectTimeout', 'maxRedirects' ), + array() + ), + self::dto_case( + 'request', + \WordPress\AiClient\Providers\Http\DTO\Request::class, + self::request_array( $ctx->fork( 'request' ) ), + array( 'method', 'uri', 'headers', 'body', 'options' ), + array( array( 'method', 'uri', 'headers' ) ) + ), + self::dto_case( + 'response', + \WordPress\AiClient\Providers\Http\DTO\Response::class, + self::response_array( $ctx->fork( 'response' ) ), + array( 'statusCode', 'headers', 'body' ), + array( array( 'statusCode', 'headers' ) ) + ), + self::dto_case( + 'api-key-auth', + \WordPress\AiClient\Providers\Http\DTO\ApiKeyRequestAuthentication::class, + array( 'apiKey' => 'key-' . self::slug_piece( $ctx->fork( 'api-key' ), 'key' ) ), + array( 'apiKey' ), + array( array( 'apiKey' ) ) + ), + ); + } + + private static function dto_case( + string $label, + string $class, + array $input, + array $canonical_keys, + array $schema_required_sets + ): array { + return array( + 'label' => $label, + 'class' => $class, + 'input' => $input, + 'canonicalKeys' => $canonical_keys, + 'schemaRequiredSets' => $schema_required_sets, + ); + } + + private static function file_array( \ComponentFuzz\FuzzContext $ctx ): array { + return array( + 'fileType' => 'inline', + 'mimeType' => 'text/plain', + 'base64Data' => base64_encode( self::safe_text( $ctx, 4, 24 ) ), + ); + } + + private static function message_part_text_array( \ComponentFuzz\FuzzContext $ctx ): array { + return array( + 'channel' => $ctx->bool() ? 'content' : 'thought', + 'type' => 'text', + 'text' => self::safe_text( $ctx, 3, 28 ), + 'thoughtSignature' => 'sig-' . self::slug_piece( $ctx->fork( 'sig' ), 'sig' ), + ); + } + + private static function message_part_file_array( \ComponentFuzz\FuzzContext $ctx ): array { + return array( + 'channel' => 'content', + 'type' => 'file', + 'file' => self::file_array( $ctx->fork( 'part-file' ) ), + ); + } + + private static function message_part_function_call_array( \ComponentFuzz\FuzzContext $ctx ): array { + return array( + 'channel' => 'content', + 'type' => 'function_call', + 'functionCall' => self::function_call_array( $ctx ), + ); + } + + private static function message_part_function_response_array( \ComponentFuzz\FuzzContext $ctx ): array { + return array( + 'channel' => 'content', + 'type' => 'function_response', + 'functionResponse' => self::function_response_array( $ctx ), + ); + } + + private static function user_message_array( \ComponentFuzz\FuzzContext $ctx ): array { + return array( + 'role' => 'user', + 'parts' => array( + self::message_part_text_array( $ctx->fork( 'text' ) ), + self::message_part_file_array( $ctx->fork( 'file' ) ), + self::message_part_function_response_array( $ctx->fork( 'function-response' ) ), + ), + ); + } + + private static function model_message_array( \ComponentFuzz\FuzzContext $ctx ): array { + return array( + 'role' => 'model', + 'parts' => array( + self::message_part_text_array( $ctx->fork( 'text' ) ), + self::message_part_function_call_array( $ctx->fork( 'function-call' ) ), + ), + ); + } + + private static function function_declaration_array( \ComponentFuzz\FuzzContext $ctx ): array { + return array( + 'name' => 'tool_' . self::slug_piece( $ctx, 'tool' ), + 'description' => 'Tool ' . self::safe_text( $ctx->fork( 'description' ), 4, 20 ), + 'parameters' => self::simple_json_schema(), + ); + } + + private static function function_call_array( \ComponentFuzz\FuzzContext $ctx ): array { + return array( + 'id' => 'call-' . self::slug_piece( $ctx, 'id' ), + 'name' => 'tool_' . self::slug_piece( $ctx->fork( 'name' ), 'tool' ), + 'args' => array( + 'value' => self::safe_text( $ctx->fork( 'args' ), 2, 16 ), + 'count' => $ctx->int( 0, 10 ), + ), + ); + } + + private static function function_response_array( \ComponentFuzz\FuzzContext $ctx ): array { + return array( + 'id' => 'call-' . self::slug_piece( $ctx, 'id' ), + 'name' => 'tool_' . self::slug_piece( $ctx->fork( 'name' ), 'tool' ), + 'response' => array( + 'ok' => true, + 'value' => self::safe_text( $ctx->fork( 'response' ), 2, 16 ), + ), + ); + } + + private static function web_search_array( \ComponentFuzz\FuzzContext $ctx ): array { + return array( + 'allowedDomains' => array( self::domain( $ctx->fork( 'allow-a' ) ), self::domain( $ctx->fork( 'allow-b' ) ) ), + 'disallowedDomains' => array( self::domain( $ctx->fork( 'deny' ) ) ), + ); + } + + private static function model_config_array( \ComponentFuzz\FuzzContext $ctx ): array { + $orientation = $ctx->choice( array( 'square', 'landscape', 'portrait' ) ); + $aspect = array( + 'square' => '1:1', + 'landscape' => '16:9', + 'portrait' => '9:16', + )[ $orientation ]; + + return array( + 'outputModalities' => array( 'text' ), + 'systemInstruction' => 'System ' . self::safe_text( $ctx->fork( 'system' ), 4, 18 ), + 'candidateCount' => $ctx->int( 1, 3 ), + 'maxTokens' => $ctx->int( 8, 96 ), + 'temperature' => self::small_float( $ctx->fork( 'temperature' ), 0, 20 ), + 'topP' => self::small_float( $ctx->fork( 'top-p' ), 1, 10 ), + 'topK' => $ctx->int( 1, 64 ), + 'stopSequences' => array( 'STOP-' . self::slug_piece( $ctx->fork( 'stop' ), 'stop' ) ), + 'presencePenalty' => self::small_float( $ctx->fork( 'presence' ), -10, 10 ), + 'frequencyPenalty' => self::small_float( $ctx->fork( 'frequency' ), -10, 10 ), + 'logprobs' => $ctx->bool(), + 'topLogprobs' => $ctx->int( 1, 5 ), + 'functionDeclarations' => array( self::function_declaration_array( $ctx->fork( 'tool' ) ) ), + 'webSearch' => self::web_search_array( $ctx->fork( 'web' ) ), + 'outputFileType' => 'inline', + 'outputMimeType' => 'application/json', + 'outputSchema' => self::simple_json_schema(), + 'outputMediaOrientation' => $orientation, + 'outputMediaAspectRatio' => $aspect, + 'outputSpeechVoice' => 'voice-' . self::slug_piece( $ctx->fork( 'voice' ), 'voice' ), + 'customOptions' => array( + 'trace' => 'trace-' . self::slug_piece( $ctx->fork( 'trace' ), 'trace' ), + ), + ); + } + + private static function supported_option_array(): array { + return array( + 'name' => 'outputModalities', + 'supportedValues' => array( array( 'text' ), array( 'image' ) ), + ); + } + + private static function required_option_array(): array { + return array( + 'name' => 'outputModalities', + 'value' => array( 'text' ), + ); + } + + private static function model_requirements_array(): array { + return array( + 'requiredCapabilities' => array( 'text_generation' ), + 'requiredOptions' => array( self::required_option_array() ), + ); + } + + private static function provider_metadata_array( \ComponentFuzz\FuzzContext $ctx ): array { + return array( + 'id' => 'provider-' . self::slug_piece( $ctx, 'provider' ), + 'name' => 'Provider ' . self::safe_text( $ctx->fork( 'name' ), 4, 18 ), + 'description' => 'Description ' . self::safe_text( $ctx->fork( 'description' ), 4, 20 ), + 'type' => 'server', + 'credentialsUrl' => 'https://example.test/credentials', + 'authenticationMethod' => 'api_key', + 'logoPath' => '/tmp/component-fuzz-logo.svg', + ); + } + + private static function model_metadata_array( \ComponentFuzz\FuzzContext $ctx, array $supported_options ): array { + return array( + 'id' => 'model-' . self::slug_piece( $ctx, 'model' ), + 'name' => 'Model ' . self::safe_text( $ctx->fork( 'name' ), 4, 18 ), + 'supportedCapabilities' => array( 'text_generation', 'chat_history' ), + 'supportedOptions' => $supported_options, + ); + } + + private static function token_usage_array( \ComponentFuzz\FuzzContext $ctx ): array { + $prompt = $ctx->int( 1, 128 ); + $completion = $ctx->int( 1, 128 ); + $thought = $ctx->int( 0, $completion ); + + return array( + 'promptTokens' => $prompt, + 'completionTokens' => $completion, + 'totalTokens' => $prompt + $completion, + 'thoughtTokens' => $thought, + ); + } + + private static function candidate_array( array $model_message ): array { + return array( + 'message' => $model_message, + 'finishReason' => 'stop', + ); + } + + private static function result_array( + \ComponentFuzz\FuzzContext $ctx, + array $candidate, + array $token_usage, + array $provider, + array $model + ): array { + return array( + 'id' => 'result-' . self::slug_piece( $ctx, 'result' ), + 'candidates' => array( $candidate ), + 'tokenUsage' => $token_usage, + 'providerMetadata' => $provider, + 'modelMetadata' => $model, + 'additionalData' => array( + 'seed' => $ctx->seed(), + 'note' => self::safe_text( $ctx->fork( 'note' ), 2, 18 ), + ), + ); + } + + private static function request_options_array( \ComponentFuzz\FuzzContext $ctx ): array { + return array( + 'timeout' => self::small_float( $ctx->fork( 'timeout' ), 1, 100 ), + 'connectTimeout' => self::small_float( $ctx->fork( 'connect-timeout' ), 1, 50 ), + 'maxRedirects' => $ctx->int( 0, 5 ), + ); + } + + private static function request_array( \ComponentFuzz\FuzzContext $ctx ): array { + return array( + 'method' => 'POST', + 'uri' => 'https://example.test/ai/' . self::slug_piece( $ctx, 'request' ), + 'headers' => array( + 'Content-Type' => array( 'application/json' ), + 'X-Fuzz' => array( 'seed-' . $ctx->seed() ), + ), + 'body' => wp_json_encode( + array( + 'value' => self::safe_text( $ctx->fork( 'body' ), 2, 18 ), + ) + ), + 'options' => self::request_options_array( $ctx->fork( 'options' ) ), + ); + } + + private static function response_array( \ComponentFuzz\FuzzContext $ctx ): array { + return array( + 'statusCode' => $ctx->choice( array( 200, 201, 202, 400, 429, 500 ) ), + 'headers' => array( + 'Content-Type' => array( 'application/json' ), + 'X-Fuzz' => array( 'response-' . $ctx->seed() ), + ), + 'body' => wp_json_encode( + array( + 'ok' => true, + 'value' => self::safe_text( $ctx->fork( 'body' ), 2, 18 ), + ) + ), + ); + } + + private static function model_config( \ComponentFuzz\FuzzContext $ctx ): \WordPress\AiClient\Providers\Models\DTO\ModelConfig { + return \WordPress\AiClient\Providers\Models\DTO\ModelConfig::fromArray( + array( + 'outputModalities' => array( 'text' ), + 'systemInstruction' => 'System ' . self::safe_text( $ctx->fork( 'config' ), 3, 16 ), + 'maxTokens' => $ctx->fork( 'max-tokens' )->int( 8, 64 ), + 'temperature' => self::small_float( $ctx->fork( 'config-temp' ), 0, 20 ), + ) + ); + } + + private static function simple_json_schema(): array { + return array( + 'type' => 'object', + 'required' => array( 'value' ), + 'properties' => array( + 'value' => array( 'type' => 'string' ), + ), + ); + } + + private static function schema_declares_required_set( array $schema, array $required_set ): bool { + if ( isset( $schema['required'] ) && is_array( $schema['required'] ) ) { + $required = array_map( 'strval', $schema['required'] ); + if ( array() === array_diff( $required_set, $required ) ) { + return true; + } + } + + foreach ( array( 'oneOf', 'anyOf', 'allOf' ) as $key ) { + if ( ! isset( $schema[ $key ] ) || ! is_array( $schema[ $key ] ) ) { + continue; + } + foreach ( $schema[ $key ] as $child ) { + if ( is_array( $child ) && self::schema_declares_required_set( $child, $required_set ) ) { + return true; + } + } + } + + if ( isset( $schema['properties'] ) && is_array( $schema['properties'] ) ) { + foreach ( $schema['properties'] as $child ) { + if ( is_array( $child ) && self::schema_declares_required_set( $child, $required_set ) ) { + return true; + } + } + } + + if ( isset( $schema['items'] ) && is_array( $schema['items'] ) ) { + return self::schema_declares_required_set( $schema['items'], $required_set ); + } + + return false; + } + + private static function get_wrapped_prompt_builder_model_config( \WP_AI_Client_Prompt_Builder $builder ) { + $builder_property = new \ReflectionProperty( \WP_AI_Client_Prompt_Builder::class, 'builder' ); + $sdk_builder = $builder_property->getValue( $builder ); + + $config_property = new \ReflectionProperty( $sdk_builder, 'modelConfig' ); + return $config_property->getValue( $sdk_builder ); + } + + private static function ensure_init_fired(): void { + if ( ! isset( $GLOBALS['wp_actions'] ) || ! is_array( $GLOBALS['wp_actions'] ) ) { + $GLOBALS['wp_actions'] = array(); + } + $GLOBALS['wp_actions']['init'] = max( 1, (int) ( $GLOBALS['wp_actions']['init'] ?? 0 ) ); + } + + private static function reset_ability_registries(): void { + self::set_static_property( 'WP_Abilities_Registry', 'instance', null ); + self::set_static_property( 'WP_Ability_Categories_Registry', 'instance', null ); + } + + private static function snapshot_state(): array { + $abilities = self::get_static_property( 'WP_Abilities_Registry', 'instance' ); + $categories = self::get_static_property( 'WP_Ability_Categories_Registry', 'instance' ); + + return array( + 'globals' => self::snapshot_globals( + array( + 'wp_filter', + 'wp_filters', + 'wp_actions', + 'wp_current_filter', + 'wp_object_cache', + ) + ), + 'aiDefaultRegistry' => self::get_static_property( \WordPress\AiClient\AiClient::class, 'defaultRegistry' ), + 'aiCache' => self::get_static_property( \WordPress\AiClient\AiClient::class, 'cache' ), + 'aiEventDispatcher' => self::get_static_property( \WordPress\AiClient\AiClient::class, 'eventDispatcher' ), + 'fakeProvider' => AiClientSurface_FakeProvider::snapshot(), + 'abilities' => $abilities, + 'registeredAbilities' => $abilities instanceof \WP_Abilities_Registry + ? self::get_object_property( $abilities, 'registered_abilities' ) + : null, + 'categories' => $categories, + 'registeredCategories' => $categories instanceof \WP_Ability_Categories_Registry + ? self::get_object_property( $categories, 'registered_categories' ) + : null, + ); + } + + private static function restore_state( array $snapshot ): void { + self::restore_globals( $snapshot['globals'] ); + self::set_static_property( \WordPress\AiClient\AiClient::class, 'defaultRegistry', $snapshot['aiDefaultRegistry'] ); + self::set_static_property( \WordPress\AiClient\AiClient::class, 'cache', $snapshot['aiCache'] ); + self::set_static_property( + \WordPress\AiClient\AiClient::class, + 'eventDispatcher', + $snapshot['aiEventDispatcher'] + ); + AiClientSurface_FakeProvider::restore( $snapshot['fakeProvider'] ); + + if ( $snapshot['abilities'] instanceof \WP_Abilities_Registry ) { + self::set_object_property( $snapshot['abilities'], 'registered_abilities', $snapshot['registeredAbilities'] ); + self::set_static_property( 'WP_Abilities_Registry', 'instance', $snapshot['abilities'] ); + } else { + self::set_static_property( 'WP_Abilities_Registry', 'instance', null ); + } + + if ( $snapshot['categories'] instanceof \WP_Ability_Categories_Registry ) { + self::set_object_property( $snapshot['categories'], 'registered_categories', $snapshot['registeredCategories'] ); + self::set_static_property( 'WP_Ability_Categories_Registry', 'instance', $snapshot['categories'] ); + } else { + self::set_static_property( 'WP_Ability_Categories_Registry', 'instance', null ); + } + } + + private static function snapshot_class_discovery_state(): array { + $class = \WordPress\AiClientDependencies\Http\Discovery\ClassDiscovery::class; + + return array( + 'strategies' => self::get_static_property( $class, 'strategies' ), + 'cache' => self::get_static_property( $class, 'cache' ), + ); + } + + private static function restore_class_discovery_state( array $snapshot ): void { + $class = \WordPress\AiClientDependencies\Http\Discovery\ClassDiscovery::class; + + self::set_static_property( $class, 'strategies', $snapshot['strategies'] ?? array() ); + self::set_static_property( $class, 'cache', $snapshot['cache'] ?? array() ); + } + + private static function snapshot_globals( array $names ): array { + $snapshot = array(); + foreach ( $names as $name ) { + $snapshot[ $name ] = array( + 'exists' => array_key_exists( $name, $GLOBALS ), + 'value' => array_key_exists( $name, $GLOBALS ) ? $GLOBALS[ $name ] : null, + ); + } + return $snapshot; + } + + private static function restore_globals( array $snapshot ): void { + foreach ( $snapshot as $name => $entry ) { + if ( $entry['exists'] ) { + $GLOBALS[ $name ] = $entry['value']; + } else { + unset( $GLOBALS[ $name ] ); + } + } + } + + private static function get_static_property( string $class, string $property ) { + if ( ! class_exists( $class ) ) { + return null; + } + $reflection = new \ReflectionProperty( $class, $property ); + return $reflection->getValue(); + } + + private static function set_static_property( string $class, string $property, $value ): void { + if ( ! class_exists( $class ) ) { + return; + } + $reflection = new \ReflectionProperty( $class, $property ); + $reflection->setValue( null, $value ); + } + + private static function get_object_property( object $object, string $property ) { + $reflection = new \ReflectionProperty( $object, $property ); + return $reflection->getValue( $object ); + } + + private static function set_object_property( object $object, string $property, $value ): void { + $reflection = new \ReflectionProperty( $object, $property ); + $reflection->setValue( $object, $value ); + } + + private static function throws( callable $callback ): bool { + try { + $callback(); + } catch ( \Throwable $e ) { + return true; + } + + return false; + } + + private static function same_value( $expected, $actual ): bool { + return $expected === $actual; + } + + private static function collect_failure( array &$failures, bool $condition, string $label, array $details ): void { + if ( $condition ) { + return; + } + + $failures[] = array( + 'label' => $label, + 'details' => self::describe_value( $details ), + ); + } + + private static function result( \ComponentFuzz\FuzzContext $ctx, string $invariant, bool $ok, array $data = array() ): array { + return $ok ? $ctx->pass( $invariant, $data ) : $ctx->fail( $invariant, $data ); + } + + private static function describe_ability( $ability ): array { + if ( ! $ability instanceof \WP_Ability ) { + return array( 'value' => self::describe_value( $ability ) ); + } + + return array( + 'name' => $ability->get_name(), + 'description' => $ability->get_description(), + 'category' => $ability->get_category(), + 'inputSchema' => $ability->get_input_schema(), + ); + } + + private static function describe_error( $value ): array { + if ( ! \is_wp_error( $value ) ) { + return array( 'value' => self::describe_value( $value ) ); + } + + return array( + 'code' => $value->get_error_code(), + 'message' => $value->get_error_message(), + 'data' => $value->get_error_data(), + ); + } + + private static function wp_http_response( int $status_code, string $message, array $headers, string $body ): array { + return array( + 'headers' => $headers, + 'body' => $body, + 'response' => array( + 'code' => $status_code, + 'message' => $message, + ), + 'cookies' => array(), + 'filename' => null, + ); + } + + private static function header_values( array $headers, string $name ): ?array { + foreach ( $headers as $header_name => $values ) { + if ( 0 !== strcasecmp( (string) $header_name, $name ) ) { + continue; + } + + if ( is_array( $values ) ) { + return array_map( 'strval', array_values( $values ) ); + } + + return array_map( 'trim', explode( ',', (string) $values ) ); + } + + return null; + } + + private static function result_selects_provider_model( $result, string $provider_id, string $model_id ): bool { + return $result instanceof \WordPress\AiClient\Results\DTO\GenerativeAiResult + && $provider_id === $result->getProviderMetadata()->getId() + && $model_id === $result->getModelMetadata()->getId(); + } + + private static function describe_ai_result_selection( $result ): array { + if ( \is_wp_error( $result ) ) { + return self::describe_error( $result ); + } + + if ( ! $result instanceof \WordPress\AiClient\Results\DTO\GenerativeAiResult ) { + return array( 'value' => self::describe_value( $result ) ); + } + + return array( + 'providerId' => $result->getProviderMetadata()->getId(), + 'modelId' => $result->getModelMetadata()->getId(), + 'text' => $result->toText(), + ); + } + + private static function describe_value( $value ) { + if ( is_object( $value ) ) { + return '[object ' . get_class( $value ) . ']'; + } + if ( is_array( $value ) ) { + return $value; + } + return $value; + } + + private static function describe_throwable( \Throwable $e ): array { + return array( + 'class' => get_class( $e ), + 'message' => $e->getMessage(), + 'file' => $e->getFile(), + 'line' => $e->getLine(), + ); + } + + private static function safe_text( \ComponentFuzz\FuzzContext $ctx, int $min, int $max ): string { + $text = $ctx->ascii( $min, $max ); + $text = preg_replace( '/[^A-Za-z0-9 _.,:-]/', '_', $text ); + $text = trim( (string) $text ); + return '' === $text ? 'component-fuzz' : $text; + } + + private static function slug_piece( \ComponentFuzz\FuzzContext $ctx, string $prefix ): string { + $raw = strtolower( $prefix . '-' . $ctx->identifier( 3, 12 ) . '-' . dechex( $ctx->seed() & 0xffff ) ); + $raw = preg_replace( '/[^a-z0-9]+/', '-', $raw ); + $raw = trim( (string) $raw, '-' ); + $raw = preg_replace( '/-+/', '-', $raw ); + return '' === $raw ? $prefix . '-' . dechex( $ctx->seed() & 0xffff ) : substr( $raw, 0, 40 ); + } + + private static function domain( \ComponentFuzz\FuzzContext $ctx ): string { + return self::slug_piece( $ctx, 'domain' ) . '.example.test'; + } + + private static function small_float( \ComponentFuzz\FuzzContext $ctx, int $min_tenths, int $max_tenths ): float { + return $ctx->int( $min_tenths, $max_tenths ) / 10; + } +} + +final class AiClientSurface_CapturingHttpClient implements + \WordPress\AiClientDependencies\Psr\Http\Client\ClientInterface, + \WordPress\AiClient\Providers\Http\Contracts\ClientWithOptionsInterface { + + private \WordPress\AiClientDependencies\Psr\Http\Message\ResponseFactoryInterface $response_factory; + private \WordPress\AiClientDependencies\Psr\Http\Message\StreamFactoryInterface $stream_factory; + private ?\WordPress\AiClientDependencies\Psr\Http\Message\RequestInterface $last_request = null; + private ?\WordPress\AiClient\Providers\Http\DTO\RequestOptions $last_options = null; + private int $send_request_count = 0; + private int $send_request_with_options_count = 0; + private int $status_code; + private string $reason_phrase; + private string $body; + + public function __construct( + \WordPress\AiClientDependencies\Psr\Http\Message\ResponseFactoryInterface $response_factory, + \WordPress\AiClientDependencies\Psr\Http\Message\StreamFactoryInterface $stream_factory, + int $status_code, + string $reason_phrase, + string $body + ) { + $this->response_factory = $response_factory; + $this->stream_factory = $stream_factory; + $this->status_code = $status_code; + $this->reason_phrase = $reason_phrase; + $this->body = $body; + } + + public function sendRequest( + \WordPress\AiClientDependencies\Psr\Http\Message\RequestInterface $request + ): \WordPress\AiClientDependencies\Psr\Http\Message\ResponseInterface { + $this->last_request = $request; + $this->last_options = null; + ++$this->send_request_count; + + return $this->response(); + } + + public function sendRequestWithOptions( + \WordPress\AiClientDependencies\Psr\Http\Message\RequestInterface $request, + \WordPress\AiClient\Providers\Http\DTO\RequestOptions $options + ): \WordPress\AiClientDependencies\Psr\Http\Message\ResponseInterface { + $this->last_request = $request; + $this->last_options = $options; + ++$this->send_request_with_options_count; + + return $this->response(); + } + + public function last_request(): ?\WordPress\AiClientDependencies\Psr\Http\Message\RequestInterface { + return $this->last_request; + } + + public function last_options(): ?\WordPress\AiClient\Providers\Http\DTO\RequestOptions { + return $this->last_options; + } + + public function send_request_count(): int { + return $this->send_request_count; + } + + public function send_request_with_options_count(): int { + return $this->send_request_with_options_count; + } + + private function response(): \WordPress\AiClientDependencies\Psr\Http\Message\ResponseInterface { + $response = $this->response_factory->createResponse( $this->status_code, $this->reason_phrase ) + ->withHeader( 'Content-Type', 'application/json' ) + ->withHeader( 'X-Capture', 'options' ); + + if ( '' !== $this->body ) { + $response = $response->withBody( $this->stream_factory->createStream( $this->body ) ); + } + + return $response; + } +} + +final class AiClientSurface_FakeProvider implements \WordPress\AiClient\Providers\Contracts\ProviderInterface { + private static bool $configured = true; + + public static function reset(): void { + self::$configured = true; + } + + public static function snapshot(): array { + return array( 'configured' => self::$configured ); + } + + public static function restore( array $snapshot ): void { + self::$configured = (bool) ( $snapshot['configured'] ?? true ); + } + + public static function set_configured( bool $configured ): void { + self::$configured = $configured; + } + + public static function metadata(): \WordPress\AiClient\Providers\DTO\ProviderMetadata { + return new \WordPress\AiClient\Providers\DTO\ProviderMetadata( + AiClientSurface::PROVIDER_ID, + 'Component Fuzz AI', + \WordPress\AiClient\Providers\Enums\ProviderTypeEnum::server() + ); + } + + public static function modelMetadata(): \WordPress\AiClient\Providers\Models\DTO\ModelMetadata { + $options = array_map( + static fn( \WordPress\AiClient\Providers\Models\Enums\OptionEnum $option ) => + new \WordPress\AiClient\Providers\Models\DTO\SupportedOption( $option ), + \WordPress\AiClient\Providers\Models\Enums\OptionEnum::cases() + ); + + return new \WordPress\AiClient\Providers\Models\DTO\ModelMetadata( + AiClientSurface::MODEL_ID, + 'Component Fuzz Text', + array( + \WordPress\AiClient\Providers\Models\Enums\CapabilityEnum::textGeneration(), + \WordPress\AiClient\Providers\Models\Enums\CapabilityEnum::chatHistory(), + ), + $options + ); + } + + public static function model( + string $modelId, + ?\WordPress\AiClient\Providers\Models\DTO\ModelConfig $modelConfig = null + ): \WordPress\AiClient\Providers\Models\Contracts\ModelInterface { + if ( AiClientSurface::MODEL_ID !== $modelId ) { + throw new \WordPress\AiClient\Common\Exception\InvalidArgumentException( 'Unknown fake model: ' . $modelId ); + } + + return new AiClientSurface_FakeTextModel( $modelConfig ?? new \WordPress\AiClient\Providers\Models\DTO\ModelConfig() ); + } + + public static function availability(): \WordPress\AiClient\Providers\Contracts\ProviderAvailabilityInterface { + return new AiClientSurface_FakeAvailability( self::$configured ); + } + + public static function modelMetadataDirectory(): \WordPress\AiClient\Providers\Contracts\ModelMetadataDirectoryInterface { + return new AiClientSurface_FakeModelMetadataDirectory(); + } +} + +final class AiClientSurface_FakeAvailability implements \WordPress\AiClient\Providers\Contracts\ProviderAvailabilityInterface { + private bool $configured; + + public function __construct( bool $configured ) { + $this->configured = $configured; + } + + public function isConfigured(): bool { + return $this->configured; + } +} + +final class AiClientSurface_FakeModelMetadataDirectory implements \WordPress\AiClient\Providers\Contracts\ModelMetadataDirectoryInterface { + public function listModelMetadata(): array { + return array( AiClientSurface_FakeProvider::modelMetadata() ); + } + + public function hasModelMetadata( string $modelId ): bool { + return AiClientSurface::MODEL_ID === $modelId; + } + + public function getModelMetadata( string $modelId ): \WordPress\AiClient\Providers\Models\DTO\ModelMetadata { + if ( ! $this->hasModelMetadata( $modelId ) ) { + throw new \WordPress\AiClient\Common\Exception\InvalidArgumentException( 'Unknown fake model: ' . $modelId ); + } + + return AiClientSurface_FakeProvider::modelMetadata(); + } +} + +final class AiClientSurface_FakeTextModel implements + \WordPress\AiClient\Providers\Models\Contracts\ModelInterface, + \WordPress\AiClient\Providers\Models\TextGeneration\Contracts\TextGenerationModelInterface { + + private \WordPress\AiClient\Providers\Models\DTO\ModelConfig $config; + + public function __construct( \WordPress\AiClient\Providers\Models\DTO\ModelConfig $config ) { + $this->config = $config; + } + + public function metadata(): \WordPress\AiClient\Providers\Models\DTO\ModelMetadata { + return AiClientSurface_FakeProvider::modelMetadata(); + } + + public function providerMetadata(): \WordPress\AiClient\Providers\DTO\ProviderMetadata { + return AiClientSurface_FakeProvider::metadata(); + } + + public function setConfig( \WordPress\AiClient\Providers\Models\DTO\ModelConfig $config ): void { + $this->config = $config; + } + + public function getConfig(): \WordPress\AiClient\Providers\Models\DTO\ModelConfig { + return $this->config; + } + + public function generateTextResult( array $prompt ): \WordPress\AiClient\Results\DTO\GenerativeAiResult { + $last_text = ''; + foreach ( $prompt as $message ) { + if ( ! $message instanceof \WordPress\AiClient\Messages\DTO\Message ) { + continue; + } + foreach ( $message->getParts() as $part ) { + if ( null !== $part->getText() ) { + $last_text = $part->getText(); + } + } + } + + $text = 'component-fuzz:' . count( $prompt ) . ':' . $last_text; + + return new \WordPress\AiClient\Results\DTO\GenerativeAiResult( + 'fake-result-' . substr( sha1( $text ), 0, 12 ), + array( + new \WordPress\AiClient\Results\DTO\Candidate( + new \WordPress\AiClient\Messages\DTO\ModelMessage( + array( new \WordPress\AiClient\Messages\DTO\MessagePart( $text ) ) + ), + \WordPress\AiClient\Results\Enums\FinishReasonEnum::stop() + ), + ), + new \WordPress\AiClient\Results\DTO\TokenUsage( strlen( $last_text ), strlen( $text ), strlen( $last_text ) + strlen( $text ) ), + $this->providerMetadata(), + $this->metadata(), + array( 'promptCount' => count( $prompt ) ) + ); + } +} + +final class AiClientSurface_CollisionProviderA implements \WordPress\AiClient\Providers\Contracts\ProviderInterface { + public static function metadata(): \WordPress\AiClient\Providers\DTO\ProviderMetadata { + return new \WordPress\AiClient\Providers\DTO\ProviderMetadata( + AiClientSurface::COLLISION_PROVIDER_A_ID, + 'Component Fuzz Collision A', + \WordPress\AiClient\Providers\Enums\ProviderTypeEnum::server() + ); + } + + public static function model( + string $modelId, + ?\WordPress\AiClient\Providers\Models\DTO\ModelConfig $modelConfig = null + ): \WordPress\AiClient\Providers\Models\Contracts\ModelInterface { + if ( ! in_array( $modelId, array( AiClientSurface::COLLISION_SHARED_MODEL, AiClientSurface::COLLISION_A_ONLY_MODEL ), true ) ) { + throw new \WordPress\AiClient\Common\Exception\InvalidArgumentException( 'Unknown collision model A: ' . $modelId ); + } + + return new AiClientSurface_CollisionTextModel( + self::metadata(), + $modelId, + $modelConfig ?? new \WordPress\AiClient\Providers\Models\DTO\ModelConfig() + ); + } + + public static function availability(): \WordPress\AiClient\Providers\Contracts\ProviderAvailabilityInterface { + return new AiClientSurface_FakeAvailability( true ); + } + + public static function modelMetadataDirectory(): \WordPress\AiClient\Providers\Contracts\ModelMetadataDirectoryInterface { + return new AiClientSurface_CollisionModelMetadataDirectory( + array( AiClientSurface::COLLISION_SHARED_MODEL, AiClientSurface::COLLISION_A_ONLY_MODEL ) + ); + } +} + +final class AiClientSurface_CollisionProviderB implements \WordPress\AiClient\Providers\Contracts\ProviderInterface { + public static function metadata(): \WordPress\AiClient\Providers\DTO\ProviderMetadata { + return new \WordPress\AiClient\Providers\DTO\ProviderMetadata( + AiClientSurface::COLLISION_PROVIDER_B_ID, + 'Component Fuzz Collision B', + \WordPress\AiClient\Providers\Enums\ProviderTypeEnum::server() + ); + } + + public static function model( + string $modelId, + ?\WordPress\AiClient\Providers\Models\DTO\ModelConfig $modelConfig = null + ): \WordPress\AiClient\Providers\Models\Contracts\ModelInterface { + if ( ! in_array( $modelId, array( AiClientSurface::COLLISION_SHARED_MODEL, AiClientSurface::COLLISION_B_ONLY_MODEL ), true ) ) { + throw new \WordPress\AiClient\Common\Exception\InvalidArgumentException( 'Unknown collision model B: ' . $modelId ); + } + + return new AiClientSurface_CollisionTextModel( + self::metadata(), + $modelId, + $modelConfig ?? new \WordPress\AiClient\Providers\Models\DTO\ModelConfig() + ); + } + + public static function availability(): \WordPress\AiClient\Providers\Contracts\ProviderAvailabilityInterface { + return new AiClientSurface_FakeAvailability( true ); + } + + public static function modelMetadataDirectory(): \WordPress\AiClient\Providers\Contracts\ModelMetadataDirectoryInterface { + return new AiClientSurface_CollisionModelMetadataDirectory( + array( AiClientSurface::COLLISION_SHARED_MODEL, AiClientSurface::COLLISION_B_ONLY_MODEL ) + ); + } +} + +final class AiClientSurface_CollisionModelMetadataDirectory implements + \WordPress\AiClient\Providers\Contracts\ModelMetadataDirectoryInterface { + + /** @var list */ + private array $model_ids; + + /** + * @param list $model_ids Model identifiers exposed by the provider. + */ + public function __construct( array $model_ids ) { + $this->model_ids = array_values( $model_ids ); + } + + public function listModelMetadata(): array { + return array_map( + static fn( string $model_id ): \WordPress\AiClient\Providers\Models\DTO\ModelMetadata => + AiClientSurface_CollisionTextModel::metadata_for( $model_id ), + $this->model_ids + ); + } + + public function hasModelMetadata( string $modelId ): bool { + return in_array( $modelId, $this->model_ids, true ); + } + + public function getModelMetadata( string $modelId ): \WordPress\AiClient\Providers\Models\DTO\ModelMetadata { + if ( ! $this->hasModelMetadata( $modelId ) ) { + throw new \WordPress\AiClient\Common\Exception\InvalidArgumentException( 'Unknown collision model: ' . $modelId ); + } + + return AiClientSurface_CollisionTextModel::metadata_for( $modelId ); + } +} + +final class AiClientSurface_CollisionTextModel implements + \WordPress\AiClient\Providers\Models\Contracts\ModelInterface, + \WordPress\AiClient\Providers\Models\TextGeneration\Contracts\TextGenerationModelInterface { + + private \WordPress\AiClient\Providers\DTO\ProviderMetadata $provider_metadata; + + private string $model_id; + + private \WordPress\AiClient\Providers\Models\DTO\ModelConfig $config; + + public function __construct( + \WordPress\AiClient\Providers\DTO\ProviderMetadata $provider_metadata, + string $model_id, + \WordPress\AiClient\Providers\Models\DTO\ModelConfig $config + ) { + $this->provider_metadata = $provider_metadata; + $this->model_id = $model_id; + $this->config = $config; + } + + public static function metadata_for( string $model_id ): \WordPress\AiClient\Providers\Models\DTO\ModelMetadata { + $options = array_map( + static fn( \WordPress\AiClient\Providers\Models\Enums\OptionEnum $option ) => + new \WordPress\AiClient\Providers\Models\DTO\SupportedOption( $option ), + \WordPress\AiClient\Providers\Models\Enums\OptionEnum::cases() + ); + + return new \WordPress\AiClient\Providers\Models\DTO\ModelMetadata( + $model_id, + 'Component Fuzz ' . $model_id, + array( + \WordPress\AiClient\Providers\Models\Enums\CapabilityEnum::textGeneration(), + \WordPress\AiClient\Providers\Models\Enums\CapabilityEnum::chatHistory(), + ), + $options + ); + } + + public function metadata(): \WordPress\AiClient\Providers\Models\DTO\ModelMetadata { + return self::metadata_for( $this->model_id ); + } + + public function providerMetadata(): \WordPress\AiClient\Providers\DTO\ProviderMetadata { + return $this->provider_metadata; + } + + public function setConfig( \WordPress\AiClient\Providers\Models\DTO\ModelConfig $config ): void { + $this->config = $config; + } + + public function getConfig(): \WordPress\AiClient\Providers\Models\DTO\ModelConfig { + return $this->config; + } + + public function generateTextResult( array $prompt ): \WordPress\AiClient\Results\DTO\GenerativeAiResult { + $last_text = ''; + foreach ( $prompt as $message ) { + if ( ! $message instanceof \WordPress\AiClient\Messages\DTO\Message ) { + continue; + } + foreach ( $message->getParts() as $part ) { + if ( null !== $part->getText() ) { + $last_text = $part->getText(); + } + } + } + + $text = implode( + ':', + array( + 'component-fuzz-collision', + $this->provider_metadata->getId(), + $this->model_id, + $last_text, + ) + ); + + return new \WordPress\AiClient\Results\DTO\GenerativeAiResult( + 'collision-result-' . substr( sha1( $text ), 0, 12 ), + array( + new \WordPress\AiClient\Results\DTO\Candidate( + new \WordPress\AiClient\Messages\DTO\ModelMessage( + array( new \WordPress\AiClient\Messages\DTO\MessagePart( $text ) ) + ), + \WordPress\AiClient\Results\Enums\FinishReasonEnum::stop() + ), + ), + new \WordPress\AiClient\Results\DTO\TokenUsage( strlen( $last_text ), strlen( $text ), strlen( $last_text ) + strlen( $text ) ), + $this->providerMetadata(), + $this->metadata(), + array( + 'providerId' => $this->provider_metadata->getId(), + 'modelId' => $this->model_id, + 'promptCount' => count( $prompt ), + ) + ); + } +} diff --git a/tools/component-fuzz/surfaces/AppearanceMediaSurface.php b/tools/component-fuzz/surfaces/AppearanceMediaSurface.php new file mode 100644 index 0000000000000..12e449c256ab5 --- /dev/null +++ b/tools/component-fuzz/surfaces/AppearanceMediaSurface.php @@ -0,0 +1,2211 @@ +skip( + 'appearance-media.bootstrap-apis-available', + 'Required appearance media APIs are unavailable.', + array( 'missing' => implode( ', ', $missing ) ) + ), + ); + } + + $snapshot = self::snapshot_state(); + $rows = array(); + + try { + self::prepare_runtime( $ctx ); + + $rows[] = self::check_background_post_normalization( $ctx->fork( 'background' ) ); + $rows[] = self::check_header_defaults_and_selection( $ctx->fork( 'headers' ) ); + $rows[] = self::check_header_and_background_frontend_helpers( $ctx->fork( 'frontend' ) ); + $rows[] = self::check_head_callback_css( $ctx->fork( 'head-callback-css' ) ); + $rows[] = self::check_custom_header_markup_and_video( $ctx->fork( 'custom-header-video' ) ); + $rows[] = self::check_custom_header_markup_print_side_effects( $ctx->fork( 'custom-header-print' ) ); + $rows[] = self::check_custom_logo_helpers( $ctx->fork( 'custom-logo' ) ); + $rows[] = self::check_site_icon_helpers( $ctx->fork( 'site-icon' ) ); + $rows[] = self::check_site_icon_attachment_urls( $ctx->fork( 'site-icon-attachment-urls' ) ); + $rows[] = self::check_admin_action_guards( $ctx->fork( 'admin-action-guards' ) ); + $rows[] = self::check_background_remove_redirect( $ctx->fork( 'background-remove-redirect' ) ); + } catch ( \Throwable $e ) { + $rows[] = $ctx->fail( + 'appearance-media.surface-no-throw', + array( 'throwable' => self::describe_throwable( $e ) ) + ); + } finally { + self::restore_state( $snapshot ); + } + + $state_diff = self::state_diff( $snapshot ); + $rows[] = self::row( + $ctx, + 'appearance-media.state-restored', + array() === $state_diff, + array( + 'trackedGlobals' => array_keys( $snapshot['globals'] ), + 'trackedOptions' => array_keys( $snapshot['options'] ), + 'contentCounts' => $snapshot['contentCounts'], + 'obLevel' => ob_get_level(), + 'diff' => $state_diff, + ) + ); + + return $rows; + } + + private static function load_support(): void { + $files = array( + 'Custom_Background' => defined( 'ABSPATH' ) ? ABSPATH . 'wp-admin/includes/class-custom-background.php' : '', + 'Custom_Image_Header' => defined( 'ABSPATH' ) ? ABSPATH . 'wp-admin/includes/class-custom-image-header.php' : '', + 'WP_Site_Icon' => defined( 'ABSPATH' ) ? ABSPATH . 'wp-admin/includes/class-wp-site-icon.php' : '', + ); + + foreach ( $files as $class => $path ) { + if ( ! class_exists( $class, false ) && $path && file_exists( $path ) ) { + require_once $path; + } + } + } + + private static function missing_requirements(): array { + $missing = array(); + foreach ( array( 'Custom_Background', 'Custom_Image_Header', 'WP_Site_Icon' ) as $class ) { + if ( ! class_exists( $class, false ) ) { + $missing[] = "class {$class}"; + } + } + if ( ! class_exists( 'WP_Scripts', false ) ) { + $missing[] = 'class WP_Scripts'; + } + if ( ! class_exists( 'Component_Fuzz_WPDB_Stub', false ) ) { + $missing[] = 'class Component_Fuzz_WPDB_Stub'; + } + + foreach ( + array( + 'add_filter', + 'add_theme_support', + 'admin_url', + 'apply_filters', + 'checked', + 'check_admin_referer', + 'create_initial_post_types', + 'current_user_can', + 'current_theme_supports', + 'delete_option', + 'display_header_text', + 'esc_attr', + 'esc_url', + 'get_background_color', + 'get_background_image', + 'get_custom_logo', + 'get_custom_header', + 'get_custom_header_markup', + 'get_header_image', + 'get_header_image_tag', + 'get_header_textcolor', + 'get_header_video_settings', + 'get_header_video_url', + 'get_option', + 'get_post_meta', + 'get_site_icon_url', + 'get_stylesheet', + 'get_template_directory_uri', + 'get_theme_mod', + 'get_theme_support', + 'has_custom_logo', + 'has_custom_header', + 'has_filter', + 'has_header_image', + 'has_header_video', + 'has_site_icon', + 'home_url', + 'is_header_video_active', + 'is_random_header_image', + 'maybe_hash_hex_color', + 'remove_all_filters', + 'remove_filter', + 'remove_theme_support', + 'remove_theme_mod', + 'sanitize_html_class', + 'sanitize_url', + 'set_url_scheme', + 'set_theme_mod', + 'site_icon_url', + 'the_custom_logo', + 'the_custom_header_markup', + 'the_header_video_url', + '_custom_background_cb', + '_custom_logo_header_styles', + 'update_option', + 'update_post_meta', + 'wp_check_filetype', + 'wp_create_nonce', + 'wp_die', + 'wp_get_attachment_image_src', + 'wp_get_attachment_image_url', + 'wp_get_attachment_metadata', + 'wp_get_attachment_url', + 'wp_get_referer', + 'wp_get_mime_types', + 'wp_get_upload_dir', + 'wp_enqueue_script', + 'wp_localize_script', + 'wp_nonce_ays', + 'wp_nonce_tick', + 'wp_register_script', + 'wp_script_is', + 'wp_scripts', + 'wp_update_attachment_metadata', + 'wp_verify_nonce', + 'wp_cache_delete', + 'wp_json_encode', + 'wp_redirect', + 'wp_safe_redirect', + 'wp_sanitize_redirect', + 'wp_site_icon', + 'wp_validate_redirect', + ) as $function + ) { + if ( ! function_exists( $function ) ) { + $missing[] = "function {$function}"; + } + } + + if ( ! isset( $GLOBALS['wpdb'] ) || ! $GLOBALS['wpdb'] instanceof \Component_Fuzz_WPDB_Stub ) { + $missing[] = 'global wpdb Component_Fuzz_WPDB_Stub'; + } + + return $missing; + } + + private static function prepare_runtime( \ComponentFuzz\FuzzContext $ctx ): void { + unset( $ctx ); + + if ( ! get_post_type_object( 'post' ) || ! get_post_status_object( 'publish' ) ) { + \create_initial_post_types(); + } + + \update_option( 'template', 'component-fuzz-theme' ); + \update_option( 'stylesheet', 'component-fuzz-theme' ); + \update_option( self::theme_mod_option_name(), array() ); + + \add_theme_support( + 'custom-background', + array( + 'default-color' => 'f0f0f0', + 'default-image' => 'http://example.test/default-background.png', + 'default-preset' => 'fill', + 'default-position-x' => 'left', + 'default-position-y' => 'top', + 'default-size' => 'auto', + 'default-repeat' => 'repeat', + 'default-attachment' => 'scroll', + ) + ); + \add_theme_support( + 'custom-header', + array( + 'default-image' => '%s/images/default-header.jpg', + 'default-text-color' => '123456', + 'header-text' => true, + 'width' => 1200, + 'height' => 300, + 'random-default' => true, + ) + ); + + $GLOBALS['_wp_default_headers'] = self::default_headers_case(); + } + + private static function check_background_post_normalization( \ComponentFuzz\FuzzContext $ctx ): array { + $failures = array(); + $case = self::background_case( $ctx ); + $nonce = \wp_create_nonce( 'custom-background' ); + $subject = new \Custom_Background(); + + $_POST = array( + '_wpnonce' => $nonce, + 'background-preset' => $case['presetInput'], + 'background-position' => $case['positionInput'], + 'background-size' => $case['sizeInput'], + 'background-repeat' => $case['repeatInput'], + 'background-attachment' => $case['attachmentInput'], + 'background-color' => $case['colorInput'], + ); + $_REQUEST = $_POST; + + $subject->take_action(); + + $actual = array( + 'preset' => \get_theme_mod( 'background_preset' ), + 'positionX' => \get_theme_mod( 'background_position_x' ), + 'positionY' => \get_theme_mod( 'background_position_y' ), + 'size' => \get_theme_mod( 'background_size' ), + 'repeat' => \get_theme_mod( 'background_repeat' ), + 'attachment' => \get_theme_mod( 'background_attachment' ), + 'color' => \get_theme_mod( 'background_color' ), + ); + + self::collect_failure( + $failures, + $case['expected'] === $actual, + 'Custom_Background::take_action normalizes bounded display options into theme mods', + array( + 'case' => $case, + 'actual' => $actual, + 'themeMod' => \get_option( self::theme_mod_option_name() ), + ) + ); + + return self::row( + $ctx, + 'appearance-media.background.post-normalization', + array() === $failures, + array( 'failures' => array_slice( $failures, 0, self::MAX_FAILURES ) ) + ); + } + + private static function check_header_defaults_and_selection( \ComponentFuzz\FuzzContext $ctx ): array { + $failures = array(); + $subject = new \Custom_Image_Header( static function (): void {} ); + $subject->process_default_headers(); + + $template_uri = \get_template_directory_uri(); + $stylesheet_uri = \get_stylesheet_directory_uri(); + $header_alpha = $subject->default_headers['alpha'] ?? null; + $header_beta = $subject->default_headers['beta'] ?? null; + + self::collect_failure( + $failures, + is_array( $header_alpha ) + && is_array( $header_beta ) + && "{$template_uri}/images/alpha.jpg" === $header_alpha['url'] + && "{$stylesheet_uri}/images/alpha-thumb.jpg" === $header_alpha['thumbnail_url'] + && "{$stylesheet_uri}/images/beta.jpg" === $header_beta['url'], + 'process_default_headers expands template and stylesheet placeholders once', + array( + 'alpha' => $header_alpha, + 'beta' => $header_beta, + 'templateUri' => $template_uri, + 'stylesheetUri' => $stylesheet_uri, + ) + ); + + $subject->set_header_image( 'alpha' ); + $selected = \get_theme_mod( 'header_image' ); + $selected_data = \get_theme_mod( 'header_image_data' ); + $output = self::capture( static fn() => $subject->show_header_selector( 'default' ) ); + $subject->remove_header_image(); + $removed = \get_header_image(); + $subject->set_header_image( + array( + 'attachment_id' => 991, + 'url' => 'http://example.test/uploads/generated-header.jpg?unsafe=', + 'width' => 1440, + 'height' => 360, + ) + ); + $array_data = \get_theme_mod( 'header_image_data' ); + $array_url = \get_theme_mod( 'header_image' ); + $subject->set_header_image( 'random-default-image' ); + + self::collect_failure( + $failures, + "{$template_uri}/images/alpha.jpg" === $selected + && is_array( $selected_data ) + && 'Alpha ' === ( $selected_data['alt_text'] ?? null ) + && false === $removed + && $array_data instanceof \stdClass + && 991 === (int) $array_data->attachment_id + && 'http://example.test/uploads/generated-header.jpg?unsafe=tag' === $array_url + && \is_random_header_image( 'default' ) + && str_contains( $output, 'Random:' ) + && str_contains( $output, 'Alpha <unsafe>' ) + && ! str_contains( $output, '' ), + 'set_header_image handles default, remove, array, and random choices with escaped selector output', + array( + 'selected' => $selected, + 'selectedData' => self::describe_value( $selected_data ), + 'removed' => $removed, + 'arrayData' => self::describe_value( $array_data ), + 'arrayUrl' => $array_url, + 'currentImage' => \get_theme_mod( 'header_image' ), + 'random' => \is_random_header_image( 'default' ), + 'selector' => self::preview( $output ), + ) + ); + $GLOBALS['wpdb']->delete( $GLOBALS['wpdb']->postmeta, array( 'post_id' => 991 ) ); + \wp_cache_delete( 991, 'post_meta' ); + + return self::row( + $ctx, + 'appearance-media.header.defaults-and-selection', + array() === $failures, + array( 'failures' => array_slice( $failures, 0, self::MAX_FAILURES ) ) + ); + } + + private static function check_header_and_background_frontend_helpers( \ComponentFuzz\FuzzContext $ctx ): array { + $failures = array(); + + \set_theme_mod( 'header_textcolor', 'abcdef' ); + \set_theme_mod( 'header_image', 'http://example.test/header-image.jpg?x=' ); + \set_theme_mod( + 'header_image_data', + (object) array( + 'attachment_id' => 0, + 'url' => 'http://example.test/header-image.jpg?x=', + 'thumbnail_url' => 'http://example.test/header-thumb.jpg', + 'width' => 960, + 'height' => 240, + ) + ); + \set_theme_mod( 'background_image', 'http://example.test/bg.png?x=' ); + \set_theme_mod( 'background_color', '#12zz34' ); + + $header_image = \get_header_image(); + $header_tag = \get_header_image_tag( + array( + 'alt' => 'Header ', + 'loading' => false, + 'decoding' => false, + ) + ); + $bg_image = \get_background_image(); + $bg_color = \get_background_color(); + $text_color = \get_header_textcolor(); + $display_text = \display_header_text(); + $expected_header_image = \set_url_scheme( 'http://example.test/header-image.jpg?x=tag' ); + + self::collect_failure( + $failures, + $expected_header_image === $header_image + && str_contains( $header_tag, 'src="' . \esc_attr( $expected_header_image ) . '"' ) + && str_contains( $header_tag, 'alt="Header <alt>"' ) + && ! str_contains( $header_tag, 'loading=' ) + && ! str_contains( $header_tag, 'decoding=' ) + && 'http://example.test/bg.png?x=' === $bg_image + && '#12zz34' === $bg_color + && 'abcdef' === $text_color + && true === $display_text, + 'frontend header/background helpers sanitize URLs, escape markup attributes, and expose theme mods consistently', + array( + 'headerImage' => self::preview( (string) $header_image ), + 'expectedHeaderImage' => self::preview( $expected_header_image ), + 'headerTag' => self::preview( $header_tag ), + 'bgImage' => self::preview( (string) $bg_image ), + 'bgColor' => $bg_color, + 'textColor' => $text_color, + 'displayText' => $display_text, + ) + ); + + \set_theme_mod( 'header_textcolor', 'blank' ); + self::collect_failure( + $failures, + false === \display_header_text(), + 'blank header text color hides header text', + array( 'displayText' => \display_header_text() ) + ); + + return self::row( + $ctx, + 'appearance-media.frontend.helpers', + array() === $failures, + array( 'failures' => array_slice( $failures, 0, self::MAX_FAILURES ) ) + ); + } + + private static function check_head_callback_css( \ComponentFuzz\FuzzContext $ctx ): array { + $failures = array(); + $token = strtolower( preg_replace( '/[^a-z0-9-]+/', '-', $ctx->identifier( 4, 10 ) ) ); + $background_url = 'http://example.test/component-fuzz/bg-' . rawurlencode( $token ) . '.png?unsafe='; + $position_x = $ctx->choice( array( 'left', 'center', 'right', 'bad-x' ) ); + $position_y = $ctx->choice( array( 'top', 'center', 'bottom', 'bad-y' ) ); + $size = $ctx->choice( array( 'auto', 'contain', 'cover', 'stretch' ) ); + $repeat = $ctx->choice( array( 'repeat-x', 'repeat-y', 'repeat', 'no-repeat', 'round' ) ); + $attachment = $ctx->choice( array( 'scroll', 'fixed', 'local' ) ); + $color = $ctx->choice( array( 'abc123', '#abc', '112233' ) ); + $expected_position_x = in_array( $position_x, array( 'left', 'center', 'right' ), true ) ? $position_x : 'left'; + $expected_position_y = in_array( $position_y, array( 'top', 'center', 'bottom' ), true ) ? $position_y : 'top'; + $expected_size = in_array( $size, array( 'auto', 'contain', 'cover' ), true ) ? $size : 'auto'; + $expected_repeat = in_array( $repeat, array( 'repeat-x', 'repeat-y', 'repeat', 'no-repeat' ), true ) ? $repeat : 'repeat'; + $expected_attachment = 'fixed' === $attachment ? 'fixed' : 'scroll'; + $expected_url = \sanitize_url( \set_url_scheme( str_replace( '', 'tag', $background_url ) ) ); + $expected_color = \maybe_hash_hex_color( $color ); + $had_theme_features = array_key_exists( '_wp_theme_features', $GLOBALS ); + $theme_features_before = $GLOBALS['_wp_theme_features'] ?? null; + $logo_css = ''; + $logo_css_visible = ''; + + try { + \set_theme_mod( 'background_image', $background_url ); + \set_theme_mod( 'background_color', $color ); + \set_theme_mod( 'background_position_x', $position_x ); + \set_theme_mod( 'background_position_y', $position_y ); + \set_theme_mod( 'background_size', $size ); + \set_theme_mod( 'background_repeat', $repeat ); + \set_theme_mod( 'background_attachment', $attachment ); + + $background_css = self::capture( static fn() => \_custom_background_cb() ); + + self::collect_failure( + $failures, + str_contains( $background_css, '', + 'seed() . ':' . $ctx->iteration() . ':' . $suffix ), 0, 12 ); + $user_id = \wp_insert_user( + array( + 'display_name' => 'Customizer Lock ' . ucfirst( $suffix ) . ' ' . $token, + 'role' => 'subscriber', + 'user_email' => 'customizer-lock-' . $suffix . '-' . $token . '@example.test', + 'user_login' => 'customizer_lock_' . $suffix . '_' . $token, + 'user_pass' => 'component-fuzz-pass', + ) + ); + + return is_int( $user_id ) ? $user_id : 0; + } + + private static function parse_changeset_lock( $lock ): ?array { + if ( ! is_string( $lock ) ) { + return null; + } + + if ( ! preg_match( '/^([0-9]+):([0-9]+)$/', $lock, $matches ) ) { + return null; + } + + return array( + 'time' => (int) $matches[1], + 'userId' => (int) $matches[2], + ); + } + + private static function changeset_lock_string( int $timestamp, int $user_id ): string { + return max( 1, $timestamp ) . ':' . max( 1, $user_id ); + } + + private static function lock_belongs_to_user( ?array $lock, int $user_id ): bool { + return is_array( $lock ) + && (int) $user_id === (int) ( $lock['userId'] ?? 0 ) + && (int) ( $lock['time'] ?? 0 ) > 0; + } + + private static function capture_ajax( callable $callback ): array { + $start_level = ob_get_level(); + $die_calls = array(); + $status_headers = array(); + $captured = false; + $returned = false; + $throwable = null; + $output = ''; + $cleaned_buffers = 0; + $doing_ajax_filter = static function (): bool { + return true; + }; + $ajax_die_filter = static function ( $handler ) use ( &$die_calls ) { + unset( $handler ); + return static function ( $message = '', string $title = '', $args = array() ) use ( &$die_calls ): void { + $die_calls[] = array( + 'kind' => 'ajax', + 'message' => $message, + 'title' => $title, + 'args' => \wp_parse_args( $args ), + ); + throw new CustomizerPersistence_DieCaptured( 'Captured ajax wp_die.' ); + }; + }; + $default_die_filter = static function ( $handler ) use ( &$die_calls ) { + unset( $handler ); + return static function ( $message = '', string $title = '', $args = array() ) use ( &$die_calls ): void { + $die_calls[] = array( + 'kind' => 'default', + 'message' => $message, + 'title' => $title, + 'args' => \wp_parse_args( $args ), + ); + throw new CustomizerPersistence_DieCaptured( 'Captured default wp_die.' ); + }; + }; + $status_filter = static function ( string $status_header, int $code, string $description, string $protocol ) use ( &$status_headers ): string { + $status_headers[] = compact( 'code', 'description', 'protocol', 'status_header' ); + return $status_header; + }; + $charset_filter = static fn() => 'UTF-8'; + + if ( ! headers_sent() ) { + header_remove(); + } + + \add_filter( 'wp_doing_ajax', $doing_ajax_filter, 9999 ); + \add_filter( 'wp_die_ajax_handler', $ajax_die_filter, 1 ); + \add_filter( 'wp_die_handler', $default_die_filter, 1 ); + \add_filter( 'status_header', $status_filter, 10, 4 ); + \add_filter( 'pre_option_blog_charset', $charset_filter, 10, 3 ); + + ob_start(); + try { + $callback(); + $returned = true; + } catch ( CustomizerPersistence_DieCaptured $e ) { + $captured = true; + } catch ( \Throwable $e ) { + $throwable = $e; + } finally { + while ( ob_get_level() > $start_level ) { + $chunk = ob_get_clean(); + $output = ( false === $chunk ? '' : $chunk ) . $output; + ++$cleaned_buffers; + } + + \remove_filter( 'wp_doing_ajax', $doing_ajax_filter, 9999 ); + \remove_filter( 'wp_die_ajax_handler', $ajax_die_filter, 1 ); + \remove_filter( 'wp_die_handler', $default_die_filter, 1 ); + \remove_filter( 'status_header', $status_filter, 10 ); + \remove_filter( 'pre_option_blog_charset', $charset_filter, 10 ); + + if ( ! headers_sent() ) { + header_remove(); + } + } + + return array( + 'bufferBalanced' => $start_level === ob_get_level() && 1 === $cleaned_buffers, + 'captured' => $captured, + 'dieCalls' => $die_calls, + 'output' => $output, + 'returned' => $returned, + 'statusHeaders' => $status_headers, + 'throwable' => null === $throwable ? null : self::describe_throwable( $throwable ), + ); + } + + private static function set_ajax_post( array $post ): void { + $_POST = $post; + $_REQUEST = $post; + } + + private static function json_body( array $capture ): ?array { + $body = trim( (string) ( $capture['output'] ?? '' ) ); + foreach ( array_reverse( $capture['dieCalls'] ?? array() ) as $call ) { + $message = $call['message'] ?? ''; + if ( is_string( $message ) && '' !== trim( $message ) ) { + $body .= trim( $message ); + break; + } + } + + $decoded = json_decode( $body, true ); + return is_array( $decoded ) ? $decoded : null; + } + + private static function json_success( array $capture ): bool { + $body = self::json_body( $capture ); + return is_array( $body ) && true === ( $body['success'] ?? null ); + } + + private static function json_error_code( array $capture ): ?string { + $body = self::json_body( $capture ); + if ( ! is_array( $body ) || true === ( $body['success'] ?? null ) ) { + return null; + } + if ( is_string( $body['data'] ?? null ) ) { + return $body['data']; + } + return is_array( $body['data'] ?? null ) && isset( $body['data']['code'] ) ? (string) $body['data']['code'] : null; + } + + private static function status_code( array $capture ): ?int { + $headers = $capture['statusHeaders'] ?? array(); + $last = is_array( $headers ) ? end( $headers ) : false; + return is_array( $last ) && isset( $last['code'] ) ? (int) $last['code'] : null; + } + + private static function ajax_die_message( array $capture ): ?string { + $call = $capture['dieCalls'][0] ?? null; + if ( ! is_array( $call ) ) { + return null; + } + return (string) ( $call['message'] ?? '' ); + } + + private static function summarize_capture( array $capture ): array { + return array( + 'captured' => (bool) ( $capture['captured'] ?? false ), + 'returned' => (bool) ( $capture['returned'] ?? false ), + 'bufferBalanced' => (bool) ( $capture['bufferBalanced'] ?? false ), + 'json' => self::json_body( $capture ), + 'dieMessage' => self::ajax_die_message( $capture ), + 'statusHeaders' => $capture['statusHeaders'] ?? array(), + 'throwable' => $capture['throwable'] ?? null, + ); + } + + private static function customizer_ajax_action_names(): array { + return array( + 'wp_ajax_customize_save', + 'wp_ajax_customize_trash', + 'wp_ajax_customize_refresh_nonces', + 'wp_ajax_customize_override_changeset_lock', + ); + } + + private static function action_counts( array $names ): array { + $counts = array(); + foreach ( $names as $name ) { + $counts[ $name ] = (int) ( $GLOBALS['wp_actions'][ $name ] ?? 0 ); + } + return $counts; + } + + private static function all_sanitize_calls_match( array $calls ): bool { + foreach ( $calls as $call ) { + if ( empty( $call['matches'] ) ) { + return false; + } + } + return true; + } + + private static function event_names( array $events ): array { + return array_values( + array_map( + static function ( array $event ): string { + return (string) ( $event['name'] ?? '' ); + }, + $events + ) + ); + } + + private static function ordered_subsequence( array $actual, array $expected ): bool { + $offset = 0; + foreach ( $expected as $expected_name ) { + $found = false; + for ( $i = $offset, $count = count( $actual ); $i < $count; ++$i ) { + if ( $expected_name === $actual[ $i ] ) { + $offset = $i + 1; + $found = true; + break; + } + } + + if ( ! $found ) { + return false; + } + } + + return true; + } + + private static function event_counts_are_one( array $actual, array $expected ): bool { + $counts = array_count_values( $actual ); + foreach ( $expected as $expected_name ) { + if ( 1 !== ( $counts[ $expected_name ] ?? 0 ) ) { + return false; + } + } + + return true; + } + + private static function all_events_match( array $events ): bool { + foreach ( $events as $event ) { + if ( empty( $event['matches'] ) ) { + return false; + } + } + + return array() !== $events; + } + + private static function js_validity_has_code( $validity, string $code ): bool { + return is_array( $validity ) && array_key_exists( $code, $validity ); + } + + private static function same_value( $expected, $actual ): bool { + return $expected === $actual; + } + + private static function wp_error_code_is( $value, string $code ): bool { + return $value instanceof \WP_Error && $code === $value->get_error_code(); + } + + private static function collect_failure( array &$failures, bool $condition, string $label, array $details ): void { + if ( $condition ) { + return; + } + + $failures[] = array( + 'label' => $label, + 'details' => self::describe_value( $details ), + ); + } + + private static function row( \ComponentFuzz\FuzzContext $ctx, string $invariant, bool $ok, array $data = array() ): array { + return $ok ? $ctx->pass( $invariant, $data ) : $ctx->fail( $invariant, $data ); + } + + private static function describe_lock( $lock ): array { + return array( + 'raw' => is_string( $lock ) ? self::preview( $lock ) : self::describe_value( $lock ), + 'parsed' => self::parse_changeset_lock( $lock ), + ); + } + + private static function describe_lock_user( $user ) { + if ( ! $user instanceof \WP_User ) { + return self::describe_value( $user ); + } + + return array( + 'ID' => $user->ID, + 'user_login' => $user->user_login, + 'display_name' => $user->display_name, + ); + } + + private static function describe_post( $post ) { + if ( ! $post instanceof \WP_Post ) { + return self::describe_value( $post ); + } + + return array( + 'ID' => $post->ID, + 'post_type' => $post->post_type, + 'post_status' => $post->post_status, + 'post_name' => $post->post_name, + 'post_title' => $post->post_title, + 'post_content' => self::preview( $post->post_content ), + 'post_content_filtered' => self::preview( $post->post_content_filtered ), + ); + } + + private static function describe_throwable( \Throwable $throwable ): array { + return array( + 'class' => get_class( $throwable ), + 'message' => $throwable->getMessage(), + 'file' => $throwable->getFile(), + 'line' => $throwable->getLine(), + ); + } + + private static function describe_value( $value ) { + if ( $value instanceof \WP_Error ) { + return array( + 'wpErrorCodes' => $value->get_error_codes(), + 'wpErrorData' => $value->get_all_error_data(), + ); + } + + if ( $value instanceof \WP_Post ) { + return self::describe_post( $value ); + } + + if ( is_object( $value ) ) { + return '[object ' . get_class( $value ) . ']'; + } + + if ( is_array( $value ) ) { + $out = array(); + foreach ( $value as $key => $item ) { + $out[ $key ] = self::describe_value( $item ); + } + return $out; + } + + if ( is_string( $value ) ) { + return self::preview( $value ); + } + + return $value; + } + + private static function preview( string $value ) { + return \ComponentFuzz\preview_value( $value ); + } +} + +final class CustomizerPersistence_DieCaptured extends \RuntimeException {} diff --git a/tools/component-fuzz/surfaces/CustomizerSurface.php b/tools/component-fuzz/surfaces/CustomizerSurface.php new file mode 100644 index 0000000000000..2a9574301aefe --- /dev/null +++ b/tools/component-fuzz/surfaces/CustomizerSurface.php @@ -0,0 +1,2649 @@ +skip( + 'customizer.bootstrap-apis-available', + 'Required Customizer APIs are unavailable.', + array( 'missing' => $missing ) + ), + ); + } + + $snapshot = self::snapshot_state(); + $rows = array(); + + try { + self::reset_runtime(); + + $rows[] = self::check_registry_lifecycle_and_ordering( $ctx->fork( 'registry' ) ); + $rows[] = self::check_setting_callbacks_and_post_values( $ctx->fork( 'settings' ) ); + $rows[] = self::check_manager_post_value_merging( $ctx->fork( 'post-values' ) ); + $rows[] = self::check_multidimensional_values( $ctx->fork( 'multidimensional' ) ); + $rows[] = self::check_json_and_active_callbacks( $ctx->fork( 'json-active' ) ); + $rows[] = self::check_control_rendering_contracts( $ctx->fork( 'control-rendering' ) ); + $rows[] = self::check_media_control_subclasses( $ctx->fork( 'media-controls' ) ); + $rows[] = self::check_selective_refresh_partials( $ctx->fork( 'partials' ) ); + $rows[] = self::check_theme_preview_lifecycle( $ctx->fork( 'theme-preview' ) ); + } catch ( \Throwable $e ) { + $rows[] = $ctx->fail( + 'customizer.surface-no-throw', + array( 'throwable' => self::describe_throwable( $e ) ) + ); + } finally { + self::restore_state( $snapshot ); + } + + return $rows; + } + + public static function grant_runtime_capabilities( array $allcaps ): array { + $allcaps[ self::CAPABILITY ] = true; + $allcaps['customize'] = true; + $allcaps['edit_theme_options'] = true; + $allcaps['upload_files'] = true; + return $allcaps; + } + + private static function missing_requirements(): array { + $missing = array(); + + foreach ( + array( + 'WP_Customize_Manager', + 'WP_Customize_Setting', + 'WP_Customize_Control', + 'WP_Customize_Cropped_Image_Control', + 'WP_Customize_Image_Control', + 'WP_Customize_Section', + 'WP_Customize_Media_Control', + 'WP_Customize_Panel', + 'WP_Customize_Selective_Refresh', + 'WP_Customize_Partial', + 'WP_Customize_Site_Icon_Control', + 'WP_Customize_Upload_Control', + 'WP_Error', + 'WP_Post', + 'WP_Rewrite', + ) as $class + ) { + if ( ! class_exists( $class ) ) { + $missing[] = "class {$class}"; + } + } + + foreach ( + array( + '__', + 'absint', + 'add_action', + 'add_filter', + 'apply_filters', + 'attachment_url_to_postid', + 'checked', + 'current_user_can', + 'esc_attr', + 'esc_html', + 'esc_js', + 'esc_textarea', + 'get_bloginfo', + 'get_option', + 'get_raw_theme_root', + 'get_stylesheet', + 'get_template', + 'has_action', + 'has_filter', + 'is_wp_error', + 'remove_action', + 'remove_filter', + 'selected', + 'update_option', + 'wp_basename', + 'wp_cache_set', + 'wp_check_filetype', + 'wp_get_ext_types', + 'wp_json_encode', + 'wp_mime_type_icon', + 'wp_prepare_attachment_for_js', + 'wp_slash', + ) as $function + ) { + if ( ! function_exists( $function ) ) { + $missing[] = "function {$function}"; + } + } + + return $missing; + } + + private static function check_registry_lifecycle_and_ordering( \ComponentFuzz\FuzzContext $ctx ): array { + $manager = self::manager( $ctx ); + $failures = array(); + + $setting_id = self::id( $ctx, 'setting' ); + $setting = $manager->add_setting( + $setting_id, + array( + 'type' => 'component_fuzz_no_db', + 'capability' => self::CAPABILITY, + 'default' => self::fuzz_value( $ctx->fork( 'default' ) ), + 'transport' => $ctx->choice( array( 'refresh', 'postMessage' ) ), + 'dirty' => true, + ) + ); + + $temp_setting_id = self::id( $ctx->fork( 'temp-setting' ), 'setting' ); + $temp_setting = $manager->add_setting( + $temp_setting_id, + array( + 'type' => 'component_fuzz_no_db', + 'capability' => self::CAPABILITY, + 'default' => 'temporary', + ) + ); + $manager->remove_setting( $temp_setting_id ); + + self::collect_failure( + $failures, + $setting instanceof \WP_Customize_Setting + && $setting === $manager->get_setting( $setting_id ) + && $setting_id === $setting->id + && true === $setting->json()['dirty'] + && null === $manager->get_setting( $temp_setting_id ) + && $temp_setting instanceof \WP_Customize_Setting, + 'add_setting/get_setting/remove_setting keep the registry consistent', + array( + 'settingId' => $setting_id, + 'tempSettingId' => $temp_setting_id, + 'json' => $setting->json(), + ) + ); + + $panel_a = self::id( $ctx->fork( 'panel-a' ), 'panel' ); + $panel_b = self::id( $ctx->fork( 'panel-b' ), 'panel' ); + $panel_c = self::id( $ctx->fork( 'panel-c' ), 'panel' ); + $manager->add_panel( + $panel_a, + array( + 'priority' => 5, + 'title' => 'Panel A', + 'capability' => self::CAPABILITY, + ) + ); + $manager->add_panel( + $panel_b, + array( + 'priority' => 20, + 'title' => 'Panel B', + 'capability' => self::CAPABILITY, + ) + ); + $manager->add_panel( + $panel_c, + array( + 'priority' => 5, + 'title' => 'Panel C', + 'capability' => self::CAPABILITY, + ) + ); + + $temp_panel = self::id( $ctx->fork( 'temp-panel' ), 'panel' ); + $manager->add_panel( $temp_panel, array( 'capability' => self::CAPABILITY ) ); + $manager->remove_panel( $temp_panel ); + + self::collect_failure( + $failures, + $manager->get_panel( $panel_a ) instanceof \WP_Customize_Panel + && $manager->get_panel( $panel_b ) instanceof \WP_Customize_Panel + && $manager->get_panel( $panel_c ) instanceof \WP_Customize_Panel + && null === $manager->get_panel( $temp_panel ), + 'add_panel/get_panel/remove_panel keep the registry consistent', + array( + 'panels' => array( $panel_a, $panel_b, $panel_c ), + 'tempPanel' => $temp_panel, + ) + ); + + $section_main = self::id( $ctx->fork( 'section-main' ), 'section' ); + $section_a = self::id( $ctx->fork( 'section-a' ), 'section' ); + $section_b = self::id( $ctx->fork( 'section-b' ), 'section' ); + $manager->add_section( + $section_main, + array( + 'priority' => 15, + 'title' => 'Main ' . self::unsafe_string( $ctx->fork( 'section-title' ) ), + 'capability' => self::CAPABILITY, + ) + ); + $manager->add_section( + $section_a, + array( + 'priority' => 5, + 'title' => 'Section A', + 'capability' => self::CAPABILITY, + ) + ); + $manager->add_section( + $section_b, + array( + 'priority' => 5, + 'title' => 'Section B', + 'capability' => self::CAPABILITY, + ) + ); + + $temp_section = self::id( $ctx->fork( 'temp-section' ), 'section' ); + $manager->add_section( $temp_section, array( 'capability' => self::CAPABILITY ) ); + $manager->remove_section( $temp_section ); + + self::collect_failure( + $failures, + $manager->get_section( $section_main ) instanceof \WP_Customize_Section + && $manager->get_section( $section_a ) instanceof \WP_Customize_Section + && $manager->get_section( $section_b ) instanceof \WP_Customize_Section + && null === $manager->get_section( $temp_section ), + 'add_section/get_section/remove_section keep the registry consistent', + array( + 'sections' => array( $section_a, $section_b, $section_main ), + 'tempSection' => $temp_section, + ) + ); + + $control_a = self::id( $ctx->fork( 'control-a' ), 'control' ); + $control_b = self::id( $ctx->fork( 'control-b' ), 'control' ); + $control_c = self::id( $ctx->fork( 'control-c' ), 'control' ); + $manager->add_control( + $control_a, + array( + 'settings' => $setting_id, + 'section' => $section_main, + 'priority' => 30, + 'label' => 'Control A', + 'capability' => self::CAPABILITY, + ) + ); + $manager->add_control( + $control_b, + array( + 'settings' => $setting_id, + 'section' => $section_main, + 'priority' => 10, + 'label' => 'Control B', + 'capability' => self::CAPABILITY, + ) + ); + $manager->add_control( + $control_c, + array( + 'settings' => $setting_id, + 'section' => $section_main, + 'priority' => 10, + 'label' => 'Control C', + 'capability' => self::CAPABILITY, + ) + ); + + $temp_control = self::id( $ctx->fork( 'temp-control' ), 'control' ); + $manager->add_control( + $temp_control, + array( + 'settings' => $setting_id, + 'section' => $section_main, + 'capability' => self::CAPABILITY, + ) + ); + $manager->remove_control( $temp_control ); + + self::collect_failure( + $failures, + $manager->get_control( $control_a ) instanceof \WP_Customize_Control + && $manager->get_control( $control_b ) instanceof \WP_Customize_Control + && $manager->get_control( $control_c ) instanceof \WP_Customize_Control + && null === $manager->get_control( $temp_control ), + 'add_control/get_control/remove_control keep the registry consistent', + array( + 'controls' => array( $control_a, $control_b, $control_c ), + 'tempControl' => $temp_control, + ) + ); + + self::with_capabilities( + static function () use ( $manager ): void { + $manager->prepare_controls(); + } + ); + + $panels = array_keys( $manager->panels() ); + $sections = array_keys( $manager->sections() ); + $section_control = $manager->get_section( $section_main ); + $control_order = $section_control instanceof \WP_Customize_Section + ? array_map( + static function ( \WP_Customize_Control $control ): string { + return $control->id; + }, + $section_control->controls + ) + : array(); + + self::collect_failure( + $failures, + array( $panel_a, $panel_c, $panel_b ) === array_values( + array_intersect( $panels, array( $panel_a, $panel_b, $panel_c ) ) + ) + && array( $section_a, $section_b, $section_main ) === array_values( + array_intersect( $sections, array( $section_a, $section_b, $section_main ) ) + ) + && array( $control_b, $control_c, $control_a ) === $control_order, + 'prepare_controls sorts panels, sections, and controls by priority with stable instance order', + array( + 'panelOrder' => $panels, + 'sectionOrder' => $sections, + 'controlOrder' => $control_order, + ) + ); + + return self::row( + $ctx, + 'customizer.registry.lifecycle-priority', + array() === $failures, + array( 'failures' => $failures ) + ); + } + + private static function check_setting_callbacks_and_post_values( \ComponentFuzz\FuzzContext $ctx ): array { + $manager = self::manager( $ctx ); + $failures = array(); + $id = self::id( $ctx, 'option' ); + $raw = self::non_null_value( $ctx->fork( 'raw' ) ); + $expected = array( + 'kind' => gettype( $raw ), + 'value' => self::stringify_value( $raw ), + 'id' => $id, + ); + $calls = array( + 'sanitize' => array(), + 'validate' => array(), + 'js' => array(), + ); + + $sanitize_callback = static function ( $value, \WP_Customize_Setting $setting ) use ( &$calls, $raw, $expected, $id ) { + $calls['sanitize'][] = array( + 'value' => self::describe_value( $value ), + 'settingId' => $setting->id, + 'sameSetting' => $id === $setting->id, + ); + + if ( $value === $raw ) { + return $expected; + } + + return $value; + }; + $validate_callback = static function ( \WP_Error $validity, $value, \WP_Customize_Setting $setting ) use ( &$calls, $id ) { + $calls['validate'][] = array( + 'value' => self::describe_value( $value ), + 'settingId' => $setting->id, + 'sameSetting' => $id === $setting->id, + ); + return $validity; + }; + $sanitize_js_callback = static function ( $value, \WP_Customize_Setting $setting ) use ( &$calls, $id ) { + $calls['js'][] = array( + 'value' => self::describe_value( $value ), + 'settingId' => $setting->id, + 'sameSetting' => $id === $setting->id, + ); + return array( + 'js' => true, + 'setting' => $setting->id, + 'value' => $value, + ); + }; + + $setting = $manager->add_setting( + $id, + array( + 'type' => 'option', + 'capability' => self::CAPABILITY, + 'default' => 'fallback', + 'transport' => 'postMessage', + 'dirty' => true, + 'sanitize_callback' => $sanitize_callback, + 'validate_callback' => $validate_callback, + 'sanitize_js_callback' => $sanitize_js_callback, + ) + ); + + $invalid_id = self::id( $ctx->fork( 'invalid' ), 'option' ); + $invalid_calls = array( + 'validate' => 0, + 'sanitize' => 0, + ); + $manager->add_setting( + $invalid_id, + array( + 'type' => 'option', + 'capability' => self::CAPABILITY, + 'sanitize_callback' => static function ( $value ) use ( &$invalid_calls ) { + ++$invalid_calls['sanitize']; + return $value; + }, + 'validate_callback' => static function ( \WP_Error $validity ) use ( &$invalid_calls ) { + ++$invalid_calls['validate']; + $validity->add( 'component_fuzz_invalid', 'Component fuzz invalid value.' ); + return $validity; + }, + ) + ); + + $null_id = self::id( $ctx->fork( 'null' ), 'option' ); + $null_calls = array( + 'validate' => 0, + 'sanitize' => 0, + ); + $manager->add_setting( + $null_id, + array( + 'type' => 'option', + 'capability' => self::CAPABILITY, + 'sanitize_callback' => static function ( $value ) use ( &$null_calls ) { + ++$null_calls['sanitize']; + return $value; + }, + 'validate_callback' => static function ( \WP_Error $validity ) use ( &$null_calls ) { + ++$null_calls['validate']; + return $validity; + }, + ) + ); + + $manager->set_post_value( $id, $raw ); + + $post_value = $setting->post_value( 'fallback-post' ); + $previewed = $setting->preview(); + $value_after_preview = $setting->value(); + $js_value = $setting->js_value(); + $unknown_id = self::id( $ctx->fork( 'unknown' ), 'option' ); + $validities = $manager->validate_setting_values( + array( + $id => $raw, + $invalid_id => 'invalid', + $null_id => null, + $unknown_id => 'unknown', + ), + array( 'validate_existence' => true ) + ); + + self::collect_failure( + $failures, + $expected === $post_value + && true === $previewed + && $expected === $value_after_preview + && array( + 'js' => true, + 'setting' => $id, + 'value' => $expected, + ) === $js_value + && true === ( $validities[ $id ] ?? null ), + 'post values are sanitized, previewed, exported to JS, and validated deterministically', + array( + 'raw' => self::describe_value( $raw ), + 'postValue' => $post_value, + 'valueAfterPreview' => $value_after_preview, + 'jsValue' => $js_value, + 'validity' => self::describe_value( $validities[ $id ] ?? null ), + ) + ); + + self::collect_failure( + $failures, + 6 === count( $calls['validate'] ) + && 5 === count( $calls['sanitize'] ) + && 1 === count( $calls['js'] ) + && self::all_call_settings_match( $calls['validate'] ) + && self::all_call_settings_match( $calls['sanitize'] ) + && self::all_call_settings_match( $calls['js'] ), + 'custom sanitize, validate, and JS callbacks are invoked exactly as expected', + array( 'calls' => $calls ) + ); + + self::collect_failure( + $failures, + isset( $validities[ $invalid_id ] ) + && \is_wp_error( $validities[ $invalid_id ] ) + && $validities[ $invalid_id ]->get_error_code() === 'component_fuzz_invalid' + && 1 === $invalid_calls['validate'] + && 0 === $invalid_calls['sanitize'] + && ! array_key_exists( $null_id, $validities ) + && 0 === $null_calls['validate'] + && 0 === $null_calls['sanitize'] + && isset( $validities[ $unknown_id ] ) + && \is_wp_error( $validities[ $unknown_id ] ) + && 'unrecognized' === $validities[ $unknown_id ]->get_error_code(), + 'validate_setting_values marks invalid/unrecognized values and skips null without callbacks', + array( + 'invalidValidity' => self::describe_value( $validities[ $invalid_id ] ?? null ), + 'invalidCalls' => $invalid_calls, + 'nullCalls' => $null_calls, + 'unknownValidity' => self::describe_value( $validities[ $unknown_id ] ?? null ), + ) + ); + + return self::row( + $ctx, + 'customizer.settings.callbacks-post-values', + array() === $failures, + array( 'failures' => $failures ) + ); + } + + private static function check_manager_post_value_merging( \ComponentFuzz\FuzzContext $ctx ): array { + $manager = self::manager( $ctx ); + $failures = array(); + $posted_id = self::id( $ctx, 'posted' ); + $program_id = self::id( $ctx->fork( 'program' ), 'posted' ); + $unknown_id = self::id( $ctx->fork( 'unknown' ), 'posted' ); + $posted_value = array( + 'source' => 'post', + 'value' => self::non_null_value( $ctx->fork( 'posted-value' ) ), + ); + $program_from_post = array( + 'source' => 'post-before-programmatic', + 'value' => self::non_null_value( $ctx->fork( 'program-posted' ) ), + ); + $program_override = array( + 'source' => 'programmatic', + 'value' => self::non_null_value( $ctx->fork( 'program-override' ) ), + ); + $sanitize_calls = array(); + $post_value_events = array(); + $expected_sanitized = static function ( string $setting_id, $value ): array { + return array( + 'setting' => $setting_id, + 'value' => $value, + 'encoded' => self::stringify_value( $value ), + ); + }; + $sanitize_callback = static function ( $value, \WP_Customize_Setting $setting ) use ( &$sanitize_calls, $expected_sanitized ): array { + $sanitize_calls[] = array( + 'setting' => $setting->id, + 'value' => self::describe_value( $value ), + ); + return $expected_sanitized( $setting->id, $value ); + }; + + $posted_setting = $manager->add_setting( + $posted_id, + array( + 'type' => 'option', + 'capability' => self::CAPABILITY, + 'default' => 'posted-default', + 'sanitize_callback' => $sanitize_callback, + ) + ); + $program_setting = $manager->add_setting( + $program_id, + array( + 'type' => 'option', + 'capability' => self::CAPABILITY, + 'default' => 'program-default', + 'sanitize_callback' => $sanitize_callback, + ) + ); + + $customized = \wp_json_encode( + array( + $posted_id => $posted_value, + $program_id => $program_from_post, + ) + ); + if ( ! is_string( $customized ) ) { + throw new \RuntimeException( 'Could not encode Customizer posted values.' ); + } + $decoded_customized = json_decode( $customized, true ); + if ( ! is_array( $decoded_customized ) ) { + throw new \RuntimeException( 'Could not decode Customizer posted values.' ); + } + $posted_value_from_json = $decoded_customized[ $posted_id ] ?? null; + $program_from_post_from_json = $decoded_customized[ $program_id ] ?? null; + + $_POST['customized'] = \wp_slash( $customized ); + $_REQUEST['customized'] = $_POST['customized']; + self::set_object_property( $manager, '_post_values', null ); + + $from_post = self::with_capabilities( + static function () use ( $manager ): array { + return $manager->unsanitized_post_values( + array( + 'exclude_changeset' => true, + 'exclude_post_data' => false, + ) + ); + } + ); + + $dynamic_action = static function ( $value, \WP_Customize_Manager $seen_manager ) use ( &$post_value_events, $manager ): void { + $post_value_events[] = array( + 'hook' => 'dynamic', + 'value' => self::describe_value( $value ), + 'sameManager' => $seen_manager === $manager, + ); + }; + $global_action = static function ( string $setting_id, $value, \WP_Customize_Manager $seen_manager ) use ( &$post_value_events, $manager ): void { + $post_value_events[] = array( + 'hook' => 'global', + 'setting' => $setting_id, + 'value' => self::describe_value( $value ), + 'sameManager' => $seen_manager === $manager, + ); + }; + + \add_action( "customize_post_value_set_{$program_id}", $dynamic_action, 10, 2 ); + \add_action( 'customize_post_value_set', $global_action, 10, 3 ); + try { + $manager->set_post_value( $program_id, $program_override ); + } finally { + \remove_action( 'customize_post_value_set', $global_action, 10 ); + \remove_action( "customize_post_value_set_{$program_id}", $dynamic_action, 10 ); + } + + $merged = self::with_capabilities( + static function () use ( $manager ): array { + return $manager->unsanitized_post_values( + array( + 'exclude_changeset' => true, + 'exclude_post_data' => false, + ) + ); + } + ); + $posted_post_value = $manager->post_value( $posted_setting, 'posted-fallback' ); + $program_post_value = $manager->post_value( $program_setting, 'program-fallback' ); + $validities = $manager->validate_setting_values( + array( + $posted_id => $posted_value_from_json, + $program_id => $program_override, + $unknown_id => 'unknown', + ), + array( 'validate_existence' => true ) + ); + + self::collect_failure( + $failures, + array( + $posted_id => $posted_value_from_json, + $program_id => $program_from_post_from_json, + ) === $from_post + && $posted_value_from_json === ( $merged[ $posted_id ] ?? null ) + && $program_override === ( $merged[ $program_id ] ?? null ), + 'unsanitized_post_values parses slashed customized JSON and programmatic values override posted values', + array( + 'fromPost' => $from_post, + 'merged' => $merged, + ) + ); + + self::collect_failure( + $failures, + $expected_sanitized( $posted_id, $posted_value_from_json ) === $posted_post_value + && $expected_sanitized( $program_id, $program_override ) === $program_post_value + && true === ( $validities[ $posted_id ] ?? null ) + && true === ( $validities[ $program_id ] ?? null ) + && isset( $validities[ $unknown_id ] ) + && \is_wp_error( $validities[ $unknown_id ] ) + && 'unrecognized' === $validities[ $unknown_id ]->get_error_code(), + 'post_value sanitizes merged values and validate_setting_values reports unknown settings', + array( + 'postedPostValue' => $posted_post_value, + 'programPostValue' => $program_post_value, + 'validities' => self::describe_value( $validities ), + 'sanitizeCalls' => $sanitize_calls, + ) + ); + + self::collect_failure( + $failures, + array( + array( + 'hook' => 'dynamic', + 'value' => self::describe_value( $program_override ), + 'sameManager' => true, + ), + array( + 'hook' => 'global', + 'setting' => $program_id, + 'value' => self::describe_value( $program_override ), + 'sameManager' => true, + ), + ) === $post_value_events + && false === \has_action( "customize_post_value_set_{$program_id}", $dynamic_action ) + && false === \has_action( 'customize_post_value_set', $global_action ), + 'set_post_value fires scoped and global events and removes temporary hooks', + array( 'events' => $post_value_events ) + ); + + return self::row( + $ctx, + 'customizer.manager.post-value-json-merge-events', + array() === $failures, + array( 'failures' => $failures ) + ); + } + + private static function check_multidimensional_values( \ComponentFuzz\FuzzContext $ctx ): array { + $manager = self::manager( $ctx ); + $failures = array(); + $root_id = self::id( $ctx, 'root' ); + $key_a = 'alpha_' . $ctx->identifier( 3, 8 ); + $key_b = 'beta_' . $ctx->identifier( 3, 8 ); + $id = $root_id . '[' . $key_a . '][' . $key_b . ']'; + $initial = self::non_null_value( $ctx->fork( 'initial' ) ); + $posted = self::non_null_value( $ctx->fork( 'posted' ) ); + $updated = self::non_null_value( $ctx->fork( 'updated' ) ); + $root = array( + $key_a => array( + $key_b => $initial, + 'spare' => self::unsafe_string( $ctx->fork( 'spare' ) ), + ), + 'flat' => $ctx->int( -50, 50 ), + ); + + \update_option( $root_id, $root, false ); + + $sanitize_calls = array(); + $setting = $manager->add_setting( + $id, + array( + 'type' => 'option', + 'capability' => self::CAPABILITY, + 'default' => 'fallback-dimensional', + 'sanitize_callback' => static function ( $value, \WP_Customize_Setting $setting ) use ( &$sanitize_calls ) { + $sanitize_calls[] = array( + 'value' => self::describe_value( $value ), + 'settingId' => $setting->id, + ); + return $value; + }, + ) + ); + + $id_data = $setting->id_data(); + self::collect_failure( + $failures, + $root_id === $id_data['base'] + && array( $key_a, $key_b ) === $id_data['keys'] + && $initial === $setting->value(), + 'multidimensional setting ID is parsed and initial option leaf is read', + array( + 'id' => $id, + 'idData' => $id_data, + 'initial' => self::describe_value( $initial ), + 'value' => self::describe_value( $setting->value() ), + ) + ); + + $manager->set_post_value( $setting->id, $posted ); + $unsanitized = $manager->unsanitized_post_values( + array( + 'exclude_changeset' => true, + 'exclude_post_data' => false, + ) + ); + $post_value = $setting->post_value( 'fallback-posted' ); + $previewed = $setting->preview(); + $value = $setting->value(); + $preview_root = \get_option( $root_id ); + + self::collect_failure( + $failures, + array_key_exists( $setting->id, $unsanitized ) + && $posted === $unsanitized[ $setting->id ] + && $posted === $post_value + && true === $previewed + && $posted === $value + && is_array( $preview_root ) + && $posted === self::nested_get( $preview_root, array( $key_a, $key_b ), null ) + && self::nested_get( $root, array( $key_a, 'spare' ), null ) === self::nested_get( $preview_root, array( $key_a, 'spare' ), null ), + 'set_post_value and preview round-trip multidimensional values without clobbering siblings', + array( + 'unsanitized' => $unsanitized, + 'postValue' => self::describe_value( $post_value ), + 'value' => self::describe_value( $value ), + 'previewRoot' => self::describe_value( $preview_root ), + ) + ); + + $manager->set_post_value( $setting->id, $updated ); + $value_after_second_post = $setting->value(); + $preview_root_updated = \get_option( $root_id ); + + self::collect_failure( + $failures, + $updated === $value_after_second_post + && is_array( $preview_root_updated ) + && $updated === self::nested_get( $preview_root_updated, array( $key_a, $key_b ), null ) + && count( $sanitize_calls ) >= 3, + 'multidimensional dirty state is cleared when a new post value is set', + array( + 'updated' => self::describe_value( $updated ), + 'valueAfterPost' => self::describe_value( $value_after_second_post ), + 'previewRoot' => self::describe_value( $preview_root_updated ), + 'sanitizeCalls' => $sanitize_calls, + ) + ); + + return self::row( + $ctx, + 'customizer.settings.multidimensional-post-values', + array() === $failures, + array( 'failures' => $failures ) + ); + } + + private static function check_json_and_active_callbacks( \ComponentFuzz\FuzzContext $ctx ): array { + $manager = self::manager( $ctx ); + $failures = array(); + $setting_id = self::id( $ctx, 'json-setting' ); + $panel_id = self::id( $ctx->fork( 'panel' ), 'json-panel' ); + $section_id = self::id( $ctx->fork( 'section' ), 'json-section' ); + $control_id = self::id( $ctx->fork( 'control' ), 'json-control' ); + $unsafe_label = self::unsafe_string( $ctx->fork( 'label' ) ); + $unsafe_desc = self::unsafe_string( $ctx->fork( 'description' ) ); + $active_calls = array(); + $panel_active = $ctx->bool(); + $section_active = $ctx->bool(); + $control_active = $ctx->bool(); + + $manager->add_setting( + $setting_id, + array( + 'type' => 'component_fuzz_json', + 'capability' => self::CAPABILITY, + 'default' => self::unsafe_string( $ctx->fork( 'setting-default' ) ), + ) + ); + + $panel = $manager->add_panel( + $panel_id, + array( + 'title' => 'Panel & ' . $unsafe_label, + 'description' => $unsafe_desc, + 'priority' => 15, + 'capability' => self::CAPABILITY, + 'active_callback' => static function ( \WP_Customize_Panel $panel ) use ( &$active_calls, $panel_active ): bool { + $active_calls[] = array( + 'kind' => 'panel', + 'id' => $panel->id, + ); + return $panel_active; + }, + ) + ); + $section = $manager->add_section( + $section_id, + array( + 'title' => 'Section & ' . $unsafe_label, + 'description' => $unsafe_desc, + 'panel' => $panel_id, + 'priority' => 10, + 'capability' => self::CAPABILITY, + 'description_hidden' => $ctx->bool(), + 'active_callback' => static function ( \WP_Customize_Section $section ) use ( &$active_calls, $section_active ): bool { + $active_calls[] = array( + 'kind' => 'section', + 'id' => $section->id, + ); + return $section_active; + }, + ) + ); + $control = $manager->add_control( + $control_id, + array( + 'settings' => $setting_id, + 'section' => $section_id, + 'type' => 'text', + 'label' => $unsafe_label, + 'description' => $unsafe_desc, + 'priority' => 7, + 'capability' => self::CAPABILITY, + 'input_attrs' => array( + 'data-component-fuzz' => self::unsafe_string( $ctx->fork( 'input-attr' ) ), + ), + 'active_callback' => static function ( \WP_Customize_Control $control ) use ( &$active_calls, $control_active ): bool { + $active_calls[] = array( + 'kind' => 'control', + 'id' => $control->id, + ); + return $control_active; + }, + ) + ); + + $json = self::with_capabilities( + static function () use ( $panel, $section, $control ): array { + return array( + 'panel' => $panel->json(), + 'section' => $section->json(), + 'control' => $control->json(), + 'link' => $control->get_link(), + ); + } + ); + $encoded = \wp_json_encode( $json ); + $decoded = is_string( $encoded ) ? json_decode( $encoded, true ) : null; + + self::collect_failure( + $failures, + $panel_active === $json['panel']['active'] + && $section_active === $json['section']['active'] + && $control_active === $json['control']['active'] + && array( + array( + 'kind' => 'panel', + 'id' => $panel_id, + ), + array( + 'kind' => 'section', + 'id' => $section_id, + ), + array( + 'kind' => 'control', + 'id' => $control_id, + ), + ) === $active_calls, + 'panel, section, and control active callbacks are scoped to their own instances', + array( + 'expected' => array( $panel_active, $section_active, $control_active ), + 'json' => $json, + 'calls' => $active_calls, + ) + ); + + self::collect_failure( + $failures, + $panel_id === $json['panel']['id'] + && 'Panel & ' . $unsafe_label === $json['panel']['title'] + && $section_id === $json['section']['id'] + && $panel_id === $json['section']['panel'] + && 'Section & ' . $unsafe_label === $json['section']['title'] + && $control_id === $control->id + && $unsafe_label === $json['control']['label'] + && $unsafe_desc === $json['control']['description'] + && array( 'default' => $setting_id ) === $json['control']['settings'] + && str_contains( $json['link'], 'data-customize-setting-link="' ) + && str_contains( $json['link'], esc_attr( $setting_id ) ) + && is_string( $encoded ) + && is_array( $decoded ), + 'JSON exports preserve expected fields and encode deterministically', + array( + 'json' => $json, + 'encoded' => $encoded, + 'decoded' => $decoded, + ) + ); + + return self::row( + $ctx, + 'customizer.controls-containers.json-active-callbacks', + array() === $failures, + array( 'failures' => $failures ) + ); + } + + private static function check_control_rendering_contracts( \ComponentFuzz\FuzzContext $ctx ): array { + $manager = self::manager( $ctx ); + $failures = array(); + + $section_id = self::id( $ctx, 'render-section' ); + $manager->add_section( + $section_id, + array( + 'title' => 'Render Section', + 'capability' => self::CAPABILITY, + ) + ); + + $description = 'Component fuzz description & details'; + $controls = array(); + $events = array(); + $specifics = array(); + + $text_setting_id = self::id( $ctx->fork( 'text-setting' ), 'render-text-setting' ); + $text_value = self::unsafe_string( $ctx->fork( 'text-value' ) ); + $text_label = 'Text label ' . self::unsafe_string( $ctx->fork( 'text-label' ) ); + $text_attr = '" onmouseover="componentFuzz() '; + $manager->add_setting( + $text_setting_id, + array( + 'type' => 'component_fuzz_render', + 'capability' => self::CAPABILITY, + 'default' => $text_value, + ) + ); + $text_control_id = self::id( $ctx->fork( 'text-control' ), 'render-text-control' ); + $controls['text'] = $manager->add_control( + $text_control_id, + array( + 'settings' => $text_setting_id, + 'section' => $section_id, + 'type' => 'text', + 'label' => $text_label, + 'description' => $description, + 'capability' => self::CAPABILITY, + 'input_attrs' => array( + 'placeholder' => $text_attr, + 'data-component-fuzz' => self::unsafe_string( $ctx->fork( 'text-attr' ) ), + ), + ) + ); + + $textarea_setting_id = self::id( $ctx->fork( 'textarea-setting' ), 'render-textarea-setting' ); + $textarea_value = self::unsafe_string( $ctx->fork( 'textarea-value' ) ) . "\nline two"; + $textarea_label = 'Textarea ' . self::unsafe_string( $ctx->fork( 'textarea-label' ) ); + $manager->add_setting( + $textarea_setting_id, + array( + 'type' => 'component_fuzz_render', + 'capability' => self::CAPABILITY, + 'default' => $textarea_value, + ) + ); + $textarea_control_id = self::id( $ctx->fork( 'textarea-control' ), 'render-textarea-control' ); + $controls['textarea'] = $manager->add_control( + $textarea_control_id, + array( + 'settings' => $textarea_setting_id, + 'section' => $section_id, + 'type' => 'textarea', + 'label' => $textarea_label, + 'description' => $description, + 'capability' => self::CAPABILITY, + 'input_attrs' => array( + 'data-component-fuzz' => self::unsafe_string( $ctx->fork( 'textarea-attr' ) ), + ), + ) + ); + + $checkbox_setting_id = self::id( $ctx->fork( 'checkbox-setting' ), 'render-checkbox-setting' ); + $checkbox_label = 'Checkbox ' . self::unsafe_string( $ctx->fork( 'checkbox-label' ) ); + $manager->add_setting( + $checkbox_setting_id, + array( + 'type' => 'component_fuzz_render', + 'capability' => self::CAPABILITY, + 'default' => '1', + ) + ); + $checkbox_control_id = self::id( $ctx->fork( 'checkbox-control' ), 'render-checkbox-control' ); + $controls['checkbox'] = $manager->add_control( + $checkbox_control_id, + array( + 'settings' => $checkbox_setting_id, + 'section' => $section_id, + 'type' => 'checkbox', + 'label' => $checkbox_label, + 'description' => $description, + 'capability' => self::CAPABILITY, + ) + ); + + $radio_setting_id = self::id( $ctx->fork( 'radio-setting' ), 'render-radio-setting' ); + $radio_value = 'beta-' . $ctx->identifier( 3, 8 ); + $radio_other = 'alpha-' . $ctx->identifier( 3, 8 ); + $radio_label = 'Radio ' . self::unsafe_string( $ctx->fork( 'radio-label' ) ); + $radio_choices = array( + $radio_other => 'Alpha ' . self::unsafe_string( $ctx->fork( 'radio-alpha-label' ) ), + $radio_value => 'Beta ' . self::unsafe_string( $ctx->fork( 'radio-beta-label' ) ), + ); + $manager->add_setting( + $radio_setting_id, + array( + 'type' => 'component_fuzz_render', + 'capability' => self::CAPABILITY, + 'default' => $radio_value, + ) + ); + $radio_control_id = self::id( $ctx->fork( 'radio-control' ), 'render-radio-control' ); + $controls['radio'] = $manager->add_control( + $radio_control_id, + array( + 'settings' => $radio_setting_id, + 'section' => $section_id, + 'type' => 'radio', + 'label' => $radio_label, + 'description' => $description, + 'capability' => self::CAPABILITY, + 'choices' => $radio_choices, + ) + ); + + $select_setting_id = self::id( $ctx->fork( 'select-setting' ), 'render-select-setting' ); + $select_value = 'two-' . $ctx->identifier( 3, 8 ); + $select_other = 'one-' . $ctx->identifier( 3, 8 ); + $select_label = 'Select ' . self::unsafe_string( $ctx->fork( 'select-label' ) ); + $select_choices = array( + $select_other => 'One ' . self::unsafe_string( $ctx->fork( 'select-one-label' ) ), + $select_value => 'Two ' . self::unsafe_string( $ctx->fork( 'select-two-label' ) ), + ); + $manager->add_setting( + $select_setting_id, + array( + 'type' => 'component_fuzz_render', + 'capability' => self::CAPABILITY, + 'default' => $select_value, + ) + ); + $select_control_id = self::id( $ctx->fork( 'select-control' ), 'render-select-control' ); + $controls['select'] = $manager->add_control( + $select_control_id, + array( + 'settings' => $select_setting_id, + 'section' => $section_id, + 'type' => 'select', + 'label' => $select_label, + 'description' => $description, + 'capability' => self::CAPABILITY, + 'choices' => $select_choices, + ) + ); + + $denied_setting_id = self::id( $ctx->fork( 'denied-setting' ), 'render-denied-setting' ); + $manager->add_setting( + $denied_setting_id, + array( + 'type' => 'component_fuzz_render', + 'capability' => 'component_fuzz_denied_cap', + 'default' => 'denied', + ) + ); + $denied_control_id = self::id( $ctx->fork( 'denied-control' ), 'render-denied-control' ); + $denied_control = $manager->add_control( + $denied_control_id, + array( + 'settings' => $denied_setting_id, + 'section' => $section_id, + 'type' => 'text', + 'label' => 'Denied', + 'capability' => 'component_fuzz_denied_cap', + ) + ); + + $global_action = static function ( \WP_Customize_Control $control ) use ( &$events ): void { + $events[] = array( + 'hook' => 'global', + 'id' => $control->id, + 'type' => $control->type, + ); + }; + foreach ( array_merge( $controls, array( 'denied' => $denied_control ) ) as $kind => $control ) { + $specifics[ $control->id ] = static function ( \WP_Customize_Control $seen ) use ( &$events, $kind ): void { + $events[] = array( + 'hook' => 'specific', + 'kind' => $kind, + 'id' => $seen->id, + 'type' => $seen->type, + ); + }; + } + + \add_action( 'customize_render_control', $global_action, 10, 1 ); + foreach ( $specifics as $control_id => $callback ) { + \add_action( "customize_render_control_{$control_id}", $callback, 10, 1 ); + } + + try { + $outputs = self::with_capabilities( + static function () use ( $controls, $denied_control ): array { + $rendered = array(); + foreach ( $controls as $kind => $control ) { + $rendered[ $kind ] = $control instanceof \WP_Customize_Control ? $control->get_content() : ''; + } + $rendered['denied'] = $denied_control instanceof \WP_Customize_Control ? $denied_control->get_content() : ''; + return $rendered; + } + ); + } finally { + \remove_action( 'customize_render_control', $global_action, 10 ); + foreach ( $specifics as $control_id => $callback ) { + \remove_action( "customize_render_control_{$control_id}", $callback, 10 ); + } + } + + $rendered_ids = array( $text_control_id, $textarea_control_id, $checkbox_control_id, $radio_control_id, $select_control_id ); + $global_event_ids = array_values( + array_map( + static fn ( array $event ): string => (string) $event['id'], + array_filter( + $events, + static fn ( array $event ): bool => 'global' === $event['hook'] + ) + ) + ); + $specific_event_ids = array_values( + array_map( + static fn ( array $event ): string => (string) $event['id'], + array_filter( + $events, + static fn ( array $event ): bool => 'specific' === $event['hook'] + ) + ) + ); + + self::collect_failure( + $failures, + $rendered_ids === $global_event_ids + && $rendered_ids === $specific_event_ids + && '' === ( $outputs['denied'] ?? null ) + && ! in_array( $denied_control_id, $global_event_ids, true ) + && ! in_array( $denied_control_id, $specific_event_ids, true ) + && false === \has_action( 'customize_render_control', $global_action ) + && self::all_specific_render_hooks_removed( $specifics ), + 'maybe_render fires global and specific hooks for capable controls only and restores temporary hooks', + array( + 'events' => $events, + 'globalIds' => $global_event_ids, + 'specificIds' => $specific_event_ids, + 'deniedHtml' => self::describe_value( $outputs['denied'] ?? null ), + ) + ); + + $text_html = (string) ( $outputs['text'] ?? '' ); + self::collect_failure( + $failures, + str_contains( $text_html, 'id="customize-control-' . esc_attr( str_replace( array( '[', ']' ), array( '-', '' ), $text_control_id ) ) . '"' ) + && str_contains( $text_html, 'class="customize-control customize-control-text"' ) + && str_contains( $text_html, esc_html( $text_label ) ) + && str_contains( $text_html, 'value="' . esc_attr( $text_value ) . '"' ) + && str_contains( $text_html, 'data-customize-setting-link="' . esc_attr( $text_setting_id ) . '"' ) + && str_contains( $text_html, 'placeholder="' . esc_attr( $text_attr ) . '"' ) + && str_contains( $text_html, $description ) + && ! str_contains( $text_html, $text_label ) + && ! str_contains( $text_html, $text_value ), + 'text control rendering escapes labels, values, input attributes, and includes setting links', + array( 'html' => self::describe_string( $text_html ) ) + ); + + $textarea_html = (string) ( $outputs['textarea'] ?? '' ); + self::collect_failure( + $failures, + str_contains( $textarea_html, 'class="customize-control customize-control-textarea"' ) + && str_contains( $textarea_html, esc_html( $textarea_label ) ) + && str_contains( $textarea_html, 'rows="5"' ) + && str_contains( $textarea_html, 'data-customize-setting-link="' . esc_attr( $textarea_setting_id ) . '"' ) + && str_contains( $textarea_html, '>' . esc_textarea( $textarea_value ) . '' ) + && ! str_contains( $textarea_html, $textarea_label ) + && ! str_contains( $textarea_html, $textarea_value ), + 'textarea control rendering escapes label/value content and adds the default row count', + array( 'html' => self::describe_string( $textarea_html ) ) + ); + + $checkbox_html = (string) ( $outputs['checkbox'] ?? '' ); + self::collect_failure( + $failures, + str_contains( $checkbox_html, 'class="customize-control customize-control-checkbox"' ) + && str_contains( $checkbox_html, 'type="checkbox"' ) + && str_contains( $checkbox_html, "checked='checked'" ) + && str_contains( $checkbox_html, 'value="1"' ) + && str_contains( $checkbox_html, esc_html( $checkbox_label ) ) + && str_contains( $checkbox_html, 'data-customize-setting-link="' . esc_attr( $checkbox_setting_id ) . '"' ) + && ! str_contains( $checkbox_html, $checkbox_label ), + 'checkbox control rendering marks truthy values checked and escapes labels', + array( 'html' => self::describe_string( $checkbox_html ) ) + ); + + $radio_html = (string) ( $outputs['radio'] ?? '' ); + self::collect_failure( + $failures, + str_contains( $radio_html, 'class="customize-control customize-control-radio"' ) + && 2 === substr_count( $radio_html, 'type="radio"' ) + && 1 === substr_count( $radio_html, "checked='checked'" ) + && str_contains( $radio_html, 'value="' . esc_attr( $radio_value ) . '"' ) + && str_contains( $radio_html, esc_html( $radio_choices[ $radio_other ] ) ) + && str_contains( $radio_html, esc_html( $radio_choices[ $radio_value ] ) ) + && str_contains( $radio_html, 'data-customize-setting-link="' . esc_attr( $radio_setting_id ) . '"' ) + && ! str_contains( $radio_html, $radio_choices[ $radio_other ] ) + && ! str_contains( $radio_html, $radio_choices[ $radio_value ] ), + 'radio control rendering escapes choices and checks exactly the selected value', + array( 'html' => self::describe_string( $radio_html ) ) + ); + + $select_html = (string) ( $outputs['select'] ?? '' ); + self::collect_failure( + $failures, + str_contains( $select_html, 'class="customize-control customize-control-select"' ) + && str_contains( $select_html, ' self::describe_string( $select_html ) ) + ); + + return self::row( + $ctx, + 'customizer.controls.rendering-hooks-escaping-selection', + array() === $failures, + array( + 'controls' => array_keys( $controls ), + 'events' => count( $events ), + 'failures' => $failures, + ) + ); + } + + private static function check_media_control_subclasses( \ComponentFuzz\FuzzContext $ctx ): array { + $manager = self::manager( $ctx ); + $failures = array(); + + if ( ! isset( $GLOBALS['wp_rewrite'] ) || ! $GLOBALS['wp_rewrite'] instanceof \WP_Rewrite ) { + $GLOBALS['wp_rewrite'] = new \WP_Rewrite(); + } + + $section_id = self::id( $ctx, 'media-section' ); + $manager->add_section( + $section_id, + array( + 'title' => 'Media Controls', + 'capability' => self::CAPABILITY, + ) + ); + + $token = substr( md5( (string) $ctx->seed() . ':' . (string) $ctx->iteration() ), 0, 10 ); + $image_default_url = "http://example.test/wp-content/uploads/component-fuzz-media-{$token}.jpg"; + $media_setting_id = self::id( $ctx->fork( 'media-setting' ), 'media-setting' ); + $media_label = 'Media & Label'; + $select_override = 'Choose component media ' . $token; + $extra_label = 'Component extra ' . $token; + + $manager->add_setting( + $media_setting_id, + array( + 'type' => 'component_fuzz_media', + 'capability' => self::CAPABILITY, + 'default' => $image_default_url, + ) + ); + $media_control = new \WP_Customize_Media_Control( + $manager, + self::id( $ctx->fork( 'media-control' ), 'media-control' ), + array( + 'settings' => $media_setting_id, + 'section' => $section_id, + 'capability' => self::CAPABILITY, + 'label' => $media_label, + 'description' => 'Component fuzz media description', + 'mime_type' => 'image/jpeg', + 'button_labels' => array( + 'select' => $select_override, + 'component_fuzz_extra' => $extra_label, + ), + ) + ); + $manager->add_control( $media_control ); + + $media_json = self::media_control_json( $media_control ); + $media_attachment = $media_json['defaultAttachment'] ?? array(); + $media_labels = $media_json['button_labels'] ?? array(); + $media_template = self::capture_output( + static function () use ( $media_control ): void { + $media_control->content_template(); + } + ); + + self::collect_failure( + $failures, + 'media' === ( $media_json['type'] ?? null ) + && 'image/jpeg' === ( $media_json['mime_type'] ?? null ) + && 'Media & Label' === ( $media_json['label'] ?? null ) + && true === ( $media_json['canUpload'] ?? null ) + && $select_override === ( $media_labels['select'] ?? null ) + && $extra_label === ( $media_labels['component_fuzz_extra'] ?? null ) + && self::array_has_keys( $media_labels, array( 'select', 'site_icon', 'change', 'default', 'remove', 'placeholder', 'frame_title', 'frame_button' ) ), + 'media control JSON decodes labels, reports upload capability, and merges image button labels', + array( + 'json' => $media_json, + 'labels' => $media_labels, + ) + ); + + self::collect_failure( + $failures, + 1 === (int) ( $media_attachment['id'] ?? 0 ) + && $image_default_url === ( $media_attachment['url'] ?? null ) + && 'image' === ( $media_attachment['type'] ?? null ) + && \wp_basename( $image_default_url ) === ( $media_attachment['title'] ?? null ) + && $image_default_url === ( $media_attachment['sizes']['full']['url'] ?? null ) + && ( $media_json['attachment'] ?? null ) == $media_attachment, + 'image default URLs create a default attachment and alias the selected attachment when values match', + array( + 'defaultAttachment' => $media_attachment, + 'attachment' => $media_json['attachment'] ?? null, + ) + ); + + self::collect_failure( + $failures, + str_contains( $media_template, 'customize-control-title' ) + && str_contains( $media_template, 'customize-control-notifications-container' ) + && str_contains( $media_template, 'attachment-media-view-{{ data.attachment.type }}' ) + && str_contains( $media_template, 'wp-audio-shortcode' ) + && str_contains( $media_template, 'wp-video-shortcode' ) + && str_contains( $media_template, 'remove-button' ) + && str_contains( $media_template, 'upload-button control-focus' ) + && str_contains( $media_template, 'default-button' ) + && ! str_contains( $media_template, $image_default_url ) + && ! str_contains( $media_template, $media_label ) + && ! str_contains( $media_template, $select_override ), + 'media control template keeps media branches structural and data-driven', + array( 'template' => self::describe_string( $media_template ) ) + ); + + $deny_upload = static function ( array $allcaps ): array { + $allcaps['upload_files'] = false; + return $allcaps; + }; + \add_filter( 'user_has_cap', $deny_upload, 1000, 4 ); + try { + $denied_json = self::media_control_json( $media_control ); + } finally { + \remove_filter( 'user_has_cap', $deny_upload, 1000 ); + } + + self::collect_failure( + $failures, + false === ( $denied_json['canUpload'] ?? null ), + 'media control canUpload follows the upload_files capability', + array( 'json' => $denied_json ) + ); + + $document_url = "http://example.test/wp-content/uploads/component-fuzz-media-{$token}.pdf"; + $document_setting_id = self::id( $ctx->fork( 'document-setting' ), 'media-setting' ); + $manager->add_setting( + $document_setting_id, + array( + 'type' => 'component_fuzz_media', + 'capability' => self::CAPABILITY, + 'default' => $document_url, + ) + ); + $document_control = new \WP_Customize_Media_Control( + $manager, + self::id( $ctx->fork( 'document-control' ), 'media-control' ), + array( + 'settings' => $document_setting_id, + 'section' => $section_id, + 'capability' => self::CAPABILITY, + 'mime_type' => 'application/pdf', + 'label' => 'Document Media', + ) + ); + $manager->add_control( $document_control ); + $document_json = self::media_control_json( $document_control ); + $document_attachment = $document_json['defaultAttachment'] ?? array(); + + self::collect_failure( + $failures, + 'media' === ( $document_json['type'] ?? null ) + && 'application/pdf' === ( $document_json['mime_type'] ?? null ) + && 'document' === ( $document_attachment['type'] ?? null ) + && $document_url === ( $document_attachment['url'] ?? null ) + && \wp_basename( $document_url ) === ( $document_attachment['title'] ?? null ) + && ! isset( $document_attachment['sizes'] ) + && ! array_key_exists( 'site_icon', $document_json['button_labels'] ?? array() ) + && ( $document_json['attachment'] ?? null ) == $document_attachment, + 'document default URLs use document attachment models and file button labels', + array( + 'json' => $document_json, + 'defaultAttachment' => $document_attachment, + ) + ); + + $label_cases = array( + 'image/png' => array( 'word' => 'image', 'siteIcon' => true ), + 'audio/mpeg' => array( 'word' => 'audio', 'siteIcon' => false ), + 'video/mp4' => array( 'word' => 'video', 'siteIcon' => false ), + 'application/pdf' => array( 'word' => 'file', 'siteIcon' => false ), + ); + $label_results = array(); + foreach ( $label_cases as $mime_type => $expectation ) { + $label_setting_id = self::id( $ctx->fork( 'labels-' . str_replace( array( '/', '-' ), '_', $mime_type ) ), 'media-label-setting' ); + $manager->add_setting( + $label_setting_id, + array( + 'type' => 'component_fuzz_media', + 'capability' => self::CAPABILITY, + 'default' => '', + ) + ); + $label_override = 'Override ' . $mime_type . ' ' . $token; + $label_control = new \WP_Customize_Media_Control( + $manager, + self::id( $ctx->fork( 'labels-control-' . str_replace( array( '/', '-' ), '_', $mime_type ) ), 'media-label-control' ), + array( + 'settings' => $label_setting_id, + 'section' => $section_id, + 'capability' => self::CAPABILITY, + 'mime_type' => $mime_type, + 'button_labels' => array( 'select' => $label_override ), + ) + ); + $manager->add_control( $label_control ); + + $labels = $label_control->button_labels; + $has_all = self::array_has_keys( $labels, array( 'select', 'change', 'default', 'remove', 'placeholder', 'frame_title', 'frame_button' ) ); + $ok = $has_all + && $label_override === ( $labels['select'] ?? null ) + && (bool) $expectation['siteIcon'] === array_key_exists( 'site_icon', $labels ) + && str_contains( strtolower( (string) ( $labels['placeholder'] ?? '' ) ), (string) $expectation['word'] ); + + $label_results[ $mime_type ] = array( + 'ok' => $ok, + 'labels' => $labels, + ); + } + + self::collect_failure( + $failures, + array() === array_filter( + $label_results, + static fn ( array $result ): bool => ! $result['ok'] + ), + 'media control default button labels track mime families while preserving caller overrides', + array( 'cases' => $label_results ) + ); + + $image_control_setting_id = self::id( $ctx->fork( 'image-subclass-setting' ), 'media-setting' ); + $manager->add_setting( + $image_control_setting_id, + array( + 'type' => 'component_fuzz_media', + 'capability' => self::CAPABILITY, + 'default' => $image_default_url, + ) + ); + $image_control = new \WP_Customize_Image_Control( + $manager, + self::id( $ctx->fork( 'image-subclass-control' ), 'media-control' ), + array( + 'settings' => $image_control_setting_id, + 'section' => $section_id, + 'capability' => self::CAPABILITY, + ) + ); + $manager->add_control( $image_control ); + $image_control_json = self::media_control_json( $image_control ); + + self::collect_failure( + $failures, + 'image' === ( $image_control_json['type'] ?? null ) + && 'image' === ( $image_control_json['mime_type'] ?? null ) + && 'image' === ( $image_control_json['defaultAttachment']['type'] ?? null ) + && array_key_exists( 'site_icon', $image_control_json['button_labels'] ?? array() ), + 'image control inherits media JSON with the image type and image defaults', + array( 'json' => $image_control_json ) + ); + + $upload_url = "http://example.test/wp-content/uploads/component-fuzz-upload-{$token}.jpg"; + $upload_setting_id = self::id( $ctx->fork( 'upload-setting' ), 'media-setting' ); + $upload_id = 700000 + $ctx->int( 1, 99999 ); + self::seed_attachment( $upload_id, $upload_url, 'Uploaded ' . $token ); + $manager->add_setting( + $upload_setting_id, + array( + 'type' => 'component_fuzz_media', + 'capability' => self::CAPABILITY, + 'default' => $upload_url, + ) + ); + $upload_control = new \WP_Customize_Upload_Control( + $manager, + self::id( $ctx->fork( 'upload-control' ), 'media-control' ), + array( + 'settings' => $upload_setting_id, + 'section' => $section_id, + 'capability' => self::CAPABILITY, + 'mime_type' => 'image/jpeg', + ) + ); + $manager->add_control( $upload_control ); + + $missing_upload_url = "http://example.test/wp-content/uploads/component-fuzz-missing-{$token}.jpg"; + $missing_upload_setting_id = self::id( $ctx->fork( 'upload-missing-setting' ), 'media-setting' ); + $manager->add_setting( + $missing_upload_setting_id, + array( + 'type' => 'component_fuzz_media', + 'capability' => self::CAPABILITY, + 'default' => $missing_upload_url, + ) + ); + $missing_upload_control = new \WP_Customize_Upload_Control( + $manager, + self::id( $ctx->fork( 'upload-missing-control' ), 'media-control' ), + array( + 'settings' => $missing_upload_setting_id, + 'section' => $section_id, + 'capability' => self::CAPABILITY, + 'mime_type' => 'image/jpeg', + ) + ); + $manager->add_control( $missing_upload_control ); + + $url_resolver = static function ( $post_id, string $url ) use ( $upload_url, $upload_id ): int { + return $upload_url === $url ? $upload_id : 0; + }; + \add_filter( 'pre_attachment_url_to_postid', $url_resolver, 10, 2 ); + try { + $upload_json = self::media_control_json( $upload_control ); + $missing_upload_json = self::media_control_json( $missing_upload_control ); + } finally { + \remove_filter( 'pre_attachment_url_to_postid', $url_resolver, 10 ); + } + + self::collect_failure( + $failures, + $upload_id === (int) ( $upload_json['attachment']['id'] ?? 0 ) + && $upload_url === ( $upload_json['attachment']['url'] ?? null ) + && 'image' === ( $upload_json['attachment']['type'] ?? null ) + && $upload_url === ( $upload_json['attachment']['sizes']['full']['url'] ?? null ) + && 1 === (int) ( $missing_upload_json['attachment']['id'] ?? 0 ) + && $missing_upload_url === ( $missing_upload_json['attachment']['url'] ?? null ), + 'upload control resolves URL values to prepared attachments only when attachment_url_to_postid finds one', + array( + 'resolved' => $upload_json['attachment'] ?? null, + 'missing' => $missing_upload_json['attachment'] ?? null, + ) + ); + + $cropped_setting_id = self::id( $ctx->fork( 'cropped-setting' ), 'media-setting' ); + $manager->add_setting( + $cropped_setting_id, + array( + 'type' => 'component_fuzz_media', + 'capability' => self::CAPABILITY, + 'default' => "http://example.test/wp-content/uploads/component-fuzz-crop-{$token}.png", + ) + ); + $cropped_control = new \WP_Customize_Cropped_Image_Control( + $manager, + self::id( $ctx->fork( 'cropped-control' ), 'media-control' ), + array( + 'settings' => $cropped_setting_id, + 'section' => $section_id, + 'capability' => self::CAPABILITY, + 'width' => -33, + 'height' => '91', + 'flex_width' => true, + 'flex_height' => false, + ) + ); + $manager->add_control( $cropped_control ); + $cropped_json = self::media_control_json( $cropped_control ); + + self::collect_failure( + $failures, + 'cropped_image' === ( $cropped_json['type'] ?? null ) + && 33 === ( $cropped_json['width'] ?? null ) + && 91 === ( $cropped_json['height'] ?? null ) + && 1 === ( $cropped_json['flex_width'] ?? null ) + && 0 === ( $cropped_json['flex_height'] ?? null ) + && 'image' === ( $cropped_json['defaultAttachment']['type'] ?? null ), + 'cropped image control normalizes crop dimensions and flex flags through absint', + array( 'json' => $cropped_json ) + ); + + $site_icon_setting_id = self::id( $ctx->fork( 'site-icon-setting' ), 'media-setting' ); + $site_icon_default_url = "http://example.test/wp-content/uploads/component-fuzz-site-icon-{$token}.png"; + $site_icon_label = 'Select Site Icon'; + $manager->add_setting( + $site_icon_setting_id, + array( + 'type' => 'component_fuzz_media', + 'capability' => self::CAPABILITY, + 'default' => $site_icon_default_url, + ) + ); + $had_site_icon_hook = self::hook_has_callback_at( 'customize_controls_print_styles', 'wp_site_icon', 99 ); + $site_icon_control = new \WP_Customize_Site_Icon_Control( + $manager, + self::id( $ctx->fork( 'site-icon-control' ), 'media-control' ), + array( + 'settings' => $site_icon_setting_id, + 'section' => $section_id, + 'capability' => self::CAPABILITY, + 'width' => 512, + 'height' => -512, + 'flex_width' => false, + 'flex_height' => true, + 'button_labels' => array( 'site_icon' => $site_icon_label ), + ) + ); + $manager->add_control( $site_icon_control ); + $site_icon_hook_registered = self::hook_has_callback_at( 'customize_controls_print_styles', 'wp_site_icon', 99 ); + $site_icon_json = self::media_control_json( $site_icon_control ); + $site_icon_template = self::capture_output( + static function () use ( $site_icon_control ): void { + $site_icon_control->print_template(); + } + ); + if ( ! $had_site_icon_hook ) { + \remove_action( 'customize_controls_print_styles', 'wp_site_icon', 99 ); + } + + self::collect_failure( + $failures, + 'site_icon' === ( $site_icon_json['type'] ?? null ) + && 512 === ( $site_icon_json['width'] ?? null ) + && 512 === ( $site_icon_json['height'] ?? null ) + && 0 === ( $site_icon_json['flex_width'] ?? null ) + && 1 === ( $site_icon_json['flex_height'] ?? null ) + && $site_icon_hook_registered + && ( $had_site_icon_hook || ! self::hook_has_callback_at( 'customize_controls_print_styles', 'wp_site_icon', 99 ) ) + && str_contains( $site_icon_template, 'tmpl-customize-control-site_icon-content' ) + && str_contains( $site_icon_template, 'site-icon-preview customizer' ) + && str_contains( $site_icon_template, 'app-icon-preview' ) + && str_contains( $site_icon_template, 'browser-icon-preview' ) + && str_contains( $site_icon_template, '--site-icon-url' ) + && str_contains( $site_icon_template, $site_icon_label ) + && ! str_contains( $site_icon_template, $site_icon_default_url ), + 'site icon control installs its print-styles hook, exports cropped JSON, and prints structural preview template markup', + array( + 'json' => $site_icon_json, + 'hadHook' => $had_site_icon_hook, + 'hookRegistered' => $site_icon_hook_registered, + 'hookAfterCleanup' => self::hook_has_callback_at( 'customize_controls_print_styles', 'wp_site_icon', 99 ), + 'template' => self::describe_string( $site_icon_template ), + ) + ); + + return self::row( + $ctx, + 'customizer.media-controls.subclass-json-templates-capabilities', + array() === $failures, + array( + 'failures' => $failures, + ) + ); + } + + private static function check_selective_refresh_partials( \ComponentFuzz\FuzzContext $ctx ): array { + $manager = self::manager( $ctx ); + $failures = array(); + $setting_id = self::id( $ctx, 'partial-setting' ); + $partial_id = $setting_id . '[' . $ctx->identifier( 3, 8 ) . ']'; + $unsafe = self::unsafe_string( $ctx->fork( 'partial' ) ); + $calls = array(); + + $manager->add_setting( + $setting_id, + array( + 'type' => 'component_fuzz_partial', + 'capability' => self::CAPABILITY, + 'default' => 'partial-default', + ) + ); + + $partial = $manager->selective_refresh->add_partial( + $partial_id, + array( + 'selector' => '#component-fuzz-' . preg_replace( '/[^A-Za-z0-9_-]/', '-', $partial_id ), + 'settings' => array( $setting_id ), + 'primary_setting' => $setting_id, + 'capability' => self::CAPABILITY, + 'container_inclusive' => $ctx->bool(), + 'fallback_refresh' => $ctx->bool(), + 'render_callback' => static function ( \WP_Customize_Partial $partial, array $context ) use ( &$calls, $unsafe ) { + $calls[] = array( + 'id' => $partial->id, + 'context' => $context, + ); + return '' . $unsafe . ''; + }, + ) + ); + + $context = array( + 'number' => $ctx->int( -100, 100 ), + 'text' => self::unsafe_string( $ctx->fork( 'context' ) ), + ); + $json = $partial->json(); + $rendered = $partial->render( $context ); + $can = self::with_capabilities( + static function () use ( $partial ): bool { + return $partial->check_capabilities(); + } + ); + $all_partials = $manager->selective_refresh->partials(); + + self::collect_failure( + $failures, + $partial instanceof \WP_Customize_Partial + && $partial === $manager->selective_refresh->get_partial( $partial_id ) + && isset( $all_partials[ $partial_id ] ) + && $partial === $all_partials[ $partial_id ], + 'selective refresh partial add/get/aggregate registry is consistent', + array( + 'partialId' => $partial_id, + 'partials' => array_keys( $all_partials ), + ) + ); + + self::collect_failure( + $failures, + array( $setting_id ) === $json['settings'] + && $setting_id === $json['primarySetting'] + && $partial->selector === $json['selector'] + && $partial->container_inclusive === $json['containerInclusive'] + && $partial->fallback_refresh === $json['fallbackRefresh'] + && true === $can, + 'partial JSON and capability checks reflect configured fields', + array( + 'json' => $json, + 'can' => $can, + ) + ); + + self::collect_failure( + $failures, + is_string( $rendered ) + && str_contains( $rendered, esc_attr( $partial_id ) ) + && str_contains( $rendered, $unsafe ) + && array( + array( + 'id' => $partial_id, + 'context' => $context, + ), + ) === $calls, + 'partial render callback receives scoped context and returns deterministic markup', + array( + 'rendered' => $rendered, + 'calls' => $calls, + ) + ); + + $manager->selective_refresh->remove_partial( $partial_id ); + self::collect_failure( + $failures, + null === $manager->selective_refresh->get_partial( $partial_id ), + 'remove_partial removes the registered partial', + array( 'partialId' => $partial_id ) + ); + + return self::row( + $ctx, + 'customizer.selective-refresh.partials', + array() === $failures, + array( 'failures' => $failures ) + ); + } + + private static function check_theme_preview_lifecycle( \ComponentFuzz\FuzzContext $ctx ): array { + $failures = array(); + $fixture = self::create_theme_fixture( $ctx ); + $option_snapshot = isset( $GLOBALS['wpdb'] ) && method_exists( $GLOBALS['wpdb'], 'component_fuzz_get_options' ) + ? $GLOBALS['wpdb']->component_fuzz_get_options() + : null; + $start_events = array(); + $stop_events = array(); + $preview_manager = null; + $active_manager = null; + + $theme_filters = array( + 'template' => 'get_template', + 'stylesheet' => 'get_stylesheet', + 'pre_option_current_theme' => 'current_theme', + 'pre_option_stylesheet' => 'get_stylesheet', + 'pre_option_template' => 'get_template', + 'pre_option_stylesheet_root' => 'get_stylesheet_root', + 'pre_option_template_root' => 'get_template_root', + ); + + $start_action = static function ( \WP_Customize_Manager $manager ) use ( &$start_events, &$preview_manager, &$active_manager ): void { + $start_events[] = array( + 'samePreviewManager' => null !== $preview_manager && $manager === $preview_manager, + 'sameActiveManager' => null !== $active_manager && $manager === $active_manager, + 'isPreview' => $manager->is_preview(), + 'isThemeActive' => $manager->is_theme_active(), + 'stylesheet' => $manager->get_stylesheet(), + 'template' => $manager->get_template(), + ); + }; + $stop_action = static function ( \WP_Customize_Manager $manager ) use ( &$stop_events, &$preview_manager, &$active_manager ): void { + $stop_events[] = array( + 'samePreviewManager' => null !== $preview_manager && $manager === $preview_manager, + 'sameActiveManager' => null !== $active_manager && $manager === $active_manager, + 'isPreview' => $manager->is_preview(), + 'isThemeActive' => $manager->is_theme_active(), + 'stylesheet' => $manager->get_stylesheet(), + 'template' => $manager->get_template(), + ); + }; + + \add_action( 'start_previewing_theme', $start_action ); + \add_action( 'stop_previewing_theme', $stop_action ); + + try { + self::seed_active_theme_options( $fixture ); + + $preview_manager = self::manager( $ctx, array( 'theme' => $fixture['previewSlug'] ) ); + $before_preview_filters = self::manager_theme_filters( $preview_manager, $theme_filters ); + + $preview_manager->start_previewing_theme(); + $after_start_values = self::theme_preview_values(); + $after_start_filters = self::manager_theme_filters( $preview_manager, $theme_filters ); + $preview_manager->start_previewing_theme(); + $after_second_start_events = count( $start_events ); + + $preview_manager->stop_previewing_theme(); + $after_stop_values = self::theme_preview_values(); + $after_stop_filters = self::manager_theme_filters( $preview_manager, $theme_filters ); + $preview_manager->stop_previewing_theme(); + $after_second_stop_events = count( $stop_events ); + + self::collect_failure( + $failures, + ! $preview_manager->is_theme_active() + && false === $before_preview_filters['any'] + && true === $after_start_filters['all'] + && false === $after_stop_filters['any'] + && $fixture['previewSlug'] === $after_start_values['stylesheet'] + && $fixture['previewTemplate'] === $after_start_values['template'] + && $fixture['previewName'] === $after_start_values['currentTheme'] + && $fixture['rawRoot'] === $after_start_values['stylesheetRoot'] + && $fixture['rawRoot'] === $after_start_values['templateRoot'] + && $fixture['activeSlug'] === $after_stop_values['stylesheet'] + && $fixture['activeSlug'] === $after_stop_values['template'] + && $fixture['activeName'] === $after_stop_values['currentTheme'] + && $fixture['rawRoot'] === $after_stop_values['stylesheetRoot'] + && $fixture['rawRoot'] === $after_stop_values['templateRoot'] + && array( + array( + 'samePreviewManager' => true, + 'sameActiveManager' => false, + 'isPreview' => true, + 'isThemeActive' => false, + 'stylesheet' => $fixture['previewSlug'], + 'template' => $fixture['previewTemplate'], + ), + ) === $start_events + && array( + array( + 'samePreviewManager' => true, + 'sameActiveManager' => false, + 'isPreview' => false, + 'isThemeActive' => false, + 'stylesheet' => $fixture['previewSlug'], + 'template' => $fixture['previewTemplate'], + ), + ) === $stop_events + && 1 === $after_second_start_events + && 1 === $after_second_stop_events, + 'inactive theme preview installs theme-switching filters, exposes preview values, fires once, and cleans up on stop', + array( + 'fixture' => $fixture, + 'beforeFilters' => $before_preview_filters, + 'afterStartFilters' => $after_start_filters, + 'afterStopFilters' => $after_stop_filters, + 'afterStartValues' => $after_start_values, + 'afterStopValues' => $after_stop_values, + 'startEvents' => $start_events, + 'stopEvents' => $stop_events, + 'afterSecondStartEvents' => $after_second_start_events, + 'afterSecondStopEvents' => $after_second_stop_events, + ) + ); + + $start_events = array(); + $stop_events = array(); + $active_manager = self::manager( $ctx->fork( 'active' ), array( 'theme' => $fixture['activeSlug'] ) ); + $before_active_filters = self::manager_theme_filters( $active_manager, $theme_filters ); + $active_manager->start_previewing_theme(); + $active_start_filters = self::manager_theme_filters( $active_manager, $theme_filters ); + $active_manager->stop_previewing_theme(); + $active_stop_filters = self::manager_theme_filters( $active_manager, $theme_filters ); + + self::collect_failure( + $failures, + $active_manager->is_theme_active() + && false === $before_active_filters['any'] + && false === $active_start_filters['any'] + && false === $active_stop_filters['any'] + && array( + array( + 'samePreviewManager' => false, + 'sameActiveManager' => true, + 'isPreview' => true, + 'isThemeActive' => true, + 'stylesheet' => $fixture['activeSlug'], + 'template' => $fixture['activeSlug'], + ), + ) === $start_events + && array( + array( + 'samePreviewManager' => false, + 'sameActiveManager' => true, + 'isPreview' => false, + 'isThemeActive' => true, + 'stylesheet' => $fixture['activeSlug'], + 'template' => $fixture['activeSlug'], + ), + ) === $stop_events, + 'active theme preview toggles preview state and actions without installing theme-switching filters', + array( + 'beforeFilters' => $before_active_filters, + 'activeStartFilters' => $active_start_filters, + 'activeStopFilters' => $active_stop_filters, + 'startEvents' => $start_events, + 'stopEvents' => $stop_events, + ) + ); + } finally { + \remove_action( 'start_previewing_theme', $start_action ); + \remove_action( 'stop_previewing_theme', $stop_action ); + if ( null !== $option_snapshot && isset( $GLOBALS['wpdb'] ) && method_exists( $GLOBALS['wpdb'], 'component_fuzz_reset_options' ) ) { + $GLOBALS['wpdb']->component_fuzz_reset_options( $option_snapshot ); + } + self::remove_theme_fixture( $fixture ); + } + + self::collect_failure( + $failures, + false === \has_action( 'start_previewing_theme', $start_action ) + && false === \has_action( 'stop_previewing_theme', $stop_action ), + 'theme preview lifecycle actions are removed after the check', + array( + 'startAction' => \has_action( 'start_previewing_theme', $start_action ), + 'stopAction' => \has_action( 'stop_previewing_theme', $stop_action ), + ) + ); + + return self::row( + $ctx, + 'customizer.manager.theme-preview-filter-lifecycle', + array() === $failures, + array( 'failures' => $failures ) + ); + } + + private static function manager( \ComponentFuzz\FuzzContext $ctx, array $args = array() ): \WP_Customize_Manager { + $components_filter = static function (): array { + return array(); + }; + \add_filter( 'customize_loaded_components', $components_filter, 1000 ); + try { + $manager = new \WP_Customize_Manager( + array_merge( + array( + 'changeset_uuid' => self::uuid( $ctx ), + 'settings_previewed' => false, + 'branching' => true, + 'autosaved' => false, + ), + $args + ) + ); + } finally { + \remove_filter( 'customize_loaded_components', $components_filter, 1000 ); + } + + self::set_object_property( $manager, '_changeset_data', array() ); + self::set_object_property( $manager, '_post_values', array() ); + if ( false === \has_filter( 'user_has_cap', array( self::class, 'grant_runtime_capabilities' ) ) ) { + \add_filter( 'user_has_cap', array( self::class, 'grant_runtime_capabilities' ), 10, 4 ); + } + return $manager; + } + + private static function create_theme_fixture( \ComponentFuzz\FuzzContext $ctx ): array { + $theme_root = defined( 'WP_CONTENT_DIR' ) ? WP_CONTENT_DIR . '/themes' : sys_get_temp_dir() . '/component-fuzz-themes'; + if ( ! is_dir( $theme_root ) ) { + @mkdir( $theme_root, 0777, true ); + } + + $suffix = strtolower( preg_replace( '/[^a-z0-9-]+/', '-', $ctx->identifier( 6, 14 ) ) ); + $suffix = trim( $suffix, '-' ); + $suffix = '' === $suffix ? substr( md5( (string) $ctx->seed() ), 0, 8 ) : $suffix; + $active_slug = 'cfz-active-' . $suffix; + $preview_parent = 'cfz-parent-' . $suffix; + $preview_slug = 'cfz-child-' . $suffix; + $active_name = 'CFZ Active ' . $suffix; + $parent_name = 'CFZ Parent ' . $suffix; + $preview_name = 'CFZ Preview ' . $suffix; + $created_paths = array( + $theme_root . '/' . $active_slug, + $theme_root . '/' . $preview_parent, + $theme_root . '/' . $preview_slug, + ); + + self::write_theme_files( $created_paths[0], $active_name ); + self::write_theme_files( $created_paths[1], $parent_name ); + self::write_theme_files( $created_paths[2], $preview_name, $preview_parent ); + self::refresh_theme_discovery(); + + return array( + 'themeRoot' => $theme_root, + 'rawRoot' => \get_raw_theme_root( $preview_slug, true ), + 'activeSlug' => $active_slug, + 'activeName' => $active_name, + 'previewSlug' => $preview_slug, + 'previewTemplate' => $preview_parent, + 'previewName' => $preview_name, + 'createdPaths' => $created_paths, + ); + } + + private static function write_theme_files( string $path, string $name, ?string $template = null ): void { + if ( ! is_dir( $path ) ) { + @mkdir( $path, 0777, true ); + } + + $headers = "/*\nTheme Name: {$name}\n"; + if ( null !== $template ) { + $headers .= "Template: {$template}\n"; + } + $headers .= "*/\n"; + + file_put_contents( $path . '/style.css', $headers ); + file_put_contents( $path . '/index.php', " $method ) { + $states[ $hook ] = \has_filter( $hook, array( $manager, $method ) ); + } + + $truthy = array_filter( + $states, + static function ( $priority ): bool { + return false !== $priority; + } + ); + + return array( + 'states' => $states, + 'all' => count( $states ) === count( $truthy ) && array( 10 ) === array_values( array_unique( array_values( $truthy ) ) ), + 'any' => array() !== $truthy, + ); + } + + private static function theme_preview_values(): array { + return array( + 'stylesheet' => \get_stylesheet(), + 'template' => \get_template(), + 'currentTheme' => \get_option( 'current_theme' ), + 'stylesheetRoot' => \get_option( 'stylesheet_root' ), + 'templateRoot' => \get_option( 'template_root' ), + ); + } + + private static function with_capabilities( callable $callback ) { + $cap_filter = static function ( array $allcaps ): array { + $allcaps[ self::CAPABILITY ] = true; + $allcaps['customize'] = true; + $allcaps['edit_theme_options'] = true; + $allcaps['unfiltered_html'] = true; + $allcaps['edit_css'] = true; + $allcaps['upload_files'] = true; + return $allcaps; + }; + + \add_filter( 'user_has_cap', $cap_filter, 10, 4 ); + try { + return $callback(); + } finally { + \remove_filter( 'user_has_cap', $cap_filter, 10 ); + } + } + + private static function media_control_json( \WP_Customize_Control $control ): array { + $control->json = array(); + return self::with_capabilities( + static function () use ( $control ): array { + $control->to_json(); + return $control->json; + } + ); + } + + private static function capture_output( callable $callback ): string { + ob_start(); + try { + $callback(); + return (string) ob_get_clean(); + } catch ( \Throwable $e ) { + ob_end_clean(); + throw $e; + } + } + + private static function array_has_keys( array $array, array $keys ): bool { + foreach ( $keys as $key ) { + if ( ! array_key_exists( $key, $array ) ) { + return false; + } + } + return true; + } + + private static function seed_attachment( int $attachment_id, string $url, string $title ): void { + $path = (string) parse_url( $url, PHP_URL_PATH ); + $filename = \wp_basename( $path ); + $filename = '' === $filename ? 'component-fuzz-upload.jpg' : $filename; + $extension = str_contains( $filename, '.' ) ? substr( $filename, strrpos( $filename, '.' ) ) : '.jpg'; + $stem = str_ends_with( $filename, $extension ) ? substr( $filename, 0, -strlen( $extension ) ) : $filename; + + $post = new \WP_Post( + (object) array( + 'ID' => $attachment_id, + 'post_author' => 0, + 'post_date' => '2026-07-15 12:00:00', + 'post_date_gmt' => '2026-07-15 10:00:00', + 'post_content' => '', + 'post_title' => $title, + 'post_excerpt' => '', + 'post_status' => 'inherit', + 'comment_status' => 'closed', + 'ping_status' => 'closed', + 'post_password' => '', + 'post_name' => 'component-fuzz-upload-' . $attachment_id, + 'to_ping' => '', + 'pinged' => '', + 'post_modified' => '2026-07-15 12:00:00', + 'post_modified_gmt' => '2026-07-15 10:00:00', + 'post_content_filtered' => '', + 'post_parent' => 0, + 'guid' => $url, + 'menu_order' => 0, + 'post_type' => 'attachment', + 'post_mime_type' => 'image/jpeg', + 'comment_count' => 0, + 'filter' => 'raw', + ) + ); + + \wp_cache_set( $attachment_id, (object) $post->to_array(), 'posts' ); + \wp_cache_set( + $attachment_id, + array( + '_wp_attached_file' => array( $filename ), + '_wp_attachment_image_alt' => array( 'Component fuzz uploaded image' ), + '_wp_attachment_context' => array( '' ), + '_wp_attachment_metadata' => array( + array( + 'width' => 640, + 'height' => 480, + 'file' => $filename, + 'sizes' => array( + 'medium' => array( + 'file' => $stem . '-medium' . $extension, + 'width' => 300, + 'height' => 225, + 'mime-type' => 'image/jpeg', + ), + ), + ), + ), + ), + 'post_meta' + ); + } + + private static function hook_has_callback_at( string $hook, string $callback, int $priority ): bool { + return isset( $GLOBALS['wp_filter'][ $hook ] ) + && $GLOBALS['wp_filter'][ $hook ] instanceof \WP_Hook + && isset( $GLOBALS['wp_filter'][ $hook ]->callbacks[ $priority ][ $callback ] ); + } + + private static function id( \ComponentFuzz\FuzzContext $ctx, string $prefix ): string { + $pieces = array( + $prefix, + (string) $ctx->iteration(), + substr( md5( (string) $ctx->seed() . ':' . $prefix ), 0, 8 ), + $ctx->identifier( 3, 10 ), + ); + + return 'cfz_' . implode( '_', $pieces ); + } + + private static function uuid( \ComponentFuzz\FuzzContext $ctx ): string { + $hex = md5( 'customizer:' . $ctx->seed() . ':' . $ctx->iteration() ); + return sprintf( + '%s-%s-%s-%s-%s', + substr( $hex, 0, 8 ), + substr( $hex, 8, 4 ), + substr( $hex, 12, 4 ), + substr( $hex, 16, 4 ), + substr( $hex, 20, 12 ) + ); + } + + private static function unsafe_string( \ComponentFuzz\FuzzContext $ctx ): string { + return '' + . '' + . '& "\' ' + . $ctx->text( 0, 32 ); + } + + private static function fuzz_value( \ComponentFuzz\FuzzContext $ctx, bool $allow_null = true ) { + $choices = array( 'unsafe-string', 'utf8-text', 'int', 'bool', 'array' ); + if ( $allow_null ) { + $choices[] = 'null'; + } + + switch ( $ctx->choice( $choices ) ) { + case 'unsafe-string': + return self::unsafe_string( $ctx->fork( 'unsafe' ) ); + case 'utf8-text': + return "utf8-\xE2\x98\x83-" . $ctx->text( 1, 48 ); + case 'int': + return $ctx->int( -100000, 100000 ); + case 'bool': + return $ctx->bool(); + case 'array': + return array( + 'html' => self::unsafe_string( $ctx->fork( 'array-html' ) ), + 'number' => $ctx->int( -1000, 1000 ), + 'flag' => $ctx->bool(), + ); + default: + return null; + } + } + + private static function non_null_value( \ComponentFuzz\FuzzContext $ctx ) { + return self::fuzz_value( $ctx, false ); + } + + private static function stringify_value( $value ): string { + if ( is_scalar( $value ) || null === $value ) { + return (string) $value; + } + + $encoded = json_encode( $value, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE ); + return false === $encoded ? '[unencodable]' : $encoded; + } + + private static function nested_get( $root, array $keys, $default ) { + $node = $root; + foreach ( $keys as $key ) { + if ( ! is_array( $node ) || ! array_key_exists( $key, $node ) ) { + return $default; + } + $node = $node[ $key ]; + } + return $node; + } + + private static function all_call_settings_match( array $calls ): bool { + foreach ( $calls as $call ) { + if ( empty( $call['sameSetting'] ) ) { + return false; + } + } + return true; + } + + private static function all_specific_render_hooks_removed( array $callbacks ): bool { + foreach ( $callbacks as $control_id => $callback ) { + if ( false !== \has_action( "customize_render_control_{$control_id}", $callback ) ) { + return false; + } + } + return true; + } + + private static function reset_runtime(): void { + unset( $_POST['customized'], $_POST['customize_changeset_data'] ); + unset( $_REQUEST['customized'], $_REQUEST['customize_changeset_data'] ); + unset( $_GET['customize_theme'], $_GET['theme'] ); + unset( $_POST['customize_theme'], $_POST['theme'] ); + unset( $_REQUEST['customize_theme'], $_REQUEST['theme'] ); + + if ( isset( $GLOBALS['wpdb'] ) && method_exists( $GLOBALS['wpdb'], 'component_fuzz_get_options' ) ) { + $options = $GLOBALS['wpdb']->component_fuzz_get_options(); + $GLOBALS['wpdb']->component_fuzz_reset_options( + array_merge( + $options, + array( + 'home' => 'http://example.test', + 'siteurl' => 'http://example.test', + 'stylesheet' => 'component-fuzz-theme', + 'template' => 'component-fuzz-theme', + 'current_theme' => 'Component Fuzz Theme', + 'theme_mods_component-fuzz-theme' => array(), + 'customize_stashed_theme_mods' => array(), + 'can_compress_scripts' => '0', + 'page_for_posts' => '0', + 'page_on_front' => '0', + 'show_on_front' => 'posts', + 'fresh_site' => '0', + 'widget_block' => array(), + 'sidebars_widgets' => array(), + 'nav_menu_options' => array(), + 'theme_switched_via_customizer' => false, + 'dismissed_update_core' => array(), + 'auto_update_core_major' => 'unset', + 'auto_update_core_minor' => 'unset', + 'auto_update_core_dev' => 'unset', + 'wp_force_deactivated_plugins' => array(), + 'wp_force_deactivated_plugins_changed' => false, + 'wp_force_deactivated_plugins_baseline' => array(), + ) + ) + ); + } + + if ( method_exists( 'WP_Customize_Setting', 'reset_aggregated_multidimensionals' ) ) { + \WP_Customize_Setting::reset_aggregated_multidimensionals(); + } + } + + private static function snapshot_state(): array { + return array( + 'globals' => self::snapshot_globals( + array( + '_GET', + '_POST', + '_REQUEST', + 'current_user', + 'wp_actions', + 'wp_current_filter', + 'wp_customize', + 'wp_filter', + 'wp_filters', + 'wp_object_cache', + 'wp_rewrite', + 'wp_stylesheet_path', + 'wp_template_path', + 'wp_theme_directories', + ) + ), + 'options' => isset( $GLOBALS['wpdb'] ) && method_exists( $GLOBALS['wpdb'], 'component_fuzz_get_options' ) + ? $GLOBALS['wpdb']->component_fuzz_get_options() + : null, + 'aggregatedSettings' => self::get_static_property( 'WP_Customize_Setting', 'aggregated_multidimensionals' ), + 'controlCount' => self::get_static_property( 'WP_Customize_Control', 'instance_count' ), + 'sectionCount' => self::get_static_property( 'WP_Customize_Section', 'instance_count' ), + 'panelCount' => self::get_static_property( 'WP_Customize_Panel', 'instance_count' ), + ); + } + + private static function restore_state( array $snapshot ): void { + self::restore_globals( $snapshot['globals'] ); + + if ( null !== $snapshot['options'] && isset( $GLOBALS['wpdb'] ) && method_exists( $GLOBALS['wpdb'], 'component_fuzz_reset_options' ) ) { + $GLOBALS['wpdb']->component_fuzz_reset_options( $snapshot['options'] ); + } + + self::set_static_property( 'WP_Customize_Setting', 'aggregated_multidimensionals', $snapshot['aggregatedSettings'] ); + self::set_static_property( 'WP_Customize_Control', 'instance_count', $snapshot['controlCount'] ); + self::set_static_property( 'WP_Customize_Section', 'instance_count', $snapshot['sectionCount'] ); + self::set_static_property( 'WP_Customize_Panel', 'instance_count', $snapshot['panelCount'] ); + } + + private static function snapshot_globals( array $names ): array { + $snapshot = array(); + foreach ( $names as $name ) { + $snapshot[ $name ] = array( + 'exists' => array_key_exists( $name, $GLOBALS ), + 'value' => array_key_exists( $name, $GLOBALS ) ? self::clone_value( $GLOBALS[ $name ] ) : null, + ); + } + return $snapshot; + } + + private static function restore_globals( array $snapshot ): void { + foreach ( $snapshot as $name => $entry ) { + if ( $entry['exists'] ) { + $GLOBALS[ $name ] = $entry['value']; + } else { + unset( $GLOBALS[ $name ] ); + } + } + } + + private static function clone_value( $value ) { + if ( is_object( $value ) ) { + return clone $value; + } + + if ( is_array( $value ) ) { + $copy = array(); + foreach ( $value as $key => $item ) { + $copy[ $key ] = self::clone_value( $item ); + } + return $copy; + } + + return $value; + } + + private static function get_static_property( string $class_name, string $property ) { + $reflection = new \ReflectionProperty( $class_name, $property ); + self::make_reflection_accessible( $reflection ); + return $reflection->getValue(); + } + + private static function set_static_property( string $class_name, string $property, $value ): void { + $reflection = new \ReflectionProperty( $class_name, $property ); + self::make_reflection_accessible( $reflection ); + $reflection->setValue( null, $value ); + } + + private static function set_object_property( object $object, string $property, $value ): void { + $reflection = new \ReflectionProperty( $object, $property ); + self::make_reflection_accessible( $reflection ); + $reflection->setValue( $object, $value ); + } + + private static function make_reflection_accessible( \ReflectionProperty $reflection ): void { + if ( PHP_VERSION_ID < 80100 && method_exists( $reflection, 'setAccessible' ) ) { + $reflection->setAccessible( true ); + } + } + + private static function collect_failure( array &$failures, bool $condition, string $label, array $details ): void { + if ( $condition ) { + return; + } + + $failures[] = array( + 'label' => $label, + 'details' => self::describe_value( $details ), + ); + } + + private static function row( \ComponentFuzz\FuzzContext $ctx, string $invariant, bool $ok, array $data = array() ): array { + return $ok ? $ctx->pass( $invariant, $data ) : $ctx->fail( $invariant, $data ); + } + + private static function describe_throwable( \Throwable $throwable ): array { + return array( + 'class' => get_class( $throwable ), + 'message' => $throwable->getMessage(), + 'file' => $throwable->getFile(), + 'line' => $throwable->getLine(), + ); + } + + private static function describe_string( string $value ): string { + return \ComponentFuzz\preview_value( $value ); + } + + private static function describe_value( $value ) { + if ( $value instanceof \WP_Error ) { + return array( + 'wpErrorCodes' => $value->get_error_codes(), + 'wpErrorData' => $value->get_all_error_data(), + ); + } + + if ( is_object( $value ) ) { + return '[object ' . get_class( $value ) . ']'; + } + + if ( is_array( $value ) ) { + $out = array(); + foreach ( $value as $key => $item ) { + $out[ $key ] = self::describe_value( $item ); + } + return $out; + } + + if ( is_string( $value ) ) { + return \ComponentFuzz\preview_value( $value ); + } + + return $value; + } +} diff --git a/tools/component-fuzz/surfaces/DateTimeSurface.php b/tools/component-fuzz/surfaces/DateTimeSurface.php new file mode 100644 index 0000000000000..94dad519098ca --- /dev/null +++ b/tools/component-fuzz/surfaces/DateTimeSurface.php @@ -0,0 +1,2469 @@ +skip( + 'date-time.required-apis-available', + 'Required WordPress date/time APIs are unavailable.', + array( 'missing' => $missing ) + ), + ); + } + + $snapshot = self::snapshot_runtime(); + + try { + $timestamps = self::timestamp_cases( $ctx->fork( 'timestamps' ) ); + $timezones = self::timezone_cases( $ctx->fork( 'timezones' ) ); + $formats = self::format_cases( $ctx->fork( 'formats' ) ); + $mysql = self::mysql_cases( $ctx->fork( 'mysql' ), $timestamps, $timezones ); + $offsets = self::offset_cases( $ctx->fork( 'offsets' ) ); + + $rows = array( + self::check_wp_date_oracle( $ctx, $timestamps, $timezones, $formats ), + self::check_date_i18n_oracle( $ctx, $timestamps, $timezones, $formats ), + self::check_mysql2date_oracle( $ctx, $mysql, $timezones ), + self::check_timezone_options_and_current_time( $ctx, $timezones, $offsets ), + self::check_timezone_choice_markup( $ctx, $timezones ), + self::check_gmt_local_round_trips( $ctx, $timestamps, $timezones ), + self::check_iso8601_offsets( $ctx, $offsets ), + self::check_weekstartend_windows( $ctx, $timestamps ), + self::check_human_time_diff( $ctx ), + self::check_safe_format_option_filters( $ctx, $timestamps, $timezones, $formats ), + self::check_current_datetime_timezone_override_and_iso8601( $ctx, $timestamps, $timezones, $offsets ), + self::check_date_and_human_diff_filter_contracts( $ctx, $timestamps, $timezones, $formats ), + self::check_named_timezone_dst_boundaries_and_iso8601_modes( $ctx, $timezones, $offsets ), + self::check_wp_checkdate_contract( $ctx ), + self::check_maybe_decline_date_locale_contract( $ctx->fork( 'decline-date' ) ), + ); + } catch ( \Throwable $e ) { + $rows = array( + self::throwable_row( $ctx, 'date-time.surface-no-throw', $e ), + ); + } finally { + self::restore_runtime( $snapshot ); + } + + $rows[] = self::check_runtime_restored( $ctx, $snapshot ); + + return $rows; + } + + private static function missing_requirements(): array { + $missing = array(); + + foreach ( + array( + 'wp_date', + 'date_i18n', + 'current_time', + 'current_datetime', + 'mysql2date', + 'get_gmt_from_date', + 'get_date_from_gmt', + 'iso8601_timezone_to_offset', + 'iso8601_to_datetime', + 'wp_timezone_string', + 'wp_timezone', + 'wp_timezone_override_offset', + 'wp_timezone_choice', + 'wp_checkdate', + 'get_weekstartend', + 'human_time_diff', + 'get_option', + 'get_locale', + 'add_filter', + 'has_filter', + 'remove_filter', + 'wp_maybe_decline_date', + ) as $function + ) { + if ( ! function_exists( $function ) ) { + $missing[] = "function {$function}"; + } + } + + foreach ( array( 'DateTimeImmutable', 'DateTimeZone' ) as $class ) { + if ( ! class_exists( $class ) ) { + $missing[] = "class {$class}"; + } + } + + return $missing; + } + + private static function check_wp_date_oracle( \ComponentFuzz\FuzzContext $ctx, array $timestamps, array $timezones, array $formats ): array { + $failures = array(); + $samples = array(); + $cases = 0; + + foreach ( $timestamps as $index => $timestamp ) { + $timezone = new \DateTimeZone( $timezones[ $index % count( $timezones ) ] ); + $format = $formats[ $index % count( $formats ) ]; + $expected = ( new \DateTimeImmutable( '@' . $timestamp ) )->setTimezone( $timezone )->format( $format ); + $actual = \wp_date( $format, $timestamp, $timezone ); + ++$cases; + + self::sample( + $samples, + array( + 'timestamp' => $timestamp, + 'timezone' => $timezone->getName(), + 'format' => $format, + 'expected' => $expected, + 'actual' => $actual, + ) + ); + + if ( $actual !== $expected ) { + $failures[] = array( + 'api' => 'wp_date', + 'timestamp' => $timestamp, + 'timezone' => $timezone->getName(), + 'format' => $format, + 'expected' => $expected, + 'actual' => self::describe_value( $actual ), + ); + } + } + + $invalid = \wp_date( 'Y-m-d H:i:s', 'not-a-timestamp', new \DateTimeZone( 'UTC' ) ); + if ( false !== $invalid ) { + $failures[] = array( + 'api' => 'wp_date', + 'case' => 'invalid timestamp', + 'expected' => false, + 'actual' => self::describe_value( $invalid ), + ); + } + + return self::result( + $ctx, + 'date-time.wp-date-datetimeimmutable-oracle', + $failures, + array( + 'cases' => $cases, + 'timezones' => array_values( array_unique( $timezones ) ), + 'formats' => $formats, + 'preEpoch' => count( array_filter( $timestamps, static fn ( int $timestamp ): bool => $timestamp < 0 ) ), + 'samples' => $samples, + 'failures' => array_slice( $failures, 0, self::FAILURE_LIMIT ), + ) + ); + } + + private static function check_date_i18n_oracle( \ComponentFuzz\FuzzContext $ctx, array $timestamps, array $timezones, array $formats ): array { + $failures = array(); + $samples = array(); + $cases = 0; + + foreach ( $timestamps as $index => $timestamp ) { + $timezone_name = $timezones[ $index % count( $timezones ) ]; + $timezone = new \DateTimeZone( $timezone_name ); + $format = self::date_i18n_format( $formats[ ( $index + 3 ) % count( $formats ) ] ); + + self::with_option_filters( + array( + 'timezone_string' => $timezone_name, + 'gmt_offset' => 0, + ), + static function () use ( $timestamp, $timezone, $format, &$failures, &$samples, &$cases ): void { + $local_time = gmdate( 'Y-m-d H:i:s', $timestamp ); + $expected = ( new \DateTimeImmutable( $local_time, $timezone ) )->format( $format ); + $actual = \date_i18n( $format, $timestamp, false ); + $actual_u = \date_i18n( 'U', $timestamp, false ); + ++$cases; + + self::sample( + $samples, + array( + 'timestampWithOffset' => $timestamp, + 'timezone' => $timezone->getName(), + 'format' => $format, + 'expected' => $expected, + 'actual' => $actual, + ) + ); + + if ( $actual !== $expected ) { + $failures[] = array( + 'api' => 'date_i18n', + 'timestampWithOffset' => $timestamp, + 'timezone' => $timezone->getName(), + 'format' => $format, + 'expected' => $expected, + 'actual' => self::describe_value( $actual ), + ); + } + + if ( (string) $timestamp !== (string) $actual_u ) { + $failures[] = array( + 'api' => 'date_i18n', + 'case' => 'U preserves explicit timestamp-with-offset', + 'timestampWithOffset' => $timestamp, + 'expected' => (string) $timestamp, + 'actual' => self::describe_value( $actual_u ), + ); + } + } + ); + } + + return self::result( + $ctx, + 'date-time.date-i18n-explicit-timestamp-oracle', + $failures, + array( + 'cases' => $cases, + 'samples' => $samples, + 'failures' => array_slice( $failures, 0, self::FAILURE_LIMIT ), + ) + ); + } + + private static function check_mysql2date_oracle( \ComponentFuzz\FuzzContext $ctx, array $mysql_cases, array $timezones ): array { + $failures = array(); + $samples = array(); + $cases = 0; + + foreach ( $mysql_cases as $index => $case ) { + $mysql = $case['mysql']; + $timezone_name = $case['timezone'] ?? $timezones[ $index % count( $timezones ) ]; + $timezone = new \DateTimeZone( $timezone_name ); + $format = 0 === $index % 3 ? 'Y-m-d H:i:s' : 'Y-m-d\TH:i:sP'; + + self::with_option_filters( + array( + 'timezone_string' => $timezone_name, + 'gmt_offset' => 0, + ), + static function () use ( $mysql, $timezone, $format, &$failures, &$samples, &$cases ): void { + $expected_u = strtotime( $mysql . ' UTC' ); + $actual_u = \mysql2date( 'U', $mysql, false ); + $expected_formatted = ( new \DateTimeImmutable( $mysql, $timezone ) )->format( $format ); + $actual_formatted = \mysql2date( $format, $mysql, false ); + ++$cases; + + self::sample( + $samples, + array( + 'mysql' => $mysql, + 'timezone' => $timezone->getName(), + 'expectedTimestamp' => $expected_u, + 'actualTimestamp' => $actual_u, + 'format' => $format, + ) + ); + + if ( false === $expected_u || (string) $expected_u !== (string) $actual_u ) { + $failures[] = array( + 'api' => 'mysql2date', + 'format' => 'U', + 'mysql' => $mysql, + 'timezone' => $timezone->getName(), + 'expected' => self::describe_value( $expected_u ), + 'actual' => self::describe_value( $actual_u ), + ); + } + + if ( $actual_formatted !== $expected_formatted ) { + $failures[] = array( + 'api' => 'mysql2date', + 'format' => $format, + 'mysql' => $mysql, + 'timezone' => $timezone->getName(), + 'expected' => $expected_formatted, + 'actual' => self::describe_value( $actual_formatted ), + ); + } + } + ); + } + + $invalid = \mysql2date( 'U', '', false ); + if ( false !== $invalid ) { + $failures[] = array( + 'api' => 'mysql2date', + 'case' => 'empty date', + 'expected' => false, + 'actual' => self::describe_value( $invalid ), + ); + } + + return self::result( + $ctx, + 'date-time.mysql2date-strtotime-oracle', + $failures, + array( + 'cases' => $cases, + 'samples' => $samples, + 'failures' => array_slice( $failures, 0, self::FAILURE_LIMIT ), + ) + ); + } + + private static function check_timezone_options_and_current_time( \ComponentFuzz\FuzzContext $ctx, array $timezones, array $offsets ): array { + $failures = array(); + $samples = array(); + $cases = 0; + + foreach ( array_slice( $timezones, 0, min( 8, count( $timezones ) ) ) as $timezone_name ) { + self::with_option_filters( + array( + 'timezone_string' => $timezone_name, + 'gmt_offset' => 0, + ), + static function () use ( $timezone_name, &$failures, &$samples, &$cases ): void { + $string = \wp_timezone_string(); + $zone = \wp_timezone(); + ++$cases; + + self::sample( + $samples, + array( + 'mode' => 'timezone_string', + 'timezoneString' => $timezone_name, + 'wpTimezoneString' => $string, + 'wpTimezoneGetName' => $zone->getName(), + ) + ); + + if ( $string !== $timezone_name || $zone->getName() !== $timezone_name ) { + $failures[] = array( + 'case' => 'timezone_string option wins', + 'timezoneString' => $timezone_name, + 'wpTimezoneString' => $string, + 'wpTimezoneGetName' => $zone->getName(), + ); + } + } + ); + } + + foreach ( array_slice( $offsets, 0, min( 10, count( $offsets ) ) ) as $offset ) { + self::with_option_filters( + array( + 'timezone_string' => '', + 'gmt_offset' => $offset, + ), + static function () use ( $offset, &$failures, &$samples, &$cases ): void { + $expected_string = self::format_gmt_offset( $offset ); + $string = \wp_timezone_string(); + $zone = \wp_timezone(); + + $before = time(); + $gmt = \time(); + $local = \current_time( 'timestamp', false ); + $after = time(); + ++$cases; + + self::sample( + $samples, + array( + 'mode' => 'gmt_offset', + 'offset' => $offset, + 'expectedTimezone' => $expected_string, + 'actualTimezone' => $string, + 'currentDelta' => $local - $gmt, + ) + ); + + if ( $string !== $expected_string || $zone->getName() !== $expected_string ) { + $failures[] = array( + 'case' => 'gmt_offset fallback', + 'offset' => $offset, + 'expected' => $expected_string, + 'wpTimezoneString' => $string, + 'wpTimezoneGetName' => $zone->getName(), + ); + } + + $expected_delta = (int) ( (float) $offset * HOUR_IN_SECONDS ); + if ( $gmt < $before || $gmt > $after || abs( ( $local - $gmt ) - $expected_delta ) > 2 ) { + $failures[] = array( + 'api' => 'current_time', + 'offset' => $offset, + 'expectedDelta' => $expected_delta, + 'actualDelta' => $local - $gmt, + 'gmtWindow' => array( $before, $after ), + 'gmt' => $gmt, + 'local' => $local, + ); + } + } + ); + } + + return self::result( + $ctx, + 'date-time.timezone-options-and-current-time', + $failures, + array( + 'cases' => $cases, + 'samples' => $samples, + 'failures' => array_slice( $failures, 0, self::FAILURE_LIMIT ), + ) + ); + } + + private static function check_timezone_choice_markup( \ComponentFuzz\FuzzContext $ctx, array $timezones ): array { + $failures = array(); + $samples = array(); + $textdomain_events = array(); + $locale = 'cf_' . preg_replace( '/[^A-Za-z0-9_]+/', '_', $ctx->identifier( 5, 12 ) ); + $hostile = 'UTC+5.75"> ' . $ctx->identifier( 3, 8 ); + $html = '

    Allowed ' . $ctx->identifier( 3, 8 ) . '

    '; + + \wp_set_current_user( 0 ); + + $search = ( new \WP_Widget_Search() )->update( array( 'title' => $title ), array() ); + $meta = ( new \WP_Widget_Meta() )->update( array( 'title' => $title ), array() ); + $cal = ( new \WP_Widget_Calendar() )->update( array( 'title' => $title ), array() ); + self::collect_failure( + $failures, + self::safe_title( $search['title'] ?? '' ) + && self::safe_title( $meta['title'] ?? '' ) + && self::safe_title( $cal['title'] ?? '' ), + 'Single-title widgets sanitize title fields on update', + array( + 'search' => $search, + 'meta' => $meta, + 'calendar' => $cal, + ) + ); + + $pages = ( new \WP_Widget_Pages() )->update( + array( + 'title' => $title, + 'sortby' => 'post_date', + 'exclude' => '12, 19', + ), + array() + ); + self::collect_failure( + $failures, + self::safe_title( $pages['title'] ?? '' ) + && 'menu_order' === ( $pages['sortby'] ?? null ) + && '12, 19' === ( $pages['exclude'] ?? null ), + 'Pages update rejects unsupported sort keys and sanitizes title/exclude', + array( 'pages' => $pages ) + ); + + $categories = ( new \WP_Widget_Categories() )->update( + array( + 'title' => $title, + 'count' => 'yes', + 'hierarchical' => 'on', + 'dropdown' => '1', + ), + array() + ); + $archives = ( new \WP_Widget_Archives() )->update( + array( + 'title' => $title, + 'count' => 1, + 'dropdown' => 'on', + ), + array() + ); + self::collect_failure( + $failures, + 1 === ( $categories['count'] ?? null ) + && 1 === ( $categories['hierarchical'] ?? null ) + && 1 === ( $categories['dropdown'] ?? null ) + && 1 === ( $archives['count'] ?? null ) + && 1 === ( $archives['dropdown'] ?? null ), + 'Categories and Archives updates normalize checkbox fields', + array( + 'categories' => $categories, + 'archives' => $archives, + ) + ); + + $recent_posts = ( new \WP_Widget_Recent_Posts() )->update( + array( + 'title' => $title, + 'number' => '-7', + 'show_date' => '1', + ), + array() + ); + $recent_comments = ( new \WP_Widget_Recent_Comments() )->update( + array( + 'title' => $title, + 'number' => '-7', + ), + array() + ); + self::collect_failure( + $failures, + -7 === ( $recent_posts['number'] ?? null ) + && true === ( $recent_posts['show_date'] ?? null ) + && 7 === ( $recent_comments['number'] ?? null ), + 'Recent Posts preserves integer number while Recent Comments absints it', + array( + 'recentPosts' => $recent_posts, + 'recentComments' => $recent_comments, + ) + ); + + $text = ( new \WP_Widget_Text() )->update( + array( + 'title' => $title, + 'text' => $html, + 'filter' => 'content', + 'visual' => '1', + ), + array() + ); + $html_widget = ( new \WP_Widget_Custom_HTML() )->update( + array( + 'title' => $title, + 'content' => $html, + ), + array() + ); + self::collect_failure( + $failures, + ! str_contains( $text['text'] ?? '', '' ) + && true === ( $text['filter'] ?? null ) + && true === ( $text['visual'] ?? null ) + && ! str_contains( $html_widget['content'] ?? '', '' ), + 'Text and Custom HTML updates apply capability-sensitive KSES and visual flags', + array( + 'text' => $text, + 'customHtml' => $html_widget, + ) + ); + + $rss_url = 'https://example.test/feed/' . $ctx->identifier( 3, 8 ); + $rss = ( new \WP_Widget_RSS() )->update( + array( + 'title' => $title, + 'url' => $rss_url, + 'items' => '99', + 'show_summary' => '1', + 'show_author' => '1', + 'show_date' => '1', + ), + array( 'url' => $rss_url ) + ); + self::collect_failure( + $failures, + self::safe_title( $rss['title'] ?? '' ) + && $rss_url === ( $rss['url'] ?? null ) + && 10 === ( $rss['items'] ?? null ) + && false === ( $rss['error'] ?? null ) + && 1 === ( $rss['show_summary'] ?? null ), + 'RSS update sanitizes title/url and bounds item count without feed check when URL is unchanged', + array( 'rss' => $rss ) + ); + + $tag_cloud = ( new \WP_Widget_Tag_Cloud() )->update( + array( + 'title' => $title, + 'count' => 'on', + 'taxonomy' => 'category\\', + ), + array() + ); + $links = ( new \WP_Widget_Links() )->update( + array( + 'images' => 'on', + 'name' => 'on', + 'description' => 'on', + 'orderby' => 'post_date', + 'category' => '12dogs', + 'limit' => '', + ), + array() + ); + $nav_menu = ( new \WP_Nav_Menu_Widget() )->update( + array( + 'title' => $title, + 'nav_menu' => '23cats', + ), + array() + ); + self::collect_failure( + $failures, + 1 === ( $tag_cloud['count'] ?? null ) + && 'category' === ( $tag_cloud['taxonomy'] ?? null ) + && 'name' === ( $links['orderby'] ?? null ) + && 12 === ( $links['category'] ?? null ) + && -1 === ( $links['limit'] ?? null ) + && 23 === ( $nav_menu['nav_menu'] ?? null ) + && self::safe_title( $nav_menu['title'] ?? '' ), + 'Tag Cloud, Links, and Nav Menu updates normalize taxonomy, booleans, and numeric selections', + array( + 'tagCloud' => $tag_cloud, + 'links' => $links, + 'navMenu' => $nav_menu, + ) + ); + + return self::result( $ctx, 'default-widgets.update.sanitization-and-normalization', $failures ); + } + + private static function check_widget_callback_lifecycle( \ComponentFuzz\FuzzContext $ctx ): array { + $failures = array(); + $number = $ctx->int( 60, 95 ); + $blocked_number = $number + 100; + $token = $ctx->identifier( 4, 9 ); + $widget = new \WP_Widget_Text(); + $display_seen = array(); + $form_seen = array(); + $update_seen = array(); + $original_post = $_POST; + $saved_instances = array( + $number => array( + 'title' => 'Saved ' . $token, + 'text' => '

    Saved body ' . $token . '

    ', + 'filter' => true, + 'visual' => true, + ), + $blocked_number => array( + 'title' => 'Blocked ' . $token, + 'text' => '

    Blocked body ' . $token . '

    ', + 'filter' => true, + 'visual' => true, + ), + ); + + $display_filter = static function ( $instance, \WP_Widget $current_widget, array $args ) use ( &$display_seen, $token, $blocked_number ) { + $display_seen[] = array( + 'idBase' => $current_widget->id_base, + 'number' => $current_widget->number, + 'title' => $instance['title'] ?? null, + 'widgetId' => $args['widget_id'] ?? null, + ); + + if ( $blocked_number === (int) $current_widget->number ) { + return false; + } + + $instance['title'] .= ' filtered'; + $instance['text'] .= ''; + return $instance; + }; + $form_filter = static function ( $instance, \WP_Widget $current_widget ) use ( &$form_seen, $token ) { + $form_seen[] = array( + 'idBase' => $current_widget->id_base, + 'number' => $current_widget->number, + 'title' => $instance['title'] ?? null, + ); + + $instance['title'] = ( $instance['title'] ?? '' ) . ' form-filtered'; + $instance['text'] = ( $instance['text'] ?? '' ) . "\nForm " . $token; + return $instance; + }; + $form_action = static function ( \WP_Widget $current_widget, &$return, array $instance ) use ( &$form_seen ): void { + $form_seen[] = array( + 'action' => 'in_widget_form', + 'idBase' => $current_widget->id_base, + 'number' => $current_widget->number, + 'return' => $return, + 'title' => $instance['title'] ?? null, + ); + }; + $update_filter = static function ( $instance, array $new_instance, array $old_instance, \WP_Widget $current_widget ) use ( &$update_seen ) { + $update_seen[] = array( + 'idBase' => $current_widget->id_base, + 'number' => $current_widget->number, + 'new' => $new_instance, + 'old' => $old_instance, + 'saved' => $instance, + ); + + if ( is_array( $instance ) ) { + $instance['title'] .= ' update-filtered'; + } + + return $instance; + }; + + $widget->save_settings( $saved_instances ); + $widget->_set( $number ); + $display_args = self::widget_args( $widget ); + $widget->_set( $number + 1000 ); + + \add_filter( 'widget_display_callback', $display_filter, 10, 3 ); + \add_filter( 'widget_form_callback', $form_filter, 10, 2 ); + \add_action( 'in_widget_form', $form_action, 10, 3 ); + \add_filter( 'widget_update_callback', $update_filter, 10, 4 ); + try { + $display_output = self::capture_callback_output( + static function () use ( $widget, $display_args, $number ): void { + $widget->display_callback( $display_args, array( 'number' => $number ) ); + } + ); + + $widget->_set( $blocked_number ); + $blocked_args = self::widget_args( $widget ); + $widget->_set( $blocked_number + 1000 ); + $blocked_output = self::capture_callback_output( + static function () use ( $widget, $blocked_args, $blocked_number ): void { + $widget->display_callback( $blocked_args, $blocked_number ); + } + ); + $form_output = self::capture_callback_output( + static function () use ( $widget, $number ): void { + $widget->form_callback( array( 'number' => $number ) ); + } + ); + $template_output = self::capture_callback_output( + static function () use ( $widget ): void { + $widget->form_callback( array( 'number' => -1 ) ); + } + ); + + $_POST = array( + 'widget-text' => array( + $number => array( + 'title' => 'Updated ' . $token . '', + 'text' => '

    Updated ' . $token . '

    ', + 'filter' => 'content', + 'visual' => '1', + ), + ), + ); + $widget->update_callback(); + $settings_after_update = $widget->get_settings(); + } finally { + $_POST = $original_post; + \remove_filter( 'widget_update_callback', $update_filter, 10 ); + \remove_action( 'in_widget_form', $form_action, 10 ); + \remove_filter( 'widget_form_callback', $form_filter, 10 ); + \remove_filter( 'widget_display_callback', $display_filter, 10 ); + } + + $updated = $settings_after_update[ $number ] ?? array(); + + self::collect_failure( + $failures, + str_contains( $display_output, '
    ' ) + && str_contains( $display_output, 'Saved ' . $token . ' filtered' ) + && str_contains( $display_output, 'data-display-token="' . \esc_attr( $token ) . '"' ) + && '' === $blocked_output + && 2 === count( $display_seen ) + && $number === (int) ( $display_seen[0]['number'] ?? 0 ) + && $blocked_number === (int) ( $display_seen[1]['number'] ?? 0 ), + 'display_callback loads saved instances, applies display filter mutations, and honors false short-circuit', + array( + 'display' => self::preview( $display_output ), + 'blocked' => self::preview( $blocked_output ), + 'seen' => $display_seen, + ) + ); + self::collect_failure( + $failures, + str_contains( $form_output, 'Saved ' . $token . ' form-filtered' ) + && str_contains( $form_output, 'Form ' . $token ) + && str_contains( $template_output, 'widget-text[__i__]' ) + && isset( $form_seen[0], $form_seen[1], $form_seen[2], $form_seen[3] ) + && $number === (int) ( $form_seen[0]['number'] ?? 0 ) + && 'in_widget_form' === ( $form_seen[1]['action'] ?? null ) + && '__i__' === (string) ( $form_seen[2]['number'] ?? '' ) + && 'in_widget_form' === ( $form_seen[3]['action'] ?? null ), + 'form_callback filters saved and template instances and fires in_widget_form action', + array( + 'form' => self::preview( $form_output ), + 'template' => self::preview( $template_output ), + 'seen' => $form_seen, + ) + ); + self::collect_failure( + $failures, + 1 === count( $update_seen ) + && $number === (int) ( $update_seen[0]['number'] ?? 0 ) + && 'Saved ' . $token === ( $update_seen[0]['old']['title'] ?? null ) + && str_contains( $updated['title'] ?? '', 'Updated ' . $token ) + && str_contains( $updated['title'] ?? '', 'update-filtered' ) + && self::safe_title( $updated['title'] ?? '' ) + && str_contains( $updated['text'] ?? '', '

    Updated ' . $token . '

    ' ) + && ! str_contains( $updated['text'] ?? '', ' $updated, + 'sibling' => $settings_after_update[ $blocked_number ] ?? null, + 'settings' => $settings_after_update, + 'seen' => $update_seen, + ) + ); + + return self::result( + $ctx, + 'default-widgets.callbacks.display-form-update-lifecycle', + $failures, + array( 'number' => $number ) + ); + } + + private static function check_form_escaping( \ComponentFuzz\FuzzContext $ctx ): array { + $failures = array(); + $title = 'Form "Title" '; + $content = '

    ' . $ctx->identifier( 3, 8 ) . '

    '; + + $forms = array( + 'search' => self::capture_form( new \WP_Widget_Search(), 2, array( 'title' => $title ) ), + 'pages' => self::capture_form( new \WP_Widget_Pages(), 3, array( 'title' => $title, 'sortby' => 'ID', 'exclude' => '2, 4' ) ), + 'categories' => self::capture_form( new \WP_Widget_Categories(), 4, array( 'title' => $title, 'dropdown' => 1, 'count' => 1 ) ), + 'archives' => self::capture_form( new \WP_Widget_Archives(), 5, array( 'title' => $title, 'dropdown' => 1, 'count' => 1 ) ), + 'recentPosts' => self::capture_form( new \WP_Widget_Recent_Posts(), 6, array( 'title' => $title, 'number' => 3, 'show_date' => true ) ), + 'recentComments' => self::capture_form( new \WP_Widget_Recent_Comments(), 7, array( 'title' => $title, 'number' => 3 ) ), + 'rss' => self::capture_form( new \WP_Widget_RSS(), 8, array( 'title' => $title, 'url' => 'https://example.test/feed/', 'items' => 3 ) ), + 'tagCloud' => self::capture_form( new \WP_Widget_Tag_Cloud(), 9, array( 'title' => $title, 'taxonomy' => 'post_tag', 'count' => 1 ) ), + 'text' => self::capture_form( new \WP_Widget_Text(), 10, array( 'title' => $title, 'text' => $content, 'filter' => false, 'visual' => false ) ), + 'customHtml' => self::capture_form( new \WP_Widget_Custom_HTML(), 11, array( 'title' => $title, 'content' => $content ) ), + 'navigationMenu' => self::capture_form( new \WP_Nav_Menu_Widget(), 12, array( 'title' => $title, 'nav_menu' => 0 ) ), + 'links' => self::capture_form( new \WP_Widget_Links(), 13, array( 'orderby' => 'rating', 'limit' => 4 ) ), + ); + + foreach ( $forms as $name => $form ) { + self::collect_failure( + $failures, + ! str_contains( $form, '' ) + && ! str_contains( $form, ''; + $unsupported_index = 'unsupported-index-' . self::field_token( $ctx->fork( 'unsupported-index' ) ) . ''; + $lastmod = '2026-06-' . sprintf( '%02d', 1 + $ctx->int( 0, 20 ) ) . 'T12:34:56+00:00'; + $url_xml = $renderer->get_sitemap_xml( + array( + array( + 'loc' => 'https://example.test/render-field-boundary?unsafe="e="', + 'lastmod' => $lastmod, + 'changefreq' => 'daily', + 'priority' => '0.' . $ctx->int( 1, 9 ), + 'component:fuzz' => $unsupported_url, + 'image:image' => array( 'loc' => $unsupported_url ), + ), + ) + ); + $index_xml = $renderer->get_sitemap_index_xml( + array( + array( + 'loc' => 'https://example.test/wp-sitemap-posts-post-1.xml?unsafe="e="', + 'lastmod' => $lastmod, + 'changefreq' => 'daily', + 'componentFuzz' => $unsupported_index, + ), + ) + ); + + $parsed_url = is_string( $url_xml ) ? @simplexml_load_string( $url_xml ) : false; + $parsed_index = is_string( $index_xml ) ? @simplexml_load_string( $index_xml ) : false; + + self::collect_failure( + $failures, + is_string( $url_xml ) + && false !== $parsed_url + && 1 === count( $parsed_url->url ) + && 1 === substr_count( $url_xml, '' ) + && 1 === substr_count( $url_xml, '' ) + && 1 === substr_count( $url_xml, '' ) + && 1 === substr_count( $url_xml, '' ) + && ! str_contains( $url_xml, 'component:fuzz' ) + && ! str_contains( $url_xml, 'image:image' ) + && ! str_contains( $url_xml, 'unsupported-url-' ) + && ! str_contains( $url_xml, '' ) + && ! str_contains( $url_xml, ' self::describe_string( is_string( $url_xml ) ? $url_xml : '' ), + 'unsupported' => self::describe_string( $unsupported_url ), + ) + ); + + self::collect_failure( + $failures, + is_string( $index_xml ) + && false !== $parsed_index + && 1 === count( $parsed_index->sitemap ) + && 1 === substr_count( $index_xml, '' ) + && 1 === substr_count( $index_xml, '' ) + && ! str_contains( $index_xml, '' ) + && ! str_contains( $index_xml, 'componentFuzz' ) + && ! str_contains( $index_xml, 'unsupported-index-' ) + && ! str_contains( $index_xml, '' ) + && ! str_contains( $index_xml, ' self::describe_string( is_string( $index_xml ) ? $index_xml : '' ), + 'unsupported' => self::describe_string( $unsupported_index ), + ) + ); + + return self::row( + $ctx, + 'discovery.sitemaps.renderer-field-boundaries', + array() === $failures, + array( 'failures' => array_slice( $failures, 0, 6 ) ) + ); + } + + private static function check_sitemap_renderer_stylesheet_filters( \ComponentFuzz\FuzzContext $ctx ): array { + $failures = array(); + $token = substr( hash( 'sha1', self::NAME . ':stylesheet:' . $ctx->seed() ), 0, 12 ); + $sitemap_style_base = 'https://example.test/styles/sitemap-' . $token . '.xsl'; + $index_style_base = 'https://example.test/styles/index-' . $token . '.xsl'; + $sitemap_style_url = $sitemap_style_base . '?unsafe="e="&q=' . rawurlencode( $ctx->text( 0, 20 ) ); + $index_style_url = $index_style_base . '?unsafe="e="&q=' . rawurlencode( $ctx->fork( 'index-style' )->text( 0, 20 ) ); + $seen = array(); + + $sitemap_filter = static function ( string $stylesheet_url ) use ( $sitemap_style_url, &$seen ): string { + $seen[] = array( + 'hook' => 'sitemap', + 'url' => $stylesheet_url, + ); + + return $sitemap_style_url; + }; + + $index_filter = static function ( string $stylesheet_url ) use ( $index_style_url, &$seen ): string { + $seen[] = array( + 'hook' => 'index', + 'url' => $stylesheet_url, + ); + + return $index_style_url; + }; + + \remove_filter( 'wp_sitemaps_stylesheet_url', '__return_false', 0 ); + \remove_filter( 'wp_sitemaps_stylesheet_index_url', '__return_false', 0 ); + + try { + \add_filter( 'wp_sitemaps_stylesheet_url', $sitemap_filter, 10, 1 ); + \add_filter( 'wp_sitemaps_stylesheet_index_url', $index_filter, 10, 1 ); + + $renderer = new \WP_Sitemaps_Renderer(); + $xml = $renderer->get_sitemap_xml( + array( + array( + 'loc' => 'https://example.test/content/style?unsafe=&ok=1', + 'lastmod' => '2026-06-24T00:00:00+00:00', + ), + ) + ); + $index = $renderer->get_sitemap_index_xml( + array( + array( + 'loc' => 'https://example.test/wp-sitemap-posts-post-1.xml', + 'lastmod' => '2026-06-24T00:00:00+00:00', + ), + ) + ); + } finally { + \remove_filter( 'wp_sitemaps_stylesheet_url', $sitemap_filter, 10 ); + \remove_filter( 'wp_sitemaps_stylesheet_index_url', $index_filter, 10 ); + \add_filter( 'wp_sitemaps_stylesheet_url', '__return_false', 0 ); + \add_filter( 'wp_sitemaps_stylesheet_index_url', '__return_false', 0 ); + } + + $parsed_xml = is_string( $xml ) ? @simplexml_load_string( $xml ) : false; + $parsed_index = is_string( $index ) ? @simplexml_load_string( $index ) : false; + + self::collect_failure( + $failures, + array( 'sitemap', 'index' ) === array_column( $seen, 'hook' ) + && is_string( $xml ) + && false !== $parsed_xml + && 1 === substr_count( $xml, '' ) + && ! str_contains( $xml, 'quote="' ), + 'sitemap renderer includes filtered stylesheet processing instruction with escaped URL', + array( + 'seen' => $seen, + 'styleBase' => $sitemap_style_base, + 'xml' => self::describe_string( is_string( $xml ) ? $xml : '' ), + ) + ); + + self::collect_failure( + $failures, + is_string( $index ) + && false !== $parsed_index + && 1 === substr_count( $index, '' ) + && ! str_contains( $index, 'quote="' ) + && false === \has_filter( 'wp_sitemaps_stylesheet_url', $sitemap_filter ) + && false === \has_filter( 'wp_sitemaps_stylesheet_index_url', $index_filter ) + && false !== \has_filter( 'wp_sitemaps_stylesheet_url', '__return_false' ) + && false !== \has_filter( 'wp_sitemaps_stylesheet_index_url', '__return_false' ), + 'sitemap index renderer includes its own filtered stylesheet and restores no-stylesheet filters', + array( + 'styleBase' => $index_style_base, + 'index' => self::describe_string( is_string( $index ) ? $index : '' ), + 'sitemapFilter' => \has_filter( 'wp_sitemaps_stylesheet_url', '__return_false' ), + 'indexFilter' => \has_filter( 'wp_sitemaps_stylesheet_index_url', '__return_false' ), + 'customSitemap' => \has_filter( 'wp_sitemaps_stylesheet_url', $sitemap_filter ), + 'customIndex' => \has_filter( 'wp_sitemaps_stylesheet_index_url', $index_filter ), + ) + ); + + return self::row( + $ctx, + 'discovery.sitemaps.renderer-stylesheet-filters', + array() === $failures, + array( + 'failures' => array_slice( $failures, 0, 6 ), + ) + ); + } + + private static function check_sitemap_stylesheet_output( \ComponentFuzz\FuzzContext $ctx ): array { + $failures = array(); + $token = substr( hash( 'sha1', self::NAME . ':stylesheet-output:' . $ctx->seed() ), 0, 12 ); + $css_marker = 'cfz-css-' . $token; + $sitemap_marker = 'cfz-sitemap-xsl-' . $token; + $index_marker = 'cfz-index-xsl-' . $token; + $css_seen = array(); + $content_seen = array(); + $had_wp_locale = array_key_exists( 'wp_locale', $GLOBALS ); + $previous_wp_locale = $GLOBALS['wp_locale'] ?? null; + $stylesheet = new \WP_Sitemaps_Stylesheet(); + + $css_filter = static function ( string $css ) use ( &$css_seen, $css_marker ): string { + $css_seen[] = array( + 'bytes' => strlen( $css ), + 'alignLeft' => str_contains( $css, 'text-align: left' ), + 'alignRight' => str_contains( $css, 'text-align: right' ), + ); + + return $css . "\n /* {$css_marker} */\n #sitemap__table .{$css_marker} { text-align: inherit; }\n"; + }; + + $sitemap_content_filter = static function ( string $xsl ) use ( &$content_seen, $sitemap_marker ): string { + $content_seen[] = array( + 'hook' => 'sitemap', + 'hasUrlset' => str_contains( $xsl, 'sitemap:urlset/sitemap:url' ), + 'hasIndexSet' => str_contains( $xsl, 'sitemap:sitemapindex/sitemap:sitemap' ), + ); + + return str_replace( '', "\n\n", $xsl ); + }; + + $index_content_filter = static function ( string $xsl ) use ( &$content_seen, $index_marker ): string { + $content_seen[] = array( + 'hook' => 'index', + 'hasUrlset' => str_contains( $xsl, 'sitemap:urlset/sitemap:url' ), + 'hasIndexSet' => str_contains( $xsl, 'sitemap:sitemapindex/sitemap:sitemap' ), + ); + + return str_replace( '', "\n\n", $xsl ); + }; + + try { + \add_filter( 'wp_sitemaps_stylesheet_css', $css_filter, 10, 1 ); + \add_filter( 'wp_sitemaps_stylesheet_content', $sitemap_content_filter, 10, 1 ); + \add_filter( 'wp_sitemaps_stylesheet_index_content', $index_content_filter, 10, 1 ); + + $GLOBALS['wp_locale'] = new \WP_Locale(); + $GLOBALS['wp_locale']->text_direction = 'ltr'; + $ltr_css = $stylesheet->get_stylesheet_css(); + $sitemap_xsl = $stylesheet->get_sitemap_stylesheet(); + $index_xsl = $stylesheet->get_sitemap_index_stylesheet(); + + $GLOBALS['wp_locale']->text_direction = 'rtl'; + $rtl_css = $stylesheet->get_stylesheet_css(); + } finally { + \remove_filter( 'wp_sitemaps_stylesheet_index_content', $index_content_filter, 10 ); + \remove_filter( 'wp_sitemaps_stylesheet_content', $sitemap_content_filter, 10 ); + \remove_filter( 'wp_sitemaps_stylesheet_css', $css_filter, 10 ); + + if ( $had_wp_locale ) { + $GLOBALS['wp_locale'] = $previous_wp_locale; + } else { + unset( $GLOBALS['wp_locale'] ); + } + } + + $parsed_sitemap = is_string( $sitemap_xsl ) ? @simplexml_load_string( $sitemap_xsl ) : false; + $parsed_index = is_string( $index_xsl ) ? @simplexml_load_string( $index_xsl ) : false; + + self::collect_failure( + $failures, + is_string( $ltr_css ) + && is_string( $rtl_css ) + && str_contains( $ltr_css, 'text-align: left' ) + && ! str_contains( $ltr_css, 'text-align: right' ) + && str_contains( $rtl_css, 'text-align: right' ) + && ! str_contains( $rtl_css, 'text-align: left' ) + && str_contains( $ltr_css, $css_marker ) + && str_contains( $rtl_css, $css_marker ) + && 4 === count( $css_seen ) + && array( true, true, true, false ) === array_column( $css_seen, 'alignLeft' ) + && array( false, false, false, true ) === array_column( $css_seen, 'alignRight' ), + 'WP_Sitemaps_Stylesheet CSS honors LTR/RTL text alignment and scoped CSS filters', + array( + 'cssSeen' => $css_seen, + 'ltrCss' => self::describe_string( is_string( $ltr_css ) ? $ltr_css : '' ), + 'rtlCss' => self::describe_string( is_string( $rtl_css ) ? $rtl_css : '' ), + ) + ); + + self::collect_failure( + $failures, + is_string( $sitemap_xsl ) + && false !== $parsed_sitemap + && str_starts_with( $sitemap_xsl, '' ) + && str_contains( $sitemap_xsl, 'sitemap:urlset/sitemap:url' ) + && str_contains( $sitemap_xsl, 'count( sitemap:urlset/sitemap:url )' ) + && str_contains( $sitemap_xsl, 'name="has-changefreq"' ) + && str_contains( $sitemap_xsl, 'name="has-priority"' ) + && str_contains( $sitemap_xsl, 'class="changefreq"' ) + && str_contains( $sitemap_xsl, 'class="priority"' ) + && str_contains( $sitemap_xsl, $css_marker ) + && str_contains( $sitemap_xsl, $sitemap_marker ) + && ! str_contains( $sitemap_xsl, $index_marker ) + && 1 === substr_count( $sitemap_xsl, ' $content_seen, + 'sitemapXsl' => self::describe_string( is_string( $sitemap_xsl ) ? $sitemap_xsl : '' ), + ) + ); + + self::collect_failure( + $failures, + is_string( $index_xsl ) + && false !== $parsed_index + && str_starts_with( $index_xsl, '' ) + && str_contains( $index_xsl, 'sitemap:sitemapindex/sitemap:sitemap' ) + && str_contains( $index_xsl, 'count( sitemap:sitemapindex/sitemap:sitemap )' ) + && str_contains( $index_xsl, 'name="has-lastmod"' ) + && ! str_contains( $index_xsl, 'name="has-changefreq"' ) + && ! str_contains( $index_xsl, 'name="has-priority"' ) + && ! str_contains( $index_xsl, 'class="changefreq"' ) + && ! str_contains( $index_xsl, 'class="priority"' ) + && str_contains( $index_xsl, $css_marker ) + && str_contains( $index_xsl, $index_marker ) + && ! str_contains( $index_xsl, $sitemap_marker ) + && 1 === substr_count( $index_xsl, ' $content_seen, + 'indexXsl' => self::describe_string( is_string( $index_xsl ) ? $index_xsl : '' ), + ) + ); + + self::collect_failure( + $failures, + array( + array( + 'hook' => 'sitemap', + 'hasUrlset' => true, + 'hasIndexSet' => false, + ), + array( + 'hook' => 'index', + 'hasUrlset' => false, + 'hasIndexSet' => true, + ), + ) === $content_seen + && false === \has_filter( 'wp_sitemaps_stylesheet_css', $css_filter ) + && false === \has_filter( 'wp_sitemaps_stylesheet_content', $sitemap_content_filter ) + && false === \has_filter( 'wp_sitemaps_stylesheet_index_content', $index_content_filter ) + && ( $had_wp_locale ? ( $GLOBALS['wp_locale'] ?? null ) === $previous_wp_locale : ! array_key_exists( 'wp_locale', $GLOBALS ) ), + 'WP_Sitemaps_Stylesheet content filters are type-specific and temporary locale/filter globals are restored', + array( + 'contentSeen' => $content_seen, + 'cssFilter' => \has_filter( 'wp_sitemaps_stylesheet_css', $css_filter ), + 'sitemapFilter' => \has_filter( 'wp_sitemaps_stylesheet_content', $sitemap_content_filter ), + 'indexFilter' => \has_filter( 'wp_sitemaps_stylesheet_index_content', $index_content_filter ), + 'wpLocaleRestored' => $had_wp_locale ? ( ( $GLOBALS['wp_locale'] ?? null ) === $previous_wp_locale ) : ! array_key_exists( 'wp_locale', $GLOBALS ), + ) + ); + + return self::row( + $ctx, + 'discovery.sitemaps.stylesheet-output', + array() === $failures, + array( + 'failures' => array_slice( $failures, 0, 8 ), + ) + ); + } + + private static function check_sitemap_max_url_filter( \ComponentFuzz\FuzzContext $ctx ): array { + $failures = array(); + $limit = 10 + $ctx->int( 0, 50 ); + $seen = array(); + $filter = static function ( int $max_urls, string $object_type ) use ( $limit, &$seen ): int { + $seen[] = array( + 'max' => $max_urls, + 'type' => $object_type, + ); + + return 'component' === $object_type ? $limit : $max_urls; + }; + + try { + \add_filter( 'wp_sitemaps_max_urls', $filter, 10, 2 ); + $component = \wp_sitemaps_get_max_urls( 'component' ); + $post = \wp_sitemaps_get_max_urls( 'post' ); + } finally { + \remove_filter( 'wp_sitemaps_max_urls', $filter, 10 ); + } + + $after = \wp_sitemaps_get_max_urls( 'component' ); + self::collect_failure( + $failures, + $limit === $component + && 2000 === $post + && 2000 === $after + && 2 === count( $seen ) + && 'component' === $seen[0]['type'] + && 'post' === $seen[1]['type'], + 'wp_sitemaps_get_max_urls filter is scoped by object type and removable', + array( + 'limit' => $limit, + 'component' => $component, + 'post' => $post, + 'after' => $after, + 'seen' => $seen, + ) + ); + + return self::row( + $ctx, + 'discovery.sitemaps.max-url-filter-locality', + array() === $failures, + array( 'failures' => $failures ) + ); + } + + private static function check_sitemap_posts_provider( \ComponentFuzz\FuzzContext $ctx ): array { + $missing = self::sitemap_provider_fixture_missing_requirements(); + if ( array() !== $missing ) { + return self::skip( + $ctx, + 'discovery.sitemaps.posts-provider-fixtures', + 'Built-in sitemap provider fixture APIs are unavailable.', + array( 'missing' => $missing ) + ); + } + + $failures = array(); + $case = self::prepare_sitemap_provider_runtime( $ctx->fork( 'posts-runtime' ) ); + $provider = new \WP_Sitemaps_Posts(); + $limit = 2 + $ctx->int( 0, 1 ); + $private_type = $case['postTypePrivate']; + $public_type = $case['postTypePublic']; + $published = array(); + + \register_post_type( + $public_type, + array( + 'public' => true, + 'label' => 'Discovery Public', + 'has_archive' => true, + 'rewrite' => false, + 'supports' => array( 'title', 'editor', 'author' ), + ) + ); + + \register_post_type( + $private_type, + array( + 'public' => false, + 'label' => 'Discovery Private', + 'has_archive' => false, + 'rewrite' => false, + 'supports' => array( 'title', 'editor', 'author' ), + ) + ); + + for ( $i = 0; $i < ( $limit * 2 ) + 1; ++$i ) { + $day = 1 + $i; + $modified_gmt = sprintf( '2026-06-%02d %02d:00:00', $day, 8 + ( $i % 10 ) ); + $modified = sprintf( '2026-06-%02d %02d:30:00', $day, 11 + ( $i % 10 ) ); + $published[] = array( + 'id' => self::insert_sitemap_post( + array( + 'post_type' => 'post', + 'post_status' => 'publish', + 'post_title' => 'Discovery sitemap post ' . $i, + 'post_name' => 'discovery-sitemap-post-' . $case['token'] . '-' . $i, + 'post_date' => $modified, + 'post_date_gmt' => $modified_gmt, + 'post_modified' => $modified, + 'post_modified_gmt' => $modified_gmt, + ) + ), + 'modified_gmt' => $modified_gmt, + ); + } + + $custom_published = array(); + for ( $i = 0; $i < $limit + 1; ++$i ) { + $day = 10 + $i; + $custom_gmt = sprintf( '2026-06-%02d %02d:15:00', $day, 7 + ( $i % 10 ) ); + $custom_modified = sprintf( '2026-06-%02d %02d:45:00', $day, 10 + ( $i % 10 ) ); + $custom_published[] = array( + 'id' => self::insert_sitemap_post( + array( + 'post_type' => $public_type, + 'post_status' => 'publish', + 'post_title' => 'Discovery custom sitemap post ' . $i, + 'post_name' => 'discovery-custom-sitemap-post-' . $case['token'] . '-' . $i, + 'post_date' => $custom_modified, + 'post_date_gmt' => $custom_gmt, + 'post_modified' => $custom_modified, + 'post_modified_gmt' => $custom_gmt, + ) + ), + 'modified_gmt' => $custom_gmt, + ); + } + + $page_gmt = '2026-06-18 06:00:00'; + $page_modified = '2026-06-18 09:45:00'; + $page_fixture = array( + 'id' => self::insert_sitemap_post( + array( + 'post_type' => 'page', + 'post_status' => 'publish', + 'post_title' => 'Discovery sitemap page', + 'post_name' => 'discovery-sitemap-page-' . $case['token'], + 'post_date' => $page_modified, + 'post_date_gmt' => $page_gmt, + 'post_modified' => $page_modified, + 'post_modified_gmt' => $page_gmt, + ) + ), + 'modified_gmt' => $page_gmt, + ); + + $draft_id = self::insert_sitemap_post( + array( + 'post_type' => 'post', + 'post_status' => 'draft', + 'post_title' => 'Discovery draft sitemap post', + 'post_name' => 'discovery-draft-' . $case['token'], + 'post_modified_gmt' => '2026-06-20 10:00:00', + ) + ); + $private_id = self::insert_sitemap_post( + array( + 'post_type' => 'post', + 'post_status' => 'private', + 'post_title' => 'Discovery private sitemap post', + 'post_name' => 'discovery-private-' . $case['token'], + 'post_modified_gmt' => '2026-06-21 10:00:00', + ) + ); + $unsupported_private_type_id = self::insert_sitemap_post( + array( + 'post_type' => $private_type, + 'post_status' => 'publish', + 'post_title' => 'Discovery unsupported post type sitemap post', + 'post_name' => 'discovery-unsupported-' . $case['token'], + 'post_modified_gmt' => '2026-06-22 10:00:00', + ) + ); + $custom_draft_id = self::insert_sitemap_post( + array( + 'post_type' => $public_type, + 'post_status' => 'draft', + 'post_title' => 'Discovery custom draft sitemap post', + 'post_name' => 'discovery-custom-draft-' . $case['token'], + 'post_modified_gmt' => '2026-07-20 10:00:00', + ) + ); + + $subtypes = $provider->get_object_subtypes(); + self::collect_failure( + $failures, + isset( $subtypes['post'] ) + && isset( $subtypes['page'] ) + && isset( $subtypes[ $public_type ] ) + && ! isset( $subtypes['attachment'] ) + && ! isset( $subtypes[ $private_type ] ), + 'WP_Sitemaps_Posts exposes viewable public post subtypes and excludes attachments/private types', + array( + 'subtypes' => array_keys( $subtypes ), + 'publicType' => $public_type, + 'privateType' => $private_type, + ) + ); + + $query_seen = array(); + $entry_seen = array(); + $max_filter = static function ( int $max_urls, string $object_type ) use ( $limit ): int { + return 'post' === $object_type ? $limit : $max_urls; + }; + $query_filter = static function ( array $args, string $post_type ) use ( &$query_seen ): array { + $query_seen[] = array( + 'postType' => $post_type, + 'status' => $args['post_status'] ?? null, + 'postsPerPage' => $args['posts_per_page'] ?? null, + 'noFoundRows' => $args['no_found_rows'] ?? null, + 'ignoreSticky' => $args['ignore_sticky_posts'] ?? null, + ); + + return $args; + }; + $entry_filter = static function ( array $entry, \WP_Post $post, string $post_type ) use ( &$entry_seen ): array { + $entry_seen[] = array( + 'id' => (int) $post->ID, + 'type' => $post_type, + 'loc' => $entry['loc'] ?? null, + 'lastmod' => $entry['lastmod'] ?? null, + ); + + $entry['component-fuzz-id'] = (string) $post->ID; + return $entry; + }; + + \add_filter( 'wp_sitemaps_max_urls', $max_filter, 10, 2 ); + \add_filter( 'wp_sitemaps_posts_query_args', $query_filter, 10, 2 ); + \add_filter( 'wp_sitemaps_posts_entry', $entry_filter, 10, 3 ); + try { + $max_pages = $provider->get_max_num_pages( 'post' ); + $page_entries = array(); + for ( $page = 1; $page <= $max_pages; ++$page ) { + $page_entries[ $page ] = $provider->get_url_list( $page, 'post' ); + } + $unsupported_list = $provider->get_url_list( 1, $private_type ); + $attachment_list = $provider->get_url_list( 1, 'attachment' ); + } finally { + \remove_filter( 'wp_sitemaps_posts_entry', $entry_filter, 10 ); + \remove_filter( 'wp_sitemaps_posts_query_args', $query_filter, 10 ); + \remove_filter( 'wp_sitemaps_max_urls', $max_filter, 10 ); + } + + $all_entries = array_merge( ...array_values( $page_entries ) ); + self::collect_failure( + $failures, + 3 === $max_pages + && $limit === count( $page_entries[1] ?? array() ) + && $limit === count( $page_entries[2] ?? array() ) + && 1 === count( $page_entries[3] ?? array() ) + && count( $published ) === count( $all_entries ) + && self::sitemap_post_entries_match_fixtures( $all_entries, $published ) + && ! self::sitemap_entries_contain_locs_for_ids( $all_entries, array( $draft_id, $private_id, $unsupported_private_type_id ) ) + && array() === $unsupported_list + && array() === $attachment_list + && self::sitemap_post_query_args_local_to_type( $query_seen, 'post', $limit ) + && array_column( $entry_seen, 'id' ) === array_column( $published, 'id' ) + && false === \has_filter( 'wp_sitemaps_posts_entry', $entry_filter ) + && false === \has_filter( 'wp_sitemaps_posts_query_args', $query_filter ) + && false === \has_filter( 'wp_sitemaps_max_urls', $max_filter ), + 'WP_Sitemaps_Posts lists only published supported subtype entries with lastmod and max-page math', + array( + 'limit' => $limit, + 'maxPages' => $max_pages, + 'pageCounts' => array_map( 'count', $page_entries ), + 'entrySeen' => $entry_seen, + 'querySeen' => $query_seen, + 'unsupportedList' => $unsupported_list, + 'attachmentList' => $attachment_list, + ) + ); + + $custom_query_seen = array(); + $custom_entry_seen = array(); + $custom_max_filter = static function ( int $max_urls, string $object_type ) use ( $limit ): int { + return 'post' === $object_type ? $limit : $max_urls; + }; + $custom_query_filter = static function ( array $args, string $post_type ) use ( &$custom_query_seen ): array { + $custom_query_seen[] = array( + 'postType' => $post_type, + 'status' => $args['post_status'] ?? null, + 'postsPerPage' => $args['posts_per_page'] ?? null, + 'noFoundRows' => $args['no_found_rows'] ?? null, + 'ignoreSticky' => $args['ignore_sticky_posts'] ?? null, + ); + + return $args; + }; + $custom_entry_filter = static function ( array $entry, \WP_Post $post, string $post_type ) use ( &$custom_entry_seen ): array { + $custom_entry_seen[] = array( + 'id' => (int) $post->ID, + 'type' => $post_type, + 'loc' => $entry['loc'] ?? null, + 'lastmod' => $entry['lastmod'] ?? null, + ); + + $entry['component-fuzz-id'] = (string) $post->ID; + return $entry; + }; + + \add_filter( 'wp_sitemaps_max_urls', $custom_max_filter, 10, 2 ); + \add_filter( 'wp_sitemaps_posts_query_args', $custom_query_filter, 10, 2 ); + \add_filter( 'wp_sitemaps_posts_entry', $custom_entry_filter, 10, 3 ); + try { + $custom_max_pages = $provider->get_max_num_pages( $public_type ); + $custom_page_entries = array(); + for ( $page = 1; $page <= $custom_max_pages; ++$page ) { + $custom_page_entries[ $page ] = $provider->get_url_list( $page, $public_type ); + } + } finally { + \remove_filter( 'wp_sitemaps_posts_entry', $custom_entry_filter, 10 ); + \remove_filter( 'wp_sitemaps_posts_query_args', $custom_query_filter, 10 ); + \remove_filter( 'wp_sitemaps_max_urls', $custom_max_filter, 10 ); + } + + $custom_entries = array_merge( ...array_values( $custom_page_entries ) ); + self::collect_failure( + $failures, + 2 === $custom_max_pages + && $limit === count( $custom_page_entries[1] ?? array() ) + && 1 === count( $custom_page_entries[2] ?? array() ) + && count( $custom_published ) === count( $custom_entries ) + && self::sitemap_post_entries_match_fixtures( $custom_entries, $custom_published ) + && ! self::sitemap_entries_contain_locs_for_ids( $custom_entries, array( $custom_draft_id ) ) + && self::sitemap_post_query_args_local_to_type( $custom_query_seen, $public_type, $limit ) + && array_column( $custom_entry_seen, 'id' ) === array_column( $custom_published, 'id' ) + && false === \has_filter( 'wp_sitemaps_posts_entry', $custom_entry_filter ) + && false === \has_filter( 'wp_sitemaps_posts_query_args', $custom_query_filter ) + && false === \has_filter( 'wp_sitemaps_max_urls', $custom_max_filter ), + 'WP_Sitemaps_Posts includes viewable public custom post type entries with local filters and max-page math', + array( + 'publicType' => $public_type, + 'limit' => $limit, + 'maxPages' => $custom_max_pages, + 'pageCounts' => array_map( 'count', $custom_page_entries ), + 'entrySeen' => $custom_entry_seen, + 'querySeen' => $custom_query_seen, + 'customDraft' => $custom_draft_id, + ) + ); + + $page_query_seen = array(); + $page_entry_seen = array(); + $home_seen = array(); + $page_max_filter = static function ( int $max_urls, string $object_type ) use ( $limit ): int { + return 'post' === $object_type ? $limit : $max_urls; + }; + $page_query_filter = static function ( array $args, string $post_type ) use ( &$page_query_seen ): array { + $page_query_seen[] = array( + 'postType' => $post_type, + 'status' => $args['post_status'] ?? null, + 'postsPerPage' => $args['posts_per_page'] ?? null, + 'noFoundRows' => $args['no_found_rows'] ?? null, + 'ignoreSticky' => $args['ignore_sticky_posts'] ?? null, + ); + + return $args; + }; + $page_entry_filter = static function ( array $entry, \WP_Post $post, string $post_type ) use ( &$page_entry_seen ): array { + $page_entry_seen[] = array( + 'id' => (int) $post->ID, + 'type' => $post_type, + 'loc' => $entry['loc'] ?? null, + 'lastmod' => $entry['lastmod'] ?? null, + ); + + $entry['component-fuzz-id'] = (string) $post->ID; + return $entry; + }; + $home_filter = static function ( array $entry ) use ( &$home_seen ): array { + $home_seen[] = $entry; + $entry['component-fuzz-home'] = '1'; + return $entry; + }; + + \add_filter( 'wp_sitemaps_max_urls', $page_max_filter, 10, 2 ); + \add_filter( 'wp_sitemaps_posts_query_args', $page_query_filter, 10, 2 ); + \add_filter( 'wp_sitemaps_posts_entry', $page_entry_filter, 10, 3 ); + \add_filter( 'wp_sitemaps_posts_show_on_front_entry', $home_filter, 10, 1 ); + try { + $page_max_pages = $provider->get_max_num_pages( 'page' ); + $page_list = $provider->get_url_list( 1, 'page' ); + $page_two_list = $provider->get_url_list( 2, 'page' ); + } finally { + \remove_filter( 'wp_sitemaps_posts_show_on_front_entry', $home_filter, 10 ); + \remove_filter( 'wp_sitemaps_posts_entry', $page_entry_filter, 10 ); + \remove_filter( 'wp_sitemaps_posts_query_args', $page_query_filter, 10 ); + \remove_filter( 'wp_sitemaps_max_urls', $page_max_filter, 10 ); + } + + $latest_post = $published[ count( $published ) - 1 ]; + $expected_home_lastmod = self::expected_sitemap_lastmod_from_gmt( (string) $latest_post['modified_gmt'] ); + $page_post_entries = array_slice( $page_list, 1 ); + $page_home_entry = $page_list[0] ?? array(); + $captured_home_entry = $home_seen[0] ?? array(); + self::collect_failure( + $failures, + 1 === $page_max_pages + && 2 === count( $page_list ) + && array() === $page_two_list + && \home_url( '/' ) === ( $page_home_entry['loc'] ?? null ) + && '1' === ( $page_home_entry['component-fuzz-home'] ?? null ) + && $expected_home_lastmod === ( $page_home_entry['lastmod'] ?? null ) + && \home_url( '/' ) === ( $captured_home_entry['loc'] ?? null ) + && $expected_home_lastmod === ( $captured_home_entry['lastmod'] ?? null ) + && self::sitemap_post_entries_match_fixtures( $page_post_entries, array( $page_fixture ) ) + && self::sitemap_post_query_args_local_to_type( $page_query_seen, 'page', $limit ) + && array_column( $page_entry_seen, 'id' ) === array( $page_fixture['id'] ) + && false === \has_filter( 'wp_sitemaps_posts_show_on_front_entry', $home_filter ) + && false === \has_filter( 'wp_sitemaps_posts_entry', $page_entry_filter ) + && false === \has_filter( 'wp_sitemaps_posts_query_args', $page_query_filter ) + && false === \has_filter( 'wp_sitemaps_max_urls', $page_max_filter ), + 'WP_Sitemaps_Posts page subtype includes the show-on-front home entry, page entries, and min-page math', + array( + 'limit' => $limit, + 'pageMaxPages' => $page_max_pages, + 'pageList' => $page_list, + 'pageTwoList' => $page_two_list, + 'homeSeen' => $home_seen, + 'expectedHomeLastmod' => $expected_home_lastmod, + 'pageEntrySeen' => $page_entry_seen, + 'pageQuerySeen' => $page_query_seen, + ) + ); + + $pre_seen = array(); + $pre_page_seen = array(); + $pre_sentinel = array( + array( + 'loc' => 'https://example.test/pre-posts-' . $case['token'], + 'lastmod' => '2026-06-25T00:00:00+00:00', + ), + ); + $pre_list_filter = static function ( $url_list, string $post_type, int $page_num ) use ( &$pre_seen, $pre_sentinel ) { + $pre_seen[] = array( + 'type' => $post_type, + 'page' => $page_num, + 'null' => null === $url_list, + ); + + return 'post' === $post_type && 2 === $page_num ? $pre_sentinel : $url_list; + }; + $pre_page_filter = static function ( $max_num_pages, string $post_type ) use ( &$pre_page_seen ): int { + $pre_page_seen[] = array( + 'type' => $post_type, + 'null' => null === $max_num_pages, + ); + + return 'post' === $post_type ? 17 : (int) $max_num_pages; + }; + + \add_filter( 'wp_sitemaps_posts_pre_url_list', $pre_list_filter, 10, 3 ); + \add_filter( 'wp_sitemaps_posts_pre_max_num_pages', $pre_page_filter, 10, 2 ); + try { + $pre_list = $provider->get_url_list( 2, 'post' ); + $pre_unsupported = $provider->get_url_list( 2, $private_type ); + $pre_pages = $provider->get_max_num_pages( 'post' ); + } finally { + \remove_filter( 'wp_sitemaps_posts_pre_max_num_pages', $pre_page_filter, 10 ); + \remove_filter( 'wp_sitemaps_posts_pre_url_list', $pre_list_filter, 10 ); + } + + self::collect_failure( + $failures, + $pre_sentinel === $pre_list + && array() === $pre_unsupported + && 17 === $pre_pages + && array( array( 'type' => 'post', 'page' => 2, 'null' => true ) ) === $pre_seen + && array( array( 'type' => 'post', 'null' => true ) ) === $pre_page_seen + && false === \has_filter( 'wp_sitemaps_posts_pre_url_list', $pre_list_filter ) + && false === \has_filter( 'wp_sitemaps_posts_pre_max_num_pages', $pre_page_filter ), + 'WP_Sitemaps_Posts pre-list and pre-page filters short-circuit only matching subtype calls', + array( + 'preList' => $pre_list, + 'preUnsupported' => $pre_unsupported, + 'prePages' => $pre_pages, + 'preSeen' => $pre_seen, + 'prePageSeen' => $pre_page_seen, + ) + ); + + return self::row( + $ctx, + 'discovery.sitemaps.posts-provider-fixtures', + array() === $failures, + array( + 'limit' => $limit, + 'failures' => array_slice( $failures, 0, 6 ), + ) + ); + } + + private static function check_sitemap_taxonomies_provider( \ComponentFuzz\FuzzContext $ctx ): array { + $missing = self::sitemap_provider_fixture_missing_requirements(); + if ( array() !== $missing ) { + return self::skip( + $ctx, + 'discovery.sitemaps.taxonomies-provider-fixtures', + 'Built-in sitemap provider fixture APIs are unavailable.', + array( 'missing' => $missing ) + ); + } + + $failures = array(); + $case = self::prepare_sitemap_provider_runtime( $ctx->fork( 'taxonomies-runtime' ) ); + $provider = new \WP_Sitemaps_Taxonomies(); + $limit = 2 + $ctx->int( 0, 1 ); + $private_tax = $case['taxonomyPrivate']; + $public_tax = $case['taxonomyPublic']; + $included = array(); + $empty = array(); + + \register_taxonomy( + $public_tax, + array( 'post' ), + array( + 'public' => true, + 'hierarchical' => false, + 'label' => 'Discovery Public Taxonomy', + 'rewrite' => false, + ) + ); + + \register_taxonomy( + $private_tax, + array( 'post' ), + array( + 'public' => false, + 'hierarchical' => true, + 'label' => 'Discovery Private Taxonomy', + 'rewrite' => false, + ) + ); + + for ( $i = 0; $i < $limit + 1; ++$i ) { + $included[] = self::insert_sitemap_term( + 'category', + 'Discovery Sitemap Category ' . $i, + 'discovery-sitemap-category-' . $case['token'] . '-' . $i, + 1 + $i + ); + } + + for ( $i = 0; $i < 2; ++$i ) { + $empty[] = self::insert_sitemap_term( + 'category', + 'Discovery Empty Category ' . $i, + 'discovery-empty-category-' . $case['token'] . '-' . $i, + 0 + ); + } + + $public_included = array(); + for ( $i = 0; $i < $limit + 1; ++$i ) { + $public_included[] = self::insert_sitemap_term( + $public_tax, + 'Discovery Public Taxonomy Term ' . $i, + 'discovery-public-taxonomy-term-' . $case['token'] . '-' . $i, + 2 + $i + ); + } + + $public_empty = self::insert_sitemap_term( + $public_tax, + 'Discovery Empty Public Taxonomy Term', + 'discovery-empty-public-taxonomy-term-' . $case['token'], + 0 + ); + + $private_term = self::insert_sitemap_term( + $private_tax, + 'Discovery Private Term', + 'discovery-private-term-' . $case['token'], + 3 + ); + + $subtypes = $provider->get_object_subtypes(); + self::collect_failure( + $failures, + isset( $subtypes['category'] ) + && isset( $subtypes['post_tag'] ) + && isset( $subtypes[ $public_tax ] ) + && ! isset( $subtypes[ $private_tax ] ), + 'WP_Sitemaps_Taxonomies exposes public taxonomies and excludes private taxonomy subtypes', + array( + 'subtypes' => array_keys( $subtypes ), + 'publicTax' => $public_tax, + 'privateTax' => $private_tax, + ) + ); + + $query_seen = array(); + $entry_seen = array(); + $max_filter = static function ( int $max_urls, string $object_type ) use ( $limit ): int { + return 'term' === $object_type ? $limit : $max_urls; + }; + $query_filter = static function ( array $args, string $taxonomy ) use ( &$query_seen ): array { + $query_seen[] = array( + 'taxonomy' => $taxonomy, + 'number' => $args['number'] ?? null, + 'hideEmpty' => $args['hide_empty'] ?? null, + 'fields' => $args['fields'] ?? null, + 'offset' => $args['offset'] ?? null, + ); + + return $args; + }; + $entry_filter = static function ( array $entry, int $term_id, string $taxonomy, \WP_Term $term ) use ( &$entry_seen ): array { + $entry_seen[] = array( + 'id' => $term_id, + 'taxonomy' => $taxonomy, + 'count' => (int) $term->count, + 'loc' => $entry['loc'] ?? null, + ); + + $entry['component-fuzz-term'] = (string) $term_id; + return $entry; + }; + + \add_filter( 'wp_sitemaps_max_urls', $max_filter, 10, 2 ); + \add_filter( 'wp_sitemaps_taxonomies_query_args', $query_filter, 10, 2 ); + \add_filter( 'wp_sitemaps_taxonomies_entry', $entry_filter, 10, 4 ); + try { + $max_pages = $provider->get_max_num_pages( 'category' ); + $page_entries = array(); + for ( $page = 1; $page <= $max_pages; ++$page ) { + $page_entries[ $page ] = $provider->get_url_list( $page, 'category' ); + } + $unsupported_list = $provider->get_url_list( 1, $private_tax ); + } finally { + \remove_filter( 'wp_sitemaps_taxonomies_entry', $entry_filter, 10 ); + \remove_filter( 'wp_sitemaps_taxonomies_query_args', $query_filter, 10 ); + \remove_filter( 'wp_sitemaps_max_urls', $max_filter, 10 ); + } + + $all_entries = array_merge( ...array_values( $page_entries ) ); + self::collect_failure( + $failures, + 2 === $max_pages + && $limit === count( $page_entries[1] ?? array() ) + && 1 === count( $page_entries[2] ?? array() ) + && count( $included ) === count( $all_entries ) + && self::sitemap_term_entries_match_fixtures( $all_entries, $included, 'category' ) + && array_column( $entry_seen, 'id' ) === array_column( $included, 'term_id' ) + && ! array_intersect( array_column( $entry_seen, 'id' ), array_column( $empty, 'term_id' ) ) + && ! in_array( $private_term['term_id'], array_column( $entry_seen, 'id' ), true ) + && array() === $unsupported_list + && self::sitemap_taxonomy_query_args_local_to_taxonomy( $query_seen, 'category', $limit ) + && false === \has_filter( 'wp_sitemaps_taxonomies_entry', $entry_filter ) + && false === \has_filter( 'wp_sitemaps_taxonomies_query_args', $query_filter ) + && false === \has_filter( 'wp_sitemaps_max_urls', $max_filter ), + 'WP_Sitemaps_Taxonomies honors public subtype gating, hide-empty terms, locs, and max-page math', + array( + 'limit' => $limit, + 'maxPages' => $max_pages, + 'pageCounts' => array_map( 'count', $page_entries ), + 'entrySeen' => $entry_seen, + 'querySeen' => $query_seen, + 'unsupportedList' => $unsupported_list, + ) + ); + + $public_tax_query_seen = array(); + $public_tax_entry_seen = array(); + $public_tax_max_filter = static function ( int $max_urls, string $object_type ) use ( $limit ): int { + return 'term' === $object_type ? $limit : $max_urls; + }; + $public_tax_query_filter = static function ( array $args, string $taxonomy ) use ( &$public_tax_query_seen ): array { + $public_tax_query_seen[] = array( + 'taxonomy' => $taxonomy, + 'number' => $args['number'] ?? null, + 'hideEmpty' => $args['hide_empty'] ?? null, + 'fields' => $args['fields'] ?? null, + 'offset' => $args['offset'] ?? null, + ); + + return $args; + }; + $public_tax_entry_filter = static function ( array $entry, int $term_id, string $taxonomy, \WP_Term $term ) use ( &$public_tax_entry_seen ): array { + $public_tax_entry_seen[] = array( + 'id' => $term_id, + 'taxonomy' => $taxonomy, + 'count' => (int) $term->count, + 'loc' => $entry['loc'] ?? null, + ); + + $entry['component-fuzz-term'] = (string) $term_id; + return $entry; + }; + + \add_filter( 'wp_sitemaps_max_urls', $public_tax_max_filter, 10, 2 ); + \add_filter( 'wp_sitemaps_taxonomies_query_args', $public_tax_query_filter, 10, 2 ); + \add_filter( 'wp_sitemaps_taxonomies_entry', $public_tax_entry_filter, 10, 4 ); + try { + $public_tax_max_pages = $provider->get_max_num_pages( $public_tax ); + $public_tax_page_entries = array(); + for ( $page = 1; $page <= $public_tax_max_pages; ++$page ) { + $public_tax_page_entries[ $page ] = $provider->get_url_list( $page, $public_tax ); + } + } finally { + \remove_filter( 'wp_sitemaps_taxonomies_entry', $public_tax_entry_filter, 10 ); + \remove_filter( 'wp_sitemaps_taxonomies_query_args', $public_tax_query_filter, 10 ); + \remove_filter( 'wp_sitemaps_max_urls', $public_tax_max_filter, 10 ); + } + + $public_tax_entries = array_merge( ...array_values( $public_tax_page_entries ) ); + self::collect_failure( + $failures, + 2 === $public_tax_max_pages + && $limit === count( $public_tax_page_entries[1] ?? array() ) + && 1 === count( $public_tax_page_entries[2] ?? array() ) + && count( $public_included ) === count( $public_tax_entries ) + && self::sitemap_term_entries_match_fixtures( $public_tax_entries, $public_included, $public_tax ) + && array_column( $public_tax_entry_seen, 'id' ) === array_column( $public_included, 'term_id' ) + && ! in_array( $public_empty['term_id'], array_column( $public_tax_entry_seen, 'id' ), true ) + && self::sitemap_taxonomy_query_args_local_to_taxonomy( $public_tax_query_seen, $public_tax, $limit ) + && false === \has_filter( 'wp_sitemaps_taxonomies_entry', $public_tax_entry_filter ) + && false === \has_filter( 'wp_sitemaps_taxonomies_query_args', $public_tax_query_filter ) + && false === \has_filter( 'wp_sitemaps_max_urls', $public_tax_max_filter ), + 'WP_Sitemaps_Taxonomies includes public custom taxonomy terms with hide-empty and max-page math', + array( + 'publicTax' => $public_tax, + 'limit' => $limit, + 'maxPages' => $public_tax_max_pages, + 'pageCounts' => array_map( 'count', $public_tax_page_entries ), + 'entrySeen' => $public_tax_entry_seen, + 'querySeen' => $public_tax_query_seen, + 'publicEmpty' => $public_empty, + ) + ); + + $pre_seen = array(); + $pre_page_seen = array(); + $pre_sentinel = array( array( 'loc' => 'https://example.test/pre-taxonomies-' . $case['token'] ) ); + $pre_list_filter = static function ( $url_list, string $taxonomy, int $page_num ) use ( &$pre_seen, $pre_sentinel ) { + $pre_seen[] = array( + 'taxonomy' => $taxonomy, + 'page' => $page_num, + 'null' => null === $url_list, + ); + + return 'category' === $taxonomy && 2 === $page_num ? $pre_sentinel : $url_list; + }; + $pre_page_filter = static function ( $max_num_pages, string $taxonomy ) use ( &$pre_page_seen ): int { + $pre_page_seen[] = array( + 'taxonomy' => $taxonomy, + 'null' => null === $max_num_pages, + ); + + return 'category' === $taxonomy ? 19 : (int) $max_num_pages; + }; + + \add_filter( 'wp_sitemaps_taxonomies_pre_url_list', $pre_list_filter, 10, 3 ); + \add_filter( 'wp_sitemaps_taxonomies_pre_max_num_pages', $pre_page_filter, 10, 2 ); + try { + $pre_list = $provider->get_url_list( 2, 'category' ); + $pre_unsupported = $provider->get_url_list( 2, $private_tax ); + $pre_pages = $provider->get_max_num_pages( 'category' ); + } finally { + \remove_filter( 'wp_sitemaps_taxonomies_pre_max_num_pages', $pre_page_filter, 10 ); + \remove_filter( 'wp_sitemaps_taxonomies_pre_url_list', $pre_list_filter, 10 ); + } + + self::collect_failure( + $failures, + $pre_sentinel === $pre_list + && array() === $pre_unsupported + && 19 === $pre_pages + && array( array( 'taxonomy' => 'category', 'page' => 2, 'null' => true ) ) === $pre_seen + && array( array( 'taxonomy' => 'category', 'null' => true ) ) === $pre_page_seen + && false === \has_filter( 'wp_sitemaps_taxonomies_pre_url_list', $pre_list_filter ) + && false === \has_filter( 'wp_sitemaps_taxonomies_pre_max_num_pages', $pre_page_filter ), + 'WP_Sitemaps_Taxonomies pre-list and pre-page filters short-circuit only matching taxonomy calls', + array( + 'preList' => $pre_list, + 'preUnsupported' => $pre_unsupported, + 'prePages' => $pre_pages, + 'preSeen' => $pre_seen, + 'prePageSeen' => $pre_page_seen, + ) + ); + + return self::row( + $ctx, + 'discovery.sitemaps.taxonomies-provider-fixtures', + array() === $failures, + array( + 'limit' => $limit, + 'failures' => array_slice( $failures, 0, 6 ), + ) + ); + } + + private static function check_sitemap_users_provider( \ComponentFuzz\FuzzContext $ctx ): array { + $missing = self::sitemap_provider_fixture_missing_requirements(); + if ( array() !== $missing ) { + return self::skip( + $ctx, + 'discovery.sitemaps.users-provider-fixtures', + 'Built-in sitemap provider fixture APIs are unavailable.', + array( 'missing' => $missing ) + ); + } + + $failures = array(); + $case = self::prepare_sitemap_provider_runtime( $ctx->fork( 'users-runtime' ) ); + $provider = new \WP_Sitemaps_Users(); + $limit = 2 + $ctx->int( 0, 1 ); + $private_type = $case['postTypePrivate']; + $public_type = $case['postTypePublic']; + $included_users = array(); + + \register_post_type( + $private_type, + array( + 'public' => false, + 'label' => 'Discovery Private Author Type', + 'has_archive' => false, + 'rewrite' => false, + 'supports' => array( 'title', 'editor', 'author' ), + ) + ); + + \register_post_type( + $public_type, + array( + 'public' => true, + 'label' => 'Discovery Public Author Type', + 'has_archive' => true, + 'rewrite' => false, + 'supports' => array( 'title', 'editor', 'author' ), + ) + ); + + for ( $i = 0; $i < $limit + 1; ++$i ) { + $user_id = self::insert_sitemap_user( $case, 'public-' . $i ); + $included_users[] = $user_id; + self::insert_sitemap_post( + array( + 'post_type' => 0 === $i % 2 ? 'post' : $public_type, + 'post_status' => 'publish', + 'post_author' => $user_id, + 'post_title' => 'Discovery public author post ' . $i, + 'post_name' => 'discovery-public-author-' . $case['token'] . '-' . $i, + 'post_modified_gmt' => sprintf( '2026-06-%02d 12:00:00', 1 + $i ), + ) + ); + } + + $page_only_user = self::insert_sitemap_user( $case, 'page-only' ); + self::insert_sitemap_post( + array( + 'post_type' => 'page', + 'post_status' => 'publish', + 'post_author' => $page_only_user, + 'post_title' => 'Discovery author page only', + 'post_name' => 'discovery-author-page-only-' . $case['token'], + 'post_modified_gmt' => '2026-06-15 12:00:00', + ) + ); + + $draft_only_user = self::insert_sitemap_user( $case, 'draft-only' ); + self::insert_sitemap_post( + array( + 'post_type' => 'post', + 'post_status' => 'draft', + 'post_author' => $draft_only_user, + 'post_title' => 'Discovery author draft only', + 'post_name' => 'discovery-author-draft-only-' . $case['token'], + 'post_modified_gmt' => '2026-06-16 12:00:00', + ) + ); + + $private_only_user = self::insert_sitemap_user( $case, 'private-only' ); + self::insert_sitemap_post( + array( + 'post_type' => $private_type, + 'post_status' => 'publish', + 'post_author' => $private_only_user, + 'post_title' => 'Discovery author private type only', + 'post_name' => 'discovery-author-private-only-' . $case['token'], + 'post_modified_gmt' => '2026-06-17 12:00:00', + ) + ); + + $empty_user = self::insert_sitemap_user( $case, 'empty' ); + + $query_seen = array(); + $entry_seen = array(); + $max_filter = static function ( int $max_urls, string $object_type ) use ( $limit ): int { + return 'user' === $object_type ? $limit : $max_urls; + }; + $query_filter = static function ( array $args ) use ( &$query_seen ): array { + $query_seen[] = array( + 'number' => $args['number'] ?? null, + 'hasPublishedPosts' => array_values( (array) ( $args['has_published_posts'] ?? array() ) ), + ); + + return $args; + }; + $entry_filter = static function ( array $entry, \WP_User $user ) use ( &$entry_seen ): array { + $entry_seen[] = array( + 'id' => (int) $user->ID, + 'loc' => $entry['loc'] ?? null, + ); + + $entry['component-fuzz-user'] = (string) $user->ID; + return $entry; + }; + + \add_filter( 'wp_sitemaps_max_urls', $max_filter, 10, 2 ); + \add_filter( 'wp_sitemaps_users_query_args', $query_filter, 10, 1 ); + \add_filter( 'wp_sitemaps_users_entry', $entry_filter, 10, 2 ); + try { + $max_pages = $provider->get_max_num_pages(); + $page_entries = array(); + for ( $page = 1; $page <= $max_pages; ++$page ) { + $page_entries[ $page ] = $provider->get_url_list( $page ); + } + } finally { + \remove_filter( 'wp_sitemaps_users_entry', $entry_filter, 10 ); + \remove_filter( 'wp_sitemaps_users_query_args', $query_filter, 10 ); + \remove_filter( 'wp_sitemaps_max_urls', $max_filter, 10 ); + } + + $all_entries = array_merge( ...array_values( $page_entries ) ); + $excluded_ids = array( $page_only_user, $draft_only_user, $private_only_user, $empty_user ); + self::collect_failure( + $failures, + 2 === $max_pages + && $limit === count( $page_entries[1] ?? array() ) + && 1 === count( $page_entries[2] ?? array() ) + && count( $included_users ) === count( $all_entries ) + && self::sitemap_user_entries_match_ids( $all_entries, $included_users ) + && array_column( $entry_seen, 'id' ) === $included_users + && ! array_intersect( array_column( $entry_seen, 'id' ), $excluded_ids ) + && self::sitemap_user_query_args_include_public_post_types( $query_seen, $limit, $public_type, $private_type ) + && false === \has_filter( 'wp_sitemaps_users_entry', $entry_filter ) + && false === \has_filter( 'wp_sitemaps_users_query_args', $query_filter ) + && false === \has_filter( 'wp_sitemaps_max_urls', $max_filter ), + 'WP_Sitemaps_Users lists only authors with published public posts and computes max pages', + array( + 'limit' => $limit, + 'maxPages' => $max_pages, + 'pageCounts' => array_map( 'count', $page_entries ), + 'entrySeen' => $entry_seen, + 'querySeen' => $query_seen, + 'excluded' => $excluded_ids, + 'privateType' => $private_type, + ) + ); + + $pre_seen = array(); + $pre_page_seen = array(); + $pre_sentinel = array( array( 'loc' => 'https://example.test/pre-users-' . $case['token'] ) ); + $pre_list_filter = static function ( $url_list, int $page_num ) use ( &$pre_seen, $pre_sentinel ) { + $pre_seen[] = array( + 'page' => $page_num, + 'null' => null === $url_list, + ); + + return 2 === $page_num ? $pre_sentinel : $url_list; + }; + $pre_page_filter = static function ( $max_num_pages ) use ( &$pre_page_seen ): int { + $pre_page_seen[] = array( 'null' => null === $max_num_pages ); + return 23; + }; + + \add_filter( 'wp_sitemaps_users_pre_url_list', $pre_list_filter, 10, 2 ); + \add_filter( 'wp_sitemaps_users_pre_max_num_pages', $pre_page_filter, 10, 1 ); + try { + $pre_list = $provider->get_url_list( 2 ); + $pre_pages = $provider->get_max_num_pages(); + } finally { + \remove_filter( 'wp_sitemaps_users_pre_max_num_pages', $pre_page_filter, 10 ); + \remove_filter( 'wp_sitemaps_users_pre_url_list', $pre_list_filter, 10 ); + } + + self::collect_failure( + $failures, + $pre_sentinel === $pre_list + && 23 === $pre_pages + && array( array( 'page' => 2, 'null' => true ) ) === $pre_seen + && array( array( 'null' => true ) ) === $pre_page_seen + && false === \has_filter( 'wp_sitemaps_users_pre_url_list', $pre_list_filter ) + && false === \has_filter( 'wp_sitemaps_users_pre_max_num_pages', $pre_page_filter ), + 'WP_Sitemaps_Users pre-list and pre-page filters short-circuit provider calls', + array( + 'preList' => $pre_list, + 'prePages' => $pre_pages, + 'preSeen' => $pre_seen, + 'prePageSeen' => $pre_page_seen, + ) + ); + + return self::row( + $ctx, + 'discovery.sitemaps.users-provider-fixtures', + array() === $failures, + array( + 'limit' => $limit, + 'failures' => array_slice( $failures, 0, 6 ), + ) + ); + } + + private static function sitemap_provider_fixture_missing_requirements(): array { + $missing = array(); + + self::load_builtin_sitemap_provider_classes(); + + foreach ( array( 'WP_Post', 'WP_Sitemaps_Posts', 'WP_Sitemaps_Taxonomies', 'WP_Sitemaps_Users', 'WP_Term', 'WP_User' ) as $class ) { + if ( ! class_exists( $class ) ) { + $missing[] = "class {$class}"; + } + } + + foreach ( + array( + 'create_initial_post_types', + 'create_initial_taxonomies', + 'get_author_posts_url', + 'get_permalink', + 'get_term_link', + 'home_url', + 'is_wp_error', + 'register_post_type', + 'register_taxonomy', + 'wp_cache_flush', + 'wp_insert_post', + 'wp_insert_term', + 'wp_insert_user', + 'wp_date', + 'wp_timezone', + ) as $function + ) { + if ( ! function_exists( $function ) ) { + $missing[] = "function {$function}"; + } + } + + if ( ! isset( $GLOBALS['wpdb'] ) || ! method_exists( $GLOBALS['wpdb'], 'component_fuzz_reset_content' ) || ! method_exists( $GLOBALS['wpdb'], 'component_fuzz_reset_options' ) ) { + $missing[] = 'component fuzz wpdb stub'; + } + + return $missing; + } + + private static function load_builtin_sitemap_provider_classes(): void { + if ( class_exists( 'WP_Sitemaps_Posts' ) && class_exists( 'WP_Sitemaps_Taxonomies' ) && class_exists( 'WP_Sitemaps_Users' ) ) { + return; + } + + if ( ! defined( 'ABSPATH' ) || ! defined( 'WPINC' ) ) { + return; + } + + foreach ( + array( + 'WP_Sitemaps_Posts' => ABSPATH . WPINC . '/sitemaps/providers/class-wp-sitemaps-posts.php', + 'WP_Sitemaps_Taxonomies' => ABSPATH . WPINC . '/sitemaps/providers/class-wp-sitemaps-taxonomies.php', + 'WP_Sitemaps_Users' => ABSPATH . WPINC . '/sitemaps/providers/class-wp-sitemaps-users.php', + ) as $class => $path + ) { + if ( ! class_exists( $class ) && is_file( $path ) ) { + require_once $path; + } + } + } + + private static function load_sitemap_stylesheet_class(): void { + if ( class_exists( 'WP_Sitemaps_Stylesheet' ) ) { + return; + } + + if ( ! defined( 'ABSPATH' ) || ! defined( 'WPINC' ) ) { + return; + } + + $path = ABSPATH . WPINC . '/sitemaps/class-wp-sitemaps-stylesheet.php'; + if ( is_file( $path ) ) { + require_once $path; + } + } + + private static function prepare_sitemap_provider_runtime( \ComponentFuzz\FuzzContext $ctx ): array { + global $wpdb, $wp_rewrite; + + $token = substr( hash( 'crc32b', 'discovery-sitemaps:' . $ctx->seed() ), 0, 7 ); + $wpdb->component_fuzz_reset_content(); + $wpdb->component_fuzz_reset_options( + array( + 'admin_email' => 'admin@example.test', + 'blog_charset' => 'UTF-8', + 'blog_public' => 1, + 'blogname' => 'Component Fuzz', + 'default_category' => 0, + 'default_comment_status' => 'closed', + 'default_ping_status' => 'closed', + 'gmt_offset' => 2, + 'home' => 'https://example.test', + 'permalink_structure' => '', + 'show_on_front' => 'posts', + 'siteurl' => 'https://example.test', + 'timezone_string' => 'Europe/Madrid', + ) + ); + + \wp_cache_flush(); + + $GLOBALS['wp_post_types'] = array(); + $GLOBALS['wp_taxonomies'] = array(); + \create_initial_post_types(); + \create_initial_taxonomies(); + + if ( class_exists( 'WP_Rewrite' ) ) { + $wp_rewrite = new \WP_Rewrite(); + } + + return array( + 'token' => $token, + 'postTypePrivate' => 'cfzdp' . substr( $token, 0, 7 ), + 'postTypePublic' => 'cfzdu' . substr( $token, 0, 7 ), + 'taxonomyPrivate' => 'cfzdtax' . substr( $token, 0, 7 ), + 'taxonomyPublic' => 'cfzdtp' . substr( $token, 0, 7 ), + ); + } + + private static function insert_sitemap_post( array $fields ): int { + $defaults = array( + 'post_author' => 0, + 'post_content' => 'Discovery sitemap fixture content', + 'post_date' => '2026-06-01 00:00:00', + 'post_date_gmt' => '2026-06-01 00:00:00', + 'post_excerpt' => '', + 'post_modified' => $fields['post_modified_gmt'] ?? '2026-06-01 00:00:00', + 'post_modified_gmt' => '2026-06-01 00:00:00', + 'post_name' => '', + 'post_status' => 'publish', + 'post_title' => 'Discovery sitemap fixture', + 'post_type' => 'post', + ); + + $post_id = \wp_insert_post( array_merge( $defaults, $fields ), true, false ); + if ( \is_wp_error( $post_id ) ) { + throw new \RuntimeException( 'Could not insert sitemap post fixture: ' . $post_id->get_error_code() ); + } + + return (int) $post_id; + } + + private static function insert_sitemap_user( array $case, string $suffix ): int { + $safe_suffix = preg_replace( '/[^a-z0-9_]+/', '-', strtolower( $suffix ) ); + $login = 'cfz_' . $case['token'] . '_' . $safe_suffix; + $user_id = \wp_insert_user( + array( + 'user_login' => $login, + 'user_pass' => 'component-fuzz', + 'user_email' => $login . '@example.test', + 'user_nicename' => str_replace( '_', '-', $login ), + 'display_name' => 'Discovery User ' . $suffix, + ) + ); + + if ( \is_wp_error( $user_id ) ) { + throw new \RuntimeException( 'Could not insert sitemap user fixture: ' . $user_id->get_error_code() ); + } + + return (int) $user_id; + } + + private static function insert_sitemap_term( string $taxonomy, string $name, string $slug, int $count ): array { + global $wpdb; + + $result = \wp_insert_term( + $name, + $taxonomy, + array( + 'slug' => $slug, + ) + ); + + if ( \is_wp_error( $result ) ) { + throw new \RuntimeException( 'Could not insert sitemap term fixture: ' . $result->get_error_code() ); + } + + $wpdb->update( + $wpdb->term_taxonomy, + array( 'count' => $count ), + array( 'term_taxonomy_id' => (int) $result['term_taxonomy_id'] ) + ); + + return array( + 'term_id' => (int) $result['term_id'], + 'term_taxonomy_id' => (int) $result['term_taxonomy_id'], + 'taxonomy' => $taxonomy, + 'slug' => $slug, + 'count' => $count, + ); + } + + private static function sitemap_post_entries_match_fixtures( array $entries, array $posts ): bool { + if ( count( $entries ) !== count( $posts ) ) { + return false; + } + + foreach ( $posts as $index => $post ) { + $entry = $entries[ $index ] ?? array(); + $expected = self::expected_sitemap_lastmod_from_gmt( (string) $post['modified_gmt'] ); + if ( ( $entry['loc'] ?? null ) !== \get_permalink( $post['id'] ) ) { + return false; + } + if ( ( $entry['lastmod'] ?? null ) !== $expected || ! self::is_w3c_datetime( (string) ( $entry['lastmod'] ?? '' ) ) ) { + return false; + } + if ( (string) $post['id'] !== ( $entry['component-fuzz-id'] ?? null ) ) { + return false; + } + } + + return true; + } + + private static function expected_sitemap_lastmod_from_gmt( string $modified_gmt ): string { + $datetime = \DateTimeImmutable::createFromFormat( '!Y-m-d H:i:s', $modified_gmt, new \DateTimeZone( 'UTC' ) ); + if ( ! $datetime ) { + return ''; + } + + $timezone = function_exists( 'wp_timezone' ) ? \wp_timezone() : new \DateTimeZone( 'UTC' ); + return $datetime->setTimezone( $timezone )->format( DATE_W3C ); + } + + private static function sitemap_entries_contain_locs_for_ids( array $entries, array $post_ids ): bool { + $locs = array_column( $entries, 'loc' ); + foreach ( $post_ids as $post_id ) { + if ( in_array( \get_permalink( $post_id ), $locs, true ) ) { + return true; + } + } + + return false; + } + + private static function sitemap_post_query_args_local_to_type( array $query_seen, string $post_type, int $limit ): bool { + if ( array() === $query_seen ) { + return false; + } + + foreach ( $query_seen as $seen ) { + if ( $post_type !== ( $seen['postType'] ?? null ) ) { + return false; + } + if ( $limit !== (int) ( $seen['postsPerPage'] ?? 0 ) ) { + return false; + } + if ( array( 'publish' ) !== array_values( (array) ( $seen['status'] ?? array() ) ) ) { + return false; + } + if ( true !== ( $seen['ignoreSticky'] ?? null ) ) { + return false; + } + } + + return true; + } + + private static function sitemap_term_entries_match_fixtures( array $entries, array $terms, string $taxonomy ): bool { + if ( count( $entries ) !== count( $terms ) ) { + return false; + } + + foreach ( $terms as $index => $term ) { + $entry = $entries[ $index ] ?? array(); + $link = \get_term_link( $term['term_id'], $taxonomy ); + if ( \is_wp_error( $link ) || ( $entry['loc'] ?? null ) !== $link ) { + return false; + } + if ( (string) $term['term_id'] !== ( $entry['component-fuzz-term'] ?? null ) ) { + return false; + } + } + + return true; + } + + private static function sitemap_taxonomy_query_args_local_to_taxonomy( array $query_seen, string $taxonomy, int $limit ): bool { + if ( array() === $query_seen ) { + return false; + } + + foreach ( $query_seen as $seen ) { + if ( $taxonomy !== ( $seen['taxonomy'] ?? null ) ) { + return false; + } + if ( $limit !== (int) ( $seen['number'] ?? 0 ) ) { + return false; + } + if ( true !== ( $seen['hideEmpty'] ?? null ) ) { + return false; + } + } + + return true; + } + + private static function sitemap_user_entries_match_ids( array $entries, array $user_ids ): bool { + if ( count( $entries ) !== count( $user_ids ) ) { + return false; + } + + foreach ( $user_ids as $index => $user_id ) { + $entry = $entries[ $index ] ?? array(); + if ( ( $entry['loc'] ?? null ) !== \get_author_posts_url( $user_id ) ) { + return false; + } + if ( (string) $user_id !== ( $entry['component-fuzz-user'] ?? null ) ) { + return false; + } + } + + return true; + } + + private static function sitemap_user_query_args_include_public_post_types( array $query_seen, int $limit, string $public_type, string $private_type ): bool { + if ( array() === $query_seen ) { + return false; + } + + $expected_post_types = array( 'post', $public_type ); + sort( $expected_post_types ); + + foreach ( $query_seen as $seen ) { + $post_types = array_values( (array) ( $seen['hasPublishedPosts'] ?? array() ) ); + sort( $post_types ); + if ( $limit !== (int) ( $seen['number'] ?? 0 ) ) { + return false; + } + if ( $expected_post_types !== $post_types ) { + return false; + } + if ( in_array( 'page', $post_types, true ) || in_array( 'attachment', $post_types, true ) || in_array( $private_type, $post_types, true ) ) { + return false; + } + } + + return true; + } + + private static function is_w3c_datetime( string $value ): bool { + return 1 === preg_match( '/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:Z|[+-]\d{2}:\d{2})$/', $value ); + } + + private static function robots_cases( \ComponentFuzz\FuzzContext $ctx ): array { + $cases = array( + array( 'directives' => array() ), + array( 'directives' => array( 'noindex' => true, 'nofollow' => false, 'max-image-preview' => 'large' ) ), + array( 'directives' => array( 'index' => true, 'snippet' => 'max-snippet:' . $ctx->int( 1, 99 ) ) ), + array( 'directives' => array( 'unsafe 'a"b true ) ), + ); + + for ( $i = count( $cases ); $i < 8; ++$i ) { + $case = $ctx->fork( 'case-' . $i ); + $directive = 'cfz-' . substr( hash( 'crc32b', (string) $case->seed() ), 0, 8 ); + $value = $case->choice( array( true, false, 'value-' . $case->int( 0, 999 ), "unsafe\"<>&" ) ); + $cases[] = array( + 'directives' => array( + $directive => $value, + 'max-snippet' => (string) $case->int( 1, 500 ), + 'max-image-preview' => 'large', + ), + ); + } + + return $cases; + } + + private static function field_token( \ComponentFuzz\FuzzContext $ctx ): string { + return substr( hash( 'sha1', self::NAME . ':field:' . $ctx->seed() ), 0, 10 ); + } + + private static function sitemap_url_cases( \ComponentFuzz\FuzzContext $ctx ): array { + $urls = array(); + for ( $i = 0; $i < self::URL_CASES; ++$i ) { + $case = $ctx->fork( 'url-' . $i ); + $urls[] = array( + 'loc' => 'https://example.test/content/' . $i . '?q=' . rawurlencode( $case->text( 0, 24 ) ), + 'lastmod' => sprintf( '2026-06-%02dT%02d:00:00+00:00', 1 + $case->int( 0, 20 ), $case->int( 0, 23 ) ), + 'changefreq' => $case->choice( array( 'daily', 'weekly', 'monthly' ) ), + 'priority' => sprintf( '0.%d', $case->int( 1, 9 ) ), + ); + } + + $urls[] = array( + 'loc' => 'https://example.test/unsafe?x="e="', + 'lastmod' => '2026-06-21T00:00:00+00:00', + ); + + return $urls; + } + + private static function sitemap_provider( string $name, string $object_type, array $subtypes, array $urls ): \WP_Sitemaps_Provider { + return new class( $name, $object_type, $subtypes, $urls ) extends \WP_Sitemaps_Provider { + /** @var array> */ + private array $subtypes; + + /** @var array> */ + private array $urls; + + public function __construct( string $name, string $object_type, array $subtypes, array $urls ) { + $this->name = $name; + $this->object_type = $object_type; + $this->subtypes = $subtypes; + $this->urls = $urls; + } + + public function get_url_list( $page_num, $object_subtype = '' ) { + unset( $object_subtype ); + $page_num = max( 1, (int) $page_num ); + return array_slice( $this->urls, ( $page_num - 1 ) * 4, 4 ); + } + + public function get_max_num_pages( $object_subtype = '' ) { + return 'beta' === $object_subtype ? 3 : 1; + } + + public function get_object_subtypes() { + return $this->subtypes; + } + }; + } + + private static function all_entries_are_sitemap_locs( array $entries, string $name ): bool { + foreach ( $entries as $entry ) { + if ( ! isset( $entry['loc'] ) || ! is_string( $entry['loc'] ) || ! str_contains( $entry['loc'], 'sitemap=' . rawurlencode( $name ) ) ) { + return false; + } + } + + return true; + } + + private static function reset_runtime(): void { + global $wp_rewrite; + + self::$provider_data = array(); + if ( class_exists( 'WP_Rewrite' ) ) { + $wp_rewrite = new \WP_Rewrite(); + } else { + $wp_rewrite = new class() { + public function using_permalinks(): bool { + return false; + } + }; + } + + $GLOBALS['wp_sitemaps'] = new \WP_Sitemaps(); + $GLOBALS['wp_sitemaps']->registry = new \WP_Sitemaps_Registry(); + $GLOBALS['wp_sitemaps']->renderer = new \WP_Sitemaps_Renderer(); + $GLOBALS['wp_sitemaps']->index = new \WP_Sitemaps_Index( $GLOBALS['wp_sitemaps']->registry ); + } + + private static function install_url_filters(): void { + \add_filter( 'pre_option_home', array( __CLASS__, 'filter_home_option' ), 10, 3 ); + \add_filter( 'pre_option_siteurl', array( __CLASS__, 'filter_home_option' ), 10, 3 ); + \add_filter( 'pre_option_blog_public', array( __CLASS__, 'filter_blog_public' ), 10, 3 ); + \add_filter( 'wp_sitemaps_stylesheet_url', '__return_false', 0 ); + \add_filter( 'wp_sitemaps_stylesheet_index_url', '__return_false', 0 ); + } + + private static function remove_url_filters(): void { + \remove_filter( 'pre_option_home', array( __CLASS__, 'filter_home_option' ), 10 ); + \remove_filter( 'pre_option_siteurl', array( __CLASS__, 'filter_home_option' ), 10 ); + \remove_filter( 'pre_option_blog_public', array( __CLASS__, 'filter_blog_public' ), 10 ); + \remove_filter( 'wp_sitemaps_stylesheet_url', '__return_false', 0 ); + \remove_filter( 'wp_sitemaps_stylesheet_index_url', '__return_false', 0 ); + } + + private static function collect_failure( array &$failures, bool $condition, string $label, array $details ): void { + if ( $condition ) { + return; + } + + $failures[] = array( + 'label' => $label, + 'details' => self::describe_value( $details ), + ); + } + + private static function row( \ComponentFuzz\FuzzContext $ctx, string $invariant, bool $ok, array $data = array(), ?string $status = null ): array { + return array( + 'ok' => $ok, + 'status' => $status ?? ( $ok ? 'passed' : 'failed' ), + 'surface' => self::NAME, + 'invariant' => $invariant, + 'seed' => $ctx->seed(), + 'iteration' => $ctx->iteration(), + 'data' => self::describe_value( $data ), + ); + } + + private static function skip( \ComponentFuzz\FuzzContext $ctx, string $invariant, string $reason, array $data = array() ): array { + $data['reason'] = $reason; + return self::row( $ctx, $invariant, true, $data, 'skipped' ); + } + + private static function describe_value( $value, int $depth = 0 ) { + if ( is_string( $value ) ) { + return self::describe_string( $value ); + } + + if ( is_array( $value ) ) { + if ( $depth >= 4 ) { + return array( + 'type' => 'array', + 'count' => count( $value ), + ); + } + + $out = array(); + $i = 0; + foreach ( $value as $key => $item ) { + if ( $i >= 16 ) { + $out['...'] = count( $value ) - $i; + break; + } + $out[ is_int( $key ) ? $key : self::escape_bytes( (string) $key ) ] = self::describe_value( $item, $depth + 1 ); + ++$i; + } + return $out; + } + + if ( is_object( $value ) ) { + if ( $value instanceof \Throwable ) { + return self::describe_throwable( $value ); + } + + return array( + 'type' => 'object', + 'class' => get_class( $value ), + ); + } + + return $value; + } + + private static function describe_string( string $value ): array { + return array( + 'type' => 'string', + 'bytes' => strlen( $value ), + 'sha1' => sha1( $value ), + 'preview' => self::escape_bytes( $value ), + ); + } + + private static function describe_throwable( \Throwable $e ): array { + return array( + 'class' => get_class( $e ), + 'message' => self::escape_bytes( $e->getMessage() ), + 'file' => $e->getFile(), + 'line' => $e->getLine(), + ); + } + + private static function escape_bytes( string $value, int $limit = self::PREVIEW_BYTES ): string { + $out = ''; + $length = strlen( $value ); + $shown = min( $length, $limit ); + + for ( $i = 0; $i < $shown; ++$i ) { + $byte = ord( $value[ $i ] ); + if ( 0x5C === $byte ) { + $out .= '\\\\'; + } elseif ( $byte >= 0x20 && $byte <= 0x7E ) { + $out .= chr( $byte ); + } elseif ( 0x0A === $byte ) { + $out .= '\\n'; + } elseif ( 0x0D === $byte ) { + $out .= '\\r'; + } elseif ( 0x09 === $byte ) { + $out .= '\\t'; + } else { + $out .= sprintf( '\\x%02X', $byte ); + } + } + + if ( $length > $shown ) { + $out .= '...'; + } + + return $out; + } + + private static function snapshot_globals(): array { + $snapshot = array( + 'providerData' => self::$provider_data, + 'globals' => array(), + ); + + foreach ( array( 'wpdb', 'wp_rewrite', 'wp_sitemaps', 'wp_filter', 'wp_filters', 'wp_actions', 'wp_current_filter', 'wp_object_cache', 'wp_post_types', 'wp_taxonomies', 'wp_locale' ) as $name ) { + $snapshot['globals'][ $name ] = array( + 'exists' => array_key_exists( $name, $GLOBALS ), + 'value' => array_key_exists( $name, $GLOBALS ) ? self::clone_value( $GLOBALS[ $name ] ) : null, + ); + } + + return $snapshot; + } + + private static function restore_globals( array $snapshot ): void { + self::remove_url_filters(); + self::$provider_data = $snapshot['providerData']; + + foreach ( $snapshot['globals'] as $name => $entry ) { + if ( $entry['exists'] ) { + $GLOBALS[ $name ] = $entry['value']; + } else { + unset( $GLOBALS[ $name ] ); + } + } + } + + private static function clone_value( $value ) { + if ( is_object( $value ) ) { + return clone $value; + } + + if ( is_array( $value ) ) { + $copy = array(); + foreach ( $value as $key => $item ) { + $copy[ $key ] = self::clone_value( $item ); + } + return $copy; + } + + return $value; + } +} diff --git a/tools/component-fuzz/surfaces/EditorHelpersSurface.php b/tools/component-fuzz/surfaces/EditorHelpersSurface.php new file mode 100644 index 0000000000000..1ea6b1ffef328 --- /dev/null +++ b/tools/component-fuzz/surfaces/EditorHelpersSurface.php @@ -0,0 +1,1803 @@ +skip( + 'editor-helpers.bootstrap-apis-available', + 'Required WordPress editor helper APIs are unavailable.', + array( 'missing' => implode( ', ', $missing ) ) + ), + ); + } + + $snapshot = self::snapshot_state(); + $ob_level = ob_get_level(); + $rows = array(); + + try { + self::prepare_editor_globals(); + $case = self::case_for_context( $ctx ); + + $rows[] = self::check_parse_settings( $ctx->fork( 'parse-settings' ), $case ); + $rows[] = self::check_default_editor_selection( $ctx->fork( 'default-editor' ), $case ); + $rows[] = self::check_full_editor_settings( $ctx->fork( 'full-settings' ), $case ); + $rows[] = self::check_teeny_editor_settings( $ctx->fork( 'teeny-settings' ), $case ); + $rows[] = self::check_enqueue_scripts( $ctx->fork( 'enqueue-scripts' ), $case ); + $rows[] = self::check_editor_markup( $ctx->fork( 'editor-markup' ), $case ); + $rows[] = self::check_mce_translation( $ctx->fork( 'mce-translation' ), $case ); + $rows[] = self::check_tinymce_inline_scripts( $ctx->fork( 'tinymce-inline-scripts' ), $case ); + $rows[] = self::check_link_query_and_dialog( $ctx->fork( 'link-query-dialog' ), $case ); + $rows[] = self::check_media_view_styles( $ctx->fork( 'media-view-styles' ) ); + } catch ( \Throwable $e ) { + $rows[] = $ctx->fail( + 'editor-helpers.surface-no-throw', + array( 'throwable' => self::describe_throwable( $e ) ) + ); + } finally { + while ( ob_get_level() > $ob_level ) { + ob_end_clean(); + } + self::restore_state( $snapshot ); + } + + $rows[] = $ctx->result( + 'editor-helpers.global-state-restored', + self::state_restored( $snapshot ), + array( + 'trackedGlobals' => array_keys( $snapshot['globals'] ), + 'trackedStatics' => array_keys( $snapshot['editorStatics'] ), + ) + ); + + return $rows; + } + + private static function missing_requirements(): array { + self::load_editor_class(); + + $missing = array(); + foreach ( array( '_WP_Editors', 'WP_Post', 'WP_Query', 'WP_Rewrite', 'WP_Scripts', 'WP_Styles' ) as $class ) { + if ( ! class_exists( $class ) ) { + $missing[] = "class {$class}"; + } + } + + foreach ( + array( + 'add_action', + 'add_filter', + 'add_thickbox', + 'esc_attr', + 'get_permalink', + 'get_post_type_object', + 'get_post_types', + 'has_filter', + 'mysql2date', + 'remove_action', + 'remove_filter', + 'register_post_type', + 'user_can_richedit', + 'unregister_post_type', + 'wp_cache_delete', + 'wp_cache_set', + 'wp_default_editor', + 'wp_editor', + 'wp_enqueue_script', + 'wp_nonce_field', + 'wp_enqueue_style', + 'wp_parse_url', + 'wp_print_scripts', + 'wp_print_styles', + 'wp_script_is', + 'wp_scripts', + 'wp_style_is', + 'wp_styles', + 'wp_tinymce_inline_scripts', + 'wpview_media_sandbox_styles', + ) as $function + ) { + if ( ! function_exists( $function ) ) { + $missing[] = "function {$function}"; + } + } + + return $missing; + } + + private static function load_editor_class(): void { + if ( ! class_exists( '_WP_Editors', false ) ) { + require_once ABSPATH . WPINC . '/class-wp-editor.php'; + } + + global $tinymce_version; + if ( ! isset( $tinymce_version ) || '' === $tinymce_version ) { + require ABSPATH . WPINC . '/version.php'; + } + } + + private static function check_parse_settings( \ComponentFuzz\FuzzContext $ctx, array $case ): array { + $failures = array(); + $seen = array(); + $rich_seen = 0; + $rich_filter = static function () use ( &$rich_seen ): bool { + ++$rich_seen; + return true; + }; + $settings_filter = static function ( array $settings, string $editor_id ) use ( &$seen, $case ): array { + $seen[] = $editor_id; + $settings['textarea_name'] = $case['textareaName']; + $settings['editor_height'] = $case['parseHeight']; + $settings['tabindex'] = $case['tabindex']; + return $settings; + }; + + self::reset_editor_statics(); + unset( $GLOBALS['wp_rich_edit'] ); + + \add_filter( 'user_can_richedit', $rich_filter ); + \add_filter( 'wp_editor_settings', $settings_filter, 10, 2 ); + try { + $parsed = \_WP_Editors::parse_settings( + $case['editorId'], + array( + 'drag_drop_upload' => $case['dragDropUpload'], + 'quicktags' => $case['quicktagsInput'], + 'teeny' => false, + 'tinymce' => $case['tinymceInput'], + ) + ); + $statics = self::editor_statics(); + + self::reset_editor_statics(); + $bracket_id = $case['editorId'] . '[field]'; + \_WP_Editors::parse_settings( + $bracket_id, + array( + 'quicktags' => false, + 'tinymce' => true, + ) + ); + $bracket_statics = self::editor_statics(); + } finally { + \remove_filter( 'wp_editor_settings', $settings_filter, 10 ); + \remove_filter( 'user_can_richedit', $rich_filter ); + } + + $expected_height = empty( $case['parseHeight'] ) + ? $case['parseHeight'] + : max( 50, min( 5000, (int) $case['parseHeight'] ) ); + + self::record_if_false( + $failures, + $seen === array( $case['editorId'], $case['editorId'] . '[field]' ) + && $rich_seen >= 2, + 'wp_editor_settings and user_can_richedit filters are scoped to parse_settings calls', + array( + 'seenEditorIds' => $seen, + 'richSeen' => $rich_seen, + ) + ); + self::record_if_false( + $failures, + $case['textareaName'] === $parsed['textarea_name'] + && $expected_height === $parsed['editor_height'] + && $case['tabindex'] === $parsed['tabindex'], + 'parse_settings applies filtered settings and clamps non-empty editor heights', + array( + 'expectedHeight' => $expected_height, + 'parsed' => self::preview( $parsed ), + ) + ); + self::record_if_false( + $failures, + true === $statics['this_tinymce'] + && true === $statics['has_tinymce'] + && true === $statics['this_quicktags'] + && true === $statics['has_quicktags'], + 'parse_settings records current and aggregate TinyMCE/Quicktags state', + array( 'statics' => self::preview( $statics ) ) + ); + self::record_if_false( + $failures, + false === $bracket_statics['this_tinymce'] + && false === $bracket_statics['this_quicktags'], + 'parse_settings disables TinyMCE for editor IDs containing brackets', + array( + 'bracketId' => $bracket_id, + 'bracketStatics' => self::preview( $bracket_statics ), + ) + ); + + return self::row( + $ctx, + 'editor-helpers.parse-settings.normalization-and-state', + $failures, + array( + 'editorId' => $case['editorId'], + 'inputHeight' => $case['parseHeight'], + ) + ); + } + + private static function check_default_editor_selection( \ComponentFuzz\FuzzContext $ctx, array $case ): array { + $failures = array(); + $base_seen = array(); + $rich_filter = static function () use ( $case ): bool { + return $case['richEditing']; + }; + $default_filter = static function ( string $default ) use ( &$base_seen, $case ): string { + $base_seen[] = $default; + return $case['defaultOverride']; + }; + + unset( $GLOBALS['wp_rich_edit'] ); + \add_filter( 'user_can_richedit', $rich_filter ); + try { + $base = \wp_default_editor(); + \add_filter( 'wp_default_editor', $default_filter ); + $filtered = \wp_default_editor(); + } finally { + \remove_filter( 'wp_default_editor', $default_filter ); + \remove_filter( 'user_can_richedit', $rich_filter ); + unset( $GLOBALS['wp_rich_edit'] ); + } + + $expected_base = $case['richEditing'] ? 'tinymce' : 'html'; + if ( \wp_get_current_user() ) { + $user_setting = \get_user_setting( 'editor', 'tinymce' ); + $expected_base = in_array( $user_setting, array( 'tinymce', 'html', 'test' ), true ) ? $user_setting : $expected_base; + } + + self::record_if_false( + $failures, + $expected_base === $base, + 'wp_default_editor follows rich-edit capability and user-setting fallback order', + array( + 'expected' => $expected_base, + 'actual' => $base, + 'richEditing' => $case['richEditing'], + ) + ); + self::record_if_false( + $failures, + $case['defaultOverride'] === $filtered + && $base_seen === array( $expected_base ), + 'wp_default_editor filter receives the computed default and can override it', + array( + 'override' => $case['defaultOverride'], + 'filtered' => $filtered, + 'seen' => $base_seen, + ) + ); + + return self::row( + $ctx, + 'editor-helpers.default-editor.filters-and-rich-edit', + $failures, + array( 'override' => $case['defaultOverride'] ) + ); + } + + private static function check_full_editor_settings( \ComponentFuzz\FuzzContext $ctx, array $case ): array { + $failures = array(); + $seen = self::install_full_editor_filters( $case ); + + try { + $first = self::generate_full_editor_js_output( $case ); + $second = self::generate_full_editor_js_output( $case ); + } finally { + self::remove_full_editor_filters( $case, $seen ); + } + + $statics = $first['statics']; + $mce = $statics['mce_settings'][ $case['editorId'] ] ?? array(); + $qt = $statics['qt_settings'][ $case['editorId'] ] ?? array(); + + self::record_if_false( + $failures, + ! $first['call']['threw'] + && ! $second['call']['threw'] + && $first['output'] === $second['output'] + && str_contains( $first['output'], 'tinyMCEPreInit' ) + && str_contains( $first['output'], $case['button1'] ) + && str_contains( $first['output'], $case['quicktagsButton'] ), + 'editor_js output is deterministic for identical generated settings', + array( + 'firstCall' => self::describe_call( $first['call'] ), + 'secondCall' => self::describe_call( $second['call'] ), + 'firstHash' => sha1( $first['output'] ), + 'secondHash' => sha1( $second['output'] ), + 'preview' => self::preview( $first['output'] ), + ) + ); + self::record_if_false( + $failures, + isset( $qt['id'], $qt['buttons'] ) + && $case['editorId'] === $qt['id'] + && str_contains( $qt['buttons'], $case['quicktagsButton'] ) + && in_array( $case['quicktagsButton'], $statics['qt_buttons'], true ), + 'quicktags_settings filter updates per-editor settings and aggregate button state', + array( + 'qt' => self::preview( $qt ), + 'qtButtons' => self::preview( $statics['qt_buttons'] ), + ) + ); + self::record_if_false( + $failures, + isset( $mce['plugins'], $mce['external_plugins'] ) + && str_contains( $mce['plugins'], $case['plugin'] ) + && ! str_contains( $mce['plugins'], 'spellchecker' ) + && self::external_plugin_registered( $mce['external_plugins'], $case ), + 'tiny_mce_plugins removes spellchecker and preserves filtered external plugin config', + array( + 'plugins' => $mce['plugins'] ?? null, + 'externalPlugins' => $mce['external_plugins'] ?? null, + ) + ); + self::record_if_false( + $failures, + isset( $mce['toolbar1'], $mce['toolbar3'], $mce['toolbar4'] ) + && str_contains( $mce['toolbar1'], $case['button1'] ) + && $case['button4'] === $mce['toolbar3'] + && '' === $mce['toolbar4'], + 'mce button filters update toolbars and toolbar4 rolls up to toolbar3 when toolbar3 is empty', + array( + 'toolbar1' => $mce['toolbar1'] ?? null, + 'toolbar3' => $mce['toolbar3'] ?? null, + 'toolbar4' => $mce['toolbar4'] ?? null, + ) + ); + self::record_if_false( + $failures, + isset( $mce['content_css'], $mce['body_class'], $mce['component_fuzz_marker'] ) + && str_contains( $mce['content_css'], $case['cssUrl'] ) + && ! str_starts_with( $mce['content_css'], ',' ) + && ! str_ends_with( $mce['content_css'], ',' ) + && str_contains( $mce['body_class'], $case['bodyClass'] ) + && $case['marker'] === $mce['component_fuzz_marker'], + 'tiny_mce_before_init and mce_css filters normalize generated TinyMCE settings', + array( + 'contentCss' => $mce['content_css'] ?? null, + 'bodyClass' => $mce['body_class'] ?? null, + 'marker' => $mce['component_fuzz_marker'] ?? null, + ) + ); + self::record_if_false( + $failures, + self::seen_counts_match( + $seen['events'], + array( + 'quicktags' => 2, + 'mce_external_plugins' => 2, + 'tiny_mce_plugins' => 2, + 'mce_buttons' => 2, + 'mce_buttons_2' => 2, + 'mce_buttons_3' => 2, + 'mce_buttons_4' => 2, + 'tiny_mce_before_init' => 2, + 'mce_css' => 2, + ) + ), + 'full editor filters fire exactly once per generated settings pass', + array( 'seen' => self::preview( self::seen_events_to_array( $seen['events'] ) ) ) + ); + + return self::row( + $ctx, + 'editor-helpers.full-settings.filters-scripts-and-state', + $failures, + array( 'editorId' => $case['editorId'] ) + ); + } + + private static function check_teeny_editor_settings( \ComponentFuzz\FuzzContext $ctx, array $case ): array { + $failures = array(); + $seen = array( + 'teeny_mce_plugins' => array(), + 'teeny_mce_buttons' => array(), + 'teeny_mce_before_init' => array(), + 'tiny_mce_plugins' => array(), + ); + + $plugins_filter = static function ( array $plugins, string $editor_id ) use ( &$seen, $case ): array { + $seen['teeny_mce_plugins'][] = $editor_id; + $plugins[] = $case['teenyPlugin']; + return $plugins; + }; + $buttons_filter = static function ( array $buttons, string $editor_id ) use ( &$seen, $case ): array { + $seen['teeny_mce_buttons'][] = $editor_id; + $buttons[] = $case['teenyButton']; + return $buttons; + }; + $before_filter = static function ( array $init, string $editor_id ) use ( &$seen, $case ): array { + $seen['teeny_mce_before_init'][] = $editor_id; + $init['component_fuzz_teeny'] = $case['marker']; + return $init; + }; + $full_filter = static function ( array $plugins, string $editor_id ) use ( &$seen ): array { + $seen['tiny_mce_plugins'][] = $editor_id; + return $plugins; + }; + $rich_filter = static fn (): bool => true; + + self::reset_scripts_and_styles(); + self::reset_editor_statics(); + unset( $GLOBALS['wp_rich_edit'] ); + + \add_filter( 'user_can_richedit', $rich_filter ); + \add_filter( 'teeny_mce_plugins', $plugins_filter, 10, 2 ); + \add_filter( 'teeny_mce_buttons', $buttons_filter, 10, 2 ); + \add_filter( 'teeny_mce_before_init', $before_filter, 10, 2 ); + \add_filter( 'tiny_mce_plugins', $full_filter, 10, 2 ); + try { + $set = \_WP_Editors::parse_settings( + $case['teenyEditorId'], + array( + 'media_buttons' => false, + 'quicktags' => false, + 'teeny' => true, + 'tinymce' => true, + ) + ); + \_WP_Editors::editor_settings( $case['teenyEditorId'], $set ); + $statics = self::editor_statics(); + } finally { + \remove_filter( 'tiny_mce_plugins', $full_filter, 10 ); + \remove_filter( 'teeny_mce_before_init', $before_filter, 10 ); + \remove_filter( 'teeny_mce_buttons', $buttons_filter, 10 ); + \remove_filter( 'teeny_mce_plugins', $plugins_filter, 10 ); + \remove_filter( 'user_can_richedit', $rich_filter ); + unset( $GLOBALS['wp_rich_edit'] ); + } + + $mce = $statics['mce_settings'][ $case['teenyEditorId'] ] ?? array(); + + self::record_if_false( + $failures, + isset( $mce['plugins'], $mce['toolbar1'], $mce['toolbar2'], $mce['component_fuzz_teeny'] ) + && str_contains( $mce['plugins'], $case['teenyPlugin'] ) + && str_contains( $mce['toolbar1'], $case['teenyButton'] ) + && '' === $mce['toolbar2'] + && $case['marker'] === $mce['component_fuzz_teeny'], + 'teeny editor settings use teeny plugin/button/init filters and keep secondary toolbars empty', + array( 'mce' => self::preview( $mce ) ) + ); + self::record_if_false( + $failures, + $seen['teeny_mce_plugins'] === array( $case['teenyEditorId'] ) + && $seen['teeny_mce_buttons'] === array( $case['teenyEditorId'] ) + && $seen['teeny_mce_before_init'] === array( $case['teenyEditorId'] ) + && array() === $seen['tiny_mce_plugins'], + 'teeny settings do not invoke full TinyMCE plugin filter', + array( 'seen' => $seen ) + ); + + return self::row( + $ctx, + 'editor-helpers.teeny-settings.filter-branch', + $failures, + array( 'editorId' => $case['teenyEditorId'] ) + ); + } + + private static function check_enqueue_scripts( \ComponentFuzz\FuzzContext $ctx, array $case ): array { + $failures = array(); + $targeted_seen = array(); + $default_seen = array(); + $expects_wplink = 'wplink' === $case['enqueuePlugin'] || 'link' === $case['enqueueQuicktag']; + $targeted_hook = static function ( array $to_load ) use ( &$targeted_seen ): void { + $targeted_seen[] = $to_load; + }; + $default_hook = static function ( array $to_load ) use ( &$default_seen ): void { + $default_seen[] = $to_load; + }; + + self::reset_scripts_and_styles(); + self::register_editor_enqueue_handles(); + self::reset_editor_statics(); + self::set_editor_static_property( 'has_tinymce', true ); + self::set_editor_static_property( 'has_quicktags', true ); + self::set_editor_static_property( 'has_medialib', $case['enqueueMedia'] ); + self::set_editor_static_property( 'plugins', array( $case['enqueuePlugin'] ) ); + self::set_editor_static_property( 'qt_buttons', array( $case['enqueueQuicktag'] ) ); + + \add_action( 'wp_enqueue_editor', $targeted_hook ); + try { + \_WP_Editors::enqueue_scripts( false ); + $targeted_queue = self::editor_enqueue_state(); + } finally { + \remove_action( 'wp_enqueue_editor', $targeted_hook ); + } + + self::reset_scripts_and_styles(); + self::register_editor_enqueue_handles(); + self::reset_editor_statics(); + + \add_action( 'wp_enqueue_editor', $default_hook ); + try { + \_WP_Editors::enqueue_scripts( true ); + $default_queue = self::editor_enqueue_state(); + } finally { + \remove_action( 'wp_enqueue_editor', $default_hook ); + } + + self::record_if_false( + $failures, + array( + array( + 'tinymce' => true, + 'quicktags' => true, + ), + ) === $targeted_seen + && $targeted_queue['scripts']['editor'] + && $targeted_queue['scripts']['quicktags'] + && $targeted_queue['styles']['buttons'] + && $expects_wplink === $targeted_queue['scripts']['wplink'] + && $expects_wplink === $targeted_queue['scripts']['jquery-ui-autocomplete'] + && $case['enqueueMedia'] === $targeted_queue['scripts']['media-upload'] + && $case['enqueueMedia'] === $targeted_queue['scripts']['wp-embed'] + && $case['enqueueMedia'] === $targeted_queue['scripts']['thickbox'] + && $case['enqueueMedia'] === $targeted_queue['styles']['thickbox'], + 'enqueue_scripts loads targeted editor, link, and optional media handles from editor static state', + array( + 'seen' => $targeted_seen, + 'queue' => $targeted_queue, + 'case' => array( + 'media' => $case['enqueueMedia'], + 'plugin' => $case['enqueuePlugin'], + 'quicktag' => $case['enqueueQuicktag'], + 'wplink' => $expects_wplink, + ), + ) + ); + self::record_if_false( + $failures, + array( + array( + 'tinymce' => true, + 'quicktags' => true, + ), + ) === $default_seen + && $default_queue['scripts']['editor'] + && $default_queue['scripts']['quicktags'] + && $default_queue['styles']['buttons'] + && $default_queue['scripts']['wplink'] + && $default_queue['scripts']['jquery-ui-autocomplete'] + && $default_queue['scripts']['media-upload'] + && ! $default_queue['scripts']['wp-embed'] + && $default_queue['scripts']['thickbox'] + && ! $default_queue['styles']['thickbox'], + 'enqueue_scripts default mode loads editor/link/media-upload handles, including script dependencies, without forcing embeds or Thickbox styles', + array( + 'seen' => $default_seen, + 'queue' => $default_queue, + ) + ); + + return self::row( + $ctx, + 'editor-helpers.enqueue-scripts.handle-selection-and-action-payload', + $failures, + array( + 'media' => $case['enqueueMedia'], + 'targeted' => $targeted_queue, + 'default' => $default_queue, + ) + ); + } + + private static function check_editor_markup( \ComponentFuzz\FuzzContext $ctx, array $case ): array { + $failures = array(); + $the_editor_seen = array(); + $content_seen = array(); + $default_seen = array(); + $rich_filter = static fn (): bool => true; + $default_filter = static function ( string $default ) use ( &$default_seen, $case ): string { + $default_seen[] = $default; + return $case['markupDefaultEditor']; + }; + $the_editor_filter = static function ( string $html ) use ( &$the_editor_seen, $case ): string { + $the_editor_seen[] = str_contains( $html, '%s' ); + return str_replace( + 'class="wp-editor-container"', + 'class="wp-editor-container" data-component-fuzz="' . \esc_attr( $case['marker'] ) . '"', + $html + ); + }; + $content_filter = static function ( string $content, string $default_editor ) use ( &$content_seen ): string { + $content_seen[] = $default_editor; + return $content; + }; + $format_filter_was = \has_filter( 'the_editor_content', 'format_for_editor' ); + + self::reset_scripts_and_styles(); + self::reset_editor_statics(); + self::set_editor_static_property( 'editor_buttons_css', false ); + unset( $GLOBALS['wp_rich_edit'] ); + + \add_filter( 'user_can_richedit', $rich_filter ); + \add_filter( 'wp_default_editor', $default_filter ); + \add_filter( 'the_editor', $the_editor_filter ); + \add_filter( 'the_editor_content', $content_filter, 20, 2 ); + try { + $call = self::capture_output( + static function () use ( $case ): void { + \wp_editor( + $case['dangerousContent'], + $case['markupEditorId'], + array( + 'editor_class' => $case['editorClass'], + 'editor_height' => $case['markupHeight'], + 'media_buttons' => false, + 'quicktags' => true, + 'textarea_name' => $case['textareaName'], + 'textarea_rows' => 4, + 'tinymce' => array( + 'wp_skip_init' => true, + ), + ) + ); + } + ); + $statics = self::editor_statics(); + } finally { + \remove_filter( 'the_editor_content', $content_filter, 20 ); + \remove_filter( 'the_editor', $the_editor_filter ); + \remove_filter( 'wp_default_editor', $default_filter ); + \remove_filter( 'user_can_richedit', $rich_filter ); + unset( $GLOBALS['wp_rich_edit'] ); + } + + $output = $call['output']; + $expected_wrap_class = 'html' === $case['markupDefaultEditor'] ? 'html-active' : 'tmce-active'; + $escaped_name = \esc_attr( $case['textareaName'] ); + $escaped_id = \esc_attr( $case['markupEditorId'] ); + + self::record_if_false( + $failures, + ! $call['threw'] + && str_contains( $output, 'id="' . $escaped_id . '"' ) + && str_contains( $output, 'name="' . $escaped_name . '"' ) + && str_contains( $output, $expected_wrap_class ) + && str_contains( $output, 'data-component-fuzz="' . \esc_attr( $case['marker'] ) . '"' ), + 'wp_editor emits captured markup with escaped editor attributes and expected active tab', + array( + 'call' => self::describe_call( $call ), + 'expectedWrapClass' => $expected_wrap_class, + 'preview' => self::preview( $output ), + ) + ); + self::record_if_false( + $failures, + str_contains( $output, '</textarea' ) + && ! str_contains( $output, ' self::preview( $output ) ) + ); + self::record_if_false( + $failures, + $the_editor_seen === array( true ) + && $content_seen === array( $case['markupDefaultEditor'] ) + && $default_seen === array( 'tinymce' ), + 'the_editor, the_editor_content, and wp_default_editor filters are local and contextual', + array( + 'theEditorSeen' => $the_editor_seen, + 'contentSeen' => $content_seen, + 'defaultSeen' => $default_seen, + ) + ); + self::record_if_false( + $failures, + isset( $statics['mce_settings'][ $case['markupEditorId'] ], $statics['qt_settings'][ $case['markupEditorId'] ] ) + && \has_filter( 'the_editor_content', 'format_for_editor' ) === $format_filter_was, + 'wp_editor records per-editor settings and removes its temporary content filter', + array( + 'hasMce' => isset( $statics['mce_settings'][ $case['markupEditorId'] ] ), + 'hasQuicktags' => isset( $statics['qt_settings'][ $case['markupEditorId'] ] ), + 'formatFilterBefore' => $format_filter_was, + 'formatFilterAfter' => \has_filter( 'the_editor_content', 'format_for_editor' ), + ) + ); + + return self::row( + $ctx, + 'editor-helpers.editor-markup.output-buffer-escaping-and-filters', + $failures, + array( 'editorId' => $case['markupEditorId'] ) + ); + } + + private static function check_mce_translation( \ComponentFuzz\FuzzContext $ctx, array $case ): array { + $failures = array(); + $seen = array(); + $filter = static function ( array $translations, string $locale ) use ( &$seen, $case ): array { + $seen[] = $locale; + $translations['Component Fuzz Same'] = 'Component Fuzz Same'; + $translations['Component Fuzz Entity'] = 'Ampersand & Entity'; + $translations['Component Fuzz Marker'] = $case['translationMarker']; + return $translations; + }; + + self::reset_editor_statics(); + \add_filter( 'wp_mce_translation', $filter, 10, 2 ); + try { + $json_one = \_WP_Editors::wp_mce_translation( 'en', true ); + $json_two = \_WP_Editors::wp_mce_translation( 'en', true ); + $script = \_WP_Editors::wp_mce_translation( 'en', false ); + } finally { + \remove_filter( 'wp_mce_translation', $filter, 10 ); + } + + $decoded = json_decode( $json_one, true ); + + self::record_if_false( + $failures, + is_array( $decoded ) + && $json_one === $json_two + && ! isset( $decoded['Component Fuzz Same'] ) + && 'Ampersand & Entity' === ( $decoded['Component Fuzz Entity'] ?? null ) + && $case['translationMarker'] === ( $decoded['Component Fuzz Marker'] ?? null ), + 'wp_mce_translation JSON output is deterministic, filtered, and entity-normalized', + array( + 'seen' => $seen, + 'jsonPreview' => self::preview( $json_one ), + 'jsonError' => json_last_error_msg(), + ) + ); + self::record_if_false( + $failures, + str_contains( $script, "tinymce.addI18n( 'en', " ) + && str_contains( $script, '/langs/en.js' ) + && ! str_contains( $script, ' self::preview( $script ) ) + ); + + return self::row( + $ctx, + 'editor-helpers.mce-translation.json-and-script-snippet', + $failures, + array( 'marker' => $case['translationMarker'] ) + ); + } + + private static function check_tinymce_inline_scripts( \ComponentFuzz\FuzzContext $ctx, array $case ): array { + $failures = array(); + $events = array( + 'editor_settings' => array(), + 'tiny_mce_plugins' => array(), + 'disable_captions' => 0, + 'mce_buttons' => array(), + 'mce_buttons_2' => array(), + 'mce_buttons_3' => array(), + 'mce_buttons_4' => array(), + 'mce_external_plugins' => array(), + 'tiny_mce_before_init' => array(), + ); + $inline_plugin = 'cf_inline_plugin_' . self::safe_key( $case['marker'], 'plugin' ); + $inline_button = 'cf_inline_button_' . self::safe_key( $case['button1'], 'button' ); + $inline_button_two = 'cf_inline_button2_' . self::safe_key( $case['button2'], 'button2' ); + $inline_button_three = 'cf_inline_button3_' . self::safe_key( $case['button4'], 'button3' ); + $inline_button_four = 'cf_inline_button4_' . self::safe_key( $case['teenyButton'], 'button4' ); + $external_plugin = 'cf_inline_external_' . self::safe_key( $case['externalPlugin'], 'external' ); + $external_url = 'https://example.test/classic-block/' . rawurlencode( $case['marker'] ) . '/plugin.js'; + $plain_setting = 'plain-' . self::safe_key( $case['marker'], 'plain' ); + $json_setting = '{"marker":"' . self::safe_key( $case['marker'], 'json' ) . '"}'; + $array_setting = '["' . self::safe_key( $case['marker'], 'array' ) . '"]'; + $function_setting = 'function () { return "' . self::safe_key( $case['marker'], 'fn' ) . '"; }'; + + $settings_filter = static function ( array $settings, string $editor_id ) use ( &$events, $plain_setting, $json_setting, $array_setting, $function_setting ): array { + $events['editor_settings'][] = array( + 'editorId' => $editor_id, + 'input' => $settings, + ); + $settings['tinymce'] = array( + 'component_plain' => $plain_setting, + 'component_json' => $json_setting, + 'component_array' => $array_setting, + 'component_function' => $function_setting, + 'wp_autoresize_on' => true, + ); + return $settings; + }; + $plugins_filter = static function ( array $plugins, string $editor_id ) use ( &$events, $inline_plugin ): array { + $events['tiny_mce_plugins'][] = $editor_id; + $plugins[] = $inline_plugin; + $plugins[] = $inline_plugin; + return $plugins; + }; + $disable_filter = static function () use ( &$events ): bool { + ++$events['disable_captions']; + return true; + }; + $buttons_filter = static function ( array $buttons, string $editor_id ) use ( &$events, $inline_button ): array { + $events['mce_buttons'][] = $editor_id; + $buttons[] = $inline_button; + return $buttons; + }; + $buttons_two_filter = static function ( array $buttons, string $editor_id ) use ( &$events, $inline_button_two ): array { + $events['mce_buttons_2'][] = $editor_id; + $buttons[] = $inline_button_two; + return $buttons; + }; + $buttons_three_filter = static function ( array $buttons, string $editor_id ) use ( &$events, $inline_button_three ): array { + $events['mce_buttons_3'][] = $editor_id; + $buttons[] = $inline_button_three; + return $buttons; + }; + $buttons_four_filter = static function ( array $buttons, string $editor_id ) use ( &$events, $inline_button_four ): array { + $events['mce_buttons_4'][] = $editor_id; + $buttons[] = $inline_button_four; + return $buttons; + }; + $external_filter = static function ( array $plugins, string $editor_id ) use ( &$events, $external_plugin, $external_url ): array { + $events['mce_external_plugins'][] = $editor_id; + $plugins[ $external_plugin ] = $external_url; + return $plugins; + }; + $before_filter = static function ( array $settings, string $editor_id ) use ( &$events, $case ): array { + $events['tiny_mce_before_init'][] = array( + 'editorId' => $editor_id, + 'settings' => $settings, + ); + $settings['component_before_marker'] = $case['marker']; + return $settings; + }; + + self::reset_scripts_and_styles(); + \wp_scripts()->add( 'wp-block-library', false ); + + \add_filter( 'wp_editor_settings', $settings_filter, 10, 2 ); + \add_filter( 'tiny_mce_plugins', $plugins_filter, 10, 2 ); + \add_filter( 'disable_captions', $disable_filter ); + \add_filter( 'mce_buttons', $buttons_filter, 10, 2 ); + \add_filter( 'mce_buttons_2', $buttons_two_filter, 10, 2 ); + \add_filter( 'mce_buttons_3', $buttons_three_filter, 10, 2 ); + \add_filter( 'mce_buttons_4', $buttons_four_filter, 10, 2 ); + \add_filter( 'mce_external_plugins', $external_filter, 10, 2 ); + \add_filter( 'tiny_mce_before_init', $before_filter, 10, 2 ); + try { + \wp_tinymce_inline_scripts(); + $before_data = \wp_scripts()->get_data( 'wp-block-library', 'before' ); + } finally { + \remove_filter( 'tiny_mce_before_init', $before_filter, 10 ); + \remove_filter( 'mce_external_plugins', $external_filter, 10 ); + \remove_filter( 'mce_buttons_4', $buttons_four_filter, 10 ); + \remove_filter( 'mce_buttons_3', $buttons_three_filter, 10 ); + \remove_filter( 'mce_buttons_2', $buttons_two_filter, 10 ); + \remove_filter( 'mce_buttons', $buttons_filter, 10 ); + \remove_filter( 'disable_captions', $disable_filter ); + \remove_filter( 'tiny_mce_plugins', $plugins_filter, 10 ); + \remove_filter( 'wp_editor_settings', $settings_filter, 10 ); + } + + $inline_scripts = is_array( $before_data ) ? array_values( array_filter( $before_data, 'is_string' ) ) : array(); + $script = implode( "\n", $inline_scripts ); + $captured = $events['tiny_mce_before_init'][0]['settings'] ?? array(); + + self::record_if_false( + $failures, + ( $events['editor_settings'][0]['editorId'] ?? null ) === 'classic-block' + && array( 'tinymce' => true ) === ( $events['editor_settings'][0]['input'] ?? null ) + && $events['tiny_mce_plugins'] === array( 'classic-block' ) + && 1 === $events['disable_captions'] + && $events['mce_buttons'] === array( 'classic-block' ) + && $events['mce_buttons_2'] === array( 'classic-block' ) + && $events['mce_buttons_3'] === array( 'classic-block' ) + && $events['mce_buttons_4'] === array( 'classic-block' ) + && $events['mce_external_plugins'] === array( 'classic-block' ) + && array( 'classic-block' ) === array_column( $events['tiny_mce_before_init'], 'editorId' ), + 'wp_tinymce_inline_scripts applies classic-block editor filters exactly once and in the expected branch', + array( 'events' => self::preview( $events ) ) + ); + self::record_if_false( + $failures, + isset( $captured['plugins'], $captured['toolbar1'], $captured['toolbar2'], $captured['toolbar3'], $captured['toolbar4'], $captured['external_plugins'] ) + && 1 === substr_count( ',' . $captured['plugins'] . ',', ',' . $inline_plugin . ',' ) + && str_contains( $captured['toolbar1'], $inline_button ) + && str_contains( $captured['toolbar2'], $inline_button_two ) + && str_contains( $captured['toolbar3'], $inline_button_three ) + && str_contains( $captured['toolbar4'], $inline_button_four ) + && true === ( $captured['classic_block_editor'] ?? null ) + && true === ( $captured['wpeditimage_disable_captions'] ?? null ) + && true === ( $captured['wp_autoresize_on'] ?? null ) + && $plain_setting === ( $captured['component_plain'] ?? null ) + && $json_setting === ( $captured['component_json'] ?? null ) + && $array_setting === ( $captured['component_array'] ?? null ) + && $function_setting === ( $captured['component_function'] ?? null ) + && $external_url === ( json_decode( $captured['external_plugins'] ?? '[]', true )[ $external_plugin ] ?? null ), + 'TinyMCE inline settings merge generated editor/plugin/button/caption/external-plugin values before serialization', + array( 'captured' => self::preview( $captured ) ) + ); + self::record_if_false( + $failures, + 1 === count( $inline_scripts ) + && str_contains( $script, 'window.wpEditorL10n' ) + && str_contains( $script, 'baseURL: "http://example.test/wp-includes/js/tinymce"' ) + && str_contains( $script, 'component_plain:"' . $plain_setting . '"' ) + && str_contains( $script, 'component_json:' . $json_setting ) + && str_contains( $script, 'component_array:' . $array_setting ) + && str_contains( $script, 'component_function:' . $function_setting ) + && str_contains( $script, 'wp_autoresize_on:true' ) + && str_contains( $script, 'wpeditimage_disable_captions:true' ) + && str_contains( $script, 'component_before_marker:"' . $case['marker'] . '"' ) + && ! str_contains( $script, ' count( $inline_scripts ), + 'preview' => self::preview( $script ), + ) + ); + self::record_if_false( + $failures, + false === \has_filter( 'wp_editor_settings', $settings_filter ) + && false === \has_filter( 'tiny_mce_plugins', $plugins_filter ) + && false === \has_filter( 'disable_captions', $disable_filter ) + && false === \has_filter( 'mce_buttons', $buttons_filter ) + && false === \has_filter( 'mce_buttons_2', $buttons_two_filter ) + && false === \has_filter( 'mce_buttons_3', $buttons_three_filter ) + && false === \has_filter( 'mce_buttons_4', $buttons_four_filter ) + && false === \has_filter( 'mce_external_plugins', $external_filter ) + && false === \has_filter( 'tiny_mce_before_init', $before_filter ), + 'wp_tinymce_inline_scripts coverage removes all temporary classic-block filters', + array( 'marker' => $case['marker'] ) + ); + + return self::row( + $ctx, + 'editor-helpers.tinymce-inline-scripts.classic-block-filter-merge', + $failures, + array( + 'marker' => $case['marker'], + 'scriptHash' => sha1( $script ), + ) + ); + } + + private static function check_link_query_and_dialog( \ComponentFuzz\FuzzContext $ctx, array $case ): array { + $failures = array(); + $marker_key = 'cf_link_' . self::safe_key( $case['marker'], 'link' ); + $post_id = 700000 + ( $ctx->seed() % 10000 ); + $cpt_id = $post_id + 1; + $cpt = 'cf_link_' . self::safe_key( strtolower( substr( $case['linkTitleToken'], -5 ) ), 'cpt' ); + $cpt_label = 'Component Link ' . strtoupper( substr( $cpt, -3 ) ); + $post = self::link_query_post( + $post_id, + 'post', + 'editor-link-post-' . self::safe_key( $case['marker'], 'post' ), + ' Link Query ' . $case['linkTitleToken'] . ' & More ', + '2026-06-' . str_pad( (string) $ctx->int( 1, 28 ), 2, '0', STR_PAD_LEFT ) . ' 13:24:00' + ); + $cpt_post = self::link_query_post( + $cpt_id, + $cpt, + 'editor-link-cpt-' . self::safe_key( $case['marker'], 'cpt' ), + 'Custom Link ' . $case['linkTitleToken'] . ' & Other', + '2026-05-15 08:10:00' + ); + $fixtures = array( $post, $cpt_post ); + $events = array( + 'args' => array(), + 'queries' => array(), + 'results' => array(), + ); + $post_type_names = array(); + $expected_post_permalink = ''; + $expected_cpt_permalink = ''; + $local_globals = self::snapshot_globals( array( 'wp_rewrite' ) ); + + $args_filter = static function ( array $query ) use ( &$events, $marker_key ): array { + $events['args'][] = $query; + $query['component_fuzz_link_query_marker'] = $marker_key; + $query['cache_results'] = false; + return $query; + }; + $posts_filter = static function ( $posts, \WP_Query $query ) use ( &$events, $fixtures, $case, $marker_key ) { + if ( ( $query->query_vars['component_fuzz_link_query_marker'] ?? null ) !== $marker_key ) { + return $posts; + } + + $events['queries'][] = array( + 'post_type' => $query->query_vars['post_type'] ?? null, + 's' => $query->query_vars['s'] ?? null, + 'offset' => $query->query_vars['offset'] ?? null, + 'posts_per_page' => $query->query_vars['posts_per_page'] ?? null, + 'post_status' => $query->query_vars['post_status'] ?? null, + 'suppress_filters' => $query->query_vars['suppress_filters'] ?? null, + 'cache_results' => $query->query_vars['cache_results'] ?? null, + 'term' => $query->query_vars['s'] ?? null, + ); + + if ( ( $query->query_vars['s'] ?? '' ) === $case['linkNoMatchSearch'] ) { + $query->found_posts = 0; + $query->max_num_pages = 0; + return array(); + } + + $query->found_posts = count( $fixtures ); + $query->max_num_pages = 1; + return $fixtures; + }; + $results_filter = static function ( array $results, array $query ) use ( &$events ): array { + $events['results'][] = array( + 'query' => $query, + 'results' => $results, + ); + return $results; + }; + + try { + if ( ! isset( $GLOBALS['wp_rewrite'] ) || ! is_object( $GLOBALS['wp_rewrite'] ) ) { + $GLOBALS['wp_rewrite'] = new \WP_Rewrite(); + } + + \register_post_type( + $cpt, + array( + 'public' => true, + 'labels' => array( + 'name' => $cpt_label . 's', + 'singular_name' => $cpt_label, + ), + ) + ); + + foreach ( $fixtures as $fixture ) { + \wp_cache_set( $fixture->ID, $fixture, 'posts' ); + } + $post_type_names = array_keys( \get_post_types( array( 'public' => true ), 'objects' ) ); + $expected_post_permalink = \get_permalink( $post->ID ); + $expected_cpt_permalink = \get_permalink( $cpt_post->ID ); + + \add_filter( 'wp_link_query_args', $args_filter, 10, 1 ); + \add_filter( 'posts_pre_query', $posts_filter, 10, 2 ); + \add_filter( 'wp_link_query', $results_filter, 10, 2 ); + $results = \_WP_Editors::wp_link_query( + array( + 'pagenum' => $case['linkPageNumber'], + 's' => $case['linkSearch'], + ) + ); + $empty_results = \_WP_Editors::wp_link_query( + array( + 'pagenum' => 0, + 's' => $case['linkNoMatchSearch'], + ) + ); + } finally { + \remove_filter( 'wp_link_query', $results_filter, 10 ); + \remove_filter( 'posts_pre_query', $posts_filter, 10 ); + \remove_filter( 'wp_link_query_args', $args_filter, 10 ); + + foreach ( $fixtures as $fixture ) { + \wp_cache_delete( $fixture->ID, 'posts' ); + } + if ( isset( $GLOBALS['wp'] ) && is_object( $GLOBALS['wp'] ) ) { + \unregister_post_type( $cpt ); + } else { + unset( $GLOBALS['wp_post_types'][ $cpt ] ); + \remove_action( 'future_' . $cpt, '_future_post_hook', 5 ); + } + self::restore_globals( $local_globals ); + } + + $expected_post_title = 'Link Query ' . $case['linkTitleToken'] . ' & Morebad'; + $expected_cpt_title = 'Custom Link ' . $case['linkTitleToken'] . ' & Other'; + $expected_offset = 20 * ( $case['linkPageNumber'] - 1 ); + $first_result = is_array( $results ) ? ( $results[0] ?? array() ) : array(); + $second_result = is_array( $results ) ? ( $results[1] ?? array() ) : array(); + + self::record_if_false( + $failures, + is_array( $results ) + && 2 === count( $results ) + && $post->ID === ( $first_result['ID'] ?? null ) + && $expected_post_title === ( $first_result['title'] ?? null ) + && $expected_post_permalink === ( $first_result['permalink'] ?? null ) + && \mysql2date( __( 'Y/m/d' ), $post->post_date ) === ( $first_result['info'] ?? null ) + && $cpt_post->ID === ( $second_result['ID'] ?? null ) + && $expected_cpt_title === ( $second_result['title'] ?? null ) + && $expected_cpt_permalink === ( $second_result['permalink'] ?? null ) + && $cpt_label === ( $second_result['info'] ?? null ) + && false === $empty_results + && ! str_contains( strtolower( wp_json_encode( $results ) ?: '' ), ' $results, + 'emptyResults' => $empty_results, + 'expected' => array( + 'postTitle' => $expected_post_title, + 'cptTitle' => $expected_cpt_title, + 'cptLabel' => $cpt_label, + ), + ) + ); + self::record_if_false( + $failures, + 2 === count( $events['args'] ) + && 2 === count( $events['queries'] ) + && 2 === count( $events['results'] ) + && $case['linkSearch'] === ( $events['args'][0]['s'] ?? null ) + && $case['linkNoMatchSearch'] === ( $events['args'][1]['s'] ?? null ) + && $expected_offset === ( $events['args'][0]['offset'] ?? null ) + && 0 === ( $events['args'][1]['offset'] ?? null ) + && 20 === ( $events['args'][0]['posts_per_page'] ?? null ) + && true === ( $events['args'][0]['suppress_filters'] ?? null ) + && self::same_string_set( $events['args'][0]['post_type'] ?? array(), $post_type_names ) + && $case['linkSearch'] === ( $events['queries'][0]['s'] ?? null ) + && $expected_offset === ( $events['queries'][0]['offset'] ?? null ) + && 20 === ( $events['queries'][0]['posts_per_page'] ?? null ) + && 'publish' === ( $events['queries'][0]['post_status'] ?? null ) + && true === ( $events['queries'][0]['suppress_filters'] ?? null ) + && false === ( $events['queries'][0]['cache_results'] ?? null ) + && $results === ( $events['results'][0]['results'] ?? null ) + && array() === ( $events['results'][1]['results'] ?? null ), + 'wp_link_query exposes deterministic query-argument, WP_Query, and result-filter payloads for generated searches', + array( + 'events' => $events, + 'postTypeNames' => $post_type_names, + 'expectedOffset' => $expected_offset, + ) + ); + self::record_if_false( + $failures, + false === \has_filter( 'wp_link_query_args', $args_filter ) + && false === \has_filter( 'posts_pre_query', $posts_filter ) + && false === \has_filter( 'wp_link_query', $results_filter ) + && false === \has_filter( 'future_' . $cpt, '_future_post_hook' ), + 'wp_link_query coverage removes temporary query/result filters and generated post-type hooks', + array( + 'argsFilter' => \has_filter( 'wp_link_query_args', $args_filter ), + 'postsFilter' => \has_filter( 'posts_pre_query', $posts_filter ), + 'resultsFilter' => \has_filter( 'wp_link_query', $results_filter ), + 'futureHook' => \has_filter( 'future_' . $cpt, '_future_post_hook' ), + ) + ); + + self::reset_editor_statics(); + $first_dialog = self::capture_output( + static function (): void { + \_WP_Editors::wp_link_dialog(); + } + ); + $second_dialog = self::capture_output( + static function (): void { + \_WP_Editors::wp_link_dialog(); + } + ); + $dialog_output = $first_dialog['output'] ?? ''; + self::record_if_false( + $failures, + empty( $first_dialog['threw'] ) + && empty( $second_dialog['threw'] ) + && '' === ( $second_dialog['output'] ?? '' ) + && str_contains( $dialog_output, 'id="wp-link-backdrop"' ) + && str_contains( $dialog_output, 'id="wp-link-wrap"' ) + && str_contains( $dialog_output, 'role="dialog"' ) + && str_contains( $dialog_output, 'aria-modal="true"' ) + && str_contains( $dialog_output, 'id="wp-link-url"' ) + && str_contains( $dialog_output, 'id="wp-link-search"' ) + && str_contains( $dialog_output, 'name="_ajax_linking_nonce"' ) + && str_contains( $dialog_output, 'id="search-results"' ) + && str_contains( $dialog_output, 'id="most-recent-results"' ) + && str_contains( $dialog_output, 'id="wp-link-submit"' ) + && true === self::get_editor_static_property( 'link_dialog_printed' ) + && ! str_contains( strtolower( $dialog_output ), ' self::describe_call( $first_dialog ), + 'second' => self::describe_call( $second_dialog ), + 'output' => self::preview( $dialog_output ), + ) + ); + + return self::row( + $ctx, + 'editor-helpers.link-query-dialog.query-results-and-single-print-markup', + $failures, + array( + 'search' => $case['linkSearch'], + 'pageNumber' => $case['linkPageNumber'], + 'resultCount' => is_array( $results ) ? count( $results ) : 0, + 'dialogHash' => sha1( $dialog_output ), + ) + ); + } + + private static function check_media_view_styles( \ComponentFuzz\FuzzContext $ctx ): array { + $styles = \wpview_media_sandbox_styles(); + $failures = array(); + $hosts = array(); + + foreach ( $styles as $style ) { + $parts = \wp_parse_url( $style ); + if ( isset( $parts['host'] ) ) { + $hosts[] = $parts['host']; + } + } + + self::record_if_false( + $failures, + 2 === count( $styles ) + && str_contains( $styles[0], 'mediaelementplayer-legacy.min.css' ) + && str_contains( $styles[1], 'wp-mediaelement.css' ) + && str_contains( $styles[0], 'ver=' ) + && str_contains( $styles[1], 'ver=' ), + 'wpview_media_sandbox_styles returns deterministic versioned media-view stylesheet URLs', + array( + 'styles' => $styles, + 'hosts' => $hosts, + ) + ); + + return self::row( + $ctx, + 'editor-helpers.media-view.stylesheet-urls', + $failures, + array( 'styles' => $styles ) + ); + } + + private static function generate_full_editor_js_output( array $case ): array { + self::reset_scripts_and_styles(); + self::reset_editor_statics(); + unset( $GLOBALS['wp_rich_edit'] ); + + $set = \_WP_Editors::parse_settings( + $case['editorId'], + array( + '_content_editor_dfw' => $case['dfw'], + 'media_buttons' => false, + 'quicktags' => array( + 'buttons' => 'strong,em,link', + ), + 'tabfocus_elements' => ':prev,:next', + 'teeny' => false, + 'tinymce' => array( + 'body_class' => $case['bodyClass'], + 'wp_skip_init' => true, + ), + 'wpautop' => false, + ) + ); + \_WP_Editors::editor_settings( $case['editorId'], $set ); + $statics = self::editor_statics(); + $call = self::capture_output( + static function (): void { + \_WP_Editors::editor_js(); + } + ); + + return array( + 'call' => $call, + 'output' => $call['output'], + 'statics' => $statics, + ); + } + + private static function install_full_editor_filters( array $case ): array { + $events = (object) array( + 'quicktags' => array(), + 'mce_external_plugins' => array(), + 'tiny_mce_plugins' => array(), + 'mce_buttons' => array(), + 'mce_buttons_2' => array(), + 'mce_buttons_3' => array(), + 'mce_buttons_4' => array(), + 'tiny_mce_before_init' => array(), + 'mce_css' => array(), + ); + $seen = array( 'events' => $events ); + + $seen['user_can_richedit'] = static fn (): bool => true; + $seen['quicktags_filter'] = static function ( array $qt_init, string $editor_id ) use ( $events, $case ): array { + $events->quicktags[] = $editor_id; + $qt_init['buttons'] .= ',' . $case['quicktagsButton']; + $qt_init['componentId'] = $case['marker']; + return $qt_init; + }; + $seen['external_filter'] = static function ( array $plugins, string $editor_id ) use ( $events, $case ): array { + $events->mce_external_plugins[] = $editor_id; + $plugins[ $case['externalPlugin'] ] = $case['externalUrl']; + return $plugins; + }; + $seen['plugins_filter'] = static function ( array $plugins, string $editor_id ) use ( $events, $case ): array { + $events->tiny_mce_plugins[] = $editor_id; + $plugins[] = 'spellchecker'; + $plugins[] = $case['plugin']; + return $plugins; + }; + $seen['buttons_filter'] = static function ( array $buttons, string $editor_id ) use ( $events, $case ): array { + $events->mce_buttons[] = $editor_id; + $buttons[] = $case['button1']; + return $buttons; + }; + $seen['buttons_2_filter'] = static function ( array $buttons, string $editor_id ) use ( $events, $case ): array { + $events->mce_buttons_2[] = $editor_id; + $buttons[] = $case['button2']; + return $buttons; + }; + $seen['buttons_3_filter'] = static function ( array $buttons, string $editor_id ) use ( $events ): array { + unset( $buttons ); + $events->mce_buttons_3[] = $editor_id; + return array(); + }; + $seen['buttons_4_filter'] = static function ( array $buttons, string $editor_id ) use ( $events, $case ): array { + unset( $buttons ); + $events->mce_buttons_4[] = $editor_id; + return array( $case['button4'] ); + }; + $seen['before_filter'] = static function ( array $init, string $editor_id ) use ( $events, $case ): array { + $events->tiny_mce_before_init[] = $editor_id; + $init['component_fuzz_marker'] = $case['marker']; + return $init; + }; + $seen['css_filter'] = static function ( string $css ) use ( $events, $case ): string { + $events->mce_css[] = $css; + return $css . ',' . $case['cssUrl']; + }; + + \add_filter( 'user_can_richedit', $seen['user_can_richedit'] ); + \add_filter( 'quicktags_settings', $seen['quicktags_filter'], 10, 2 ); + \add_filter( 'mce_external_plugins', $seen['external_filter'], 10, 2 ); + \add_filter( 'tiny_mce_plugins', $seen['plugins_filter'], 10, 2 ); + \add_filter( 'mce_buttons', $seen['buttons_filter'], 10, 2 ); + \add_filter( 'mce_buttons_2', $seen['buttons_2_filter'], 10, 2 ); + \add_filter( 'mce_buttons_3', $seen['buttons_3_filter'], 10, 2 ); + \add_filter( 'mce_buttons_4', $seen['buttons_4_filter'], 10, 2 ); + \add_filter( 'tiny_mce_before_init', $seen['before_filter'], 10, 2 ); + \add_filter( 'mce_css', $seen['css_filter'] ); + + return $seen; + } + + private static function remove_full_editor_filters( array $case, array $seen ): void { + unset( $case ); + \remove_filter( 'mce_css', $seen['css_filter'] ); + \remove_filter( 'tiny_mce_before_init', $seen['before_filter'], 10 ); + \remove_filter( 'mce_buttons_4', $seen['buttons_4_filter'], 10 ); + \remove_filter( 'mce_buttons_3', $seen['buttons_3_filter'], 10 ); + \remove_filter( 'mce_buttons_2', $seen['buttons_2_filter'], 10 ); + \remove_filter( 'mce_buttons', $seen['buttons_filter'], 10 ); + \remove_filter( 'tiny_mce_plugins', $seen['plugins_filter'], 10 ); + \remove_filter( 'mce_external_plugins', $seen['external_filter'], 10 ); + \remove_filter( 'quicktags_settings', $seen['quicktags_filter'], 10 ); + \remove_filter( 'user_can_richedit', $seen['user_can_richedit'] ); + unset( $GLOBALS['wp_rich_edit'] ); + } + + private static function external_plugin_registered( string $json, array $case ): bool { + $decoded = json_decode( $json, true ); + $expected_url = function_exists( 'set_url_scheme' ) ? \set_url_scheme( $case['externalUrl'] ) : $case['externalUrl']; + + return is_array( $decoded ) + && isset( $decoded[ $case['externalPlugin'] ] ) + && $expected_url === $decoded[ $case['externalPlugin'] ]; + } + + private static function seen_counts_match( $seen, array $expected ): bool { + $seen = is_object( $seen ) ? get_object_vars( $seen ) : $seen; + foreach ( $expected as $key => $count ) { + if ( ! isset( $seen[ $key ] ) || count( $seen[ $key ] ) !== $count ) { + return false; + } + } + return true; + } + + private static function seen_events_to_array( object $events ): array { + return get_object_vars( $events ); + } + + private static function link_query_post( int $id, string $post_type, string $post_name, string $post_title, string $post_date ): \WP_Post { + return new \WP_Post( + (object) array( + 'ID' => $id, + 'post_author' => 1, + 'post_date' => $post_date, + 'post_date_gmt' => $post_date, + 'post_content' => '', + 'post_title' => $post_title, + 'post_excerpt' => '', + 'post_status' => 'publish', + 'comment_status' => 'closed', + 'ping_status' => 'closed', + 'post_password' => '', + 'post_name' => $post_name, + 'to_ping' => '', + 'pinged' => '', + 'post_modified' => $post_date, + 'post_modified_gmt' => $post_date, + 'post_content_filtered' => '', + 'post_parent' => 0, + 'guid' => 'http://example.test/?p=' . $id, + 'menu_order' => 0, + 'post_type' => $post_type, + 'post_mime_type' => '', + 'comment_count' => 0, + 'filter' => 'raw', + ) + ); + } + + private static function same_string_set( $actual, array $expected ): bool { + if ( ! is_array( $actual ) ) { + return false; + } + + $actual = array_values( array_map( 'strval', $actual ) ); + $expected = array_values( array_map( 'strval', $expected ) ); + sort( $actual ); + sort( $expected ); + + return $actual === $expected; + } + + private static function case_for_context( \ComponentFuzz\FuzzContext $ctx ): array { + $slug = self::safe_key( strtolower( $ctx->identifier( 4, 10 ) ), 'case' ); + + return array( + 'bodyClass' => 'cf-body-' . $slug, + 'button1' => 'cf_btn_' . $slug, + 'button2' => 'cf_btn2_' . $slug, + 'button4' => 'cf_btn4_' . $slug, + 'cssUrl' => 'https://example.test/editor-' . $slug . '.css?ver=1%2C2', + 'dangerousContent' => "Lead {$slug}\nTail", + 'defaultOverride' => $ctx->choice( array( 'tinymce', 'html', 'test' ) ), + 'dfw' => $ctx->bool(), + 'dragDropUpload' => $ctx->bool(), + 'enqueueMedia' => $ctx->bool(), + 'enqueuePlugin' => $ctx->choice( array( 'wplink', 'lists' ) ), + 'enqueueQuicktag' => $ctx->choice( array( 'link', 'strong' ) ), + 'editorClass' => 'cf-class-' . $slug . ' quoted', + 'editorId' => 'cf_editor_' . $slug, + 'externalPlugin' => 'cf_external_' . $slug, + 'externalUrl' => 'https://example.test/plugins/' . $slug . '/plugin.js', + 'linkNoMatchSearch' => 'missing-' . $slug . ' ', + 'linkPageNumber' => $ctx->int( 2, 5 ), + 'linkSearch' => 'needle ' . $slug . ' & ', + 'linkTitleToken' => 'Token ' . strtoupper( substr( $slug, 0, 5 ) ), + 'markupDefaultEditor' => $ctx->choice( array( 'tinymce', 'html' ) ), + 'markupEditorId' => 'cf_markup_' . $slug, + 'markupHeight' => $ctx->int( 50, 420 ), + 'marker' => 'cf-marker-' . $slug, + 'parseHeight' => $ctx->choice( array( -20, 0, 49, 50, 51, 4999, 5000, 5001, 6400 ) ), + 'plugin' => 'cf_plugin_' . $slug, + 'quicktagsButton' => 'cf_qt_' . $slug, + 'quicktagsInput' => array( 'buttons' => 'strong,em,link' ), + 'richEditing' => $ctx->bool(), + 'tabindex' => (string) $ctx->int( 1, 20 ), + 'teenyButton' => 'cf_teeny_btn_' . $slug, + 'teenyEditorId' => 'cf_teeny_' . $slug, + 'teenyPlugin' => 'cf_teeny_plugin_' . $slug, + 'textareaName' => 'component_fuzz[' . $slug . ']', + 'tinymceInput' => array( 'body_class' => 'parse-body-' . $slug ), + 'translationMarker' => 'Translated marker ' . $slug, + ); + } + + private static function prepare_editor_globals(): void { + self::load_editor_class(); + if ( ! isset( $_SERVER['SERVER_NAME'] ) ) { + $_SERVER['SERVER_NAME'] = 'example.test'; + } + } + + private static function reset_scripts_and_styles(): void { + $GLOBALS['wp_scripts'] = new \WP_Scripts(); + $GLOBALS['wp_styles'] = new \WP_Styles(); + } + + private static function reset_editor_statics(): void { + foreach ( self::editor_static_defaults() as $property => $value ) { + self::set_editor_static_property( $property, $value ); + } + } + + private static function editor_static_defaults(): array { + return array( + 'mce_locale' => null, + 'mce_settings' => array(), + 'qt_settings' => array(), + 'plugins' => array(), + 'qt_buttons' => array(), + 'ext_plugins' => null, + 'baseurl' => null, + 'first_init' => null, + 'this_tinymce' => false, + 'this_quicktags' => false, + 'has_tinymce' => false, + 'has_quicktags' => false, + 'has_medialib' => false, + 'editor_buttons_css' => true, + 'drag_drop_upload' => false, + 'translation' => null, + 'tinymce_scripts_printed' => false, + 'link_dialog_printed' => false, + ); + } + + private static function snapshot_state(): array { + return array( + 'globals' => self::snapshot_globals( + array( + '_updated_user_settings', + 'concatenate_scripts', + 'compress_css', + 'compress_scripts', + 'current_screen', + 'current_user', + 'is_IE', + 'is_chrome', + 'is_edge', + 'is_gecko', + 'is_opera', + 'is_safari', + 'post', + 'tinymce_version', + 'user_ID', + 'wp_actions', + 'wp_current_filter', + 'wp_filter', + 'wp_filters', + 'wp_post_types', + 'wp_rich_edit', + 'wp_rewrite', + 'wp_scripts', + 'wp_styles', + ) + ), + 'editorStatics' => self::snapshot_editor_statics(), + ); + } + + private static function restore_state( array $snapshot ): void { + self::restore_globals( $snapshot['globals'] ); + foreach ( $snapshot['editorStatics'] as $property => $entry ) { + self::set_editor_static_property( $property, $entry['value'] ); + } + } + + private static function state_restored( array $snapshot ): bool { + foreach ( $snapshot['globals'] as $name => $entry ) { + $exists = array_key_exists( $name, $GLOBALS ); + if ( $exists !== $entry['exists'] ) { + return false; + } + if ( $exists && $GLOBALS[ $name ] !== $entry['value'] ) { + return false; + } + } + + foreach ( $snapshot['editorStatics'] as $property => $entry ) { + if ( self::get_editor_static_property_raw( $property ) !== $entry['value'] ) { + return false; + } + } + + return true; + } + + private static function snapshot_globals( array $names ): array { + $snapshot = array(); + foreach ( $names as $name ) { + $snapshot[ $name ] = array( + 'exists' => array_key_exists( $name, $GLOBALS ), + 'value' => array_key_exists( $name, $GLOBALS ) ? self::clone_value( $GLOBALS[ $name ] ) : null, + ); + } + + return $snapshot; + } + + private static function restore_globals( array $snapshot ): void { + foreach ( $snapshot as $name => $entry ) { + if ( $entry['exists'] ) { + $GLOBALS[ $name ] = $entry['value']; + } else { + unset( $GLOBALS[ $name ] ); + } + } + } + + private static function snapshot_editor_statics(): array { + $statics = array(); + foreach ( array_keys( self::editor_static_defaults() ) as $property ) { + $statics[ $property ] = array( + 'value' => self::get_editor_static_property( $property ), + ); + } + + return $statics; + } + + private static function editor_statics(): array { + $statics = array(); + foreach ( array_keys( self::editor_static_defaults() ) as $property ) { + $statics[ $property ] = self::get_editor_static_property( $property ); + } + return $statics; + } + + private static function register_editor_enqueue_handles(): void { + $scripts = \wp_scripts(); + $styles = \wp_styles(); + + foreach ( array( 'editor', 'quicktags', 'wplink', 'jquery-ui-autocomplete', 'wp-embed', 'thickbox', 'shortcode' ) as $handle ) { + $scripts->add( $handle, false ); + } + $scripts->add( 'media-upload', false, array( 'thickbox', 'shortcode' ) ); + + foreach ( array( 'buttons', 'thickbox' ) as $handle ) { + $styles->add( $handle, false ); + } + } + + private static function editor_enqueue_state(): array { + $scripts = array( + 'editor', + 'quicktags', + 'wplink', + 'jquery-ui-autocomplete', + 'media-upload', + 'wp-embed', + 'thickbox', + ); + $styles = array( + 'buttons', + 'thickbox', + ); + $state = array( + 'scripts' => array(), + 'styles' => array(), + ); + + foreach ( $scripts as $handle ) { + $state['scripts'][ $handle ] = \wp_script_is( $handle, 'enqueued' ); + } + + foreach ( $styles as $handle ) { + $state['styles'][ $handle ] = \wp_style_is( $handle, 'enqueued' ); + } + + return $state; + } + + private static function get_editor_static_property_raw( string $property ) { + $reflection = new \ReflectionProperty( '_WP_Editors', $property ); + return $reflection->getValue(); + } + + private static function get_editor_static_property( string $property ) { + return self::clone_value( self::get_editor_static_property_raw( $property ) ); + } + + private static function set_editor_static_property( string $property, $value ): void { + $reflection = new \ReflectionProperty( '_WP_Editors', $property ); + $reflection->setValue( null, $value ); + } + + private static function capture_output( callable $callback ): array { + $level = ob_get_level(); + ob_start(); + try { + $value = $callback(); + $output = ob_get_clean(); + return array( + 'threw' => false, + 'output' => $output, + 'value' => $value, + ); + } catch ( \Throwable $e ) { + while ( ob_get_level() > $level ) { + ob_end_clean(); + } + return array( + 'threw' => true, + 'output' => '', + 'throwable' => self::describe_throwable( $e ), + ); + } + } + + private static function clone_value( $value ) { + if ( is_array( $value ) ) { + $copy = array(); + foreach ( $value as $key => $item ) { + $copy[ $key ] = self::clone_value( $item ); + } + return $copy; + } + if ( $value instanceof \Closure ) { + return $value; + } + if ( is_object( $value ) ) { + try { + return clone $value; + } catch ( \Throwable $e ) { + return $value; + } + } + + return $value; + } + + private static function row( \ComponentFuzz\FuzzContext $ctx, string $invariant, array $failures, array $data = array() ): array { + return $ctx->result( + $invariant, + array() === $failures, + $data + array( + 'failures' => array_slice( $failures, 0, 8 ), + ) + ); + } + + private static function record_if_false( array &$failures, bool $ok, string $check, array $data = array() ): void { + if ( $ok ) { + return; + } + + $failures[] = array( + 'check' => $check, + 'data' => $data, + ); + } + + private static function safe_key( string $value, string $fallback ): string { + $value = preg_replace( '/[^A-Za-z0-9_-]+/', '-', $value ); + $value = trim( (string) $value, '-' ); + + return '' === $value ? $fallback : $value; + } + + private static function preview( $value ) { + if ( is_string( $value ) ) { + return strlen( $value ) > self::PREVIEW_BYTES ? substr( $value, 0, self::PREVIEW_BYTES ) . '...' : $value; + } + if ( is_array( $value ) ) { + $json = wp_json_encode( $value, JSON_UNESCAPED_SLASHES ); + return false === $json ? '[array]' : self::preview( $json ); + } + if ( is_object( $value ) ) { + return '[object ' . get_class( $value ) . ']'; + } + + return $value; + } + + private static function describe_call( array $call ): array { + if ( empty( $call['threw'] ) ) { + return array( + 'threw' => false, + 'outputBytes' => strlen( $call['output'] ?? '' ), + ); + } + + return array( + 'threw' => true, + 'throwable' => $call['throwable'] ?? null, + ); + } + + private static function describe_throwable( \Throwable $e ): array { + return array( + 'class' => get_class( $e ), + 'message' => $e->getMessage(), + 'file' => $e->getFile(), + 'line' => $e->getLine(), + ); + } +} diff --git a/tools/component-fuzz/surfaces/EmailSurface.php b/tools/component-fuzz/surfaces/EmailSurface.php new file mode 100644 index 0000000000000..bc4991cf9f0b9 --- /dev/null +++ b/tools/component-fuzz/surfaces/EmailSurface.php @@ -0,0 +1,10028 @@ +skip( + 'email.bootstrap-apis-available', + 'Required WordPress email APIs are unavailable.', + array( 'missing' => implode( ', ', $missing ) ) + ), + ); + } + + $unicode_missing = self::missing_unicode_requirements(); + if ( array() !== $unicode_missing ) { + return array_merge( + self::check_core_ascii_email_baseline( $ctx ), + array( + $ctx->skip( + 'email.unicode-email-optional-apis-available', + 'Optional WordPress Unicode email APIs are unavailable in this checkout.', + array( 'missing' => implode( ', ', $unicode_missing ) ) + ), + ) + ); + } + + $rows = array(); + $snapshot = self::snapshot_hook_globals(); + $tracked_hook_snapshot = self::snapshot_tracked_hook_state(); + + try { + $cases = self::cases( $ctx ); + + $rows = array_merge( $rows, self::check_utf8mb4_filter_gate( $ctx ) ); + + self::install_email_filters( 'unicode' ); + $rows[] = self::check_unicode_filters( $ctx ); + $rows = array_merge( $rows, self::check_direct_filter_callbacks( $ctx ) ); + $rows = array_merge( $rows, self::check_disabled_filter_fail_closed( $ctx ) ); + $rows = array_merge( $rows, self::check_whatwg_examples( $ctx ) ); + $rows = array_merge( $rows, self::check_whatwg_ascii_oracle( $ctx ) ); + $rows = array_merge( $rows, self::check_sanitizer_recovery( $ctx ) ); + $rows = array_merge( $rows, self::check_malformed_utf8_byte_matrix( $ctx ) ); + $rows = array_merge( $rows, self::check_unicode_localpart_byte_boundaries( $ctx ) ); + $rows = array_merge( $rows, self::check_generated_utf8_localpart_oracle( $ctx ) ); + $rows = array_merge( $rows, self::check_generated_utf8_address_model_invariants( $ctx ) ); + $rows = array_merge( $rows, self::check_construction_mode_consistency( $ctx ) ); + $rows = array_merge( $rows, self::check_generated_unicode_filter_view_matrix( $ctx ) ); + $rows = array_merge( $rows, self::check_generated_malformed_variant_matrix( $ctx ) ); + $rows = array_merge( $rows, self::check_quoted_escaped_localpart_boundaries( $ctx ) ); + $rows = array_merge( $rows, self::check_control_character_boundaries( $ctx ) ); + $rows = array_merge( $rows, self::check_localpart_identity_preservation( $ctx ) ); + + foreach ( $cases as $case_index => $case ) { + $rows = array_merge( $rows, self::check_unicode_case( $ctx, $case_index, $case ) ); + } + + $rows = array_merge( $rows, self::check_distinct_localparts( $ctx ) ); + $rows = array_merge( $rows, self::check_normalization_sensitive_localparts( $ctx ) ); + $rows = array_merge( $rows, self::check_comment_author_email_filters( $ctx ) ); + $rows = array_merge( $rows, self::check_comment_submission_unicode_email_paths( $ctx ) ); + $rows = array_merge( $rows, self::check_rest_email_schema_filter_modes( $ctx ) ); + $rows = array_merge( $rows, self::check_user_email_indexes_distinct_localparts( $ctx ) ); + $rows = array_merge( $rows, self::check_user_email_indexes_generated_localpart_aliases( $ctx ) ); + $rows = array_merge( $rows, self::check_user_email_updates_generated_localpart_aliases( $ctx ) ); + $rows = array_merge( $rows, self::check_user_email_authentication_unicode_paths( $ctx ) ); + $rows = array_merge( $rows, self::check_profile_email_confirmation_unicode_paths( $ctx ) ); + $rows = array_merge( $rows, self::check_user_email_search_unicode_terms( $ctx ) ); + $rows = array_merge( $rows, self::check_confusable_localpart_boundaries( $ctx ) ); + $rows = array_merge( $rows, self::check_user_email_indexes_distinct_domains( $ctx ) ); + $rows = array_merge( $rows, self::check_user_email_indexes_canonical_domain_aliases( $ctx ) ); + $rows = array_merge( $rows, self::check_password_reset_unicode_email_paths( $ctx ) ); + $rows = array_merge( $rows, self::check_password_reset_notification_recipient_views( $ctx ) ); + $rows = array_merge( $rows, self::check_punycode_views( $ctx ) ); + $rows = array_merge( $rows, self::check_idn_views( $ctx ) ); + $rows = array_merge( $rows, self::check_extension_address_views( $ctx ) ); + $rows = array_merge( $rows, self::check_mailto_rendering_context_round_trips( $ctx ) ); + $rows = array_merge( $rows, self::check_make_clickable_email_rendering( $ctx ) ); + $rows = array_merge( $rows, self::check_length_boundaries( $ctx ) ); + + self::install_email_filters( 'ascii' ); + $rows[] = self::check_ascii_filters( $ctx ); + + foreach ( $cases as $case_index => $case ) { + $rows = array_merge( $rows, self::check_ascii_case( $ctx, $case_index, $case ) ); + } + } catch ( \Throwable $e ) { + $rows[] = $ctx->fail( + 'email.surface-no-throw', + array( + 'throwable' => self::describe_throwable( $e ), + ) + ); + } finally { + self::restore_hook_globals( $snapshot ); + } + + $tracked_hook_actual = self::snapshot_tracked_hook_state(); + $tracked_hook_ok = $tracked_hook_snapshot === $tracked_hook_actual; + $tracked_hook_data = array( 'trackedHooks' => self::TRACKED_HOOKS ); + if ( ! $tracked_hook_ok ) { + $tracked_hook_data['expected'] = $tracked_hook_snapshot; + $tracked_hook_data['actual'] = $tracked_hook_actual; + } + + $rows[] = $ctx->result( + 'email.global-hook-state-restored', + $tracked_hook_ok, + $tracked_hook_data + ); + + return $rows; + } + + private static function missing_requirements(): array { + $missing = array(); + foreach ( + array( + 'add_filter', + 'has_filter', + 'remove_all_filters', + 'email_exists', + 'get_user_by', + 'is_email', + 'is_wp_error', + 'make_clickable', + 'sanitize_email', + 'wp_cache_flush', + 'wp_insert_user', + 'wp_is_valid_utf8', + ) as $function + ) { + if ( ! function_exists( $function ) ) { + $missing[] = "function {$function}"; + } + } + + if ( ! class_exists( 'WP_User' ) ) { + $missing[] = 'class WP_User'; + } + + return $missing; + } + + private static function missing_unicode_requirements(): array { + $missing = array(); + foreach ( + array( + 'wp_is_unicode_email', + 'wp_sanitize_unicode_email', + 'wp_is_ascii_email', + 'wp_sanitize_ascii_email', + ) as $function + ) { + if ( ! function_exists( $function ) ) { + $missing[] = "function {$function}"; + } + } + + if ( ! class_exists( 'WP_Email_Address' ) ) { + $missing[] = 'class WP_Email_Address'; + } + + return $missing; + } + + private static function check_core_ascii_email_baseline( \ComponentFuzz\FuzzContext $ctx ): array { + $valid = 'user+tag@example.com'; + $unicode = "jos\u{00E9}@example.com"; + $invalid_cases = array( + 'missing-at' => 'missing-at.example.com', + 'empty-local' => '@example.com', + 'empty-domain' => 'user@', + 'double-at' => 'bad@@example.com', + ); + $failures = array(); + $observed = array(); + + $valid_is_email = self::call( static fn() => \is_email( $valid ) ); + $valid_sanitized = self::call( static fn() => \sanitize_email( $valid ) ); + + if ( + $valid_is_email['threw'] || + $valid_sanitized['threw'] || + $valid !== $valid_is_email['value'] || + $valid !== $valid_sanitized['value'] + ) { + $failures[] = array( + 'label' => 'ascii-valid', + 'input' => self::describe_string( $valid ), + 'isEmail' => self::describe_call( $valid_is_email ), + 'sanitizeEmail' => self::describe_call( $valid_sanitized ), + ); + } + + $observed[] = array( + 'label' => 'ascii-valid', + 'input' => self::describe_string( $valid ), + 'isEmail' => self::describe_call( $valid_is_email ), + 'sanitizeEmail' => self::describe_call( $valid_sanitized ), + ); + + foreach ( $invalid_cases as $label => $input ) { + $is_email = self::call( static fn() => \is_email( $input ) ); + $sanitized = self::call( static fn() => \sanitize_email( $input ) ); + + if ( + $is_email['threw'] || + $sanitized['threw'] || + false !== $is_email['value'] || + '' !== $sanitized['value'] + ) { + $failures[] = array( + 'label' => $label, + 'input' => self::describe_string( $input ), + 'isEmail' => self::describe_call( $is_email ), + 'sanitizeEmail' => self::describe_call( $sanitized ), + ); + } + + $observed[] = array( + 'label' => $label, + 'input' => self::describe_string( $input ), + 'isEmail' => self::describe_call( $is_email ), + 'sanitizeEmail' => self::describe_call( $sanitized ), + ); + } + + $unicode_is_email = self::call( static fn() => \is_email( $unicode ) ); + $unicode_sanitized = self::call( static fn() => \sanitize_email( $unicode ) ); + $observed[] = array( + 'label' => 'unicode-observed-without-optional-oracles', + 'input' => self::describe_string( $unicode ), + 'isEmail' => self::describe_call( $unicode_is_email ), + 'sanitizeEmail' => self::describe_call( $unicode_sanitized ), + ); + + return array( + $ctx->result( + 'email.core-ascii-baseline-without-unicode-oracles', + array() === $failures, + array( + 'observed' => $observed, + 'failures' => $failures, + ) + ), + ); + } + + private static function check_unicode_filters( \ComponentFuzz\FuzzContext $ctx ): array { + $sample = "gr\u{00E5}@example.org"; + $is_email = self::call( static fn() => \is_email( $sample ) ); + $sanitized = self::call( static fn() => \sanitize_email( $sample ) ); + $ok = ! $is_email['threw'] + && ! $sanitized['threw'] + && $sample === $is_email['value'] + && $sample === $sanitized['value'] + && 10 === \has_filter( 'is_email', 'wp_is_unicode_email' ) + && 10 === \has_filter( 'sanitize_email', 'wp_sanitize_unicode_email' ) + && false === \has_filter( 'is_email', 'wp_is_ascii_email' ) + && false === \has_filter( 'sanitize_email', 'wp_sanitize_ascii_email' ); + + return $ctx->result( + 'email.scoped-unicode-filters-active', + $ok, + array( + 'sample' => self::describe_string( $sample ), + 'isEmail' => self::describe_call( $is_email ), + 'sanitizeEmail' => self::describe_call( $sanitized ), + 'isFilter' => \has_filter( 'is_email', 'wp_is_unicode_email' ), + 'sanitizeFilter' => \has_filter( 'sanitize_email', 'wp_sanitize_unicode_email' ), + ) + ); + } + + private static function check_utf8mb4_filter_gate( \ComponentFuzz\FuzzContext $ctx ): array { + $unicode = "jos\u{00E9}@example.org"; + $ascii = 'user@example.com'; + $charsets = array( + array( 'charset' => 'utf8mb4', 'unicode' => true ), + array( 'charset' => 'utf8', 'unicode' => false ), + array( 'charset' => 'latin1', 'unicode' => false ), + array( 'charset' => '', 'unicode' => false ), + ); + $failures = array(); + $observed = array(); + + $hook_snapshot = self::snapshot_hook_globals(); + $wpdb_snapshot = self::snapshot_wpdb_charset(); + + try { + foreach ( $charsets as $case ) { + self::restore_hook_globals( $hook_snapshot ); + self::install_email_filters_from_default_filters( $case['charset'] ); + + $is_unicode = self::call( static fn() => \is_email( $unicode ) ); + $sanitize_unicode = self::call( static fn() => \sanitize_email( $unicode ) ); + $is_ascii = self::call( static fn() => \is_email( $ascii ) ); + $sanitize_ascii = self::call( static fn() => \sanitize_email( $ascii ) ); + $unicode_filter = \has_filter( 'is_email', 'wp_is_unicode_email' ); + $unicode_sanitize = \has_filter( 'sanitize_email', 'wp_sanitize_unicode_email' ); + $ascii_filter = \has_filter( 'is_email', 'wp_is_ascii_email' ); + $ascii_sanitize = \has_filter( 'sanitize_email', 'wp_sanitize_ascii_email' ); + + $expected_unicode = $case['unicode'] ? $unicode : false; + $expected_sanitize_unicode = $case['unicode'] ? $unicode : ''; + $expected_unicode_filter = $case['unicode'] ? 10 : false; + $expected_ascii_filter = $case['unicode'] ? false : 10; + $ok = ! $is_unicode['threw'] + && ! $sanitize_unicode['threw'] + && ! $is_ascii['threw'] + && ! $sanitize_ascii['threw'] + && $expected_unicode === $is_unicode['value'] + && $expected_sanitize_unicode === $sanitize_unicode['value'] + && $ascii === $is_ascii['value'] + && $ascii === $sanitize_ascii['value'] + && $expected_unicode_filter === $unicode_filter + && $expected_unicode_filter === $unicode_sanitize + && $expected_ascii_filter === $ascii_filter + && $expected_ascii_filter === $ascii_sanitize; + + if ( ! $ok ) { + $failures[] = array( + 'charset' => $case['charset'], + 'expectsUnicode' => $case['unicode'], + 'isUnicode' => self::describe_call( $is_unicode ), + 'sanitizeUnicode' => self::describe_call( $sanitize_unicode ), + 'isAscii' => self::describe_call( $is_ascii ), + 'sanitizeAscii' => self::describe_call( $sanitize_ascii ), + 'unicodeFilter' => $unicode_filter, + 'unicodeSanitize' => $unicode_sanitize, + 'asciiFilter' => $ascii_filter, + 'asciiSanitize' => $ascii_sanitize, + ); + } + + $observed[] = array( + 'charset' => $case['charset'], + 'unicodeEnabled' => $case['unicode'], + 'isEmailFilter' => false !== $unicode_filter ? 'unicode' : 'ascii', + 'sanitizeFilter' => false !== $unicode_sanitize ? 'unicode' : 'ascii', + 'unicodeAccepted' => ! $is_unicode['threw'] && false !== $is_unicode['value'], + 'unicodeSanitized' => ! $sanitize_unicode['threw'] && '' !== $sanitize_unicode['value'], + ); + } + } finally { + self::restore_hook_globals( $hook_snapshot ); + self::restore_wpdb_charset( $wpdb_snapshot ); + } + + return array( + $ctx->result( + 'email.default-filters.utf8mb4-gate', + array() === $failures, + array( + 'unicode' => self::describe_string( $unicode ), + 'ascii' => self::describe_string( $ascii ), + 'observed' => $observed, + 'failures' => $failures, + ) + ), + ); + } + + private static function check_ascii_filters( \ComponentFuzz\FuzzContext $ctx ): array { + $unicode = "gr\u{00E5}@gr\u{00E5}.org"; + $ascii = 'user@example.com'; + $is_ascii = self::call( static fn() => \is_email( $ascii ) ); + $san_ascii = self::call( static fn() => \sanitize_email( $ascii ) ); + $is_uni = self::call( static fn() => \is_email( $unicode ) ); + $san_uni = self::call( static fn() => \sanitize_email( $unicode ) ); + $ok = ! $is_ascii['threw'] + && ! $san_ascii['threw'] + && ! $is_uni['threw'] + && ! $san_uni['threw'] + && $ascii === $is_ascii['value'] + && $ascii === $san_ascii['value'] + && false === $is_uni['value'] + && '' === $san_uni['value'] + && 10 === \has_filter( 'is_email', 'wp_is_ascii_email' ) + && 10 === \has_filter( 'sanitize_email', 'wp_sanitize_ascii_email' ) + && false === \has_filter( 'is_email', 'wp_is_unicode_email' ) + && false === \has_filter( 'sanitize_email', 'wp_sanitize_unicode_email' ); + + return $ctx->result( + 'email.scoped-ascii-filters-active', + $ok, + array( + 'ascii' => self::describe_string( $ascii ), + 'unicode' => self::describe_string( $unicode ), + 'isAscii' => self::describe_call( $is_ascii ), + 'sanitizeAscii' => self::describe_call( $san_ascii ), + 'isUnicode' => self::describe_call( $is_uni ), + 'sanitizeUnicode' => self::describe_call( $san_uni ), + ) + ); + } + + private static function check_direct_filter_callbacks( \ComponentFuzz\FuzzContext $ctx ): array { + $unicode = "gr\u{00E5}@example.org"; + $ascii = 'user@example.com'; + $punycode = 'books@xn--bcher-kva.de'; + $punycode_unicode = "books@b\u{00FC}cher.de"; + $sentinel = 'sentinel@example.com'; + $is_unicode = self::call( static fn() => \wp_is_unicode_email( false, $unicode, null ) ); + $sanitize_unicode = self::call( static fn() => \wp_sanitize_unicode_email( '', $unicode, null ) ); + $is_ascii = self::call( static fn() => \wp_is_ascii_email( false, $ascii, null ) ); + $sanitize_ascii = self::call( static fn() => \wp_sanitize_ascii_email( '', $ascii, null ) ); + $is_unicode_as_ascii = self::call( static fn() => \wp_is_ascii_email( false, $unicode, null ) ); + $sanitize_unicode_as_ascii = self::call( static fn() => \wp_sanitize_ascii_email( '', $unicode, null ) ); + $is_unicode_context = self::call( static fn() => \wp_is_unicode_email( $sentinel, 'not an address', 'local_invalid_chars' ) ); + $is_ascii_context = self::call( static fn() => \wp_is_ascii_email( $sentinel, 'not an address', 'domain_no_periods' ) ); + $is_punycode_unicode = self::call( static fn() => \wp_is_unicode_email( false, $punycode, null ) ); + $sanitize_punycode_unicode = self::call( static fn() => \wp_sanitize_unicode_email( '', $punycode, null ) ); + + $punycode_ok = ! $is_punycode_unicode['threw'] && ! $sanitize_punycode_unicode['threw'] && ( + ! self::has_idn() + ? false === $is_punycode_unicode['value'] && '' === $sanitize_punycode_unicode['value'] + : $punycode_unicode === $is_punycode_unicode['value'] && $punycode_unicode === $sanitize_punycode_unicode['value'] + ); + + $ok = ! $is_unicode['threw'] + && ! $sanitize_unicode['threw'] + && ! $is_ascii['threw'] + && ! $sanitize_ascii['threw'] + && ! $is_unicode_as_ascii['threw'] + && ! $sanitize_unicode_as_ascii['threw'] + && ! $is_unicode_context['threw'] + && ! $is_ascii_context['threw'] + && ! $is_punycode_unicode['threw'] + && ! $sanitize_punycode_unicode['threw'] + && $unicode === $is_unicode['value'] + && $unicode === $sanitize_unicode['value'] + && $ascii === $is_ascii['value'] + && $ascii === $sanitize_ascii['value'] + && false === $is_unicode_as_ascii['value'] + && '' === $sanitize_unicode_as_ascii['value'] + && $sentinel === $is_unicode_context['value'] + && $sentinel === $is_ascii_context['value'] + && $punycode_ok; + + return array( + $ctx->result( + 'email.filter-callbacks.direct-contracts', + $ok, + array( + 'unicode' => self::describe_call( $is_unicode ), + 'sanitizeUnicode' => self::describe_call( $sanitize_unicode ), + 'ascii' => self::describe_call( $is_ascii ), + 'sanitizeAscii' => self::describe_call( $sanitize_ascii ), + 'unicodeAsAscii' => self::describe_call( $is_unicode_as_ascii ), + 'sanitizeUnicodeAsAscii' => self::describe_call( $sanitize_unicode_as_ascii ), + 'unicodeContext' => self::describe_call( $is_unicode_context ), + 'asciiContext' => self::describe_call( $is_ascii_context ), + 'punycodeUnicode' => self::describe_call( $is_punycode_unicode ), + 'sanitizePunycodeUnicode' => self::describe_call( $sanitize_punycode_unicode ), + ) + ), + ); + } + + private static function check_disabled_filter_fail_closed( \ComponentFuzz\FuzzContext $ctx ): array { + $cases = array( + array( 'label' => 'ascii', 'input' => 'user@example.com' ), + array( 'label' => 'unicode-local', 'input' => "gr\u{00E5}@example.com" ), + array( 'label' => 'unicode-domain', 'input' => "mail@gr\u{00E5}.org" ), + array( 'label' => 'punycode-domain', 'input' => 'mail@xn--bcher-kva.de' ), + ); + $failures = array(); + $observed = array(); + $snapshot = self::snapshot_hook_globals(); + + try { + \remove_all_filters( 'is_email' ); + \remove_all_filters( 'sanitize_email' ); + + foreach ( $cases as $case ) { + $is_email = self::call( static fn() => \is_email( $case['input'] ) ); + $sanitized = self::call( static fn() => \sanitize_email( $case['input'] ) ); + $ok = ! $is_email['threw'] + && ! $sanitized['threw'] + && false === $is_email['value'] + && '' === $sanitized['value']; + + if ( ! $ok ) { + $failures[] = array( + 'label' => $case['label'], + 'input' => self::describe_string( $case['input'] ), + 'isEmail' => self::describe_call( $is_email ), + 'sanitizeEmail' => self::describe_call( $sanitized ), + ); + } + + $observed[] = array( + 'label' => $case['label'], + 'input' => self::describe_string( $case['input'] ), + 'isEmail' => self::describe_call( $is_email ), + 'sanitizeEmail' => self::describe_call( $sanitized ), + ); + } + } finally { + self::restore_hook_globals( $snapshot ); + } + + return array( + $ctx->result( + 'email.filters.disabled-fail-closed', + array() === $failures, + array( + 'observed' => $observed, + 'failures' => $failures, + ) + ), + ); + } + + private static function check_whatwg_examples( \ComponentFuzz\FuzzContext $ctx ): array { + $valid = array( + array( 'label' => 'ascii-atext-local', 'input' => 'azAZ09.!#$%&\'*+/=?^_`{|}~-@example.com', 'expected' => 'azAZ09.!#$%&\'*+/=?^_`{|}~-@example.com' ), + array( 'label' => 'single-label-domain', 'input' => 'a@b', 'expected' => 'a@b' ), + array( 'label' => 'consecutive-local-dots', 'input' => 'first..last@example.com', 'expected' => 'first..last@example.com' ), + array( 'label' => 'subdomain-hyphen', 'input' => 'user@sub-domain.example', 'expected' => 'user@sub-domain.example' ), + array( 'label' => 'latin-local', 'input' => "jos\u{00E9}@example.com", 'expected' => "jos\u{00E9}@example.com" ), + array( 'label' => 'combining-local', 'input' => "jose\u{0301}@example.com", 'expected' => "jose\u{0301}@example.com" ), + array( 'label' => 'devanagari-local', 'input' => "\u{0928}\u{092E}\u{0938}\u{094D}\u{0924}\u{0947}@example.com", 'expected' => "\u{0928}\u{092E}\u{0938}\u{094D}\u{0924}\u{0947}@example.com" ), + array( 'label' => 'arabic-local', 'input' => "\u{0645}\u{0633}\u{062A}\u{062E}\u{062F}\u{0645}@example.com", 'expected' => "\u{0645}\u{0633}\u{062A}\u{062E}\u{062F}\u{0645}@example.com" ), + array( 'label' => 'greek-local', 'input' => "\u{03B4}\u{03BF}\u{03BA}\u{03B9}\u{03BC}\u{03AE}@example.com", 'expected' => "\u{03B4}\u{03BF}\u{03BA}\u{03B9}\u{03BC}\u{03AE}@example.com" ), + array( 'label' => 'cyrillic-local', 'input' => "\u{043F}\u{043E}\u{0447}\u{0442}\u{0430}@example.com", 'expected' => "\u{043F}\u{043E}\u{0447}\u{0442}\u{0430}@example.com" ), + array( 'label' => 'hiragana-local', 'input' => "\u{3086}\u{3046}\u{3056}\u{3042}@example.com", 'expected' => "\u{3086}\u{3046}\u{3056}\u{3042}@example.com" ), + ); + if ( self::has_idn() ) { + $valid[] = array( 'label' => 'arabic-address', 'input' => "\u{0645}\u{0633}\u{062A}\u{062E}\u{062F}\u{0645}@\u{0645}\u{062B}\u{0627}\u{0644}.\u{0625}\u{062E}\u{062A}\u{0628}\u{0627}\u{0631}", 'expected' => "\u{0645}\u{0633}\u{062A}\u{062E}\u{062F}\u{0645}@\u{0645}\u{062B}\u{0627}\u{0644}.\u{0625}\u{062E}\u{062A}\u{0628}\u{0627}\u{0631}" ); + $valid[] = array( 'label' => 'cjk-address', 'input' => "\u{7528}\u{6237}@\u{4F8B}\u{5B50}.\u{5E7F}\u{544A}", 'expected' => "\u{7528}\u{6237}@\u{4F8B}\u{5B50}.\u{5E7F}\u{544A}" ); + $valid[] = array( 'label' => 'greek-address', 'input' => "\u{03B4}\u{03BF}\u{03BA}\u{03B9}\u{03BC}\u{03AE}@\u{03C0}\u{03B1}\u{03C1}\u{03AC}\u{03B4}\u{03B5}\u{03B9}\u{03B3}\u{03BC}\u{03B1}.\u{03B4}\u{03BF}\u{03BA}\u{03B9}\u{03BC}\u{03AE}", 'expected' => "\u{03B4}\u{03BF}\u{03BA}\u{03B9}\u{03BC}\u{03AE}@\u{03C0}\u{03B1}\u{03C1}\u{03AC}\u{03B4}\u{03B5}\u{03B9}\u{03B3}\u{03BC}\u{03B1}.\u{03B4}\u{03BF}\u{03BA}\u{03B9}\u{03BC}\u{03AE}" ); + $valid[] = array( 'label' => 'cyrillic-address', 'input' => "\u{043F}\u{043E}\u{0447}\u{0442}\u{0430}@\u{043F}\u{0440}\u{0438}\u{043C}\u{0435}\u{0440}.\u{0438}\u{0441}\u{043F}\u{044B}\u{0442}\u{0430}\u{043D}\u{0438}\u{0435}", 'expected' => "\u{043F}\u{043E}\u{0447}\u{0442}\u{0430}@\u{043F}\u{0440}\u{0438}\u{043C}\u{0435}\u{0440}.\u{0438}\u{0441}\u{043F}\u{044B}\u{0442}\u{0430}\u{043D}\u{0438}\u{0435}" ); + $valid[] = array( 'label' => 'hiragana-address', 'input' => "\u{3086}\u{3046}\u{3056}\u{3042}@\u{308C}\u{3044}.\u{307F}\u{3093}\u{306A}", 'expected' => "\u{3086}\u{3046}\u{3056}\u{3042}@\u{308C}\u{3044}.\u{307F}\u{3093}\u{306A}" ); + } + $invalid = array( + array( 'label' => 'quoted-rfc5322-local', 'input' => '"quoted"@example.com' ), + array( 'label' => 'comment-local', 'input' => 'user(comment)@example.com' ), + array( 'label' => 'domain-literal', 'input' => 'user@[127.0.0.1]' ), + array( 'label' => 'domain-underscore', 'input' => 'user@example_corp.com' ), + array( 'label' => 'leading-domain-hyphen', 'input' => 'user@-example.com' ), + array( 'label' => 'trailing-domain-hyphen', 'input' => 'user@example-.com' ), + array( 'label' => 'leading-combining-local', 'input' => "\u{0301}bad@example.com" ), + array( 'label' => 'leading-combining-domain', 'input' => "user@\u{0301}bad.example" ), + array( 'label' => 'fullwidth-at-separator', 'input' => "user\u{FF20}example.com" ), + array( 'label' => 'ideographic-dot-separator', 'input' => "user@example\u{3002}com" ), + array( 'label' => 'zero-width-local', 'input' => "zero\u{200D}width@example.com" ), + array( 'label' => 'line-break-local', 'input' => "line\nbreak@example.com" ), + ); + $failures = array(); + + foreach ( $valid as $case ) { + $parsed = self::call( static fn() => \WP_Email_Address::from_string( $case['input'], 'unicode' ) ); + $is_email = self::call( static fn() => \is_email( $case['input'] ) ); + $sanitized = self::call( static fn() => \sanitize_email( $case['input'] ) ); + $expected = $case['expected']; + + if ( + $parsed['threw'] || + $is_email['threw'] || + $sanitized['threw'] || + ! ( $parsed['value'] instanceof \WP_Email_Address ) || + $expected !== $parsed['value']->get_unicode_address() || + $expected !== $is_email['value'] || + $expected !== $sanitized['value'] + ) { + $failures[] = array( + 'label' => $case['label'], + 'input' => self::describe_string( $case['input'] ), + 'expected' => self::describe_string( $expected ), + 'expectedValid' => true, + 'parsed' => self::describe_call( $parsed ), + 'isEmail' => self::describe_call( $is_email ), + 'sanitizeEmail' => self::describe_call( $sanitized ), + ); + } + } + + foreach ( $invalid as $case ) { + $parsed = self::call( static fn() => \WP_Email_Address::from_string( $case['input'], 'unicode' ) ); + $is_email = self::call( static fn() => \is_email( $case['input'] ) ); + $sanitized = self::call( static fn() => \sanitize_email( $case['input'] ) ); + + if ( + $parsed['threw'] || + $is_email['threw'] || + $sanitized['threw'] || + null !== $parsed['value'] || + false !== $is_email['value'] || + '' !== $sanitized['value'] + ) { + $failures[] = array( + 'label' => $case['label'], + 'input' => self::describe_string( $case['input'] ), + 'expectedValid' => false, + 'parsed' => self::describe_call( $parsed ), + 'isEmail' => self::describe_call( $is_email ), + 'sanitizeEmail' => self::describe_call( $sanitized ), + ); + } + } + + return array( + $ctx->result( + 'email.whatwg-style.examples', + array() === $failures, + array( + 'validCount' => count( $valid ), + 'invalidCount' => count( $invalid ), + 'failures' => $failures, + ) + ), + ); + } + + private static function check_whatwg_ascii_oracle( \ComponentFuzz\FuzzContext $ctx ): array { + $cases = array( + array( 'label' => 'single-label-domain', 'input' => 'a@b' ), + array( 'label' => 'single-label-domain-long-local', 'input' => 'first.last@example' ), + array( 'label' => 'consecutive-local-dots', 'input' => 'first..last@example.com' ), + array( 'label' => 'leading-local-dot', 'input' => '.start@example.com' ), + array( 'label' => 'trailing-local-dot', 'input' => 'end.@example.com' ), + array( 'label' => 'subdomain-hyphen', 'input' => 'user@sub-domain.example' ), + array( 'label' => 'quoted-local', 'input' => '"quoted"@example.com' ), + array( 'label' => 'comment-local', 'input' => 'user(comment)@example.com' ), + array( 'label' => 'domain-underscore', 'input' => 'user@example_corp.com' ), + array( 'label' => 'leading-domain-hyphen', 'input' => 'user@-example.com' ), + array( 'label' => 'trailing-domain-hyphen', 'input' => 'user@example-.com' ), + array( 'label' => 'domain-literal', 'input' => 'user@[127.0.0.1]' ), + array( 'label' => 'line-break-local', 'input' => "line\nbreak@example.com" ), + ); + $failures = array(); + + foreach ( $cases as $case ) { + $expected_valid = 1 === preg_match( self::WHATWG_ASCII_EMAIL_REGEX, $case['input'] ); + $parsed = self::call( static fn() => \WP_Email_Address::from_string( $case['input'], 'unicode' ) ); + $is_email = self::call( static fn() => \is_email( $case['input'] ) ); + $sanitized = self::call( static fn() => \sanitize_email( $case['input'] ) ); + $expected_is_email = $expected_valid ? $case['input'] : false; + $expected_sanitized = $expected_valid ? $case['input'] : ''; + $actual_valid = $parsed['value'] instanceof \WP_Email_Address; + + if ( + $parsed['threw'] || + $is_email['threw'] || + $sanitized['threw'] || + $expected_valid !== $actual_valid || + $expected_is_email !== $is_email['value'] || + $expected_sanitized !== $sanitized['value'] + ) { + $failures[] = array( + 'label' => $case['label'], + 'input' => self::describe_string( $case['input'] ), + 'expectedValid' => $expected_valid, + 'actualValid' => $actual_valid, + 'parsed' => self::describe_call( $parsed ), + 'isEmail' => self::describe_call( $is_email ), + 'sanitizeEmail' => self::describe_call( $sanitized ), + 'whatwgAsciiRegex' => self::WHATWG_ASCII_EMAIL_REGEX, + ); + } + } + + return array( + $ctx->result( + 'email.whatwg-ascii.oracle-agreement', + array() === $failures, + array( + 'caseCount' => count( $cases ), + 'failures' => $failures, + ) + ), + ); + } + + private static function check_sanitizer_recovery( \ComponentFuzz\FuzzContext $ctx ): array { + $cases = array( + array( + 'label' => 'separator-whitespace', + 'input' => " info @ example . com. \t", + 'expected' => 'info@example.com', + ), + array( + 'label' => 'display-name-ascii', + 'input' => 'Display Name ', + 'expected' => 'user@example.com', + ), + array( + 'label' => 'display-name-unicode', + 'input' => "Display Name ", + 'expected' => "jos\u{00E9}@gr\u{00E5}.org", + ), + array( + 'label' => 'quoted-display-name-unicode', + 'input' => "\"\u{00C5}sa Example\" ", + 'expected' => "gr\u{00E5}@example.com", + ), + array( + 'label' => 'nbsp-around-separators', + 'input' => "user\u{00A0}@\u{00A0}example.com", + 'expected' => 'user@example.com', + ), + array( + 'label' => 'soft-hyphen-before-dot', + 'input' => "info@example\u{00AD}.com", + 'expected' => 'info@example.com', + ), + ); + + if ( self::has_idn() ) { + $cases[] = array( + 'label' => 'punycode-with-separators', + 'input' => 'books @ xn--bcher-kva . de.', + 'expected' => "books@b\u{00FC}cher.de", + ); + } + + $failures = array(); + foreach ( $cases as $case ) { + $raw_is_email = self::call( static fn() => \is_email( $case['input'] ) ); + $sanitized = self::call( static fn() => \sanitize_email( $case['input'] ) ); + $again = self::call( static fn() => \sanitize_email( $case['expected'] ) ); + $expected_is = self::call( static fn() => \is_email( $case['expected'] ) ); + $parsed = self::call( static fn() => \WP_Email_Address::from_string( $case['expected'], 'unicode' ) ); + + if ( + $raw_is_email['threw'] || + $sanitized['threw'] || + $again['threw'] || + $expected_is['threw'] || + $parsed['threw'] || + $case['expected'] !== $sanitized['value'] || + $case['expected'] !== $again['value'] || + $case['expected'] !== $expected_is['value'] || + ! ( $parsed['value'] instanceof \WP_Email_Address ) + ) { + $failures[] = array( + 'label' => $case['label'], + 'input' => self::describe_string( $case['input'] ), + 'expected' => self::describe_string( $case['expected'] ), + 'rawIsEmail' => self::describe_call( $raw_is_email ), + 'sanitizeEmail' => self::describe_call( $sanitized ), + 'sanitizeAgain' => self::describe_call( $again ), + 'expectedIsEmail' => self::describe_call( $expected_is ), + 'parsedExpected' => self::describe_call( $parsed ), + ); + } + } + + return array( + $ctx->result( + 'email.sanitize-email.recovery-contracts', + array() === $failures, + array( + 'caseCount' => count( $cases ), + 'failures' => $failures, + ) + ), + ); + } + + private static function check_malformed_utf8_byte_matrix( \ComponentFuzz\FuzzContext $ctx ): array { + $fragments = array( + array( 'label' => 'lone-continuation', 'bytes' => "\x80" ), + array( 'label' => 'truncated-two-byte', 'bytes' => "\xC3" ), + array( 'label' => 'truncated-three-byte', 'bytes' => "\xE2\x82" ), + array( 'label' => 'truncated-four-byte', 'bytes' => "\xF0\x9F\x98" ), + array( 'label' => 'overlong-slash', 'bytes' => "\xC0\xAF" ), + array( 'label' => 'surrogate-codepoint', 'bytes' => "\xED\xA0\x80" ), + array( 'label' => 'impossible-leading-byte', 'bytes' => "\xFE" ), + ); + $failures = array(); + $observed = array(); + + foreach ( $fragments as $fragment ) { + $inputs = array( + array( + 'label' => $fragment['label'] . '-local', + 'segment' => 'local', + 'input' => 'bad' . $fragment['bytes'] . '@example.com', + ), + array( + 'label' => $fragment['label'] . '-domain-label', + 'segment' => 'domain', + 'input' => 'user@bad' . $fragment['bytes'] . '.example', + ), + array( + 'label' => $fragment['label'] . '-domain-suffix', + 'segment' => 'domain', + 'input' => 'user@example.' . $fragment['bytes'], + ), + ); + + foreach ( $inputs as $case ) { + $is_valid_utf8 = self::call( static fn() => \wp_is_valid_utf8( $case['input'] ) ); + $is_email = self::call( static fn() => \is_email( $case['input'] ) ); + $sanitized = self::call( static fn() => \sanitize_email( $case['input'] ) ); + $unicode_parse = self::call( static fn() => \WP_Email_Address::from_string( $case['input'], 'unicode' ) ); + $ascii_parse = self::call( static fn() => \WP_Email_Address::from_string( $case['input'], 'ascii' ) ); + $ok = ! $is_valid_utf8['threw'] + && ! $is_email['threw'] + && ! $sanitized['threw'] + && ! $unicode_parse['threw'] + && ! $ascii_parse['threw'] + && false === $is_valid_utf8['value'] + && false === $is_email['value'] + && '' === $sanitized['value'] + && null === $unicode_parse['value'] + && null === $ascii_parse['value']; + + if ( ! $ok ) { + $failures[] = array( + 'label' => $case['label'], + 'segment' => $case['segment'], + 'input' => self::describe_string( $case['input'] ), + 'validUtf8' => self::describe_call( $is_valid_utf8 ), + 'isEmail' => self::describe_call( $is_email ), + 'sanitizeEmail' => self::describe_call( $sanitized ), + 'unicodeParse' => self::describe_call( $unicode_parse ), + 'asciiParse' => self::describe_call( $ascii_parse ), + ); + } + + $observed[] = array( + 'label' => $case['label'], + 'segment' => $case['segment'], + 'bytes' => strlen( $case['input'] ), + 'rejected' => $ok, + ); + } + } + + return array( + $ctx->result( + 'email.invalid-utf8.byte-matrix-rejected', + array() === $failures, + array( + 'caseCount' => count( $observed ), + 'observed' => $observed, + 'failures' => $failures, + ) + ), + ); + } + + private static function check_unicode_localpart_byte_boundaries( \ComponentFuzz\FuzzContext $ctx ): array { + $cases = array( + array( + 'label' => 'latin-two-byte-local-64-bytes', + 'local' => str_repeat( "\u{00E5}", 32 ), + 'localBytes' => 64, + ), + array( + 'label' => 'latin-two-byte-local-65-bytes', + 'local' => str_repeat( "\u{00E5}", 32 ) . 'a', + 'localBytes' => 65, + ), + array( + 'label' => 'combining-local-64-bytes', + 'local' => str_repeat( "e\u{0301}", 21 ) . 'x', + 'localBytes' => 64, + ), + array( + 'label' => 'combining-local-65-bytes', + 'local' => str_repeat( "e\u{0301}", 21 ) . 'xy', + 'localBytes' => 65, + ), + array( + 'label' => 'cjk-local-63-bytes', + 'local' => str_repeat( "\u{7528}", 21 ), + 'localBytes' => 63, + ), + array( + 'label' => 'cjk-local-66-bytes', + 'local' => str_repeat( "\u{7528}", 22 ), + 'localBytes' => 66, + ), + ); + $failures = array(); + $observed = array(); + + foreach ( $cases as $case ) { + $input = $case['local'] . '@example.com'; + $parsed = self::call( static fn() => \WP_Email_Address::from_string( $input, 'unicode' ) ); + $is_email = self::call( static fn() => \is_email( $input ) ); + $sanitized = self::call( static fn() => \sanitize_email( $input ) ); + $ascii_parse = self::call( static fn() => \WP_Email_Address::from_string( $input, 'ascii' ) ); + $ascii_is = self::call( static fn() => \wp_is_ascii_email( false, $input, null ) ); + $ascii_sanitize = self::call( static fn() => \wp_sanitize_ascii_email( '', $input, null ) ); + $email = $parsed['value'] ?? null; + $ok = ! $parsed['threw'] + && ! $is_email['threw'] + && ! $sanitized['threw'] + && ! $ascii_parse['threw'] + && ! $ascii_is['threw'] + && ! $ascii_sanitize['threw'] + && $email instanceof \WP_Email_Address + && $case['localBytes'] === strlen( $case['local'] ) + && \wp_is_valid_utf8( $case['local'] ) + && $case['local'] === $email->get_localpart() + && 'example.com' === $email->get_ascii_domain() + && 'example.com' === $email->get_unicode_domain() + && $input === $email->get_ascii_address() + && $input === $email->get_unicode_address() + && $input === $is_email['value'] + && $input === $sanitized['value'] + && null === $ascii_parse['value'] + && false === $ascii_is['value'] + && '' === $ascii_sanitize['value']; + + if ( ! $ok ) { + $failures[] = array( + 'label' => $case['label'], + 'input' => self::describe_string( $input ), + 'localBytes' => strlen( $case['local'] ), + 'expectedBytes' => $case['localBytes'], + 'parsed' => self::describe_call( $parsed ), + 'isEmail' => self::describe_call( $is_email ), + 'sanitizeEmail' => self::describe_call( $sanitized ), + 'asciiParse' => self::describe_call( $ascii_parse ), + 'asciiIsEmail' => self::describe_call( $ascii_is ), + 'asciiSanitize' => self::describe_call( $ascii_sanitize ), + ); + } + + $observed[] = array( + 'label' => $case['label'], + 'localBytes' => strlen( $case['local'] ), + 'addressBytes' => strlen( $input ), + 'unicodeValid' => $email instanceof \WP_Email_Address, + 'asciiRejected' => ! $ascii_parse['threw'] && null === $ascii_parse['value'], + ); + } + + return array( + $ctx->result( + 'email.wp-email-address.unicode-localpart-byte-boundaries', + array() === $failures, + array( + 'observed' => $observed, + 'failures' => $failures, + ) + ), + ); + } + + private static function check_generated_utf8_localpart_oracle( \ComponentFuzz\FuzzContext $ctx ): array { + $cases = self::generated_utf8_localpart_oracle_cases( $ctx->fork( 'utf8-localpart-oracle' ) ); + $failures = array(); + $observed = array(); + $hook_snapshot = self::snapshot_hook_globals(); + $wpdb_snapshot = self::snapshot_wpdb_charset(); + + try { + foreach ( $cases as $case ) { + if ( null !== $case['conversionError'] ) { + $failures[] = array( + 'label' => $case['label'], + 'domain' => $case['domainLabel'], + 'error' => $case['conversionError'], + ); + continue; + } + + self::install_email_filters( 'unicode' ); + $unicode_parse = self::call( static fn() => \WP_Email_Address::from_string( $case['input'], 'unicode' ) ); + $unicode_is_email = self::call( static fn() => \is_email( $case['input'] ) ); + $unicode_sanitized = self::call( static fn() => \sanitize_email( $case['input'] ) ); + $direct_unicode_is = self::call( static fn() => \wp_is_unicode_email( false, $case['input'], null ) ); + $direct_unicode_sanitize = self::call( static fn() => \wp_sanitize_unicode_email( '', $case['input'], null ) ); + $ascii_view_parse = $case['expectedUnicodeValid'] + ? self::call( static fn() => \WP_Email_Address::from_string( $case['expectedAsciiAddress'], 'unicode' ) ) + : array( 'threw' => false, 'value' => null ); + $unicode_view_parse = $case['expectedUnicodeValid'] + ? self::call( static fn() => \WP_Email_Address::from_string( $case['expectedUnicodeAddress'], 'unicode' ) ) + : array( 'threw' => false, 'value' => null ); + + self::install_email_filters( 'ascii' ); + $ascii_parse = self::call( static fn() => \WP_Email_Address::from_string( $case['input'], 'ascii' ) ); + $ascii_is_email = self::call( static fn() => \is_email( $case['input'] ) ); + $ascii_sanitized = self::call( static fn() => \sanitize_email( $case['input'] ) ); + $direct_ascii_is = self::call( static fn() => \wp_is_ascii_email( false, $case['input'], null ) ); + $direct_ascii_sanitize = self::call( static fn() => \wp_sanitize_ascii_email( '', $case['input'], null ) ); + + $unicode_email = $unicode_parse['value'] ?? null; + $ascii_email = $ascii_parse['value'] ?? null; + $ascii_view_email = $ascii_view_parse['value'] ?? null; + $unicode_view_email = $unicode_view_parse['value'] ?? null; + $expected_views = array( + 'localpart' => $case['local'], + 'asciiDomain' => $case['asciiDomain'], + 'unicodeDomain' => $case['unicodeDomain'], + 'asciiAddress' => $case['expectedAsciiAddress'], + 'unicodeAddress' => $case['expectedUnicodeAddress'], + ); + $expected_unicode_is_email = $case['expectedUnicodeValid'] ? $case['expectedUnicodeAddress'] : false; + $expected_unicode_sanitized = $case['expectedUnicodeValid'] ? $case['expectedUnicodeAddress'] : ''; + $expected_ascii_is_email = $case['expectedAsciiValid'] ? $case['expectedUnicodeAddress'] : false; + $expected_ascii_sanitized = $case['expectedAsciiValid'] ? $case['expectedUnicodeAddress'] : ''; + + $unicode_parse_ok = ! $unicode_parse['threw'] + && ( + $case['expectedUnicodeValid'] + ? $unicode_email instanceof \WP_Email_Address + && $expected_views === self::address_raw_views( $unicode_email ) + : null === $unicode_email + ); + + $unicode_filter_ok = ! $unicode_is_email['threw'] + && ! $unicode_sanitized['threw'] + && ! $direct_unicode_is['threw'] + && ! $direct_unicode_sanitize['threw'] + && $expected_unicode_is_email === $unicode_is_email['value'] + && $expected_unicode_sanitized === $unicode_sanitized['value'] + && $expected_unicode_is_email === $direct_unicode_is['value'] + && $expected_unicode_sanitized === $direct_unicode_sanitize['value']; + + $ascii_mode_ok = ! $ascii_parse['threw'] + && ! $ascii_is_email['threw'] + && ! $ascii_sanitized['threw'] + && ! $direct_ascii_is['threw'] + && ! $direct_ascii_sanitize['threw'] + && ( + $case['expectedAsciiValid'] + ? $ascii_email instanceof \WP_Email_Address + && $expected_views === self::address_raw_views( $ascii_email ) + : null === $ascii_email + ) + && $expected_ascii_is_email === $ascii_is_email['value'] + && $expected_ascii_sanitized === $ascii_sanitized['value'] + && $expected_ascii_is_email === $direct_ascii_is['value'] + && $expected_ascii_sanitized === $direct_ascii_sanitize['value']; + + $view_roundtrip_ok = ! $case['expectedUnicodeValid'] || ( + ! $ascii_view_parse['threw'] + && ! $unicode_view_parse['threw'] + && $ascii_view_email instanceof \WP_Email_Address + && $unicode_view_email instanceof \WP_Email_Address + && $expected_views === self::address_raw_views( $ascii_view_email ) + && $expected_views === self::address_raw_views( $unicode_view_email ) + && $unicode_email instanceof \WP_Email_Address + && self::address_round_trip_ok( $unicode_email ) + ); + + $ok = $unicode_parse_ok + && $unicode_filter_ok + && $ascii_mode_ok + && $view_roundtrip_ok; + + if ( ! $ok ) { + $failures[] = array( + 'label' => $case['label'], + 'input' => self::describe_string( $case['input'] ), + 'local' => self::describe_string( $case['local'] ), + 'profile' => $case['profile'], + 'domainLabel' => $case['domainLabel'], + 'inputDomainView' => $case['inputDomainView'], + 'expectedUnicodeValid' => $case['expectedUnicodeValid'], + 'expectedAsciiValid' => $case['expectedAsciiValid'], + 'expectedViews' => self::describe_value( $expected_views ), + 'unicodeParse' => self::describe_call( $unicode_parse ), + 'unicodeIsEmail' => self::describe_call( $unicode_is_email ), + 'unicodeSanitized' => self::describe_call( $unicode_sanitized ), + 'directUnicodeIs' => self::describe_call( $direct_unicode_is ), + 'directUnicodeSanitize' => self::describe_call( $direct_unicode_sanitize ), + 'asciiParse' => self::describe_call( $ascii_parse ), + 'asciiIsEmail' => self::describe_call( $ascii_is_email ), + 'asciiSanitized' => self::describe_call( $ascii_sanitized ), + 'directAsciiIs' => self::describe_call( $direct_ascii_is ), + 'directAsciiSanitize' => self::describe_call( $direct_ascii_sanitize ), + 'asciiViewParse' => self::describe_call( $ascii_view_parse ), + 'unicodeViewParse' => self::describe_call( $unicode_view_parse ), + 'unicodeParseOk' => $unicode_parse_ok, + 'unicodeFilterOk' => $unicode_filter_ok, + 'asciiModeOk' => $ascii_mode_ok, + 'viewRoundtripOk' => $view_roundtrip_ok, + ); + } + + $observed[] = array( + 'label' => $case['label'], + 'profile' => $case['profile'], + 'domainLabel' => $case['domainLabel'], + 'inputDomainView' => $case['inputDomainView'], + 'localUtf8' => ! self::is_ascii( $case['local'] ), + 'localValidUtf8' => \wp_is_valid_utf8( $case['local'] ), + 'expectedUnicodeValid' => $case['expectedUnicodeValid'], + 'expectedAsciiValid' => $case['expectedAsciiValid'], + 'acceptedUnicode' => $unicode_email instanceof \WP_Email_Address, + 'acceptedAscii' => $ascii_email instanceof \WP_Email_Address, + ); + } + } finally { + self::restore_hook_globals( $hook_snapshot ); + self::restore_wpdb_charset( $wpdb_snapshot ); + } + + return array( + $ctx->result( + 'email.generated-utf8-localpart.oracle-agreement', + array() === $failures, + array( + 'caseCount' => count( $cases ), + 'observed' => $observed, + 'failures' => $failures, + ) + ), + ); + } + + private static function check_generated_utf8_address_model_invariants( \ComponentFuzz\FuzzContext $ctx ): array { + if ( ! self::has_idn() ) { + return array( + $ctx->skip( + 'email.generated-utf8-address-model.invariants', + 'idn_to_ascii() or idn_to_utf8() is unavailable.' + ), + ); + } + + $cases = self::generated_utf8_address_model_cases( $ctx->fork( 'utf8-address-model' ) ); + $failures = array(); + $observed = array(); + $hook_snapshot = self::snapshot_hook_globals(); + $wpdb_snapshot = self::snapshot_wpdb_charset(); + + try { + foreach ( $cases as $case ) { + if ( null !== $case['conversionError'] ) { + $failures[] = array( + 'label' => $case['label'], + 'domain' => $case['domainLabel'], + 'error' => $case['conversionError'], + ); + continue; + } + + $valid_utf8 = self::capture_warnings( static fn() => \wp_is_valid_utf8( $case['input'] ) ); + + self::install_email_filters( 'unicode' ); + $unicode_parse = self::capture_warnings( static fn() => \WP_Email_Address::from_string( $case['input'], 'unicode' ) ); + $unicode_is_email = self::capture_warnings( static fn() => \is_email( $case['input'] ) ); + $unicode_sanitized = self::capture_warnings( static fn() => \sanitize_email( $case['input'] ) ); + $direct_unicode_is = self::capture_warnings( static fn() => \wp_is_unicode_email( false, $case['input'], null ) ); + $direct_unicode_sanitize = self::capture_warnings( static fn() => \wp_sanitize_unicode_email( '', $case['input'], null ) ); + + $unicode_email = $unicode_parse['value'] ?? null; + $ascii_view_parse = $unicode_email instanceof \WP_Email_Address + ? self::capture_warnings( static fn() => \WP_Email_Address::from_string( $unicode_email->get_ascii_address(), 'unicode' ) ) + : self::not_called(); + $unicode_view_parse = $unicode_email instanceof \WP_Email_Address + ? self::capture_warnings( static fn() => \WP_Email_Address::from_string( $unicode_email->get_unicode_address(), 'unicode' ) ) + : self::not_called(); + + self::install_email_filters( 'ascii' ); + $ascii_parse = self::capture_warnings( static fn() => \WP_Email_Address::from_string( $case['input'], 'ascii' ) ); + $ascii_is_email = self::capture_warnings( static fn() => \is_email( $case['input'] ) ); + $ascii_sanitized = self::capture_warnings( static fn() => \sanitize_email( $case['input'] ) ); + $direct_ascii_is = self::capture_warnings( static fn() => \wp_is_ascii_email( false, $case['input'], null ) ); + $direct_ascii_sanitize = self::capture_warnings( static fn() => \wp_sanitize_ascii_email( '', $case['input'], null ) ); + + $ascii_email = $ascii_parse['value'] ?? null; + $ascii_view_email = $ascii_view_parse['value'] ?? null; + $unicode_view_email = $unicode_view_parse['value'] ?? null; + $expected_views = array( + 'localpart' => $case['local'], + 'asciiDomain' => $case['asciiDomain'], + 'unicodeDomain' => $case['unicodeDomain'], + 'asciiAddress' => $case['expectedAsciiAddress'], + 'unicodeAddress' => $case['expectedUnicodeAddress'], + ); + $expected_unicode_is_email = $case['expectedUnicodeValid'] ? $case['expectedUnicodeAddress'] : false; + $expected_unicode_sanitized = $case['expectedUnicodeValid'] ? $case['expectedUnicodeAddress'] : ''; + $expected_ascii_is_email = $case['expectedAsciiValid'] ? $case['expectedUnicodeAddress'] : false; + $expected_ascii_sanitized = $case['expectedAsciiValid'] ? $case['expectedUnicodeAddress'] : ''; + $captured_calls = array( + 'validUtf8' => $valid_utf8, + 'unicodeParse' => $unicode_parse, + 'unicodeIsEmail' => $unicode_is_email, + 'unicodeSanitized' => $unicode_sanitized, + 'directUnicodeIs' => $direct_unicode_is, + 'directUnicodeSanitize' => $direct_unicode_sanitize, + 'asciiViewParse' => $ascii_view_parse, + 'unicodeViewParse' => $unicode_view_parse, + 'asciiParse' => $ascii_parse, + 'asciiIsEmail' => $ascii_is_email, + 'asciiSanitized' => $ascii_sanitized, + 'directAsciiIs' => $direct_ascii_is, + 'directAsciiSanitize' => $direct_ascii_sanitize, + ); + + $calls_clean = true; + foreach ( $captured_calls as $call ) { + $calls_clean = $calls_clean + && ! $call['threw'] + && array() === ( $call['warnings'] ?? array() ); + } + + $utf8_ok = ! $valid_utf8['threw'] + && array() === $valid_utf8['warnings'] + && ( $case['malformedUtf8'] ? false === $valid_utf8['value'] : true === $valid_utf8['value'] ); + $unicode_parse_ok = $case['expectedUnicodeValid'] + ? ( + $unicode_email instanceof \WP_Email_Address + && $expected_views === self::address_raw_views( $unicode_email ) + && self::address_parts_ok( $unicode_email ) + ) + : null === $unicode_email; + $roundtrip_ok = ! $case['expectedUnicodeValid'] || ( + $ascii_view_email instanceof \WP_Email_Address + && $unicode_view_email instanceof \WP_Email_Address + && $expected_views === self::address_raw_views( $ascii_view_email ) + && $expected_views === self::address_raw_views( $unicode_view_email ) + && self::address_raw_views( $ascii_view_email ) === self::address_raw_views( $unicode_view_email ) + ); + $unicode_filter_ok = ! $unicode_is_email['threw'] + && ! $unicode_sanitized['threw'] + && ! $direct_unicode_is['threw'] + && ! $direct_unicode_sanitize['threw'] + && $expected_unicode_is_email === $unicode_is_email['value'] + && $expected_unicode_sanitized === $unicode_sanitized['value'] + && $expected_unicode_is_email === $direct_unicode_is['value'] + && $expected_unicode_sanitized === $direct_unicode_sanitize['value']; + $ascii_mode_ok = ! $ascii_parse['threw'] + && ! $ascii_is_email['threw'] + && ! $ascii_sanitized['threw'] + && ! $direct_ascii_is['threw'] + && ! $direct_ascii_sanitize['threw'] + && ( + $case['expectedAsciiValid'] + ? $ascii_email instanceof \WP_Email_Address + && $expected_views === self::address_raw_views( $ascii_email ) + : null === $ascii_email + ) + && $expected_ascii_is_email === $ascii_is_email['value'] + && $expected_ascii_sanitized === $ascii_sanitized['value'] + && $expected_ascii_is_email === $direct_ascii_is['value'] + && $expected_ascii_sanitized === $direct_ascii_sanitize['value']; + $ok = $calls_clean + && $utf8_ok + && $unicode_parse_ok + && $roundtrip_ok + && $unicode_filter_ok + && $ascii_mode_ok; + + if ( ! $ok ) { + $failures[] = array( + 'label' => $case['label'], + 'source' => $case['source'], + 'reason' => $case['reason'], + 'input' => self::describe_string( $case['input'] ), + 'inputDomainView' => $case['inputDomainView'], + 'expectedUnicodeValid' => $case['expectedUnicodeValid'], + 'expectedAsciiValid' => $case['expectedAsciiValid'], + 'expectedViews' => self::describe_value( $expected_views ), + 'validUtf8' => self::describe_captured_call( $valid_utf8 ), + 'unicodeParse' => self::describe_captured_call( $unicode_parse ), + 'unicodeIsEmail' => self::describe_captured_call( $unicode_is_email ), + 'unicodeSanitized' => self::describe_captured_call( $unicode_sanitized ), + 'directUnicodeIs' => self::describe_captured_call( $direct_unicode_is ), + 'directUnicodeSanitize' => self::describe_captured_call( $direct_unicode_sanitize ), + 'asciiViewParse' => self::describe_captured_call( $ascii_view_parse ), + 'unicodeViewParse' => self::describe_captured_call( $unicode_view_parse ), + 'asciiParse' => self::describe_captured_call( $ascii_parse ), + 'asciiIsEmail' => self::describe_captured_call( $ascii_is_email ), + 'asciiSanitized' => self::describe_captured_call( $ascii_sanitized ), + 'directAsciiIs' => self::describe_captured_call( $direct_ascii_is ), + 'directAsciiSanitize' => self::describe_captured_call( $direct_ascii_sanitize ), + 'callsClean' => $calls_clean, + 'utf8Ok' => $utf8_ok, + 'unicodeParseOk' => $unicode_parse_ok, + 'roundtripOk' => $roundtrip_ok, + 'unicodeFilterOk' => $unicode_filter_ok, + 'asciiModeOk' => $ascii_mode_ok, + ); + } + + $warning_count = 0; + foreach ( $captured_calls as $call ) { + $warning_count += count( $call['warnings'] ?? array() ); + } + + $observed[] = array( + 'label' => $case['label'], + 'source' => $case['source'], + 'reason' => $case['reason'], + 'inputDomainView' => $case['inputDomainView'], + 'hasUnicodeLocal' => $case['hasUnicodeLocal'], + 'hasUnicodeDomain' => $case['hasUnicodeDomain'], + 'malformedUtf8' => $case['malformedUtf8'], + 'expectedUnicodeValid' => $case['expectedUnicodeValid'], + 'expectedAsciiValid' => $case['expectedAsciiValid'], + 'unicodeAccepted' => $unicode_email instanceof \WP_Email_Address, + 'asciiAccepted' => $ascii_email instanceof \WP_Email_Address, + 'warnings' => $warning_count, + ); + } + } finally { + self::restore_hook_globals( $hook_snapshot ); + self::restore_wpdb_charset( $wpdb_snapshot ); + } + + return array( + $ctx->result( + 'email.generated-utf8-address-model.invariants', + array() === $failures, + array( + 'caseCount' => count( $cases ), + 'observed' => $observed, + 'failures' => $failures, + ) + ), + ); + } + + private static function check_construction_mode_consistency( \ComponentFuzz\FuzzContext $ctx ): array { + $base_cases = array( + array( + 'label' => 'ascii-plus-subdomain', + 'input' => 'USER+tag@example.co.uk', + 'localpart' => 'USER+tag', + 'asciiDomain' => 'example.co.uk', + 'unicodeDomain' => 'example.co.uk', + 'asciiModeValid' => true, + ), + array( + 'label' => 'whatwg-single-label-domain', + 'input' => 'a@b', + 'localpart' => 'a', + 'asciiDomain' => 'b', + 'unicodeDomain' => 'b', + 'asciiModeValid' => true, + ), + array( + 'label' => 'whatwg-local-dots', + 'input' => 'first..last@example.com', + 'localpart' => 'first..last', + 'asciiDomain' => 'example.com', + 'unicodeDomain' => 'example.com', + 'asciiModeValid' => true, + ), + array( + 'label' => 'unicode-local-ascii-domain', + 'input' => "gr\u{00E5}@example.com", + 'localpart' => "gr\u{00E5}", + 'asciiDomain' => 'example.com', + 'unicodeDomain' => 'example.com', + 'asciiModeValid' => false, + ), + ); + + $base_result = self::evaluate_construction_mode_cases( $base_cases ); + $rows = array( + $ctx->result( + 'email.wp-email-address.construction-mode-consistency', + array() === $base_result['failures'], + $base_result + ), + ); + + if ( ! self::has_idn() ) { + $rows[] = $ctx->skip( + 'email.wp-email-address.idn-construction-mode-consistency', + 'idn_to_ascii() or idn_to_utf8() is unavailable.' + ); + + return $rows; + } + + $idn_cases = array( + array( + 'label' => 'punycode-domain', + 'input' => 'books@xn--bcher-kva.de', + 'localpart' => 'books', + 'asciiDomain' => 'xn--bcher-kva.de', + 'unicodeDomain' => "b\u{00FC}cher.de", + 'asciiModeValid' => false, + ), + array( + 'label' => 'unicode-domain', + 'input' => "books@b\u{00FC}cher.de", + 'localpart' => 'books', + 'asciiDomain' => 'xn--bcher-kva.de', + 'unicodeDomain' => "b\u{00FC}cher.de", + 'asciiModeValid' => false, + ), + array( + 'label' => 'unicode-local-punycode-domain', + 'input' => "jose\u{0301}@xn--bcher-kva.de", + 'localpart' => "jose\u{0301}", + 'asciiDomain' => 'xn--bcher-kva.de', + 'unicodeDomain' => "b\u{00FC}cher.de", + 'asciiModeValid' => false, + ), + ); + $idn_result = self::evaluate_construction_mode_cases( $idn_cases ); + + $rows[] = $ctx->result( + 'email.wp-email-address.idn-construction-mode-consistency', + array() === $idn_result['failures'], + $idn_result + ); + + return $rows; + } + + private static function evaluate_construction_mode_cases( array $cases ): array { + $failures = array(); + $observed = array(); + + foreach ( $cases as $case ) { + $unicode_parse = self::call( static fn() => \WP_Email_Address::from_string( $case['input'], 'unicode' ) ); + $ascii_parse = self::call( static fn() => \WP_Email_Address::from_string( $case['input'], 'ascii' ) ); + $unicode_email = $unicode_parse['value'] ?? null; + $ascii_email = $ascii_parse['value'] ?? null; + $expected = array( + 'localpart' => $case['localpart'], + 'asciiDomain' => $case['asciiDomain'], + 'unicodeDomain' => $case['unicodeDomain'], + 'asciiAddress' => $case['localpart'] . '@' . $case['asciiDomain'], + 'unicodeAddress' => $case['localpart'] . '@' . $case['unicodeDomain'], + ); + $unicode_views = $unicode_email instanceof \WP_Email_Address + ? self::address_raw_views( $unicode_email ) + : null; + $ascii_views = $ascii_email instanceof \WP_Email_Address + ? self::address_raw_views( $ascii_email ) + : null; + + $unicode_ok = ! $unicode_parse['threw'] + && $unicode_email instanceof \WP_Email_Address + && $expected === $unicode_views; + $ascii_ok = $case['asciiModeValid'] + ? ( + ! $ascii_parse['threw'] + && $ascii_email instanceof \WP_Email_Address + && $expected === $ascii_views + && $unicode_views === $ascii_views + ) + : ! $ascii_parse['threw'] && null === $ascii_email; + $ok = $unicode_ok && $ascii_ok; + + if ( ! $ok ) { + $failures[] = array( + 'label' => $case['label'], + 'input' => self::describe_string( $case['input'] ), + 'expected' => self::describe_value( $expected ), + 'unicodeParse' => self::describe_call( $unicode_parse ), + 'asciiParse' => self::describe_call( $ascii_parse ), + 'unicodeViews' => self::describe_value( $unicode_views ), + 'asciiViews' => self::describe_value( $ascii_views ), + ); + } + + $observed[] = array( + 'label' => $case['label'], + 'input' => self::describe_string( $case['input'] ), + 'asciiModeValid' => $case['asciiModeValid'], + 'unicodeViews' => self::describe_value( $unicode_views ), + 'asciiAccepted' => $ascii_email instanceof \WP_Email_Address, + ); + } + + return array( + 'observed' => $observed, + 'failures' => $failures, + ); + } + + private static function check_generated_unicode_filter_view_matrix( \ComponentFuzz\FuzzContext $ctx ): array { + if ( ! self::has_idn() ) { + return array( + $ctx->skip( + 'email.generated-unicode.filter-view-matrix', + 'idn_to_ascii() or idn_to_utf8() is unavailable.' + ), + ); + } + + $cases = self::generated_unicode_filter_view_cases( $ctx->fork( 'unicode-filter-view-matrix' ) ); + $failures = array(); + $observed = array(); + $hook_snapshot = self::snapshot_hook_globals(); + $wpdb_snapshot = self::snapshot_wpdb_charset(); + + try { + foreach ( $cases as $case ) { + if ( null !== $case['conversionError'] ) { + $failures[] = array( + 'label' => $case['label'], + 'domain' => $case['domainLabel'], + 'error' => $case['conversionError'], + ); + continue; + } + + $input_parse = self::call( static fn() => \WP_Email_Address::from_string( $case['input'], 'unicode' ) ); + $ascii_view_parse = self::call( static fn() => \WP_Email_Address::from_string( $case['expectedAsciiAddress'], 'unicode' ) ); + $unicode_view_parse = self::call( static fn() => \WP_Email_Address::from_string( $case['expectedUnicodeAddress'], 'unicode' ) ); + $ascii_mode_parse = self::call( static fn() => \WP_Email_Address::from_string( $case['input'], 'ascii' ) ); + + self::install_email_filters( 'unicode' ); + $unicode_is_email = self::call( static fn() => \is_email( $case['input'] ) ); + $unicode_sanitized = self::call( static fn() => \sanitize_email( $case['input'] ) ); + $unicode_filter_state = array( + 'isUnicode' => \has_filter( 'is_email', 'wp_is_unicode_email' ), + 'sanitizeUnicode' => \has_filter( 'sanitize_email', 'wp_sanitize_unicode_email' ), + 'isAscii' => \has_filter( 'is_email', 'wp_is_ascii_email' ), + 'sanitizeAscii' => \has_filter( 'sanitize_email', 'wp_sanitize_ascii_email' ), + ); + + self::install_email_filters( 'ascii' ); + $ascii_is_email = self::call( static fn() => \is_email( $case['input'] ) ); + $ascii_sanitized = self::call( static fn() => \sanitize_email( $case['input'] ) ); + $ascii_filter_state = array( + 'isUnicode' => \has_filter( 'is_email', 'wp_is_unicode_email' ), + 'sanitizeUnicode' => \has_filter( 'sanitize_email', 'wp_sanitize_unicode_email' ), + 'isAscii' => \has_filter( 'is_email', 'wp_is_ascii_email' ), + 'sanitizeAscii' => \has_filter( 'sanitize_email', 'wp_sanitize_ascii_email' ), + ); + + self::restore_hook_globals( $hook_snapshot ); + self::restore_wpdb_charset( $wpdb_snapshot ); + \remove_all_filters( 'is_email' ); + \remove_all_filters( 'sanitize_email' ); + self::install_email_filters_from_default_filters( 'utf8mb4' ); + $default_utf8mb4_is_email = self::call( static fn() => \is_email( $case['input'] ) ); + $default_utf8mb4_sanitized = self::call( static fn() => \sanitize_email( $case['input'] ) ); + $default_utf8mb4_filter_state = array( + 'isUnicode' => \has_filter( 'is_email', 'wp_is_unicode_email' ), + 'sanitizeUnicode' => \has_filter( 'sanitize_email', 'wp_sanitize_unicode_email' ), + 'isAscii' => \has_filter( 'is_email', 'wp_is_ascii_email' ), + 'sanitizeAscii' => \has_filter( 'sanitize_email', 'wp_sanitize_ascii_email' ), + ); + + self::restore_hook_globals( $hook_snapshot ); + self::restore_wpdb_charset( $wpdb_snapshot ); + \remove_all_filters( 'is_email' ); + \remove_all_filters( 'sanitize_email' ); + self::install_email_filters_from_default_filters( 'latin1' ); + $default_ascii_is_email = self::call( static fn() => \is_email( $case['input'] ) ); + $default_ascii_sanitized = self::call( static fn() => \sanitize_email( $case['input'] ) ); + $default_ascii_filter_state = array( + 'isUnicode' => \has_filter( 'is_email', 'wp_is_unicode_email' ), + 'sanitizeUnicode' => \has_filter( 'sanitize_email', 'wp_sanitize_unicode_email' ), + 'isAscii' => \has_filter( 'is_email', 'wp_is_ascii_email' ), + 'sanitizeAscii' => \has_filter( 'sanitize_email', 'wp_sanitize_ascii_email' ), + ); + + self::restore_hook_globals( $hook_snapshot ); + self::restore_wpdb_charset( $wpdb_snapshot ); + + $input_email = $input_parse['value'] ?? null; + $ascii_view_email = $ascii_view_parse['value'] ?? null; + $unicode_view_email = $unicode_view_parse['value'] ?? null; + $ascii_mode_email = $ascii_mode_parse['value'] ?? null; + $expected_views = array( + 'localpart' => $case['local'], + 'asciiDomain' => $case['asciiDomain'], + 'unicodeDomain' => $case['unicodeDomain'], + 'asciiAddress' => $case['expectedAsciiAddress'], + 'unicodeAddress' => $case['expectedUnicodeAddress'], + ); + $ascii_mode_expected_is_email = $case['asciiModeValid'] ? $case['expectedUnicodeAddress'] : false; + $ascii_mode_expected_sanitized = $case['asciiModeValid'] ? $case['expectedUnicodeAddress'] : ''; + + $parse_ok = ! $input_parse['threw'] + && ! $ascii_view_parse['threw'] + && ! $unicode_view_parse['threw'] + && $input_email instanceof \WP_Email_Address + && $ascii_view_email instanceof \WP_Email_Address + && $unicode_view_email instanceof \WP_Email_Address + && $expected_views === self::address_raw_views( $input_email ) + && $expected_views === self::address_raw_views( $ascii_view_email ) + && $expected_views === self::address_raw_views( $unicode_view_email ); + + $ascii_parse_ok = ! $ascii_mode_parse['threw'] + && ( + $case['asciiModeValid'] + ? $ascii_mode_email instanceof \WP_Email_Address + && $expected_views === self::address_raw_views( $ascii_mode_email ) + : null === $ascii_mode_email + ); + + $unicode_filter_ok = ! $unicode_is_email['threw'] + && ! $unicode_sanitized['threw'] + && $case['expectedUnicodeAddress'] === $unicode_is_email['value'] + && $case['expectedUnicodeAddress'] === $unicode_sanitized['value'] + && 10 === $unicode_filter_state['isUnicode'] + && 10 === $unicode_filter_state['sanitizeUnicode'] + && false === $unicode_filter_state['isAscii'] + && false === $unicode_filter_state['sanitizeAscii']; + + $ascii_filter_ok = ! $ascii_is_email['threw'] + && ! $ascii_sanitized['threw'] + && $ascii_mode_expected_is_email === $ascii_is_email['value'] + && $ascii_mode_expected_sanitized === $ascii_sanitized['value'] + && 10 === $ascii_filter_state['isAscii'] + && 10 === $ascii_filter_state['sanitizeAscii'] + && false === $ascii_filter_state['isUnicode'] + && false === $ascii_filter_state['sanitizeUnicode']; + + $default_filter_ok = ! $default_utf8mb4_is_email['threw'] + && ! $default_utf8mb4_sanitized['threw'] + && ! $default_ascii_is_email['threw'] + && ! $default_ascii_sanitized['threw'] + && $case['expectedUnicodeAddress'] === $default_utf8mb4_is_email['value'] + && $case['expectedUnicodeAddress'] === $default_utf8mb4_sanitized['value'] + && $ascii_mode_expected_is_email === $default_ascii_is_email['value'] + && $ascii_mode_expected_sanitized === $default_ascii_sanitized['value'] + && 10 === $default_utf8mb4_filter_state['isUnicode'] + && 10 === $default_utf8mb4_filter_state['sanitizeUnicode'] + && false === $default_utf8mb4_filter_state['isAscii'] + && false === $default_utf8mb4_filter_state['sanitizeAscii'] + && 10 === $default_ascii_filter_state['isAscii'] + && 10 === $default_ascii_filter_state['sanitizeAscii'] + && false === $default_ascii_filter_state['isUnicode'] + && false === $default_ascii_filter_state['sanitizeUnicode']; + + $view_shape_ok = $input_email instanceof \WP_Email_Address + && self::is_ascii( $input_email->get_ascii_domain() ) + && self::is_ascii( $case['local'] ) === self::is_ascii( $input_email->get_ascii_address() ) + && $case['hasUnicodeDomain'] === ( $input_email->get_ascii_domain() !== $input_email->get_unicode_domain() ) + && $case['hasUnicodeLocal'] === ! self::is_ascii( $input_email->get_localpart() ); + + $ok = $parse_ok + && $ascii_parse_ok + && $unicode_filter_ok + && $ascii_filter_ok + && $default_filter_ok + && $view_shape_ok; + + if ( ! $ok ) { + $failures[] = array( + 'label' => $case['label'], + 'input' => self::describe_string( $case['input'] ), + 'inputDomainView' => $case['inputDomainView'], + 'expectedViews' => self::describe_value( $expected_views ), + 'inputParse' => self::describe_call( $input_parse ), + 'asciiViewParse' => self::describe_call( $ascii_view_parse ), + 'unicodeViewParse' => self::describe_call( $unicode_view_parse ), + 'asciiModeParse' => self::describe_call( $ascii_mode_parse ), + 'unicodeIsEmail' => self::describe_call( $unicode_is_email ), + 'unicodeSanitized' => self::describe_call( $unicode_sanitized ), + 'asciiIsEmail' => self::describe_call( $ascii_is_email ), + 'asciiSanitized' => self::describe_call( $ascii_sanitized ), + 'defaultUtf8mb4Is' => self::describe_call( $default_utf8mb4_is_email ), + 'defaultUtf8mb4San' => self::describe_call( $default_utf8mb4_sanitized ), + 'defaultAsciiIs' => self::describe_call( $default_ascii_is_email ), + 'defaultAsciiSan' => self::describe_call( $default_ascii_sanitized ), + 'unicodeFilterState' => self::describe_value( $unicode_filter_state ), + 'asciiFilterState' => self::describe_value( $ascii_filter_state ), + 'defaultUtf8mb4FilterState' => self::describe_value( $default_utf8mb4_filter_state ), + 'defaultAsciiFilterState' => self::describe_value( $default_ascii_filter_state ), + 'parseOk' => $parse_ok, + 'asciiParseOk' => $ascii_parse_ok, + 'unicodeFilterOk' => $unicode_filter_ok, + 'asciiFilterOk' => $ascii_filter_ok, + 'defaultFilterOk' => $default_filter_ok, + 'viewShapeOk' => $view_shape_ok, + ); + } + + $observed[] = array( + 'label' => $case['label'], + 'inputDomainView' => $case['inputDomainView'], + 'localUtf8' => $case['hasUnicodeLocal'], + 'domainIdn' => $case['hasUnicodeDomain'], + 'asciiModeValid' => $case['asciiModeValid'], + 'unicodeAddress' => self::describe_string( $case['expectedUnicodeAddress'] ), + 'asciiAddress' => self::describe_string( $case['expectedAsciiAddress'] ), + 'asciiAddressAscii' => $input_email instanceof \WP_Email_Address ? self::is_ascii( $input_email->get_ascii_address() ) : null, + ); + } + } finally { + self::restore_hook_globals( $hook_snapshot ); + self::restore_wpdb_charset( $wpdb_snapshot ); + } + + return array( + $ctx->result( + 'email.generated-unicode.filter-view-matrix', + array() === $failures, + array( + 'caseCount' => count( $cases ), + 'observed' => $observed, + 'failures' => $failures, + ) + ), + ); + } + + private static function check_generated_malformed_variant_matrix( \ComponentFuzz\FuzzContext $ctx ): array { + $cases = self::generated_malformed_variant_cases( $ctx->fork( 'malformed-variant-matrix' ) ); + $failures = array(); + $observed = array(); + $hook_snapshot = self::snapshot_hook_globals(); + $wpdb_snapshot = self::snapshot_wpdb_charset(); + + try { + foreach ( $cases as $case ) { + if ( null !== $case['conversionError'] ) { + $failures[] = array( + 'label' => $case['label'], + 'domain' => $case['domainLabel'], + 'error' => $case['conversionError'], + ); + continue; + } + + $base_parse = self::call( static fn() => \WP_Email_Address::from_string( $case['input'], 'unicode' ) ); + $base_email = $base_parse['value'] ?? null; + $base_ok = ! $base_parse['threw'] + && $base_email instanceof \WP_Email_Address + && $case['expectedAsciiAddress'] === $base_email->get_ascii_address() + && $case['expectedUnicodeAddress'] === $base_email->get_unicode_address() + && self::address_round_trip_ok( $base_email ); + + if ( ! $base_ok ) { + $failures[] = array( + 'label' => $case['label'], + 'input' => self::describe_string( $case['input'] ), + 'expectedAscii' => self::describe_string( $case['expectedAsciiAddress'] ), + 'expectedUnicode' => self::describe_string( $case['expectedUnicodeAddress'] ), + 'baseParse' => self::describe_call( $base_parse ), + ); + continue; + } + + foreach ( $case['variants'] as $variant ) { + self::install_email_filters( 'unicode' ); + $unicode_parse = self::call( static fn() => \WP_Email_Address::from_string( $variant['input'], 'unicode' ) ); + $unicode_is_email = self::call( static fn() => \is_email( $variant['input'] ) ); + $unicode_sanitized = self::call( static fn() => \sanitize_email( $variant['input'] ) ); + $direct_unicode_is = self::call( static fn() => \wp_is_unicode_email( false, $variant['input'], null ) ); + $direct_unicode_sanitize = self::call( static fn() => \wp_sanitize_unicode_email( '', $variant['input'], null ) ); + + self::install_email_filters( 'ascii' ); + $ascii_parse = self::call( static fn() => \WP_Email_Address::from_string( $variant['input'], 'ascii' ) ); + $ascii_is_email = self::call( static fn() => \is_email( $variant['input'] ) ); + $ascii_sanitized = self::call( static fn() => \sanitize_email( $variant['input'] ) ); + $direct_ascii_is = self::call( static fn() => \wp_is_ascii_email( false, $variant['input'], null ) ); + $direct_ascii_sanitize = self::call( static fn() => \wp_sanitize_ascii_email( '', $variant['input'], null ) ); + + self::restore_hook_globals( $hook_snapshot ); + self::restore_wpdb_charset( $wpdb_snapshot ); + \remove_all_filters( 'is_email' ); + \remove_all_filters( 'sanitize_email' ); + self::install_email_filters_from_default_filters( 'utf8mb4' ); + $default_utf8mb4_is_email = self::call( static fn() => \is_email( $variant['input'] ) ); + $default_utf8mb4_sanitized = self::call( static fn() => \sanitize_email( $variant['input'] ) ); + $default_utf8mb4_filters = array( + 'isUnicode' => \has_filter( 'is_email', 'wp_is_unicode_email' ), + 'sanitizeUnicode' => \has_filter( 'sanitize_email', 'wp_sanitize_unicode_email' ), + 'isAscii' => \has_filter( 'is_email', 'wp_is_ascii_email' ), + 'sanitizeAscii' => \has_filter( 'sanitize_email', 'wp_sanitize_ascii_email' ), + ); + + self::restore_hook_globals( $hook_snapshot ); + self::restore_wpdb_charset( $wpdb_snapshot ); + \remove_all_filters( 'is_email' ); + \remove_all_filters( 'sanitize_email' ); + self::install_email_filters_from_default_filters( 'latin1' ); + $default_ascii_is_email = self::call( static fn() => \is_email( $variant['input'] ) ); + $default_ascii_sanitized = self::call( static fn() => \sanitize_email( $variant['input'] ) ); + $default_ascii_filters = array( + 'isUnicode' => \has_filter( 'is_email', 'wp_is_unicode_email' ), + 'sanitizeUnicode' => \has_filter( 'sanitize_email', 'wp_sanitize_unicode_email' ), + 'isAscii' => \has_filter( 'is_email', 'wp_is_ascii_email' ), + 'sanitizeAscii' => \has_filter( 'sanitize_email', 'wp_sanitize_ascii_email' ), + ); + + self::restore_hook_globals( $hook_snapshot ); + self::restore_wpdb_charset( $wpdb_snapshot ); + + $ok = ! $unicode_parse['threw'] + && ! $unicode_is_email['threw'] + && ! $unicode_sanitized['threw'] + && ! $direct_unicode_is['threw'] + && ! $direct_unicode_sanitize['threw'] + && ! $ascii_parse['threw'] + && ! $ascii_is_email['threw'] + && ! $ascii_sanitized['threw'] + && ! $direct_ascii_is['threw'] + && ! $direct_ascii_sanitize['threw'] + && ! $default_utf8mb4_is_email['threw'] + && ! $default_utf8mb4_sanitized['threw'] + && ! $default_ascii_is_email['threw'] + && ! $default_ascii_sanitized['threw'] + && null === $unicode_parse['value'] + && false === $unicode_is_email['value'] + && '' === $unicode_sanitized['value'] + && false === $direct_unicode_is['value'] + && '' === $direct_unicode_sanitize['value'] + && null === $ascii_parse['value'] + && false === $ascii_is_email['value'] + && '' === $ascii_sanitized['value'] + && false === $direct_ascii_is['value'] + && '' === $direct_ascii_sanitize['value'] + && false === $default_utf8mb4_is_email['value'] + && '' === $default_utf8mb4_sanitized['value'] + && false === $default_ascii_is_email['value'] + && '' === $default_ascii_sanitized['value'] + && 10 === $default_utf8mb4_filters['isUnicode'] + && 10 === $default_utf8mb4_filters['sanitizeUnicode'] + && false === $default_utf8mb4_filters['isAscii'] + && false === $default_utf8mb4_filters['sanitizeAscii'] + && false === $default_ascii_filters['isUnicode'] + && false === $default_ascii_filters['sanitizeUnicode'] + && 10 === $default_ascii_filters['isAscii'] + && 10 === $default_ascii_filters['sanitizeAscii']; + + if ( ! $ok ) { + $failures[] = array( + 'label' => $case['label'], + 'variant' => $variant['label'], + 'base' => self::describe_string( $case['input'] ), + 'input' => self::describe_string( $variant['input'] ), + 'unicodeParse' => self::describe_call( $unicode_parse ), + 'unicodeIsEmail' => self::describe_call( $unicode_is_email ), + 'unicodeSanitized' => self::describe_call( $unicode_sanitized ), + 'directUnicodeIs' => self::describe_call( $direct_unicode_is ), + 'directUnicodeSanitize' => self::describe_call( $direct_unicode_sanitize ), + 'asciiParse' => self::describe_call( $ascii_parse ), + 'asciiIsEmail' => self::describe_call( $ascii_is_email ), + 'asciiSanitized' => self::describe_call( $ascii_sanitized ), + 'directAsciiIs' => self::describe_call( $direct_ascii_is ), + 'directAsciiSanitize' => self::describe_call( $direct_ascii_sanitize ), + 'defaultUtf8mb4Is' => self::describe_call( $default_utf8mb4_is_email ), + 'defaultUtf8mb4San' => self::describe_call( $default_utf8mb4_sanitized ), + 'defaultAsciiIs' => self::describe_call( $default_ascii_is_email ), + 'defaultAsciiSan' => self::describe_call( $default_ascii_sanitized ), + 'defaultUtf8mb4Filters' => self::describe_value( $default_utf8mb4_filters ), + 'defaultAsciiFilters' => self::describe_value( $default_ascii_filters ), + ); + } + + $observed[] = array( + 'label' => $case['label'], + 'variant' => $variant['label'], + 'inputDomainView' => $case['inputDomainView'], + 'localUtf8' => $case['hasUnicodeLocal'], + 'domainIdn' => $case['hasUnicodeDomain'], + 'rejected' => $ok, + ); + } + } + } finally { + self::restore_hook_globals( $hook_snapshot ); + self::restore_wpdb_charset( $wpdb_snapshot ); + } + + return array( + $ctx->result( + 'email.generated-malformed-variants.reject-consistently', + array() === $failures, + array( + 'baseCaseCount' => count( $cases ), + 'variantCount' => count( $observed ), + 'observed' => $observed, + 'failures' => $failures, + ) + ), + ); + } + + private static function check_quoted_escaped_localpart_boundaries( \ComponentFuzz\FuzzContext $ctx ): array { + $cases = array( + array( 'label' => 'quoted-simple', 'input' => '"quoted"@example.com' ), + array( 'label' => 'quoted-space', 'input' => '"first last"@example.com' ), + array( 'label' => 'quoted-escaped-backslash', 'input' => '"quo\\ted"@example.com' ), + array( 'label' => 'quoted-escaped-quote', 'input' => '"a\"b"@example.com' ), + array( 'label' => 'quoted-at-sign', 'input' => '"a@b"@example.com' ), + array( 'label' => 'quoted-control', 'input' => "\"\x01\"@example.com" ), + array( 'label' => 'escaped-at-outside-quotes', 'input' => 'user\\@example.com@example.org' ), + ); + $failures = array(); + $observed = array(); + $snapshot = self::snapshot_hook_globals(); + + try { + foreach ( array( 'unicode', 'ascii' ) as $mode ) { + self::install_email_filters( $mode ); + + foreach ( $cases as $case ) { + $parse = self::call( static fn() => \WP_Email_Address::from_string( $case['input'], $mode ) ); + $is_email = self::call( static fn() => \is_email( $case['input'] ) ); + $sanitized = self::call( static fn() => \sanitize_email( $case['input'] ) ); + $direct_is = 'unicode' === $mode + ? self::call( static fn() => \wp_is_unicode_email( false, $case['input'], null ) ) + : self::call( static fn() => \wp_is_ascii_email( false, $case['input'], null ) ); + $direct_sanitize = 'unicode' === $mode + ? self::call( static fn() => \wp_sanitize_unicode_email( '', $case['input'], null ) ) + : self::call( static fn() => \wp_sanitize_ascii_email( '', $case['input'], null ) ); + $ok = ! $parse['threw'] + && ! $is_email['threw'] + && ! $sanitized['threw'] + && ! $direct_is['threw'] + && ! $direct_sanitize['threw'] + && null === $parse['value'] + && false === $is_email['value'] + && '' === $sanitized['value'] + && false === $direct_is['value'] + && '' === $direct_sanitize['value']; + + if ( ! $ok ) { + $failures[] = array( + 'mode' => $mode, + 'label' => $case['label'], + 'input' => self::describe_string( $case['input'] ), + 'parse' => self::describe_call( $parse ), + 'isEmail' => self::describe_call( $is_email ), + 'sanitizeEmail' => self::describe_call( $sanitized ), + 'directIs' => self::describe_call( $direct_is ), + 'directSanitize' => self::describe_call( $direct_sanitize ), + ); + } + + $observed[] = array( + 'mode' => $mode, + 'label' => $case['label'], + 'input' => self::describe_string( $case['input'] ), + 'rejected' => $ok, + ); + } + } + } finally { + self::restore_hook_globals( $snapshot ); + } + + return array( + $ctx->result( + 'email.rfc-quoted-escaped-localparts.rejected', + array() === $failures, + array( + 'caseCount' => count( $cases ), + 'observed' => $observed, + 'failures' => $failures, + ) + ), + ); + } + + private static function check_control_character_boundaries( \ComponentFuzz\FuzzContext $ctx ): array { + $cases = array( + array( 'label' => 'nul-local', 'input' => "bad\0local@example.com", 'expectedSanitized' => '' ), + array( 'label' => 'tab-local', 'input' => "bad\tlocal@example.com", 'expectedSanitized' => '' ), + array( 'label' => 'lf-local', 'input' => "bad\nlocal@example.com", 'expectedSanitized' => '' ), + array( 'label' => 'cr-local', 'input' => "bad\rlocal@example.com", 'expectedSanitized' => '' ), + array( 'label' => 'del-local', 'input' => "bad\x7Flocal@example.com", 'expectedSanitized' => '' ), + array( 'label' => 'nul-domain', 'input' => "user@example\0.com", 'expectedSanitized' => '' ), + array( 'label' => 'tab-domain', 'input' => "user@example\t.com", 'expectedSanitized' => 'user@example.com' ), + array( 'label' => 'lf-domain', 'input' => "user@example\n.com", 'expectedSanitized' => 'user@example.com' ), + array( 'label' => 'cr-domain', 'input' => "user@example\r.com", 'expectedSanitized' => 'user@example.com' ), + array( 'label' => 'del-domain', 'input' => "user@example\x7F.com", 'expectedSanitized' => '' ), + array( 'label' => 'control-before-at', 'input' => "user\x01@example.com", 'expectedSanitized' => '' ), + array( 'label' => 'control-after-at', 'input' => "user@\x01example.com", 'expectedSanitized' => '' ), + array( 'label' => 'line-separator-local', 'input' => "line\u{2028}sep@example.com", 'expectedSanitized' => '' ), + array( 'label' => 'paragraph-separator-domain', 'input' => "user@example\u{2029}.com", 'expectedSanitized' => 'user@example.com' ), + ); + $failures = array(); + $observed = array(); + $snapshot = self::snapshot_hook_globals(); + + try { + foreach ( array( 'unicode', 'ascii' ) as $mode ) { + self::install_email_filters( $mode ); + + foreach ( $cases as $case ) { + $valid_utf8 = self::call( static fn() => \wp_is_valid_utf8( $case['input'] ) ); + $parse = self::call( static fn() => \WP_Email_Address::from_string( $case['input'], $mode ) ); + $is_email = self::call( static fn() => \is_email( $case['input'] ) ); + $sanitized = self::call( static fn() => \sanitize_email( $case['input'] ) ); + $direct_is = 'unicode' === $mode + ? self::call( static fn() => \wp_is_unicode_email( false, $case['input'], null ) ) + : self::call( static fn() => \wp_is_ascii_email( false, $case['input'], null ) ); + $direct_sanitize = 'unicode' === $mode + ? self::call( static fn() => \wp_sanitize_unicode_email( '', $case['input'], null ) ) + : self::call( static fn() => \wp_sanitize_ascii_email( '', $case['input'], null ) ); + $sanitized_value = is_string( $sanitized['value'] ?? null ) ? $sanitized['value'] : ''; + $expected_sanitized = $case['expectedSanitized']; + $sanitized_parse = '' === $sanitized_value + ? array( 'threw' => false, 'value' => null ) + : self::call( static fn() => \WP_Email_Address::from_string( $sanitized_value, $mode ) ); + $sanitized_is = '' === $sanitized_value + ? array( 'threw' => false, 'value' => false ) + : self::call( static fn() => \is_email( $sanitized_value ) ); + $ok = ! $valid_utf8['threw'] + && ! $parse['threw'] + && ! $is_email['threw'] + && ! $sanitized['threw'] + && ! $direct_is['threw'] + && ! $direct_sanitize['threw'] + && ! $sanitized_parse['threw'] + && ! $sanitized_is['threw'] + && null === $parse['value'] + && false === $is_email['value'] + && false === $direct_is['value'] + && $expected_sanitized === $sanitized_value + && '' === $direct_sanitize['value'] + && 1 !== preg_match( '/[\x00-\x1F\x7F]/', $sanitized_value ) + && \wp_is_valid_utf8( $sanitized_value ) + && ( + '' === $expected_sanitized + ? null === $sanitized_parse['value'] && false === $sanitized_is['value'] + : $sanitized_parse['value'] instanceof \WP_Email_Address && $expected_sanitized === $sanitized_is['value'] + ); + + if ( ! $ok ) { + $failures[] = array( + 'mode' => $mode, + 'label' => $case['label'], + 'input' => self::describe_string( $case['input'] ), + 'expectedSanitized' => self::describe_string( $expected_sanitized ), + 'validUtf8' => self::describe_call( $valid_utf8 ), + 'parse' => self::describe_call( $parse ), + 'isEmail' => self::describe_call( $is_email ), + 'sanitizeEmail' => self::describe_call( $sanitized ), + 'directIs' => self::describe_call( $direct_is ), + 'directSanitize' => self::describe_call( $direct_sanitize ), + 'sanitizedParse' => self::describe_call( $sanitized_parse ), + 'sanitizedIs' => self::describe_call( $sanitized_is ), + ); + } + + $observed[] = array( + 'mode' => $mode, + 'label' => $case['label'], + 'input' => self::describe_string( $case['input'] ), + 'validUtf8' => ! $valid_utf8['threw'] ? $valid_utf8['value'] : null, + 'sanitized' => self::describe_string( $sanitized_value ), + 'rejected' => $ok, + ); + } + } + } finally { + self::restore_hook_globals( $snapshot ); + } + + return array( + $ctx->result( + 'email.control-characters.rejected-in-all-segments', + array() === $failures, + array( + 'caseCount' => count( $cases ), + 'observed' => $observed, + 'failures' => $failures, + ) + ), + ); + } + + private static function check_localpart_identity_preservation( \ComponentFuzz\FuzzContext $ctx ): array { + $groups = array( + array( + 'label' => 'case-sensitive-ascii-local', + 'locals' => array( 'user', 'User', 'USER' ), + ), + array( + 'label' => 'width-sensitive-latin-local', + 'locals' => array( 'user', "\u{FF55}ser" ), + ), + array( + 'label' => 'width-sensitive-katakana-local', + 'locals' => array( "\u{30A2}\u{30A4}", "\u{FF71}\u{FF72}" ), + ), + array( + 'label' => 'compatibility-ligature-local', + 'locals' => array( 'ffi', "\u{FB03}" ), + ), + array( + 'label' => 'normalization-sensitive-latin-local', + 'locals' => array( "jos\u{00E9}", "jose\u{0301}" ), + ), + ); + $failures = array(); + $observed = array(); + $snapshot = self::snapshot_hook_globals(); + + try { + self::install_email_filters( 'unicode' ); + + foreach ( $groups as $group ) { + $addresses = self::addresses_for_localparts( $group['locals'], 'example.com' ); + $views = array(); + + foreach ( $addresses as $index => $address ) { + $parse = self::capture_warnings( static fn() => \WP_Email_Address::from_string( $address, 'unicode' ) ); + $is_email = self::capture_warnings( static fn() => \is_email( $address ) ); + $sanitized = self::capture_warnings( static fn() => \sanitize_email( $address ) ); + $ascii_parse = self::capture_warnings( static fn() => \WP_Email_Address::from_string( $address, 'ascii' ) ); + $email = $parse['value'] ?? null; + $local = $group['locals'][ $index ]; + $ascii_email = $ascii_parse['value'] ?? null; + $ascii_valid = self::is_ascii( $local ); + $expected_raw = $local . '@example.com'; + $ok = ! $parse['threw'] + && ! $is_email['threw'] + && ! $sanitized['threw'] + && ! $ascii_parse['threw'] + && array() === $parse['warnings'] + && array() === $is_email['warnings'] + && array() === $sanitized['warnings'] + && array() === $ascii_parse['warnings'] + && $email instanceof \WP_Email_Address + && $local === $email->get_localpart() + && $expected_raw === $email->get_unicode_address() + && $expected_raw === $email->get_ascii_address() + && $expected_raw === $is_email['value'] + && $expected_raw === $sanitized['value'] + && ( + $ascii_valid + ? $ascii_email instanceof \WP_Email_Address && $expected_raw === $ascii_email->get_unicode_address() + : null === $ascii_email + ); + + if ( ! $ok ) { + $failures[] = array( + 'group' => $group['label'], + 'local' => self::describe_string( $local ), + 'address' => self::describe_string( $address ), + 'parse' => self::describe_captured_call( $parse ), + 'isEmail' => self::describe_captured_call( $is_email ), + 'sanitizeEmail' => self::describe_captured_call( $sanitized ), + 'asciiParse' => self::describe_captured_call( $ascii_parse ), + ); + } + + $views[] = array( + 'local' => self::describe_string( $local ), + 'address' => self::describe_string( $address ), + 'asciiValid' => $ascii_valid, + 'unicodeValid' => $email instanceof \WP_Email_Address, + ); + } + + if ( + count( array_unique( $group['locals'], SORT_REGULAR ) ) !== count( $group['locals'] ) || + count( array_unique( $addresses, SORT_REGULAR ) ) !== count( $addresses ) + ) { + $failures[] = array( + 'group' => $group['label'], + 'failure' => 'locals-or-addresses-collapsed-before-validation', + 'locals' => array_map( array( self::class, 'describe_string' ), $group['locals'] ), + 'addresses' => array_map( array( self::class, 'describe_string' ), $addresses ), + ); + } + + $observed[] = array( + 'label' => $group['label'], + 'views' => $views, + ); + } + } finally { + self::restore_hook_globals( $snapshot ); + } + + return array( + $ctx->result( + 'email.localpart-identity.case-width-normalization-preserved', + array() === $failures, + array( + 'groupCount' => count( $groups ), + 'observed' => $observed, + 'failures' => $failures, + ) + ), + ); + } + + private static function check_unicode_case( \ComponentFuzz\FuzzContext $ctx, int $case_index, array $case ): array { + $rows = array(); + $input = $case['input']; + $is_email = self::call( static fn() => \is_email( $input ) ); + $sanitized = self::call( static fn() => \sanitize_email( $input ) ); + $parsed = self::call( static fn() => \WP_Email_Address::from_string( $input, 'unicode' ) ); + $no_throw = ! $is_email['threw'] && ! $sanitized['threw'] && ! $parsed['threw']; + + $rows[] = self::case_result( + $ctx, + $case_index, + $case, + 'email.unicode.no-throw', + $no_throw, + array( + 'isEmail' => self::describe_call( $is_email ), + 'sanitizeEmail' => self::describe_call( $sanitized ), + 'parsed' => self::describe_call( $parsed ), + ) + ); + + if ( ! $no_throw ) { + return $rows; + } + + $is_email_value = $is_email['value']; + $sanitized_value = $sanitized['value']; + $parsed_value = $parsed['value']; + + $rows[] = self::case_result( + $ctx, + $case_index, + $case, + 'email.sanitize-email.returns-string', + is_string( $sanitized_value ), + array( 'sanitizeEmail' => self::describe_value( $sanitized_value ) ) + ); + + if ( is_string( $sanitized_value ) ) { + $rows[] = self::case_result( + $ctx, + $case_index, + $case, + 'email.sanitize-email.output-valid-utf8', + \wp_is_valid_utf8( $sanitized_value ), + array( 'sanitizeEmail' => self::describe_string( $sanitized_value ) ) + ); + + $again = self::call( static fn() => \sanitize_email( $sanitized_value ) ); + $rows[] = self::case_result( + $ctx, + $case_index, + $case, + 'email.sanitize-email.idempotent', + ! $again['threw'] && $sanitized_value === $again['value'], + array( + 'first' => self::describe_string( $sanitized_value ), + 'second' => self::describe_call( $again ), + ) + ); + + $valid_sanitized = '' === $sanitized_value + ? array( 'threw' => false, 'value' => false ) + : self::call( static fn() => \is_email( $sanitized_value ) ); + + $rows[] = self::case_result( + $ctx, + $case_index, + $case, + 'email.sanitize-email.output-validates', + '' === $sanitized_value || ( ! $valid_sanitized['threw'] && false !== $valid_sanitized['value'] ), + array( + 'sanitizeEmail' => self::describe_string( $sanitized_value ), + 'isEmail' => self::describe_call( $valid_sanitized ), + ) + ); + + $parsed_sanitized = '' === $sanitized_value + ? array( 'threw' => false, 'value' => null ) + : self::call( static fn() => \WP_Email_Address::from_string( $sanitized_value, 'unicode' ) ); + $rows[] = self::case_result( + $ctx, + $case_index, + $case, + 'email.sanitize-email.output-agrees-with-class', + '' === $sanitized_value + || ( + ! $parsed_sanitized['threw'] + && $parsed_sanitized['value'] instanceof \WP_Email_Address + && $sanitized_value === $parsed_sanitized['value']->get_unicode_address() + ), + array( + 'sanitizeEmail' => self::describe_string( $sanitized_value ), + 'parsed' => self::describe_call( $parsed_sanitized ), + ) + ); + } + + $expected_is_email = $parsed_value instanceof \WP_Email_Address ? $parsed_value->get_unicode_address() : false; + $rows[] = self::case_result( + $ctx, + $case_index, + $case, + 'email.is-email.agrees-with-class', + $is_email_value === $expected_is_email, + array( + 'expected' => self::describe_value( $expected_is_email ), + 'actual' => self::describe_value( $is_email_value ), + 'parsed' => self::describe_value( $parsed_value ), + ) + ); + + if ( array_key_exists( 'expectRawUnicode', $case ) ) { + $rows[] = self::case_result( + $ctx, + $case_index, + $case, + 'email.unicode.raw-expected-contract', + $case['expectRawUnicode'] === $is_email_value, + array( + 'expected' => self::describe_value( $case['expectRawUnicode'] ), + 'actual' => self::describe_value( $is_email_value ), + ) + ); + } + + if ( array_key_exists( 'expectSanitizedUnicode', $case ) ) { + $rows[] = self::case_result( + $ctx, + $case_index, + $case, + 'email.unicode.sanitized-expected-contract', + $case['expectSanitizedUnicode'] === $sanitized_value, + array( + 'expected' => self::describe_value( $case['expectSanitizedUnicode'] ), + 'actual' => self::describe_value( $sanitized_value ), + ) + ); + } + + if ( self::has_trait( $case, 'invalidUtf8' ) ) { + $rows[] = self::case_result( + $ctx, + $case_index, + $case, + 'email.invalid-utf8.rejected-and-emptied', + false === $is_email_value && '' === $sanitized_value && null === $parsed_value, + array( + 'isEmail' => self::describe_value( $is_email_value ), + 'sanitizeEmail' => self::describe_value( $sanitized_value ), + 'parsed' => self::describe_value( $parsed_value ), + ) + ); + } + + if ( $parsed_value instanceof \WP_Email_Address ) { + $rows[] = self::case_result( + $ctx, + $case_index, + $case, + 'email.wp-email-address.structural-parts', + self::address_parts_ok( $parsed_value ), + array( + 'address' => self::describe_address( $parsed_value ), + ) + ); + $rows[] = self::case_result( + $ctx, + $case_index, + $case, + 'email.wp-email-address.structural-round-trip', + self::address_round_trip_ok( $parsed_value ), + array( + 'address' => self::describe_address( $parsed_value ), + ) + ); + } + + return $rows; + } + + private static function check_ascii_case( \ComponentFuzz\FuzzContext $ctx, int $case_index, array $case ): array { + $rows = array(); + $input = $case['input']; + $is_email = self::call( static fn() => \is_email( $input ) ); + $sanitized = self::call( static fn() => \sanitize_email( $input ) ); + $parsed = self::call( static fn() => \WP_Email_Address::from_string( $input, 'ascii' ) ); + $no_throw = ! $is_email['threw'] && ! $sanitized['threw'] && ! $parsed['threw']; + + $rows[] = self::case_result( + $ctx, + $case_index, + $case, + 'email.ascii-fallback.no-throw', + $no_throw, + array( + 'isEmail' => self::describe_call( $is_email ), + 'sanitizeEmail' => self::describe_call( $sanitized ), + 'parsed' => self::describe_call( $parsed ), + ) + ); + + if ( ! $no_throw ) { + return $rows; + } + + if ( array_key_exists( 'expectRawAscii', $case ) ) { + $rows[] = self::case_result( + $ctx, + $case_index, + $case, + 'email.ascii-fallback.raw-expected-contract', + $case['expectRawAscii'] === $is_email['value'], + array( + 'expected' => self::describe_value( $case['expectRawAscii'] ), + 'actual' => self::describe_value( $is_email['value'] ), + ) + ); + } + + if ( array_key_exists( 'expectSanitizedAscii', $case ) ) { + $rows[] = self::case_result( + $ctx, + $case_index, + $case, + 'email.ascii-fallback.sanitized-expected-contract', + $case['expectSanitizedAscii'] === $sanitized['value'], + array( + 'expected' => self::describe_value( $case['expectSanitizedAscii'] ), + 'actual' => self::describe_value( $sanitized['value'] ), + ) + ); + } + + if ( is_string( $sanitized['value'] ) ) { + $rows[] = self::case_result( + $ctx, + $case_index, + $case, + 'email.ascii-fallback.sanitize-email.output-valid-utf8', + \wp_is_valid_utf8( $sanitized['value'] ), + array( 'sanitizeEmail' => self::describe_string( $sanitized['value'] ) ) + ); + } + + if ( self::has_trait( $case, 'unicodeAddress' ) || self::has_trait( $case, 'punycodeUnicodeDomain' ) ) { + $rows[] = self::case_result( + $ctx, + $case_index, + $case, + 'email.ascii-fallback.rejects-unicode-addresses', + false === $is_email['value'] && '' === $sanitized['value'] && null === $parsed['value'], + array( + 'isEmail' => self::describe_value( $is_email['value'] ), + 'sanitizeEmail' => self::describe_value( $sanitized['value'] ), + 'parsed' => self::describe_value( $parsed['value'] ), + ) + ); + } + + return $rows; + } + + private static function check_distinct_localparts( \ComponentFuzz\FuzzContext $ctx ): array { + $domain = 'example.com'; + $inputs = array( + 'jose@' . $domain, + "jos\u{00E9}@" . $domain, + "jose\u{0301}@" . $domain, + "josejose@gr\u{00E5}.org", + "jos\u{00E9}jos\u{00E9}@gr\u{00E5}.org", + "jose\u{0301}jose\u{0301}@gr\u{00E5}.org", + ); + $sanitized = array(); + $locals = array(); + $throws = array(); + + foreach ( $inputs as $input ) { + $sanitize_call = self::call( static fn() => \sanitize_email( $input ) ); + $parse_call = self::call( static fn() => \WP_Email_Address::from_string( $input, 'unicode' ) ); + $throws[] = $sanitize_call['threw'] || $parse_call['threw']; + $sanitized[] = $sanitize_call['value'] ?? null; + $locals[] = $parse_call['value'] instanceof \WP_Email_Address ? $parse_call['value']->get_localpart() : null; + } + + $ok = ! in_array( true, $throws, true ) + && ! in_array( '', $sanitized, true ) + && count( array_unique( $sanitized, SORT_REGULAR ) ) === count( $sanitized ) + && count( array_unique( $locals, SORT_REGULAR ) ) === count( $locals ); + + return array( + $ctx->result( + 'email.sanitize-email.distinct-localparts-preserved', + $ok, + array( + 'inputs' => array_map( array( self::class, 'describe_string' ), $inputs ), + 'sanitized' => self::describe_value( $sanitized ), + 'locals' => self::describe_value( $locals ), + 'throws' => self::describe_value( $throws ), + ) + ), + ); + } + + private static function check_normalization_sensitive_localparts( \ComponentFuzz\FuzzContext $ctx ): array { + $pairs = array( + array( + 'label' => 'latin-acute', + 'nfc' => "jos\u{00E9}@example.com", + 'nfd' => "jose\u{0301}@example.com", + ), + array( + 'label' => 'latin-ring', + 'nfc' => "\u{00E5}ngstrom@example.com", + 'nfd' => "a\u{030A}ngstrom@example.com", + ), + array( + 'label' => 'greek-tonos', + 'nfc' => "\u{03AC}\u{03BB}\u{03C6}\u{03B1}@example.com", + 'nfd' => "\u{03B1}\u{0301}\u{03BB}\u{03C6}\u{03B1}@example.com", + ), + ); + $failures = array(); + $observed = array(); + + foreach ( $pairs as $pair ) { + $nfc_sanitized = self::call( static fn() => \sanitize_email( $pair['nfc'] ) ); + $nfd_sanitized = self::call( static fn() => \sanitize_email( $pair['nfd'] ) ); + $nfc_parsed = self::call( static fn() => \WP_Email_Address::from_string( $pair['nfc'], 'unicode' ) ); + $nfd_parsed = self::call( static fn() => \WP_Email_Address::from_string( $pair['nfd'], 'unicode' ) ); + $nfc_email = $nfc_parsed['value'] ?? null; + $nfd_email = $nfd_parsed['value'] ?? null; + $ok = ! $nfc_sanitized['threw'] + && ! $nfd_sanitized['threw'] + && ! $nfc_parsed['threw'] + && ! $nfd_parsed['threw'] + && $pair['nfc'] === $nfc_sanitized['value'] + && $pair['nfd'] === $nfd_sanitized['value'] + && $nfc_email instanceof \WP_Email_Address + && $nfd_email instanceof \WP_Email_Address + && $pair['nfc'] === $nfc_email->get_unicode_address() + && $pair['nfd'] === $nfd_email->get_unicode_address() + && $nfc_email->get_localpart() !== $nfd_email->get_localpart() + && $nfc_email->get_unicode_address() !== $nfd_email->get_unicode_address(); + + if ( ! $ok ) { + $failures[] = array( + 'label' => $pair['label'], + 'nfc' => self::describe_string( $pair['nfc'] ), + 'nfd' => self::describe_string( $pair['nfd'] ), + 'nfcSanitized' => self::describe_call( $nfc_sanitized ), + 'nfdSanitized' => self::describe_call( $nfd_sanitized ), + 'nfcParsed' => self::describe_call( $nfc_parsed ), + 'nfdParsed' => self::describe_call( $nfd_parsed ), + ); + } + + $observed[] = array( + 'label' => $pair['label'], + 'nfcLocal' => $nfc_email instanceof \WP_Email_Address ? self::describe_string( $nfc_email->get_localpart() ) : null, + 'nfdLocal' => $nfd_email instanceof \WP_Email_Address ? self::describe_string( $nfd_email->get_localpart() ) : null, + 'localsDistinct' => $nfc_email instanceof \WP_Email_Address && $nfd_email instanceof \WP_Email_Address && $nfc_email->get_localpart() !== $nfd_email->get_localpart(), + ); + } + + return array( + $ctx->result( + 'email.wp-email-address.normalization-sensitive-localparts-preserved', + array() === $failures, + array( + 'observed' => $observed, + 'failures' => $failures, + ) + ), + ); + } + + private static function check_comment_author_email_filters( \ComponentFuzz\FuzzContext $ctx ): array { + if ( ! function_exists( 'wp_filter_comment' ) ) { + return array( + $ctx->skip( + 'email.comment-author-email.filter-agreement', + 'wp_filter_comment() is unavailable.' + ), + ); + } + + $cases = array( + array( + 'label' => 'cyrillic-local', + 'email' => "\u{043F}\u{043E}\u{0447}\u{0442}\u{0430}@example.com", + 'expectEmail' => "\u{043F}\u{043E}\u{0447}\u{0442}\u{0430}@example.com", + ), + array( + 'label' => 'hiragana-local', + 'email' => "\u{3086}\u{3046}\u{3056}\u{3042}@example.com", + 'expectEmail' => "\u{3086}\u{3046}\u{3056}\u{3042}@example.com", + ), + array( + 'label' => 'display-name-recovery', + 'email' => "Display Name ", + 'expectEmail' => "jos\u{00E9}@example.com", + ), + array( + 'label' => 'emoji-local-rejected', + 'email' => "emoji\u{1F600}@example.com", + 'expectEmail' => '', + ), + array( + 'label' => 'fullwidth-at-rejected', + 'email' => "bad\u{FF20}example.com", + 'expectEmail' => '', + ), + ); + $failures = array(); + $observed = array(); + $snapshot = self::snapshot_hook_globals(); + + try { + \remove_all_filters( 'pre_comment_author_email' ); + \add_filter( 'pre_comment_author_email', 'trim' ); + \add_filter( 'pre_comment_author_email', 'sanitize_email' ); + + foreach ( $cases as $index => $case ) { + $comment = array( + 'comment_author' => 'Component Fuzzer', + 'comment_author_email' => $case['email'], + 'comment_author_url' => '', + 'comment_content' => 'Unicode email comment case ' . $index, + 'comment_author_IP' => '127.0.0.1', + 'comment_agent' => 'component-fuzz', + ); + $filtered = self::call( static fn() => \wp_filter_comment( $comment ) ); + $sanitized = self::call( static fn() => \sanitize_email( trim( $case['email'] ) ) ); + $filtered_email = ! $filtered['threw'] && is_array( $filtered['value'] ) + ? ( $filtered['value']['comment_author_email'] ?? null ) + : null; + $filtered_valid = '' === $filtered_email + ? array( 'threw' => false, 'value' => false ) + : self::call( static fn() => \is_email( $filtered_email ) ); + $parsed = '' === $filtered_email || ! is_string( $filtered_email ) + ? array( 'threw' => false, 'value' => null ) + : self::call( static fn() => \WP_Email_Address::from_string( $filtered_email, 'unicode' ) ); + $ok = ! $filtered['threw'] + && ! $sanitized['threw'] + && ! $filtered_valid['threw'] + && ! $parsed['threw'] + && is_array( $filtered['value'] ) + && true === ( $filtered['value']['filtered'] ?? null ) + && $case['expectEmail'] === $filtered_email + && $sanitized['value'] === $filtered_email + && ( + '' === $filtered_email + ? false === $filtered_valid['value'] && null === $parsed['value'] + : $filtered_email === $filtered_valid['value'] && $parsed['value'] instanceof \WP_Email_Address + ); + + if ( ! $ok ) { + $failures[] = array( + 'label' => $case['label'], + 'input' => self::describe_string( $case['email'] ), + 'expectedEmail' => self::describe_string( $case['expectEmail'] ), + 'filteredEmail' => self::describe_value( $filtered_email ), + 'filtered' => self::describe_call( $filtered ), + 'sanitized' => self::describe_call( $sanitized ), + 'isEmail' => self::describe_call( $filtered_valid ), + 'parsed' => self::describe_call( $parsed ), + ); + } + + $observed[] = array( + 'label' => $case['label'], + 'input' => self::describe_string( $case['email'] ), + 'filteredEmail' => self::describe_value( $filtered_email ), + 'accepted' => '' !== $filtered_email, + ); + } + } finally { + self::restore_hook_globals( $snapshot ); + } + + return array( + $ctx->result( + 'email.comment-author-email.filter-agreement', + array() === $failures, + array( + 'observed' => $observed, + 'failures' => $failures, + ) + ), + ); + } + + private static function check_comment_submission_unicode_email_paths( \ComponentFuzz\FuzzContext $ctx ): array { + $result_name = 'email.comment-submission.unicode-address-paths'; + + if ( ! self::can_reset_stub_content() ) { + return array( + $ctx->skip( + $result_name, + 'The in-memory wpdb content reset hook is unavailable.' + ), + ); + } + + $missing = array(); + foreach ( + array( + 'add_action', + 'add_filter', + 'create_initial_post_types', + 'get_comment', + 'is_email', + 'is_wp_error', + 'remove_all_filters', + 'sanitize_email', + 'wp_handle_comment_submission', + 'wp_new_comment', + 'wp_set_current_user', + ) as $function + ) { + if ( ! function_exists( $function ) ) { + $missing[] = "function {$function}"; + } + } + + foreach ( array( 'WP_Comment', 'WP_Email_Address', 'WP_Error' ) as $class ) { + if ( ! class_exists( $class ) ) { + $missing[] = "class {$class}"; + } + } + + if ( array() !== $missing ) { + return array( + $ctx->skip( + $result_name, + 'Required WordPress comment submission APIs are unavailable.', + array( 'missing' => implode( ', ', $missing ) ) + ), + ); + } + + $cases = self::generated_comment_submission_cases( $ctx->fork( 'comment-submission' ) ); + $failures = array(); + $observed = array(); + $insert_events = array(); + $post_events = array(); + $submission_hooks = array( + 'preprocess_comment', + 'pre_comment_author_email', + 'pre_comment_author_name', + 'pre_comment_author_url', + 'pre_comment_content', + 'pre_comment_user_agent', + 'pre_comment_user_ip', + 'pre_comment_approved', + 'duplicate_comment_id', + 'check_comment_flood', + 'wp_is_comment_flood', + 'comment_post', + 'wp_insert_comment', + 'comment_text', + 'comment_max_links_url', + 'wp_check_comment_disallowed_list', + 'pre_option_comment_registration', + 'pre_option_require_name_email', + 'pre_option_comment_moderation', + 'pre_option_comment_max_links', + 'pre_option_moderation_keys', + 'pre_option_disallowed_keys', + 'pre_option_comment_previously_approved', + 'pre_option_comments_notify', + 'pre_option_moderation_notify', + 'pre_option_admin_email', + 'pre_option_blogname', + ); + $hook_snapshot = self::snapshot_hook_globals(); + $hook_state_before = self::snapshot_selected_hook_state( $submission_hooks ); + $server_keys = array( 'REMOTE_ADDR', 'HTTP_USER_AGENT', 'REQUEST_URI', 'HTTP_HOST' ); + $global_keys = array( + 'current_user', + 'user_ID', + 'userdata', + 'user_login', + 'user_level', + 'user_email', + 'user_url', + 'user_identity', + 'post', + 'comment', + 'wp_post_types', + 'wp_post_statuses', + '_wp_post_type_features', + 'post_type_meta_caps', + ); + $server_snapshot = self::snapshot_array_keys( $_SERVER, $server_keys ); + $global_snapshot = self::snapshot_named_globals( $global_keys ); + + self::reset_stub_content(); + $content_before = self::stub_content_counts(); + + try { + $_SERVER['REMOTE_ADDR'] = '127.0.0.1'; + $_SERVER['HTTP_USER_AGENT'] = 'ComponentFuzz EmailSurface'; + $_SERVER['REQUEST_URI'] = '/component-fuzz/email-comment-unicode/'; + $_SERVER['HTTP_HOST'] = 'example.test'; + + \wp_set_current_user( 0 ); + \create_initial_post_types(); + self::install_email_filters( 'unicode' ); + self::install_comment_submission_filters( $insert_events, $post_events ); + + foreach ( $cases as $case_index => $case ) { + $post_id = self::seed_comment_submission_post( $ctx, $case_index, $case ); + if ( $post_id <= 0 ) { + $failures[] = array( + 'label' => $case['label'], + 'failure' => 'post-seed-failed', + ); + continue; + } + + $comments_before = self::stub_comment_count(); + $insert_before = count( $insert_events ); + $post_before = count( $post_events ); + $content = 'Unicode comment submission ' . $ctx->iteration() . '.' . $case_index . ' ' . $case['label']; + $submit = self::capture_warnings( + static function () use ( $case, $post_id, $content ) { + if ( 'handle-submission' === $case['path'] ) { + return \wp_handle_comment_submission( + array( + 'comment_post_ID' => $post_id, + 'author' => 'Component Fuzzer', + 'email' => $case['input'], + 'url' => '', + 'comment' => $content, + 'comment_parent' => 0, + ) + ); + } + + return \wp_new_comment( + array( + 'comment_post_ID' => $post_id, + 'comment_parent' => 0, + 'comment_author' => 'Component Fuzzer', + 'comment_author_email' => $case['input'], + 'comment_author_url' => '', + 'comment_content' => $content, + 'comment_author_IP' => '127.0.0.1', + 'comment_agent' => 'ComponentFuzz EmailSurface', + 'comment_date' => '2026-06-26 12:00:00', + 'comment_date_gmt' => '2026-06-26 10:00:00', + 'comment_type' => 'comment', + 'user_id' => 0, + ), + true + ); + } + ); + $comments_after = self::stub_comment_count(); + $case_inserts = array_slice( $insert_events, $insert_before ); + $case_posts = array_slice( $post_events, $post_before ); + + if ( $case['accepted'] || 'wp-new-comment-sanitizes-invalid' === $case['expectedError'] ) { + $comment_id = 0; + $returned = $submit['value'] ?? null; + $return_shape_ok = 'handle-submission' === $case['path'] + ? $returned instanceof \WP_Comment + : is_int( $returned ); + if ( 'handle-submission' === $case['path'] && $returned instanceof \WP_Comment ) { + $comment_id = (int) $returned->comment_ID; + } elseif ( 'wp-new-comment' === $case['path'] && is_int( $returned ) ) { + $comment_id = $returned; + } + + $stored = $comment_id > 0 + ? self::capture_warnings( static fn() => \get_comment( $comment_id ) ) + : self::not_called(); + $stored_comment = $stored['value'] ?? null; + $expected_email = (string) $case['expectedEmail']; + $raw_sanitized = self::capture_warnings( static fn() => \sanitize_email( $case['input'] ) ); + $expected_sanitized = self::capture_warnings( static fn() => \sanitize_email( $expected_email ) ); + $is_email = '' === $expected_email + ? self::not_called() + : self::capture_warnings( static fn() => \is_email( $expected_email ) ); + $parsed = '' === $expected_email + ? self::not_called() + : self::capture_warnings( static fn() => \WP_Email_Address::from_string( $expected_email, 'unicode' ) ); + $email = $parsed['value'] ?? null; + $insert_event = $case_inserts[0] ?? null; + $post_event = $case_posts[0] ?? null; + + $stored_ok = $stored_comment instanceof \WP_Comment + && $expected_email === $stored_comment->comment_author_email + && (string) $post_id === (string) $stored_comment->comment_post_ID + && '1' === (string) $stored_comment->comment_approved; + $oracle_ok = ! $raw_sanitized['threw'] + && array() === $raw_sanitized['warnings'] + && ! $expected_sanitized['threw'] + && array() === $expected_sanitized['warnings'] + && ! $is_email['threw'] + && array() === $is_email['warnings'] + && ! $parsed['threw'] + && array() === $parsed['warnings'] + && $expected_email === $raw_sanitized['value'] + && $expected_email === $expected_sanitized['value'] + && ( + '' === $expected_email + ? null === $is_email['value'] && null === $parsed['value'] + : $expected_email === $is_email['value'] + && $email instanceof \WP_Email_Address + && $expected_email === $email->get_unicode_address() + ); + $events_ok = 1 === count( $case_inserts ) + && 1 === count( $case_posts ) + && is_array( $insert_event ) + && is_array( $post_event ) + && $comment_id === (int) ( $insert_event['id'] ?? 0 ) + && $comment_id === (int) ( $post_event['id'] ?? 0 ) + && $post_id === (int) ( $insert_event['postId'] ?? 0 ) + && $post_id === (int) ( $post_event['postId'] ?? 0 ) + && $expected_email === ( $insert_event['email'] ?? null ) + && $expected_email === ( $post_event['email'] ?? null ) + && '1' === (string) ( $insert_event['approved'] ?? '' ) + && '1' === (string) ( $post_event['approved'] ?? '' ) + && true === ( $post_event['filtered'] ?? null ); + $ok = ! $submit['threw'] + && array() === $submit['warnings'] + && $return_shape_ok + && ! $stored['threw'] + && array() === $stored['warnings'] + && $comment_id > 0 + && $comments_after === $comments_before + 1 + && $stored_ok + && $oracle_ok + && $events_ok; + + if ( ! $ok ) { + $failures[] = array( + 'label' => $case['label'], + 'path' => $case['path'], + 'input' => self::describe_string( $case['input'] ), + 'expectedEmail' => self::describe_string( $expected_email ), + 'submit' => self::describe_captured_call( $submit ), + 'stored' => self::describe_captured_call( $stored ), + 'rawSanitized' => self::describe_captured_call( $raw_sanitized ), + 'expectedSanitized' => self::describe_captured_call( $expected_sanitized ), + 'isEmail' => self::describe_captured_call( $is_email ), + 'parsed' => self::describe_captured_call( $parsed ), + 'insertEvents' => self::describe_value( $case_inserts ), + 'postEvents' => self::describe_value( $case_posts ), + 'returnShapeOk' => $return_shape_ok, + 'commentsBefore' => $comments_before, + 'commentsAfter' => $comments_after, + 'storedOk' => $stored_ok, + 'oracleOk' => $oracle_ok, + 'eventsOk' => $events_ok, + ); + } + + $observed[] = array( + 'label' => $case['label'], + 'path' => $case['path'], + 'accepted' => (bool) $case['accepted'], + 'inserted' => true, + 'expectedEmail' => self::describe_string( $expected_email ), + 'commentId' => $comment_id, + 'traits' => implode( ',', $case['traits'] ), + ); + continue; + } + + $is_email = self::capture_warnings( static fn() => \is_email( $case['input'] ) ); + $sanitized = self::capture_warnings( static fn() => \sanitize_email( $case['input'] ) ); + $parsed = self::capture_warnings( static fn() => \WP_Email_Address::from_string( $case['input'], 'unicode' ) ); + $error = $submit['value'] ?? null; + $ok = ! $submit['threw'] + && array() === $submit['warnings'] + && \is_wp_error( $error ) + && $case['expectedError'] === $error->get_error_code() + && $comments_after === $comments_before + && array() === $case_inserts + && array() === $case_posts + && ! $is_email['threw'] + && array() === $is_email['warnings'] + && false === $is_email['value'] + && ! $sanitized['threw'] + && array() === $sanitized['warnings'] + && '' === $sanitized['value'] + && ! $parsed['threw'] + && array() === $parsed['warnings'] + && null === $parsed['value']; + + if ( ! $ok ) { + $failures[] = array( + 'label' => $case['label'], + 'path' => $case['path'], + 'input' => self::describe_string( $case['input'] ), + 'expectedError' => $case['expectedError'], + 'submit' => self::describe_captured_call( $submit ), + 'isEmail' => self::describe_captured_call( $is_email ), + 'sanitizeEmail' => self::describe_captured_call( $sanitized ), + 'parsed' => self::describe_captured_call( $parsed ), + 'insertEvents' => self::describe_value( $case_inserts ), + 'postEvents' => self::describe_value( $case_posts ), + 'commentsBefore' => $comments_before, + 'commentsAfter' => $comments_after, + ); + } + + $observed[] = array( + 'label' => $case['label'], + 'path' => $case['path'], + 'accepted' => false, + 'error' => \is_wp_error( $error ) ? $error->get_error_code() : self::describe_value( $error ), + 'traits' => implode( ',', $case['traits'] ), + 'commentsHeld' => $comments_after === $comments_before, + ); + } + } finally { + self::restore_hook_globals( $hook_snapshot ); + self::restore_named_globals( $global_snapshot ); + self::restore_array_keys( $_SERVER, $server_snapshot ); + self::reset_stub_content(); + } + + $hook_state_after = self::snapshot_selected_hook_state( $submission_hooks ); + $server_after = self::snapshot_array_keys( $_SERVER, $server_keys ); + $global_after = self::snapshot_named_globals( $global_keys ); + $content_after = self::stub_content_counts(); + $state_ok = $hook_state_before === $hook_state_after + && $server_snapshot === $server_after + && $global_snapshot === $global_after + && $content_before === $content_after; + + if ( ! $state_ok ) { + $failures[] = array( + 'failure' => 'state-not-restored', + 'hooksBefore' => self::describe_value( $hook_state_before ), + 'hooksAfter' => self::describe_value( $hook_state_after ), + 'serverBefore' => self::describe_value( $server_snapshot ), + 'serverAfter' => self::describe_value( $server_after ), + 'globalsBefore' => self::describe_value( $global_snapshot ), + 'globalsAfter' => self::describe_value( $global_after ), + 'contentBefore' => self::describe_value( $content_before ), + 'contentAfter' => self::describe_value( $content_after ), + ); + } + + return array( + $ctx->result( + $result_name, + array() === $failures, + array( + 'caseCount' => count( $cases ), + 'idnSupported' => self::has_idn(), + 'observed' => $observed, + 'stateRestored' => $state_ok, + 'failures' => self::describe_value( $failures ), + ) + ), + ); + } + + private static function check_rest_email_schema_filter_modes( \ComponentFuzz\FuzzContext $ctx ): array { + foreach ( array( 'rest_validate_value_from_schema', 'rest_sanitize_value_from_schema' ) as $function ) { + if ( ! function_exists( $function ) ) { + return array( + $ctx->skip( + 'email.rest-schema.email-format-filter-modes', + 'Required REST schema APIs are unavailable.', + array( 'missing' => 'function ' . $function ) + ), + ); + } + } + + if ( ! class_exists( 'WP_REST_Users_Controller' ) ) { + return array( + $ctx->skip( + 'email.rest-schema.email-format-filter-modes', + 'WP_REST_Users_Controller is unavailable.' + ), + ); + } + + $controller = new \WP_REST_Users_Controller(); + $item_schema = $controller->get_item_schema(); + $email_schema = $item_schema['properties']['email'] ?? null; + + if ( + ! is_array( $email_schema ) || + 'string' !== ( $email_schema['type'] ?? null ) || + 'email' !== ( $email_schema['format'] ?? null ) + ) { + return array( + $ctx->result( + 'email.rest-schema.email-format-filter-modes', + false, + array( + 'emailSchema' => self::describe_value( $email_schema ), + ) + ), + ); + } + + $cases = array( + array( + 'label' => 'ascii', + 'input' => 'user@example.com', + 'expect' => array( + 'unicode' => true, + 'ascii' => true, + ), + ), + array( + 'label' => 'unicode-local', + 'input' => "jos\u{00E9}@example.com", + 'expect' => array( + 'unicode' => true, + 'ascii' => false, + ), + ), + array( + 'label' => 'invalid-unicode-local', + 'input' => "emoji\u{1F600}@example.com", + 'expect' => array( + 'unicode' => false, + 'ascii' => false, + ), + ), + array( + 'label' => 'fullwidth-at', + 'input' => "bad\u{FF20}example.com", + 'expect' => array( + 'unicode' => false, + 'ascii' => false, + ), + ), + ); + + if ( self::has_idn() ) { + $cases[] = array( + 'label' => 'unicode-local-and-domain', + 'input' => "gr\u{00E5}@gr\u{00E5}.org", + 'expect' => array( + 'unicode' => true, + 'ascii' => false, + ), + ); + } + + $failures = array(); + $observed = array(); + $snapshot = self::snapshot_hook_globals(); + + try { + foreach ( array( 'unicode', 'ascii' ) as $mode ) { + self::install_email_filters( $mode ); + + foreach ( $cases as $case ) { + $expected_valid = $case['expect'][ $mode ]; + $validate = self::capture_warnings( + static fn() => \rest_validate_value_from_schema( $case['input'], $email_schema, 'email' ) + ); + $sanitize = self::capture_warnings( + static fn() => \rest_sanitize_value_from_schema( $case['input'], $email_schema, 'email' ) + ); + $sanitized = $sanitize['value'] ?? null; + $validate_sanitized = is_string( $sanitized ) + ? self::capture_warnings( + static fn() => \rest_validate_value_from_schema( $sanitized, $email_schema, 'email' ) + ) + : array( + 'threw' => false, + 'value' => null, + 'warnings' => array(), + ); + $is_email = self::capture_warnings( static fn() => \is_email( $case['input'] ) ); + + $validation_result = $validate['value'] ?? null; + $sanitized_validation_result = $validate_sanitized['value'] ?? null; + $is_email_result = $is_email['value'] ?? null; + $validation_ok = true === $validation_result; + $sanitized_validation_ok = true === $sanitized_validation_result; + $is_email_ok = false !== $is_email_result; + $validation_error_ok = $expected_valid || ( + \is_wp_error( $validation_result ) && + 'rest_invalid_email' === $validation_result->get_error_code() + ); + $sanitized_validation_error_ok = $expected_valid || ( + \is_wp_error( $sanitized_validation_result ) && + 'rest_invalid_email' === $sanitized_validation_result->get_error_code() + ); + + $ok = ! $validate['threw'] + && ! $sanitize['threw'] + && ! $validate_sanitized['threw'] + && ! $is_email['threw'] + && array() === $validate['warnings'] + && array() === $sanitize['warnings'] + && array() === $validate_sanitized['warnings'] + && array() === $is_email['warnings'] + && $expected_valid === $validation_ok + && $expected_valid === $sanitized_validation_ok + && $expected_valid === $is_email_ok + && $validation_error_ok + && $sanitized_validation_error_ok + && $case['input'] === $sanitized + && ( ! $expected_valid || $case['input'] === $is_email_result ); + + if ( ! $ok ) { + $failures[] = array( + 'mode' => $mode, + 'label' => $case['label'], + 'input' => self::describe_string( $case['input'] ), + 'expectedValid' => $expected_valid, + 'validate' => self::describe_captured_call( $validate ), + 'sanitize' => self::describe_captured_call( $sanitize ), + 'validateSanitized' => self::describe_captured_call( $validate_sanitized ), + 'isEmail' => self::describe_captured_call( $is_email ), + ); + } + + $observed[] = array( + 'mode' => $mode, + 'label' => $case['label'], + 'input' => self::describe_string( $case['input'] ), + 'expectedValid' => $expected_valid, + 'validation' => self::describe_value( $validation_result ), + 'sanitized' => self::describe_value( $sanitized ), + 'sanitizedValid' => self::describe_value( $sanitized_validation_result ), + 'isEmail' => self::describe_value( $is_email_result ), + ); + } + } + + if ( ! self::has_idn() ) { + $observed[] = array( + 'label' => 'unicode-local-and-domain', + 'status' => 'skipped-idn-unavailable', + ); + } + } finally { + self::restore_hook_globals( $snapshot ); + } + + return array( + $ctx->result( + 'email.rest-schema.email-format-filter-modes', + array() === $failures, + array( + 'observed' => $observed, + 'failures' => $failures, + ) + ), + ); + } + + private static function check_user_email_indexes_distinct_localparts( \ComponentFuzz\FuzzContext $ctx ): array { + if ( ! self::can_reset_stub_content() ) { + return array( + $ctx->skip( + 'email.user-email-indexes.distinct-localparts-preserved', + 'The in-memory wpdb content reset hook is unavailable.' + ), + ); + } + + $inputs = array( + 'josejose@example.org', + 'joséjosé@example.org', + "jose\u{0301}jose\u{0301}@example.org", + ); + $inserted = array(); + $lookups = array(); + $failures = array(); + + self::reset_stub_content(); + try { + foreach ( $inputs as $index => $input ) { + $user_id = \wp_insert_user( + array( + 'user_login' => 'cfz_email_' . $ctx->iteration() . '_' . $index . '_' . substr( sha1( $input ), 0, 10 ), + 'user_pass' => 'component-fuzz-pass', + 'user_email' => $input, + 'role' => 'subscriber', + ) + ); + + $inserted[] = $user_id; + + if ( ! is_int( $user_id ) ) { + $failures[] = array( + 'label' => 'insert-failed', + 'input' => self::describe_string( $input ), + 'result' => self::describe_value( $user_id ), + ); + continue; + } + + $exists = \email_exists( $input ); + $by_email = \get_user_by( 'email', $input ); + $lookups[] = array( + 'input' => self::describe_string( $input ), + 'userId' => $user_id, + 'exists' => $exists, + 'byEmail' => $by_email instanceof \WP_User ? $by_email->ID : self::describe_value( $by_email ), + ); + + if ( $exists !== $user_id || ! ( $by_email instanceof \WP_User ) || $by_email->ID !== $user_id ) { + $failures[] = array( + 'label' => 'lookup-mismatch', + 'input' => self::describe_string( $input ), + 'userId' => $user_id, + 'exists' => $exists, + 'byEmail' => self::describe_value( $by_email ), + ); + } + } + + $duplicate = \wp_insert_user( + array( + 'user_login' => 'cfz_email_duplicate_' . $ctx->iteration(), + 'user_pass' => 'component-fuzz-pass', + 'user_email' => $inputs[1], + 'role' => 'subscriber', + ) + ); + + if ( + count( array_filter( $inserted, 'is_int' ) ) !== count( $inputs ) + || count( array_unique( array_filter( $inserted, 'is_int' ), SORT_REGULAR ) ) !== count( $inputs ) + ) { + $failures[] = array( + 'label' => 'inserted-ids-not-distinct', + 'inserted' => self::describe_value( $inserted ), + ); + } + + if ( ! \is_wp_error( $duplicate ) || 'existing_user_email' !== $duplicate->get_error_code() ) { + $failures[] = array( + 'label' => 'exact-duplicate-not-rejected', + 'duplicate' => self::describe_value( $duplicate ), + ); + } + } finally { + self::reset_stub_content(); + } + + return array( + $ctx->result( + 'email.user-email-indexes.distinct-localparts-preserved', + array() === $failures, + array( + 'inputs' => array_map( array( self::class, 'describe_string' ), $inputs ), + 'inserted' => self::describe_value( $inserted ), + 'lookups' => self::describe_value( $lookups ), + 'failures' => self::describe_value( $failures ), + ) + ), + ); + } + + private static function check_user_email_indexes_generated_localpart_aliases( \ComponentFuzz\FuzzContext $ctx ): array { + if ( ! self::can_reset_stub_content() ) { + return array( + $ctx->skip( + 'email.user-email-indexes.generated-localpart-aliases-clean', + 'The in-memory wpdb content reset hook is unavailable.' + ), + ); + } + + $cases = self::generated_localpart_alias_cases( $ctx->fork( 'user-email-localpart-aliases' ) ); + $failures = array(); + $observed = array(); + $hook_snapshot = self::snapshot_hook_globals(); + $not_called = static function (): array { + return array( + 'threw' => false, + 'value' => null, + 'warnings' => array(), + ); + }; + + self::reset_stub_content(); + try { + \remove_all_filters( 'pre_user_email' ); + \add_filter( 'pre_user_email', 'trim' ); + \add_filter( 'pre_user_email', 'sanitize_email' ); + if ( function_exists( 'wp_filter_kses' ) ) { + \add_filter( 'pre_user_email', 'wp_filter_kses' ); + } + + foreach ( $cases as $case_index => $case ) { + self::reset_stub_content(); + + $inserted = array(); + $lookups = array(); + $canonical_addresses = array(); + + foreach ( $case['addresses'] as $address_index => $input ) { + $parse = self::capture_warnings( static fn() => \WP_Email_Address::from_string( $input, 'unicode' ) ); + $email = $parse['value'] ?? null; + $canonical = $email instanceof \WP_Email_Address ? $email->get_unicode_address() : null; + $login = 'cfz_alias_local_' . $ctx->iteration() . '_' . $case_index . '_' . $address_index . '_' . substr( sha1( $input ), 0, 8 ); + $insert = self::capture_warnings( + static fn() => \wp_insert_user( + array( + 'user_login' => $login, + 'user_pass' => 'component-fuzz-pass', + 'user_email' => $input, + 'role' => 'subscriber', + ) + ) + ); + $user_id = $insert['value'] ?? null; + $inserted[] = $user_id; + + $stored = is_int( $user_id ) ? self::capture_warnings( static fn() => \get_user_by( 'id', $user_id ) ) : $not_called(); + $exists_input = is_int( $user_id ) ? self::capture_warnings( static fn() => \email_exists( $input ) ) : $not_called(); + $by_input = is_int( $user_id ) ? self::capture_warnings( static fn() => \get_user_by( 'email', $input ) ) : $not_called(); + $exists_canonical = is_int( $user_id ) && is_string( $canonical ) ? self::capture_warnings( static fn() => \email_exists( $canonical ) ) : $not_called(); + $by_canonical = is_int( $user_id ) && is_string( $canonical ) ? self::capture_warnings( static fn() => \get_user_by( 'email', $canonical ) ) : $not_called(); + $stored_user = $stored['value'] ?? null; + $by_input_user = $by_input['value'] ?? null; + $by_canonical_user = $by_canonical['value'] ?? null; + + if ( is_string( $canonical ) ) { + $canonical_addresses[] = $canonical; + } + + $ok = ! $parse['threw'] + && ! $insert['threw'] + && ! $stored['threw'] + && ! $exists_input['threw'] + && ! $by_input['threw'] + && ! $exists_canonical['threw'] + && ! $by_canonical['threw'] + && array() === $parse['warnings'] + && array() === $insert['warnings'] + && array() === $stored['warnings'] + && array() === $exists_input['warnings'] + && array() === $by_input['warnings'] + && array() === $exists_canonical['warnings'] + && array() === $by_canonical['warnings'] + && $email instanceof \WP_Email_Address + && is_int( $user_id ) + && $stored_user instanceof \WP_User + && $stored_user->ID === $user_id + && $canonical === $stored_user->user_email + && $exists_input['value'] === $user_id + && $by_input_user instanceof \WP_User + && $by_input_user->ID === $user_id + && $exists_canonical['value'] === $user_id + && $by_canonical_user instanceof \WP_User + && $by_canonical_user->ID === $user_id; + + if ( ! $ok ) { + $failures[] = array( + 'label' => $case['label'], + 'input' => self::describe_string( $input ), + 'canonical' => is_string( $canonical ) ? self::describe_string( $canonical ) : null, + 'parse' => self::describe_captured_call( $parse ), + 'insert' => self::describe_captured_call( $insert ), + 'stored' => self::describe_captured_call( $stored ), + 'existsInput' => self::describe_captured_call( $exists_input ), + 'byInput' => self::describe_captured_call( $by_input ), + 'existsCanonical' => self::describe_captured_call( $exists_canonical ), + 'byCanonical' => self::describe_captured_call( $by_canonical ), + ); + } + + $lookups[] = array( + 'input' => self::describe_string( $input ), + 'canonical' => is_string( $canonical ) ? self::describe_string( $canonical ) : null, + 'userId' => $user_id, + 'warnings' => count( $parse['warnings'] ) + count( $insert['warnings'] ) + count( $stored['warnings'] ) + count( $exists_input['warnings'] ) + count( $by_input['warnings'] ) + count( $exists_canonical['warnings'] ) + count( $by_canonical['warnings'] ), + ); + } + + $ids = array_values( array_filter( $inserted, 'is_int' ) ); + if ( count( $ids ) !== count( $case['addresses'] ) || count( array_unique( $ids, SORT_REGULAR ) ) !== count( $case['addresses'] ) ) { + $failures[] = array( + 'label' => $case['label'], + 'failure' => 'inserted-ids-not-distinct', + 'inserted' => self::describe_value( $inserted ), + ); + } + + if ( count( $canonical_addresses ) !== count( $case['addresses'] ) || count( array_unique( $canonical_addresses, SORT_REGULAR ) ) !== count( $case['addresses'] ) ) { + $failures[] = array( + 'label' => $case['label'], + 'failure' => 'canonical-addresses-not-distinct', + 'addresses' => self::describe_value( $canonical_addresses ), + ); + } + + $duplicate = self::capture_warnings( + static fn() => \wp_insert_user( + array( + 'user_login' => 'cfz_alias_local_duplicate_' . $ctx->iteration() . '_' . $case_index, + 'user_pass' => 'component-fuzz-pass', + 'user_email' => $case['addresses'][1], + 'role' => 'subscriber', + ) + ) + ); + + if ( + $duplicate['threw'] || + array() !== $duplicate['warnings'] || + ! \is_wp_error( $duplicate['value'] ?? null ) || + 'existing_user_email' !== $duplicate['value']->get_error_code() + ) { + $failures[] = array( + 'label' => $case['label'], + 'failure' => 'exact-duplicate-not-rejected-cleanly', + 'duplicate' => self::describe_captured_call( $duplicate ), + ); + } + + $hostile_lookup = $case['addresses'][0]; + $hostile_candidate = $case['addresses'][1] ?? null; + $hostile_probe = array( + 'ok' => false, + 'reason' => 'not-run', + ); + if ( is_string( $hostile_candidate ) && $hostile_lookup !== $hostile_candidate ) { + self::reset_stub_content(); + $hostile_seed = self::seed_stub_user_email_row( + $hostile_candidate, + 'cfz_alias_local_hostile_' . $ctx->iteration() . '_' . $case_index + ); + if ( ! $hostile_seed['threw'] && array() === $hostile_seed['warnings'] && is_int( $hostile_seed['value'] ?? null ) ) { + $hostile_probe = self::hostile_user_email_lookup_probe( $hostile_lookup, $hostile_candidate ); + } else { + $hostile_probe = array( + 'ok' => false, + 'reason' => 'candidate-seed-failed', + 'seed' => self::describe_captured_call( $hostile_seed ), + ); + } + self::reset_stub_content(); + } + + if ( true !== ( $hostile_probe['ok'] ?? false ) ) { + $failures[] = array( + 'label' => $case['label'], + 'failure' => 'accent-folded-db-candidate-not-ignored', + 'lookup' => self::describe_string( $hostile_lookup ), + 'candidate' => is_string( $hostile_candidate ) ? self::describe_string( $hostile_candidate ) : null, + 'probe' => self::describe_value( $hostile_probe ), + ); + } + + $observed[] = array( + 'label' => $case['label'], + 'profile' => $case['profile'], + 'domain' => self::describe_string( $case['domain'] ), + 'addressCount' => count( $case['addresses'] ), + 'insertedIds' => self::describe_value( $inserted ), + 'lookups' => self::describe_value( $lookups ), + 'duplicateOk' => ! $duplicate['threw'] && array() === $duplicate['warnings'] && \is_wp_error( $duplicate['value'] ?? null ), + 'hostileLookup' => array( + 'lookup' => self::describe_string( $hostile_lookup ), + 'candidate' => is_string( $hostile_candidate ) ? self::describe_string( $hostile_candidate ) : null, + 'ok' => true === ( $hostile_probe['ok'] ?? false ), + ), + ); + } + } finally { + self::restore_hook_globals( $hook_snapshot ); + self::reset_stub_content(); + } + + return array( + $ctx->result( + 'email.user-email-indexes.generated-localpart-aliases-clean', + array() === $failures, + array( + 'caseCount' => count( $cases ), + 'observed' => $observed, + 'failures' => self::describe_value( $failures ), + ) + ), + ); + } + + private static function check_user_email_updates_generated_localpart_aliases( \ComponentFuzz\FuzzContext $ctx ): array { + $result_name = 'email.user-email-updates.generated-localpart-aliases-clean'; + + if ( ! self::can_reset_stub_content() ) { + return array( + $ctx->skip( + $result_name, + 'The in-memory wpdb content reset hook is unavailable.' + ), + ); + } + + $missing = array(); + foreach ( + array( + 'email_exists', + 'get_user_by', + 'is_email', + 'sanitize_email', + 'wp_insert_user', + 'wp_mail', + 'wp_update_user', + ) as $function + ) { + if ( ! function_exists( $function ) ) { + $missing[] = 'function ' . $function; + } + } + + if ( array() !== $missing ) { + return array( + $ctx->skip( + $result_name, + 'Required WordPress user update APIs are unavailable.', + array( 'missing' => implode( ', ', $missing ) ) + ), + ); + } + + $cases = self::generated_localpart_update_alias_cases( $ctx->fork( 'user-email-localpart-update-aliases' ) ); + $failures = array(); + $observed = array(); + $hook_snapshot = self::snapshot_hook_globals(); + $mail_calls = array(); + $mail_filter = static function ( $return, array $atts ) use ( &$mail_calls ) { + $mail_calls[] = $atts; + return true; + }; + + self::reset_stub_content(); + try { + self::install_email_filters( 'unicode' ); + \remove_all_filters( 'pre_user_email' ); + \add_filter( 'pre_user_email', 'trim' ); + \add_filter( 'pre_user_email', 'sanitize_email' ); + if ( function_exists( 'wp_filter_kses' ) ) { + \add_filter( 'pre_user_email', 'wp_filter_kses' ); + } + \remove_all_filters( 'pre_wp_mail' ); + \add_filter( 'pre_wp_mail', $mail_filter, PHP_INT_MAX, 2 ); + + foreach ( $cases as $case_index => $case ) { + self::reset_stub_content(); + $mail_calls = array(); + $canonical_addresses = array(); + $address_oracles = array(); + + foreach ( $case['addresses'] as $address ) { + $parse = self::capture_warnings( static fn() => \WP_Email_Address::from_string( $address, 'unicode' ) ); + $email = $parse['value'] ?? null; + $canonical = $email instanceof \WP_Email_Address ? $email->get_unicode_address() : null; + $is_email = self::capture_warnings( static fn() => \is_email( $address ) ); + $sanitized = self::capture_warnings( static fn() => \sanitize_email( $address ) ); + $oracle_ok = ! $parse['threw'] + && array() === $parse['warnings'] + && ! $is_email['threw'] + && array() === $is_email['warnings'] + && ! $sanitized['threw'] + && array() === $sanitized['warnings'] + && $email instanceof \WP_Email_Address + && $canonical === $is_email['value'] + && $canonical === $sanitized['value']; + + if ( ! $oracle_ok ) { + $failures[] = array( + 'label' => $case['label'], + 'failure' => 'address-oracle-mismatch', + 'address' => self::describe_string( $address ), + 'parse' => self::describe_captured_call( $parse ), + 'isEmail' => self::describe_captured_call( $is_email ), + 'sanitizeEmail' => self::describe_captured_call( $sanitized ), + ); + } + + if ( is_string( $canonical ) ) { + $canonical_addresses[] = $canonical; + } + + $address_oracles[] = array( + 'input' => self::describe_string( $address ), + 'canonical' => is_string( $canonical ) ? self::describe_string( $canonical ) : null, + 'ok' => $oracle_ok, + ); + } + + if ( + count( $canonical_addresses ) !== count( $case['addresses'] ) || + count( array_unique( $canonical_addresses, SORT_REGULAR ) ) !== count( $case['addresses'] ) + ) { + $failures[] = array( + 'label' => $case['label'], + 'failure' => 'canonical-addresses-not-distinct', + 'addresses' => self::describe_value( $case['addresses'] ), + 'canonical' => self::describe_value( $canonical_addresses ), + 'oracles' => $address_oracles, + ); + continue; + } + + $primary_email = $canonical_addresses[0]; + $holder_email = 'cfz-update-holder-' . $ctx->iteration() . '-' . $case_index . '-' . substr( sha1( $primary_email ), 0, 10 ) . '@example.org'; + $primary_login = 'cfz_update_primary_' . $ctx->iteration() . '_' . $case_index . '_' . substr( sha1( $primary_email ), 0, 8 ); + $other_login = 'cfz_update_other_' . $ctx->iteration() . '_' . $case_index . '_' . substr( sha1( $holder_email ), 0, 8 ); + + $primary_insert = self::capture_warnings( + static fn() => \wp_insert_user( + array( + 'user_login' => $primary_login, + 'user_pass' => 'component-fuzz-pass', + 'user_email' => $primary_email, + 'role' => 'subscriber', + ) + ) + ); + $other_insert = self::capture_warnings( + static fn() => \wp_insert_user( + array( + 'user_login' => $other_login, + 'user_pass' => 'component-fuzz-pass', + 'user_email' => $holder_email, + 'role' => 'subscriber', + ) + ) + ); + $primary_id = $primary_insert['value'] ?? null; + $other_id = $other_insert['value'] ?? null; + + if ( + $primary_insert['threw'] || + $other_insert['threw'] || + array() !== $primary_insert['warnings'] || + array() !== $other_insert['warnings'] || + ! is_int( $primary_id ) || + ! is_int( $other_id ) + ) { + $failures[] = array( + 'label' => $case['label'], + 'failure' => 'seed-users-not-inserted-cleanly', + 'primaryEmail' => self::describe_string( $primary_email ), + 'holderEmail' => self::describe_string( $holder_email ), + 'primaryInsert' => self::describe_captured_call( $primary_insert ), + 'otherInsert' => self::describe_captured_call( $other_insert ), + ); + continue; + } + + $current_email = $holder_email; + $updates = array(); + foreach ( array_slice( $case['addresses'], 1, null, true ) as $address_index => $input_address ) { + $expected_email = $canonical_addresses[ $address_index ]; + $mail_before = count( $mail_calls ); + $update = self::capture_warnings( + static fn() => \wp_update_user( + array( + 'ID' => $other_id, + 'user_email' => $input_address, + ) + ) + ); + $stored = self::capture_warnings( static fn() => \get_user_by( 'id', $other_id ) ); + $by_new = self::capture_warnings( static fn() => \get_user_by( 'email', $expected_email ) ); + $exists_new = self::capture_warnings( static fn() => \email_exists( $expected_email ) ); + $by_old = self::capture_warnings( static fn() => \get_user_by( 'email', $current_email ) ); + $exists_old = self::capture_warnings( static fn() => \email_exists( $current_email ) ); + $primary_exists = self::capture_warnings( static fn() => \email_exists( $primary_email ) ); + $mail_slice = array_slice( $mail_calls, $mail_before ); + $mail = $mail_slice[0] ?? null; + $message = is_array( $mail ) && is_string( $mail['message'] ?? null ) ? $mail['message'] : ''; + $stored_user = $stored['value'] ?? null; + $new_user = $by_new['value'] ?? null; + $update_ok = ! $update['threw'] + && array() === $update['warnings'] + && $update['value'] === $other_id + && ! $stored['threw'] + && array() === $stored['warnings'] + && $stored_user instanceof \WP_User + && $stored_user->ID === $other_id + && $expected_email === $stored_user->user_email + && ! $by_new['threw'] + && array() === $by_new['warnings'] + && $new_user instanceof \WP_User + && $new_user->ID === $other_id + && ! $exists_new['threw'] + && array() === $exists_new['warnings'] + && $exists_new['value'] === $other_id + && ! $by_old['threw'] + && array() === $by_old['warnings'] + && false === $by_old['value'] + && ! $exists_old['threw'] + && array() === $exists_old['warnings'] + && false === $exists_old['value'] + && ! $primary_exists['threw'] + && array() === $primary_exists['warnings'] + && $primary_exists['value'] === $primary_id; + $mail_ok = 1 === count( $mail_slice ) + && is_array( $mail ) + && $current_email === ( $mail['to'] ?? null ) + && is_string( $mail['subject'] ?? null ) + && '' !== ( $mail['subject'] ?? '' ) + && str_contains( $message, $current_email ) + && str_contains( $message, $expected_email ); + + if ( ! $update_ok || ! $mail_ok ) { + $failures[] = array( + 'label' => $case['label'], + 'failure' => 'update-alias-not-preserved-cleanly', + 'input' => self::describe_string( $input_address ), + 'previousEmail' => self::describe_string( $current_email ), + 'expectedEmail' => self::describe_string( $expected_email ), + 'update' => self::describe_captured_call( $update ), + 'stored' => self::describe_captured_call( $stored ), + 'byNew' => self::describe_captured_call( $by_new ), + 'existsNew' => self::describe_captured_call( $exists_new ), + 'byOld' => self::describe_captured_call( $by_old ), + 'existsOld' => self::describe_captured_call( $exists_old ), + 'primaryExists' => self::describe_captured_call( $primary_exists ), + 'mailCalls' => self::describe_value( $mail_slice ), + 'updateOk' => $update_ok, + 'mailOk' => $mail_ok, + ); + } + + $updates[] = array( + 'input' => self::describe_string( $input_address ), + 'previousEmail' => self::describe_string( $current_email ), + 'expectedEmail' => self::describe_string( $expected_email ), + 'ok' => $update_ok && $mail_ok, + 'mailTo' => is_array( $mail ) && is_string( $mail['to'] ?? null ) ? self::describe_string( $mail['to'] ) : null, + ); + $current_email = $expected_email; + } + + $mail_before = count( $mail_calls ); + $collision_update = self::capture_warnings( + static fn() => \wp_update_user( + array( + 'ID' => $other_id, + 'user_email' => $case['addresses'][0], + ) + ) + ); + $stored_after_collision = self::capture_warnings( static fn() => \get_user_by( 'id', $other_id ) ); + $primary_lookup = self::capture_warnings( static fn() => \email_exists( $primary_email ) ); + $current_lookup = self::capture_warnings( static fn() => \email_exists( $current_email ) ); + $stored_after_user = $stored_after_collision['value'] ?? null; + $collision_ok = ! $collision_update['threw'] + && array() === $collision_update['warnings'] + && \is_wp_error( $collision_update['value'] ?? null ) + && 'existing_user_email' === $collision_update['value']->get_error_code() + && ! $stored_after_collision['threw'] + && array() === $stored_after_collision['warnings'] + && $stored_after_user instanceof \WP_User + && $stored_after_user->ID === $other_id + && $current_email === $stored_after_user->user_email + && ! $primary_lookup['threw'] + && array() === $primary_lookup['warnings'] + && $primary_lookup['value'] === $primary_id + && ! $current_lookup['threw'] + && array() === $current_lookup['warnings'] + && $current_lookup['value'] === $other_id + && $mail_before === count( $mail_calls ); + + if ( ! $collision_ok ) { + $failures[] = array( + 'label' => $case['label'], + 'failure' => 'exact-collision-update-not-rejected-cleanly', + 'primaryEmail' => self::describe_string( $primary_email ), + 'currentEmail' => self::describe_string( $current_email ), + 'collisionUpdate' => self::describe_captured_call( $collision_update ), + 'storedAfterCollision' => self::describe_captured_call( $stored_after_collision ), + 'primaryLookup' => self::describe_captured_call( $primary_lookup ), + 'currentLookup' => self::describe_captured_call( $current_lookup ), + 'mailCalls' => self::describe_value( array_slice( $mail_calls, $mail_before ) ), + ); + } + + $observed[] = array( + 'label' => $case['label'], + 'profile' => $case['profile'], + 'domain' => self::describe_string( $case['domain'] ), + 'addressCount' => count( $case['addresses'] ), + 'canonical' => self::describe_value( $canonical_addresses ), + 'updates' => $updates, + 'collisionRejected' => $collision_ok, + 'mailCalls' => count( $mail_calls ), + ); + } + } finally { + self::restore_hook_globals( $hook_snapshot ); + self::reset_stub_content(); + } + + return array( + $ctx->result( + $result_name, + array() === $failures, + array( + 'caseCount' => count( $cases ), + 'observed' => $observed, + 'failures' => self::describe_value( $failures ), + ) + ), + ); + } + + private static function check_user_email_authentication_unicode_paths( \ComponentFuzz\FuzzContext $ctx ): array { + $result_name = 'email.user-email-authentication.unicode-exact-addresses'; + + if ( ! self::can_reset_stub_content() ) { + return array( + $ctx->skip( + $result_name, + 'The in-memory wpdb content reset hook is unavailable.' + ), + ); + } + + $missing = array(); + foreach ( + array( + 'email_exists', + 'get_user_by', + 'is_email', + 'is_wp_error', + 'sanitize_email', + 'wp_authenticate_email_password', + 'wp_insert_user', + ) as $function + ) { + if ( ! function_exists( $function ) ) { + $missing[] = 'function ' . $function; + } + } + + foreach ( array( 'WP_Email_Address', 'WP_Error', 'WP_User' ) as $class ) { + if ( ! class_exists( $class ) ) { + $missing[] = 'class ' . $class; + } + } + + if ( array() !== $missing ) { + return array( + $ctx->skip( + $result_name, + 'Required WordPress user authentication APIs are unavailable.', + array( 'missing' => implode( ', ', $missing ) ) + ), + ); + } + + $cases = self::generated_authentication_cases( $ctx->fork( 'user-email-authentication' ) ); + $failures = array(); + $observed = array(); + $hook_snapshot = self::snapshot_hook_globals(); + + self::reset_stub_content(); + try { + self::install_email_filters( 'unicode' ); + \remove_all_filters( 'pre_user_email' ); + \add_filter( 'pre_user_email', 'trim' ); + \add_filter( 'pre_user_email', 'sanitize_email' ); + if ( function_exists( 'wp_filter_kses' ) ) { + \add_filter( 'pre_user_email', 'wp_filter_kses' ); + } + \remove_all_filters( 'wp_authenticate_user' ); + + foreach ( $cases as $case_index => $case ) { + self::reset_stub_content(); + self::install_email_filters( 'unicode' ); + + $parse = self::capture_warnings( static fn() => \WP_Email_Address::from_string( $case['input'], 'unicode' ) ); + $alias_parse = self::capture_warnings( static fn() => \WP_Email_Address::from_string( $case['aliasInput'], 'unicode' ) ); + $email = $parse['value'] ?? null; + $alias_email = $alias_parse['value'] ?? null; + + if ( + null !== $case['conversionError'] + || $parse['threw'] + || $alias_parse['threw'] + || array() !== $parse['warnings'] + || array() !== $alias_parse['warnings'] + || ! $email instanceof \WP_Email_Address + || ! $alias_email instanceof \WP_Email_Address + ) { + $failures[] = array( + 'label' => $case['label'], + 'failure' => 'authentication-address-oracle-unavailable', + 'input' => self::describe_string( $case['input'] ), + 'aliasInput' => self::describe_string( $case['aliasInput'] ), + 'conversionError' => $case['conversionError'], + 'parse' => self::describe_captured_call( $parse ), + 'aliasParse' => self::describe_captured_call( $alias_parse ), + ); + continue; + } + + $canonical = $email->get_unicode_address(); + $machine = $email->get_ascii_address(); + $alias_canonical = $alias_email->get_unicode_address(); + $password = 'cfz-auth-pass-' . $ctx->iteration() . '-' . $case_index . '-' . substr( sha1( $canonical ), 0, 12 ); + $alias_password = 'cfz-auth-alias-pass-' . $ctx->iteration() . '-' . $case_index . '-' . substr( sha1( $alias_canonical ), 0, 12 ); + $login = 'cfz_auth_' . $ctx->iteration() . '_' . $case_index . '_' . substr( sha1( $canonical ), 0, 8 ); + $alias_login = 'cfz_auth_alias_' . $ctx->iteration() . '_' . $case_index . '_' . substr( sha1( $alias_canonical ), 0, 8 ); + + if ( $canonical === $alias_canonical ) { + $failures[] = array( + 'label' => $case['label'], + 'failure' => 'authentication-alias-not-distinct', + 'canonical' => self::describe_string( $canonical ), + 'alias' => self::describe_string( $alias_canonical ), + ); + continue; + } + + $insert = self::capture_warnings( + static fn() => \wp_insert_user( + array( + 'user_login' => $login, + 'user_pass' => $password, + 'user_email' => $case['input'], + 'role' => 'subscriber', + ) + ) + ); + $user_id = $insert['value'] ?? null; + + $alias_insert = self::capture_warnings( + static fn() => \wp_insert_user( + array( + 'user_login' => $alias_login, + 'user_pass' => $alias_password, + 'user_email' => $case['aliasInput'], + 'role' => 'subscriber', + ) + ) + ); + $alias_id = $alias_insert['value'] ?? null; + + $stored = is_int( $user_id ) ? self::capture_warnings( static fn() => \get_user_by( 'id', $user_id ) ) : self::not_called(); + $by_canonical = is_int( $user_id ) ? self::capture_warnings( static fn() => \get_user_by( 'email', $canonical ) ) : self::not_called(); + $exists_canonical = is_int( $user_id ) ? self::capture_warnings( static fn() => \email_exists( $canonical ) ) : self::not_called(); + $alias_stored = is_int( $alias_id ) ? self::capture_warnings( static fn() => \get_user_by( 'id', $alias_id ) ) : self::not_called(); + $alias_by_email = is_int( $alias_id ) ? self::capture_warnings( static fn() => \get_user_by( 'email', $alias_canonical ) ) : self::not_called(); + $auth = is_int( $user_id ) ? self::capture_warnings( static fn() => \wp_authenticate_email_password( null, $canonical, $password ) ) : self::not_called(); + $wrong_password = is_int( $user_id ) ? self::capture_warnings( static fn() => \wp_authenticate_email_password( null, $canonical, $alias_password ) ) : self::not_called(); + $alias_auth = is_int( $alias_id ) ? self::capture_warnings( static fn() => \wp_authenticate_email_password( null, $alias_canonical, $alias_password ) ) : self::not_called(); + $alias_with_target_password = is_int( $alias_id ) ? self::capture_warnings( static fn() => \wp_authenticate_email_password( null, $alias_canonical, $password ) ) : self::not_called(); + $machine_auth = $machine !== $canonical && is_int( $user_id ) + ? self::capture_warnings( static fn() => \wp_authenticate_email_password( null, $machine, $password ) ) + : self::not_called(); + + $stored_user = $stored['value'] ?? null; + $canonical_user = $by_canonical['value'] ?? null; + $alias_stored_user = $alias_stored['value'] ?? null; + $alias_lookup_user = $alias_by_email['value'] ?? null; + $auth_user = $auth['value'] ?? null; + $alias_auth_user = $alias_auth['value'] ?? null; + $wrong_error = $wrong_password['value'] ?? null; + $alias_wrong_error = $alias_with_target_password['value'] ?? null; + $machine_auth_value = $machine_auth['value'] ?? null; + + $insert_lookup_ok = ! $insert['threw'] + && ! $alias_insert['threw'] + && ! $stored['threw'] + && ! $by_canonical['threw'] + && ! $exists_canonical['threw'] + && ! $alias_stored['threw'] + && ! $alias_by_email['threw'] + && array() === $insert['warnings'] + && array() === $alias_insert['warnings'] + && array() === $stored['warnings'] + && array() === $by_canonical['warnings'] + && array() === $exists_canonical['warnings'] + && array() === $alias_stored['warnings'] + && array() === $alias_by_email['warnings'] + && is_int( $user_id ) + && is_int( $alias_id ) + && $user_id !== $alias_id + && $stored_user instanceof \WP_User + && $stored_user->ID === $user_id + && $canonical === $stored_user->user_email + && $canonical_user instanceof \WP_User + && $canonical_user->ID === $user_id + && $exists_canonical['value'] === $user_id + && $alias_stored_user instanceof \WP_User + && $alias_stored_user->ID === $alias_id + && $alias_canonical === $alias_stored_user->user_email + && $alias_lookup_user instanceof \WP_User + && $alias_lookup_user->ID === $alias_id; + + $auth_ok = ! $auth['threw'] + && ! $wrong_password['threw'] + && ! $alias_auth['threw'] + && ! $alias_with_target_password['threw'] + && array() === $auth['warnings'] + && array() === $wrong_password['warnings'] + && array() === $alias_auth['warnings'] + && array() === $alias_with_target_password['warnings'] + && $auth_user instanceof \WP_User + && $auth_user->ID === $user_id + && $canonical === $auth_user->user_email + && \is_wp_error( $wrong_error ) + && 'incorrect_password' === $wrong_error->get_error_code() + && $alias_auth_user instanceof \WP_User + && $alias_auth_user->ID === $alias_id + && $alias_canonical === $alias_auth_user->user_email + && \is_wp_error( $alias_wrong_error ) + && 'incorrect_password' === $alias_wrong_error->get_error_code(); + + $machine_probe_ok = $machine === $canonical + ? null === $machine_auth['value'] + : ( + ! $machine_auth['threw'] + && array() === $machine_auth['warnings'] + && \is_wp_error( $machine_auth_value ) + && 'invalid_email' === $machine_auth_value->get_error_code() + ); + + if ( ! $insert_lookup_ok || ! $auth_ok || ! $machine_probe_ok ) { + $failures[] = array( + 'label' => $case['label'], + 'profile' => $case['profile'], + 'input' => self::describe_string( $case['input'] ), + 'canonical' => self::describe_string( $canonical ), + 'machine' => self::describe_string( $machine ), + 'alias' => self::describe_string( $alias_canonical ), + 'insert' => self::describe_captured_call( $insert ), + 'aliasInsert' => self::describe_captured_call( $alias_insert ), + 'stored' => self::describe_captured_call( $stored ), + 'byCanonical' => self::describe_captured_call( $by_canonical ), + 'existsCanonical' => self::describe_captured_call( $exists_canonical ), + 'aliasStored' => self::describe_captured_call( $alias_stored ), + 'aliasByEmail' => self::describe_captured_call( $alias_by_email ), + 'auth' => self::describe_captured_call( $auth ), + 'wrongPassword' => self::describe_captured_call( $wrong_password ), + 'aliasAuth' => self::describe_captured_call( $alias_auth ), + 'aliasWithTargetPassword' => self::describe_captured_call( $alias_with_target_password ), + 'machineAuth' => self::describe_captured_call( $machine_auth ), + 'insertLookupOk' => $insert_lookup_ok, + 'authOk' => $auth_ok, + 'machineProbeOk' => $machine_probe_ok, + ); + } + + $observed[] = array( + 'label' => $case['label'], + 'profile' => $case['profile'], + 'source' => $case['source'], + 'inputDomainView' => $case['inputDomainView'], + 'canonical' => self::describe_string( $canonical ), + 'machineDiffers' => $machine !== $canonical, + 'alias' => self::describe_string( $alias_canonical ), + 'userId' => $user_id, + 'aliasId' => $alias_id, + 'exactAuthUserId' => $auth_user instanceof \WP_User ? $auth_user->ID : null, + 'aliasAuthUserId' => $alias_auth_user instanceof \WP_User ? $alias_auth_user->ID : null, + 'machineRejected' => $machine !== $canonical ? \is_wp_error( $machine_auth_value ) : null, + ); + } + } finally { + self::restore_hook_globals( $hook_snapshot ); + self::reset_stub_content(); + } + + return array( + $ctx->result( + $result_name, + array() === $failures, + array( + 'caseCount' => count( $cases ), + 'observed' => $observed, + 'failures' => self::describe_value( $failures ), + ) + ), + ); + } + + private static function check_profile_email_confirmation_unicode_paths( \ComponentFuzz\FuzzContext $ctx ): array { + $result_name = 'email.profile-confirmation.unicode-email-change-paths'; + + if ( ! self::can_reset_stub_content() ) { + return array( + $ctx->skip( + $result_name, + 'The in-memory wpdb content reset hook is unavailable.' + ), + ); + } + + $missing = array(); + foreach ( + array( + 'delete_user_meta', + 'email_exists', + 'get_user_by', + 'get_user_meta', + 'is_email', + 'sanitize_email', + 'send_confirmation_on_profile_email', + 'update_user_meta', + 'wp_insert_user', + 'wp_mail', + 'wp_set_current_user', + ) as $function + ) { + if ( ! function_exists( $function ) ) { + $missing[] = 'function ' . $function; + } + } + + foreach ( array( 'WP_Email_Address', 'WP_Error', 'WP_User' ) as $class ) { + if ( ! class_exists( $class ) ) { + $missing[] = 'class ' . $class; + } + } + + if ( array() !== $missing ) { + return array( + $ctx->skip( + $result_name, + 'Required WordPress profile email confirmation APIs are unavailable.', + array( 'missing' => implode( ', ', $missing ) ) + ), + ); + } + + $cases = self::generated_profile_confirmation_cases( $ctx->fork( 'profile-email-confirmation' ) ); + $failures = array(); + $observed = array(); + $mail_calls = array(); + $content_events = array(); + $hook_snapshot = self::snapshot_hook_globals(); + $global_snapshot = self::snapshot_named_globals( + array( + 'current_user', + 'errors', + 'user_ID', + 'userdata', + 'user_email', + 'user_identity', + 'user_level', + 'user_login', + 'user_url', + ) + ); + $post_snapshot = $_POST; + $mail_filter = static function ( $return, array $atts ) use ( &$mail_calls ) { + $mail_calls[] = $atts; + return true; + }; + $content_filter = static function ( string $content, array $new_user_email ) use ( &$content_events ): string { + $content_events[] = $new_user_email; + return $content . "\nFiltered confirmation for ###EMAIL### via ###ADMIN_URL###"; + }; + $option_values = array( + 'pre_option_blogname' => 'Component Fuzz Profile Site', + 'pre_option_home' => 'http://profile.example.test', + 'pre_option_siteurl' => 'http://profile.example.test/wp', + ); + + self::reset_stub_content(); + try { + self::install_email_filters( 'unicode' ); + \remove_all_filters( 'pre_user_email' ); + \add_filter( 'pre_user_email', 'trim' ); + \add_filter( 'pre_user_email', 'sanitize_email' ); + if ( function_exists( 'wp_filter_kses' ) ) { + \add_filter( 'pre_user_email', 'wp_filter_kses' ); + } + \remove_all_filters( 'pre_wp_mail' ); + \add_filter( 'pre_wp_mail', $mail_filter, PHP_INT_MAX, 2 ); + \remove_all_filters( 'new_user_email_content' ); + \add_filter( 'new_user_email_content', $content_filter, 10, 2 ); + foreach ( $option_values as $hook => $value ) { + \remove_all_filters( $hook ); + \add_filter( + $hook, + static function () use ( $value ) { + return $value; + } + ); + } + + foreach ( $cases as $case_index => $case ) { + $mail_calls = array(); + $content_events = array(); + + $parse = self::capture_warnings( static fn() => \WP_Email_Address::from_string( $case['email'], 'unicode' ) ); + $email = $parse['value'] ?? null; + $canonical = $email instanceof \WP_Email_Address ? $email->get_unicode_address() : null; + $is_email = self::capture_warnings( static fn() => \is_email( $case['email'] ) ); + $sanitized = self::capture_warnings( static fn() => \sanitize_email( $case['email'] ) ); + + if ( + $parse['threw'] + || array() !== $parse['warnings'] + || ! $email instanceof \WP_Email_Address + || $is_email['value'] !== $canonical + || $sanitized['value'] !== $canonical + ) { + $failures[] = array( + 'label' => $case['label'], + 'failure' => 'profile-confirmation-address-oracle-mismatch', + 'email' => self::describe_string( $case['email'] ), + 'parse' => self::describe_captured_call( $parse ), + 'isEmail' => self::describe_captured_call( $is_email ), + 'sanitizeEmail' => self::describe_captured_call( $sanitized ), + ); + continue; + } + + $success = self::exercise_profile_email_confirmation_success( $ctx, $case_index, $case, $canonical, $mail_calls, $content_events ); + if ( ! ( $success['ok'] ?? false ) ) { + $failures[] = $success; + } + + $duplicate = self::exercise_profile_email_confirmation_duplicate( $ctx, $case_index, $case, $canonical, $mail_calls, $content_events ); + if ( ! ( $duplicate['ok'] ?? false ) ) { + $failures[] = $duplicate; + } + + $invalid = self::exercise_profile_email_confirmation_invalid( $ctx, $case_index, $case, $mail_calls, $content_events ); + if ( ! ( $invalid['ok'] ?? false ) ) { + $failures[] = $invalid; + } + + $wrong_user = self::exercise_profile_email_confirmation_wrong_user( $ctx, $case_index, $case, $canonical, $mail_calls, $content_events ); + if ( ! ( $wrong_user['ok'] ?? false ) ) { + $failures[] = $wrong_user; + } + + $same_email = self::exercise_profile_email_confirmation_same_email( $ctx, $case_index, $case, $canonical, $mail_calls, $content_events ); + if ( ! ( $same_email['ok'] ?? false ) ) { + $failures[] = $same_email; + } + + $observed[] = array( + 'label' => $case['label'], + 'profile' => $case['profile'], + 'email' => self::describe_string( $canonical ), + 'success' => $success['summary'] ?? null, + 'duplicate' => $duplicate['summary'] ?? null, + 'invalid' => $invalid['summary'] ?? null, + 'wrongUser' => $wrong_user['summary'] ?? null, + 'sameEmail' => $same_email['summary'] ?? null, + ); + } + } finally { + $_POST = $post_snapshot; + self::restore_named_globals( $global_snapshot ); + self::restore_hook_globals( $hook_snapshot ); + self::reset_stub_content(); + } + + return array( + $ctx->result( + $result_name, + array() === $failures, + array( + 'caseCount' => count( $cases ), + 'observed' => $observed, + 'failures' => self::describe_value( $failures ), + ) + ), + ); + } + + private static function exercise_profile_email_confirmation_success( \ComponentFuzz\FuzzContext $ctx, int $case_index, array $case, string $email, array &$mail_calls, array &$content_events ): array { + self::reset_stub_content(); + \wp_set_current_user( 0 ); + $mail_calls = array(); + $content_events = array(); + + $current_email = 'profile-current-' . $ctx->iteration() . '-' . $case_index . '-' . substr( sha1( $email ), 0, 10 ) . '@example.org'; + $login = 'cfz_profile_' . $ctx->iteration() . '_' . $case_index . '_' . substr( sha1( $email ), 0, 8 ); + $insert = self::capture_warnings( + static fn() => \wp_insert_user( + array( + 'user_login' => $login, + 'user_pass' => 'component-fuzz-pass', + 'user_email' => $current_email, + 'role' => 'subscriber', + ) + ) + ); + $user_id = $insert['value'] ?? null; + + if ( $insert['threw'] || array() !== $insert['warnings'] || ! is_int( $user_id ) ) { + return array( + 'ok' => false, + 'label' => $case['label'], + 'branch' => 'success', + 'failure' => 'profile-confirmation-user-insert-failed', + 'insert' => self::describe_captured_call( $insert ), + ); + } + + \wp_set_current_user( $user_id ); + $GLOBALS['errors'] = new \WP_Error(); + $_POST = array( + 'user_id' => (string) $user_id, + 'email' => $email, + ); + + $call = self::capture_warnings( static fn() => \send_confirmation_on_profile_email() ); + $meta = self::capture_warnings( static fn() => \get_user_meta( $user_id, '_new_email', true ) ); + $stored = self::capture_warnings( static fn() => \get_user_by( 'id', $user_id ) ); + $mail = $mail_calls[0] ?? null; + $event = $content_events[0] ?? null; + $meta_value = $meta['value'] ?? null; + $stored_user = $stored['value'] ?? null; + $hash = is_array( $meta_value ) && is_string( $meta_value['hash'] ?? null ) ? $meta_value['hash'] : ''; + $message = is_array( $mail ) && is_string( $mail['message'] ?? null ) ? $mail['message'] : ''; + $subject = is_array( $mail ) && is_string( $mail['subject'] ?? null ) ? $mail['subject'] : ''; + $error_codes = $GLOBALS['errors'] instanceof \WP_Error ? $GLOBALS['errors']->get_error_codes() : array( 'missing-error-object' ); + $expected_url = '' !== $hash ? \esc_url( \self_admin_url( 'profile.php?newuseremail=' . $hash ) ) : ''; + $ok = ! $call['threw'] + && array() === $call['warnings'] + && null === $call['value'] + && ! $meta['threw'] + && array() === $meta['warnings'] + && is_array( $meta_value ) + && $email === ( $meta_value['newemail'] ?? null ) + && 1 === preg_match( '/^[a-f0-9]{32}$/', $hash ) + && ! $stored['threw'] + && array() === $stored['warnings'] + && $stored_user instanceof \WP_User + && $stored_user->ID === $user_id + && $current_email === $stored_user->user_email + && 1 === count( $content_events ) + && is_array( $event ) + && $hash === ( $event['hash'] ?? null ) + && $email === ( $event['newemail'] ?? null ) + && 1 === count( $mail_calls ) + && is_array( $mail ) + && $email === ( $mail['to'] ?? null ) + && '[Component Fuzz Profile Site] Email Change Request' === $subject + && str_contains( $message, $login ) + && str_contains( $message, $email ) + && '' !== $expected_url + && str_contains( $message, $expected_url ) + && str_contains( $message, 'Filtered confirmation for ' . $email . ' via ' . $expected_url ) + && ! str_contains( $message, '###' ) + && $current_email === ( $_POST['email'] ?? null ) + && array() === $error_codes; + + return array( + 'ok' => $ok, + 'label' => $case['label'], + 'branch' => 'success', + 'failure' => $ok ? null : 'profile-confirmation-success-oracle-mismatch', + 'summary' => array( + 'userId' => $user_id, + 'email' => self::describe_string( $email ), + 'currentEmail' => self::describe_string( $current_email ), + 'hash' => $hash, + 'mailTo' => is_array( $mail ) && is_string( $mail['to'] ?? null ) ? self::describe_string( $mail['to'] ) : null, + ), + 'details' => $ok ? null : array( + 'call' => self::describe_captured_call( $call ), + 'meta' => self::describe_captured_call( $meta ), + 'stored' => self::describe_captured_call( $stored ), + 'mailCalls' => self::describe_value( $mail_calls ), + 'contentEvents' => self::describe_value( $content_events ), + 'expectedUrl' => $expected_url, + 'postEmail' => self::describe_value( $_POST['email'] ?? null ), + 'errors' => self::describe_value( $error_codes ), + ), + ); + } + + private static function exercise_profile_email_confirmation_duplicate( \ComponentFuzz\FuzzContext $ctx, int $case_index, array $case, string $email, array &$mail_calls, array &$content_events ): array { + self::reset_stub_content(); + \wp_set_current_user( 0 ); + $mail_calls = array(); + $content_events = array(); + + $current_email = 'profile-duplicate-current-' . $ctx->iteration() . '-' . $case_index . '-' . substr( sha1( $email ), 0, 10 ) . '@example.org'; + $current_login = 'cfz_profile_dup_current_' . $ctx->iteration() . '_' . $case_index . '_' . substr( sha1( $current_email ), 0, 8 ); + $other_login = 'cfz_profile_dup_other_' . $ctx->iteration() . '_' . $case_index . '_' . substr( sha1( $email ), 0, 8 ); + $current = self::capture_warnings( + static fn() => \wp_insert_user( + array( + 'user_login' => $current_login, + 'user_pass' => 'component-fuzz-pass', + 'user_email' => $current_email, + 'role' => 'subscriber', + ) + ) + ); + $duplicate = self::capture_warnings( + static fn() => \wp_insert_user( + array( + 'user_login' => $other_login, + 'user_pass' => 'component-fuzz-pass', + 'user_email' => $email, + 'role' => 'subscriber', + ) + ) + ); + $user_id = $current['value'] ?? null; + + if ( $current['threw'] || $duplicate['threw'] || array() !== $current['warnings'] || array() !== $duplicate['warnings'] || ! is_int( $user_id ) || ! is_int( $duplicate['value'] ?? null ) ) { + return array( + 'ok' => false, + 'label' => $case['label'], + 'branch' => 'duplicate', + 'failure' => 'profile-confirmation-duplicate-users-not-inserted', + 'current' => self::describe_captured_call( $current ), + 'duplicate' => self::describe_captured_call( $duplicate ), + ); + } + + $stale_meta = array( 'hash' => 'stale', 'newemail' => 'stale@example.org' ); + \update_user_meta( $user_id, '_new_email', $stale_meta ); + $meta_before = self::capture_warnings( static fn() => \get_user_meta( $user_id, '_new_email', true ) ); + \wp_set_current_user( $user_id ); + $GLOBALS['errors'] = new \WP_Error(); + $_POST = array( + 'user_id' => (string) $user_id, + 'email' => $email, + ); + + $call = self::capture_warnings( static fn() => \send_confirmation_on_profile_email() ); + $meta = self::capture_warnings( static fn() => \get_user_meta( $user_id, '_new_email', true ) ); + $error_codes = $GLOBALS['errors'] instanceof \WP_Error ? $GLOBALS['errors']->get_error_codes() : array( 'missing-error-object' ); + $error_data = $GLOBALS['errors'] instanceof \WP_Error ? $GLOBALS['errors']->get_error_data( 'user_email' ) : null; + $ok = ! $call['threw'] + && array() === $call['warnings'] + && null === $call['value'] + && ! $meta_before['threw'] + && array() === $meta_before['warnings'] + && $stale_meta === $meta_before['value'] + && ! $meta['threw'] + && array() === $meta['warnings'] + && '' === $meta['value'] + && in_array( 'user_email', $error_codes, true ) + && array( 'form-field' => 'email' ) === $error_data + && 0 === count( $mail_calls ) + && 0 === count( $content_events ) + && $email === ( $_POST['email'] ?? null ); + + return array( + 'ok' => $ok, + 'label' => $case['label'], + 'branch' => 'duplicate', + 'failure' => $ok ? null : 'profile-confirmation-duplicate-oracle-mismatch', + 'summary' => array( + 'userId' => $user_id, + 'email' => self::describe_string( $email ), + 'errors' => $error_codes, + 'mailCalls' => count( $mail_calls ), + 'contentEvents' => count( $content_events ), + 'metaCleared' => '' === ( $meta['value'] ?? null ), + ), + 'details' => $ok ? null : array( + 'call' => self::describe_captured_call( $call ), + 'metaBefore' => self::describe_captured_call( $meta_before ), + 'meta' => self::describe_captured_call( $meta ), + 'mailCalls' => self::describe_value( $mail_calls ), + 'contentEvents' => self::describe_value( $content_events ), + 'postEmail' => self::describe_value( $_POST['email'] ?? null ), + 'errors' => self::describe_value( $error_codes ), + 'errorData' => self::describe_value( $error_data ), + ), + ); + } + + private static function exercise_profile_email_confirmation_invalid( \ComponentFuzz\FuzzContext $ctx, int $case_index, array $case, array &$mail_calls, array &$content_events ): array { + self::reset_stub_content(); + \wp_set_current_user( 0 ); + $mail_calls = array(); + $content_events = array(); + + $current_email = 'profile-invalid-current-' . $ctx->iteration() . '-' . $case_index . '-' . substr( sha1( $case['invalid'] ), 0, 10 ) . '@example.org'; + $login = 'cfz_profile_invalid_' . $ctx->iteration() . '_' . $case_index . '_' . substr( sha1( $current_email ), 0, 8 ); + $insert = self::capture_warnings( + static fn() => \wp_insert_user( + array( + 'user_login' => $login, + 'user_pass' => 'component-fuzz-pass', + 'user_email' => $current_email, + 'role' => 'subscriber', + ) + ) + ); + $user_id = $insert['value'] ?? null; + + if ( $insert['threw'] || array() !== $insert['warnings'] || ! is_int( $user_id ) ) { + return array( + 'ok' => false, + 'label' => $case['label'], + 'branch' => 'invalid', + 'failure' => 'profile-confirmation-invalid-user-insert-failed', + 'insert' => self::describe_captured_call( $insert ), + ); + } + + $stale_meta = array( 'hash' => 'stale', 'newemail' => 'stale@example.org' ); + \update_user_meta( $user_id, '_new_email', $stale_meta ); + \wp_set_current_user( $user_id ); + $GLOBALS['errors'] = 'not-an-error-object'; + $_POST = array( + 'user_id' => (string) $user_id, + 'email' => $case['invalid'], + ); + + $call = self::capture_warnings( static fn() => \send_confirmation_on_profile_email() ); + $meta = self::capture_warnings( static fn() => \get_user_meta( $user_id, '_new_email', true ) ); + $error_codes = $GLOBALS['errors'] instanceof \WP_Error ? $GLOBALS['errors']->get_error_codes() : array( 'missing-error-object' ); + $error_data = $GLOBALS['errors'] instanceof \WP_Error ? $GLOBALS['errors']->get_error_data( 'user_email' ) : null; + $ok = ! $call['threw'] + && array() === $call['warnings'] + && null === $call['value'] + && $GLOBALS['errors'] instanceof \WP_Error + && ! $meta['threw'] + && array() === $meta['warnings'] + && $stale_meta === $meta['value'] + && in_array( 'user_email', $error_codes, true ) + && array( 'form-field' => 'email' ) === $error_data + && 0 === count( $mail_calls ) + && 0 === count( $content_events ) + && $case['invalid'] === ( $_POST['email'] ?? null ); + + return array( + 'ok' => $ok, + 'label' => $case['label'], + 'branch' => 'invalid', + 'failure' => $ok ? null : 'profile-confirmation-invalid-oracle-mismatch', + 'summary' => array( + 'userId' => $user_id, + 'invalid' => self::describe_string( $case['invalid'] ), + 'errors' => $error_codes, + 'mailCalls' => count( $mail_calls ), + 'contentEvents' => count( $content_events ), + 'metaRetained' => $stale_meta === ( $meta['value'] ?? null ), + ), + 'details' => $ok ? null : array( + 'call' => self::describe_captured_call( $call ), + 'meta' => self::describe_captured_call( $meta ), + 'mailCalls' => self::describe_value( $mail_calls ), + 'contentEvents' => self::describe_value( $content_events ), + 'postEmail' => self::describe_value( $_POST['email'] ?? null ), + 'errors' => self::describe_value( $error_codes ), + 'errorData' => self::describe_value( $error_data ), + ), + ); + } + + private static function exercise_profile_email_confirmation_wrong_user( \ComponentFuzz\FuzzContext $ctx, int $case_index, array $case, string $email, array &$mail_calls, array &$content_events ): array { + self::reset_stub_content(); + \wp_set_current_user( 0 ); + $mail_calls = array(); + $content_events = array(); + + $current_email = 'profile-wrong-current-' . $ctx->iteration() . '-' . $case_index . '-' . substr( sha1( $email ), 0, 10 ) . '@example.org'; + $other_email = 'profile-wrong-other-' . $ctx->iteration() . '-' . $case_index . '-' . substr( sha1( $current_email ), 0, 10 ) . '@example.org'; + $current = self::capture_warnings( + static fn() => \wp_insert_user( + array( + 'user_login' => 'cfz_profile_wrong_current_' . $ctx->iteration() . '_' . $case_index, + 'user_pass' => 'component-fuzz-pass', + 'user_email' => $current_email, + 'role' => 'subscriber', + ) + ) + ); + $other = self::capture_warnings( + static fn() => \wp_insert_user( + array( + 'user_login' => 'cfz_profile_wrong_other_' . $ctx->iteration() . '_' . $case_index, + 'user_pass' => 'component-fuzz-pass', + 'user_email' => $other_email, + 'role' => 'subscriber', + ) + ) + ); + $current_id = $current['value'] ?? null; + $other_id = $other['value'] ?? null; + + if ( $current['threw'] || $other['threw'] || array() !== $current['warnings'] || array() !== $other['warnings'] || ! is_int( $current_id ) || ! is_int( $other_id ) ) { + return array( + 'ok' => false, + 'label' => $case['label'], + 'branch' => 'wrong-user', + 'failure' => 'profile-confirmation-wrong-user-seed-failed', + 'current' => self::describe_captured_call( $current ), + 'other' => self::describe_captured_call( $other ), + ); + } + + $current_stale_meta = array( 'hash' => 'current-stale', 'newemail' => 'current-stale@example.org' ); + $other_stale_meta = array( 'hash' => 'other-stale', 'newemail' => 'other-stale@example.org' ); + \update_user_meta( $current_id, '_new_email', $current_stale_meta ); + \update_user_meta( $other_id, '_new_email', $other_stale_meta ); + \wp_set_current_user( $current_id ); + $GLOBALS['errors'] = new \WP_Error(); + $_POST = array( + 'user_id' => (string) $other_id, + 'email' => $email, + ); + + $call = self::capture_warnings( static fn() => \send_confirmation_on_profile_email() ); + $current_meta = self::capture_warnings( static fn() => \get_user_meta( $current_id, '_new_email', true ) ); + $other_meta = self::capture_warnings( static fn() => \get_user_meta( $other_id, '_new_email', true ) ); + $error_codes = $GLOBALS['errors'] instanceof \WP_Error ? $GLOBALS['errors']->get_error_codes() : array( 'missing-error-object' ); + $ok = ! $call['threw'] + && array() === $call['warnings'] + && false === $call['value'] + && ! $current_meta['threw'] + && ! $other_meta['threw'] + && array() === $current_meta['warnings'] + && array() === $other_meta['warnings'] + && $current_stale_meta === $current_meta['value'] + && $other_stale_meta === $other_meta['value'] + && array() === $error_codes + && 0 === count( $mail_calls ) + && 0 === count( $content_events ) + && $email === ( $_POST['email'] ?? null ); + + return array( + 'ok' => $ok, + 'label' => $case['label'], + 'branch' => 'wrong-user', + 'failure' => $ok ? null : 'profile-confirmation-wrong-user-oracle-mismatch', + 'summary' => array( + 'currentId' => $current_id, + 'postedId' => $other_id, + 'email' => self::describe_string( $email ), + 'mailCalls' => count( $mail_calls ), + 'contentEvents' => count( $content_events ), + 'currentMetaPreserved' => $current_stale_meta === ( $current_meta['value'] ?? null ), + 'otherMetaPreserved' => $other_stale_meta === ( $other_meta['value'] ?? null ), + ), + 'details' => $ok ? null : array( + 'call' => self::describe_captured_call( $call ), + 'currentMeta' => self::describe_captured_call( $current_meta ), + 'otherMeta' => self::describe_captured_call( $other_meta ), + 'mailCalls' => self::describe_value( $mail_calls ), + 'contentEvents' => self::describe_value( $content_events ), + 'postEmail' => self::describe_value( $_POST['email'] ?? null ), + 'errors' => self::describe_value( $error_codes ), + ), + ); + } + + private static function exercise_profile_email_confirmation_same_email( \ComponentFuzz\FuzzContext $ctx, int $case_index, array $case, string $email, array &$mail_calls, array &$content_events ): array { + self::reset_stub_content(); + \wp_set_current_user( 0 ); + $mail_calls = array(); + $content_events = array(); + + $login = 'cfz_profile_same_' . $ctx->iteration() . '_' . $case_index . '_' . substr( sha1( $email ), 0, 8 ); + $insert = self::capture_warnings( + static fn() => \wp_insert_user( + array( + 'user_login' => $login, + 'user_pass' => 'component-fuzz-pass', + 'user_email' => $email, + 'role' => 'subscriber', + ) + ) + ); + $user_id = $insert['value'] ?? null; + + if ( $insert['threw'] || array() !== $insert['warnings'] || ! is_int( $user_id ) ) { + return array( + 'ok' => false, + 'label' => $case['label'], + 'branch' => 'same-email', + 'failure' => 'profile-confirmation-same-email-user-insert-failed', + 'insert' => self::describe_captured_call( $insert ), + ); + } + + $stale_meta = array( 'hash' => 'same-stale', 'newemail' => 'same-stale@example.org' ); + \update_user_meta( $user_id, '_new_email', $stale_meta ); + \wp_set_current_user( $user_id ); + $GLOBALS['errors'] = 'same-email-scalar'; + $_POST = array( + 'user_id' => (string) $user_id, + 'email' => $email, + ); + + $call = self::capture_warnings( static fn() => \send_confirmation_on_profile_email() ); + $meta = self::capture_warnings( static fn() => \get_user_meta( $user_id, '_new_email', true ) ); + $stored = self::capture_warnings( static fn() => \get_user_by( 'id', $user_id ) ); + $error_codes = $GLOBALS['errors'] instanceof \WP_Error ? $GLOBALS['errors']->get_error_codes() : array( 'missing-error-object' ); + $stored_user = $stored['value'] ?? null; + $ok = ! $call['threw'] + && array() === $call['warnings'] + && null === $call['value'] + && $GLOBALS['errors'] instanceof \WP_Error + && array() === $error_codes + && ! $meta['threw'] + && array() === $meta['warnings'] + && $stale_meta === $meta['value'] + && ! $stored['threw'] + && array() === $stored['warnings'] + && $stored_user instanceof \WP_User + && $stored_user->ID === $user_id + && $email === $stored_user->user_email + && 0 === count( $mail_calls ) + && 0 === count( $content_events ) + && $email === ( $_POST['email'] ?? null ); + + return array( + 'ok' => $ok, + 'label' => $case['label'], + 'branch' => 'same-email', + 'failure' => $ok ? null : 'profile-confirmation-same-email-oracle-mismatch', + 'summary' => array( + 'userId' => $user_id, + 'email' => self::describe_string( $email ), + 'mailCalls' => count( $mail_calls ), + 'contentEvents' => count( $content_events ), + 'errors' => $error_codes, + 'metaPreserved' => $stale_meta === ( $meta['value'] ?? null ), + ), + 'details' => $ok ? null : array( + 'call' => self::describe_captured_call( $call ), + 'meta' => self::describe_captured_call( $meta ), + 'stored' => self::describe_captured_call( $stored ), + 'mailCalls' => self::describe_value( $mail_calls ), + 'contentEvents' => self::describe_value( $content_events ), + 'postEmail' => self::describe_value( $_POST['email'] ?? null ), + 'errors' => self::describe_value( $error_codes ), + ), + ); + } + + private static function check_user_email_search_unicode_terms( \ComponentFuzz\FuzzContext $ctx ): array { + if ( ! class_exists( 'WP_User_Query' ) ) { + return array( + $ctx->skip( + 'email.user-email-search.unicode-terms-byte-preserving', + 'WP_User_Query is unavailable.' + ), + ); + } + if ( ! self::can_reset_stub_content() ) { + return array( + $ctx->skip( + 'email.user-email-search.unicode-terms-byte-preserving', + 'The in-memory wpdb content reset hook is unavailable.' + ), + ); + } + + $cases = self::generated_user_search_cases( $ctx->fork( 'user-email-search-unicode-terms' ) ); + $failures = array(); + $observed = array(); + $hook_snapshot = self::snapshot_hook_globals(); + + try { + foreach ( $cases as $case ) { + $unicode_parse = self::call( static fn() => \WP_Email_Address::from_string( $case['unicodeAddress'], 'unicode' ) ); + $folded_parse = self::call( static fn() => \WP_Email_Address::from_string( $case['foldedAddress'], 'unicode' ) ); + $unicode_local = self::email_localpart( $case['unicodeAddress'] ); + $folded_local = self::email_localpart( $case['foldedAddress'] ); + $full_query = self::prepare_user_email_search_query( $case['unicodeAddress'] ); + $wild_query = self::prepare_user_email_search_query( '*' . $unicode_local . '*', array( 'user_email' ) ); + $folded_query = self::prepare_user_email_search_query( '*' . $folded_local . '*', array( 'user_email' ) ); + $default_query = self::prepare_user_email_search_query( '*' . $unicode_local . '*' ); + $full_where = is_array( $full_query['value'] ?? null ) ? (string) ( $full_query['value']['queryWhere'] ?? '' ) : ''; + $wild_where = is_array( $wild_query['value'] ?? null ) ? (string) ( $wild_query['value']['queryWhere'] ?? '' ) : ''; + $folded_where = is_array( $folded_query['value'] ?? null ) ? (string) ( $folded_query['value']['queryWhere'] ?? '' ) : ''; + $default_where = is_array( $default_query['value'] ?? null ) ? (string) ( $default_query['value']['queryWhere'] ?? '' ) : ''; + $full_likes = self::sql_like_literals( $full_where, 'user_email' ); + $wild_likes = self::sql_like_literals( $wild_where, 'user_email' ); + $folded_likes = self::sql_like_literals( $folded_where, 'user_email' ); + $default_likes = self::sql_like_literals( $default_where, 'user_email' ); + $full_other = self::sql_like_literals_for_columns( $full_where, array( 'user_login', 'user_url', 'user_nicename', 'display_name' ) ); + $wild_other = self::sql_like_literals_for_columns( $wild_where, array( 'user_login', 'user_url', 'user_nicename', 'display_name' ) ); + $folded_other = self::sql_like_literals_for_columns( $folded_where, array( 'user_login', 'user_url', 'user_nicename', 'display_name' ) ); + $default_other = self::sql_like_literals_for_columns( $default_where, array( 'user_login', 'user_url', 'user_nicename', 'display_name' ) ); + $default_other_literals = self::flatten_sql_like_literal_groups( $default_other ); + $expected_full = $case['unicodeAddress']; + $expected_wild = '%' . $unicode_local . '%'; + $folded_wild = '%' . $folded_local . '%'; + $result_probe = self::probe_user_email_localpart_search_results( $ctx, $case ); + + $ok = ! $unicode_parse['threw'] + && ! $folded_parse['threw'] + && ! $full_query['threw'] + && ! $wild_query['threw'] + && ! $folded_query['threw'] + && ! $default_query['threw'] + && $unicode_parse['value'] instanceof \WP_Email_Address + && $folded_parse['value'] instanceof \WP_Email_Address + && array() === ( $full_query['warnings'] ?? array() ) + && array() === ( $wild_query['warnings'] ?? array() ) + && array() === ( $folded_query['warnings'] ?? array() ) + && array() === ( $default_query['warnings'] ?? array() ) + && array( $expected_full ) === $full_likes + && array( $expected_wild ) === $wild_likes + && array( $folded_wild ) === $folded_likes + && array( $expected_wild ) === $default_likes + && array() === $full_other + && array() === $wild_other + && array() === $folded_other + && count( $default_other_literals ) >= 4 + && array() === array_diff( $default_other_literals, array( $expected_wild ) ) + && $case['unicodeAddress'] !== $case['foldedAddress'] + && $unicode_local !== $folded_local + && $expected_wild !== $folded_wild + && bin2hex( $expected_wild ) !== bin2hex( $folded_wild ) + && true === ( $result_probe['ok'] ?? false ); + + if ( ! $ok ) { + $failures[] = array( + 'label' => $case['label'], + 'profile' => $case['profile'], + 'unicode' => self::describe_string( $case['unicodeAddress'] ), + 'folded' => self::describe_string( $case['foldedAddress'] ), + 'unicodeLocal' => self::describe_string( $unicode_local ), + 'foldedLocal' => self::describe_string( $folded_local ), + 'unicodeParse' => self::describe_call( $unicode_parse ), + 'foldedParse' => self::describe_call( $folded_parse ), + 'fullQuery' => self::describe_captured_call( $full_query ), + 'wildQuery' => self::describe_captured_call( $wild_query ), + 'foldedQuery' => self::describe_captured_call( $folded_query ), + 'defaultQuery' => self::describe_captured_call( $default_query ), + 'fullLikes' => self::describe_value( $full_likes ), + 'wildLikes' => self::describe_value( $wild_likes ), + 'foldedLikes' => self::describe_value( $folded_likes ), + 'defaultLikes' => self::describe_value( $default_likes ), + 'fullOther' => self::describe_value( $full_other ), + 'wildOther' => self::describe_value( $wild_other ), + 'foldedOther' => self::describe_value( $folded_other ), + 'defaultOther' => self::describe_value( $default_other ), + 'defaultOtherLiterals' => self::describe_value( $default_other_literals ), + 'resultProbe' => self::describe_value( $result_probe ), + ); + } + + $observed[] = array( + 'label' => $case['label'], + 'profile' => $case['profile'], + 'unicode' => self::describe_string( $case['unicodeAddress'] ), + 'folded' => self::describe_string( $case['foldedAddress'] ), + 'unicodeLocal' => self::describe_string( $unicode_local ), + 'foldedLocal' => self::describe_string( $folded_local ), + 'fullLikes' => self::describe_value( $full_likes ), + 'wildLikes' => self::describe_value( $wild_likes ), + 'foldedLikes' => self::describe_value( $folded_likes ), + 'resultProbe' => array( + 'unicode' => $result_probe['unicode'] ?? null, + 'folded' => $result_probe['folded'] ?? null, + ), + ); + } + } finally { + self::restore_hook_globals( $hook_snapshot ); + self::reset_stub_content(); + } + + return array( + $ctx->result( + 'email.user-email-search.unicode-terms-byte-preserving', + array() === $failures, + array( + 'caseCount' => count( $cases ), + 'observed' => $observed, + 'failures' => self::describe_value( $failures ), + ) + ), + ); + } + + private static function check_confusable_localpart_boundaries( \ComponentFuzz\FuzzContext $ctx ): array { + $result_name = 'email.confusable-localparts.lookup-boundaries'; + + if ( ! self::can_reset_stub_content() ) { + return array( + $ctx->skip( + $result_name, + 'The in-memory wpdb content reset hook is unavailable.' + ), + ); + } + + $missing = array(); + foreach ( array( 'retrieve_password', 'wp_filter_comment', 'wp_mail' ) as $function ) { + if ( ! function_exists( $function ) ) { + $missing[] = 'function ' . $function; + } + } + + if ( ! class_exists( 'WP_User_Query' ) ) { + $missing[] = 'class WP_User_Query'; + } + + if ( array() !== $missing ) { + return array( + $ctx->skip( + $result_name, + 'Required lookup/search/password/comment APIs are unavailable.', + array( 'missing' => implode( ', ', $missing ) ) + ), + ); + } + + $cases = self::generated_confusable_localpart_cases( $ctx->fork( 'confusable-localparts' ) ); + $failures = array(); + $observed = array(); + $hook_snapshot = self::snapshot_hook_globals(); + $server_snapshot = array_key_exists( 'REMOTE_ADDR', $_SERVER ) + ? array( 'exists' => true, 'value' => $_SERVER['REMOTE_ADDR'] ) + : array( 'exists' => false, 'value' => null ); + $mail_calls = array(); + $mail_filter = static function ( $return, $atts ) use ( &$mail_calls ) { + $mail_calls[] = $atts; + return true; + }; + $not_called = static function (): array { + return array( + 'threw' => false, + 'value' => null, + 'warnings' => array(), + ); + }; + + self::reset_stub_content(); + try { + $_SERVER['REMOTE_ADDR'] = '127.0.0.1'; + + \remove_all_filters( 'pre_user_email' ); + \add_filter( 'pre_user_email', 'trim' ); + \add_filter( 'pre_user_email', 'sanitize_email' ); + if ( function_exists( 'wp_filter_kses' ) ) { + \add_filter( 'pre_user_email', 'wp_filter_kses' ); + } + + \remove_all_filters( 'pre_comment_author_email' ); + \add_filter( 'pre_comment_author_email', 'trim' ); + \add_filter( 'pre_comment_author_email', 'sanitize_email' ); + + foreach ( + array( + 'lostpassword_user_data', + 'lostpassword_post', + 'lostpassword_errors', + 'send_retrieve_password_email', + 'retrieve_password_title', + 'retrieve_password_message', + 'retrieve_password_notification_email', + 'wp_mail', + 'pre_wp_mail', + ) as $password_reset_hook + ) { + \remove_all_filters( $password_reset_hook ); + } + \add_filter( 'pre_wp_mail', $mail_filter, PHP_INT_MAX, 2 ); + + foreach ( $cases as $case_index => $case ) { + self::reset_stub_content(); + self::install_email_filters( 'unicode' ); + $mail_calls = array(); + $variants = array( + array( + 'kind' => 'ascii', + 'local' => $case['asciiLocal'], + 'address' => $case['asciiAddress'], + 'asciiModeValid' => true, + ), + array( + 'kind' => 'confusable', + 'local' => $case['confusableLocal'], + 'address' => $case['confusableAddress'], + 'asciiModeValid' => false, + ), + ); + $inserted = array(); + $lookups = array(); + + foreach ( $variants as $variant_index => $variant ) { + self::install_email_filters( 'unicode' ); + + $parse = self::capture_warnings( + static fn() => \WP_Email_Address::from_string( $variant['address'], 'unicode' ) + ); + $is_email = self::capture_warnings( static fn() => \is_email( $variant['address'] ) ); + $sanitized = self::capture_warnings( static fn() => \sanitize_email( $variant['address'] ) ); + $email = $parse['value'] ?? null; + $parse_ok = ! $parse['threw'] + && array() === $parse['warnings'] + && ! $is_email['threw'] + && array() === $is_email['warnings'] + && ! $sanitized['threw'] + && array() === $sanitized['warnings'] + && $email instanceof \WP_Email_Address + && $variant['local'] === $email->get_localpart() + && $case['domain'] === $email->get_ascii_domain() + && $case['domain'] === $email->get_unicode_domain() + && $variant['address'] === $email->get_ascii_address() + && $variant['address'] === $email->get_unicode_address() + && $variant['address'] === $is_email['value'] + && $variant['address'] === $sanitized['value']; + + self::install_email_filters( 'ascii' ); + $ascii_parse = self::capture_warnings( + static fn() => \WP_Email_Address::from_string( $variant['address'], 'ascii' ) + ); + $ascii_is_email = self::capture_warnings( static fn() => \is_email( $variant['address'] ) ); + $ascii_sanitized = self::capture_warnings( static fn() => \sanitize_email( $variant['address'] ) ); + $ascii_email = $ascii_parse['value'] ?? null; + $expected_ascii_is_email = $variant['asciiModeValid'] ? $variant['address'] : false; + $expected_ascii_sanitized = $variant['asciiModeValid'] ? $variant['address'] : ''; + $ascii_mode_ok = ! $ascii_parse['threw'] + && array() === $ascii_parse['warnings'] + && ! $ascii_is_email['threw'] + && array() === $ascii_is_email['warnings'] + && ! $ascii_sanitized['threw'] + && array() === $ascii_sanitized['warnings'] + && ( + $variant['asciiModeValid'] + ? $ascii_email instanceof \WP_Email_Address + && $variant['address'] === $ascii_email->get_unicode_address() + : null === $ascii_email + ) + && $expected_ascii_is_email === $ascii_is_email['value'] + && $expected_ascii_sanitized === $ascii_sanitized['value']; + + self::install_email_filters( 'unicode' ); + $login = 'cfz_confusable_' . $ctx->iteration() . '_' . $case_index . '_' . $variant_index . '_' . substr( sha1( $variant['address'] ), 0, 8 ); + $insert = self::capture_warnings( + static fn() => \wp_insert_user( + array( + 'user_login' => $login, + 'user_pass' => 'component-fuzz-pass', + 'user_email' => $variant['address'], + 'role' => 'subscriber', + ) + ) + ); + $user_id = $insert['value'] ?? null; + $stored = is_int( $user_id ) ? self::capture_warnings( static fn() => \get_user_by( 'id', $user_id ) ) : $not_called(); + $exists = is_int( $user_id ) ? self::capture_warnings( static fn() => \email_exists( $variant['address'] ) ) : $not_called(); + $by_email = is_int( $user_id ) ? self::capture_warnings( static fn() => \get_user_by( 'email', $variant['address'] ) ) : $not_called(); + $stored_user = $stored['value'] ?? null; + $by_email_user = $by_email['value'] ?? null; + $insert_ok = ! $insert['threw'] + && array() === $insert['warnings'] + && ! $stored['threw'] + && array() === $stored['warnings'] + && ! $exists['threw'] + && array() === $exists['warnings'] + && ! $by_email['threw'] + && array() === $by_email['warnings'] + && is_int( $user_id ) + && $stored_user instanceof \WP_User + && $stored_user->ID === $user_id + && $variant['address'] === $stored_user->user_email + && $exists['value'] === $user_id + && $by_email_user instanceof \WP_User + && $by_email_user->ID === $user_id + && $variant['address'] === $by_email_user->user_email; + + $comment = array( + 'comment_author' => 'Component Fuzzer', + 'comment_author_email' => $variant['address'], + 'comment_author_url' => '', + 'comment_content' => 'Confusable email local-part case ' . $case_index . '.' . $variant_index, + 'comment_author_IP' => '127.0.0.1', + 'comment_agent' => 'component-fuzz', + ); + $filtered = self::capture_warnings( static fn() => \wp_filter_comment( $comment ) ); + $filtered_email = ! $filtered['threw'] && is_array( $filtered['value'] ) + ? ( $filtered['value']['comment_author_email'] ?? null ) + : null; + $filtered_valid = is_string( $filtered_email ) + ? self::capture_warnings( static fn() => \is_email( $filtered_email ) ) + : $not_called(); + $filtered_parse = is_string( $filtered_email ) + ? self::capture_warnings( static fn() => \WP_Email_Address::from_string( $filtered_email, 'unicode' ) ) + : $not_called(); + $comment_ok = ! $filtered['threw'] + && array() === $filtered['warnings'] + && ! $filtered_valid['threw'] + && array() === $filtered_valid['warnings'] + && ! $filtered_parse['threw'] + && array() === $filtered_parse['warnings'] + && is_array( $filtered['value'] ) + && true === ( $filtered['value']['filtered'] ?? null ) + && $variant['address'] === $filtered_email + && $variant['address'] === $filtered_valid['value'] + && ( $filtered_parse['value'] ?? null ) instanceof \WP_Email_Address; + + if ( ! $parse_ok || ! $ascii_mode_ok || ! $insert_ok || ! $comment_ok ) { + $failures[] = array( + 'label' => $case['label'], + 'variant' => $variant['kind'], + 'address' => self::describe_string( $variant['address'] ), + 'parse' => self::describe_captured_call( $parse ), + 'isEmail' => self::describe_captured_call( $is_email ), + 'sanitized' => self::describe_captured_call( $sanitized ), + 'asciiParse' => self::describe_captured_call( $ascii_parse ), + 'asciiIsEmail' => self::describe_captured_call( $ascii_is_email ), + 'asciiSanitized' => self::describe_captured_call( $ascii_sanitized ), + 'insert' => self::describe_captured_call( $insert ), + 'stored' => self::describe_captured_call( $stored ), + 'exists' => self::describe_captured_call( $exists ), + 'byEmail' => self::describe_captured_call( $by_email ), + 'comment' => self::describe_captured_call( $filtered ), + 'commentValid' => self::describe_captured_call( $filtered_valid ), + 'commentParse' => self::describe_captured_call( $filtered_parse ), + 'parseOk' => $parse_ok, + 'asciiModeOk' => $ascii_mode_ok, + 'insertOk' => $insert_ok, + 'commentOk' => $comment_ok, + ); + } + + $inserted[ $variant['kind'] ] = array( + 'id' => $user_id, + 'login' => $login, + 'address' => $variant['address'], + 'local' => $variant['local'], + ); + $lookups[] = array( + 'variant' => $variant['kind'], + 'address' => self::describe_string( $variant['address'] ), + 'userId' => $user_id, + 'asciiModeValid' => $variant['asciiModeValid'], + 'asciiModeResult' => self::describe_value( $ascii_is_email['value'] ?? null ), + 'commentEmail' => self::describe_value( $filtered_email ), + ); + } + + $ids = array(); + foreach ( $inserted as $entry ) { + if ( is_int( $entry['id'] ?? null ) ) { + $ids[] = $entry['id']; + } + } + + $distinct_ok = 2 === count( $ids ) + && 2 === count( array_unique( $ids, SORT_REGULAR ) ) + && $case['asciiLocal'] !== $case['confusableLocal'] + && $case['asciiAddress'] !== $case['confusableAddress'] + && bin2hex( $case['asciiLocal'] ) !== bin2hex( $case['confusableLocal'] ); + + foreach ( $variants as $variant ) { + $duplicate = self::capture_warnings( + static fn() => \wp_insert_user( + array( + 'user_login' => 'cfz_confusable_duplicate_' . $ctx->iteration() . '_' . $case_index . '_' . $variant['kind'], + 'user_pass' => 'component-fuzz-pass', + 'user_email' => $variant['address'], + 'role' => 'subscriber', + ) + ) + ); + + if ( + $duplicate['threw'] || + array() !== $duplicate['warnings'] || + ! \is_wp_error( $duplicate['value'] ?? null ) || + 'existing_user_email' !== $duplicate['value']->get_error_code() + ) { + $failures[] = array( + 'label' => $case['label'], + 'variant' => $variant['kind'], + 'failure' => 'exact-duplicate-not-rejected-cleanly', + 'duplicate' => self::describe_captured_call( $duplicate ), + ); + } + } + + $hostile_ascii = self::hostile_user_email_lookup_probe( $case['asciiAddress'], $case['confusableAddress'] ); + $hostile_confusable = self::hostile_user_email_lookup_probe( $case['confusableAddress'], $case['asciiAddress'] ); + $hostile_ok = true === ( $hostile_ascii['ok'] ?? false ) + && true === ( $hostile_confusable['ok'] ?? false ); + + $ascii_query = self::prepare_user_email_search_query( '*' . $case['asciiLocal'] . '*', array( 'user_email' ) ); + $confusable_query = self::prepare_user_email_search_query( '*' . $case['confusableLocal'] . '*', array( 'user_email' ) ); + $ascii_where = is_array( $ascii_query['value'] ?? null ) ? (string) ( $ascii_query['value']['queryWhere'] ?? '' ) : ''; + $confusable_where = is_array( $confusable_query['value'] ?? null ) ? (string) ( $confusable_query['value']['queryWhere'] ?? '' ) : ''; + $ascii_likes = self::sql_like_literals( $ascii_where, 'user_email' ); + $confusable_likes = self::sql_like_literals( $confusable_where, 'user_email' ); + $search_records = array(); + foreach ( $inserted as $entry ) { + if ( is_int( $entry['id'] ?? null ) && is_string( $entry['address'] ?? null ) ) { + $search_records[] = array( + 'id' => $entry['id'], + 'email' => $entry['address'], + ); + } + } + $result_probe = self::probe_user_email_localpart_search_results( + $ctx, + array( + 'label' => $case['label'], + 'unicodeAddress' => $case['confusableAddress'], + 'foldedAddress' => $case['asciiAddress'], + ), + $search_records + ); + $search_ok = ! $ascii_query['threw'] + && array() === $ascii_query['warnings'] + && ! $confusable_query['threw'] + && array() === $confusable_query['warnings'] + && array( '%' . $case['asciiLocal'] . '%' ) === $ascii_likes + && array( '%' . $case['confusableLocal'] . '%' ) === $confusable_likes + && true === ( $result_probe['ok'] ?? false ); + + if ( ! $distinct_ok || ! $hostile_ok || ! $search_ok ) { + $failures[] = array( + 'label' => $case['label'], + 'ascii' => self::describe_string( $case['asciiAddress'] ), + 'confusable' => self::describe_string( $case['confusableAddress'] ), + 'inserted' => self::describe_value( $inserted ), + 'hostileAscii' => self::describe_value( $hostile_ascii ), + 'hostileConfusable' => self::describe_value( $hostile_confusable ), + 'asciiQuery' => self::describe_captured_call( $ascii_query ), + 'confusableQuery' => self::describe_captured_call( $confusable_query ), + 'asciiLikes' => self::describe_value( $ascii_likes ), + 'confusableLikes' => self::describe_value( $confusable_likes ), + 'resultProbe' => self::describe_value( $result_probe ), + 'distinctOk' => $distinct_ok, + 'hostileOk' => $hostile_ok, + 'searchOk' => $search_ok, + ); + } + + $password_resets = array(); + foreach ( $variants as $variant ) { + self::install_email_filters( 'unicode' ); + $user_id = $inserted[ $variant['kind'] ]['id'] ?? null; + $login = $inserted[ $variant['kind'] ]['login'] ?? ''; + $mail_count_before = count( $mail_calls ); + $reset = is_int( $user_id ) + ? self::capture_warnings( static fn() => \retrieve_password( $variant['address'] ) ) + : $not_called(); + $mail = $mail_calls[ $mail_count_before ] ?? null; + $reset_ok = is_int( $user_id ) + && ! $reset['threw'] + && array() === $reset['warnings'] + && true === $reset['value'] + && count( $mail_calls ) === $mail_count_before + 1 + && is_array( $mail ) + && $variant['address'] === ( $mail['to'] ?? null ) + && is_string( $mail['message'] ?? null ) + && str_contains( $mail['message'], rawurlencode( $login ) ); + + if ( ! $reset_ok ) { + $failures[] = array( + 'label' => $case['label'], + 'variant' => $variant['kind'], + 'failure' => 'password-reset-not-routed-to-exact-address', + 'address' => self::describe_string( $variant['address'] ), + 'userId' => self::describe_value( $user_id ), + 'login' => self::describe_value( $login ), + 'reset' => self::describe_captured_call( $reset ), + 'mailCalls' => self::describe_value( array_slice( $mail_calls, $mail_count_before ) ), + ); + } + + $password_resets[] = array( + 'variant' => $variant['kind'], + 'userId' => $user_id, + 'ok' => $reset_ok, + 'mailTo' => is_array( $mail ) && is_string( $mail['to'] ?? null ) ? self::describe_string( $mail['to'] ) : null, + ); + } + + $observed[] = array( + 'label' => $case['label'], + 'profile' => $case['profile'], + 'domain' => self::describe_string( $case['domain'] ), + 'asciiLocal' => self::describe_string( $case['asciiLocal'] ), + 'confusableLocal' => self::describe_string( $case['confusableLocal'] ), + 'insertedIds' => self::describe_value( $ids ), + 'lookups' => $lookups, + 'hostileLookups' => array( + 'asciiLookup' => true === ( $hostile_ascii['ok'] ?? false ), + 'confusableLookup' => true === ( $hostile_confusable['ok'] ?? false ), + ), + 'searchLikes' => array( + 'ascii' => self::describe_value( $ascii_likes ), + 'confusable' => self::describe_value( $confusable_likes ), + ), + 'passwordResets' => $password_resets, + ); + } + } finally { + if ( $server_snapshot['exists'] ) { + $_SERVER['REMOTE_ADDR'] = $server_snapshot['value']; + } else { + unset( $_SERVER['REMOTE_ADDR'] ); + } + self::restore_hook_globals( $hook_snapshot ); + self::reset_stub_content(); + } + + return array( + $ctx->result( + $result_name, + array() === $failures, + array( + 'caseCount' => count( $cases ), + 'observed' => $observed, + 'failures' => self::describe_value( $failures ), + ) + ), + ); + } + + private static function check_user_email_indexes_distinct_domains( \ComponentFuzz\FuzzContext $ctx ): array { + if ( ! self::can_reset_stub_content() ) { + return array( + $ctx->skip( + 'email.user-email-indexes.distinct-domains-preserved', + 'The in-memory wpdb content reset hook is unavailable.' + ), + ); + } + + if ( ! self::has_idn() ) { + return array( + $ctx->skip( + 'email.user-email-indexes.distinct-domains-preserved', + 'idn_to_ascii() or idn_to_utf8() is unavailable.' + ), + ); + } + + $inputs = array( + 'mail@gra.org', + "mail@gr\u{00E5}.org", + 'mail@bucher.de', + "mail@b\u{00FC}cher.de", + "jos\u{00E9}@example.org", + "jos\u{00E9}@gr\u{00E5}.org", + ); + $inserted = array(); + $lookups = array(); + $failures = array(); + + self::reset_stub_content(); + try { + foreach ( $inputs as $index => $input ) { + $insert = self::capture_warnings( + static fn() => \wp_insert_user( + array( + 'user_login' => 'cfz_domain_email_' . $ctx->iteration() . '_' . $index . '_' . substr( sha1( $input ), 0, 10 ), + 'user_pass' => 'component-fuzz-pass', + 'user_email' => $input, + 'role' => 'subscriber', + ) + ) + ); + $user_id = $insert['value'] ?? null; + $inserted[] = $user_id; + + if ( $insert['threw'] || array() !== $insert['warnings'] || ! is_int( $user_id ) ) { + $failures[] = array( + 'label' => 'insert-failed-or-warned', + 'input' => self::describe_string( $input ), + 'insert' => self::describe_captured_call( $insert ), + ); + continue; + } + + $exists = self::capture_warnings( static fn() => \email_exists( $input ) ); + $by_email = self::capture_warnings( static fn() => \get_user_by( 'email', $input ) ); + $user = $by_email['value'] ?? null; + $lookups[] = array( + 'input' => self::describe_string( $input ), + 'userId' => $user_id, + 'exists' => $exists['value'] ?? null, + 'byEmail' => $user instanceof \WP_User ? $user->ID : self::describe_value( $user ), + 'warnings' => count( $exists['warnings'] ) + count( $by_email['warnings'] ), + ); + + if ( + $exists['threw'] || + $by_email['threw'] || + array() !== $exists['warnings'] || + array() !== $by_email['warnings'] || + $exists['value'] !== $user_id || + ! ( $user instanceof \WP_User ) || + $user->ID !== $user_id || + $user->user_email !== $input + ) { + $failures[] = array( + 'label' => 'lookup-mismatch-or-warning', + 'input' => self::describe_string( $input ), + 'userId' => $user_id, + 'exists' => self::describe_captured_call( $exists ), + 'byEmail' => self::describe_captured_call( $by_email ), + ); + } + } + + $ids = array_values( array_filter( $inserted, 'is_int' ) ); + if ( count( $ids ) !== count( $inputs ) || count( array_unique( $ids, SORT_REGULAR ) ) !== count( $inputs ) ) { + $failures[] = array( + 'label' => 'inserted-ids-not-distinct', + 'inserted' => self::describe_value( $inserted ), + ); + } + + $duplicate = self::capture_warnings( + static fn() => \wp_insert_user( + array( + 'user_login' => 'cfz_domain_email_duplicate_' . $ctx->iteration(), + 'user_pass' => 'component-fuzz-pass', + 'user_email' => $inputs[1], + 'role' => 'subscriber', + ) + ) + ); + $missing = self::capture_warnings( static fn() => \email_exists( "missing@gr\u{00E5}.org" ) ); + + if ( + $duplicate['threw'] || + array() !== $duplicate['warnings'] || + ! \is_wp_error( $duplicate['value'] ?? null ) || + 'existing_user_email' !== $duplicate['value']->get_error_code() + ) { + $failures[] = array( + 'label' => 'exact-duplicate-not-rejected-cleanly', + 'duplicate' => self::describe_captured_call( $duplicate ), + ); + } + + if ( $missing['threw'] || array() !== $missing['warnings'] || false !== $missing['value'] ) { + $failures[] = array( + 'label' => 'missing-unicode-domain-lookup-not-false-cleanly', + 'missing' => self::describe_captured_call( $missing ), + ); + } + } finally { + self::reset_stub_content(); + } + + return array( + $ctx->result( + 'email.user-email-indexes.distinct-domains-preserved', + array() === $failures, + array( + 'inputs' => array_map( array( self::class, 'describe_string' ), $inputs ), + 'inserted' => self::describe_value( $inserted ), + 'lookups' => self::describe_value( $lookups ), + 'failures' => self::describe_value( $failures ), + ) + ), + ); + } + + private static function check_user_email_indexes_canonical_domain_aliases( \ComponentFuzz\FuzzContext $ctx ): array { + if ( ! self::can_reset_stub_content() ) { + return array( + $ctx->skip( + 'email.user-email-indexes.canonical-domain-aliases', + 'The in-memory wpdb content reset hook is unavailable.' + ), + ); + } + + if ( ! self::has_idn() ) { + return array( + $ctx->skip( + 'email.user-email-indexes.canonical-domain-aliases', + 'idn_to_ascii() or idn_to_utf8() is unavailable.' + ), + ); + } + + if ( ! function_exists( 'wp_update_user' ) ) { + return array( + $ctx->skip( + 'email.user-email-indexes.canonical-domain-aliases', + 'wp_update_user() is unavailable.' + ), + ); + } + + $domain_aliases = array( + "gr\u{00E5}.org", + "b\u{00FC}cher.de", + "fa\u{00DF}.de", + "\u{4F8B}\u{5B50}.\u{5E7F}\u{544A}", + "\u{043F}\u{0440}\u{0438}\u{043C}\u{0435}\u{0440}.\u{0438}\u{0441}\u{043F}\u{044B}\u{0442}\u{0430}\u{043D}\u{0438}\u{0435}", + "\u{308C}\u{3044}.\u{307F}\u{3093}\u{306A}", + "\u{03C0}\u{03B1}\u{03C1}\u{03AC}\u{03B4}\u{03B5}\u{03B9}\u{03B3}\u{03BC}\u{03B1}.\u{03B4}\u{03BF}\u{03BA}\u{03B9}\u{03BC}\u{03AE}", + ); + $localparts = array( + 'mail', + 'USER+tag', + "gr\u{00E5}", + "jose\u{0301}", + "\u{7528}\u{6237}", + "\u{043F}\u{043E}\u{0447}\u{0442}\u{0430}", + "\u{3086}\u{3046}\u{3056}\u{3042}", + ); + $alias_ctx = $ctx->fork( 'user-email-canonical-domain-aliases' ); + $cases = array(); + $failures = array(); + $observed = array(); + $mail_calls = array(); + $not_called = static function (): array { + return array( + 'threw' => false, + 'value' => null, + 'warnings' => array(), + ); + }; + $mail_filter = static function ( $return, $atts ) use ( &$mail_calls ) { + $mail_calls[] = $atts; + return true; + }; + + for ( $i = 0; $i < self::GENERATED_DOMAIN_ALIAS_CASES; $i++ ) { + $unicode_domain = $alias_ctx->choice( $domain_aliases ); + $ascii_domain = idn_to_ascii( $unicode_domain, IDNA_DEFAULT, INTL_IDNA_VARIANT_UTS46 ); + $decoded_domain = false === $ascii_domain + ? false + : idn_to_utf8( $ascii_domain, IDNA_DEFAULT, INTL_IDNA_VARIANT_UTS46 ); + $localpart = $alias_ctx->choice( $localparts ) . $alias_ctx->int( 10, 99 ); + + if ( false === $ascii_domain || false === $decoded_domain ) { + $failures[] = array( + 'label' => 'idn-conversion-failed', + 'domain' => self::describe_string( $unicode_domain ), + ); + continue; + } + + $unicode_input = $localpart . '@' . $decoded_domain; + $machine_input = $localpart . '@' . $ascii_domain; + $unicode_parse = self::call( static fn() => \WP_Email_Address::from_string( $unicode_input, 'unicode' ) ); + $machine_parse = self::call( static fn() => \WP_Email_Address::from_string( $machine_input, 'unicode' ) ); + $unicode_email = $unicode_parse['value'] ?? null; + $machine_email = $machine_parse['value'] ?? null; + + if ( + $unicode_parse['threw'] || + $machine_parse['threw'] || + ! ( $unicode_email instanceof \WP_Email_Address ) || + ! ( $machine_email instanceof \WP_Email_Address ) || + $unicode_email->get_ascii_address() !== $machine_email->get_ascii_address() || + $unicode_email->get_unicode_address() !== $machine_email->get_unicode_address() + ) { + $failures[] = array( + 'label' => 'alias-parse-mismatch', + 'unicodeInput' => self::describe_string( $unicode_input ), + 'machineInput' => self::describe_string( $machine_input ), + 'unicodeParse' => self::describe_call( $unicode_parse ), + 'machineParse' => self::describe_call( $machine_parse ), + ); + continue; + } + + $cases[] = array( + 'label' => 'generated-domain-alias-' . $i, + 'canonical' => $unicode_email->get_unicode_address(), + 'machine' => $unicode_email->get_ascii_address(), + 'domain' => $unicode_email->get_unicode_domain(), + 'ascii' => $unicode_email->get_ascii_domain(), + ); + } + + $hook_snapshot = self::snapshot_hook_globals(); + + self::reset_stub_content(); + try { + \remove_all_filters( 'pre_user_email' ); + \add_filter( 'pre_user_email', 'trim' ); + \add_filter( 'pre_user_email', 'sanitize_email' ); + if ( function_exists( 'wp_filter_kses' ) ) { + \add_filter( 'pre_user_email', 'wp_filter_kses' ); + } + \remove_all_filters( 'pre_wp_mail' ); + \add_filter( 'pre_wp_mail', $mail_filter, PHP_INT_MAX, 2 ); + + foreach ( $cases as $index => $case ) { + self::reset_stub_content(); + + $canonical = $case['canonical']; + $machine = $case['machine']; + $login = 'cfz_alias_' . $ctx->iteration() . '_' . $index . '_' . substr( sha1( $canonical ), 0, 10 ); + $other = 'cfz_alias_other_' . $ctx->iteration() . '_' . $index . '_' . substr( sha1( $machine ), 0, 10 ); + $other_email = 'other-' . substr( sha1( $canonical ), 0, 12 ) . '@example.org'; + $mail_count_before = count( $mail_calls ); + + $canonical_sanitized = self::capture_warnings( static fn() => \sanitize_email( $canonical ) ); + $machine_sanitized = self::capture_warnings( static fn() => \sanitize_email( $machine ) ); + $canonical_valid = self::capture_warnings( static fn() => \is_email( $canonical ) ); + $machine_valid = self::capture_warnings( static fn() => \is_email( $machine ) ); + $insert = self::capture_warnings( + static fn() => \wp_insert_user( + array( + 'user_login' => $login, + 'user_pass' => 'component-fuzz-pass', + 'user_email' => $machine, + 'role' => 'subscriber', + ) + ) + ); + $user_id = $insert['value'] ?? null; + $stored = is_int( $user_id ) ? self::capture_warnings( static fn() => \get_user_by( 'id', $user_id ) ) : $not_called(); + $exists = is_int( $user_id ) ? self::capture_warnings( static fn() => \email_exists( $canonical ) ) : $not_called(); + $by_email = is_int( $user_id ) ? self::capture_warnings( static fn() => \get_user_by( 'email', $canonical ) ) : $not_called(); + $machine_exists = is_int( $user_id ) ? self::capture_warnings( static fn() => \email_exists( $machine ) ) : $not_called(); + $machine_by_email = is_int( $user_id ) ? self::capture_warnings( static fn() => \get_user_by( 'email', $machine ) ) : $not_called(); + $duplicate = is_int( $user_id ) + ? self::capture_warnings( + static fn() => \wp_insert_user( + array( + 'user_login' => $login . '_duplicate', + 'user_pass' => 'component-fuzz-pass', + 'user_email' => $canonical, + 'role' => 'subscriber', + ) + ) + ) + : $not_called(); + $self_update = is_int( $user_id ) + ? self::capture_warnings( + static fn() => \wp_update_user( + array( + 'ID' => $user_id, + 'user_email' => $canonical, + ) + ) + ) + : $not_called(); + $stored_after_update = is_int( $user_id ) ? self::capture_warnings( static fn() => \get_user_by( 'id', $user_id ) ) : $not_called(); + $other_insert = self::capture_warnings( + static fn() => \wp_insert_user( + array( + 'user_login' => $other, + 'user_pass' => 'component-fuzz-pass', + 'user_email' => $other_email, + 'role' => 'subscriber', + ) + ) + ); + $other_id = $other_insert['value'] ?? null; + $collision_update = is_int( $other_id ) + ? self::capture_warnings( + static fn() => \wp_update_user( + array( + 'ID' => $other_id, + 'user_email' => $machine, + ) + ) + ) + : $not_called(); + $stored_user = $stored['value'] ?? null; + $by_email_user = $by_email['value'] ?? null; + $machine_email_user = $machine_by_email['value'] ?? null; + $updated_user = $stored_after_update['value'] ?? null; + $other_after_collision = is_int( $other_id ) ? self::capture_warnings( static fn() => \get_user_by( 'id', $other_id ) ) : $not_called(); + $other_after_user = $other_after_collision['value'] ?? null; + + $ok = ! $canonical_sanitized['threw'] + && ! $machine_sanitized['threw'] + && ! $canonical_valid['threw'] + && ! $machine_valid['threw'] + && ! $insert['threw'] + && ! $stored['threw'] + && ! $exists['threw'] + && ! $by_email['threw'] + && ! $machine_exists['threw'] + && ! $machine_by_email['threw'] + && ! $duplicate['threw'] + && ! $self_update['threw'] + && ! $stored_after_update['threw'] + && ! $other_insert['threw'] + && ! $collision_update['threw'] + && ! $other_after_collision['threw'] + && array() === $canonical_sanitized['warnings'] + && array() === $machine_sanitized['warnings'] + && array() === $canonical_valid['warnings'] + && array() === $machine_valid['warnings'] + && array() === $insert['warnings'] + && array() === $stored['warnings'] + && array() === $exists['warnings'] + && array() === $by_email['warnings'] + && array() === $machine_exists['warnings'] + && array() === $machine_by_email['warnings'] + && array() === $duplicate['warnings'] + && array() === $self_update['warnings'] + && array() === $stored_after_update['warnings'] + && array() === $other_insert['warnings'] + && array() === $collision_update['warnings'] + && array() === $other_after_collision['warnings'] + && $canonical === $canonical_sanitized['value'] + && $canonical === $machine_sanitized['value'] + && $canonical === $canonical_valid['value'] + && $canonical === $machine_valid['value'] + && is_int( $user_id ) + && $stored_user instanceof \WP_User + && $canonical === $stored_user->user_email + && $exists['value'] === $user_id + && $by_email_user instanceof \WP_User + && $by_email_user->ID === $user_id + && $canonical === $by_email_user->user_email + && false === $machine_exists['value'] + && false === $machine_email_user + && \is_wp_error( $duplicate['value'] ?? null ) + && 'existing_user_email' === $duplicate['value']->get_error_code() + && $self_update['value'] === $user_id + && $updated_user instanceof \WP_User + && $canonical === $updated_user->user_email + && is_int( $other_id ) + && \is_wp_error( $collision_update['value'] ?? null ) + && 'existing_user_email' === $collision_update['value']->get_error_code() + && $other_after_user instanceof \WP_User + && $other_after_user->ID === $other_id + && $other_email === $other_after_user->user_email + && count( $mail_calls ) === $mail_count_before; + + if ( ! $ok ) { + $failures[] = array( + 'label' => $case['label'], + 'canonical' => self::describe_string( $canonical ), + 'machine' => self::describe_string( $machine ), + 'canonicalSanitized' => self::describe_captured_call( $canonical_sanitized ), + 'machineSanitized' => self::describe_captured_call( $machine_sanitized ), + 'canonicalValid' => self::describe_captured_call( $canonical_valid ), + 'machineValid' => self::describe_captured_call( $machine_valid ), + 'insert' => self::describe_captured_call( $insert ), + 'stored' => self::describe_captured_call( $stored ), + 'exists' => self::describe_captured_call( $exists ), + 'byEmail' => self::describe_captured_call( $by_email ), + 'machineExists' => self::describe_captured_call( $machine_exists ), + 'machineByEmail' => self::describe_captured_call( $machine_by_email ), + 'duplicate' => self::describe_captured_call( $duplicate ), + 'selfUpdate' => self::describe_captured_call( $self_update ), + 'storedAfterUpdate' => self::describe_captured_call( $stored_after_update ), + 'otherInsert' => self::describe_captured_call( $other_insert ), + 'collisionUpdate' => self::describe_captured_call( $collision_update ), + 'otherAfterCollision' => self::describe_captured_call( $other_after_collision ), + 'mailCalls' => self::describe_value( array_slice( $mail_calls, $mail_count_before ) ), + ); + } + + $observed[] = array( + 'label' => $case['label'], + 'canonical' => self::describe_string( $canonical ), + 'machine' => self::describe_string( $machine ), + 'domain' => self::describe_string( $case['domain'] ), + 'asciiDomain' => self::describe_string( $case['ascii'] ), + 'userId' => $user_id, + 'otherId' => $other_id, + 'storedCanonical' => $stored_user instanceof \WP_User && $canonical === $stored_user->user_email, + 'machineLookupMiss' => false === ( $machine_exists['value'] ?? null ) && false === $machine_email_user, + 'duplicateRejected' => \is_wp_error( $duplicate['value'] ?? null ), + 'collisionRejected' => \is_wp_error( $collision_update['value'] ?? null ), + 'collisionLeftOtherUnchanged' => $other_after_user instanceof \WP_User && $other_email === $other_after_user->user_email, + 'mailCalls' => count( $mail_calls ) - $mail_count_before, + ); + } + } finally { + self::restore_hook_globals( $hook_snapshot ); + self::reset_stub_content(); + } + + return array( + $ctx->result( + 'email.user-email-indexes.canonical-domain-aliases', + array() === $failures, + array( + 'caseCount' => count( $cases ), + 'observed' => $observed, + 'failures' => self::describe_value( $failures ), + ) + ), + ); + } + + private static function check_password_reset_unicode_email_paths( \ComponentFuzz\FuzzContext $ctx ): array { + if ( ! self::can_reset_stub_content() ) { + return array( + $ctx->skip( + 'email.password-reset.unicode-email-paths', + 'The in-memory wpdb content reset hook is unavailable.' + ), + ); + } + + $required = array( + 'retrieve_password', + 'wp_mail', + 'add_filter', + 'remove_filter', + ); + foreach ( $required as $function ) { + if ( ! function_exists( $function ) ) { + return array( + $ctx->skip( + 'email.password-reset.unicode-email-paths', + 'Required password reset APIs are unavailable.', + array( 'missing' => 'function ' . $function ) + ), + ); + } + } + + $cases = array( + array( + 'label' => 'unicode-local', + 'email' => "jos\u{00E9}.reset@example.org", + ), + ); + + if ( self::has_idn() ) { + $cases[] = array( + 'label' => 'unicode-domain', + 'email' => "reset@gr\u{00E5}.org", + ); + $cases[] = array( + 'label' => 'unicode-local-and-domain', + 'email' => "jos\u{00E9}.reset@gr\u{00E5}.org", + ); + } + + $failures = array(); + $observed = array(); + $hook_snapshot = self::snapshot_hook_globals(); + $server_snapshot = array_key_exists( 'REMOTE_ADDR', $_SERVER ) + ? array( 'exists' => true, 'value' => $_SERVER['REMOTE_ADDR'] ) + : array( 'exists' => false, 'value' => null ); + + self::reset_stub_content(); + try { + $_SERVER['REMOTE_ADDR'] = '127.0.0.1'; + + foreach ( $cases as $index => $case ) { + $mail_calls = array(); + $mail_filter = static function ( $return, $atts ) use ( &$mail_calls ) { + $mail_calls[] = $atts; + return true; + }; + + \add_filter( 'pre_wp_mail', $mail_filter, 10, 2 ); + try { + $login = 'cfz_reset_' . $ctx->iteration() . '_' . $index . '_' . substr( sha1( $case['email'] ), 0, 8 ); + $insert = self::capture_warnings( + static fn() => \wp_insert_user( + array( + 'user_login' => $login, + 'user_pass' => 'component-fuzz-pass', + 'user_email' => $case['email'], + 'role' => 'subscriber', + ) + ) + ); + $user_id = $insert['value'] ?? null; + $reset = is_int( $user_id ) + ? self::capture_warnings( static fn() => \retrieve_password( $case['email'] ) ) + : array( + 'threw' => false, + 'value' => null, + 'warnings' => array(), + ); + $mail = $mail_calls[0] ?? null; + + $ok = ! $insert['threw'] + && array() === $insert['warnings'] + && is_int( $user_id ) + && ! $reset['threw'] + && array() === $reset['warnings'] + && true === $reset['value'] + && 1 === count( $mail_calls ) + && is_array( $mail ) + && $case['email'] === ( $mail['to'] ?? null ) + && is_string( $mail['subject'] ?? null ) + && '' !== ( $mail['subject'] ?? '' ) + && is_string( $mail['message'] ?? null ) + && str_contains( $mail['message'], rawurlencode( $login ) ); + + if ( ! $ok ) { + $failures[] = array( + 'label' => $case['label'], + 'email' => self::describe_string( $case['email'] ), + 'login' => $login, + 'insert' => self::describe_captured_call( $insert ), + 'reset' => self::describe_captured_call( $reset ), + 'mailCalls' => self::describe_value( $mail_calls ), + ); + } + + $observed[] = array( + 'label' => $case['label'], + 'email' => self::describe_string( $case['email'] ), + 'userId' => $user_id, + 'reset' => self::describe_value( $reset['value'] ?? null ), + 'mailCalls' => count( $mail_calls ), + 'mailTo' => isset( $mail['to'] ) && is_string( $mail['to'] ) ? self::describe_string( $mail['to'] ) : null, + 'warnings' => count( $insert['warnings'] ) + count( $reset['warnings'] ), + ); + } finally { + \remove_filter( 'pre_wp_mail', $mail_filter, 10 ); + } + } + } finally { + if ( $server_snapshot['exists'] ) { + $_SERVER['REMOTE_ADDR'] = $server_snapshot['value']; + } else { + unset( $_SERVER['REMOTE_ADDR'] ); + } + self::restore_hook_globals( $hook_snapshot ); + self::reset_stub_content(); + } + + return array( + $ctx->result( + 'email.password-reset.unicode-email-paths', + array() === $failures, + array( + 'observed' => $observed, + 'failures' => self::describe_value( $failures ), + ) + ), + ); + } + + private static function check_password_reset_notification_recipient_views( \ComponentFuzz\FuzzContext $ctx ): array { + $result_name = 'email.password-reset.notification-recipient-views'; + + if ( ! self::can_reset_stub_content() ) { + return array( + $ctx->skip( + $result_name, + 'The in-memory wpdb content reset hook is unavailable.' + ), + ); + } + + if ( ! self::has_idn() ) { + return array( + $ctx->skip( + $result_name, + 'idn_to_ascii() or idn_to_utf8() is unavailable.' + ), + ); + } + + $missing = array(); + foreach ( + array( + 'add_action', + 'add_filter', + 'email_exists', + 'get_user_by', + 'is_email', + 'remove_filter', + 'remove_action', + 'retrieve_password', + 'sanitize_email', + 'wp_insert_user', + 'wp_mail', + ) as $function + ) { + if ( ! function_exists( $function ) ) { + $missing[] = 'function ' . $function; + } + } + + foreach ( array( 'PHPMailer\PHPMailer\PHPMailer', 'WP_PHPMailer' ) as $class ) { + if ( ! class_exists( $class ) ) { + $missing[] = 'class ' . $class; + } + } + + if ( array() !== $missing ) { + return array( + $ctx->skip( + $result_name, + 'Required password reset recipient APIs are unavailable.', + array( 'missing' => implode( ', ', $missing ) ) + ), + ); + } + + $cases = self::password_reset_recipient_alias_cases( $ctx->fork( 'password-reset-recipient-views' ) ); + $failures = array(); + $observed = array(); + $hook_snapshot = self::snapshot_hook_globals(); + $server_snapshot = array_key_exists( 'REMOTE_ADDR', $_SERVER ) + ? array( 'exists' => true, 'value' => $_SERVER['REMOTE_ADDR'] ) + : array( 'exists' => false, 'value' => null ); + $mailer_snapshot = array_key_exists( 'phpmailer', $GLOBALS ) + ? array( 'exists' => true, 'value' => $GLOBALS['phpmailer'] ) + : array( 'exists' => false, 'value' => null ); + $validator_snapshot = \PHPMailer\PHPMailer\PHPMailer::$validator; + $not_called = static function (): array { + return array( + 'threw' => false, + 'value' => null, + 'warnings' => array(), + ); + }; + $mail_succeeded = array(); + $success_action = static function ( array $mail_data ) use ( &$mail_succeeded ): void { + $mail_succeeded[] = $mail_data; + }; + + self::reset_stub_content(); + try { + $_SERVER['REMOTE_ADDR'] = '127.0.0.1'; + \PHPMailer\PHPMailer\PHPMailer::$validator = static function ( $email ): bool { + return (bool) \is_email( $email ); + }; + + \remove_all_filters( 'pre_user_email' ); + \add_filter( 'pre_user_email', 'trim' ); + \add_filter( 'pre_user_email', 'sanitize_email' ); + if ( function_exists( 'wp_filter_kses' ) ) { + \add_filter( 'pre_user_email', 'wp_filter_kses' ); + } + + foreach ( + array( + 'lostpassword_user_data', + 'lostpassword_post', + 'lostpassword_errors', + 'send_retrieve_password_email', + 'retrieve_password_title', + 'retrieve_password_message', + 'retrieve_password_notification_email', + 'wp_mail', + 'pre_wp_mail', + 'wp_mail_succeeded', + ) as $password_reset_hook + ) { + \remove_all_filters( $password_reset_hook ); + } + \add_action( 'wp_mail_succeeded', $success_action ); + + foreach ( $cases as $case_index => $case ) { + self::reset_stub_content(); + self::install_email_filters( 'unicode' ); + EmailSurfaceMailer::$sent = array(); + $GLOBALS['phpmailer'] = self::new_mailer(); + $mail_succeeded = array(); + + if ( null !== $case['conversionError'] ) { + $failures[] = array( + 'label' => $case['label'], + 'error' => $case['conversionError'], + ); + continue; + } + + $unicode_parse = self::capture_warnings( + static fn() => \WP_Email_Address::from_string( $case['unicodeInput'], 'unicode' ) + ); + $machine_parse = self::capture_warnings( + static fn() => \WP_Email_Address::from_string( $case['machineInput'], 'unicode' ) + ); + $unicode_email = $unicode_parse['value'] ?? null; + $machine_email = $machine_parse['value'] ?? null; + $parse_ok = ! $unicode_parse['threw'] + && ! $machine_parse['threw'] + && array() === $unicode_parse['warnings'] + && array() === $machine_parse['warnings'] + && $unicode_email instanceof \WP_Email_Address + && $machine_email instanceof \WP_Email_Address + && $case['local'] === $unicode_email->get_localpart() + && $case['unicodeInput'] === $unicode_email->get_unicode_address() + && $case['machineInput'] === $unicode_email->get_ascii_address() + && $unicode_email->get_unicode_address() === $machine_email->get_unicode_address() + && $unicode_email->get_ascii_address() === $machine_email->get_ascii_address() + && $unicode_email->get_unicode_domain() !== $unicode_email->get_ascii_domain() + && self::is_ascii( $unicode_email->get_ascii_domain() ); + + if ( ! $parse_ok ) { + $failures[] = array( + 'label' => $case['label'], + 'unicodeInput' => self::describe_string( $case['unicodeInput'] ), + 'machineInput' => self::describe_string( $case['machineInput'] ), + 'unicodeParse' => self::describe_captured_call( $unicode_parse ), + 'machineParse' => self::describe_captured_call( $machine_parse ), + ); + continue; + } + + $canonical = $unicode_email->get_unicode_address(); + $machine = $unicode_email->get_ascii_address(); + $notification_calls = array(); + $notification_filter = static function ( $defaults, $key, $user_login, $user_data ) use ( &$notification_calls, $machine ) { + $notification_calls[] = array( + 'defaults' => $defaults, + 'key' => $key, + 'userLogin' => $user_login, + 'userEmail' => $user_data instanceof \WP_User ? $user_data->user_email : null, + ); + + $defaults['to'] = $machine; + return $defaults; + }; + + \add_filter( 'retrieve_password_notification_email', $notification_filter, 10, 4 ); + try { + $login = 'cfz_reset_views_' . $ctx->iteration() . '_' . $case_index . '_' . substr( sha1( $canonical ), 0, 8 ); + $insert = self::capture_warnings( + static fn() => \wp_insert_user( + array( + 'user_login' => $login, + 'user_pass' => 'component-fuzz-pass', + 'user_email' => $machine, + 'role' => 'subscriber', + ) + ) + ); + $user_id = $insert['value'] ?? null; + $stored = is_int( $user_id ) ? self::capture_warnings( static fn() => \get_user_by( 'id', $user_id ) ) : $not_called(); + $exists_canonical = is_int( $user_id ) ? self::capture_warnings( static fn() => \email_exists( $canonical ) ) : $not_called(); + $by_canonical = is_int( $user_id ) ? self::capture_warnings( static fn() => \get_user_by( 'email', $canonical ) ) : $not_called(); + $exists_machine = is_int( $user_id ) ? self::capture_warnings( static fn() => \email_exists( $machine ) ) : $not_called(); + $reset = is_int( $user_id ) ? self::capture_warnings( static fn() => \retrieve_password( $canonical ) ) : $not_called(); + $mail = EmailSurfaceMailer::$sent[0] ?? null; + $mail_success = $mail_succeeded[0] ?? null; + $notification = $notification_calls[0] ?? null; + $mail_to = is_array( $mail ) && is_array( $mail['to'] ?? null ) && is_string( $mail['to'][0][0] ?? null ) + ? $mail['to'][0][0] + : null; + $mail_to_parse = is_string( $mail_to ) + ? self::capture_warnings( static fn() => \WP_Email_Address::from_string( $mail_to, 'unicode' ) ) + : $not_called(); + $mail_to_is = is_string( $mail_to ) + ? self::capture_warnings( static fn() => \is_email( $mail_to ) ) + : $not_called(); + $mail_to_sanitize = is_string( $mail_to ) + ? self::capture_warnings( static fn() => \sanitize_email( $mail_to ) ) + : $not_called(); + $mail_count_before = count( EmailSurfaceMailer::$sent ); + $success_count_before = count( $mail_succeeded ); + $notification_count_before = count( $notification_calls ); + $machine_reset = is_int( $user_id ) ? self::capture_warnings( static fn() => \retrieve_password( $machine ) ) : $not_called(); + } finally { + \remove_filter( 'retrieve_password_notification_email', $notification_filter, 10 ); + } + + $stored_user = $stored['value'] ?? null; + $canonical_user = $by_canonical['value'] ?? null; + $mail_to_email = $mail_to_parse['value'] ?? null; + $notification_to = is_array( $notification ) && is_array( $notification['defaults'] ?? null ) + ? ( $notification['defaults']['to'] ?? null ) + : null; + $notification_login = is_array( $notification ) ? ( $notification['userLogin'] ?? null ) : null; + $notification_email = is_array( $notification ) ? ( $notification['userEmail'] ?? null ) : null; + $message = is_array( $mail ) && is_string( $mail['message'] ?? null ) ? $mail['message'] : ''; + $success_to = is_array( $mail_success ) ? (array) ( $mail_success['to'] ?? array() ) : array(); + + $insert_lookup_ok = ! $insert['threw'] + && ! $stored['threw'] + && ! $exists_canonical['threw'] + && ! $by_canonical['threw'] + && ! $exists_machine['threw'] + && array() === $insert['warnings'] + && array() === $stored['warnings'] + && array() === $exists_canonical['warnings'] + && array() === $by_canonical['warnings'] + && array() === $exists_machine['warnings'] + && is_int( $user_id ) + && $stored_user instanceof \WP_User + && $stored_user->ID === $user_id + && $canonical === $stored_user->user_email + && $exists_canonical['value'] === $user_id + && $canonical_user instanceof \WP_User + && $canonical_user->ID === $user_id + && $canonical === $canonical_user->user_email + && false === $exists_machine['value']; + + $notification_ok = is_array( $notification ) + && $canonical === $notification_to + && $login === $notification_login + && $canonical === $notification_email + && is_string( $notification['key'] ?? null ) + && '' !== ( $notification['key'] ?? '' ); + + $mail_to_ok = ! $mail_to_parse['threw'] + && ! $mail_to_is['threw'] + && ! $mail_to_sanitize['threw'] + && array() === $mail_to_parse['warnings'] + && array() === $mail_to_is['warnings'] + && array() === $mail_to_sanitize['warnings'] + && $mail_to_email instanceof \WP_Email_Address + && $machine === $mail_to + && $machine === $mail_to_email->get_ascii_address() + && $canonical === $mail_to_email->get_unicode_address() + && $canonical === $mail_to_is['value'] + && $canonical === $mail_to_sanitize['value'] + && is_array( $mail ) + && 1 === count( $mail['to'] ?? array() ) + && self::addresses_include( $mail['to'] ?? array(), $machine, '' ); + + $reset_ok = ! $reset['threw'] + && array() === $reset['warnings'] + && true === $reset['value'] + && 1 === count( $notification_calls ) + && 1 === count( EmailSurfaceMailer::$sent ) + && 1 === count( $mail_succeeded ) + && is_array( $mail ) + && is_array( $mail_success ) + && array( $machine ) === $success_to + && is_string( $mail['subject'] ?? null ) + && '' !== ( $mail['subject'] ?? '' ) + && str_contains( $message, rawurlencode( $login ) ); + + $machine_reset_ok = ! $machine_reset['threw'] + && array() === $machine_reset['warnings'] + && \is_wp_error( $machine_reset['value'] ?? null ) + && 'invalid_email' === $machine_reset['value']->get_error_code() + && $mail_count_before === count( EmailSurfaceMailer::$sent ) + && $success_count_before === count( $mail_succeeded ) + && $notification_count_before === count( $notification_calls ); + + $ok = $insert_lookup_ok + && $notification_ok + && $mail_to_ok + && $reset_ok + && $machine_reset_ok; + + if ( ! $ok ) { + $failures[] = array( + 'label' => $case['label'], + 'canonical' => self::describe_string( $canonical ), + 'machine' => self::describe_string( $machine ), + 'login' => $login, + 'insert' => self::describe_captured_call( $insert ), + 'stored' => self::describe_captured_call( $stored ), + 'existsCanonical' => self::describe_captured_call( $exists_canonical ), + 'byCanonical' => self::describe_captured_call( $by_canonical ), + 'existsMachine' => self::describe_captured_call( $exists_machine ), + 'reset' => self::describe_captured_call( $reset ), + 'machineReset' => self::describe_captured_call( $machine_reset ), + 'notificationCalls' => self::describe_value( $notification_calls ), + 'sentMail' => self::describe_value( EmailSurfaceMailer::$sent ), + 'mailSucceeded' => self::describe_value( $mail_succeeded ), + 'mailToParse' => self::describe_captured_call( $mail_to_parse ), + 'mailToIsEmail' => self::describe_captured_call( $mail_to_is ), + 'mailToSanitize' => self::describe_captured_call( $mail_to_sanitize ), + 'insertLookupOk' => $insert_lookup_ok, + 'notificationOk' => $notification_ok, + 'mailToOk' => $mail_to_ok, + 'resetOk' => $reset_ok, + 'machineResetOk' => $machine_reset_ok, + ); + } + + $observed[] = array( + 'label' => $case['label'], + 'profile' => $case['profile'], + 'canonical' => self::describe_string( $canonical ), + 'machine' => self::describe_string( $machine ), + 'unicodeLocal' => ! self::is_ascii( $unicode_email->get_localpart() ), + 'unicodeDomain' => self::describe_string( $unicode_email->get_unicode_domain() ), + 'asciiDomain' => self::describe_string( $unicode_email->get_ascii_domain() ), + 'storedCanonical' => $stored_user instanceof \WP_User && $canonical === $stored_user->user_email, + 'machineLookupMiss' => false === ( $exists_machine['value'] ?? null ), + 'machineResetRejected' => \is_wp_error( $machine_reset['value'] ?? null ), + 'mailToMachine' => is_array( $mail ) && self::addresses_include( $mail['to'] ?? array(), $machine, '' ), + ); + } + } finally { + \remove_action( 'wp_mail_succeeded', $success_action ); + \PHPMailer\PHPMailer\PHPMailer::$validator = $validator_snapshot; + EmailSurfaceMailer::$sent = array(); + if ( $mailer_snapshot['exists'] ) { + $GLOBALS['phpmailer'] = $mailer_snapshot['value']; + } else { + unset( $GLOBALS['phpmailer'] ); + } + if ( $server_snapshot['exists'] ) { + $_SERVER['REMOTE_ADDR'] = $server_snapshot['value']; + } else { + unset( $_SERVER['REMOTE_ADDR'] ); + } + self::restore_hook_globals( $hook_snapshot ); + self::reset_stub_content(); + } + + return array( + $ctx->result( + $result_name, + array() === $failures, + array( + 'caseCount' => count( $cases ), + 'dbCoverage' => 'stub-backed no-DB lookup only; MySQL user_email collation and index behavior is not exercised here.', + 'observed' => $observed, + 'failures' => self::describe_value( $failures ), + ) + ), + ); + } + + private static function check_punycode_views( \ComponentFuzz\FuzzContext $ctx ): array { + if ( ! self::has_idn() ) { + return array( + $ctx->skip( + 'email.wp-email-address.punycode-domain-decodes', + 'idn_to_ascii() or idn_to_utf8() is unavailable.' + ), + ); + } + + $input = 'books@xn--bcher-kva.de'; + $call = self::call( static fn() => \WP_Email_Address::from_string( $input, 'unicode' ) ); + $email = $call['value'] ?? null; + $ok = ! $call['threw'] + && $email instanceof \WP_Email_Address + && 'xn--bcher-kva.de' === $email->get_ascii_domain() + && "b\u{00FC}cher.de" === $email->get_unicode_domain() + && $input === $email->get_ascii_address() + && "books@b\u{00FC}cher.de" === $email->get_unicode_address() + && self::is_ascii( $email->get_ascii_address() ) + && ! self::is_ascii( $email->get_unicode_address() ); + + return array( + $ctx->result( + 'email.wp-email-address.punycode-domain-decodes', + $ok, + array( + 'input' => self::describe_string( $input ), + 'parsed' => self::describe_call( $call ), + ) + ), + ); + } + + private static function check_idn_views( \ComponentFuzz\FuzzContext $ctx ): array { + if ( ! self::has_idn() ) { + return array( + $ctx->skip( + 'email.wp-email-address.idn-view-matrix', + 'idn_to_ascii() or idn_to_utf8() is unavailable.' + ), + ); + } + + $samples = array( + array( 'label' => 'latin-diaeresis', 'domain' => "b\u{00FC}cher.de" ), + array( 'label' => 'latin-ring', 'domain' => "gr\u{00E5}.org" ), + array( 'label' => 'cjk', 'domain' => "\u{4F8B}\u{5B50}.\u{5E7F}\u{544A}" ), + array( 'label' => 'greek', 'domain' => "\u{03C0}\u{03B1}\u{03C1}\u{03AC}\u{03B4}\u{03B5}\u{03B9}\u{03B3}\u{03BC}\u{03B1}.\u{03B4}\u{03BF}\u{03BA}\u{03B9}\u{03BC}\u{03AE}" ), + array( 'label' => 'cyrillic', 'domain' => "\u{043F}\u{0440}\u{0438}\u{043C}\u{0435}\u{0440}.\u{0438}\u{0441}\u{043F}\u{044B}\u{0442}\u{0430}\u{043D}\u{0438}\u{0435}" ), + array( 'label' => 'hiragana', 'domain' => "\u{308C}\u{3044}.\u{307F}\u{3093}\u{306A}" ), + array( 'label' => 'eszett', 'domain' => "fa\u{00DF}.de" ), + ); + $failures = array(); + $views = array(); + + foreach ( $samples as $sample ) { + $encoded_domain = idn_to_ascii( $sample['domain'], IDNA_DEFAULT, INTL_IDNA_VARIANT_UTS46 ); + $decoded_domain = false === $encoded_domain ? false : idn_to_utf8( $encoded_domain, IDNA_DEFAULT, INTL_IDNA_VARIANT_UTS46 ); + if ( false === $encoded_domain || false === $decoded_domain ) { + $failures[] = array( + 'label' => $sample['label'], + 'domain' => self::describe_string( $sample['domain'] ), + 'error' => 'IDN conversion failed.', + ); + continue; + } + + $ascii_input = 'mail@' . $encoded_domain; + $unicode_input = 'mail@' . $sample['domain']; + $encoded_parse = self::call( static fn() => \WP_Email_Address::from_string( $ascii_input, 'unicode' ) ); + $raw_parse = self::call( static fn() => \WP_Email_Address::from_string( $unicode_input, 'unicode' ) ); + $is_encoded = self::call( static fn() => \is_email( $ascii_input ) ); + $sanitize_encoded = self::call( static fn() => \sanitize_email( $ascii_input ) ); + $encoded_email = $encoded_parse['value'] ?? null; + $raw_email = $raw_parse['value'] ?? null; + $raw_roundtrip = $raw_email instanceof \WP_Email_Address + ? self::call( static fn() => \WP_Email_Address::from_string( $raw_email->get_ascii_address(), 'unicode' ) ) + : array( 'threw' => false, 'value' => null ); + + $encoded_ok = ! $encoded_parse['threw'] + && ! $is_encoded['threw'] + && ! $sanitize_encoded['threw'] + && $encoded_email instanceof \WP_Email_Address + && $encoded_domain === $encoded_email->get_ascii_domain() + && $decoded_domain === $encoded_email->get_unicode_domain() + && $ascii_input === $encoded_email->get_ascii_address() + && 'mail@' . $decoded_domain === $encoded_email->get_unicode_address() + && 'mail@' . $decoded_domain === $is_encoded['value'] + && 'mail@' . $decoded_domain === $sanitize_encoded['value'] + && self::is_ascii( $encoded_email->get_ascii_address() ); + + $raw_ok = ! $raw_parse['threw'] + && ! $raw_roundtrip['threw'] + && $raw_email instanceof \WP_Email_Address + && $encoded_domain === $raw_email->get_ascii_domain() + && $sample['domain'] === $raw_email->get_unicode_domain() + && 'mail@' . $encoded_domain === $raw_email->get_ascii_address() + && $unicode_input === $raw_email->get_unicode_address() + && self::is_ascii( $raw_email->get_ascii_address() ) + && $raw_roundtrip['value'] instanceof \WP_Email_Address + && $unicode_input === $raw_roundtrip['value']->get_unicode_address(); + + if ( ! $encoded_ok || ! $raw_ok ) { + $failures[] = array( + 'label' => $sample['label'], + 'domain' => self::describe_string( $sample['domain'] ), + 'encodedDomain' => self::describe_string( $encoded_domain ), + 'decodedDomain' => self::describe_string( $decoded_domain ), + 'encodedParse' => self::describe_call( $encoded_parse ), + 'rawParse' => self::describe_call( $raw_parse ), + 'isEncoded' => self::describe_call( $is_encoded ), + 'sanitizeEncoded' => self::describe_call( $sanitize_encoded ), + 'rawRoundtrip' => self::describe_call( $raw_roundtrip ), + ); + } + + $views[] = array( + 'label' => $sample['label'], + 'domain' => self::describe_string( $sample['domain'] ), + 'encodedDomain' => self::describe_string( $encoded_domain ), + 'decodedDomain' => self::describe_string( $decoded_domain ), + 'rawAsciiDomainMatchesIdn' => $raw_email instanceof \WP_Email_Address && $encoded_domain === $raw_email->get_ascii_domain(), + 'rawAsciiAddressIsAscii' => $raw_email instanceof \WP_Email_Address && self::is_ascii( $raw_email->get_ascii_address() ), + ); + } + + return array( + $ctx->result( + 'email.wp-email-address.idn-view-matrix', + array() === $failures, + array( + 'views' => $views, + 'failures' => $failures, + ) + ), + ); + } + + private static function check_extension_address_views( \ComponentFuzz\FuzzContext $ctx ): array { + $samples = self::extension_view_cases( $ctx ); + $failures = array(); + $views = array(); + + foreach ( $samples as $sample ) { + $input = $sample['local'] . '@' . $sample['domain']; + $parsed = self::call( static fn() => \WP_Email_Address::from_string( $input, 'unicode' ) ); + $email = $parsed['value'] ?? null; + + if ( $parsed['threw'] || ! ( $email instanceof \WP_Email_Address ) ) { + $failures[] = array( + 'label' => $sample['label'], + 'source' => $sample['source'], + 'input' => self::describe_string( $input ), + 'parsed' => self::describe_call( $parsed ), + ); + continue; + } + + $href_address = $email->get_ascii_address(); + $href = 'mailto:' . $href_address; + $text = $email->get_unicode_address(); + $href_parts = explode( '@', $href_address, 2 ); + $text_parts = explode( '@', $text, 2 ); + $href_local = 2 === count( $href_parts ) ? $href_parts[0] : null; + $href_domain = 2 === count( $href_parts ) ? $href_parts[1] : null; + $text_local = 2 === count( $text_parts ) ? $text_parts[0] : null; + $text_domain = 2 === count( $text_parts ) ? $text_parts[1] : null; + $href_parse = self::call( static fn() => \WP_Email_Address::from_string( $href_address, 'unicode' ) ); + $text_parse = self::call( static fn() => \WP_Email_Address::from_string( $text, 'unicode' ) ); + + $has_unicode_local = ! self::is_ascii( $email->get_localpart() ); + $has_unicode_domain = $email->get_ascii_domain() !== $email->get_unicode_domain(); + $href_roundtrip = $href_parse['value'] ?? null; + $text_roundtrip = $text_parse['value'] ?? null; + $href_has_mailto = 0 === strpos( $href, 'mailto:' ) + && $href_address === substr( $href, strlen( 'mailto:' ) ); + $local_preserved = $email->get_localpart() === $href_local + && $email->get_localpart() === $text_local; + $domain_views = $email->get_ascii_domain() === $href_domain + && $email->get_unicode_domain() === $text_domain + && is_string( $href_domain ) + && self::is_ascii( $href_domain ); + $unicode_local = ! $has_unicode_local + || ( + is_string( $href_local ) + && is_string( $text_local ) + && ! self::is_ascii( $href_local ) + && ! self::is_ascii( $text_local ) + ); + $unicode_domain = ! $has_unicode_domain || $href_domain !== $text_domain; + $roundtrips = ! $href_parse['threw'] + && ! $text_parse['threw'] + && $href_roundtrip instanceof \WP_Email_Address + && $text_roundtrip instanceof \WP_Email_Address + && $href_roundtrip->get_ascii_address() === $email->get_ascii_address() + && $href_roundtrip->get_unicode_address() === $email->get_unicode_address() + && $text_roundtrip->get_ascii_address() === $email->get_ascii_address() + && $text_roundtrip->get_unicode_address() === $email->get_unicode_address(); + $ok = $href_has_mailto + && $href_address === $email->get_ascii_address() + && $text === $email->get_unicode_address() + && $local_preserved + && $domain_views + && $unicode_local + && $unicode_domain + && $roundtrips; + + if ( ! $ok ) { + $failures[] = array( + 'label' => $sample['label'], + 'source' => $sample['source'], + 'input' => self::describe_string( $input ), + 'address' => self::describe_address( $email ), + 'href' => self::describe_string( $href ), + 'text' => self::describe_string( $text ), + 'hrefLocal' => self::describe_value( $href_local ), + 'hrefDomain' => self::describe_value( $href_domain ), + 'textLocal' => self::describe_value( $text_local ), + 'textDomain' => self::describe_value( $text_domain ), + 'hrefParse' => self::describe_call( $href_parse ), + 'textParse' => self::describe_call( $text_parse ), + 'hrefHasMailto' => $href_has_mailto, + 'localPreserved' => $local_preserved, + 'domainViews' => $domain_views, + 'unicodeLocal' => $unicode_local, + 'unicodeDomain' => $unicode_domain, + 'roundtrips' => $roundtrips, + ); + } + + $views[] = array( + 'label' => $sample['label'], + 'source' => $sample['source'], + 'input' => self::describe_string( $input ), + 'machineAddress' => self::describe_string( $href_address ), + 'readableAddress' => self::describe_string( $text ), + 'machineDomainAscii' => is_string( $href_domain ) && self::is_ascii( $href_domain ), + 'unicodeLocal' => $has_unicode_local, + 'unicodeDomain' => $has_unicode_domain, + ); + } + + return array( + $ctx->result( + 'email.wp-email-address.extension-machine-readable-views', + array() === $failures, + array( + 'caseCount' => count( $samples ), + 'views' => $views, + 'failures' => $failures, + ) + ), + ); + } + + private static function check_mailto_rendering_context_round_trips( \ComponentFuzz\FuzzContext $ctx ): array { + foreach ( array( 'esc_html', 'esc_url', 'esc_url_raw', 'sanitize_url' ) as $function ) { + if ( ! function_exists( $function ) ) { + return array( + $ctx->skip( + 'email.mailto.rendering-context-round-trips', + 'Required URL/HTML escaping APIs are unavailable.', + array( 'missing' => 'function ' . $function ) + ), + ); + } + } + + $cases = self::mailto_context_cases( $ctx->fork( 'mailto-rendering-contexts' ) ); + $failures = array(); + $observed = array(); + + foreach ( $cases as $case ) { + $input = $case['local'] . '@' . $case['domain']; + $parse = self::capture_warnings( static fn() => \WP_Email_Address::from_string( $input, 'unicode' ) ); + $email = $parse['value'] ?? null; + + if ( $parse['threw'] || array() !== $parse['warnings'] || ! ( $email instanceof \WP_Email_Address ) ) { + $failures[] = array( + 'label' => $case['label'], + 'input' => self::describe_string( $input ), + 'parse' => self::describe_captured_call( $parse ), + ); + continue; + } + + $uri_mailbox = self::mailto_uri_mailbox( $email ); + $raw_href = 'mailto:' . $uri_mailbox . '?subject=' . rawurlencode( $case['subject'] ) . '&body=' . rawurlencode( $case['body'] ); + $escaped_href = self::capture_warnings( static fn() => \esc_url( $raw_href ) ); + $raw_escaped_href = self::capture_warnings( static fn() => \esc_url_raw( $raw_href ) ); + $sanitized_href = self::capture_warnings( static fn() => \sanitize_url( $raw_href ) ); + $escaped_text = self::capture_warnings( static fn() => \esc_html( $email->get_unicode_address() ) ); + $anchor_html = ! $escaped_href['threw'] && ! $escaped_text['threw'] && is_string( $escaped_href['value'] ?? null ) && is_string( $escaped_text['value'] ?? null ) + ? '' . $escaped_text['value'] . '' + : ''; + $anchors = '' !== $anchor_html ? self::mailto_anchors( $anchor_html ) : array(); + $anchor = $anchors[0] ?? array(); + $decoded_href = isset( $anchor['href'] ) && is_string( $anchor['href'] ) + ? 'mailto:' . html_entity_decode( $anchor['href'], ENT_QUOTES, 'UTF-8' ) + : ''; + $decoded_text = isset( $anchor['text'] ) && is_string( $anchor['text'] ) + ? html_entity_decode( $anchor['text'], ENT_QUOTES, 'UTF-8' ) + : ''; + $href_parts = '' !== $decoded_href ? parse_url( $decoded_href ) : false; + $decoded_mailbox = is_array( $href_parts ) && isset( $href_parts['path'] ) ? rawurldecode( $href_parts['path'] ) : ''; + $query = array(); + if ( is_array( $href_parts ) && isset( $href_parts['query'] ) ) { + parse_str( $href_parts['query'], $query ); + } + $href_parse = '' === $decoded_mailbox + ? array( 'threw' => false, 'value' => null, 'warnings' => array() ) + : self::capture_warnings( static fn() => \WP_Email_Address::from_string( $decoded_mailbox, 'unicode' ) ); + $href_email = $href_parse['value'] ?? null; + + $display_href_round_trips = ! $escaped_href['threw'] + && is_string( $escaped_href['value'] ?? null ) + && $raw_href === html_entity_decode( $escaped_href['value'], ENT_QUOTES, 'UTF-8' ) + && str_contains( $escaped_href['value'], '&body=' ) + && ! str_contains( $escaped_href['value'], '&body=' ); + $raw_href_round_trips = ! $raw_escaped_href['threw'] + && ! $sanitized_href['threw'] + && array() === $raw_escaped_href['warnings'] + && array() === $sanitized_href['warnings'] + && $raw_href === $raw_escaped_href['value'] + && $raw_href === $sanitized_href['value']; + $anchor_ok = 1 === count( $anchors ) + && $decoded_href === $raw_href + && $decoded_text === $email->get_unicode_address(); + $href_parts_ok = is_array( $href_parts ) + && 'mailto' === ( $href_parts['scheme'] ?? null ) + && $decoded_mailbox === $email->get_ascii_address() + && ( $query['subject'] ?? null ) === $case['subject'] + && ( $query['body'] ?? null ) === $case['body']; + $href_parse_ok = ! $href_parse['threw'] + && array() === ( $href_parse['warnings'] ?? array() ) + && $href_email instanceof \WP_Email_Address + && $href_email->get_ascii_address() === $email->get_ascii_address() + && $href_email->get_unicode_address() === $email->get_unicode_address(); + $uri_uses_context_views = str_contains( $raw_href, '@' . $email->get_ascii_domain() ) + && ( + $email->get_ascii_domain() === $email->get_unicode_domain() + || ! str_contains( $uri_mailbox, $email->get_unicode_domain() ) + ) + && ( + self::is_ascii( $email->get_localpart() ) + || ! str_contains( $uri_mailbox, $email->get_localpart() ) + ); + $ok = array() === $parse['warnings'] + && ! $escaped_href['threw'] + && ! $raw_escaped_href['threw'] + && ! $sanitized_href['threw'] + && ! $escaped_text['threw'] + && array() === $escaped_href['warnings'] + && array() === $escaped_text['warnings'] + && $display_href_round_trips + && $raw_href_round_trips + && $anchor_ok + && $href_parts_ok + && $href_parse_ok + && $uri_uses_context_views; + + if ( ! $ok ) { + $failures[] = array( + 'label' => $case['label'], + 'source' => $case['source'], + 'input' => self::describe_string( $input ), + 'address' => self::describe_address( $email ), + 'uriMailbox' => self::describe_string( $uri_mailbox ), + 'rawHref' => self::describe_string( $raw_href ), + 'escapedHref' => self::describe_captured_call( $escaped_href ), + 'rawEscapedHref' => self::describe_captured_call( $raw_escaped_href ), + 'sanitizedHref' => self::describe_captured_call( $sanitized_href ), + 'escapedText' => self::describe_captured_call( $escaped_text ), + 'anchorHtml' => self::describe_string( $anchor_html ), + 'anchors' => self::describe_value( $anchors ), + 'decodedHref' => self::describe_string( $decoded_href ), + 'decodedText' => self::describe_string( $decoded_text ), + 'decodedMailbox' => self::describe_string( $decoded_mailbox ), + 'query' => self::describe_value( $query ), + 'hrefParse' => self::describe_captured_call( $href_parse ), + 'displayRoundTrips' => $display_href_round_trips, + 'rawRoundTrips' => $raw_href_round_trips, + 'anchorOk' => $anchor_ok, + 'hrefPartsOk' => $href_parts_ok, + 'hrefParseOk' => $href_parse_ok, + 'uriViewsOk' => $uri_uses_context_views, + ); + } + + $observed[] = array( + 'label' => $case['label'], + 'source' => $case['source'], + 'readableAddress' => self::describe_string( $email->get_unicode_address() ), + 'uriMailbox' => self::describe_string( $uri_mailbox ), + 'unicodeLocal' => ! self::is_ascii( $email->get_localpart() ), + 'unicodeDomain' => $email->get_ascii_domain() !== $email->get_unicode_domain(), + 'hrefPercentEncoded' => $uri_mailbox !== $email->get_ascii_address(), + ); + } + + return array( + $ctx->result( + 'email.mailto.rendering-context-round-trips', + array() === $failures, + array( + 'caseCount' => count( $cases ), + 'observed' => $observed, + 'failures' => $failures, + ) + ), + ); + } + + private static function check_make_clickable_email_rendering( \ComponentFuzz\FuzzContext $ctx ): array { + $samples = array( + array( + 'label' => 'ascii-baseline', + 'address' => 'user@example.com', + 'valid' => true, + ), + array( + 'label' => 'plus-tag', + 'address' => 'USER+tag@example.com', + 'valid' => true, + ), + ); + + if ( self::has_idn() ) { + $samples[] = array( + 'label' => 'ascii-local-punycode-domain', + 'address' => 'mail@xn--bcher-kva.de', + 'valid' => true, + ); + $samples[] = array( + 'label' => 'ascii-local-punycode-tld', + 'address' => 'mail@xn--fsqu00a.xn--4rr70v', + 'valid' => true, + ); + } + + foreach ( self::extension_view_cases( $ctx->fork( 'make-clickable' ) ) as $sample ) { + $input = $sample['local'] . '@' . $sample['domain']; + $parsed = self::call( static fn() => \WP_Email_Address::from_string( $input, 'unicode' ) ); + $email = $parsed['value'] ?? null; + + if ( ! $parsed['threw'] && $email instanceof \WP_Email_Address ) { + $samples[] = array( + 'label' => $sample['label'] . '-machine', + 'address' => $email->get_ascii_address(), + 'valid' => true, + ); + + if ( $email->get_unicode_address() !== $email->get_ascii_address() ) { + $samples[] = array( + 'label' => $sample['label'] . '-readable', + 'address' => $email->get_unicode_address(), + 'valid' => true, + ); + } + } + } + + $samples = array_merge( + $samples, + array( + array( + 'label' => 'unicode-local-valid-address', + 'address' => "jos\u{00E9}@example.com", + 'valid' => true, + ), + array( + 'label' => 'emoji-local-invalid-address', + 'address' => "emoji\u{1F600}@example.com", + 'valid' => false, + ), + array( + 'label' => 'invalid-utf8-local-address', + 'address' => "bad\x80@example.com", + 'valid' => false, + ), + array( + 'label' => 'double-at-address', + 'address' => 'bad@@example.com', + 'valid' => false, + ), + array( + 'label' => 'double-dot-domain-address', + 'address' => 'user@example..com', + 'valid' => false, + ), + array( + 'label' => 'invalid-punycode-tld-no-partial-link', + 'address' => 'mail@example.xn--', + 'valid' => false, + ), + array( + 'label' => 'trailing-hyphen-tld-no-partial-link', + 'address' => 'mail@example.co-', + 'valid' => false, + ), + ) + ); + + $failures = array(); + $observed = array(); + $partial_link_failures = array(); + + foreach ( $samples as $case ) { + $address = $case['address']; + $wrapped = 'Contact ' . $address . ' now'; + $rendered = self::call( static fn() => \make_clickable( $wrapped ) ); + $parse = self::call( static fn() => \WP_Email_Address::from_string( $address, 'unicode' ) ); + $email = $parse['value'] ?? null; + $anchors = ! $rendered['threw'] && is_string( $rendered['value'] ) + ? self::mailto_anchors( $rendered['value'] ) + : array(); + $expect_link = true === $case['valid'] && self::make_clickable_email_pattern_matches( $address ); + $actual_link = 1 === count( $anchors ); + $anchor = $anchors[0] ?? array(); + $href_address = (string) ( $anchor['href'] ?? '' ); + $text_address = (string) ( $anchor['text'] ?? '' ); + $href_parse = '' === $href_address + ? array( 'threw' => false, 'value' => null ) + : self::call( static fn() => \WP_Email_Address::from_string( $href_address, 'unicode' ) ); + $text_parse = '' === $text_address + ? array( 'threw' => false, 'value' => null ) + : self::call( static fn() => \WP_Email_Address::from_string( $text_address, 'unicode' ) ); + $href_email = $href_parse['value'] ?? null; + $text_email = $text_parse['value'] ?? null; + + if ( + ! $expect_link + && array() !== $anchors + && str_contains( strtolower( $address ), '.xn--' ) + ) { + $partial_link_failures[] = array( + 'label' => $case['label'], + 'address' => self::describe_string( $address ), + 'rendered' => self::describe_call( $rendered ), + 'anchors' => self::describe_value( $anchors ), + ); + } + + if ( $expect_link ) { + $ok = ! $rendered['threw'] + && $email instanceof \WP_Email_Address + && $actual_link + && $address === $href_address + && $address === $text_address + && $href_email instanceof \WP_Email_Address + && $text_email instanceof \WP_Email_Address + && $href_email->get_unicode_address() === $email->get_unicode_address() + && $text_email->get_unicode_address() === $email->get_unicode_address() + && str_contains( $rendered['value'], '' . $address . '' ); + } else { + $ok = ! $rendered['threw'] + && array() === $anchors + && $wrapped === $rendered['value']; + } + + if ( ! $ok ) { + $failures[] = array( + 'label' => $case['label'], + 'address' => self::describe_string( $address ), + 'valid' => $case['valid'], + 'expectLink' => $expect_link, + 'rendered' => self::describe_call( $rendered ), + 'parse' => self::describe_call( $parse ), + 'anchors' => self::describe_value( $anchors ), + 'hrefParse' => self::describe_call( $href_parse ), + 'textParse' => self::describe_call( $text_parse ), + ); + } + + $observed[] = array( + 'label' => $case['label'], + 'address' => self::describe_string( $address ), + 'valid' => $case['valid'], + 'expectLink' => $expect_link, + 'linked' => $actual_link, + ); + } + + $rows = array( + $ctx->result( + 'email.make-clickable.mailto-rendering-boundaries', + array() === $failures, + array( + 'caseCount' => count( $samples ), + 'observed' => $observed, + 'failures' => $failures, + ) + ), + ); + + $rows[] = $ctx->result( + 'email.make-clickable.punycode-tld-partial-link-boundary', + array() === $partial_link_failures, + array( 'cases' => $partial_link_failures ) + ); + + return $rows; + } + + private static function check_length_boundaries( \ComponentFuzz\FuzzContext $ctx ): array { + $address_254 = str_repeat( 'a', 64 ) . '@' . str_repeat( 'b', 63 ) . '.' . str_repeat( 'c', 63 ) . '.' . str_repeat( 'd', 57 ) . '.com'; + $address_255 = str_repeat( 'a', 64 ) . '@' . str_repeat( 'b', 63 ) . '.' . str_repeat( 'c', 63 ) . '.' . str_repeat( 'd', 58 ) . '.com'; + $exact = array( + array( + 'label' => 'ascii-domain-label-63', + 'input' => 'u@' . str_repeat( 'a', 63 ) . '.com', + 'valid' => true, + ), + array( + 'label' => 'ascii-domain-label-64', + 'input' => 'u@' . str_repeat( 'a', 64 ) . '.com', + 'valid' => false, + ), + ); + if ( self::has_idn() ) { + $exact[] = array( + 'label' => 'unicode-domain-label-63-bytes', + 'input' => 'u@' . str_repeat( "\u{00E5}", 31 ) . 'a.com', + 'valid' => true, + ); + $exact[] = array( + 'label' => 'unicode-domain-label-64-bytes', + 'input' => 'u@' . str_repeat( "\u{00E5}", 32 ) . '.com', + 'valid' => false, + ); + } + $observed = array( + array( 'label' => 'local-64-bytes', 'input' => str_repeat( 'a', 64 ) . '@example.com' ), + array( 'label' => 'local-65-bytes', 'input' => str_repeat( 'a', 65 ) . '@example.com' ), + array( 'label' => 'whole-address-254-bytes', 'input' => $address_254 ), + array( 'label' => 'whole-address-255-bytes', 'input' => $address_255 ), + ); + $failures = array(); + $lengths = array(); + + foreach ( $exact as $case ) { + $parsed = self::call( static fn() => \WP_Email_Address::from_string( $case['input'], 'unicode' ) ); + $is_email = self::call( static fn() => \is_email( $case['input'] ) ); + $sanitized = self::call( static fn() => \sanitize_email( $case['input'] ) ); + $expected_is_email = $case['valid'] ? $case['input'] : false; + $expected_sanitized = $case['valid'] ? $case['input'] : ''; + $expected_parsed = $case['valid']; + + if ( + $parsed['threw'] || + $is_email['threw'] || + $sanitized['threw'] || + $expected_parsed !== ( $parsed['value'] instanceof \WP_Email_Address ) || + $expected_is_email !== $is_email['value'] || + $expected_sanitized !== $sanitized['value'] + ) { + $failures[] = array( + 'label' => $case['label'], + 'input' => self::describe_string( $case['input'] ), + 'expectedValid' => $case['valid'], + 'parsed' => self::describe_call( $parsed ), + 'isEmail' => self::describe_call( $is_email ), + 'sanitizeEmail' => self::describe_call( $sanitized ), + ); + } + + $lengths[] = array( + 'label' => $case['label'], + 'bytes' => strlen( $case['input'] ), + 'valid' => $case['valid'], + ); + } + + foreach ( $observed as $case ) { + $parsed = self::call( static fn() => \WP_Email_Address::from_string( $case['input'], 'unicode' ) ); + $is_email = self::call( static fn() => \is_email( $case['input'] ) ); + $sanitized = self::call( static fn() => \sanitize_email( $case['input'] ) ); + $expected = $parsed['value'] instanceof \WP_Email_Address ? $parsed['value']->get_unicode_address() : false; + + if ( + $parsed['threw'] || + $is_email['threw'] || + $sanitized['threw'] || + $expected !== $is_email['value'] || + ( false === $expected ? '' : $expected ) !== $sanitized['value'] + ) { + $failures[] = array( + 'label' => $case['label'], + 'input' => self::describe_string( $case['input'] ), + 'expectedValue' => self::describe_value( $expected ), + 'parsed' => self::describe_call( $parsed ), + 'isEmail' => self::describe_call( $is_email ), + 'sanitizeEmail' => self::describe_call( $sanitized ), + ); + } + + $lengths[] = array( + 'label' => $case['label'], + 'bytes' => strlen( $case['input'] ), + 'valid' => $parsed['value'] instanceof \WP_Email_Address, + ); + } + + return array( + $ctx->result( + 'email.wp-email-address.length-boundaries', + array() === $failures, + array( + 'lengths' => $lengths, + 'failures' => $failures, + ) + ), + ); + } + + private static function address_parts_ok( \WP_Email_Address $email ): bool { + $parts = array( + $email->get_localpart(), + $email->get_ascii_domain(), + $email->get_unicode_domain(), + $email->get_ascii_address(), + $email->get_unicode_address(), + ); + + foreach ( $parts as $part ) { + if ( ! \wp_is_valid_utf8( $part ) ) { + return false; + } + } + + if ( + '' === $email->get_localpart() || + '' === $email->get_ascii_domain() || + '' === $email->get_unicode_domain() || + str_contains( $email->get_localpart(), '@' ) || + str_contains( $email->get_ascii_domain(), '@' ) || + str_contains( $email->get_unicode_domain(), '@' ) + ) { + return false; + } + + if ( ! self::is_ascii( $email->get_ascii_domain() ) ) { + return false; + } + + foreach ( explode( '.', $email->get_ascii_domain() ) as $label ) { + if ( '' === $label || strlen( $label ) > 63 ) { + return false; + } + } + + foreach ( explode( '.', $email->get_unicode_domain() ) as $label ) { + if ( '' === $label ) { + return false; + } + } + + return str_contains( $email->get_ascii_address(), '@' ) + && str_contains( $email->get_unicode_address(), '@' ); + } + + private static function address_raw_views( \WP_Email_Address $email ): array { + return array( + 'localpart' => $email->get_localpart(), + 'asciiDomain' => $email->get_ascii_domain(), + 'unicodeDomain' => $email->get_unicode_domain(), + 'asciiAddress' => $email->get_ascii_address(), + 'unicodeAddress' => $email->get_unicode_address(), + ); + } + + private static function mailto_uri_mailbox( \WP_Email_Address $email ): string { + return rawurlencode( $email->get_localpart() ) . '@' . $email->get_ascii_domain(); + } + + private static function mailto_anchors( string $html ): array { + $count = preg_match_all( '#([^<]+)#', $html, $matches, PREG_SET_ORDER ); + if ( false === $count || 0 === $count ) { + return array(); + } + + $anchors = array(); + foreach ( $matches as $match ) { + $anchors[] = array( + 'href' => $match[1], + 'text' => $match[2], + ); + } + + return $anchors; + } + + private static function make_clickable_email_pattern_matches( string $address ): bool { + return 1 === preg_match( '/\A[.0-9a-z_+-]+@(?:[0-9a-z-]+\.)+(?:xn--[0-9a-z-]*[0-9a-z]|[0-9a-z]{2,})\z/i', $address ); + } + + private static function address_round_trip_ok( \WP_Email_Address $email ): bool { + if ( $email->get_localpart() . '@' . $email->get_ascii_domain() !== $email->get_ascii_address() ) { + return false; + } + if ( $email->get_localpart() . '@' . $email->get_unicode_domain() !== $email->get_unicode_address() ) { + return false; + } + + $ascii_roundtrip = \WP_Email_Address::from_string( $email->get_ascii_address(), 'unicode' ); + if ( ! $ascii_roundtrip instanceof \WP_Email_Address ) { + return false; + } + if ( $ascii_roundtrip->get_unicode_address() !== $email->get_unicode_address() ) { + return false; + } + + $unicode_roundtrip = \WP_Email_Address::from_string( $email->get_unicode_address(), 'unicode' ); + return $unicode_roundtrip instanceof \WP_Email_Address + && $unicode_roundtrip->get_unicode_address() === $email->get_unicode_address(); + } + + private static function hostile_user_email_lookup_probe( string $lookup, string $candidate ): array { + global $wpdb; + + self::delete_user_email_cache( $candidate ); + $candidate_user = \get_user_by( 'email', $candidate ); + if ( ! ( $candidate_user instanceof \WP_User ) || ! is_object( $candidate_user->data ) ) { + return array( + 'ok' => false, + 'reason' => 'candidate-user-unavailable', + ); + } + + $original_wpdb = $wpdb; + $candidate_row = clone $candidate_user->data; + $proxy = new class( $original_wpdb, $candidate_row ) { + /** @var object */ + private $delegate; + + /** @var object */ + private $candidate; + + /** @var int */ + public $hits = 0; + + /** @var string */ + public $last_query = ''; + + public function __construct( $delegate, object $candidate ) { + $this->delegate = $delegate; + $this->candidate = $candidate; + } + + public function get_results( $query = null, $output = OBJECT ) { + $sql = null === $query ? (string) ( $this->delegate->last_query ?? '' ) : (string) $query; + $this->last_query = $sql; + + if ( preg_match( '/WHERE\s+user_email\s*=\s*/i', $sql ) ) { + ++$this->hits; + return array( clone $this->candidate ); + } + + return $this->delegate->get_results( $query, $output ); + } + + public function __call( string $method, array $args ) { + return $this->delegate->$method( ...$args ); + } + + public function __get( string $name ) { + return $this->delegate->$name; + } + + public function __set( string $name, $value ): void { + $this->delegate->$name = $value; + } + + public function __isset( string $name ): bool { + return isset( $this->delegate->$name ); + } + }; + + self::delete_user_email_cache( $lookup ); + $wpdb = $proxy; + $GLOBALS['wpdb'] = $proxy; + try { + $exists = self::capture_warnings( static fn() => \email_exists( $lookup ) ); + self::delete_user_email_cache( $lookup ); + $by_email = self::capture_warnings( static fn() => \get_user_by( 'email', $lookup ) ); + } finally { + $wpdb = $original_wpdb; + $GLOBALS['wpdb'] = $original_wpdb; + self::delete_user_email_cache( $lookup ); + } + + return array( + 'ok' => 0 !== strcasecmp( $lookup, $candidate ) + && ! $exists['threw'] + && ! $by_email['threw'] + && array() === $exists['warnings'] + && array() === $by_email['warnings'] + && false === $exists['value'] + && false === $by_email['value'] + && $proxy->hits >= 2, + 'lookup' => self::describe_string( $lookup ), + 'candidate' => self::describe_string( $candidate ), + 'hits' => $proxy->hits, + 'query' => self::describe_string( $proxy->last_query ), + 'exists' => self::describe_captured_call( $exists ), + 'byEmail' => self::describe_captured_call( $by_email ), + ); + } + + private static function seed_stub_user_email_row( string $email, string $login ): array { + global $wpdb; + + self::delete_user_email_cache( $email ); + + return self::capture_warnings( + static function () use ( $wpdb, $email, $login ) { + $result = $wpdb->insert( + $wpdb->users, + array( + 'user_login' => $login, + 'user_pass' => 'component-fuzz-seeded-password', + 'user_nicename' => $login, + 'user_email' => $email, + 'user_url' => '', + 'user_registered' => '2026-06-25 00:00:00', + 'display_name' => $login, + ) + ); + + if ( false === $result ) { + return false; + } + + return (int) $wpdb->insert_id; + } + ); + } + + private static function probe_user_email_localpart_search_results( \ComponentFuzz\FuzzContext $ctx, array $case, ?array $records = null ): array { + $unicode_local = self::email_localpart( $case['unicodeAddress'] ); + $folded_local = self::email_localpart( $case['foldedAddress'] ); + $seeded_ids = array(); + $seed_call = array( + 'threw' => false, + 'value' => array(), + 'warnings' => array(), + ); + + if ( null === $records ) { + $base_id = 800000 + ( 2 * ( (int) sprintf( '%u', crc32( $case['label'] . '|' . $ctx->iteration() ) ) % 50000 ) ); + $records = array( + array( + 'id' => $base_id, + 'email' => $case['unicodeAddress'], + ), + array( + 'id' => $base_id + 1, + 'email' => $case['foldedAddress'], + ), + ); + + $seed_call = self::capture_warnings( + static function () use ( $records, &$seeded_ids ): array { + if ( ! isset( $GLOBALS['wpdb'] ) || ! is_object( $GLOBALS['wpdb'] ) ) { + return array(); + } + + foreach ( $records as $record ) { + $id = (int) ( $record['id'] ?? 0 ); + $email = (string) ( $record['email'] ?? '' ); + if ( $id <= 0 || '' === $email ) { + continue; + } + + $GLOBALS['wpdb']->delete( $GLOBALS['wpdb']->users, array( 'ID' => $id ) ); + $GLOBALS['wpdb']->insert( + $GLOBALS['wpdb']->users, + array( + 'ID' => $id, + 'user_login' => 'cfz_search_probe_' . $id, + 'user_pass' => 'component-fuzz-pass', + 'user_nicename' => 'cfz-search-probe-' . $id, + 'user_email' => $email, + 'display_name' => 'Component Fuzz Search Probe ' . $id, + ) + ); + self::delete_user_email_cache( $email ); + $seeded_ids[] = $id; + } + + return $seeded_ids; + } + ); + } else { + $records = array_values( array_filter( $records, static fn( $record ) => is_array( $record ) ) ); + } + + $hook_snapshot = self::snapshot_hook_globals(); + try { + \remove_all_filters( 'pre_user_query' ); + \remove_all_filters( 'users_pre_query' ); + $unicode_query = self::run_user_email_localpart_search_query( '*' . $unicode_local . '*' ); + $folded_query = self::run_user_email_localpart_search_query( '*' . $folded_local . '*' ); + } finally { + self::restore_hook_globals( $hook_snapshot ); + foreach ( $seeded_ids as $seeded_id ) { + if ( isset( $GLOBALS['wpdb'] ) && is_object( $GLOBALS['wpdb'] ) ) { + $GLOBALS['wpdb']->delete( $GLOBALS['wpdb']->users, array( 'ID' => (int) $seeded_id ) ); + } + } + } + + $expected_unicode = self::expected_byte_preserving_email_search_ids( $records, $unicode_local ); + $expected_folded = self::expected_byte_preserving_email_search_ids( $records, $folded_local ); + $actual_unicode = self::captured_query_result_ids( $unicode_query ); + $actual_folded = self::captured_query_result_ids( $folded_query ); + $used_query_path = is_array( $unicode_query['value'] ?? null ) + && is_array( $folded_query['value'] ?? null ) + && str_contains( (string) ( $unicode_query['value']['queryWhere'] ?? '' ), 'LIKE' ) + && str_contains( (string) ( $folded_query['value']['queryWhere'] ?? '' ), 'LIKE' ); + $seed_ok = ! $seed_call['threw'] && array() === $seed_call['warnings']; + + return array( + 'ok' => 2 === count( $records ) + && $seed_ok + && ! $unicode_query['threw'] + && ! $folded_query['threw'] + && array() === $unicode_query['warnings'] + && array() === $folded_query['warnings'] + && $actual_unicode === $expected_unicode + && $actual_folded === $expected_folded + && $used_query_path, + 'unicode' => array( + 'search' => self::describe_string( $unicode_local ), + 'expected' => $expected_unicode, + 'actual' => $actual_unicode, + ), + 'folded' => array( + 'search' => self::describe_string( $folded_local ), + 'expected' => $expected_folded, + 'actual' => $actual_folded, + ), + 'seed' => self::describe_captured_call( $seed_call ), + 'records' => $records, + 'unicodeQuery' => self::describe_captured_call( $unicode_query ), + 'foldedQuery' => self::describe_captured_call( $folded_query ), + 'usedQueryPath' => $used_query_path, + ); + } + + private static function prepare_user_email_search_query( string $search, array $search_columns = array() ): array { + $query = new \WP_User_Query(); + + return self::capture_warnings( + static function () use ( $query, $search, $search_columns ): array { + $query->prepare_query( + array( + 'blog_id' => 0, + 'cache_results' => false, + 'count_total' => false, + 'fields' => 'ID', + 'number' => 1, + 'orderby' => 'ID', + 'search' => $search, + 'search_columns' => $search_columns, + ) + ); + + return array( + 'queryWhere' => (string) $query->query_where, + 'queryVars' => $query->query_vars, + ); + } + ); + } + + private static function run_user_email_localpart_search_query( string $search ): array { + return self::capture_warnings( + static function () use ( $search ): array { + $query = new \WP_User_Query( + array( + 'blog_id' => 0, + 'cache_results' => false, + 'count_total' => false, + 'fields' => 'ID', + 'orderby' => 'ID', + 'order' => 'ASC', + 'search' => $search, + 'search_columns' => array( 'user_email' ), + ) + ); + + return array( + 'results' => array_map( 'intval', (array) $query->get_results() ), + 'queryWhere' => (string) $query->query_where, + 'queryVars' => $query->query_vars, + ); + } + ); + } + + private static function expected_byte_preserving_email_search_ids( array $records, string $needle ): array { + $ids = array(); + foreach ( $records as $record ) { + if ( str_contains( $record['email'], $needle ) ) { + $ids[] = (int) $record['id']; + } + } + + return $ids; + } + + private static function captured_query_result_ids( array $query ): array { + if ( ! is_array( $query['value'] ?? null ) || ! is_array( $query['value']['results'] ?? null ) ) { + return array(); + } + + return array_map( 'intval', $query['value']['results'] ); + } + + private static function email_localpart( string $address ): string { + $at = strpos( $address, '@' ); + if ( false === $at ) { + return $address; + } + + return substr( $address, 0, $at ); + } + + private static function generated_comment_submission_cases( \ComponentFuzz\FuzzContext $ctx ): array { + $cases = array( + array( + 'label' => 'handle-unicode-local-latin', + 'path' => 'handle-submission', + 'input' => "gr\u{00E5}@example.com", + 'expectedEmail' => "gr\u{00E5}@example.com", + 'accepted' => true, + 'expectedError' => null, + 'source' => 'fixed', + 'traits' => array( 'valid', 'unicodeLocal', 'handleSubmission' ), + ), + array( + 'label' => 'handle-unicode-local-combining', + 'path' => 'handle-submission', + 'input' => "jose\u{0301}@example.com", + 'expectedEmail' => "jose\u{0301}@example.com", + 'accepted' => true, + 'expectedError' => null, + 'source' => 'fixed', + 'traits' => array( 'valid', 'unicodeLocal', 'combiningMark', 'handleSubmission' ), + ), + array( + 'label' => 'new-comment-display-name-recovery', + 'path' => 'wp-new-comment', + 'input' => "\"\u{00C5}sa Example\" ", + 'expectedEmail' => "gr\u{00E5}@example.com", + 'accepted' => true, + 'expectedError' => null, + 'source' => 'fixed', + 'traits' => array( 'valid', 'unicodeLocal', 'displayName', 'recoverableWhitespace', 'wpNewComment' ), + ), + array( + 'label' => 'new-comment-nbsp-recovery', + 'path' => 'wp-new-comment', + 'input' => "jos\u{00E9}\u{00A0}@\u{00A0}example.com", + 'expectedEmail' => "jos\u{00E9}@example.com", + 'accepted' => true, + 'expectedError' => null, + 'source' => 'fixed', + 'traits' => array( 'valid', 'unicodeLocal', 'recoverableWhitespace', 'wpNewComment' ), + ), + array( + 'label' => 'handle-emoji-local-rejected', + 'path' => 'handle-submission', + 'input' => "emoji\u{1F600}@example.com", + 'expectedEmail' => null, + 'accepted' => false, + 'expectedError' => 'require_valid_email', + 'source' => 'fixed', + 'traits' => array( 'invalid', 'emojiLocal', 'handleSubmission' ), + ), + array( + 'label' => 'handle-fullwidth-at-rejected', + 'path' => 'handle-submission', + 'input' => "bad\u{FF20}example.com", + 'expectedEmail' => null, + 'accepted' => false, + 'expectedError' => 'require_valid_email', + 'source' => 'fixed', + 'traits' => array( 'invalid', 'fullwidthAt', 'handleSubmission' ), + ), + array( + 'label' => 'handle-invalid-utf8-rejected', + 'path' => 'handle-submission', + 'input' => "bad\xE2\x82@example.com", + 'expectedEmail' => null, + 'accepted' => false, + 'expectedError' => 'require_valid_email', + 'source' => 'fixed', + 'traits' => array( 'invalid', 'invalidUtf8', 'handleSubmission' ), + ), + array( + 'label' => 'handle-leading-combining-local-rejected', + 'path' => 'handle-submission', + 'input' => "\u{0301}bad@example.com", + 'expectedEmail' => null, + 'accepted' => false, + 'expectedError' => 'require_valid_email', + 'source' => 'fixed', + 'traits' => array( 'invalid', 'leadingCombiningMark', 'handleSubmission' ), + ), + array( + 'label' => 'new-comment-emoji-local-sanitized-empty', + 'path' => 'wp-new-comment', + 'input' => "direct\u{1F600}@example.com", + 'expectedEmail' => '', + 'accepted' => false, + 'expectedError' => 'wp-new-comment-sanitizes-invalid', + 'source' => 'fixed', + 'traits' => array( 'invalid', 'emojiLocal', 'wpNewComment', 'sanitizedEmpty' ), + ), + array( + 'label' => 'new-comment-fullwidth-at-sanitized-empty', + 'path' => 'wp-new-comment', + 'input' => "direct\u{FF20}example.com", + 'expectedEmail' => '', + 'accepted' => false, + 'expectedError' => 'wp-new-comment-sanitizes-invalid', + 'source' => 'fixed', + 'traits' => array( 'invalid', 'fullwidthAt', 'wpNewComment', 'sanitizedEmpty' ), + ), + ); + + $valid_locals = array( + array( 'value' => "gr\u{00E5}", 'traits' => array( 'unicodeLocal', 'latin' ) ), + array( 'value' => "jos\u{00E9}", 'traits' => array( 'unicodeLocal', 'latin' ) ), + array( 'value' => "jose\u{0301}", 'traits' => array( 'unicodeLocal', 'combiningMark' ) ), + array( 'value' => "\u{03B4}\u{03BF}\u{03BA}\u{03B9}\u{03BC}\u{03AE}", 'traits' => array( 'unicodeLocal', 'greek' ) ), + array( 'value' => "\u{043F}\u{043E}\u{0447}\u{0442}\u{0430}", 'traits' => array( 'unicodeLocal', 'cyrillic' ) ), + array( 'value' => "\u{3086}\u{3046}\u{3056}\u{3042}", 'traits' => array( 'unicodeLocal', 'hiragana' ) ), + ); + $domains = array( + array( 'value' => 'example.com', 'traits' => array( 'asciiDomain' ) ), + array( 'value' => 'sub-domain.example', 'traits' => array( 'asciiDomain' ) ), + ); + + if ( self::has_idn() ) { + $cases[] = array( + 'label' => 'handle-unicode-domain-latin', + 'path' => 'handle-submission', + 'input' => "mail@gr\u{00E5}.org", + 'expectedEmail' => "mail@gr\u{00E5}.org", + 'accepted' => true, + 'expectedError' => null, + 'source' => 'fixed', + 'traits' => array( 'valid', 'unicodeDomain', 'handleSubmission' ), + ); + $cases[] = array( + 'label' => 'new-comment-unicode-domain-recovery', + 'path' => 'wp-new-comment', + 'input' => "Display Name ", + 'expectedEmail' => "mail@gr\u{00E5}.org", + 'accepted' => true, + 'expectedError' => null, + 'source' => 'fixed', + 'traits' => array( 'valid', 'unicodeDomain', 'displayName', 'recoverableWhitespace', 'wpNewComment' ), + ); + $domains[] = array( 'value' => "gr\u{00E5}.org", 'traits' => array( 'unicodeDomain', 'latin' ) ); + $domains[] = array( 'value' => "b\u{00FC}cher.tld", 'traits' => array( 'unicodeDomain', 'latin' ) ); + $domains[] = array( + 'value' => "\u{03C0}\u{03B1}\u{03C1}\u{03AC}\u{03B4}\u{03B5}\u{03B9}\u{03B3}\u{03BC}\u{03B1}.\u{03B4}\u{03BF}\u{03BA}\u{03B9}\u{03BC}\u{03AE}", + 'traits' => array( 'unicodeDomain', 'greek' ), + ); + } + + $invalid_profiles = array( + array( 'label' => 'emoji-local', 'input' => "emoji\u{1F642}@example.com", 'traits' => array( 'emojiLocal' ) ), + array( 'label' => 'fullwidth-at', 'input' => "generated\u{FF20}example.com", 'traits' => array( 'fullwidthAt' ) ), + array( 'label' => 'invalid-utf8', 'input' => "generated\xC0\xAF@example.com", 'traits' => array( 'invalidUtf8' ) ), + array( 'label' => 'leading-combining-local', 'input' => "\u{0301}generated@example.com", 'traits' => array( 'leadingCombiningMark' ) ), + ); + + for ( $i = 0; $i < self::GENERATED_COMMENT_SUBMISSION_CASES; $i++ ) { + if ( $ctx->bool( 58 ) ) { + $local = $ctx->choice( $valid_locals ); + $domain = $ctx->choice( $domains ); + $expected_email = $local['value'] . '@' . $domain['value']; + $path = $ctx->bool( 35 ) ? 'wp-new-comment' : 'handle-submission'; + $input = $expected_email; + $traits = array_merge( array( 'valid' ), $local['traits'], $domain['traits'] ); + + if ( 'wp-new-comment' === $path ) { + $input = 'Generated ' . $i . ' <' . $local['value'] . ' @ ' . str_replace( '.', ' . ', $domain['value'] ) . '.>'; + $traits[] = 'displayName'; + $traits[] = 'recoverableWhitespace'; + $traits[] = 'wpNewComment'; + } else { + $traits[] = 'handleSubmission'; + } + + $cases[] = array( + 'label' => 'generated-valid-' . $i, + 'path' => $path, + 'input' => $input, + 'expectedEmail' => $expected_email, + 'accepted' => true, + 'expectedError' => null, + 'source' => 'generated', + 'traits' => array_values( array_unique( $traits ) ), + ); + continue; + } + + $invalid = $ctx->choice( $invalid_profiles ); + $path = $ctx->bool( 30 ) ? 'wp-new-comment' : 'handle-submission'; + $cases[] = array( + 'label' => 'generated-invalid-' . $i . '-' . $invalid['label'], + 'path' => $path, + 'input' => $invalid['input'], + 'expectedEmail' => 'wp-new-comment' === $path ? '' : null, + 'accepted' => false, + 'expectedError' => 'wp-new-comment' === $path ? 'wp-new-comment-sanitizes-invalid' : 'require_valid_email', + 'source' => 'generated', + 'traits' => array_values( + array_unique( + array_merge( + array( 'invalid', 'wp-new-comment' === $path ? 'wpNewComment' : 'handleSubmission' ), + 'wp-new-comment' === $path ? array( 'sanitizedEmpty' ) : array(), + $invalid['traits'] + ) + ) + ), + ); + } + + return $cases; + } + + private static function install_comment_submission_filters( array &$insert_events, array &$post_events ): void { + foreach ( + array( + 'preprocess_comment', + 'pre_comment_author_name', + 'pre_comment_author_url', + 'pre_comment_content', + 'pre_comment_user_agent', + 'pre_comment_user_ip', + 'pre_comment_author_email', + 'pre_comment_approved', + 'duplicate_comment_id', + 'check_comment_flood', + 'wp_is_comment_flood', + 'comment_post', + 'wp_insert_comment', + 'comment_text', + 'comment_max_links_url', + 'wp_check_comment_disallowed_list', + ) as $hook + ) { + \remove_all_filters( $hook ); + } + + foreach ( + array( + 'pre_option_comment_registration', + 'pre_option_require_name_email', + 'pre_option_comment_moderation', + 'pre_option_comment_max_links', + 'pre_option_moderation_keys', + 'pre_option_disallowed_keys', + 'pre_option_comment_previously_approved', + 'pre_option_comments_notify', + 'pre_option_moderation_notify', + 'pre_option_admin_email', + 'pre_option_blogname', + ) as $hook + ) { + \remove_all_filters( $hook ); + } + + \add_filter( 'pre_comment_author_email', 'trim' ); + \add_filter( 'pre_comment_author_email', 'sanitize_email' ); + \add_filter( + 'pre_comment_approved', + static function () { + return 1; + }, + 10, + 2 + ); + \add_filter( + 'wp_is_comment_flood', + static function () { + return false; + }, + PHP_INT_MAX, + 5 + ); + + $option_values = array( + 'pre_option_comment_registration' => 0, + 'pre_option_require_name_email' => 1, + 'pre_option_comment_moderation' => 0, + 'pre_option_comment_max_links' => 0, + 'pre_option_moderation_keys' => '', + 'pre_option_disallowed_keys' => '', + 'pre_option_comment_previously_approved' => 0, + 'pre_option_comments_notify' => 0, + 'pre_option_moderation_notify' => 0, + 'pre_option_admin_email' => 'admin@example.test', + 'pre_option_blogname' => 'Component Fuzz', + ); + foreach ( $option_values as $hook => $value ) { + \add_filter( + $hook, + static function () use ( $value ) { + return $value; + } + ); + } + + \add_action( + 'wp_insert_comment', + static function ( $comment_id, $comment ) use ( &$insert_events ): void { + $insert_events[] = array( + 'id' => (int) $comment_id, + 'postId' => $comment instanceof \WP_Comment ? (int) $comment->comment_post_ID : null, + 'email' => $comment instanceof \WP_Comment ? $comment->comment_author_email : null, + 'approved' => $comment instanceof \WP_Comment ? (string) $comment->comment_approved : null, + ); + }, + 10, + 2 + ); + \add_action( + 'comment_post', + static function ( $comment_id, $approved, array $commentdata ) use ( &$post_events ): void { + $post_events[] = array( + 'id' => (int) $comment_id, + 'postId' => isset( $commentdata['comment_post_ID'] ) ? (int) $commentdata['comment_post_ID'] : null, + 'email' => $commentdata['comment_author_email'] ?? null, + 'approved' => (string) $approved, + 'filtered' => true === ( $commentdata['filtered'] ?? null ), + ); + }, + 10, + 3 + ); + } + + private static function seed_comment_submission_post( \ComponentFuzz\FuzzContext $ctx, int $case_index, array $case ): int { + if ( ! isset( $GLOBALS['wpdb'] ) || ! is_object( $GLOBALS['wpdb'] ) ) { + return 0; + } + + $slug = 'email-comment-unicode-' . $ctx->iteration() . '-' . $case_index . '-' . substr( sha1( $case['label'] ), 0, 10 ); + $result = $GLOBALS['wpdb']->insert( + $GLOBALS['wpdb']->posts, + array( + 'post_author' => 0, + 'post_date' => '2026-06-26 12:00:00', + 'post_date_gmt' => '2026-06-26 10:00:00', + 'post_content' => 'Email comment Unicode host post.', + 'post_title' => 'Email Comment Unicode ' . $case_index, + 'post_status' => 'publish', + 'comment_status' => 'open', + 'ping_status' => 'closed', + 'post_name' => $slug, + 'post_modified' => '2026-06-26 12:00:00', + 'post_modified_gmt' => '2026-06-26 10:00:00', + 'post_type' => 'post', + ) + ); + + return false === $result ? 0 : (int) $GLOBALS['wpdb']->insert_id; + } + + private static function delete_user_email_cache( string $email ): void { + if ( function_exists( 'wp_cache_delete' ) ) { + \wp_cache_delete( $email, 'useremail' ); + } + } + + private static function sql_like_literals_for_columns( string $sql, array $columns ): array { + $out = array(); + + foreach ( $columns as $column ) { + $literals = self::sql_like_literals( $sql, $column ); + if ( array() !== $literals ) { + $out[ $column ] = $literals; + } + } + + return $out; + } + + private static function flatten_sql_like_literal_groups( array $groups ): array { + $out = array(); + foreach ( $groups as $literals ) { + foreach ( (array) $literals as $literal ) { + $out[] = $literal; + } + } + + return $out; + } + + private static function sql_like_literals( string $sql, string $column ): array { + $column = preg_quote( $column, '/' ); + if ( ! preg_match_all( '/(?= $length ) { + $out .= $literal[ $i ]; + continue; + } + + $next = $literal[ ++$i ]; + if ( '0' === $next ) { + $out .= "\0"; + } elseif ( in_array( $next, array( '\\', "'", '"' ), true ) ) { + $out .= $next; + } else { + $out .= '\\' . $next; + } + } + + return $out; + } + + private static function can_reset_stub_content(): bool { + return isset( $GLOBALS['wpdb'] ) + && is_object( $GLOBALS['wpdb'] ) + && method_exists( $GLOBALS['wpdb'], 'component_fuzz_reset_content' ); + } + + private static function reset_stub_content(): void { + if ( self::can_reset_stub_content() ) { + $GLOBALS['wpdb']->component_fuzz_reset_content(); + } + + \wp_cache_flush(); + } + + private static function stub_content_counts(): array { + if ( + isset( $GLOBALS['wpdb'] ) + && is_object( $GLOBALS['wpdb'] ) + && method_exists( $GLOBALS['wpdb'], 'component_fuzz_content_counts' ) + ) { + return $GLOBALS['wpdb']->component_fuzz_content_counts(); + } + + return array(); + } + + private static function stub_comment_count(): int { + $counts = self::stub_content_counts(); + return (int) ( $counts['comments'] ?? 0 ); + } + + private static function install_email_filters( string $mode ): void { + \remove_all_filters( 'is_email' ); + \remove_all_filters( 'sanitize_email' ); + + if ( 'ascii' === $mode ) { + \add_filter( 'is_email', 'wp_is_ascii_email', 10, 3 ); + \add_filter( 'sanitize_email', 'wp_sanitize_ascii_email', 10, 3 ); + return; + } + + \add_filter( 'is_email', 'wp_is_unicode_email', 10, 3 ); + \add_filter( 'sanitize_email', 'wp_sanitize_unicode_email', 10, 3 ); + } + + private static function install_email_filters_from_default_filters( string $charset ): void { + $default_filters = \ComponentFuzz\repo_root() . DIRECTORY_SEPARATOR . 'src' . DIRECTORY_SEPARATOR . 'wp-includes' . DIRECTORY_SEPARATOR . 'default-filters.php'; + + if ( ! isset( $GLOBALS['wpdb'] ) || ! is_object( $GLOBALS['wpdb'] ) ) { + $GLOBALS['wpdb'] = new \stdClass(); + } + + $GLOBALS['wpdb']->charset = $charset; + $wpdb = $GLOBALS['wpdb']; + require $default_filters; + } + + private static function cases( \ComponentFuzz\FuzzContext $ctx ): array { + $has_idn = self::has_idn(); + $unicode_domain_label_63 = 'u@' . str_repeat( "\u{00E5}", 31 ) . 'a.com'; + $unicode_domain_label_64 = 'u@' . str_repeat( "\u{00E5}", 32 ) . '.com'; + $unicode_domain_63_traits = array( 'unicodeAddress', 'boundaryLength' ); + if ( $has_idn ) { + $unicode_domain_63_traits[] = 'valid'; + } + + $cases = array( + self::case( 'ascii-simple', 'user@example.com', array( 'ascii', 'valid' ), 'user@example.com', 'user@example.com', 'user@example.com', 'user@example.com' ), + self::case( 'ascii-plus-subdomain', 'USER+tag@example.co.uk', array( 'ascii', 'valid' ), 'USER+tag@example.co.uk', 'USER+tag@example.co.uk', 'USER+tag@example.co.uk', 'USER+tag@example.co.uk' ), + self::case( 'ascii-whatwg-atext-local', 'azAZ09.!#$%&\'*+/=?^_`{|}~-@example.com', array( 'ascii', 'valid', 'whatwgAtext' ), 'azAZ09.!#$%&\'*+/=?^_`{|}~-@example.com', 'azAZ09.!#$%&\'*+/=?^_`{|}~-@example.com', 'azAZ09.!#$%&\'*+/=?^_`{|}~-@example.com', 'azAZ09.!#$%&\'*+/=?^_`{|}~-@example.com' ), + self::case( 'unicode-local-domain', "gr\u{00E5}@gr\u{00E5}.org", array( 'valid', 'unicodeAddress' ), $has_idn ? "gr\u{00E5}@gr\u{00E5}.org" : false, $has_idn ? "gr\u{00E5}@gr\u{00E5}.org" : '', false, '' ), + self::case( 'unicode-local-ascii-domain', "jos\u{00E9}@example.com", array( 'valid', 'unicodeAddress' ), "jos\u{00E9}@example.com", "jos\u{00E9}@example.com", false, '' ), + self::case( 'unicode-combining-local', "jose\u{0301}@example.com", array( 'valid', 'unicodeAddress' ), "jose\u{0301}@example.com", "jose\u{0301}@example.com", false, '' ), + self::case( 'unicode-domain', "checkout@b\u{00FC}cher.tld", array( 'valid', 'unicodeAddress' ), $has_idn ? "checkout@b\u{00FC}cher.tld" : false, $has_idn ? "checkout@b\u{00FC}cher.tld" : '', false, '' ), + self::case( 'unicode-cjk-address', "\u{7528}\u{6237}@\u{4F8B}\u{5B50}.\u{5E7F}\u{544A}", array( 'valid', 'unicodeAddress' ), $has_idn ? "\u{7528}\u{6237}@\u{4F8B}\u{5B50}.\u{5E7F}\u{544A}" : false, $has_idn ? "\u{7528}\u{6237}@\u{4F8B}\u{5B50}.\u{5E7F}\u{544A}" : '', false, '' ), + self::case( 'unicode-arabic-address', "\u{0645}\u{0633}\u{062A}\u{062E}\u{062F}\u{0645}@\u{0645}\u{062B}\u{0627}\u{0644}.\u{0625}\u{062E}\u{062A}\u{0628}\u{0627}\u{0631}", array( 'valid', 'unicodeAddress' ), $has_idn ? "\u{0645}\u{0633}\u{062A}\u{062E}\u{062F}\u{0645}@\u{0645}\u{062B}\u{0627}\u{0644}.\u{0625}\u{062E}\u{062A}\u{0628}\u{0627}\u{0631}" : false, $has_idn ? "\u{0645}\u{0633}\u{062A}\u{062E}\u{062F}\u{0645}@\u{0645}\u{062B}\u{0627}\u{0644}.\u{0625}\u{062E}\u{062A}\u{0628}\u{0627}\u{0631}" : '', false, '' ), + self::case( 'unicode-greek-address', "\u{03B4}\u{03BF}\u{03BA}\u{03B9}\u{03BC}\u{03AE}@\u{03C0}\u{03B1}\u{03C1}\u{03AC}\u{03B4}\u{03B5}\u{03B9}\u{03B3}\u{03BC}\u{03B1}.\u{03B4}\u{03BF}\u{03BA}\u{03B9}\u{03BC}\u{03AE}", array( 'valid', 'unicodeAddress' ), $has_idn ? "\u{03B4}\u{03BF}\u{03BA}\u{03B9}\u{03BC}\u{03AE}@\u{03C0}\u{03B1}\u{03C1}\u{03AC}\u{03B4}\u{03B5}\u{03B9}\u{03B3}\u{03BC}\u{03B1}.\u{03B4}\u{03BF}\u{03BA}\u{03B9}\u{03BC}\u{03AE}" : false, $has_idn ? "\u{03B4}\u{03BF}\u{03BA}\u{03B9}\u{03BC}\u{03AE}@\u{03C0}\u{03B1}\u{03C1}\u{03AC}\u{03B4}\u{03B5}\u{03B9}\u{03B3}\u{03BC}\u{03B1}.\u{03B4}\u{03BF}\u{03BA}\u{03B9}\u{03BC}\u{03AE}" : '', false, '' ), + self::case( 'unicode-devanagari-local', "\u{0928}\u{092E}\u{0938}\u{094D}\u{0924}\u{0947}@example.com", array( 'valid', 'unicodeAddress' ), "\u{0928}\u{092E}\u{0938}\u{094D}\u{0924}\u{0947}@example.com", "\u{0928}\u{092E}\u{0938}\u{094D}\u{0924}\u{0947}@example.com", false, '' ), + self::case( 'unicode-cyrillic-local', "\u{043F}\u{043E}\u{0447}\u{0442}\u{0430}@example.com", array( 'valid', 'unicodeAddress' ), "\u{043F}\u{043E}\u{0447}\u{0442}\u{0430}@example.com", "\u{043F}\u{043E}\u{0447}\u{0442}\u{0430}@example.com", false, '' ), + self::case( 'unicode-hiragana-local', "\u{3086}\u{3046}\u{3056}\u{3042}@example.com", array( 'valid', 'unicodeAddress' ), "\u{3086}\u{3046}\u{3056}\u{3042}@example.com", "\u{3086}\u{3046}\u{3056}\u{3042}@example.com", false, '' ), + self::case( 'unicode-cyrillic-address', "\u{043F}\u{043E}\u{0447}\u{0442}\u{0430}@\u{043F}\u{0440}\u{0438}\u{043C}\u{0435}\u{0440}.\u{0438}\u{0441}\u{043F}\u{044B}\u{0442}\u{0430}\u{043D}\u{0438}\u{0435}", array( 'valid', 'unicodeAddress' ), $has_idn ? "\u{043F}\u{043E}\u{0447}\u{0442}\u{0430}@\u{043F}\u{0440}\u{0438}\u{043C}\u{0435}\u{0440}.\u{0438}\u{0441}\u{043F}\u{044B}\u{0442}\u{0430}\u{043D}\u{0438}\u{0435}" : false, $has_idn ? "\u{043F}\u{043E}\u{0447}\u{0442}\u{0430}@\u{043F}\u{0440}\u{0438}\u{043C}\u{0435}\u{0440}.\u{0438}\u{0441}\u{043F}\u{044B}\u{0442}\u{0430}\u{043D}\u{0438}\u{0435}" : '', false, '' ), + self::case( 'unicode-hiragana-address', "\u{3086}\u{3046}\u{3056}\u{3042}@\u{308C}\u{3044}.\u{307F}\u{3093}\u{306A}", array( 'valid', 'unicodeAddress' ), $has_idn ? "\u{3086}\u{3046}\u{3056}\u{3042}@\u{308C}\u{3044}.\u{307F}\u{3093}\u{306A}" : false, $has_idn ? "\u{3086}\u{3046}\u{3056}\u{3042}@\u{308C}\u{3044}.\u{307F}\u{3093}\u{306A}" : '', false, '' ), + self::case( 'unicode-eszett-domain', "mail@fa\u{00DF}.de", array( 'valid', 'unicodeAddress' ), $has_idn ? "mail@fa\u{00DF}.de" : false, $has_idn ? "mail@fa\u{00DF}.de" : '', false, '' ), + self::case( 'punycode-domain', 'books@xn--bcher-kva.de', array( 'valid', 'punycodeUnicodeDomain' ), $has_idn ? "books@b\u{00FC}cher.de" : false, $has_idn ? "books@b\u{00FC}cher.de" : '', false, '' ), + self::case( 'punycode-cjk-domain', 'mail@xn--fsqu00a.xn--4rr70v', array( 'valid', 'punycodeUnicodeDomain' ), $has_idn ? "mail@\u{4F8B}\u{5B50}.\u{5E7F}\u{544A}" : false, $has_idn ? "mail@\u{4F8B}\u{5B50}.\u{5E7F}\u{544A}" : '', false, '' ), + self::case( 'punycode-greek-domain', 'mail@xn--hxajbheg2az3al.xn--jxalpdlp', array( 'valid', 'punycodeUnicodeDomain' ), $has_idn ? "mail@\u{03C0}\u{03B1}\u{03C1}\u{03AC}\u{03B4}\u{03B5}\u{03B9}\u{03B3}\u{03BC}\u{03B1}.\u{03B4}\u{03BF}\u{03BA}\u{03B9}\u{03BC}\u{03AE}" : false, $has_idn ? "mail@\u{03C0}\u{03B1}\u{03C1}\u{03AC}\u{03B4}\u{03B5}\u{03B9}\u{03B3}\u{03BC}\u{03B1}.\u{03B4}\u{03BF}\u{03BA}\u{03B9}\u{03BC}\u{03AE}" : '', false, '' ), + self::case( 'mixed-case-punycode-prefix', 'books@XN--BCHER-KVA.DE', array( 'reservedAcePrefix' ), false, '', false, '' ), + self::case( 'display-name-wrapper', 'Display Name ', array( 'displayName' ), false, 'user@example.com', false, 'user@example.com' ), + self::case( 'display-name-unicode-wrapper', "Display Name ", array( 'displayName', 'recoverableWhitespace' ), false, "jos\u{00E9}@gr\u{00E5}.org", false, '' ), + self::case( 'display-name-quoted-local-rejected', 'Display <"quoted"@example.com>', array( 'displayName', 'quotedLookingLocal' ), false, '', false, '' ), + self::case( 'separator-whitespace-and-trailing-dot', " info @ example . com. \t", array( 'recoverableWhitespace' ), false, 'info@example.com', false, 'info@example.com' ), + self::case( 'nbsp-separator-whitespace', "user\u{00A0}@\u{00A0}example.com", array( 'recoverableWhitespace' ), false, 'user@example.com', false, 'user@example.com' ), + self::case( 'soft-hyphen-near-dot', "info@example\u{00AD}.com", array( 'recoverableWhitespace' ), false, 'info@example.com', false, 'info@example.com' ), + self::case( 'multiple-at', 'bad@@example.com', array( 'malformedAt' ), false, '', false, '' ), + self::case( 'fullwidth-at-separator', "bad\u{FF20}example.com", array( 'unicodeAddress', 'malformedAt' ), false, '', false, '' ), + self::case( 'missing-at', 'not-an-address.example.com', array( 'malformedAt' ), false, '', false, '' ), + self::case( 'empty-local', '@example.com', array( 'malformedAt' ), false, '', false, '' ), + self::case( 'empty-domain', 'user@', array( 'malformedDomain' ), false, '', false, '' ), + self::case( 'no-domain-period', 'a@b', array( 'ascii', 'valid', 'whatwgSingleLabelDomain' ), 'a@b', 'a@b', 'a@b', 'a@b' ), + self::case( 'empty-domain-label', 'name@domain..com', array( 'malformedDomain' ), false, '', false, '' ), + self::case( 'leading-domain-dot', 'name@.example.com', array( 'malformedDomain' ), false, '', false, '' ), + self::case( 'leading-domain-hyphen', 'name@-example.com', array( 'malformedDomain' ), false, '', false, '' ), + self::case( 'trailing-domain-hyphen', 'name@example-.com', array( 'malformedDomain' ), false, '', false, '' ), + self::case( 'domain-underscore', 'name@example_corp.com', array( 'malformedDomain' ), false, '', false, '' ), + self::case( 'domain-literal', 'name@[127.0.0.1]', array( 'malformedDomain' ), false, '', false, '' ), + self::case( 'ideographic-dot-separator', "name@example\u{3002}com", array( 'unicodeAddress', 'malformedDomain' ), false, '', false, '' ), + self::case( 'quoted-looking-local', '"quoted"@example.com', array( 'quotedLookingLocal' ), false, '', false, '' ), + self::case( 'quoted-html-looking-local', '"' . $iframe_open . ''; + $filtered_content = \wp_filter_content_tags( $content, $context ); + $filtered_imgs = self::extract_tag_opens( $filtered_content, 'img' ); + $filtered_iframes = self::extract_tag_opens( $filtered_content, 'iframe' ); + $attachment_imgs = array_values( + array_filter( + $filtered_imgs, + static function ( string $tag ) use ( $medium_src ): bool { + $attrs = self::tag_attributes( $tag, 'img' ); + return $medium_src === ( $attrs['src'] ?? null ); + } + ) + ); + $non_attachment_imgs = array_values( + array_filter( + $filtered_imgs, + static function ( string $tag ) use ( $non_attachment_src ): bool { + $attrs = self::tag_attributes( $tag, 'img' ); + return $non_attachment_src === ( $attrs['src'] ?? null ); + } + ) + ); + $attachment_attrs = isset( $attachment_imgs[0] ) ? self::tag_attributes( $attachment_imgs[0], 'img' ) : array(); + $non_attrs = isset( $non_attachment_imgs[0] ) ? self::tag_attributes( $non_attachment_imgs[0], 'img' ) : array(); + $iframe_attrs = isset( $filtered_iframes[0] ) ? self::tag_attributes( $filtered_iframes[0], 'iframe' ) : array(); + $attachment_srcset_widths = isset( $attachment_attrs['srcset'] ) && is_string( $attachment_attrs['srcset'] ) + ? self::parse_srcset_widths( $attachment_attrs['srcset'] ) + : array(); + $attachment_srcset_urls = isset( $attachment_attrs['srcset'] ) && is_string( $attachment_attrs['srcset'] ) + ? self::parse_srcset_width_urls( $attachment_attrs['srcset'] ) + : array(); + $attachment_sizes = 'auto, ' . sprintf( '(max-width: %1$dpx) 100vw, %1$dpx', $medium['width'] ); + $attachment_call_count = count( + array_filter( + $content_img_calls, + static fn( array $call ): bool => $attachment_id === ( $call['attachmentId'] ?? null ) + ) + ); + $non_attachment_call_count = count( + array_filter( + $content_img_calls, + static fn( array $call ): bool => 0 === ( $call['attachmentId'] ?? null ) + ) + ); + $all_filtered_tags_unique = true; + foreach ( array_merge( $filtered_imgs, $filtered_iframes ) as $tag ) { + $all_filtered_tags_unique = $all_filtered_tags_unique + && self::tag_has_unique_attributes( $tag, array( 'srcset', 'sizes', 'loading', 'decoding' ) ); + } + + self::collect_failure( + $failures, + 3 === count( $filtered_imgs ) + && 2 === count( $filtered_iframes ) + && 2 === count( $attachment_imgs ) + && isset( $attachment_imgs[0] ) + && 2 === substr_count( $filtered_content, $attachment_imgs[0] ) + && 1 === $attachment_call_count + && 1 === $non_attachment_call_count + && (string) $medium['width'] === ( $attachment_attrs['width'] ?? null ) + && (string) $medium['height'] === ( $attachment_attrs['height'] ?? null ) + && isset( $attachment_attrs['srcset'], $attachment_attrs['sizes'] ) + && self::expected_srcset_widths_present( $meta, $attachment_srcset_widths, (int) $medium['width'], (int) $medium['height'] ) + && self::expected_srcset_urls_present( $meta, $attachment_srcset_urls, (int) $medium['width'], (int) $medium['height'] ) + && $attachment_sizes === $attachment_attrs['sizes'] + && 'lazy' === ( $attachment_attrs['loading'] ?? null ) + && 'async' === ( $attachment_attrs['decoding'] ?? null ) + && (string) $attachment_id === ( $attachment_attrs['data-content-filtered'] ?? null ) + && 1 === count( $non_attachment_imgs ) + && '0' === ( $non_attrs['data-content-filtered'] ?? null ) + && 'lazy' === ( $non_attrs['loading'] ?? null ) + && 'async' === ( $non_attrs['decoding'] ?? null ) + && ! isset( $non_attrs['srcset'], $non_attrs['sizes'] ) + && '320' === ( $non_attrs['width'] ?? null ) + && '180' === ( $non_attrs['height'] ?? null ) + && 2 === substr_count( $filtered_content, $filtered_iframes[0] ?? '' ) + && 1 === count( $iframe_loading_calls ) + && $iframe_open === ( $iframe_loading_calls[0] ?? null ) + && 'lazy' === ( $iframe_attrs['loading'] ?? null ) + && $all_filtered_tags_unique, + 'wp_filter_content_tags transforms unique duplicate images/iframes once and replaces all copies', + array( + 'contentImgCalls' => $content_img_calls, + 'iframeCalls' => $iframe_loading_calls, + 'attachmentAttrs' => $attachment_attrs, + 'attachmentWidths' => $attachment_srcset_widths, + 'attachmentUrls' => $attachment_srcset_urls, + 'attachmentSizes' => $attachment_sizes, + 'nonAttachmentAttrs' => $non_attrs, + 'iframeAttrs' => $iframe_attrs, + 'filteredContent' => self::describe_string( $filtered_content ), + ) + ); + } finally { + \remove_filter( 'wp_content_img_tag', $content_img_filter, 10 ); + \remove_filter( 'wp_iframe_tag_add_loading_attr', $iframe_loading_attr_filter, 10 ); + \remove_filter( 'wp_img_tag_add_loading_attr', $img_loading_attr_filter, 10 ); + \remove_filter( 'wp_img_tag_add_decoding_attr', $decoding_attr_filter, 10 ); + \remove_filter( 'wp_lazy_loading_enabled', $lazy_enabled_filter, 10 ); + \remove_filter( 'get_post_metadata', $meta_filter, 10 ); + } + + return self::row( + $ctx, + 'images.content-tags.direct-helper-pipeline', + array() === $failures, + array( + 'attachmentId' => $attachment_id, + 'failures' => array_slice( $failures, 0, 8 ), + ) + ); + } + + private static function check_loading_optimization_attributes( \ComponentFuzz\FuzzContext $ctx ): array { + $failures = array(); + $attrs = array( + 'src' => 'https://example.test/image.jpg', + 'width' => 600 + $ctx->int( 0, 300 ), + 'height' => 400 + $ctx->int( 0, 200 ), + ); + + if ( class_exists( '\WP_Query' ) && ! isset( $GLOBALS['wp_query'] ) ) { + $GLOBALS['wp_query'] = new \WP_Query(); + } + + $first = \wp_get_loading_optimization_attributes( 'img', $attrs, 'component-fuzz' ); + $second = \wp_get_loading_optimization_attributes( 'img', $attrs, 'component-fuzz' ); + $template = \wp_get_loading_optimization_attributes( 'img', $attrs, 'template' ); + $span = \wp_get_loading_optimization_attributes( 'span', $attrs, 'component-fuzz' ); + $explicit = \wp_get_loading_optimization_attributes( 'img', $attrs + array( 'loading' => 'eager', 'decoding' => 'sync' ), 'component-fuzz' ); + $low = \wp_get_loading_optimization_attributes( 'img', $attrs + array( 'fetchpriority' => 'low' ), 'component-fuzz-low' ); + $auto = \wp_get_loading_optimization_attributes( 'img', $attrs + array( 'fetchpriority' => 'auto' ), 'component-fuzz-auto' ); + $iframe = \wp_get_loading_optimization_attributes( 'iframe', $attrs, 'component-fuzz-frame' ); + $missing_dimensions = \wp_get_loading_optimization_attributes( + 'img', + array( 'src' => 'https://example.test/missing-dimensions.jpg' ), + 'component-fuzz' + ); + $pre_filter = static function ( $loading_attrs, string $tag_name, array $attr, string $context ) { + if ( 'component-fuzz-short-circuit' === $context ) { + return array( + 'loading' => 'eager', + 'fetchpriority' => 'low', + ); + } + + return $loading_attrs; + }; + $lazy_filter = static function ( bool $default, string $tag_name, string $context ): bool { + if ( 'component-fuzz-no-lazy' === $context ) { + return false; + } + + return $default; + }; + \add_filter( 'pre_wp_get_loading_optimization_attributes', $pre_filter, 10, 4 ); + \add_filter( 'wp_lazy_loading_enabled', $lazy_filter, 10, 3 ); + try { + $short_circuit = \wp_get_loading_optimization_attributes( 'img', $attrs, 'component-fuzz-short-circuit' ); + $no_lazy = \wp_get_loading_optimization_attributes( 'img', $attrs, 'component-fuzz-no-lazy' ); + } finally { + \remove_filter( 'wp_lazy_loading_enabled', $lazy_filter, 10 ); + \remove_filter( 'pre_wp_get_loading_optimization_attributes', $pre_filter, 10 ); + } + + self::collect_failure( + $failures, + $first === $second + && isset( $first['decoding'] ) + && 'async' === $first['decoding'] + && array() === $template + && array() === $span + && isset( $explicit['decoding'] ) + && 'sync' === $explicit['decoding'] + && ! isset( $explicit['loading'] ) + && isset( $low['fetchpriority'] ) + && 'low' === $low['fetchpriority'] + && ! isset( $low['loading'] ) + && isset( $auto['fetchpriority'] ) + && 'auto' === $auto['fetchpriority'] + && isset( $auto['loading'] ) + && 'lazy' === $auto['loading'] + && ! isset( $iframe['decoding'] ) + && isset( $iframe['loading'] ) + && 'lazy' === $iframe['loading'] + && array( 'decoding' => 'async' ) === $missing_dimensions + && array( 'loading' => 'eager', 'fetchpriority' => 'low' ) === $short_circuit + && isset( $no_lazy['decoding'] ) + && 'async' === $no_lazy['decoding'] + && ! isset( $no_lazy['loading'] ), + 'wp_get_loading_optimization_attributes deterministic context behavior', + array( + 'attrs' => $attrs, + 'first' => $first, + 'second' => $second, + 'template' => $template, + 'span' => $span, + 'explicit' => $explicit, + 'low' => $low, + 'auto' => $auto, + 'iframe' => $iframe, + 'missingDimensions' => $missing_dimensions, + 'shortCircuit' => $short_circuit, + 'noLazy' => $no_lazy, + ) + ); + + return self::row( + $ctx, + 'images.loading-optimization.context-determinism', + array() === $failures, + array( 'failures' => $failures ) + ); + } + + private static function check_attachment_helpers( \ComponentFuzz\FuzzContext $ctx ): array { + $failures = array(); + $attachment_id = 100000 + $ctx->iteration(); + $meta = self::image_meta( $ctx ); + $src = 'http://example.test/wp-content/uploads/2026/06/photo-600x400.jpg'; + $full_src = 'http://example.test/wp-content/uploads/2026/06/photo.jpg'; + $attr_calls = array(); + + if ( class_exists( '\WP_Query' ) && ! isset( $GLOBALS['wp_query'] ) ) { + $GLOBALS['wp_query'] = new \WP_Query(); + } + + $source_filter = static function ( $image, int $id, $size ) use ( $attachment_id, $src, $full_src ) { + if ( $attachment_id !== $id ) { + return $image; + } + + if ( 'medium' === $size ) { + return array( $src, 600, 400, true ); + } + + if ( 'full' === $size ) { + return array( $full_src, 1200, 800, false ); + } + + if ( is_array( $size ) ) { + return array( $src, (int) $size[0], (int) $size[1], true ); + } + + return $image; + }; + $auto_sizes_filter = static fn() => false; + $meta_filter = static function ( $value, int $object_id, string $meta_key, bool $single ) use ( $attachment_id, $meta ) { + if ( $attachment_id !== $object_id || ! $single ) { + return $value; + } + + if ( '_wp_attachment_metadata' === $meta_key ) { + return array( $meta ); + } + + if ( '_wp_attachment_image_alt' === $meta_key ) { + return 'Default '; + } + + return $value; + }; + $attribute_filter = static function ( array $attr, $attachment, $size ) use ( &$attr_calls ): array { + $attr_calls[] = array( + 'attachmentClass' => is_object( $attachment ) ? get_class( $attachment ) : gettype( $attachment ), + 'size' => $size, + 'hasWidth' => isset( $attr['width'] ), + 'hasHeight' => isset( $attr['height'] ), + ); + + if ( 'medium' === $size ) { + $attr['class'] .= ' component-fuzzed'; + $attr['data-filtered'] = 'filter '; + } + + return $attr; + }; + + \add_filter( 'wp_get_attachment_image_src', $source_filter, 10, 3 ); + \add_filter( 'wp_img_tag_add_auto_sizes', $auto_sizes_filter ); + \add_filter( 'get_post_metadata', $meta_filter, 10, 4 ); + \add_filter( 'wp_get_attachment_image_attributes', $attribute_filter, 10, 3 ); + try { + $image_src = \wp_get_attachment_image_src( $attachment_id, 'medium' ); + $image_url = \wp_get_attachment_image_url( $attachment_id, 'medium' ); + $srcset = \wp_get_attachment_image_srcset( $attachment_id, 'medium', $meta ); + $sizes = \wp_get_attachment_image_sizes( $attachment_id, 'medium', $meta ); + $html = \wp_get_attachment_image( + $attachment_id, + 'medium', + false, + array( + 'alt' => 'Fuzz ', + 'loading' => false, + 'decoding' => 'sync', + 'data-extra' => 'raw " value', + ) + ); + } finally { + \remove_filter( 'wp_get_attachment_image_attributes', $attribute_filter, 10 ); + \remove_filter( 'get_post_metadata', $meta_filter, 10 ); + \remove_filter( 'wp_get_attachment_image_src', $source_filter, 10 ); + \remove_filter( 'wp_img_tag_add_auto_sizes', $auto_sizes_filter ); + } + + $full_match = \wp_image_file_matches_image_meta( $full_src . '?ver=1', $meta, $attachment_id ); + $thumbnail_match = \wp_image_file_matches_image_meta( '/var/www/wp-content/uploads/2026/06/photo-300x200.jpg', $meta, $attachment_id ); + $original_match = \wp_image_file_matches_image_meta( 'http://example.test/wp-content/uploads/2026/06/photo-original.jpg', $meta, $attachment_id ); + $missing_match = \wp_image_file_matches_image_meta( 'http://example.test/wp-content/uploads/2026/06/other.jpg', $meta, $attachment_id ); + $match_filter = static function ( bool $match, string $image_location, array $image_meta, int $id ) use ( $attachment_id ): bool { + if ( $attachment_id === $id && str_contains( $image_location, 'forced-match.jpg' ) ) { + return true; + } + + return $match; + }; + \add_filter( 'wp_image_file_matches_image_meta', $match_filter, 10, 4 ); + try { + $forced_match = \wp_image_file_matches_image_meta( 'http://example.test/wp-content/uploads/2026/06/forced-match.jpg', $meta, $attachment_id ); + } finally { + \remove_filter( 'wp_image_file_matches_image_meta', $match_filter, 10 ); + } + $after_forced_match = \wp_image_file_matches_image_meta( 'http://example.test/wp-content/uploads/2026/06/forced-match.jpg', $meta, $attachment_id ); + + self::collect_failure( + $failures, + array( $src, 600, 400, true ) === $image_src + && $src === $image_url + && is_string( $srcset ) + && str_contains( $srcset, 'photo-300x200.jpg 300w' ) + && str_contains( $srcset, 'photo-600x400.jpg 600w' ) + && str_contains( $srcset, 'photo.jpg 1200w' ) + && '(max-width: 600px) 100vw, 600px' === $sizes + && is_string( $html ) + && str_starts_with( $html, ' $image_src, + 'imageUrl' => $image_url, + 'srcset' => $srcset, + 'sizes' => $sizes, + 'html' => self::describe_string( $html ), + 'fullMatch' => $full_match, + 'thumbnailMatch' => $thumbnail_match, + 'originalMatch' => $original_match, + 'missingMatch' => $missing_match, + 'forcedMatch' => $forced_match, + 'afterForced' => $after_forced_match, + 'attrCalls' => $attr_calls, + ) + ); + + return self::row( + $ctx, + 'images.attachment.helpers-meta-agreement', + array() === $failures, + array( 'failures' => $failures ) + ); + } + + private static function check_filetype_helpers( \ComponentFuzz\FuzzContext $ctx ): array { + $failures = array(); + $mimes = array( + 'jpg|jpeg|jpe' => 'image/jpeg', + 'png' => 'image/png', + 'webp' => 'image/webp', + 'avif' => 'image/avif', + 'svg' => 'image/svg+xml', + ); + $cases = array( + array( 'name' => 'photo-' . $ctx->int( 100, 999 ) . '.JPG', 'ext' => 'jpg', 'type' => 'image/jpeg' ), + array( 'name' => 'nested/path/diagram.' . $ctx->choice( array( 'png', 'PNG' ) ), 'ext' => 'png', 'type' => 'image/png' ), + array( 'name' => 'vector.SVG', 'ext' => 'svg', 'type' => 'image/svg+xml' ), + array( 'name' => 'archive.jpg.php', 'ext' => false, 'type' => false ), + array( 'name' => 'query.webp?ver=1', 'ext' => false, 'type' => false ), + array( 'name' => 'no-extension', 'ext' => false, 'type' => false ), + ); + + foreach ( $cases as $index => $case ) { + $result = \wp_check_filetype( $case['name'], $mimes ); + self::collect_failure( + $failures, + $case['ext'] === ( is_string( $result['ext'] ) ? strtolower( $result['ext'] ) : $result['ext'] ) + && $case['type'] === $result['type'], + "wp_check_filetype maps extension case {$index}", + array( + 'case' => $case, + 'result' => $result, + ) + ); + } + + $hw_width_only = \image_hwstring( '640px', 0 ); + $hw_both = \image_hwstring( 320, '180px' ); + $mime_types = \wp_get_mime_types(); + $default_jpeg = \wp_get_default_extension_for_mime_type( 'image/jpeg' ); + + self::collect_failure( + $failures, + 'image' === \wp_ext2type( 'JPG' ) + && null === \wp_ext2type( 'component-fuzz-unknown' ) + && 'jpg' === $default_jpeg + && isset( $mime_types['jpg|jpeg|jpe'] ) + && 'image/jpeg' === $mime_types['jpg|jpeg|jpe'] + && 'width="640" ' === $hw_width_only + && 'width="320" height="180" ' === $hw_both, + 'image extension, MIME, and dimension-attribute helpers agree', + array( + 'defaultJpeg' => $default_jpeg, + 'hwWidthOnly' => $hw_width_only, + 'hwBoth' => $hw_both, + ) + ); + + return self::row( + $ctx, + 'images.filetypes.extensions-hwstring', + array() === $failures, + array( 'failures' => $failures ) + ); + } + + private static function image_meta( \ComponentFuzz\FuzzContext $ctx ): array { + unset( $ctx ); + return array( + 'width' => 1200, + 'height' => 800, + 'file' => '2026/06/photo.jpg', + 'original_image' => 'photo-original.jpg', + 'sizes' => array( + 'thumbnail' => array( + 'file' => 'photo-300x200.jpg', + 'width' => 300, + 'height' => 200, + 'mime-type' => 'image/jpeg', + ), + 'medium' => array( + 'file' => 'photo-600x400.jpg', + 'width' => 600, + 'height' => 400, + 'mime-type' => 'image/jpeg', + ), + 'large' => array( + 'file' => 'photo-1200x800.jpg', + 'width' => 1200, + 'height' => 800, + 'mime-type' => 'image/jpeg', + ), + ), + ); + } + + private static function generated_image_meta( \ComponentFuzz\FuzzContext $ctx ): array { + $width = $ctx->int( 900, 2400 ); + $height = $ctx->int( 500, 1800 ); + $base = 'fuzz-' . $ctx->int( 1000, 9999 ); + $dirname = '2026/06'; + $sizes = array(); + + foreach ( + array( + 'thumbnail' => 0.25, + 'medium' => 0.5, + 'medium_large' => 0.625, + 'large' => 0.75, + ) as $name => $scale + ) { + $size_width = max( 1, (int) round( $width * $scale ) ); + $size_height = max( 1, (int) round( $height * $size_width / $width ) ); + $file = "{$base}-{$size_width}x{$size_height}.jpg"; + $sizes[ $name ] = array( + 'file' => $file, + 'width' => $size_width, + 'height' => $size_height, + 'mime-type' => 'image/jpeg', + 'path' => "{$dirname}/{$file}", + 'url' => "http://example.test/wp-content/uploads/{$dirname}/{$file}", + ); + } + + return array( + 'width' => $width, + 'height' => $height, + 'file' => "{$dirname}/{$base}.jpg", + 'original_image' => "{$base}-original.jpg", + 'sizes' => $sizes, + ); + } + + private static function image_url( array $meta, ?string $size ): string { + $dirname = dirname( $meta['file'] ); + $prefix = '.' === $dirname ? '' : trim( $dirname, '/' ) . '/'; + $file = null === $size ? basename( $meta['file'] ) : $meta['sizes'][ $size ]['file']; + + return "http://example.test/wp-content/uploads/{$prefix}{$file}"; + } + + private static function dimension_cases( \ComponentFuzz\FuzzContext $ctx ): array { + $cases = array( + array( 'width' => 1200, 'height' => 800, 'maxWidth' => 600, 'maxHeight' => 600 ), + array( 'width' => 465, 'height' => 700, 'maxWidth' => 177, 'maxHeight' => 177 ), + array( 'width' => 1, 'height' => 400, 'maxWidth' => 100, 'maxHeight' => 100 ), + array( 'width' => 640, 'height' => 480, 'maxWidth' => 0, 'maxHeight' => 240 ), + array( 'width' => 4096, 'height' => 1, 'maxWidth' => 2048, 'maxHeight' => 99 ), + array( 'width' => 37, 'height' => 113, 'maxWidth' => 36, 'maxHeight' => 112 ), + array( 'width' => 1000, 'height' => 1000, 'maxWidth' => 333, 'maxHeight' => 0 ), + ); + + for ( $i = count( $cases ); $i < self::CASES; ++$i ) { + $case = $ctx->fork( 'dimension-' . $i ); + $width = $case->int( 1, 4000 ); + $height = $case->int( 1, 4000 ); + $cases[] = array( + 'width' => $width, + 'height' => $height, + 'maxWidth' => $case->choice( array( 0, $case->int( 1, $width ) ) ), + 'maxHeight' => $case->choice( array( 0, $case->int( 1, $height ) ) ), + ); + } + + return $cases; + } + + private static function resize_cases( \ComponentFuzz\FuzzContext $ctx ): array { + $cases = array( + array( 'origW' => 600, 'origH' => 300, 'destW' => 400, 'destH' => 400 ), + array( 'origW' => 300, 'origH' => 600, 'destW' => 200, 'destH' => 0 ), + array( 'origW' => 300, 'origH' => 200, 'destW' => 0, 'destH' => 100 ), + array( 'origW' => 64, 'origH' => 64, 'destW' => 128, 'destH' => 0 ), + ); + + for ( $i = count( $cases ); $i < self::CASES; ++$i ) { + $case = $ctx->fork( 'resize-' . $i ); + $orig_w = $case->int( 32, 4096 ); + $orig_h = $case->int( 32, 4096 ); + $cases[] = array( + 'origW' => $orig_w, + 'origH' => $orig_h, + 'destW' => $case->choice( array( 0, $case->int( 16, $orig_w ), $orig_w + $case->int( 1, 200 ) ) ), + 'destH' => $case->choice( array( 0, $case->int( 16, $orig_h ), $orig_h + $case->int( 1, 200 ) ) ), + ); + } + + return $cases; + } + + private static function expected_resize_result( array $case, $crop ) { + if ( self::resize_expected_false( $case, $crop ) ) { + return false; + } + + list( $new_w, $new_h ) = self::resize_target_dimensions( $case, $crop ); + + if ( $crop ) { + $size_ratio = max( $new_w / $case['origW'], $new_h / $case['origH'] ); + $crop_w = round( $new_w / $size_ratio ); + $crop_h = round( $new_h / $size_ratio ); + $crop = is_array( $crop ) && 2 === count( $crop ) ? $crop : array( 'center', 'center' ); + + if ( 'left' === $crop[0] ) { + $src_x = 0; + } elseif ( 'right' === $crop[0] ) { + $src_x = $case['origW'] - $crop_w; + } else { + $src_x = floor( ( $case['origW'] - $crop_w ) / 2 ); + } + + if ( 'top' === $crop[1] ) { + $src_y = 0; + } elseif ( 'bottom' === $crop[1] ) { + $src_y = $case['origH'] - $crop_h; + } else { + $src_y = floor( ( $case['origH'] - $crop_h ) / 2 ); + } + } else { + $crop_w = $case['origW']; + $crop_h = $case['origH']; + $src_x = 0; + $src_y = 0; + } + + return array( 0, 0, (int) $src_x, (int) $src_y, (int) $new_w, (int) $new_h, (int) $crop_w, (int) $crop_h ); + } + + private static function resize_expected_false( array $case, $crop ): bool { + if ( $case['origW'] <= 0 || $case['origH'] <= 0 ) { + return true; + } + + if ( $case['destW'] <= 0 && $case['destH'] <= 0 ) { + return true; + } + + if ( $case['destW'] <= 0 && $case['destH'] > $case['origH'] ) { + return true; + } + + if ( $case['destH'] <= 0 && $case['destW'] > $case['origW'] ) { + return true; + } + + if ( $case['destW'] > $case['origW'] && $case['destH'] > $case['origH'] ) { + return true; + } + + list( $new_w, $new_h ) = self::resize_target_dimensions( $case, $crop ); + return self::fuzzy_number_match( $new_w, $case['origW'] ) + && self::fuzzy_number_match( $new_h, $case['origH'] ); + } + + private static function resize_target_dimensions( array $case, $crop ): array { + if ( $crop ) { + $aspect_ratio = $case['origW'] / $case['origH']; + $new_w = min( $case['destW'], $case['origW'] ); + $new_h = min( $case['destH'], $case['origH'] ); + + if ( ! $new_w ) { + $new_w = (int) round( $new_h * $aspect_ratio ); + } + + if ( ! $new_h ) { + $new_h = (int) round( $new_w / $aspect_ratio ); + } + + return array( (int) $new_w, (int) $new_h ); + } + + $dimensions = \wp_constrain_dimensions( $case['origW'], $case['origH'], $case['destW'], $case['destH'] ); + return array( (int) $dimensions[0], (int) $dimensions[1] ); + } + + private static function fuzzy_number_match( $expected, $actual ): bool { + if ( function_exists( 'wp_fuzzy_number_match' ) ) { + return \wp_fuzzy_number_match( $expected, $actual ); + } + + return abs( (float) $expected - (float) $actual ) <= 1; + } + + private static function parse_srcset_widths( string $srcset ): array { + $widths = array(); + foreach ( explode( ',', $srcset ) as $candidate ) { + $candidate = trim( $candidate ); + if ( preg_match( '/\s(\d+)w$/', $candidate, $matches ) ) { + $widths[ (int) $matches[1] ] = $candidate; + } + } + + return $widths; + } + + private static function parse_srcset_width_urls( string $srcset ): array { + $urls = array(); + foreach ( explode( ',', $srcset ) as $candidate ) { + $candidate = trim( $candidate ); + if ( preg_match( '/^(.+)\s+(\d+)w$/', $candidate, $matches ) ) { + $urls[ (int) $matches[2] ] = $matches[1]; + } + } + + return $urls; + } + + private static function extract_tag_opens( string $html, string $tag_name ): array { + if ( ! preg_match_all( '/<' . preg_quote( $tag_name, '/' ) . '\s[^>]*>/i', $html, $matches ) ) { + return array(); + } + + return $matches[0]; + } + + private static function tag_attributes( string $tag, string $tag_name ): array { + $candidates = array( $tag ); + if ( 'img' !== strtolower( $tag_name ) && ! str_contains( strtolower( $tag ), '' ) ) { + $candidates[] = $tag . ''; + } + + foreach ( $candidates as $html ) { + $processor = new \WP_HTML_Tag_Processor( $html ); + if ( ! $processor->next_tag( array( 'tag_name' => strtoupper( $tag_name ) ) ) ) { + continue; + } + + $names = $processor->get_attribute_names_with_prefix( '' ); + if ( ! is_array( $names ) ) { + return array(); + } + + $attrs = array(); + foreach ( $names as $name ) { + $attrs[ $name ] = $processor->get_attribute( $name ); + } + + return $attrs; + } + + return array(); + } + + private static function tag_attribute_count( string $tag, string $name ): int { + return preg_match_all( '/(?<=\s)' . preg_quote( $name, '/' ) . '\s*=/i', $tag ); + } + + private static function tag_has_unique_attributes( string $tag, array $names ): bool { + foreach ( $names as $name ) { + if ( self::tag_attribute_count( $tag, (string) $name ) > 1 ) { + return false; + } + } + + return true; + } + + private static function expected_srcset_widths_present( array $meta, array $candidates, int $src_width, int $src_height ): bool { + $images = array_merge( + array( + array( + 'width' => (int) $meta['width'], + 'height' => (int) $meta['height'], + ), + ), + array_values( $meta['sizes'] ) + ); + + foreach ( $images as $image ) { + $width = (int) $image['width']; + $ratio_matches = \wp_image_matches_ratio( $src_width, $src_height, $width, (int) $image['height'] ); + $allowed_by_width = $width <= 2048 || $width === $src_width; + + if ( $ratio_matches && $allowed_by_width && ! isset( $candidates[ $width ] ) ) { + return false; + } + } + + return true; + } + + private static function expected_srcset_urls_present( array $meta, array $candidate_urls, int $src_width, int $src_height ): bool { + $images = array( + array( + 'width' => (int) $meta['width'], + 'height' => (int) $meta['height'], + 'url' => self::image_url( $meta, null ), + ), + ); + + foreach ( $meta['sizes'] as $size => $image ) { + $images[] = array( + 'width' => (int) $image['width'], + 'height' => (int) $image['height'], + 'url' => self::image_url( $meta, (string) $size ), + ); + } + + foreach ( $images as $image ) { + $width = (int) $image['width']; + $ratio_matches = \wp_image_matches_ratio( $src_width, $src_height, $width, (int) $image['height'] ); + $allowed_by_width = $width <= 2048 || $width === $src_width; + + if ( $ratio_matches && $allowed_by_width && ( ! isset( $candidate_urls[ $width ] ) || $image['url'] !== $candidate_urls[ $width ] ) ) { + return false; + } + } + + return true; + } + + private static function aspect_close( int $orig_w, int $orig_h, int $new_w, int $new_h ): bool { + if ( $orig_w <= 1 || $orig_h <= 1 || $new_w <= 2 || $new_h <= 2 ) { + return true; + } + + $diff = abs( ( $orig_w * $new_h ) - ( $new_w * $orig_h ) ); + $tolerance = 2 * max( $orig_w, $orig_h, $new_w, $new_h ); + return $diff <= $tolerance; + } + + private static function collect_failure( array &$failures, bool $condition, string $label, array $details ): void { + if ( $condition ) { + return; + } + + $failures[] = array( + 'label' => $label, + 'details' => self::describe_value( $details ), + ); + } + + private static function row( \ComponentFuzz\FuzzContext $ctx, string $invariant, bool $ok, array $data = array(), ?string $status = null ): array { + return array( + 'ok' => $ok, + 'status' => $status ?? ( $ok ? 'passed' : 'failed' ), + 'surface' => self::NAME, + 'invariant' => $invariant, + 'seed' => $ctx->seed(), + 'iteration' => $ctx->iteration(), + 'data' => self::describe_value( $data ), + ); + } + + private static function skip( \ComponentFuzz\FuzzContext $ctx, string $invariant, string $reason, array $data = array() ): array { + $data['reason'] = $reason; + return self::row( $ctx, $invariant, true, $data, 'skipped' ); + } + + private static function describe_value( $value, int $depth = 0 ) { + if ( is_string( $value ) ) { + return self::describe_string( $value ); + } + + if ( is_array( $value ) ) { + if ( $depth >= 4 ) { + return array( + 'type' => 'array', + 'count' => count( $value ), + ); + } + + $out = array(); + $i = 0; + foreach ( $value as $key => $item ) { + if ( $i >= 16 ) { + $out['...'] = count( $value ) - $i; + break; + } + $out[ is_int( $key ) ? $key : self::escape_bytes( (string) $key ) ] = self::describe_value( $item, $depth + 1 ); + ++$i; + } + return $out; + } + + if ( is_object( $value ) ) { + if ( $value instanceof \Throwable ) { + return self::describe_throwable( $value ); + } + + return array( + 'type' => 'object', + 'class' => get_class( $value ), + ); + } + + return $value; + } + + private static function describe_string( string $value ): array { + return array( + 'type' => 'string', + 'bytes' => strlen( $value ), + 'sha1' => sha1( $value ), + 'preview' => self::escape_bytes( $value ), + ); + } + + private static function describe_throwable( \Throwable $e ): array { + return array( + 'class' => get_class( $e ), + 'message' => self::escape_bytes( $e->getMessage() ), + 'file' => $e->getFile(), + 'line' => $e->getLine(), + ); + } + + private static function escape_bytes( string $value, int $limit = self::PREVIEW_BYTES ): string { + $out = ''; + $length = strlen( $value ); + $shown = min( $length, $limit ); + + for ( $i = 0; $i < $shown; ++$i ) { + $byte = ord( $value[ $i ] ); + if ( 0x5C === $byte ) { + $out .= '\\\\'; + } elseif ( $byte >= 0x20 && $byte <= 0x7E ) { + $out .= chr( $byte ); + } elseif ( 0x0A === $byte ) { + $out .= '\\n'; + } elseif ( 0x0D === $byte ) { + $out .= '\\r'; + } elseif ( 0x09 === $byte ) { + $out .= '\\t'; + } else { + $out .= sprintf( '\\x%02X', $byte ); + } + } + + if ( $length > $shown ) { + $out .= '...'; + } + + return $out; + } + + private static function snapshot_globals(): array { + $snapshot = array(); + foreach ( array( 'wp_filter', 'wp_filters', 'wp_actions', 'wp_current_filter', 'wp_query', '_wp_additional_image_sizes' ) as $name ) { + $snapshot[ $name ] = array( + 'exists' => array_key_exists( $name, $GLOBALS ), + 'value' => array_key_exists( $name, $GLOBALS ) ? self::clone_value( $GLOBALS[ $name ] ) : null, + ); + } + + return $snapshot; + } + + private static function restore_globals( array $snapshot ): void { + foreach ( $snapshot as $name => $entry ) { + if ( $entry['exists'] ) { + $GLOBALS[ $name ] = $entry['value']; + } else { + unset( $GLOBALS[ $name ] ); + } + } + } + + private static function clone_value( $value ) { + if ( is_object( $value ) ) { + return clone $value; + } + + if ( is_array( $value ) ) { + $copy = array(); + foreach ( $value as $key => $item ) { + $copy[ $key ] = self::clone_value( $item ); + } + return $copy; + } + + return $value; + } +} diff --git a/tools/component-fuzz/surfaces/ImportDiffSurface.php b/tools/component-fuzz/surfaces/ImportDiffSurface.php new file mode 100644 index 0000000000000..071698cf3bd1a --- /dev/null +++ b/tools/component-fuzz/surfaces/ImportDiffSurface.php @@ -0,0 +1,1254 @@ +skip( + 'import-diff.bootstrap-apis-available', + 'Required import and diff APIs are unavailable.', + array( 'missing' => implode( ', ', $missing ) ) + ), + ); + } + + $snapshot = self::snapshot_state(); + $rows = array(); + + try { + self::reset_runtime(); + + $rows[] = self::check_importer_registry( $ctx->fork( 'registry' ) ); + $rows[] = self::check_import_upload_form( $ctx->fork( 'upload-form' ) ); + $rows[] = self::check_import_upload_handler_and_cleanup( $ctx->fork( 'upload-handler' ) ); + $rows[] = self::check_text_diff_rendering( $ctx->fork( 'text-diff' ) ); + $rows[] = self::check_error_export_and_merge( $ctx->fork( 'error-export' ) ); + $rows[] = self::check_error_lifecycle_ordering( $ctx->fork( 'error-lifecycle' ) ); + $rows[] = self::check_imported_post_lookup( $ctx->fork( 'post-lookup' ) ); + $rows[] = self::check_imported_comment_lookup( $ctx->fork( 'comment-lookup' ) ); + $rows[] = self::check_importer_get_page_http_wrapper( $ctx->fork( 'importer-get-page' ) ); + $rows[] = self::check_importer_base_helpers( $ctx->fork( 'importer-base-helpers' ) ); + } catch ( \Throwable $e ) { + $rows[] = $ctx->fail( + 'import-diff.surface-no-throw', + array( 'throwable' => self::describe_throwable( $e ) ) + ); + } finally { + self::restore_state( $snapshot ); + } + + $rows[] = $ctx->result( + 'import-diff.global-state-restored', + self::state_matches( $snapshot ), + array( 'trackedGlobals' => array_keys( $snapshot['globals'] ) ) + ); + + return $rows; + } + + public static function importer_callback(): void {} + + private static function missing_requirements(): array { + $missing = array(); + + foreach ( array( 'Text_Diff', 'WP_Error', 'WP_Importer', 'WP_Post', 'WP_Text_Diff_Renderer_Table' ) as $class ) { + if ( ! class_exists( $class ) ) { + $missing[] = "class {$class}"; + } + } + + foreach ( + array( + 'add_filter', + 'get_importers', + 'get_post', + 'has_filter', + 'remove_filter', + 'register_importer', + 'wp_safe_remote_request', + 'wp_delete_attachment', + 'wp_import_cleanup', + 'wp_import_handle_upload', + 'wp_import_upload_form', + 'wp_text_diff', + ) as $function + ) { + if ( ! function_exists( $function ) ) { + $missing[] = "function {$function}"; + } + } + + return $missing; + } + + private static function check_importer_registry( \ComponentFuzz\FuzzContext $ctx ): array { + global $wp_importers; + + $failures = array(); + $wp_importers = array(); + $callback = array( __CLASS__, 'importer_callback' ); + $registered = array( + 'component-fuzz-beta-10' => array( + 'Beta 10 ' . $ctx->identifier( 3, 6 ), + 'Imports beta-ten data & checks sorting.', + $callback, + ), + 'component-fuzz-alpha-2' => array( + 'alpha 2 ' . $ctx->identifier( 3, 6 ), + 'Imports alpha-two data.', + $callback, + ), + 'component-fuzz-alpha-10' => array( + 'Alpha 10 ' . $ctx->identifier( 3, 6 ), + 'Imports alpha-ten data.', + $callback, + ), + 'component-fuzz-zeta' => array( + 'Zeta ' . $ctx->identifier( 3, 6 ), + 'Imports zeta data.', + $callback, + ), + ); + + foreach ( $registered as $id => $entry ) { + $result = \register_importer( $id, $entry[0], $entry[1], $entry[2] ); + self::collect_failure( + $failures, + null === $result, + 'register_importer returns void for valid callbacks', + array( 'id' => $id, 'result' => self::describe_value( $result ) ) + ); + } + + $error = new \WP_Error( 'component_fuzz_importer_unavailable', 'Importer unavailable.' ); + $error_out = \register_importer( 'component-fuzz-error', 'Error', 'Should not register.', $error ); + $importers = \get_importers(); + $expected = $registered; + $sort_names = $expected; + uasort( $sort_names, '_usort_by_first_member' ); + + self::collect_failure( + $failures, + $error === $error_out + && ! isset( $importers['component-fuzz-error'] ) + && array_keys( $sort_names ) === array_keys( $importers ) + && self::importer_entries_match( $expected, $importers ), + 'importer registry stores valid importers, preserves callbacks, sorts by name, and rejects WP_Error callbacks', + array( + 'expectedOrder' => array_keys( $sort_names ), + 'actualOrder' => array_keys( is_array( $importers ) ? $importers : array() ), + 'errorOut' => self::describe_value( $error_out ), + 'importers' => self::describe_value( $importers ), + ) + ); + + return self::result( $ctx, 'import-diff.importer-registry.sorting-and-error-callbacks', $failures ); + } + + private static function check_import_upload_form( \ComponentFuzz\FuzzContext $ctx ): array { + $failures = array(); + $limit = 1024 * $ctx->int( 2, 256 ); + $limit_observed = array(); + $limit_filter = static function ( int $bytes ) use ( $limit, &$limit_observed ): int { + $limit_observed[] = $bytes; + return $limit; + }; + + $action = 'admin.php?import=component-fuzz&step=' . rawurlencode( $ctx->identifier( 3, 10 ) ) . '&bad='; + + \add_filter( 'import_upload_size_limit', $limit_filter ); + try { + ob_start(); + \wp_import_upload_form( $action ); + $output = ob_get_clean(); + } finally { + \remove_filter( 'import_upload_size_limit', $limit_filter ); + if ( ob_get_level() > 0 && false === isset( $output ) ) { + ob_end_clean(); + } + } + + self::collect_failure( + $failures, + isset( $output ) + && '' !== $output + && array() !== $limit_observed + && str_contains( $output, 'id="import-upload-form"' ) + && str_contains( $output, 'enctype="multipart/form-data"' ) + && str_contains( $output, 'name="import"' ) + && str_contains( $output, 'name="action" value="save"' ) + && str_contains( $output, '_wpnonce=' ) + && ! str_contains( $output, '' ), + 'import upload form applies size filters, emits expected controls, and escapes action data', + array( + 'action' => self::preview( $action ), + 'limit' => $limit, + 'limitObserved' => $limit_observed, + 'output' => self::preview( $output ?? '' ), + ) + ); + + return self::result( $ctx, 'import-diff.import-upload-form.controls-size-and-escaping', $failures ); + } + + private static function check_import_upload_handler_and_cleanup( \ComponentFuzz\FuzzContext $ctx ): array { + global $wpdb; + + if ( ! isset( $wpdb ) || ! method_exists( $wpdb, 'component_fuzz_content_counts' ) ) { + return $ctx->skip( + 'import-diff.import-upload-handler.fail-closed-and-cleanup', + 'The in-memory wpdb content stub is unavailable.' + ); + } + + $before_counts = $wpdb->component_fuzz_content_counts(); + if ( array_sum( $before_counts ) !== 0 ) { + return $ctx->skip( + 'import-diff.import-upload-handler.fail-closed-and-cleanup', + 'The content stub was not empty before the import upload handler case.', + array( 'counts' => $before_counts ) + ); + } + + $failures = array(); + $token = $ctx->identifier( 5, 10 ); + $temp_dir = rtrim( sys_get_temp_dir(), DIRECTORY_SEPARATOR ) . DIRECTORY_SEPARATOR . 'component-fuzz-import-upload-' . getmypid() . '-' . $token; + $tmp_file = $temp_dir . DIRECTORY_SEPARATOR . 'incoming-' . $token . '.xml'; + $attachment_file = $temp_dir . DIRECTORY_SEPARATOR . 'cleanup-' . $token . '.txt'; + $superglobal_state = array( + '_FILES' => $_FILES, + '_POST' => $_POST, + '_REQUEST' => $_REQUEST, + ); + $events = array( + 'prefilter' => array(), + 'overrides' => array(), + 'handler' => array(), + 'handle' => array(), + 'move' => array(), + ); + $prefilter = static function ( array $file ) use ( &$events, $token ): array { + $events['prefilter'][] = array( + 'name' => $file['name'] ?? null, + 'size' => $file['size'] ?? null, + 'error' => $file['error'] ?? null, + ); + $file['error'] = 'component fuzz blocked import upload ' . $token; + return $file; + }; + $overrides_filter = static function ( $overrides, array $file ) use ( &$events ): array { + $events['overrides'][] = array( + 'overrides' => $overrides, + 'name' => $file['name'] ?? null, + 'error' => $file['error'] ?? null, + ); + + $overrides = is_array( $overrides ) ? $overrides : array(); + $overrides['upload_error_handler'] = static function ( array &$file, string $message ) use ( &$events ): array { + $events['handler'][] = array( + 'name' => $file['name'] ?? null, + 'message' => $message, + ); + + return array( + 'error' => $message, + 'name' => $file['name'] ?? null, + 'size' => $file['size'] ?? null, + ); + }; + return $overrides; + }; + $handle_filter = static function ( array $upload, string $context ) use ( &$events ): array { + $events['handle'][] = array( + 'upload' => $upload, + 'context' => $context, + ); + return $upload; + }; + $move_filter = static function ( $move, array $file, string $new_file, string $type ) use ( &$events ) { + $events['move'][] = array( + 'name' => $file['name'] ?? null, + 'newFile' => $new_file, + 'type' => $type, + ); + return $move; + }; + $filters_after = array(); + + \add_filter( 'wp_handle_upload_prefilter', $prefilter ); + \add_filter( 'wp_handle_upload_overrides', $overrides_filter, 10, 2 ); + \add_filter( 'wp_handle_upload', $handle_filter, 10, 2 ); + \add_filter( 'pre_move_uploaded_file', $move_filter, 10, 4 ); + + try { + if ( ! is_dir( $temp_dir ) ) { + mkdir( $temp_dir, 0777, true ); + } + + unset( $_FILES['import'] ); + $missing_upload = \wp_import_handle_upload(); + + self::collect_failure( + $failures, + is_array( $missing_upload ) + && isset( $missing_upload['error'] ) + && is_string( $missing_upload['error'] ) + && str_contains( $missing_upload['error'], 'post_max_size' ) + && array() === $events['prefilter'] + && $before_counts === $wpdb->component_fuzz_content_counts(), + 'wp_import_handle_upload fails closed before upload hooks or DB writes when the import file is missing', + array( + 'missingUpload' => self::describe_value( $missing_upload ), + 'events' => $events, + 'counts' => $wpdb->component_fuzz_content_counts(), + ) + ); + + file_put_contents( $tmp_file, "component import fuzz {$token}\ndata\n" ); + $_POST = array( 'action' => 'unexpected-action' ); + $_REQUEST = $_POST; + $_FILES = array( + 'import' => array( + 'name' => 'component-import-' . $token . '.xml', + 'type' => 'text/xml', + 'tmp_name' => $tmp_file, + 'error' => 0, + 'size' => filesize( $tmp_file ), + ), + ); + + $blocked_upload = \wp_import_handle_upload(); + $counts_after_block = $wpdb->component_fuzz_content_counts(); + + self::collect_failure( + $failures, + is_array( $blocked_upload ) + && 'component fuzz blocked import upload ' . $token === ( $blocked_upload['error'] ?? null ) + && 'component-import-' . $token . '.xml.txt' === ( $blocked_upload['name'] ?? null ) + && array( 'test_form' => false, 'test_type' => false ) === ( $events['overrides'][0]['overrides'] ?? null ) + && 1 === count( $events['prefilter'] ) + && 1 === count( $events['overrides'] ) + && 1 === count( $events['handler'] ) + && array() === $events['handle'] + && array() === $events['move'] + && 'component-import-' . $token . '.xml.txt' === ( $_FILES['import']['name'] ?? null ) + && $before_counts === $counts_after_block, + 'wp_import_handle_upload appends .txt, passes import overrides to wp_handle_upload, and returns prefiltered upload errors without move or attachment effects', + array( + 'blockedUpload' => self::describe_value( $blocked_upload ), + 'events' => $events, + 'filesNameAfter' => $_FILES['import']['name'] ?? null, + 'countsAfterBlock' => $counts_after_block, + ) + ); + + file_put_contents( $attachment_file, 'component import cleanup ' . $token ); + $attachment_id = self::insert_import_attachment( $attachment_file, $token ); + $before_cleanup = is_int( $attachment_id ) ? \get_post( $attachment_id ) : null; + $cleanup_return = is_int( $attachment_id ) ? \wp_import_cleanup( $attachment_id ) : null; + $after_cleanup = is_int( $attachment_id ) ? \get_post( $attachment_id ) : null; + $cleanup_completed = ! $after_cleanup || ( $after_cleanup instanceof \WP_Post && 'private' !== $after_cleanup->post_status ); + + self::collect_failure( + $failures, + is_int( $attachment_id ) + && $attachment_id > 0 + && $before_cleanup instanceof \WP_Post + && 'attachment' === $before_cleanup->post_type + && null === $cleanup_return + && $cleanup_completed, + 'wp_import_cleanup delegates to attachment deletion and does not leave the generated import attachment live', + array( + 'attachmentId' => $attachment_id, + 'beforeCleanup' => $before_cleanup instanceof \WP_Post ? array( 'type' => $before_cleanup->post_type, 'status' => $before_cleanup->post_status ) : self::describe_value( $before_cleanup ), + 'cleanupReturn' => self::describe_value( $cleanup_return ), + 'afterCleanup' => $after_cleanup instanceof \WP_Post ? array( 'type' => $after_cleanup->post_type, 'status' => $after_cleanup->post_status ) : self::describe_value( $after_cleanup ), + 'countsAfterClean' => $wpdb->component_fuzz_content_counts(), + ) + ); + } finally { + \remove_filter( 'pre_move_uploaded_file', $move_filter, 10 ); + \remove_filter( 'wp_handle_upload', $handle_filter, 10 ); + \remove_filter( 'wp_handle_upload_overrides', $overrides_filter, 10 ); + \remove_filter( 'wp_handle_upload_prefilter', $prefilter, 10 ); + $filters_after = array( + 'prefilter' => \has_filter( 'wp_handle_upload_prefilter', $prefilter ), + 'overrides' => \has_filter( 'wp_handle_upload_overrides', $overrides_filter ), + 'handle' => \has_filter( 'wp_handle_upload', $handle_filter ), + 'move' => \has_filter( 'pre_move_uploaded_file', $move_filter ), + ); + + $_FILES = $superglobal_state['_FILES']; + $_POST = $superglobal_state['_POST']; + $_REQUEST = $superglobal_state['_REQUEST']; + $wpdb->component_fuzz_reset_content(); + self::delete_tree( $temp_dir ); + } + + self::collect_failure( + $failures, + array( 'prefilter' => false, 'overrides' => false, 'handle' => false, 'move' => false ) === $filters_after, + 'import upload handler filters are removed after the upload and cleanup checks', + array( 'filtersAfter' => $filters_after ) + ); + + return self::result( $ctx, 'import-diff.import-upload-handler.fail-closed-and-cleanup', $failures ); + } + + private static function check_text_diff_rendering( \ComponentFuzz\FuzzContext $ctx ): array { + $failures = array(); + $token = $ctx->identifier( 3, 10 ); + $left = "common & {$token}\nremoved {$token}\nkept"; + $right = "common & {$token}\nadded & changed {$token}\nkept"; + $split = \wp_text_diff( + $left, + $right, + array( + 'title' => 'Component Fuzz Diff', + 'title_left' => 'Before', + 'title_right' => 'After', + 'show_split_view' => true, + ) + ); + $unified = \wp_text_diff( + $left, + $right, + array( + 'title_left' => 'Before', + 'title_right' => 'After', + 'show_split_view' => false, + ) + ); + $same = \wp_text_diff( "same\r\nwhitespace\tcase", "same\nwhitespace case" ); + + self::collect_failure( + $failures, + is_string( $split ) + && str_contains( $split, "
    " ) + && str_contains( $split, "class='diff-deletedline'" ) + && str_contains( $split, "class='diff-addedline'" ) + && str_contains( $split, 'common <tag>& ' . $token ) + && str_contains( $split, 'added & changed' ) + && str_contains( $split, $token ) + && ! str_contains( $split, 'common & ' . $token ), + 'wp_text_diff split view escapes changed content and marks added/deleted lines', + array( 'split' => self::preview( $split ) ) + ); + self::collect_failure( + $failures, + is_string( $unified ) + && str_contains( $unified, "
    " ) + && ! str_contains( $unified, 'is-split-view' ) + && str_contains( $unified, 'Before' ) + && ! str_contains( $unified, '' ), + 'wp_text_diff unified view omits split class and right-side header', + array( 'unified' => self::preview( $unified ) ) + ); + self::collect_failure( + $failures, + '' === $same, + 'wp_text_diff returns an empty string after normalizing equivalent whitespace', + array( 'same' => self::preview( $same ) ) + ); + + return self::result( $ctx, 'import-diff.text-diff.rendering-and-normalization', $failures ); + } + + private static function check_error_export_and_merge( \ComponentFuzz\FuzzContext $ctx ): array { + $failures = array(); + $source = new \WP_Error(); + $target = new \WP_Error( 'existing', 'Existing target message.', array( 'kept' => true ) ); + $merged = new \WP_Error( 'merged', 'Existing merge message.' ); + $code = 'component_fuzz_' . $ctx->identifier( 4, 10 ); + $message = 'Message <' . $ctx->identifier( 3, 7 ) . '>'; + $data_one = array( + 'seed' => $ctx->seed(), + 'token' => $ctx->identifier( 4, 10 ), + ); + $data_two = array( + 'next' => $ctx->identifier( 4, 10 ), + ); + + $source->add( $code, $message ); + $source->add_data( $data_one, $code ); + $source->add_data( $data_two, $code ); + $source->export_to( $target ); + $merged->merge_from( $source ); + + self::collect_failure( + $failures, + in_array( 'existing', $target->get_error_codes(), true ) + && in_array( $code, $target->get_error_codes(), true ) + && array( $message ) === $target->get_error_messages( $code ) + && array( $data_one, $data_two ) === $target->get_all_error_data( $code ) + && array( $message ) === $merged->get_error_messages( $code ) + && array( $data_one, $data_two ) === $merged->get_all_error_data( $code ), + 'WP_Error export_to and merge_from copy messages and all error data without dropping existing target errors', + array( + 'code' => $code, + 'target' => self::describe_error( $target ), + 'merged' => self::describe_error( $merged ), + ) + ); + + $target->remove( $code ); + self::collect_failure( + $failures, + ! in_array( $code, $target->get_error_codes(), true ) + && array() === $target->get_all_error_data( $code ) + && in_array( 'existing', $target->get_error_codes(), true ), + 'WP_Error::remove deletes copied messages and data for one code only', + array( 'target' => self::describe_error( $target ) ) + ); + + return self::result( $ctx, 'import-diff.wp-error.export-merge-and-remove', $failures ); + } + + private static function check_error_lifecycle_ordering( \ComponentFuzz\FuzzContext $ctx ): array { + $failures = array(); + $code_a = 'component_fuzz_a_' . $ctx->identifier( 4, 10 ); + $code_b = 'component_fuzz_b_' . $ctx->identifier( 4, 10 ); + $message_a = 'Primary message ' . $ctx->identifier( 3, 7 ); + $message_a_2 = 'Secondary message ' . $ctx->identifier( 3, 7 ); + $message_b = 'Other code message ' . $ctx->identifier( 3, 7 ); + $data_a = array( 'phase' => 'construct', 'seed' => $ctx->seed() ); + $data_a_2 = array( 'phase' => 'add', 'token' => $ctx->identifier( 4, 9 ) ); + $data_default = array( 'phase' => 'default-code', 'iteration' => $ctx->iteration() ); + $data_b = array( 'phase' => 'second-code', 'flag' => true ); + $error = new \WP_Error( $code_a, $message_a, $data_a ); + + $error->add( $code_a, $message_a_2, $data_a_2 ); + $error->add_data( $data_default ); + $error->add( $code_b, $message_b, $data_b ); + + self::collect_failure( + $failures, + array( $code_a, $code_b ) === $error->get_error_codes() + && $code_a === $error->get_error_code() + && array( $message_a, $message_a_2 ) === $error->get_error_messages( $code_a ) + && array( $message_b ) === $error->get_error_messages( $code_b ) + && array( $message_a, $message_a_2, $message_b ) === $error->get_error_messages() + && $message_a === $error->get_error_message() + && $message_b === $error->get_error_message( $code_b ) + && $data_default === $error->get_error_data( $code_a ) + && $data_b === $error->get_error_data( $code_b ) + && array( $data_a, $data_a_2, $data_default ) === $error->get_all_error_data( $code_a ) + && array( $data_b ) === $error->get_all_error_data( $code_b ) + && $error->has_errors(), + 'WP_Error preserves code order, message order, newest data, and all-data history across add/add_data', + array( + 'codeA' => $code_a, + 'codeB' => $code_b, + 'error' => self::describe_error( $error ), + ) + ); + + $copy = new \WP_Error(); + $error->export_to( $copy ); + $error->remove( $code_a ); + + self::collect_failure( + $failures, + array( $code_b ) === $error->get_error_codes() + && $code_b === $error->get_error_code() + && array() === $error->get_error_messages( $code_a ) + && array() === $error->get_all_error_data( $code_a ) + && $message_b === $error->get_error_message() + && array( $code_a, $code_b ) === $copy->get_error_codes() + && array( $data_a, $data_a_2, $data_default ) === $copy->get_all_error_data( $code_a ) + && array( $data_b ) === $copy->get_all_error_data( $code_b ), + 'WP_Error::remove promotes the remaining first code and export_to creates an independent copy', + array( + 'removed' => self::describe_error( $error ), + 'copy' => self::describe_error( $copy ), + ) + ); + + return self::result( $ctx, 'import-diff.wp-error.lifecycle-ordering-and-default-code', $failures ); + } + + private static function check_imported_post_lookup( \ComponentFuzz\FuzzContext $ctx ): array { + global $wpdb; + + if ( ! isset( $wpdb ) || ! method_exists( $wpdb, 'component_fuzz_content_counts' ) ) { + return $ctx->skip( + 'import-diff.importer.imported-post-lookup', + 'The in-memory wpdb content stub is unavailable.' + ); + } + + $before = $wpdb->component_fuzz_content_counts(); + if ( array_sum( $before ) !== 0 ) { + return $ctx->skip( + 'import-diff.importer.imported-post-lookup', + 'The content stub was not empty before the importer post lookup case.', + array( 'counts' => $before ) + ); + } + + $failures = array(); + $importer_name = 'component_fuzz_' . $ctx->identifier( 4, 9 ); + $blog_id = (string) $ctx->int( 2, 20 ); + $other_blog_id = (string) ( (int) $blog_id + 200 ); + $expected = array(); + $matching_ids = array(); + + try { + for ( $i = 0; $i < 105; ++$i ) { + $permalink = 'https://example.test/imported/' . $i . '-' . rawurlencode( $ctx->identifier( 4, 10 ) ); + $matching_ids[] = self::insert_imported_post_meta( $importer_name, $blog_id, $permalink, 'match-' . $i ); + $expected[ $permalink ] = end( $matching_ids ); + } + + $duplicate_permalink = array_key_first( $expected ); + $duplicate_id = self::insert_imported_post_meta( $importer_name, $blog_id, (string) $duplicate_permalink, 'duplicate' ); + $expected[ $duplicate_permalink ] = $duplicate_id; + $other_permalink = 'https://other.test/imported/' . rawurlencode( $ctx->identifier( 4, 10 ) ); + $other_id = self::insert_imported_post_meta( $importer_name, $other_blog_id, $other_permalink, 'other-blog' ); + self::insert_imported_post_meta( $importer_name . '_other', $blog_id, (string) $duplicate_permalink, 'other-importer' ); + + $importer = new \WP_Importer(); + $lookup = $importer->get_imported_posts( $importer_name, $blog_id ); + $lookup_query = (string) $wpdb->last_query; + $count = $importer->count_imported_posts( $importer_name, $blog_id ); + $other = $importer->get_imported_posts( $importer_name, $other_blog_id ); + + self::collect_failure( + $failures, + $expected === $lookup + && 106 === $count + && array( $other_permalink => $other_id ) === $other + && str_contains( $lookup_query, 'LIMIT 100,100' ), + 'WP_Importer maps imported post permalinks to local post IDs by importer/blog meta key across chunks', + array( + 'blogId' => $blog_id, + 'importerName' => $importer_name, + 'lookupCount' => count( $lookup ), + 'lookupQuery' => $lookup_query, + 'count' => $count, + 'expectedCount' => count( $expected ), + 'otherLookup' => $other, + ) + ); + } finally { + $wpdb->component_fuzz_reset_content(); + } + + return self::result( $ctx, 'import-diff.importer.imported-post-lookup', $failures ); + } + + private static function check_imported_comment_lookup( \ComponentFuzz\FuzzContext $ctx ): array { + global $wpdb; + + if ( ! isset( $wpdb ) || ! method_exists( $wpdb, 'component_fuzz_content_counts' ) ) { + return $ctx->skip( + 'import-diff.importer.imported-comment-lookup', + 'The in-memory wpdb content stub is unavailable.' + ); + } + + $before = $wpdb->component_fuzz_content_counts(); + if ( array_sum( $before ) !== 0 ) { + return $ctx->skip( + 'import-diff.importer.imported-comment-lookup', + 'The content stub was not empty before the importer lookup case.', + array( 'counts' => $before ) + ); + } + + $failures = array(); + $blog_id = $ctx->int( 2, 20 ); + $source_a = $ctx->int( 100, 999 ); + $source_b = $source_a + $ctx->int( 1, 20 ); + $other = $blog_id + 100; + $ids = array(); + + try { + $ids[] = self::insert_comment_agent( $blog_id . '-' . $source_a, 'first' ); + $ids[] = self::insert_comment_agent( $blog_id . '-' . $source_b, 'second' ); + $ids[] = self::insert_comment_agent( $other . '-' . $source_a, 'other-blog' ); + + $importer = new \WP_Importer(); + $lookup = $importer->get_imported_comments( (string) $blog_id ); + + self::collect_failure( + $failures, + array( + $source_a => $ids[0], + $source_b => $ids[1], + ) === $lookup, + 'WP_Importer maps source comment IDs to local comment IDs for the selected blog only', + array( + 'blogId' => $blog_id, + 'ids' => $ids, + 'lookup' => $lookup, + ) + ); + } finally { + $wpdb->component_fuzz_reset_content(); + } + + return self::result( $ctx, 'import-diff.importer.imported-comment-lookup', $failures ); + } + + private static function check_importer_get_page_http_wrapper( \ComponentFuzz\FuzzContext $ctx ): array { + $failures = array(); + $importer = new \WP_Importer(); + $username = 'user-' . $ctx->identifier( 3, 8 ); + $password = 'pass:' . $ctx->identifier( 4, 10 ); + $token = $ctx->identifier( 4, 10 ); + $calls = array(); + + $fake_response = static function ( string $label ) use ( $token ): array { + return array( + 'headers' => array( 'x-component-fuzz' => $label ), + 'body' => 'component-fuzz-importer-' . $label . '-' . $token, + 'response' => array( + 'code' => 200, + 'message' => 'OK ' . $label, + ), + 'cookies' => array(), + 'filename' => null, + ); + }; + $http_filter = static function ( $preempt, array $parsed_args, string $url ) use ( &$calls, $fake_response ) { + $label = 'call-' . count( $calls ); + $calls[] = array( + 'url' => $url, + 'args' => $parsed_args, + ); + + return $fake_response( $label ); + }; + + \add_filter( 'pre_http_request', $http_filter, 10, 3 ); + try { + $default_url = 'https://imports.example.test/' . rawurlencode( $token ) . '/data.xml?view=' . rawurlencode( 'full & safe' ); + $head_url = 'https://imports.example.test/' . rawurlencode( $token ) . '/head.xml'; + $auth_url = 'https://imports.example.test/' . rawurlencode( $token ) . '/auth.xml'; + $username_only_url = 'https://imports.example.test/' . rawurlencode( $token ) . '/username-only.xml'; + $password_only_url = 'https://imports.example.test/' . rawurlencode( $token ) . '/password-only.xml'; + + $default_response = $importer->get_page( $default_url ); + $head_response = $importer->get_page( $head_url, '', '', true ); + $auth_response = $importer->get_page( $auth_url, $username, $password ); + $username_only_response = $importer->get_page( $username_only_url, $username, '' ); + $password_only_response = $importer->get_page( $password_only_url, '', $password ); + } finally { + \remove_filter( 'pre_http_request', $http_filter, 10 ); + \remove_filter( 'http_request_timeout', array( $importer, 'bump_request_timeout' ) ); + } + + $expected_responses = array( + $fake_response( 'call-0' ), + $fake_response( 'call-1' ), + $fake_response( 'call-2' ), + $fake_response( 'call-3' ), + $fake_response( 'call-4' ), + ); + $responses = array( + $default_response, + $head_response, + $auth_response, + $username_only_response, + $password_only_response, + ); + $auth_header = 'Basic ' . base64_encode( "{$username}:{$password}" ); + + self::collect_failure( + $failures, + $expected_responses === $responses + && 5 === count( $calls ), + 'WP_Importer::get_page returns exact pre_http_request responses without falling through to live HTTP', + array( + 'responses' => $responses, + 'calls' => $calls, + ) + ); + + self::collect_failure( + $failures, + isset( $calls[0]['args'], $calls[1]['args'], $calls[2]['args'], $calls[3]['args'], $calls[4]['args'] ) + && 'GET' === ( $calls[0]['args']['method'] ?? null ) + && 'HEAD' === ( $calls[1]['args']['method'] ?? null ) + && 60 === ( $calls[0]['args']['timeout'] ?? null ) + && 60 === ( $calls[1]['args']['timeout'] ?? null ) + && true === ( $calls[0]['args']['reject_unsafe_urls'] ?? null ) + && true === ( $calls[1]['args']['reject_unsafe_urls'] ?? null ) + && 0 === ( $calls[1]['args']['redirection'] ?? null ) + && array() === ( $calls[0]['args']['headers'] ?? null ) + && $auth_header === ( $calls[2]['args']['headers']['Authorization'] ?? null ) + && ! isset( $calls[3]['args']['headers']['Authorization'] ) + && ! isset( $calls[4]['args']['headers']['Authorization'] ), + 'WP_Importer::get_page composes method, timeout, safe URL, HEAD redirection, and Basic Auth wrapper args', + array( + 'calls' => $calls, + 'authHeader' => $auth_header, + ) + ); + + self::collect_failure( + $failures, + false === \has_filter( 'pre_http_request', $http_filter, 10 ) + && false === \has_filter( 'http_request_timeout', array( $importer, 'bump_request_timeout' ) ), + 'WP_Importer::get_page HTTP wrapper test removes pre_http_request and importer timeout filters', + array( + 'preHttp' => \has_filter( 'pre_http_request', $http_filter, 10 ), + 'timeout' => \has_filter( 'http_request_timeout', array( $importer, 'bump_request_timeout' ) ), + ) + ); + + return self::result( $ctx, 'import-diff.importer.get-page-http-wrapper', $failures ); + } + + private static function check_importer_base_helpers( \ComponentFuzz\FuzzContext $ctx ): array { + global $wpdb, $wp_actions; + + $failures = array(); + $importer = new \WP_Importer(); + $short = 'a' . $ctx->identifier( 2, 3 ); + $medium = 'mid-' . $ctx->identifier( 4, 7 ); + $long = 'long-' . $ctx->identifier( 8, 14 ); + $unicode = "gr\xC3\xA5-" . $ctx->identifier( 2, 5 ); + $space_token = $ctx->identifier( 3, 6 ); + $spaces = "alpha\t" . $space_token . "\n\n beta \r gamma"; + $sorted = array( $medium, $long, $short, $unicode ); + $expected_order = $sorted; + usort( + $expected_order, + static function ( string $a, string $b ): int { + return strlen( $b ) - strlen( $a ); + } + ); + + usort( $sorted, array( $importer, 'cmpr_strlen' ) ); + + self::collect_failure( + $failures, + $expected_order === $sorted + && 0 === $importer->cmpr_strlen( 'aa', 'bb' ) + && $importer->cmpr_strlen( $short, $long ) > 0 + && $importer->cmpr_strlen( $long, $short ) < 0, + 'WP_Importer::cmpr_strlen orders generated strings by descending byte length and treats equal byte lengths as equal', + array( + 'expected' => $expected_order, + 'actual' => $sorted, + 'lengths' => array_map( 'strlen', $sorted ), + ) + ); + + self::collect_failure( + $failures, + 'alpha ' . $space_token . ' beta gamma' === $importer->min_whitespace( $spaces ), + 'WP_Importer::min_whitespace collapses generated tabs, newlines, carriage returns, and repeated spaces', + array( + 'input' => self::preview( $spaces ), + 'output' => self::preview( (string) $importer->min_whitespace( $spaces ) ), + ) + ); + + $timeout_inputs = array( $ctx->int( 1, 30 ), 60, $ctx->int( 61, 120 ) ); + $timeout_results = array(); + foreach ( $timeout_inputs as $timeout_input ) { + $timeout_results[] = $importer->bump_request_timeout( $timeout_input ); + } + + self::collect_failure( + $failures, + array( 60, 60, 60 ) === $timeout_results, + 'WP_Importer::bump_request_timeout always returns the importer timeout for below, equal, and above-threshold inputs', + array( + 'inputs' => $timeout_inputs, + 'results' => $timeout_results, + ) + ); + + $quota_default = $importer->is_user_over_quota(); + $quota_output = ''; + $quota_true = null; + $option_filter = static function ( $pre, string $option ) { + if ( 'blog_upload_space' === $option ) { + return 1; + } + if ( 'upload_space_check_disabled' === $option ) { + return 0; + } + + return $pre; + }; + $allowed_filter = static function () { + return 1; + }; + $used_filter = static function () { + return 2; + }; + + \add_filter( 'pre_option_blog_upload_space', $allowed_filter ); + \add_filter( 'pre_site_option', $option_filter, 10, 2 ); + \add_filter( 'pre_get_space_used', $used_filter ); + $buffer_level = ob_get_level(); + try { + ob_start(); + $quota_true = $importer->is_user_over_quota(); + $quota_output = (string) ob_get_clean(); + } finally { + while ( ob_get_level() > $buffer_level ) { + ob_end_clean(); + } + \remove_filter( 'pre_option_blog_upload_space', $allowed_filter ); + \remove_filter( 'pre_site_option', $option_filter, 10 ); + \remove_filter( 'pre_get_space_used', $used_filter ); + } + + self::collect_failure( + $failures, + false === $quota_default + && true === $quota_true + && '' !== $quota_output + && false === \has_filter( 'pre_option_blog_upload_space', $allowed_filter ) + && false === \has_filter( 'pre_site_option', $option_filter ) + && false === \has_filter( 'pre_get_space_used', $used_filter ), + 'WP_Importer::is_user_over_quota delegates to upload quota helpers for both default false and filtered true branches', + array( + 'default' => $quota_default, + 'true' => $quota_true, + 'output' => self::preview( $quota_output ), + ) + ); + + $previous_wpdb = $wpdb ?? null; + $had_wpdb = isset( $wpdb ); + $wpdb = new class() { + public $queries = array(); + }; + $wpdb->queries = array( + array( 'SELECT component fuzz', 0.1, 'component-fuzz' ), + ); + $wp_actions = array( + 'component_fuzz_import_action' => $ctx->int( 1, 5 ), + ); + + try { + $importer->stop_the_insanity(); + + self::collect_failure( + $failures, + array() === $wpdb->queries + && array() === $wp_actions, + 'WP_Importer::stop_the_insanity clears accumulated query logs and action counters', + array( + 'queries' => $wpdb->queries, + 'wpActions' => $wp_actions, + ) + ); + } finally { + if ( $had_wpdb ) { + $wpdb = $previous_wpdb; + } else { + unset( $GLOBALS['wpdb'] ); + } + } + + self::collect_failure( + $failures, + $had_wpdb ? $wpdb === $previous_wpdb : ! isset( $wpdb ), + 'temporary WPDB stand-in is restored before importer helper case returns', + array( + 'hadWpdb' => $had_wpdb, + 'restored' => $had_wpdb ? $wpdb === $previous_wpdb : ! isset( $wpdb ), + ) + ); + + return self::result( $ctx, 'import-diff.importer.base-helper-contracts', $failures ); + } + + private static function insert_imported_post_meta( string $importer_name, string $blog_id, string $permalink, string $label ): int { + global $wpdb; + + $wpdb->insert( + $wpdb->posts, + array( + 'post_author' => 0, + 'post_date' => '2026-06-24 00:00:00', + 'post_date_gmt' => '2026-06-24 00:00:00', + 'post_content' => 'Imported post ' . $label, + 'post_title' => 'Imported ' . $label, + 'post_excerpt' => '', + 'post_status' => 'publish', + 'comment_status' => 'closed', + 'ping_status' => 'closed', + 'post_password' => '', + 'post_name' => 'imported-' . $label, + 'to_ping' => '', + 'pinged' => '', + 'post_modified' => '2026-06-24 00:00:00', + 'post_modified_gmt' => '2026-06-24 00:00:00', + 'post_content_filtered' => '', + 'post_parent' => 0, + 'guid' => $permalink, + 'menu_order' => 0, + 'post_type' => 'post', + 'post_mime_type' => '', + 'comment_count' => 0, + ) + ); + $post_id = (int) $wpdb->insert_id; + + $wpdb->insert( + $wpdb->postmeta, + array( + 'post_id' => $post_id, + 'meta_key' => $importer_name . '_' . $blog_id . '_permalink', + 'meta_value' => $permalink, + ) + ); + + return $post_id; + } + + private static function insert_import_attachment( string $file, string $token ): int { + global $wpdb; + + $url = 'https://example.test/imports/' . rawurlencode( basename( $file ) ); + $wpdb->insert( + $wpdb->posts, + array( + 'post_author' => 0, + 'post_date' => '2026-06-30 12:00:00', + 'post_date_gmt' => '2026-06-30 12:00:00', + 'post_content' => $url, + 'post_title' => basename( $file ), + 'post_excerpt' => '', + 'post_status' => 'private', + 'comment_status' => 'closed', + 'ping_status' => 'closed', + 'post_password' => '', + 'post_name' => 'component-import-cleanup-' . $token, + 'to_ping' => '', + 'pinged' => '', + 'post_modified' => '2026-06-30 12:00:00', + 'post_modified_gmt' => '2026-06-30 12:00:00', + 'post_content_filtered' => '', + 'post_parent' => 0, + 'guid' => $url, + 'menu_order' => 0, + 'post_type' => 'attachment', + 'post_mime_type' => 'text/plain', + 'comment_count' => 0, + ) + ); + $post_id = (int) $wpdb->insert_id; + + $wpdb->insert( + $wpdb->postmeta, + array( + 'post_id' => $post_id, + 'meta_key' => '_wp_attached_file', + 'meta_value' => $file, + ) + ); + + return $post_id; + } + + private static function insert_comment_agent( string $comment_agent, string $label ): int { + global $wpdb; + + $wpdb->insert( + $wpdb->comments, + array( + 'comment_post_ID' => 0, + 'comment_author' => 'Component Fuzz ' . $label, + 'comment_author_email' => 'comment-' . $label . '@example.test', + 'comment_author_url' => '', + 'comment_author_IP' => '192.0.2.55', + 'comment_date' => '2026-06-23 00:00:00', + 'comment_date_gmt' => '2026-06-23 00:00:00', + 'comment_content' => 'Imported comment ' . $label, + 'comment_karma' => 0, + 'comment_approved' => '1', + 'comment_agent' => $comment_agent, + 'comment_type' => 'comment', + 'comment_parent' => 0, + 'user_id' => 0, + ) + ); + + return (int) $wpdb->insert_id; + } + + private static function importer_entries_match( array $expected, $actual ): bool { + if ( ! is_array( $actual ) ) { + return false; + } + + foreach ( $expected as $id => $entry ) { + if ( ! isset( $actual[ $id ] ) || $entry !== $actual[ $id ] ) { + return false; + } + } + + return true; + } + + private static function reset_runtime(): void { + $GLOBALS['wp_importers'] = array(); + if ( isset( $GLOBALS['wpdb'] ) && method_exists( $GLOBALS['wpdb'], 'component_fuzz_reset_content' ) ) { + $GLOBALS['wpdb']->component_fuzz_reset_content(); + } + } + + private static function snapshot_state(): array { + $globals = array(); + foreach ( + array( + 'wp_actions', + 'wp_current_filter', + 'wp_filter', + 'wp_filters', + 'wp_importers', + 'wp_object_cache', + ) as $name + ) { + $globals[ $name ] = array( + 'exists' => array_key_exists( $name, $GLOBALS ), + 'value' => array_key_exists( $name, $GLOBALS ) ? self::clone_value( $GLOBALS[ $name ] ) : null, + ); + } + + return array( + 'globals' => $globals, + ); + } + + private static function restore_state( array $snapshot ): void { + foreach ( $snapshot['globals'] as $name => $entry ) { + if ( $entry['exists'] ) { + $GLOBALS[ $name ] = $entry['value']; + } else { + unset( $GLOBALS[ $name ] ); + } + } + if ( isset( $GLOBALS['wpdb'] ) && method_exists( $GLOBALS['wpdb'], 'component_fuzz_reset_content' ) ) { + $GLOBALS['wpdb']->component_fuzz_reset_content(); + } + } + + private static function state_matches( array $snapshot ): bool { + foreach ( $snapshot['globals'] as $name => $entry ) { + if ( $entry['exists'] !== array_key_exists( $name, $GLOBALS ) ) { + return false; + } + } + + return true; + } + + private static function result( \ComponentFuzz\FuzzContext $ctx, string $invariant, array $failures ): array { + return $ctx->result( + $invariant, + array() === $failures, + array( 'failures' => array_slice( $failures, 0, 8 ) ) + ); + } + + private static function collect_failure( array &$failures, bool $ok, string $message, array $details = array() ): void { + if ( ! $ok ) { + $failures[] = array( + 'message' => $message, + 'details' => $details, + ); + } + } + + private static function describe_error( \WP_Error $error ): array { + $out = array(); + foreach ( $error->get_error_codes() as $code ) { + $out[ $code ] = array( + 'messages' => $error->get_error_messages( $code ), + 'data' => $error->get_all_error_data( $code ), + ); + } + return $out; + } + + private static function describe_value( $value ) { + if ( $value instanceof \WP_Error ) { + return self::describe_error( $value ); + } + if ( is_array( $value ) ) { + $out = array(); + foreach ( $value as $key => $item ) { + $out[ $key ] = self::describe_value( $item ); + } + return $out; + } + if ( is_object( $value ) ) { + return '[object ' . get_class( $value ) . ']'; + } + return $value; + } + + private static function preview( string $value ): array { + return array( + 'bytes' => strlen( $value ), + 'preview' => \ComponentFuzz\preview_value( $value, self::PREVIEW_BYTES ), + ); + } + + private static function describe_throwable( \Throwable $e ): array { + return array( + 'class' => get_class( $e ), + 'message' => $e->getMessage(), + 'file' => $e->getFile(), + 'line' => $e->getLine(), + ); + } + + private static function clone_value( $value ) { + if ( is_object( $value ) ) { + return clone $value; + } + if ( is_array( $value ) ) { + $copy = array(); + foreach ( $value as $key => $item ) { + $copy[ $key ] = self::clone_value( $item ); + } + return $copy; + } + return $value; + } + + private static function delete_tree( string $path ): void { + if ( is_file( $path ) || is_link( $path ) ) { + @unlink( $path ); + return; + } + + if ( ! is_dir( $path ) ) { + return; + } + + $items = scandir( $path ); + if ( false !== $items ) { + foreach ( $items as $item ) { + if ( '.' === $item || '..' === $item ) { + continue; + } + self::delete_tree( $path . DIRECTORY_SEPARATOR . $item ); + } + } + + @rmdir( $path ); + } +} diff --git a/tools/component-fuzz/surfaces/InstallSchemaSurface.php b/tools/component-fuzz/surfaces/InstallSchemaSurface.php new file mode 100644 index 0000000000000..efd55c6e509db --- /dev/null +++ b/tools/component-fuzz/surfaces/InstallSchemaSurface.php @@ -0,0 +1,1859 @@ +skip( + 'install-schema.bootstrap-apis-available', + 'Required WordPress install/schema APIs are unavailable.', + array( 'missing' => implode( ', ', $missing ) ) + ), + ); + } + + $snapshot = self::snapshot_state(); + $rows = array(); + + try { + $rows[] = self::check_wp_get_db_schema_table_sets( $ctx ); + $rows[] = self::check_global_table_upgrade_gate( $ctx ); + $rows[] = self::check_make_db_current_scope_wrappers( $ctx ); + $rows[] = self::check_create_table_parser_variants( $ctx ); + $rows[] = self::check_dbdelta_equivalent_noops( $ctx ); + $rows[] = self::check_dbdelta_missing_table_creation_replay( $ctx ); + $rows[] = self::check_dbdelta_column_change_isolation( $ctx ); + $rows[] = self::check_dbdelta_index_normalization( $ctx ); + $rows[] = self::check_sql_allowlist_on_executed_diffs( $ctx ); + $rows[] = self::check_malformed_ddl_fails_closed( $ctx ); + } catch ( \Throwable $e ) { + $rows[] = $ctx->fail( + 'install-schema.surface-no-throw', + array( 'throwable' => self::describe_throwable( $e ) ) + ); + } finally { + self::restore_state( $snapshot ); + } + + $rows[] = $ctx->result( + 'install-schema.global-state-restored', + self::state_matches( $snapshot ), + array( 'trackedGlobals' => array_keys( $snapshot ) ) + ); + + return $rows; + } + + private static function ensure_helpers_loaded(): void { + if ( function_exists( 'dbDelta' ) && function_exists( 'wp_get_db_schema' ) ) { + return; + } + + $previous_exists = array_key_exists( 'wpdb', $GLOBALS ); + $previous_wpdb = $GLOBALS['wpdb'] ?? null; + $GLOBALS['wpdb'] = new InstallSchemaWpdbDouble( 'wp_' ); + + try { + if ( ! function_exists( 'wp_get_db_schema' ) ) { + require_once ABSPATH . 'wp-admin/includes/schema.php'; + } + if ( ! function_exists( 'dbDelta' ) ) { + require_once ABSPATH . 'wp-admin/includes/upgrade.php'; + } + } finally { + if ( $previous_exists ) { + $GLOBALS['wpdb'] = $previous_wpdb; + } else { + unset( $GLOBALS['wpdb'] ); + } + } + } + + private static function missing_requirements(): array { + $missing = array(); + foreach ( + array( + 'add_filter', + 'apply_filters', + 'dbDelta', + 'has_filter', + 'is_main_network', + 'is_main_site', + 'is_multisite', + 'make_db_current', + 'make_db_current_silent', + 'remove_filter', + 'wp_get_db_schema', + 'wp_should_upgrade_global_tables', + ) as $function + ) { + if ( ! function_exists( $function ) ) { + $missing[] = "function {$function}"; + } + } + + return $missing; + } + + private static function check_wp_get_db_schema_table_sets( \ComponentFuzz\FuzzContext $ctx ): array { + $prefix = self::prefix_for_context( $ctx->fork( 'schema-prefix' ) ); + $wpdb = new InstallSchemaWpdbDouble( $prefix ); + + $result = self::with_wpdb( + $wpdb, + static function () use ( $wpdb, $prefix ): array { + $blog_sql = wp_get_db_schema( 'blog' ); + $global_sql = wp_get_db_schema( 'global' ); + $all_sql = wp_get_db_schema( 'all' ); + $ms_global_sql = wp_get_db_schema( 'ms_global' ); + $blog_for_other_site = wp_get_db_schema( 'blog', 7 ); + $blog_tables = self::create_table_names( $blog_sql ); + $global_tables = self::create_table_names( $global_sql ); + $all_tables = self::create_table_names( $all_sql ); + $ms_global_tables = self::create_table_names( $ms_global_sql ); + $other_site_tables = self::create_table_names( $blog_for_other_site ); + $expected_blog = array_values( $wpdb->tables( 'blog' ) ); + $expected_global = array_values( $wpdb->tables( 'global' ) ); + $expected_all = array_values( $wpdb->tables( 'all' ) ); + $expected_ms_global = array_values( $wpdb->tables( 'ms_global' ) ); + $blog_id_was_restored = 1 === (int) $wpdb->blogid && $prefix === $wpdb->prefix; + + sort( $blog_tables ); + sort( $global_tables ); + sort( $all_tables ); + sort( $ms_global_tables ); + sort( $other_site_tables ); + sort( $expected_blog ); + sort( $expected_global ); + sort( $expected_all ); + sort( $expected_ms_global ); + + $global_contains_ms = array_intersect( $expected_ms_global, $global_tables ); + $all_contains_ms = array_intersect( $expected_ms_global, $all_tables ); + $expected_ms_in_all = is_multisite(); + + $ok = $expected_blog === $blog_tables + && $expected_global === $global_tables + && $expected_all === $all_tables + && $expected_ms_global === $ms_global_tables + && $blog_tables === $other_site_tables + && $blog_id_was_restored + && self::all_tables_have_allowed_prefix( $all_tables, array( $prefix ) ) + && self::all_tables_have_allowed_prefix( $ms_global_tables, array( $prefix ) ) + && ( $expected_ms_in_all || array() === $global_contains_ms ) + && ( $expected_ms_in_all || array() === $all_contains_ms ); + + return array( + 'ok' => $ok, + 'blogTables' => $blog_tables, + 'expectedBlogTables' => $expected_blog, + 'globalTables' => $global_tables, + 'expectedGlobalTables' => $expected_global, + 'allTables' => $all_tables, + 'expectedAllTables' => $expected_all, + 'msGlobalTables' => $ms_global_tables, + 'expectedMsGlobal' => $expected_ms_global, + 'otherSiteTables' => $other_site_tables, + 'blogIdRestored' => $blog_id_was_restored, + 'prefix' => $prefix, + 'isMultisite' => is_multisite(), + ); + } + ); + + return $ctx->result( + 'install-schema.wp-get-db-schema-table-sets-and-prefixes', + $result['ok'], + $result + ); + } + + private static function check_global_table_upgrade_gate( \ComponentFuzz\FuzzContext $ctx ): array { + if ( defined( 'DO_NOT_UPGRADE_GLOBAL_TABLES' ) ) { + return $ctx->skip( + 'install-schema.global-table-upgrade-gate-filter', + 'DO_NOT_UPGRADE_GLOBAL_TABLES is defined for this process.' + ); + } + + $baseline = wp_should_upgrade_global_tables(); + $seen = array(); + $deny = static function ( bool $should_upgrade ) use ( &$seen ): bool { + $seen[] = array( + 'filter' => 'deny', + 'input' => $should_upgrade, + ); + return false; + }; + $allow = static function ( bool $should_upgrade ) use ( &$seen ): bool { + $seen[] = array( + 'filter' => 'allow', + 'input' => $should_upgrade, + ); + return true; + }; + + \add_filter( 'wp_should_upgrade_global_tables', $deny ); + try { + $denied = wp_should_upgrade_global_tables(); + } finally { + \remove_filter( 'wp_should_upgrade_global_tables', $deny ); + } + + \add_filter( 'wp_should_upgrade_global_tables', $allow ); + try { + $allowed = wp_should_upgrade_global_tables(); + } finally { + \remove_filter( 'wp_should_upgrade_global_tables', $allow ); + } + + $after = wp_should_upgrade_global_tables(); + $ok = is_bool( $baseline ) + && false === $denied + && true === $allowed + && $baseline === $after + && array( + array( + 'filter' => 'deny', + 'input' => $baseline, + ), + array( + 'filter' => 'allow', + 'input' => $baseline, + ), + ) === $seen + && false === \has_filter( 'wp_should_upgrade_global_tables', $deny ) + && false === \has_filter( 'wp_should_upgrade_global_tables', $allow ); + + return $ctx->result( + 'install-schema.global-table-upgrade-gate-filter', + $ok, + array( + 'baseline' => $baseline, + 'denied' => $denied, + 'allowed' => $allowed, + 'after' => $after, + 'seen' => $seen, + 'denyHasFilter' => \has_filter( 'wp_should_upgrade_global_tables', $deny ), + 'allowHasFilter' => \has_filter( 'wp_should_upgrade_global_tables', $allow ), + ) + ); + } + + private static function check_make_db_current_scope_wrappers( \ComponentFuzz\FuzzContext $ctx ): array { + $prefix = self::prefix_for_context( $ctx->fork( 'make-db-current-prefix' ) ); + $wpdb = new InstallSchemaWpdbDouble( $prefix ); + $scopes = array_values( + array_unique( + array( + 'blog', + 'global', + 'ms_global', + 'all', + $ctx->choice( array( 'blog', 'global', 'ms_global', 'all' ) ), + ) + ) + ); + + $result = self::with_wpdb( + $wpdb, + static function () use ( $scopes ): array { + $observed = array(); + $failures = array(); + + foreach ( $scopes as $scope ) { + $expected_tables = self::create_table_names( wp_get_db_schema( $scope ) ); + sort( $expected_tables ); + + $silent = self::capture_make_db_current_call( + static function () use ( $scope ): void { + make_db_current_silent( $scope ); + } + ); + + $noisy = self::capture_make_db_current_call( + static function () use ( $scope ): void { + make_db_current( $scope ); + } + ); + + $silent_tables = $silent['tables']; + $noisy_tables = $noisy['tables']; + sort( $silent_tables ); + sort( $noisy_tables ); + + $observed[ $scope ] = array( + 'expectedTables' => $expected_tables, + 'silentTables' => $silent_tables, + 'noisyTables' => $noisy_tables, + 'silentOutput' => $silent['output'], + 'noisyOutput' => $noisy['output'], + 'silentCalls' => $silent['calls'], + 'noisyCalls' => $noisy['calls'], + ); + + if ( $expected_tables !== $silent_tables || $expected_tables !== $noisy_tables ) { + $failures[] = array( + 'scope' => $scope, + 'reason' => 'schema-scope-table-set-mismatch', + 'expected' => $expected_tables, + 'silent' => $silent_tables, + 'noisy' => $noisy_tables, + ); + continue; + } + + if ( 1 !== $silent['calls'] || 1 !== $noisy['calls'] ) { + $failures[] = array( + 'scope' => $scope, + 'reason' => 'dbdelta-filter-call-count-mismatch', + 'silentCalls' => $silent['calls'], + 'noisyCalls' => $noisy['calls'], + ); + continue; + } + + if ( '' !== $silent['output'] || "
      \n
    \n" !== $noisy['output'] ) { + $failures[] = array( + 'scope' => $scope, + 'reason' => 'wrapper-output-mismatch', + 'silentOutput' => $silent['output'], + 'noisyOutput' => $noisy['output'], + ); + } + } + + return array( + 'ok' => array() === $failures, + 'observed' => $observed, + 'failures' => array_slice( $failures, 0, self::MAX_FAILURES ), + ); + } + ); + + return $ctx->result( + 'install-schema.make-db-current-wrappers-preserve-schema-scopes', + $result['ok'], + $result + ); + } + + private static function check_create_table_parser_variants( \ComponentFuzz\FuzzContext $ctx ): array { + $spec = self::schema_spec( $ctx->fork( 'parser-variants' ) ); + $variants = array( + 'canonical' => self::ddl_from_spec( $spec, 'canonical' ), + 'backticks' => self::ddl_from_spec( $spec, 'backticks' ), + 'lowercase' => self::ddl_from_spec( $spec, 'lowercase' ), + 'index-synonyms' => self::ddl_from_spec( $spec, 'index-synonyms' ), + 'wide-spacing' => self::ddl_from_spec( $spec, 'wide-spacing' ), + ); + + $failures = array(); + $signature = null; + $tables = array(); + + foreach ( $variants as $label => $ddl ) { + $parsed = self::parse_create_tables( $ddl ); + $sig = self::schema_signature( $parsed ); + $names = array_keys( $parsed['tables'] ); + $tables[ $label ] = $names; + + if ( ! $parsed['ok'] ) { + $failures[] = array( + 'case' => $label, + 'reason' => 'parse-failed', + 'errors' => $parsed['errors'], + ); + continue; + } + + if ( null === $signature ) { + $signature = $sig; + } elseif ( $signature !== $sig ) { + $failures[] = array( + 'case' => $label, + 'reason' => 'signature-mismatch', + 'expected' => $signature, + 'actual' => $sig, + ); + } + + if ( ! self::all_tables_have_allowed_prefix( $names, array( 'wp_' ) ) ) { + $failures[] = array( + 'case' => $label, + 'reason' => 'table-prefix-escaped-allowlist', + 'tables' => $names, + ); + } + } + + return $ctx->result( + 'install-schema.create-table-ddl-parses-deterministically-across-formatting', + array() === $failures, + array( + 'table' => $spec['table'], + 'variants' => array_keys( $variants ), + 'tables' => $tables, + 'failures' => array_slice( $failures, 0, self::MAX_FAILURES ), + ) + ); + } + + private static function check_dbdelta_equivalent_noops( \ComponentFuzz\FuzzContext $ctx ): array { + $spec = self::schema_spec( $ctx->fork( 'dbdelta-noops' ) ); + $existing = self::parse_create_tables( self::ddl_from_spec( $spec, 'canonical' ) ); + $variants = array( + 'canonical' => self::ddl_from_spec( $spec, 'canonical' ), + 'backticks' => self::ddl_from_spec( $spec, 'backticks' ), + 'index-synonyms' => self::ddl_from_spec( $spec, 'index-synonyms' ), + 'wide-spacing' => self::ddl_from_spec( $spec, 'wide-spacing' ), + ); + $failures = array(); + $results = array(); + + foreach ( $variants as $label => $ddl ) { + $wpdb = new InstallSchemaWpdbDouble( 'wp_', $existing['tables'] ); + $out = self::with_wpdb( + $wpdb, + static function () use ( $ddl ): array { + $first = dbDelta( $ddl, false ); + $second = dbDelta( $ddl, false ); + return array( $first, $second ); + } + ); + + $results[ $label ] = array( + 'first' => $out[0], + 'second' => $out[1], + 'introspectionSql' => $wpdb->introspection_log, + 'executedSql' => $wpdb->executed_queries, + 'violations' => $wpdb->violations, + ); + + if ( array() !== $out[0] || $out[0] !== $out[1] || array() !== $wpdb->executed_queries || array() !== $wpdb->violations ) { + $failures[] = array( + 'case' => $label, + 'first' => $out[0], + 'second' => $out[1], + 'executed' => $wpdb->executed_queries, + 'violations'=> $wpdb->violations, + ); + } + } + + return $ctx->result( + 'install-schema.dbdelta-equivalent-schemas-are-stable-noops', + array() === $failures, + array( + 'table' => $spec['table'], + 'cases' => array_keys( $variants ), + 'results' => $results, + 'failures' => array_slice( $failures, 0, self::MAX_FAILURES ), + ) + ); + } + + private static function check_dbdelta_missing_table_creation_replay( \ComponentFuzz\FuzzContext $ctx ): array { + $primary_spec = self::schema_spec( $ctx->fork( 'missing-table-create-primary' ) ); + $secondary_spec = self::schema_spec( $ctx->fork( 'missing-table-create-secondary' ) ); + $secondary_spec['table'] .= '_replay'; + $specs = array( $primary_spec, $secondary_spec ); + $styles = array( 'canonical', 'wide-spacing' ); + $expected_tables = array_column( $specs, 'table' ); + $expected_messages = array(); + $ddl_parts = array(); + + foreach ( $specs as $index => $spec ) { + $expected_messages[ $spec['table'] ] = 'Created table ' . $spec['table']; + $ddl_parts[] = self::ddl_from_spec( $spec, $styles[ $index ] ); + } + + $ddl = "\n" . implode( "\n", $ddl_parts ); + $parsed = self::parse_create_tables( $ddl ); + + $create_wpdb = new InstallSchemaWpdbDouble( 'wp_', array(), $expected_tables ); + $create_result = self::with_wpdb( + $create_wpdb, + static function () use ( $ddl ): array { + return dbDelta( $ddl, true ); + } + ); + + $create_executed_queries = $create_wpdb->executed_queries; + $create_introspection_queries = $create_wpdb->introspection_log; + $create_violations = $create_wpdb->violations; + $created_tables = self::created_tables_from_queries( $create_executed_queries ); + $executed_escaped = self::queries_escape_table_allowlist( $create_executed_queries, $expected_tables ); + $create_introspection_escaped = self::queries_escape_table_allowlist( $create_introspection_queries, $expected_tables ); + $applied_tables = $create_wpdb->component_fuzz_apply_created_tables( $parsed['tables'], $create_executed_queries ); + $replay_executed_offset = count( $create_executed_queries ); + $replay_introspection_offset = count( $create_introspection_queries ); + + $replay_results = self::with_wpdb( + $create_wpdb, + static function () use ( $ddl ): array { + return array( + dbDelta( $ddl, true ), + dbDelta( $ddl, true ), + ); + } + ); + $replay_executed = array_slice( $create_wpdb->executed_queries, $replay_executed_offset ); + $replay_introspection = array_slice( $create_wpdb->introspection_log, $replay_introspection_offset ); + $replay_introspection_escaped = self::queries_escape_table_allowlist( $replay_introspection, $expected_tables ); + + $failures = array(); + + if ( ! $parsed['ok'] || $expected_tables !== array_keys( $parsed['tables'] ) ) { + $failures[] = array( + 'reason' => 'generated-ddl-parse-mismatch', + 'expectedTables' => $expected_tables, + 'parsedTables' => array_keys( $parsed['tables'] ), + 'parseErrors' => $parsed['errors'], + ); + } + + if ( $expected_messages !== $create_result ) { + $failures[] = array( + 'reason' => 'created-table-messages-mismatch', + 'expected' => $expected_messages, + 'actual' => $create_result, + ); + } + + if ( $expected_tables !== $created_tables || count( $created_tables ) !== count( array_unique( $created_tables ) ) ) { + $failures[] = array( + 'reason' => 'executed-create-tables-not-unique-or-deterministic', + 'expected' => $expected_tables, + 'actual' => $created_tables, + 'queries' => $create_wpdb->executed_queries, + ); + } + + if ( count( $create_executed_queries ) !== count( $expected_tables ) || array() !== $executed_escaped || array() !== $create_introspection_escaped || array() !== $create_violations ) { + $failures[] = array( + 'reason' => 'create-queries-escaped-allowlist-or-non-create', + 'executed' => $create_executed_queries, + 'introspection' => $create_introspection_queries, + 'created' => $created_tables, + 'executedEscaped' => $executed_escaped, + 'introspectionEscaped' => $create_introspection_escaped, + 'violations' => $create_violations, + ); + } + + if ( $expected_tables !== $applied_tables ) { + $failures[] = array( + 'reason' => 'executed-create-tables-not-applied-to-replay-double', + 'expected' => $expected_tables, + 'applied' => $applied_tables, + ); + } + + if ( array( array(), array() ) !== $replay_results || array() !== $replay_executed || array() !== $replay_introspection_escaped || $create_violations !== $create_wpdb->violations ) { + $failures[] = array( + 'reason' => 'same-double-replay-not-stable-noop', + 'replayResults' => $replay_results, + 'replayExecuted' => $replay_executed, + 'replayIntrospection' => $replay_introspection, + 'introspectionEscaped' => $replay_introspection_escaped, + 'createViolations' => $create_violations, + 'allViolations' => $create_wpdb->violations, + ); + } + + return $ctx->result( + 'install-schema.dbdelta-missing-tables-create-and-replay-idempotently', + array() === $failures, + array( + 'tables' => $expected_tables, + 'styles' => $styles, + 'createdMessages' => $create_result, + 'createdTables' => $created_tables, + 'appliedTables' => $applied_tables, + 'createSql' => $create_executed_queries, + 'createIntrospectionSql' => $create_introspection_queries, + 'replayResults' => $replay_results, + 'replaySql' => $replay_executed, + 'replayIntrospectionSql' => $replay_introspection, + 'failures' => array_slice( $failures, 0, self::MAX_FAILURES ), + ) + ); + } + + private static function check_dbdelta_column_change_isolation( \ComponentFuzz\FuzzContext $ctx ): array { + $target_spec = self::schema_spec( $ctx->fork( 'column-target' ) ); + $target_ddl = self::ddl_from_spec( $target_spec, 'canonical' ); + $table = $target_spec['table']; + $cases = array( + 'type' => self::mutate_column( $target_spec, 'status', 'type', 'varchar(12)' ), + 'default' => self::mutate_column( $target_spec, 'status', 'default', 'archived' ), + 'null' => self::mutate_column( $target_spec, 'status', 'nullable', true ), + ); + $failures = array(); + $observed = array(); + + foreach ( $cases as $kind => $existing_spec ) { + $existing = self::parse_create_tables( self::ddl_from_spec( $existing_spec, 'canonical' ) ); + $target = self::parse_create_tables( $target_ddl ); + $diff = self::semantic_diff( $existing, $target, $table ); + $wpdb = new InstallSchemaWpdbDouble( 'wp_', $existing['tables'] ); + $dbdelta = self::with_wpdb( + $wpdb, + static function () use ( $target_ddl ): array { + return dbDelta( $target_ddl, false ); + } + ); + + $expected_diff = array( "column:{$table}.status:{$kind}" ); + $dbdelta_keys = array_keys( $dbdelta ); + $observed[ $kind ] = array( + 'semanticDiff' => $diff, + 'dbDelta' => $dbdelta, + 'queries' => $wpdb->executed_queries, + ); + + if ( $expected_diff !== $diff ) { + $failures[] = array( + 'case' => $kind, + 'reason' => 'semantic-diff-not-isolated', + 'expected' => $expected_diff, + 'actual' => $diff, + ); + continue; + } + + if ( 'null' === $kind ) { + if ( array() !== $dbdelta ) { + $failures[] = array( + 'case' => $kind, + 'reason' => 'dbdelta-emitted-null-only-change', + 'actual' => $dbdelta, + ); + } + continue; + } + + if ( array( "{$table}.status" ) !== $dbdelta_keys ) { + $failures[] = array( + 'case' => $kind, + 'reason' => 'dbdelta-change-not-isolated-to-status-column', + 'keys' => $dbdelta_keys, + 'dbDelta' => $dbdelta, + ); + } + } + + return $ctx->result( + 'install-schema.column-type-default-null-diffs-are-isolated', + array() === $failures, + array( + 'table' => $table, + 'observed' => $observed, + 'failures' => array_slice( $failures, 0, self::MAX_FAILURES ), + ) + ); + } + + private static function check_dbdelta_index_normalization( \ComponentFuzz\FuzzContext $ctx ): array { + $target_spec = self::schema_spec( $ctx->fork( 'index-normalization' ) ); + $target_ddl = self::ddl_from_spec( $target_spec, 'canonical' ); + $target_parsed = self::parse_create_tables( $target_ddl ); + $table = $target_spec['table']; + $subpart_existing = self::mutate_index_subparts( $target_spec, null ); + $subpart_parsed = self::parse_create_tables( self::ddl_from_spec( $subpart_existing, 'canonical' ) ); + $columns_only_spec = self::without_indexes( $target_spec ); + $columns_parsed = self::parse_create_tables( self::ddl_from_spec( $columns_only_spec, 'canonical' ) ); + $failures = array(); + + $subpart_wpdb = new InstallSchemaWpdbDouble( 'wp_', $subpart_parsed['tables'] ); + $subpart_diff = self::with_wpdb( + $subpart_wpdb, + static function () use ( $target_ddl ): array { + return dbDelta( $target_ddl, false ); + } + ); + + if ( array() !== $subpart_diff || array() !== $subpart_wpdb->executed_queries ) { + $failures[] = array( + 'case' => 'subparts', + 'reason' => 'equivalent-index-subparts-produced-change', + 'dbDelta' => $subpart_diff, + 'executed' => $subpart_wpdb->executed_queries, + ); + } + + $missing_wpdb = new InstallSchemaWpdbDouble( 'wp_', $columns_parsed['tables'], array( $table ) ); + $missing_result = self::with_wpdb( + $missing_wpdb, + static function () use ( $target_ddl ): array { + return dbDelta( $target_ddl, true ); + } + ); + $added_indexes = array_values( + array_filter( + $missing_result, + static function ( string $message ): bool { + return str_starts_with( $message, 'Added index ' ); + } + ) + ); + $unique_added = array_values( array_unique( $added_indexes ) ); + $expected_count = count( $target_parsed['tables'][ $table ]['indexes'] ); + + if ( $expected_count !== count( $added_indexes ) || $added_indexes !== $unique_added || array() !== $missing_wpdb->violations ) { + $failures[] = array( + 'case' => 'missing-indexes', + 'reason' => 'index-additions-not-unique-or-not-allowlisted', + 'expectedCount' => $expected_count, + 'added' => $added_indexes, + 'executed' => $missing_wpdb->executed_queries, + 'violations' => $missing_wpdb->violations, + ); + } + + return $ctx->result( + 'install-schema.dbdelta-indexes-and-subparts-normalize-without-duplicates', + array() === $failures, + array( + 'table' => $table, + 'subpartDiff' => $subpart_diff, + 'addedIndexMessages' => $added_indexes, + 'executedSql' => $missing_wpdb->executed_queries, + 'failures' => array_slice( $failures, 0, self::MAX_FAILURES ), + ) + ); + } + + private static function check_sql_allowlist_on_executed_diffs( \ComponentFuzz\FuzzContext $ctx ): array { + $target_spec = self::schema_spec( $ctx->fork( 'allowlist-target' ) ); + $existing_spec = self::without_indexes( self::remove_column( $target_spec, 'rating' ) ); + $target_ddl = self::ddl_from_spec( $target_spec, 'canonical' ); + $existing = self::parse_create_tables( self::ddl_from_spec( $existing_spec, 'canonical' ) ); + $table = $target_spec['table']; + $wpdb = new InstallSchemaWpdbDouble( 'wp_', $existing['tables'], array( $table ) ); + $result = self::with_wpdb( + $wpdb, + static function () use ( $target_ddl ): array { + return dbDelta( $target_ddl, true ); + } + ); + $escaped = self::queries_escape_table_allowlist( $wpdb->executed_queries, array( $table ) ); + $ok = array() === $escaped + && array() === $wpdb->violations + && isset( $result[ "{$table}.rating" ] ) + && str_starts_with( $result[ "{$table}.rating" ], 'Added column ' ); + + return $ctx->result( + 'install-schema.generated-sql-stays-inside-table-allowlist', + $ok, + array( + 'table' => $table, + 'dbDelta' => $result, + 'executed' => $wpdb->executed_queries, + 'escaped' => $escaped, + 'violations' => $wpdb->violations, + ) + ); + } + + private static function check_malformed_ddl_fails_closed( \ComponentFuzz\FuzzContext $ctx ): array { + $spec = self::schema_spec( $ctx->fork( 'malformed' ) ); + $table = $spec['table']; + $malformed = array( + "CREATE\tTABLE {$table} (id int(11) NOT NULL)", + "CREATE\nTABLE {$table} (id int(11) NOT NULL)", + "CREATE TABLE {$table} (id int(11) NOT NULL)", + "ALTER TABLE {$table} ADD COLUMN escaped int(11)", + "garbage {$table} CREATE TABLE", + ); + $parser_bad = array( + "CREATE TABLE {$table} id int(11) NOT NULL)", + "CREATE TABLE {$table} (id int(11) NOT NULL", + 'CREATE TABLE ../escape (id int(11) NOT NULL)', + ); + $warnings = array(); + $throwable = null; + $wpdb = new InstallSchemaWpdbDouble( 'wp_', array(), array( $table ) ); + $dbdelta = array(); + + $previous_handler = set_error_handler( + static function ( int $severity, string $message, string $file, int $line ) use ( &$warnings ): bool { + $warnings[] = compact( 'severity', 'message', 'file', 'line' ); + return true; + } + ); + unset( $previous_handler ); + + try { + $dbdelta = self::with_wpdb( + $wpdb, + static function () use ( $malformed ): array { + return dbDelta( $malformed, true ); + } + ); + } catch ( \Throwable $e ) { + $throwable = $e; + } finally { + restore_error_handler(); + } + + $parser_results = array(); + foreach ( $parser_bad as $case ) { + $parsed = self::parse_create_tables( $case ); + $parser_results[] = array( + 'ok' => $parsed['ok'], + 'errors' => $parsed['errors'], + ); + } + + $all_parser_cases_failed_closed = array() === array_filter( + $parser_results, + static function ( array $result ): bool { + return $result['ok']; + } + ); + + $ok = null === $throwable + && array() === $warnings + && array() === $dbdelta + && array() === $wpdb->executed_queries + && array() === $wpdb->violations + && $all_parser_cases_failed_closed; + + return $ctx->result( + 'install-schema.malformed-ddl-fails-closed-without-warnings', + $ok, + array( + 'table' => $table, + 'dbDelta' => $dbdelta, + 'executed' => $wpdb->executed_queries, + 'warnings' => $warnings, + 'throwable' => null === $throwable ? null : self::describe_throwable( $throwable ), + 'parserResults' => $parser_results, + ) + ); + } + + private static function schema_spec( \ComponentFuzz\FuzzContext $ctx ): array { + $suffix = strtolower( base_convert( $ctx->int( 100000, 999999 ), 10, 36 ) ); + $table = 'wp_cf_install_' . $ctx->iteration() . '_' . $suffix; + $status = $ctx->choice( array( 'draft', 'open', 'queued' ) ); + + return array( + 'table' => $table, + 'columns' => array( + 'id' => array( + 'type' => 'bigint(20) unsigned', + 'nullable' => false, + 'default' => null, + 'extra' => 'auto_increment', + ), + 'slug' => array( + 'type' => 'varchar(' . $ctx->int( 48, 96 ) . ')', + 'nullable' => false, + 'default' => '', + 'extra' => '', + ), + 'status' => array( + 'type' => 'varchar(20)', + 'nullable' => false, + 'default' => $status, + 'extra' => '', + ), + 'rating' => array( + 'type' => 'int(11)', + 'nullable' => false, + 'default' => '0', + 'extra' => '', + ), + 'payload' => array( + 'type' => 'longtext', + 'nullable' => false, + 'default' => null, + 'extra' => '', + ), + 'created_at' => array( + 'type' => 'datetime', + 'nullable' => false, + 'default' => '0000-00-00 00:00:00', + 'extra' => '', + ), + ), + 'indexes' => array( + array( + 'type' => 'PRIMARY KEY', + 'name' => '', + 'columns' => array( + array( 'name' => 'id', 'subpart' => null ), + ), + ), + array( + 'type' => 'UNIQUE KEY', + 'name' => 'slug_key', + 'columns' => array( + array( 'name' => 'slug', 'subpart' => null ), + ), + ), + array( + 'type' => 'KEY', + 'name' => 'status_created', + 'columns' => array( + array( 'name' => 'status', 'subpart' => null ), + array( 'name' => 'created_at', 'subpart' => null ), + ), + ), + array( + 'type' => 'KEY', + 'name' => 'payload_prefix', + 'columns' => array( + array( 'name' => 'payload', 'subpart' => 32 ), + ), + ), + ), + ); + } + + private static function ddl_from_spec( array $spec, string $style ): string { + $table = $spec['table']; + if ( 'backticks' === $style ) { + $table = '`' . $table . '`'; + } + + $lines = array(); + foreach ( $spec['columns'] as $name => $column ) { + $lines[] = self::column_ddl( $name, $column, $style ); + } + foreach ( $spec['indexes'] as $index ) { + $lines[] = self::index_ddl( $index, $style ); + } + + $create = 'lowercase' === $style ? 'create table' : 'CREATE TABLE'; + if ( 'wide-spacing' === $style ) { + return $create . ' ' . $table . " (\n " . implode( " ,\n ", $lines ) . "\n) DEFAULT CHARACTER SET utf8mb4;"; + } + + return $create . ' ' . $table . " (\n\t" . implode( ",\n\t", $lines ) . "\n) DEFAULT CHARACTER SET utf8mb4;"; + } + + private static function column_ddl( string $name, array $column, string $style ): string { + $identifier = 'backticks' === $style || 'wide-spacing' === $style ? '`' . $name . '`' : $name; + $type = ( 'backticks' === $style ) ? strtoupper( $column['type'] ) : $column['type']; + $null = $column['nullable'] ? 'NULL' : 'NOT NULL'; + $default = ''; + + if ( null !== $column['default'] ) { + $keyword = 'lowercase' === $style ? 'default' : 'DEFAULT'; + $default = ' ' . $keyword . " '" . str_replace( "'", "''", (string) $column['default'] ) . "'"; + } + + $extra = '' === $column['extra'] ? '' : ' ' . $column['extra']; + if ( 'backticks' === $style ) { + $null = strtolower( $null ); + $extra = strtoupper( $extra ); + } + + return trim( "{$identifier} {$type} {$null}{$default}{$extra}" ); + } + + private static function index_ddl( array $index, string $style ): string { + $type = $index['type']; + if ( 'index-synonyms' === $style && 'PRIMARY KEY' !== $type ) { + $type = str_replace( 'KEY', 'INDEX', $type ); + } elseif ( 'lowercase' === $style ) { + $type = strtolower( $type ); + } + + $name = ''; + if ( '' !== $index['name'] ) { + $name = 'backticks' === $style || 'wide-spacing' === $style ? '`' . $index['name'] . '` ' : $index['name'] . ' '; + } + + $columns = array(); + foreach ( $index['columns'] as $column ) { + $column_name = ( 'backticks' === $style || 'wide-spacing' === $style ) ? '`' . $column['name'] . '`' : $column['name']; + if ( null !== $column['subpart'] ) { + $column_name .= 'wide-spacing' === $style ? ' ( ' . $column['subpart'] . ' )' : '(' . $column['subpart'] . ')'; + } + $columns[] = $column_name; + } + + $space = 'wide-spacing' === $style ? ', ' : ','; + return trim( "{$type} {$name}(" . implode( $space, $columns ) . ')' ); + } + + private static function mutate_column( array $spec, string $column_name, string $property, $value ): array { + $mutated = $spec; + $mutated['columns'][ $column_name ][ $property ] = $value; + return $mutated; + } + + private static function mutate_index_subparts( array $spec, $subpart ): array { + $mutated = $spec; + foreach ( $mutated['indexes'] as &$index ) { + foreach ( $index['columns'] as &$column ) { + if ( null !== $column['subpart'] ) { + $column['subpart'] = $subpart; + } + } + } + unset( $index, $column ); + return $mutated; + } + + private static function without_indexes( array $spec ): array { + $spec['indexes'] = array(); + return $spec; + } + + private static function remove_column( array $spec, string $column_name ): array { + unset( $spec['columns'][ $column_name ] ); + foreach ( $spec['indexes'] as $index_id => $index ) { + foreach ( $index['columns'] as $column ) { + if ( $column_name === $column['name'] ) { + unset( $spec['indexes'][ $index_id ] ); + break; + } + } + } + $spec['indexes'] = array_values( $spec['indexes'] ); + return $spec; + } + + private static function parse_create_tables( string $sql ): array { + $tables = array(); + $errors = array(); + $offset = 0; + + while ( preg_match( '/\bCREATE\s+TABLE\s+(`?[A-Za-z0-9_]+`?)/i', $sql, $matches, PREG_OFFSET_CAPTURE, $offset ) ) { + $table_token = $matches[1][0]; + $table = self::clean_identifier( $table_token ); + $after_name = $matches[1][1] + strlen( $table_token ); + $open = strpos( $sql, '(', $after_name ); + + if ( false === $open ) { + $errors[] = "missing-open-paren:{$table}"; + $offset = $after_name; + continue; + } + + $close = self::find_matching_paren( $sql, $open ); + if ( null === $close ) { + $errors[] = "missing-close-paren:{$table}"; + $offset = $open + 1; + continue; + } + + if ( ! self::is_safe_table_name( $table ) ) { + $errors[] = "unsafe-table-name:{$table}"; + $offset = $close + 1; + continue; + } + + $body = substr( $sql, $open + 1, $close - $open - 1 ); + $parsed = self::parse_table_body( $body ); + if ( array() !== $parsed['errors'] ) { + foreach ( $parsed['errors'] as $error ) { + $errors[] = "{$table}:{$error}"; + } + } + $tables[ $table ] = array( + 'columns' => $parsed['columns'], + 'indexes' => $parsed['indexes'], + ); + $offset = $close + 1; + } + + if ( array() === $tables ) { + $errors[] = 'no-create-table'; + } + + return array( + 'ok' => array() !== $tables && array() === $errors, + 'tables' => $tables, + 'errors' => array_values( array_unique( $errors ) ), + ); + } + + private static function parse_table_body( string $body ): array { + $columns = array(); + $indexes = array(); + $errors = array(); + + foreach ( self::split_top_level_commas( $body ) as $line ) { + $line = trim( $line ); + if ( '' === $line ) { + continue; + } + + $index = self::parse_index_line( $line ); + if ( null !== $index ) { + $indexes[] = $index; + continue; + } + + $column = self::parse_column_line( $line ); + if ( null === $column ) { + $errors[] = 'unparsed-line:' . self::preview( $line ); + continue; + } + $columns[ $column['name'] ] = $column; + } + + if ( array() === $columns ) { + $errors[] = 'no-columns'; + } + + return array( + 'columns' => $columns, + 'indexes' => $indexes, + 'errors' => $errors, + ); + } + + private static function parse_column_line( string $line ): ?array { + if ( ! preg_match( '/^`?([A-Za-z0-9_]+)`?\s+(.+)$/s', $line, $matches ) ) { + return null; + } + + $name = strtolower( $matches[1] ); + $rest = trim( $matches[2] ); + if ( ! preg_match( '/^([A-Za-z]+(?:\s*\([^)]*\))?(?:\s+unsigned)?)(?:\s+|$)/i', $rest, $type_matches ) ) { + return null; + } + + $type = self::normalize_type( $type_matches[1] ); + $nullable = ! preg_match( '/\bNOT\s+NULL\b/i', $rest ); + $default = null; + + if ( preg_match( "/\bDEFAULT\s+'((?:''|[^'])*)'/i", $rest, $default_matches ) ) { + $default = str_replace( "''", "'", $default_matches[1] ); + } elseif ( preg_match( '/\bDEFAULT\s+NULL\b/i', $rest ) ) { + $default = null; + } elseif ( preg_match( '/\bDEFAULT\s+([^\s,]+)/i', $rest, $default_matches ) ) { + $default = trim( $default_matches[1], "'\"" ); + } + + return array( + 'name' => $name, + 'type' => $type, + 'nullable' => $nullable, + 'default' => $default, + 'extra' => preg_match( '/\bauto_increment\b/i', $rest ) ? 'auto_increment' : '', + 'normalized' => self::normalize_sql_fragment( $line ), + ); + } + + private static function parse_index_line( string $line ): ?array { + if ( + ! preg_match( + '/^(PRIMARY\s+KEY|(?:UNIQUE|FULLTEXT|SPATIAL)\s+(?:KEY|INDEX)|KEY|INDEX)\s+(?:(`?[A-Za-z0-9$_-]+`?)\s+)?\((.+)\)$/is', + $line, + $matches + ) + ) { + return null; + } + + $type = strtoupper( preg_replace( '/\s+/', ' ', trim( $matches[1] ) ) ); + $type = str_replace( 'INDEX', 'KEY', $type ); + $name = 'PRIMARY KEY' === $type ? '' : strtolower( self::clean_identifier( $matches[2] ?? '' ) ); + if ( 'PRIMARY KEY' !== $type && '' === $name ) { + return null; + } + + $columns = array(); + foreach ( self::split_top_level_commas( $matches[3] ) as $column ) { + if ( ! preg_match( '/`?([A-Za-z0-9_]+)`?(?:\s*\(\s*(\d+)\s*\))?/i', trim( $column ), $column_matches ) ) { + return null; + } + $columns[] = array( + 'name' => strtolower( $column_matches[1] ), + 'subpart' => isset( $column_matches[2] ) && '' !== $column_matches[2] ? (int) $column_matches[2] : null, + ); + } + + return array( + 'type' => $type, + 'name' => $name, + 'columns' => $columns, + 'normalized' => self::normalized_index_definition( $type, $name, $columns, true ), + 'withoutSubparts' => self::normalized_index_definition( $type, $name, $columns, false ), + 'showIndexType' => str_contains( $type, 'FULLTEXT' ) ? 'FULLTEXT' : ( str_contains( $type, 'SPATIAL' ) ? 'SPATIAL' : 'BTREE' ), + 'showIndexNonUnique' => str_starts_with( $type, 'UNIQUE' ) || 'PRIMARY KEY' === $type ? '0' : '1', + 'showIndexKeyName' => 'PRIMARY KEY' === $type ? 'PRIMARY' : $name, + ); + } + + private static function normalized_index_definition( string $type, string $name, array $columns, bool $with_subparts ): string { + $index_name = 'PRIMARY KEY' === $type ? '' : '`' . $name . '`'; + $parts = array(); + foreach ( $columns as $column ) { + $part = '`' . $column['name'] . '`'; + if ( $with_subparts && null !== $column['subpart'] ) { + $part .= '(' . $column['subpart'] . ')'; + } + $parts[] = $part; + } + + return "{$type} {$index_name} (" . implode( ',', $parts ) . ')'; + } + + private static function semantic_diff( array $from, array $to, string $table ): array { + $diff = array(); + if ( ! isset( $from['tables'][ $table ], $to['tables'][ $table ] ) ) { + return array( "table:{$table}" ); + } + + $from_columns = $from['tables'][ $table ]['columns']; + $to_columns = $to['tables'][ $table ]['columns']; + foreach ( array_diff( array_keys( $to_columns ), array_keys( $from_columns ) ) as $column ) { + $diff[] = "column:{$table}.{$column}:added"; + } + foreach ( array_diff( array_keys( $from_columns ), array_keys( $to_columns ) ) as $column ) { + $diff[] = "column:{$table}.{$column}:removed"; + } + foreach ( array_intersect( array_keys( $from_columns ), array_keys( $to_columns ) ) as $column ) { + foreach ( array( 'type', 'default', 'nullable' ) as $property ) { + if ( $from_columns[ $column ][ $property ] !== $to_columns[ $column ][ $property ] ) { + $name = 'nullable' === $property ? 'null' : $property; + $diff[] = "column:{$table}.{$column}:{$name}"; + } + } + } + + $from_indexes = array_column( $from['tables'][ $table ]['indexes'], 'withoutSubparts' ); + $to_indexes = array_column( $to['tables'][ $table ]['indexes'], 'withoutSubparts' ); + foreach ( array_diff( $to_indexes, $from_indexes ) as $index ) { + $diff[] = "index:{$table}:added:" . $index; + } + foreach ( array_diff( $from_indexes, $to_indexes ) as $index ) { + $diff[] = "index:{$table}:removed:" . $index; + } + + sort( $diff ); + return $diff; + } + + private static function schema_signature( array $parsed ): string { + $signature = array(); + foreach ( $parsed['tables'] as $table => $schema ) { + $columns = $schema['columns']; + ksort( $columns ); + $signature[ $table ] = array( + 'columns' => array_map( + static function ( array $column ): array { + return array( + 'type' => $column['type'], + 'nullable' => $column['nullable'], + 'default' => $column['default'], + 'extra' => $column['extra'], + ); + }, + $columns + ), + 'indexes' => array_values( array_column( $schema['indexes'], 'normalized' ) ), + ); + sort( $signature[ $table ]['indexes'] ); + } + ksort( $signature ); + return (string) wp_json_encode( $signature ); + } + + private static function create_table_names( string $sql ): array { + $parsed = self::parse_create_tables( $sql ); + return array_keys( $parsed['tables'] ); + } + + private static function all_tables_have_allowed_prefix( array $tables, array $prefixes ): bool { + foreach ( $tables as $table ) { + $allowed = false; + foreach ( $prefixes as $prefix ) { + if ( str_starts_with( $table, $prefix ) && self::is_safe_table_name( $table ) ) { + $allowed = true; + break; + } + } + if ( ! $allowed ) { + return false; + } + } + return true; + } + + private static function queries_escape_table_allowlist( array $queries, array $allowed_tables ): array { + $allowed = array_fill_keys( $allowed_tables, true ); + $escaped = array(); + $patterns = array( + '/\bALTER\s+TABLE\s+`?([A-Za-z0-9_]+)`?/i', + '/\bCREATE\s+TABLE\s+`?([A-Za-z0-9_]+)`?/i', + '/\bDESCRIBE\s+`?([A-Za-z0-9_]+)`?/i', + '/\bSHOW\s+INDEX(?:ES)?\s+FROM\s+`?([A-Za-z0-9_]+)`?/i', + ); + + foreach ( $queries as $query ) { + foreach ( $patterns as $pattern ) { + if ( preg_match( $pattern, $query, $matches ) && ! isset( $allowed[ $matches[1] ] ) ) { + $escaped[] = array( + 'query' => $query, + 'table' => $matches[1], + ); + } + } + } + + return $escaped; + } + + private static function created_tables_from_queries( array $queries ): array { + $tables = array(); + foreach ( $queries as $query ) { + if ( ! preg_match( '/^\s*CREATE\s+TABLE\s+`?([A-Za-z0-9_]+)`?\s+\(/i', $query, $matches ) ) { + continue; + } + $tables[] = $matches[1]; + } + return $tables; + } + + private static function capture_make_db_current_call( callable $callback ): array { + $tables = array(); + $calls = 0; + $filter = static function ( array $queries ) use ( &$tables, &$calls ): array { + $calls++; + foreach ( $queries as $query ) { + $parsed = self::parse_create_tables( (string) $query ); + $tables = array_merge( $tables, array_keys( $parsed['tables'] ) ); + } + + return array(); + }; + + add_filter( 'dbdelta_queries', $filter, PHP_INT_MAX ); + $output = ''; + $buffer_started = false; + + try { + ob_start(); + $buffer_started = true; + $callback(); + $output = (string) ob_get_clean(); + $buffer_started = false; + } finally { + if ( $buffer_started && ob_get_level() > 0 ) { + ob_end_clean(); + } + remove_filter( 'dbdelta_queries', $filter, PHP_INT_MAX ); + } + + return array( + 'tables' => $tables, + 'calls' => $calls, + 'output' => $output, + ); + } + + private static function with_wpdb( InstallSchemaWpdbDouble $wpdb, callable $callback ) { + $previous_exists = array_key_exists( 'wpdb', $GLOBALS ); + $previous_wpdb = $GLOBALS['wpdb'] ?? null; + $GLOBALS['wpdb'] = $wpdb; + + try { + return $callback( $wpdb ); + } finally { + if ( $previous_exists ) { + $GLOBALS['wpdb'] = $previous_wpdb; + } else { + unset( $GLOBALS['wpdb'] ); + } + } + } + + private static function split_top_level_commas( string $value ): array { + $parts = array(); + $start = 0; + $depth = 0; + $quote = null; + $len = strlen( $value ); + + for ( $i = 0; $i < $len; $i++ ) { + $char = $value[ $i ]; + if ( null !== $quote ) { + if ( $char === $quote && ( 0 === $i || '\\' !== $value[ $i - 1 ] ) ) { + $quote = null; + } + continue; + } + if ( "'" === $char || '"' === $char || '`' === $char ) { + $quote = $char; + continue; + } + if ( '(' === $char ) { + ++$depth; + continue; + } + if ( ')' === $char ) { + $depth = max( 0, $depth - 1 ); + continue; + } + if ( ',' === $char && 0 === $depth ) { + $parts[] = substr( $value, $start, $i - $start ); + $start = $i + 1; + } + } + $parts[] = substr( $value, $start ); + return $parts; + } + + private static function find_matching_paren( string $sql, int $open ): ?int { + $depth = 0; + $quote = null; + $len = strlen( $sql ); + + for ( $i = $open; $i < $len; $i++ ) { + $char = $sql[ $i ]; + if ( null !== $quote ) { + if ( $char === $quote && ( 0 === $i || '\\' !== $sql[ $i - 1 ] ) ) { + $quote = null; + } + continue; + } + if ( "'" === $char || '"' === $char || '`' === $char ) { + $quote = $char; + continue; + } + if ( '(' === $char ) { + ++$depth; + continue; + } + if ( ')' === $char ) { + --$depth; + if ( 0 === $depth ) { + return $i; + } + } + } + + return null; + } + + private static function normalize_type( string $type ): string { + $type = strtolower( preg_replace( '/\s+/', ' ', trim( $type ) ) ); + $type = preg_replace( '/\s*\(\s*/', '(', $type ); + $type = preg_replace( '/\s*\)/', ')', $type ); + return (string) $type; + } + + private static function normalize_sql_fragment( string $fragment ): string { + $fragment = str_replace( '`', '', $fragment ); + $fragment = strtolower( preg_replace( '/\s+/', ' ', trim( $fragment ) ) ); + $fragment = preg_replace( '/\s*,\s*/', ',', $fragment ); + return (string) $fragment; + } + + private static function clean_identifier( string $identifier ): string { + return trim( $identifier, "` \t\n\r\0\x0B" ); + } + + private static function is_safe_table_name( string $table ): bool { + return 1 === preg_match( '/^[A-Za-z0-9_]+$/', $table ); + } + + private static function prefix_for_context( \ComponentFuzz\FuzzContext $ctx ): string { + return $ctx->choice( array( 'wp_', 'wpfuzz_', 'wpit_' ) ); + } + + private static function snapshot_state(): array { + $state = array(); + foreach ( array( 'wpdb', 'wp_queries', 'charset_collate', 'table_prefix' ) as $name ) { + $state[ $name ] = array( + 'exists' => array_key_exists( $name, $GLOBALS ), + 'value' => $GLOBALS[ $name ] ?? null, + ); + } + return $state; + } + + private static function restore_state( array $snapshot ): void { + foreach ( $snapshot as $name => $entry ) { + if ( $entry['exists'] ) { + $GLOBALS[ $name ] = $entry['value']; + } else { + unset( $GLOBALS[ $name ] ); + } + } + } + + private static function state_matches( array $snapshot ): bool { + foreach ( $snapshot as $name => $entry ) { + if ( $entry['exists'] !== array_key_exists( $name, $GLOBALS ) ) { + return false; + } + if ( $entry['exists'] && $entry['value'] !== $GLOBALS[ $name ] ) { + return false; + } + } + return true; + } + + private static function describe_throwable( \Throwable $e ): array { + return array( + 'type' => get_class( $e ), + 'message' => $e->getMessage(), + 'file' => $e->getFile(), + 'line' => $e->getLine(), + ); + } + + private static function preview( string $value ): string { + return strlen( $value ) > 160 ? substr( $value, 0, 160 ) . '...' : $value; + } +} + +final class InstallSchemaWpdbDouble { + public string $prefix; + public string $base_prefix; + public int $blogid = 1; + public int $siteid = 1; + public string $charset = 'utf8mb4'; + public string $collate = ''; + public bool $suppress_errors = false; + public string $last_query = ''; + public int $num_rows = 0; + public string $posts = ''; + public string $comments = ''; + public string $links = ''; + public string $options = ''; + public string $postmeta = ''; + public string $terms = ''; + public string $term_taxonomy = ''; + public string $term_relationships = ''; + public string $termmeta = ''; + public string $commentmeta = ''; + public string $users = ''; + public string $usermeta = ''; + public string $blogs = ''; + public string $blogmeta = ''; + public string $signups = ''; + public string $site = ''; + public string $sitemeta = ''; + public string $registration_log = ''; + public string $categories = ''; + public string $post2cat = ''; + public string $link2cat = ''; + public string $sitecategories = ''; + + public array $tables = array( + 'posts', + 'comments', + 'links', + 'options', + 'postmeta', + 'terms', + 'term_taxonomy', + 'term_relationships', + 'termmeta', + 'commentmeta', + ); + public array $old_tables = array( 'categories', 'post2cat', 'link2cat' ); + public array $global_tables = array( 'users', 'usermeta' ); + public array $ms_global_tables = array( 'blogs', 'blogmeta', 'signups', 'site', 'sitemeta', 'registration_log' ); + public array $old_ms_global_tables = array( 'sitecategories' ); + + public array $introspection_log = array(); + public array $executed_queries = array(); + public array $violations = array(); + + private array $schemas; + private array $allowed_tables; + + public function __construct( string $prefix = 'wp_', array $schemas = array(), array $allowed_tables = array() ) { + $this->prefix = $prefix; + $this->base_prefix = $prefix; + $this->schemas = $schemas; + $this->allowed_tables = array_fill_keys( $allowed_tables ? $allowed_tables : array_keys( $schemas ), true ); + $this->refresh_table_properties(); + } + + public function get_charset_collate(): string { + $charset_collate = ''; + if ( '' !== $this->charset ) { + $charset_collate = 'DEFAULT CHARACTER SET ' . $this->charset; + } + if ( '' !== $this->collate ) { + $charset_collate .= ' COLLATE ' . $this->collate; + } + return $charset_collate; + } + + public function set_blog_id( $blog_id, $network_id = 0 ) { + $old_blog_id = $this->blogid; + $this->blogid = (int) $blog_id; + if ( $network_id ) { + $this->siteid = (int) $network_id; + } + $this->prefix = $this->get_blog_prefix(); + $this->refresh_table_properties(); + return $old_blog_id; + } + + public function get_blog_prefix( $blog_id = null ): string { + unset( $blog_id ); + return $this->base_prefix; + } + + public function tables( $scope = 'all', $prefix = true, $blog_id = 0 ): array { + switch ( $scope ) { + case 'all': + $tables = array_merge( $this->global_tables, $this->tables ); + if ( is_multisite() ) { + $tables = array_merge( $tables, $this->ms_global_tables ); + } + break; + case 'blog': + $tables = $this->tables; + break; + case 'global': + $tables = $this->global_tables; + if ( is_multisite() ) { + $tables = array_merge( $tables, $this->ms_global_tables ); + } + break; + case 'ms_global': + $tables = $this->ms_global_tables; + break; + case 'old': + $tables = $this->old_tables; + if ( is_multisite() ) { + $tables = array_merge( $tables, $this->old_ms_global_tables ); + } + break; + default: + return array(); + } + + if ( ! $prefix ) { + return $tables; + } + + if ( ! $blog_id ) { + $blog_id = $this->blogid; + } + unset( $blog_id ); + + $mapped = array(); + $global_tables = array_merge( $this->global_tables, $this->ms_global_tables ); + foreach ( $tables as $table ) { + $mapped[ $table ] = ( in_array( $table, $global_tables, true ) ? $this->base_prefix : $this->prefix ) . $table; + } + + return $mapped; + } + + public function db_version(): string { + return '8.0.16'; + } + + public function db_server_info(): string { + return '8.0.16 ComponentFuzz'; + } + + public function suppress_errors( $suppress = true ) { + $previous = $this->suppress_errors; + $this->suppress_errors = (bool) $suppress; + return $previous; + } + + public function get_results( $query = null, $output = OBJECT ): array { + unset( $output ); + + $this->last_query = (string) $query; + $this->introspection_log[] = $this->last_query; + + if ( preg_match( '/^\s*DESCRIBE\s+`?([A-Za-z0-9_]+)`?\s*;?\s*$/i', $this->last_query, $matches ) ) { + return $this->describe_table( $matches[1] ); + } + + if ( preg_match( '/^\s*SHOW\s+INDEX(?:ES)?\s+FROM\s+`?([A-Za-z0-9_]+)`?/i', $this->last_query, $matches ) ) { + return $this->show_index( $matches[1] ); + } + + return array(); + } + + public function get_var( $query = null, $x = 0, $y = 0 ) { + unset( $x, $y ); + + $this->last_query = (string) $query; + $this->introspection_log[] = $this->last_query; + if ( preg_match( "/SHOW\s+TABLES\s+LIKE\s+'([^']+)'/i", $this->last_query, $matches ) ) { + return isset( $this->schemas[ $matches[1] ] ) ? $matches[1] : null; + } + + return null; + } + + public function get_row( $query = null, $output = OBJECT, $y = 0 ) { + unset( $output, $y ); + + $this->last_query = (string) $query; + $this->introspection_log[] = $this->last_query; + $this->num_rows = 0; + return null; + } + + public function get_col( $query = null, $x = 0 ): array { + unset( $x ); + + $this->last_query = (string) $query; + $this->introspection_log[] = $this->last_query; + $this->num_rows = 0; + return array(); + } + + public function query( $query ) { + $this->last_query = (string) $query; + $this->executed_queries[] = $this->last_query; + $this->record_allowlist_violations( $this->last_query ); + return 0; + } + + public function component_fuzz_apply_created_tables( array $schemas, array $queries ): array { + $applied = array(); + foreach ( $queries as $query ) { + if ( ! preg_match( '/^\s*CREATE\s+TABLE\s+`?([A-Za-z0-9_]+)`?\s+\(/i', (string) $query, $matches ) ) { + continue; + } + + $table = $matches[1]; + if ( ! isset( $schemas[ $table ] ) ) { + continue; + } + + $this->schemas[ $table ] = $schemas[ $table ]; + $applied[] = $table; + } + + return $applied; + } + + public function _escape( $data ) { + if ( is_array( $data ) ) { + return array_map( array( $this, '_escape' ), $data ); + } + + return addslashes( (string) $data ); + } + + public function prepare( $query, ...$args ): string { + if ( 1 === count( $args ) && is_array( $args[0] ) ) { + $args = $args[0]; + } + $index = 0; + return (string) preg_replace_callback( + '/%[sd]/', + function () use ( &$index, $args ): string { + $value = $args[ $index++ ] ?? ''; + return "'" . addslashes( (string) $value ) . "'"; + }, + (string) $query + ); + } + + public function esc_like( $text ): string { + return addcslashes( (string) $text, '_%\\' ); + } + + private function refresh_table_properties(): void { + foreach ( $this->tables( 'all' ) + $this->tables( 'old' ) as $name => $table ) { + $this->$name = $table; + } + foreach ( $this->tables( 'ms_global' ) as $name => $table ) { + $this->$name = $table; + } + } + + private function describe_table( string $table ): array { + if ( ! isset( $this->schemas[ $table ] ) ) { + $this->num_rows = 0; + return array(); + } + + $rows = array(); + foreach ( $this->schemas[ $table ]['columns'] as $name => $column ) { + $rows[] = (object) array( + 'Field' => $name, + 'Type' => $column['type'], + 'Null' => $column['nullable'] ? 'YES' : 'NO', + 'Key' => $this->column_key( $table, $name ), + 'Default' => $column['default'], + 'Extra' => $column['extra'], + ); + } + $this->num_rows = count( $rows ); + return $rows; + } + + private function show_index( string $table ): array { + if ( ! isset( $this->schemas[ $table ] ) ) { + $this->num_rows = 0; + return array(); + } + + $rows = array(); + foreach ( $this->schemas[ $table ]['indexes'] as $index ) { + $sequence = 1; + foreach ( $index['columns'] as $column ) { + $rows[] = (object) array( + 'Table' => $table, + 'Non_unique' => $index['showIndexNonUnique'], + 'Key_name' => $index['showIndexKeyName'], + 'Seq_in_index' => $sequence++, + 'Column_name' => $column['name'], + 'Sub_part' => $column['subpart'], + 'Index_type' => $index['showIndexType'], + ); + } + } + $this->num_rows = count( $rows ); + return $rows; + } + + private function column_key( string $table, string $column_name ): string { + foreach ( $this->schemas[ $table ]['indexes'] ?? array() as $index ) { + if ( 'PRIMARY' === $index['showIndexKeyName'] && $column_name === $index['columns'][0]['name'] ) { + return 'PRI'; + } + } + return ''; + } + + private function record_allowlist_violations( string $query ): void { + $patterns = array( + '/\bALTER\s+TABLE\s+`?([A-Za-z0-9_]+)`?/i', + '/\bCREATE\s+TABLE\s+`?([A-Za-z0-9_]+)`?/i', + '/\bINSERT\s+INTO\s+`?([A-Za-z0-9_]+)`?/i', + '/\bUPDATE\s+`?([A-Za-z0-9_]+)`?/i', + ); + + foreach ( $patterns as $pattern ) { + if ( preg_match( $pattern, $query, $matches ) && ! isset( $this->allowed_tables[ $matches[1] ] ) ) { + $this->violations[] = array( + 'table' => $matches[1], + 'query' => $query, + ); + } + } + } +} diff --git a/tools/component-fuzz/surfaces/InteractivitySurface.php b/tools/component-fuzz/surfaces/InteractivitySurface.php new file mode 100644 index 0000000000000..8fa77fa2deecc --- /dev/null +++ b/tools/component-fuzz/surfaces/InteractivitySurface.php @@ -0,0 +1,1458 @@ +skip( + 'interactivity.bootstrap-apis-available', + 'Required WordPress Interactivity APIs are unavailable.', + array( 'missing' => $missing ) + ), + ); + } + + $snapshot = self::snapshot_state(); + $ob_level = ob_get_level(); + $rows = array(); + + try { + $case = self::case_for_context( $ctx ); + + $rows[] = self::check_state_config_helpers( $ctx, $case ); + $rows[] = self::check_directive_processing( $ctx, $case ); + $rows[] = self::check_namespaced_directive_evaluation( $ctx->fork( 'namespaced-directives' ), $case ); + $rows[] = self::check_directive_syntax_ordering_matrix( $ctx->fork( 'directive-syntax-ordering' ), $case ); + $rows[] = self::check_context_and_element_helpers( $ctx, $case ); + $rows[] = self::check_context_namespace_stack_merge_sort_and_restore( $ctx->fork( 'context-stack' ), $case ); + $rows[] = self::check_derived_state_stack_recovery( $ctx->fork( 'derived-stack' ), $case ); + $rows[] = self::check_script_module_hooks( $ctx->fork( 'script-module-hooks' ), $case ); + $rows[] = self::check_each_edge_cases( $ctx->fork( 'each-edge-cases' ), $case ); + $rows[] = self::check_router_region( $ctx, $case ); + $rows[] = self::check_unbalanced_and_unsupported_fallbacks( $ctx, $case ); + } catch ( \Throwable $e ) { + $rows[] = $ctx->fail( + 'interactivity.surface-no-throw', + array( + 'throwable' => self::describe_throwable( $e ), + ) + ); + } finally { + while ( ob_get_level() > $ob_level ) { + ob_end_clean(); + } + self::restore_state( $snapshot ); + } + + return $rows; + } + + private static function missing_requirements(): array { + $missing = array(); + + foreach ( + array( + 'WP_HTML_Tag_Processor', + 'WP_Interactivity_API', + 'WP_Interactivity_API_Directives_Processor', + ) as $class + ) { + if ( ! class_exists( $class ) ) { + $missing[] = "class {$class}"; + } + } + + foreach ( + array( + 'wp_interactivity', + 'wp_interactivity_process_directives', + 'wp_interactivity_state', + 'wp_interactivity_config', + 'wp_interactivity_data_wp_context', + 'wp_interactivity_get_context', + 'wp_interactivity_get_element', + 'wp_json_encode', + 'add_action', + 'add_filter', + 'get_self_link', + 'esc_attr', + 'esc_html', + 'has_action', + 'has_filter', + 'do_action', + 'remove_action', + 'remove_filter', + 'wp_add_inline_style', + 'wp_enqueue_style', + 'wp_register_style', + 'wp_styles', + ) as $function + ) { + if ( ! function_exists( $function ) ) { + $missing[] = "function {$function}"; + } + } + + return $missing; + } + + private static function check_state_config_helpers( \ComponentFuzz\FuzzContext $ctx, array $case ): array { + $api = self::install_fresh_api(); + + $state_first = array( + 'nested' => array( + 'kept' => $case['stateKept'], + 'replaced' => 'old', + ), + 'list' => array( 'first', 'second' ), + 'scalar' => 'before', + ); + $state_next = array( + 'nested' => array( + 'replaced' => $case['stateReplacement'], + 'added' => $case['stateAdded'], + ), + 'list' => array( 1 => $case['stateListReplacement'] ), + 'scalar' => array( 'after' => $case['stateScalarAfter'] ), + ); + + $config_first = array( + 'flags' => array( + 'enabled' => false, + 'stable' => $case['configStable'], + ), + 'limits' => array( + 'page' => 10, + ), + ); + $config_next = array( + 'flags' => array( + 'enabled' => true, + ), + 'limits' => array( + 'page' => $case['configLimit'], + ), + ); + + $initial_state = \wp_interactivity_state( $case['namespace'], $state_first ); + $merged_state = \wp_interactivity_state( $case['namespace'], $state_next ); + $read_state = \wp_interactivity_state( $case['namespace'] ); + $other_state = \wp_interactivity_state( $case['otherNamespace'], array( 'value' => $case['otherValue'] ) ); + + $initial_config = \wp_interactivity_config( $case['namespace'], $config_first ); + $merged_config = \wp_interactivity_config( $case['namespace'], $config_next ); + $read_config = \wp_interactivity_config( $case['namespace'] ); + $client_data = $api->filter_script_module_interactivity_data( array( 'existing' => true ) ); + + $expected_state = array_replace_recursive( $state_first, $state_next ); + $expected_config = array_replace_recursive( $config_first, $config_next ); + $context_attr = \wp_interactivity_data_wp_context( $case['context'], $case['namespace'] ); + + $ok = $initial_state === $state_first + && $merged_state === $expected_state + && $read_state === $expected_state + && array( 'value' => $case['otherValue'] ) === $other_state + && $initial_config === $config_first + && $merged_config === $expected_config + && $read_config === $expected_config + && isset( $client_data['state'][ $case['namespace'] ], $client_data['config'][ $case['namespace'] ] ) + && $client_data['state'][ $case['namespace'] ] === $expected_state + && $client_data['config'][ $case['namespace'] ] === $expected_config + && str_starts_with( $context_attr, "data-wp-context='" . $case['namespace'] . '::' ) + && ! str_contains( $context_attr, '<' ) + && ! str_contains( $context_attr, '>' ) + && ! str_contains( $context_attr, '&' ); + + return self::result( + $ctx, + 'interactivity.state-config.recursive-merge-and-client-data', + $ok, + array( + 'namespace' => $case['namespace'], + 'initialState' => $initial_state, + 'mergedState' => $merged_state, + 'expectedState' => $expected_state, + 'otherState' => $other_state, + 'initialConfig' => $initial_config, + 'mergedConfig' => $merged_config, + 'expectedConfig' => $expected_config, + 'clientKeys' => array_keys( $client_data ), + 'contextAttr' => $context_attr, + ) + ); + } + + private static function check_directive_processing( \ComponentFuzz\FuzzContext $ctx, array $case ): array { + self::install_fresh_api(); + \wp_interactivity_state( $case['namespace'], $case['directiveState'] ); + + $html = self::directive_html( $case ); + $processed = \wp_interactivity_process_directives( $html ); + $target = self::find_first_tag_by_attribute( $processed, 'data-case', 'target' ); + $context = self::find_element_body( $processed, 'span', 'data-case', 'context' ); + $target_body = self::find_element_body( $processed, 'a', 'data-case', 'target' ); + $items = self::find_each_children( $processed, $case['namespace'] . '::state.items' ); + + $target_style = is_array( $target ) ? self::parse_style( (string) ( $target['attributes']['style'] ?? '' ) ) : array(); + $item_failures = array(); + foreach ( $case['directiveState']['items'] as $index => $item ) { + $rendered = $items[ $index ] ?? null; + $class = is_array( $rendered ) ? (string) ( $rendered['attributes']['class'] ?? '' ) : ''; + self::collect_failure( + $item_failures, + is_array( $rendered ) + && esc_html( $item['title'] ) === $rendered['body'] + && self::class_present( $class, 'selected' ) === (bool) $item['active'], + "data-wp-each rendered item {$index}", + array( + 'item' => $item, + 'rendered' => $rendered, + ) + ); + } + + $ok = is_array( $target ) + && $target_body === esc_html( $case['directiveState']['text'] ) + && $context === esc_html( $case['context']['localText'] ) + && $case['directiveState']['href'] === ( $target['attributes']['href'] ?? null ) + && ! array_key_exists( 'hidden', $target['attributes'] ) + && ( $case['directiveState']['ariaOpen'] ? 'true' : 'false' ) === ( $target['attributes']['aria-expanded'] ?? null ) + && ( $case['directiveState']['isActive'] ? 'true' : 'false' ) === ( $target['attributes']['data-active'] ?? null ) + && self::class_present( (string) ( $target['attributes']['class'] ?? '' ), $case['baseClass'] ) + && self::class_present( (string) ( $target['attributes']['class'] ?? '' ), $case['activeClass'] ) + && self::class_present( (string) ( $target['attributes']['class'] ?? '' ), $case['uniqueClass'] . '---' . $case['uniqueId'] ) + && ! self::class_present( (string) ( $target['attributes']['class'] ?? '' ), $case['staleClass'] ) + && ( $target_style['color'] ?? null ) === $case['directiveState']['color'] + && ( $target_style['background-color'] ?? null ) === $case['directiveState']['background'] + && ( $target_style['border-color'] ?? null ) === $case['initialBorderColor'] + && ! array_key_exists( 'margin', $target_style ) + && count( $items ) === count( $case['directiveState']['items'] ) + && array() === $item_failures + && 1 === substr_count( $processed, '
    After