From 0c62951f3f98282b8eb0929f74980a67e4ccbe74 Mon Sep 17 00:00:00 2001 From: Tony Hegyes Date: Fri, 6 Nov 2026 14:13:44 +0100 Subject: [PATCH 01/29] Update packages The locks trailed the latest releases of the PHP and Node tooling. Composer moves every package to its newest release within range; symfony/filesystem and symfony/finder stay on 7.4, which humbug/php-scoper caps. npm refreshes every package but TypeScript, which stays on 6.0.3 until typescript-eslint supports 7; the peerDependency range is unchanged, so the lock keeps 6.0.3 because it satisfies it. Lighthouse, under the unused @wordpress/e2e-test-utils-playwright devDependency, now brings Sentry's CLI and bundler plugins. Assisted-by: Claude Code:claude-opus-5-5 --- composer.lock | 565 +++++++++------- package-lock.json | 1641 ++++++++++++++++++++++++--------------------- 2 files changed, 1226 insertions(+), 980 deletions(-) diff --git a/composer.lock b/composer.lock index d9a5e6e..317573f 100644 --- a/composer.lock +++ b/composer.lock @@ -8,16 +8,16 @@ "packages": [ { "name": "composer/class-map-generator", - "version": "1.8.0", + "version": "1.9.0", "source": { "type": "git", "url": "https://github.com/composer/class-map-generator.git", - "reference": "c0efbeb2f3fd7e23c7f180a59765c0b48a31fbc4" + "reference": "5fa48e0e1992ea8a786df3ceca80625c9a643b75" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/composer/class-map-generator/zipball/c0efbeb2f3fd7e23c7f180a59765c0b48a31fbc4", - "reference": "c0efbeb2f3fd7e23c7f180a59765c0b48a31fbc4", + "url": "https://api.github.com/repos/composer/class-map-generator/zipball/5fa48e0e1992ea8a786df3ceca80625c9a643b75", + "reference": "5fa48e0e1992ea8a786df3ceca80625c9a643b75", "shasum": "" }, "require": { @@ -61,7 +61,7 @@ ], "support": { "issues": "https://github.com/composer/class-map-generator/issues", - "source": "https://github.com/composer/class-map-generator/tree/1.8.0" + "source": "https://github.com/composer/class-map-generator/tree/1.9.0" }, "funding": [ { @@ -73,7 +73,8 @@ "type": "github" } ], - "time": "2026-09-25T15:24:19+00:00" + "time": "2026-11-03T10:10:19+00:00", + "published-time": "2026-11-03T10:12:49+00:00" }, { "name": "composer/pcre", @@ -149,7 +150,8 @@ "type": "github" } ], - "time": "2026-06-07T11:47:49+00:00" + "time": "2026-06-07T11:47:49+00:00", + "published-time": "2026-06-11T12:12:37+00:00" }, { "name": "dealerdirect/phpcodesniffer-composer-installer", @@ -245,7 +247,8 @@ "type": "thanks_dev" } ], - "time": "2026-05-06T08:26:05+00:00" + "time": "2026-05-06T08:26:05+00:00", + "published-time": "2026-05-06T12:56:03+00:00" }, { "name": "johnbillion/wp-compat", @@ -320,7 +323,8 @@ "type": "github" } ], - "time": "2026-10-21T20:54:48+00:00" + "time": "2026-10-21T20:54:48+00:00", + "published-time": "2026-10-21T20:54:55+00:00" }, { "name": "nikic/php-parser", @@ -377,7 +381,8 @@ "issues": "https://github.com/nikic/PHP-Parser/issues", "source": "https://github.com/nikic/PHP-Parser/tree/v5.9.0" }, - "time": "2026-09-13T18:51:52+00:00" + "time": "2026-09-13T18:51:52+00:00", + "published-time": "2026-09-13T18:53:16+00:00" }, { "name": "php-stubs/wordpress-stubs", @@ -429,7 +434,8 @@ "issues": "https://github.com/php-stubs/wordpress-stubs/issues", "source": "https://github.com/php-stubs/wordpress-stubs/tree/v7.1.2" }, - "time": "2026-10-06T06:59:21+00:00" + "time": "2026-10-06T06:59:21+00:00", + "published-time": "2026-10-06T07:32:21+00:00" }, { "name": "phpcompatibility/php-compatibility", @@ -519,7 +525,8 @@ "type": "thanks_dev" } ], - "time": "2025-11-28T11:36:33+00:00" + "time": "2025-11-28T11:36:33+00:00", + "published-time": "2025-11-28T11:41:33+00:00" }, { "name": "phpcompatibility/phpcompatibility-paragonie", @@ -590,7 +597,8 @@ "type": "thanks_dev" } ], - "time": "2025-11-29T13:09:49+00:00" + "time": "2025-11-29T13:09:49+00:00", + "published-time": "2025-11-29T13:22:17+00:00" }, { "name": "phpcompatibility/phpcompatibility-wp", @@ -657,7 +665,8 @@ "type": "thanks_dev" } ], - "time": "2025-12-16T13:35:20+00:00" + "time": "2025-12-16T13:35:20+00:00", + "published-time": "2025-12-16T13:47:29+00:00" }, { "name": "phpcsstandards/phpcsextra", @@ -739,7 +748,8 @@ "type": "thanks_dev" } ], - "time": "2026-07-27T11:13:17+00:00" + "time": "2026-07-27T11:13:17+00:00", + "published-time": "2026-07-27T11:19:59+00:00" }, { "name": "phpcsstandards/phpcsutils", @@ -832,7 +842,8 @@ "type": "thanks_dev" } ], - "time": "2026-07-27T10:28:41+00:00" + "time": "2026-07-27T10:28:41+00:00", + "published-time": "2026-07-27T10:36:31+00:00" }, { "name": "phpstan/extension-installer", @@ -880,15 +891,16 @@ "issues": "https://github.com/phpstan/extension-installer/issues", "source": "https://github.com/phpstan/extension-installer/tree/1.4.3" }, - "time": "2024-09-04T20:21:43+00:00" + "time": "2024-09-04T20:21:43+00:00", + "published-time": "2024-09-04T20:22:27+00:00" }, { "name": "phpstan/phpstan", - "version": "2.3.4", + "version": "2.3.5", "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/f20ce6f3c82556cfcfc086b152445a12d1d64fff", - "reference": "f20ce6f3c82556cfcfc086b152445a12d1d64fff", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/645a1bd6bead15fc1b8474137467961d0fa30a79", + "reference": "645a1bd6bead15fc1b8474137467961d0fa30a79", "shasum": "" }, "require": { @@ -944,7 +956,8 @@ "type": "github" } ], - "time": "2026-10-28T22:38:53+00:00" + "time": "2026-11-03T12:14:22+00:00", + "published-time": "2026-11-03T12:14:36+00:00" }, { "name": "phpstan/phpstan-deprecation-rules", @@ -995,20 +1008,21 @@ "issues": "https://github.com/phpstan/phpstan-deprecation-rules/issues", "source": "https://github.com/phpstan/phpstan-deprecation-rules/tree/2.0.5" }, - "time": "2026-07-22T06:50:43+00:00" + "time": "2026-07-22T06:50:43+00:00", + "published-time": "2026-07-27T15:18:00+00:00" }, { "name": "phpstan/phpstan-strict-rules", - "version": "2.1.0", + "version": "2.1.1", "source": { "type": "git", "url": "https://github.com/phpstan/phpstan-strict-rules.git", - "reference": "24ec51589f896f048d0d36de373d993b5cf71786" + "reference": "fe4a0afae773eedf178fab17ed9361c0061d2804" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan-strict-rules/zipball/24ec51589f896f048d0d36de373d993b5cf71786", - "reference": "24ec51589f896f048d0d36de373d993b5cf71786", + "url": "https://api.github.com/repos/phpstan/phpstan-strict-rules/zipball/fe4a0afae773eedf178fab17ed9361c0061d2804", + "reference": "fe4a0afae773eedf178fab17ed9361c0061d2804", "shasum": "" }, "require": { @@ -1045,9 +1059,10 @@ ], "support": { "issues": "https://github.com/phpstan/phpstan-strict-rules/issues", - "source": "https://github.com/phpstan/phpstan-strict-rules/tree/2.1.0" + "source": "https://github.com/phpstan/phpstan-strict-rules/tree/2.1.1" }, - "time": "2026-10-01T21:30:37+00:00" + "time": "2026-11-02T08:16:15+00:00", + "published-time": "2026-11-02T10:55:07+00:00" }, { "name": "roave/security-advisories", @@ -1055,12 +1070,12 @@ "source": { "type": "git", "url": "https://github.com/Roave/SecurityAdvisories.git", - "reference": "7fd2aa4f9a2f0db3e04724273d5121c1d16a6c66" + "reference": "7252ec7fc650313ca2c1ecc1e556da38d93acd56" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/7fd2aa4f9a2f0db3e04724273d5121c1d16a6c66", - "reference": "7fd2aa4f9a2f0db3e04724273d5121c1d16a6c66", + "url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/7252ec7fc650313ca2c1ecc1e556da38d93acd56", + "reference": "7252ec7fc650313ca2c1ecc1e556da38d93acd56", "shasum": "" }, "conflict": { @@ -1068,6 +1083,7 @@ "adaptcms/adaptcms": "<=1.3", "adawolfa/isdoc": "<1.4.3|>=1.5,<1.5.1|>=1.6,<1.6.1", "admidio/admidio": "<=5.0.11", + "adminneo-org/adminneo": "<5.5.1", "adodb/adodb-php": "<=5.22.9", "aheinze/cockpit": "<2.2", "aimeos/ai-admin-graphql": ">=2022.04.1,<2022.10.10|>=2023.04.1,<2023.10.6|>=2024.04.1,<2024.07.2", @@ -1264,7 +1280,7 @@ "doctrine/dbal": ">=2,<2.0.8|>=2.1,<2.1.2|>=3,<3.1.4", "doctrine/doctrine-bundle": "<1.5.2", "doctrine/doctrine-module": "<0.7.2", - "doctrine/mongodb-odm": "<1.0.2", + "doctrine/mongodb-odm": "<1.0.2|>=2.12,<2.17.3", "doctrine/mongodb-odm-bundle": "<3.0.1", "doctrine/orm": ">=1,<1.2.4|>=2,<2.4.8|>=2.5,<2.5.1|>=2.8.3,<2.8.4", "dolibarr/dolibarr": "<=23.0.2", @@ -1327,7 +1343,7 @@ "erusev/parsedown": "<1.7.2", "ether/logs": "<3.0.4", "evolutioncms/evolution": "<=3.2.3", - "evoweb/sf-register": "<13.2.4|>=14,<14.0.2", + "evoweb/sf-register": "<13.2.5|>=14,<14.0.3", "exceedone/exment": "<4.4.3|>=5,<5.0.3", "exceedone/laravel-admin": "<2.2.3|==3", "ezsystems/demobundle": ">=5.4,<5.4.6.1-dev", @@ -1595,6 +1611,7 @@ "mckenziearts/livewire-markdown-editor": "<1.3", "mcp/sdk": ">=0.5,<0.7.1", "mdanter/ecc": "<2", + "mediaessenz/mail": "<3.12.5|>=4,<4.2", "mediawiki/abuse-filter": "<1.39.9|>=1.40,<1.41.3|>=1.42,<1.42.2", "mediawiki/cargo": "<3.8.3", "mediawiki/core": "<1.39.5|==1.40", @@ -1784,6 +1801,7 @@ "prestashop/ps_emailsubscription": "<2.6.1", "prestashop/ps_facetedsearch": "<4.0.4", "prestashop/ps_linklist": "<3.1", + "prinsfrank/pdfparser": "<2.9|>=3,<3.9|>=4,<4.3", "privatebin/privatebin": "<=2.0.4", "processwire/processwire": "<=3.0.255", "propel/propel": ">=2.0.0.0-alpha1,<2.0.0.0-alpha8", @@ -1849,11 +1867,11 @@ "shuchkin/simplexlsx": ">=1.0.12,<1.1.13", "silverstripe-australia/advancedreports": ">=1,<=2", "silverstripe/admin": "<1.13.19|>=2,<2.1.8", - "silverstripe/assets": "<2.4.5|>=3,<3.1.3", + "silverstripe/assets": "<2.4.6|>=3,<3.2.4", "silverstripe/cms": "<6.2.1", "silverstripe/comments": ">=1.3,<3.1.1", "silverstripe/forum": "<0.6.2|>=0.7,<0.7.4", - "silverstripe/framework": "<6.2.2", + "silverstripe/framework": "<6.2.13", "silverstripe/graphql": ">=2,<2.0.5|>=3,<3.8.2|>=4,<4.3.7|>=5,<5.1.3", "silverstripe/hybridsessions": ">=1,<2.4.1|>=2.5,<2.5.1", "silverstripe/mfa": "<6", @@ -1864,7 +1882,7 @@ "silverstripe/silverstripe-omnipay": "<2.5.2|>=3,<3.0.2|>=3.1,<3.1.4|>=3.2,<3.2.1", "silverstripe/subsites": ">=2,<2.6.1", "silverstripe/taxonomy": ">=1.3,<1.3.1|>=2,<2.0.1", - "silverstripe/userforms": "<6.4.9|>=7,<7.0.7|>=7.1,<7.1.1", + "silverstripe/userforms": "<7.1.4", "silverstripe/versioned": "<3.2.1", "silverstripe/versioned-admin": ">=1,<1.11.1", "simogeo/filemanager": "<=2.5", @@ -1930,25 +1948,27 @@ "sylius/paypal-plugin": "<1.6.2|>=1.7,<1.7.2|>=2,<2.0.2", "sylius/resource-bundle": ">=1,<1.3.14|>=1.4,<1.4.7|>=1.5,<1.5.2|>=1.6,<1.6.4", "sylius/sylius": "<1.14.21|>=2,<2.1.17|>=2.2,<2.2.12|>=2.3,<2.3.2", - "symbiote/silverstripe-advancedworkflow": "<6.4.5|>=7,<7.1.3|>=7.2,<7.2.1", + "symbiote/silverstripe-advancedworkflow": "<6.4.6|>=7,<7.2.4", "symbiote/silverstripe-multivaluefield": ">=3,<3.1", "symbiote/silverstripe-queuedjobs": ">=3,<3.0.2|>=3.1,<3.1.4|>=4,<4.0.7|>=4.1,<4.1.2|>=4.2,<4.2.4|>=4.3,<4.3.3|>=4.4,<4.4.3|>=4.5,<4.5.1|>=4.6,<4.6.4", "symbiote/silverstripe-seed": "<6.0.3", "symbiote/silverstripe-versionedfiles": "<=2.0.3", "symfont/process": ">=0", "symfony/cache": "<5.4.52|>=6,<6.4.40|>=7,<7.4.12|>=8,<8.0.12", + "symfony/config": ">=2,<5.4.56|>=6,<6.4.50|>=7,<7.4.23|>=8,<8.1.11", "symfony/dependency-injection": ">=2,<2.0.17|>=2.7,<2.7.51|>=2.8,<2.8.50|>=3,<3.4.26|>=4,<4.1.12|>=4.2,<4.2.7", "symfony/dom-crawler": "<5.4.52|>=6,<6.4.40|>=7,<7.4.12|>=8,<8.0.12", "symfony/error-handler": ">=4.4,<4.4.4|>=5,<5.0.4", "symfony/expression-language": ">=2.4,<5.4.54|>=6,<6.4.48|>=7,<7.4.21|>=8,<8.1.9", + "symfony/filesystem": ">=5.4,<5.4.56|>=6,<6.4.50|>=7,<7.4.23|>=8,<8.1.11", "symfony/form": ">=2.3,<2.3.35|>=2.4,<2.6.12|>=2.7,<2.7.50|>=2.8,<2.8.49|>=3,<3.4.20|>=4,<4.0.15|>=4.1,<4.1.9|>=4.2,<4.2.1", - "symfony/framework-bundle": ">=2,<2.3.18|>=2.4,<2.4.8|>=2.5,<2.5.2|>=2.7,<2.7.51|>=2.8,<2.8.50|>=3,<3.4.26|>=4,<4.1.12|>=4.2,<4.2.7|>=5.3.14,<5.3.15|>=5.4.3,<5.4.4|>=6.0.3,<6.0.4", - "symfony/html-sanitizer": ">=6.1,<6.4.41|>=7,<7.4.13|>=8,<8.0.13", - "symfony/http-client": ">=4.3,<5.4.53|>=6,<6.4.15|>=7,<7.1.8", - "symfony/http-foundation": "<5.4.54|>=6,<6.4.46|>=7,<7.4.19|>=8,<8.1.7", - "symfony/http-kernel": ">=2,<4.4.50|>=5,<5.4.20|>=6,<6.0.20|>=6.1,<6.1.12|>=6.2,<6.2.6|>=7.4,<7.4.12|>=8,<8.0.12", + "symfony/framework-bundle": ">=2,<2.3.18|>=2.4,<2.4.8|>=2.5,<2.5.2|>=2.7,<2.7.51|>=2.8,<2.8.50|>=3,<3.4.26|>=4,<4.1.12|>=4.2,<4.2.7|>=5.3.14,<5.3.15|>=5.4.3,<5.4.4|>=6.0.3,<6.0.4|>=7.1,<7.4.23|>=8,<8.1.11", + "symfony/html-sanitizer": ">=6.1,<6.4.50|>=7,<7.4.23|>=8,<8.1.11", + "symfony/http-client": ">=4.3,<5.4.56|>=6,<6.4.50|>=7,<7.4.23|>=8,<8.1.11", + "symfony/http-foundation": "<5.4.56|>=6,<6.4.50|>=7,<7.4.23|>=8,<8.1.11", + "symfony/http-kernel": ">=2,<5.4.56|>=6,<6.4.50|>=7,<7.4.23|>=8,<8.1.11", "symfony/intl": ">=2.7,<2.7.38|>=2.8,<2.8.31|>=3,<3.2.14|>=3.3,<3.3.13", - "symfony/json-path": ">=7.3,<7.4.12|>=8,<8.0.12", + "symfony/json-path": ">=7.3,<7.4.23|>=8,<8.1.11", "symfony/lox24-notifier": ">=7.1,<7.4.12|>=8,<8.0.12", "symfony/mailer": "<5.4.52|>=6,<6.4.40|>=7,<7.4.12|>=8,<8.0.12", "symfony/mailjet-mailer": ">=6.4,<6.4.40|>=7,<7.4.12|>=8,<8.0.12", @@ -1963,16 +1983,16 @@ "symfony/polyfill-php55": ">=1,<1.10", "symfony/process": "<5.4.51|>=6,<6.4.33|>=7,<7.1.7|>=7.3,<7.3.11|>=7.4,<7.4.5|>=8,<8.0.5", "symfony/proxy-manager-bridge": ">=2.7,<2.7.51|>=2.8,<2.8.50|>=3,<3.4.26|>=4,<4.1.12|>=4.2,<4.2.7", - "symfony/routing": "<5.4.53|>=6,<6.4.41|>=7,<7.4.13|>=8,<8.0.13", + "symfony/routing": "<5.4.56|>=6,<6.4.50|>=7,<7.4.23|>=8,<8.1.11", "symfony/runtime": ">=5.3,<5.4.52|>=6,<6.4.40|>=7,<7.4.12|>=8,<8.0.12", "symfony/security": ">=2,<2.7.51|>=2.8,<3.4.49|>=4,<4.4.24|>=5,<5.2.8", "symfony/security-bundle": ">=2,<4.4.50|>=5,<5.4.20|>=6,<6.0.20|>=6.1,<6.1.12|>=6.2,<6.4.10|>=7,<7.0.10|>=7.1,<7.1.3", "symfony/security-core": ">=2.4,<2.6.13|>=2.7,<2.7.9|>=2.7.30,<2.7.32|>=2.8,<3.4.49|>=4,<4.4.24|>=5,<5.2.9", "symfony/security-csrf": ">=2.4,<2.7.48|>=2.8,<2.8.41|>=3,<3.3.17|>=3.4,<3.4.11|>=4,<4.0.11", "symfony/security-guard": ">=2.8,<3.4.48|>=4,<4.4.23|>=5,<5.2.8", - "symfony/security-http": "<5.4.53|>=6,<6.4.41|>=7,<7.4.13|>=8,<8.0.13", - "symfony/serializer": ">=2,<2.0.11|>=4.1,<4.4.35|>=5,<5.3.12", - "symfony/symfony": "<5.4.55|>=6,<6.4.49|>=7,<7.4.22|>=8,<8.1.10", + "symfony/security-http": "<5.4.56|>=6,<6.4.50|>=7,<7.4.23|>=8,<8.1.11", + "symfony/serializer": ">=2,<5.4.56|>=6,<6.4.50|>=7,<7.4.23|>=8,<8.1.11", + "symfony/symfony": "<5.4.56|>=6,<6.4.50|>=7,<7.4.23|>=8,<8.1.11", "symfony/translation": ">=2,<2.0.17", "symfony/twig-bridge": ">=2,<4.4.51|>=5,<5.4.31|>=6,<6.3.8|>=6.4.24,<6.4.40", "symfony/twilio-notifier": ">=6.4,<6.4.40|>=7,<7.4.12|>=8,<8.0.12", @@ -1981,11 +2001,11 @@ "symfony/ux-live-component": "<2.36|>=3,<3.1", "symfony/ux-toolkit": ">=2.32,<2.36.1|>=3,<3.2", "symfony/ux-twig-component": "<2.25.1", - "symfony/validator": "<5.4.43|>=6,<6.4.11|>=7,<7.1.4", + "symfony/validator": "<5.4.56|>=6,<6.4.50|>=7,<7.4.23|>=8,<8.1.11", "symfony/var-exporter": ">=4.2,<4.2.12|>=4.3,<4.3.8", "symfony/web-profiler-bundle": ">=2,<2.3.19|>=2.4,<2.4.9|>=2.5,<2.5.4|>=7.2.9,<7.4.12|>=8,<8.0.12", "symfony/webhook": ">=6.3,<6.3.8", - "symfony/yaml": "<5.4.54|>=6,<6.4.45|>=7,<7.4.18|>=8,<8.1.6", + "symfony/yaml": "<5.4.56|>=6,<6.4.50|>=7,<7.4.23|>=8,<8.1.11", "symphonycms/symphony-2": "<2.6.4", "syssy/syssy-typo3-extension": "<3.0.6", "t3/dce": "<0.11.5|>=2.2,<2.6.2", @@ -2216,26 +2236,27 @@ "type": "tidelift" } ], - "time": "2026-10-29T16:37:57+00:00" + "time": "2026-11-05T19:32:52+00:00", + "published-time": "2026-11-05T19:32:58+00:00" }, { "name": "sniccowp/php-scoper-wordpress-excludes", - "version": "7.1.0", + "version": "7.1.2", "source": { "type": "git", "url": "https://github.com/snicco/php-scoper-wordpress-excludes.git", - "reference": "d8ad8aae8a051a4f332150f37a7bf96df1d11d0a" + "reference": "3dfa46a15362197a7ebc423d4dc80db2f5b3cd35" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/snicco/php-scoper-wordpress-excludes/zipball/d8ad8aae8a051a4f332150f37a7bf96df1d11d0a", - "reference": "d8ad8aae8a051a4f332150f37a7bf96df1d11d0a", + "url": "https://api.github.com/repos/snicco/php-scoper-wordpress-excludes/zipball/3dfa46a15362197a7ebc423d4dc80db2f5b3cd35", + "reference": "3dfa46a15362197a7ebc423d4dc80db2f5b3cd35", "shasum": "" }, "require-dev": { "php": "^7.4", "php-stubs/wordpress-globals": "0.2.0", - "php-stubs/wordpress-stubs": "7.1.0", + "php-stubs/wordpress-stubs": "7.1.2", "sniccowp/php-scoper-excludes": "dev-master" }, "type": "library", @@ -2261,9 +2282,10 @@ ], "support": { "issues": "https://github.com/snicco/php-scoper-wordpress-excludes/issues", - "source": "https://github.com/snicco/php-scoper-wordpress-excludes/tree/7.1.0" + "source": "https://github.com/snicco/php-scoper-wordpress-excludes/tree/7.1.2" }, - "time": "2026-08-28T02:50:05+00:00" + "time": "2026-10-07T00:26:15+00:00", + "published-time": "2026-11-01T10:37:09+00:00" }, { "name": "squizlabs/php_codesniffer", @@ -2342,7 +2364,8 @@ "type": "thanks_dev" } ], - "time": "2026-08-06T00:17:32+00:00" + "time": "2026-08-06T00:17:32+00:00", + "published-time": "2026-08-06T01:05:02+00:00" }, { "name": "swissspidy/phpstan-no-private", @@ -2394,20 +2417,21 @@ "issues": "https://github.com/swissspidy/phpstan-no-private/issues", "source": "https://github.com/swissspidy/phpstan-no-private/tree/v1.0.0" }, - "time": "2024-11-11T11:04:45+00:00" + "time": "2024-11-11T11:04:45+00:00", + "published-time": "2024-11-11T11:08:03+00:00" }, { "name": "symfony/filesystem", - "version": "v7.4.21", + "version": "v7.4.23", "source": { "type": "git", "url": "https://github.com/symfony/filesystem.git", - "reference": "fd0e3462be6d32a0d130e8423e4940a7688162d8" + "reference": "53d609da66578dad5a39fe290e8f924ec8f05e93" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/filesystem/zipball/fd0e3462be6d32a0d130e8423e4940a7688162d8", - "reference": "fd0e3462be6d32a0d130e8423e4940a7688162d8", + "url": "https://api.github.com/repos/symfony/filesystem/zipball/53d609da66578dad5a39fe290e8f924ec8f05e93", + "reference": "53d609da66578dad5a39fe290e8f924ec8f05e93", "shasum": "" }, "require": { @@ -2444,7 +2468,7 @@ "description": "Provides basic utilities for the filesystem", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/filesystem/tree/v7.4.21" + "source": "https://github.com/symfony/filesystem/tree/v7.4.23" }, "funding": [ { @@ -2464,20 +2488,21 @@ "type": "tidelift" } ], - "time": "2026-10-06T20:05:19+00:00" + "time": "2026-11-04T08:27:22+00:00", + "published-time": "2026-11-04T11:28:00+00:00" }, { "name": "symfony/finder", - "version": "v7.4.22", + "version": "v7.4.23", "source": { "type": "git", "url": "https://github.com/symfony/finder.git", - "reference": "e13420a8e0a76bdde1e7be09f923e62ad13739f2" + "reference": "c723f990aefe8c1053b18374faf4231eba84f518" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/finder/zipball/e13420a8e0a76bdde1e7be09f923e62ad13739f2", - "reference": "e13420a8e0a76bdde1e7be09f923e62ad13739f2", + "url": "https://api.github.com/repos/symfony/finder/zipball/c723f990aefe8c1053b18374faf4231eba84f518", + "reference": "c723f990aefe8c1053b18374faf4231eba84f518", "shasum": "" }, "require": { @@ -2512,7 +2537,7 @@ "description": "Finds files and directories via an intuitive fluent interface", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/finder/tree/v7.4.22" + "source": "https://github.com/symfony/finder/tree/v7.4.23" }, "funding": [ { @@ -2532,7 +2557,8 @@ "type": "tidelift" } ], - "time": "2026-10-19T16:56:54+00:00" + "time": "2026-11-02T10:56:49+00:00", + "published-time": "2026-11-04T11:28:12+00:00" }, { "name": "symfony/polyfill-ctype", @@ -2615,7 +2641,8 @@ "type": "tidelift" } ], - "time": "2026-04-10T16:19:22+00:00" + "time": "2026-04-10T16:19:22+00:00", + "published-time": "2026-04-26T14:44:50+00:00" }, { "name": "symfony/polyfill-mbstring", @@ -2700,7 +2727,8 @@ "type": "tidelift" } ], - "time": "2026-10-28T09:35:49+00:00" + "time": "2026-10-28T09:35:49+00:00", + "published-time": "2026-10-28T10:07:45+00:00" }, { "name": "szepeviktor/phpstan-wordpress", @@ -2763,7 +2791,8 @@ "issues": "https://github.com/szepeviktor/phpstan-wordpress/issues", "source": "https://github.com/szepeviktor/phpstan-wordpress/tree/v2.0.4" }, - "time": "2026-05-22T16:22:09+00:00" + "time": "2026-05-22T16:22:09+00:00", + "published-time": "2026-08-28T06:48:49+00:00" }, { "name": "wp-coding-standards/wpcs", @@ -2829,7 +2858,8 @@ "type": "custom" } ], - "time": "2026-07-27T11:53:23+00:00" + "time": "2026-07-27T11:53:23+00:00", + "published-time": "2026-07-27T11:55:52+00:00" }, { "name": "wp-hooks/wordpress-core", @@ -2912,7 +2942,8 @@ "type": "github" } ], - "time": "2026-08-20T19:54:29+00:00" + "time": "2026-08-20T19:54:29+00:00", + "published-time": "2026-08-20T19:54:42+00:00" } ], "packages-dev": [ @@ -3002,7 +3033,8 @@ "type": "patreon" } ], - "time": "2024-02-09T13:06:12+00:00" + "time": "2024-02-09T13:06:12+00:00", + "published-time": "2024-02-09T13:06:43+00:00" }, { "name": "composer/ca-bundle", @@ -3074,20 +3106,21 @@ "type": "github" } ], - "time": "2026-10-29T14:10:42+00:00" + "time": "2026-10-29T14:10:42+00:00", + "published-time": "2026-10-29T14:45:12+00:00" }, { "name": "composer/composer", - "version": "2.11.0", + "version": "2.11.1", "source": { "type": "git", "url": "https://github.com/composer/composer.git", - "reference": "e52c65bed1de6aa272cdde4b7725ca6653d7b06a" + "reference": "472b418967419c6124edd91dc6310a3bec091dae" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/composer/composer/zipball/e52c65bed1de6aa272cdde4b7725ca6653d7b06a", - "reference": "e52c65bed1de6aa272cdde4b7725ca6653d7b06a", + "url": "https://api.github.com/repos/composer/composer/zipball/472b418967419c6124edd91dc6310a3bec091dae", + "reference": "472b418967419c6124edd91dc6310a3bec091dae", "shasum": "" }, "require": { @@ -3177,7 +3210,7 @@ "irc": "ircs://irc.libera.chat:6697/composer", "issues": "https://github.com/composer/composer/issues", "security": "https://github.com/composer/composer/security/policy", - "source": "https://github.com/composer/composer/tree/2.11.0" + "source": "https://github.com/composer/composer/tree/2.11.1" }, "funding": [ { @@ -3189,7 +3222,8 @@ "type": "github" } ], - "time": "2026-10-29T09:23:20+00:00" + "time": "2026-10-30T13:52:10+00:00", + "published-time": "2026-10-30T14:03:09+00:00" }, { "name": "composer/metadata-minifier", @@ -3254,7 +3288,8 @@ "type": "github" } ], - "time": "2026-06-30T11:34:59+00:00" + "time": "2026-06-30T11:34:59+00:00", + "published-time": "2026-06-30T11:37:18+00:00" }, { "name": "composer/semver", @@ -3331,7 +3366,8 @@ "type": "github" } ], - "time": "2026-10-29T09:13:34+00:00" + "time": "2026-10-29T09:13:34+00:00", + "published-time": "2026-10-29T09:14:11+00:00" }, { "name": "composer/spdx-licenses", @@ -3407,7 +3443,8 @@ "type": "github" } ], - "time": "2026-04-08T20:18:39+00:00" + "time": "2026-04-08T20:18:39+00:00", + "published-time": "2026-04-08T20:19:56+00:00" }, { "name": "composer/xdebug-handler", @@ -3473,7 +3510,8 @@ "type": "tidelift" } ], - "time": "2024-05-06T16:37:16+00:00" + "time": "2024-05-06T16:37:16+00:00", + "published-time": "2024-05-06T17:01:42+00:00" }, { "name": "dompdf/dompdf", @@ -3537,7 +3575,8 @@ "issues": "https://github.com/dompdf/dompdf/issues", "source": "https://github.com/dompdf/dompdf/tree/v3.1.6" }, - "time": "2026-07-20T12:29:38+00:00" + "time": "2026-07-20T12:29:38+00:00", + "published-time": "2026-07-20T12:35:08+00:00" }, { "name": "dompdf/php-font-lib", @@ -3582,7 +3621,8 @@ "issues": "https://github.com/dompdf/php-font-lib/issues", "source": "https://github.com/dompdf/php-font-lib/tree/1.0.2" }, - "time": "2026-01-20T14:10:26+00:00" + "time": "2026-01-20T14:10:26+00:00", + "published-time": "2026-01-20T14:14:50+00:00" }, { "name": "dompdf/php-svg-lib", @@ -3628,7 +3668,8 @@ "issues": "https://github.com/dompdf/php-svg-lib/issues", "source": "https://github.com/dompdf/php-svg-lib/tree/1.0.2" }, - "time": "2026-01-02T16:01:13+00:00" + "time": "2026-01-02T16:01:13+00:00", + "published-time": "2026-01-02T16:02:22+00:00" }, { "name": "fidry/console", @@ -3714,7 +3755,8 @@ "type": "github" } ], - "time": "2025-02-14T11:06:15+00:00" + "time": "2025-02-14T11:06:15+00:00", + "published-time": "2025-02-14T11:09:06+00:00" }, { "name": "fidry/cpu-core-counter", @@ -3776,7 +3818,8 @@ "type": "github" } ], - "time": "2026-09-29T22:11:39+00:00" + "time": "2026-09-29T22:11:39+00:00", + "published-time": "2026-09-29T22:12:43+00:00" }, { "name": "fidry/filesystem", @@ -3842,7 +3885,8 @@ "type": "github" } ], - "time": "2025-02-13T23:05:19+00:00" + "time": "2025-02-13T23:05:19+00:00", + "published-time": "2025-02-13T23:09:29+00:00" }, { "name": "humbug/php-scoper", @@ -3928,7 +3972,8 @@ "issues": "https://github.com/humbug/php-scoper/issues", "source": "https://github.com/humbug/php-scoper/tree/0.18.20" }, - "time": "2026-10-08T08:21:05+00:00" + "time": "2026-10-08T08:21:05+00:00", + "published-time": "2026-10-08T08:29:48+00:00" }, { "name": "infection/abstract-testframework-adapter", @@ -3985,7 +4030,8 @@ "type": "open_collective" } ], - "time": "2026-05-28T19:10:29+00:00" + "time": "2026-05-28T19:10:29+00:00", + "published-time": "2026-05-28T19:12:08+00:00" }, { "name": "infection/extension-installer", @@ -4051,7 +4097,8 @@ "type": "open_collective" } ], - "time": "2021-10-20T22:08:34+00:00" + "time": "2021-10-20T22:08:34+00:00", + "published-time": "2022-01-17T16:32:15+00:00" }, { "name": "infection/include-interceptor", @@ -4107,20 +4154,21 @@ "type": "open_collective" } ], - "time": "2024-05-03T21:48:06+00:00" + "time": "2024-05-03T21:48:06+00:00", + "published-time": "2026-05-06T22:52:27+00:00" }, { "name": "infection/infection", - "version": "0.35.8", + "version": "0.35.9", "source": { "type": "git", "url": "https://github.com/infection/infection.git", - "reference": "642f7974a73713e7a2219baea0cd9b0856afba4b" + "reference": "3ca3e072dbb578257c636e81b947fe050bc61c69" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/infection/infection/zipball/642f7974a73713e7a2219baea0cd9b0856afba4b", - "reference": "642f7974a73713e7a2219baea0cd9b0856afba4b", + "url": "https://api.github.com/repos/infection/infection/zipball/3ca3e072dbb578257c636e81b947fe050bc61c69", + "reference": "3ca3e072dbb578257c636e81b947fe050bc61c69", "shasum": "" }, "require": { @@ -4235,7 +4283,7 @@ ], "support": { "issues": "https://github.com/infection/infection/issues", - "source": "https://github.com/infection/infection/tree/0.35.8" + "source": "https://github.com/infection/infection/tree/0.35.9" }, "funding": [ { @@ -4247,7 +4295,8 @@ "type": "open_collective" } ], - "time": "2026-10-14T09:21:58+00:00" + "time": "2026-11-02T17:30:39+00:00", + "published-time": "2026-11-02T17:39:26+00:00" }, { "name": "infection/mutator", @@ -4300,7 +4349,8 @@ "type": "open_collective" } ], - "time": "2025-04-29T08:19:52+00:00" + "time": "2025-04-29T08:19:52+00:00", + "published-time": "2025-04-29T08:21:00+00:00" }, { "name": "jetbrains/phpstorm-stubs", @@ -4344,7 +4394,8 @@ "stubs", "type" ], - "time": "2026-06-12T13:19:10+00:00" + "time": "2026-06-12T13:19:10+00:00", + "published-time": "2026-07-22T11:41:13+00:00" }, { "name": "justinrainbow/json-schema", @@ -4409,7 +4460,8 @@ "issues": "https://github.com/jsonrainbow/json-schema/issues", "source": "https://github.com/jsonrainbow/json-schema/tree/6.14.0" }, - "time": "2026-10-24T08:53:34+00:00" + "time": "2026-10-24T08:53:34+00:00", + "published-time": "2026-10-24T08:53:38+00:00" }, { "name": "laravel/serializable-closure", @@ -4470,7 +4522,8 @@ "issues": "https://github.com/laravel/serializable-closure/issues", "source": "https://github.com/laravel/serializable-closure" }, - "time": "2026-09-22T14:32:34+00:00" + "time": "2026-09-22T14:32:34+00:00", + "published-time": "2026-09-22T14:33:13+00:00" }, { "name": "marc-mabe/php-enum", @@ -4543,7 +4596,8 @@ "issues": "https://github.com/marc-mabe/php-enum/issues", "source": "https://github.com/marc-mabe/php-enum/tree/v4.7.2" }, - "time": "2025-09-14T11:18:39+00:00" + "time": "2025-09-14T11:18:39+00:00", + "published-time": "2025-09-14T11:32:17+00:00" }, { "name": "masterminds/html5", @@ -4611,7 +4665,8 @@ "issues": "https://github.com/Masterminds/html5-php/issues", "source": "https://github.com/Masterminds/html5-php/tree/2.12.0" }, - "time": "2026-10-25T08:57:58+00:00" + "time": "2026-10-25T08:57:58+00:00", + "published-time": "2026-10-25T08:58:48+00:00" }, { "name": "myclabs/deep-copy", @@ -4671,7 +4726,8 @@ "type": "github" } ], - "time": "2026-08-11T10:17:44+00:00" + "time": "2026-08-11T10:17:44+00:00", + "published-time": "2026-08-11T10:29:20+00:00" }, { "name": "ondram/ci-detector", @@ -4749,7 +4805,8 @@ "issues": "https://github.com/OndraM/ci-detector/issues", "source": "https://github.com/OndraM/ci-detector/tree/4.2.0" }, - "time": "2024-03-12T13:22:30+00:00" + "time": "2024-03-12T13:22:30+00:00", + "published-time": "2024-03-12T13:23:38+00:00" }, { "name": "phar-io/manifest", @@ -4816,7 +4873,8 @@ "type": "github" } ], - "time": "2024-03-03T12:33:53+00:00" + "time": "2024-03-03T12:33:53+00:00", + "published-time": "2024-03-03T12:39:44+00:00" }, { "name": "phar-io/version", @@ -4867,7 +4925,8 @@ "issues": "https://github.com/phar-io/version/issues", "source": "https://github.com/phar-io/version/tree/3.2.1" }, - "time": "2022-02-21T01:04:05+00:00" + "time": "2022-02-21T01:04:05+00:00", + "published-time": "2022-02-21T01:04:30+00:00" }, { "name": "php-di/invoker", @@ -4922,7 +4981,8 @@ "type": "github" } ], - "time": "2025-08-30T10:22:22+00:00" + "time": "2025-08-30T10:22:22+00:00", + "published-time": "2025-08-30T10:22:43+00:00" }, { "name": "php-di/php-di", @@ -4995,20 +5055,21 @@ "type": "tidelift" } ], - "time": "2025-08-16T11:10:48+00:00" + "time": "2025-08-16T11:10:48+00:00", + "published-time": "2025-08-16T11:11:08+00:00" }, { "name": "php-stubs/woocommerce-stubs", - "version": "v11.2.0", + "version": "v11.2.2", "source": { "type": "git", "url": "https://github.com/php-stubs/woocommerce-stubs.git", - "reference": "fb590c1c2a26c47aa5da2c6c51a707130e8a389c" + "reference": "d5c232c183c09689d75c177b01bef60e6dd53639" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-stubs/woocommerce-stubs/zipball/fb590c1c2a26c47aa5da2c6c51a707130e8a389c", - "reference": "fb590c1c2a26c47aa5da2c6c51a707130e8a389c", + "url": "https://api.github.com/repos/php-stubs/woocommerce-stubs/zipball/d5c232c183c09689d75c177b01bef60e6dd53639", + "reference": "d5c232c183c09689d75c177b01bef60e6dd53639", "shasum": "" }, "require": { @@ -5037,22 +5098,23 @@ ], "support": { "issues": "https://github.com/php-stubs/woocommerce-stubs/issues", - "source": "https://github.com/php-stubs/woocommerce-stubs/tree/v11.2.0" + "source": "https://github.com/php-stubs/woocommerce-stubs/tree/v11.2.2" }, - "time": "2026-10-07T19:02:59+00:00" + "time": "2026-11-04T21:47:17+00:00", + "published-time": "2026-11-04T21:48:23+00:00" }, { "name": "phpunit/php-code-coverage", - "version": "14.5.0", + "version": "14.5.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-code-coverage.git", - "reference": "1db2f40af7a6538742e4a634b71ee556d6219be7" + "reference": "4063552c678ce3beb4b7f131a5b7354a19ff3e2a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/1db2f40af7a6538742e4a634b71ee556d6219be7", - "reference": "1db2f40af7a6538742e4a634b71ee556d6219be7", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/4063552c678ce3beb4b7f131a5b7354a19ff3e2a", + "reference": "4063552c678ce3beb4b7f131a5b7354a19ff3e2a", "shasum": "" }, "require": { @@ -5108,7 +5170,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy", - "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/14.5.0" + "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/14.5.1" }, "funding": [ { @@ -5128,7 +5190,8 @@ "type": "tidelift" } ], - "time": "2026-10-30T08:11:15+00:00" + "time": "2026-11-02T11:47:37+00:00", + "published-time": "2026-11-02T11:49:08+00:00" }, { "name": "phpunit/php-file-iterator", @@ -5201,7 +5264,8 @@ "type": "tidelift" } ], - "time": "2026-08-25T14:47:43+00:00" + "time": "2026-08-25T14:47:43+00:00", + "published-time": "2026-08-25T14:48:49+00:00" }, { "name": "phpunit/php-invoker", @@ -5277,7 +5341,8 @@ "type": "tidelift" } ], - "time": "2026-02-06T04:34:47+00:00" + "time": "2026-02-06T04:34:47+00:00", + "published-time": "2026-02-06T04:35:29+00:00" }, { "name": "phpunit/php-text-template", @@ -5349,7 +5414,8 @@ "type": "tidelift" } ], - "time": "2026-10-03T04:54:24+00:00" + "time": "2026-10-03T04:54:24+00:00", + "published-time": "2026-10-03T04:54:39+00:00" }, { "name": "phpunit/php-timer", @@ -5421,20 +5487,21 @@ "type": "tidelift" } ], - "time": "2026-02-06T04:37:53+00:00" + "time": "2026-02-06T04:37:53+00:00", + "published-time": "2026-02-06T04:38:39+00:00" }, { "name": "phpunit/phpunit", - "version": "13.4.5", + "version": "13.4.6", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "941457a4f147a60fe2074d40e1fce0d08ea8d930" + "reference": "4821376960d0e1e5a7b7b700955ebcab33ecd71b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/941457a4f147a60fe2074d40e1fce0d08ea8d930", - "reference": "941457a4f147a60fe2074d40e1fce0d08ea8d930", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/4821376960d0e1e5a7b7b700955ebcab33ecd71b", + "reference": "4821376960d0e1e5a7b7b700955ebcab33ecd71b", "shasum": "" }, "require": { @@ -5448,7 +5515,7 @@ "phar-io/manifest": "^2.0.4", "phar-io/version": "^3.2.1", "php": ">=8.4.1", - "phpunit/php-code-coverage": "^14.4.1", + "phpunit/php-code-coverage": "^14.5.0", "phpunit/php-file-iterator": "^7.0.2", "phpunit/php-invoker": "^7.0.0", "phpunit/php-text-template": "^6.0.1", @@ -5505,7 +5572,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/phpunit/issues", "security": "https://github.com/sebastianbergmann/phpunit/security/policy", - "source": "https://github.com/sebastianbergmann/phpunit/tree/13.4.5" + "source": "https://github.com/sebastianbergmann/phpunit/tree/13.4.6" }, "funding": [ { @@ -5513,7 +5580,8 @@ "type": "other" } ], - "time": "2026-10-27T04:16:22+00:00" + "time": "2026-11-02T07:12:28+00:00", + "published-time": "2026-11-02T07:13:08+00:00" }, { "name": "psr/clock", @@ -5561,7 +5629,8 @@ "issues": "https://github.com/php-fig/clock/issues", "source": "https://github.com/php-fig/clock/tree/1.0.0" }, - "time": "2022-11-25T14:36:26+00:00" + "time": "2022-11-25T14:36:26+00:00", + "published-time": "2022-11-25T14:59:22+00:00" }, { "name": "psr/container", @@ -5614,7 +5683,8 @@ "issues": "https://github.com/php-fig/container/issues", "source": "https://github.com/php-fig/container/tree/2.0.2" }, - "time": "2021-11-05T16:47:00+00:00" + "time": "2021-11-05T16:47:00+00:00", + "published-time": "2021-11-05T16:53:27+00:00" }, { "name": "psr/event-dispatcher", @@ -5664,7 +5734,8 @@ "issues": "https://github.com/php-fig/event-dispatcher/issues", "source": "https://github.com/php-fig/event-dispatcher/tree/1.0.0" }, - "time": "2019-01-08T18:20:26+00:00" + "time": "2019-01-08T18:20:26+00:00", + "published-time": "2019-03-25T15:31:44+00:00" }, { "name": "psr/log", @@ -5714,7 +5785,8 @@ "support": { "source": "https://github.com/php-fig/log/tree/3.0.2" }, - "time": "2024-09-11T13:17:53+00:00" + "time": "2024-09-11T13:17:53+00:00", + "published-time": "2024-09-11T13:21:14+00:00" }, { "name": "react/promise", @@ -5787,7 +5859,8 @@ "type": "open_collective" } ], - "time": "2025-08-19T18:57:03+00:00" + "time": "2025-08-19T18:57:03+00:00", + "published-time": "2025-08-19T19:13:36+00:00" }, { "name": "sabberworm/php-css-parser", @@ -5865,7 +5938,8 @@ "issues": "https://github.com/MyIntervals/PHP-CSS-Parser/issues", "source": "https://github.com/MyIntervals/PHP-CSS-Parser/tree/v9.5.0" }, - "time": "2026-09-20T15:02:00+00:00" + "time": "2026-09-20T15:02:00+00:00", + "published-time": "2026-09-20T15:20:25+00:00" }, { "name": "sanmai/di-container", @@ -5944,20 +6018,21 @@ "type": "github" } ], - "time": "2026-08-11T06:42:18+00:00" + "time": "2026-08-11T06:42:18+00:00", + "published-time": "2026-08-11T06:44:32+00:00" }, { "name": "sanmai/duoclock", - "version": "0.1.3", + "version": "0.1.4", "source": { "type": "git", "url": "https://github.com/sanmai/DuoClock.git", - "reference": "47461e3ff65b7308635047831a55615652e7be1a" + "reference": "9fa79ec9445c8a43e3e6f5deefdebe920085f284" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sanmai/DuoClock/zipball/47461e3ff65b7308635047831a55615652e7be1a", - "reference": "47461e3ff65b7308635047831a55615652e7be1a", + "url": "https://api.github.com/repos/sanmai/DuoClock/zipball/9fa79ec9445c8a43e3e6f5deefdebe920085f284", + "reference": "9fa79ec9445c8a43e3e6f5deefdebe920085f284", "shasum": "" }, "require": { @@ -5999,7 +6074,7 @@ "description": "PHP time mocking for tests - PSR-20 clock with mockable sleep(), time(), and TimeSpy for PHPUnit testing", "support": { "issues": "https://github.com/sanmai/DuoClock/issues", - "source": "https://github.com/sanmai/DuoClock/tree/0.1.3" + "source": "https://github.com/sanmai/DuoClock/tree/0.1.4" }, "funding": [ { @@ -6007,7 +6082,8 @@ "type": "github" } ], - "time": "2025-12-26T06:12:34+00:00" + "time": "2026-11-06T08:04:12+00:00", + "published-time": "2026-11-06T08:04:52+00:00" }, { "name": "sanmai/later", @@ -6071,7 +6147,8 @@ "type": "github" } ], - "time": "2026-06-29T07:24:33+00:00" + "time": "2026-06-29T07:24:33+00:00", + "published-time": "2026-07-29T07:33:40+00:00" }, { "name": "sanmai/pipeline", @@ -6138,7 +6215,8 @@ "type": "github" } ], - "time": "2026-09-30T13:46:33+00:00" + "time": "2026-09-30T13:46:33+00:00", + "published-time": "2026-09-30T13:48:53+00:00" }, { "name": "sebastian/cli-parser", @@ -6207,7 +6285,8 @@ "type": "tidelift" } ], - "time": "2026-08-01T04:27:14+00:00" + "time": "2026-08-01T04:27:14+00:00", + "published-time": "2026-08-01T04:29:01+00:00" }, { "name": "sebastian/comparator", @@ -6299,7 +6378,8 @@ "type": "tidelift" } ], - "time": "2026-08-07T07:23:13+00:00" + "time": "2026-08-07T07:23:13+00:00", + "published-time": "2026-08-07T07:23:29+00:00" }, { "name": "sebastian/complexity", @@ -6369,7 +6449,8 @@ "type": "tidelift" } ], - "time": "2026-02-06T04:41:32+00:00" + "time": "2026-02-06T04:41:32+00:00", + "published-time": "2026-02-06T04:41:49+00:00" }, { "name": "sebastian/diff", @@ -6448,7 +6529,8 @@ "type": "tidelift" } ], - "time": "2026-08-25T15:38:55+00:00" + "time": "2026-08-25T15:38:55+00:00", + "published-time": "2026-08-25T15:39:44+00:00" }, { "name": "sebastian/environment", @@ -6524,7 +6606,8 @@ "type": "tidelift" } ], - "time": "2026-10-01T12:37:56+00:00" + "time": "2026-10-01T12:37:56+00:00", + "published-time": "2026-10-01T12:38:48+00:00" }, { "name": "sebastian/exporter", @@ -6614,7 +6697,8 @@ "type": "tidelift" } ], - "time": "2026-08-07T07:22:06+00:00" + "time": "2026-08-07T07:22:06+00:00", + "published-time": "2026-08-07T07:23:47+00:00" }, { "name": "sebastian/file-filter", @@ -6683,7 +6767,8 @@ "type": "tidelift" } ], - "time": "2026-04-22T07:20:04+00:00" + "time": "2026-04-22T07:20:04+00:00", + "published-time": "2026-04-22T07:20:25+00:00" }, { "name": "sebastian/git-state", @@ -6752,7 +6837,8 @@ "type": "tidelift" } ], - "time": "2026-10-21T05:59:52+00:00" + "time": "2026-10-21T05:59:52+00:00", + "published-time": "2026-10-21T06:01:28+00:00" }, { "name": "sebastian/global-state", @@ -6826,7 +6912,8 @@ "type": "tidelift" } ], - "time": "2026-06-01T15:11:33+00:00" + "time": "2026-06-01T15:11:33+00:00", + "published-time": "2026-06-01T15:20:45+00:00" }, { "name": "sebastian/lines-of-code", @@ -6896,7 +6983,8 @@ "type": "tidelift" } ], - "time": "2026-07-09T08:42:34+00:00" + "time": "2026-07-09T08:42:34+00:00", + "published-time": "2026-07-09T08:44:06+00:00" }, { "name": "sebastian/object-enumerator", @@ -6965,7 +7053,8 @@ "type": "tidelift" } ], - "time": "2026-08-13T07:05:05+00:00" + "time": "2026-08-13T07:05:05+00:00", + "published-time": "2026-08-13T07:07:58+00:00" }, { "name": "sebastian/object-reflector", @@ -7033,7 +7122,8 @@ "type": "tidelift" } ], - "time": "2026-08-13T06:34:36+00:00" + "time": "2026-08-13T06:34:36+00:00", + "published-time": "2026-08-13T06:39:22+00:00" }, { "name": "sebastian/recursion-context", @@ -7109,7 +7199,8 @@ "type": "tidelift" } ], - "time": "2026-08-03T05:58:12+00:00" + "time": "2026-08-03T05:58:12+00:00", + "published-time": "2026-08-03T05:59:59+00:00" }, { "name": "sebastian/type", @@ -7178,7 +7269,8 @@ "type": "tidelift" } ], - "time": "2026-08-10T08:00:57+00:00" + "time": "2026-08-10T08:00:57+00:00", + "published-time": "2026-08-10T08:01:43+00:00" }, { "name": "sebastian/version", @@ -7244,20 +7336,21 @@ "type": "tidelift" } ], - "time": "2026-10-04T05:51:14+00:00" + "time": "2026-10-04T05:51:14+00:00", + "published-time": "2026-10-04T05:52:22+00:00" }, { "name": "seld/jsonlint", - "version": "1.12.1", + "version": "1.12.2", "source": { "type": "git", "url": "https://github.com/Seldaek/jsonlint.git", - "reference": "9a90eb5d32d5a500296bf43f946d60246444d5f7" + "reference": "4882a0459c88772e4afa3a73ee81b7824490aa9c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Seldaek/jsonlint/zipball/9a90eb5d32d5a500296bf43f946d60246444d5f7", - "reference": "9a90eb5d32d5a500296bf43f946d60246444d5f7", + "url": "https://api.github.com/repos/Seldaek/jsonlint/zipball/4882a0459c88772e4afa3a73ee81b7824490aa9c", + "reference": "4882a0459c88772e4afa3a73ee81b7824490aa9c", "shasum": "" }, "require": { @@ -7296,7 +7389,7 @@ ], "support": { "issues": "https://github.com/Seldaek/jsonlint/issues", - "source": "https://github.com/Seldaek/jsonlint/tree/1.12.1" + "source": "https://github.com/Seldaek/jsonlint/tree/1.12.2" }, "funding": [ { @@ -7308,7 +7401,8 @@ "type": "tidelift" } ], - "time": "2026-06-12T11:32:29+00:00" + "time": "2026-11-03T10:16:18+00:00", + "published-time": "2026-11-03T10:20:37+00:00" }, { "name": "seld/phar-utils", @@ -7356,7 +7450,8 @@ "issues": "https://github.com/Seldaek/phar-utils/issues", "source": "https://github.com/Seldaek/phar-utils/tree/1.2.3" }, - "time": "2026-10-01T08:16:23+00:00" + "time": "2026-10-01T08:16:23+00:00", + "published-time": "2026-10-01T08:17:41+00:00" }, { "name": "seld/signal-handler", @@ -7417,7 +7512,8 @@ "issues": "https://github.com/Seldaek/signal-handler/issues", "source": "https://github.com/Seldaek/signal-handler/tree/2.0.2" }, - "time": "2023-09-03T09:24:00+00:00" + "time": "2023-09-03T09:24:00+00:00", + "published-time": "2023-09-03T09:24:36+00:00" }, { "name": "staabm/side-effects-detector", @@ -7469,20 +7565,21 @@ "type": "github" } ], - "time": "2024-10-20T05:08:20+00:00" + "time": "2024-10-20T05:08:20+00:00", + "published-time": "2024-10-20T05:09:39+00:00" }, { "name": "symfony/console", - "version": "v7.4.22", + "version": "v7.4.23", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "8012f842110b414fd1cc641adb0ed8423353b0bb" + "reference": "81957c2440c05c962fe8291f318de6e85f8db330" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/8012f842110b414fd1cc641adb0ed8423353b0bb", - "reference": "8012f842110b414fd1cc641adb0ed8423353b0bb", + "url": "https://api.github.com/repos/symfony/console/zipball/81957c2440c05c962fe8291f318de6e85f8db330", + "reference": "81957c2440c05c962fe8291f318de6e85f8db330", "shasum": "" }, "require": { @@ -7547,7 +7644,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v7.4.22" + "source": "https://github.com/symfony/console/tree/v7.4.23" }, "funding": [ { @@ -7567,20 +7664,21 @@ "type": "tidelift" } ], - "time": "2026-10-28T11:08:27+00:00" + "time": "2026-11-03T21:47:07+00:00", + "published-time": "2026-11-04T11:27:31+00:00" }, { "name": "symfony/deprecation-contracts", - "version": "v3.7.1", + "version": "v3.8.1", "source": { "type": "git", "url": "https://github.com/symfony/deprecation-contracts.git", - "reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d" + "reference": "feb1edf4e546af3b85c5caebe8359a20db230ca3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/f3202fa1b5097b0af062dc978b32ecf63404e31d", - "reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/feb1edf4e546af3b85c5caebe8359a20db230ca3", + "reference": "feb1edf4e546af3b85c5caebe8359a20db230ca3", "shasum": "" }, "require": { @@ -7593,7 +7691,7 @@ "name": "symfony/contracts" }, "branch-alias": { - "dev-main": "3.7-dev" + "dev-main": "3.8-dev" } }, "autoload": { @@ -7618,7 +7716,7 @@ "description": "A generic function and convention to trigger deprecation notices", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/deprecation-contracts/tree/v3.7.1" + "source": "https://github.com/symfony/deprecation-contracts/tree/v3.8.1" }, "funding": [ { @@ -7638,20 +7736,21 @@ "type": "tidelift" } ], - "time": "2026-06-05T06:23:12+00:00" + "time": "2026-09-16T14:15:10+00:00", + "published-time": "2026-11-04T11:25:34+00:00" }, { "name": "symfony/event-dispatcher-contracts", - "version": "v3.7.1", + "version": "v3.8.1", "source": { "type": "git", "url": "https://github.com/symfony/event-dispatcher-contracts.git", - "reference": "c7de7a00ffb67842132da02ea92988a39ccd9f4e" + "reference": "658e29751424da3ef4758c60f769e59d1b2981eb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/c7de7a00ffb67842132da02ea92988a39ccd9f4e", - "reference": "c7de7a00ffb67842132da02ea92988a39ccd9f4e", + "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/658e29751424da3ef4758c60f769e59d1b2981eb", + "reference": "658e29751424da3ef4758c60f769e59d1b2981eb", "shasum": "" }, "require": { @@ -7665,7 +7764,7 @@ "name": "symfony/contracts" }, "branch-alias": { - "dev-main": "3.7-dev" + "dev-main": "3.8-dev" } }, "autoload": { @@ -7698,7 +7797,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/event-dispatcher-contracts/tree/v3.7.1" + "source": "https://github.com/symfony/event-dispatcher-contracts/tree/v3.8.1" }, "funding": [ { @@ -7718,7 +7817,8 @@ "type": "tidelift" } ], - "time": "2026-06-05T06:23:12+00:00" + "time": "2026-09-16T14:15:10+00:00", + "published-time": "2026-11-04T11:25:36+00:00" }, { "name": "symfony/polyfill-iconv", @@ -7802,7 +7902,8 @@ "type": "tidelift" } ], - "time": "2026-09-29T06:25:14+00:00" + "time": "2026-09-29T06:25:14+00:00", + "published-time": "2026-09-30T10:23:39+00:00" }, { "name": "symfony/polyfill-intl-grapheme", @@ -7884,7 +7985,8 @@ "type": "tidelift" } ], - "time": "2026-10-15T09:18:53+00:00" + "time": "2026-10-15T09:18:53+00:00", + "published-time": "2026-10-28T10:07:44+00:00" }, { "name": "symfony/polyfill-intl-normalizer", @@ -7969,7 +8071,8 @@ "type": "tidelift" } ], - "time": "2026-09-23T22:02:09+00:00" + "time": "2026-09-23T22:02:09+00:00", + "published-time": "2026-09-30T10:23:47+00:00" }, { "name": "symfony/polyfill-php73", @@ -8049,7 +8152,8 @@ "type": "tidelift" } ], - "time": "2024-09-09T11:45:10+00:00" + "time": "2024-09-09T11:45:10+00:00", + "published-time": "2026-04-26T14:44:49+00:00" }, { "name": "symfony/polyfill-php80", @@ -8133,7 +8237,8 @@ "type": "tidelift" } ], - "time": "2026-09-25T15:50:05+00:00" + "time": "2026-09-25T15:50:05+00:00", + "published-time": "2026-09-30T10:23:35+00:00" }, { "name": "symfony/polyfill-php81", @@ -8213,7 +8318,8 @@ "type": "tidelift" } ], - "time": "2026-05-26T12:45:58+00:00" + "time": "2026-05-26T12:45:58+00:00", + "published-time": "2026-05-26T13:40:34+00:00" }, { "name": "symfony/polyfill-php84", @@ -8293,7 +8399,8 @@ "type": "tidelift" } ], - "time": "2026-09-23T22:01:09+00:00" + "time": "2026-09-23T22:01:09+00:00", + "published-time": "2026-09-30T10:23:33+00:00" }, { "name": "symfony/polyfill-php85", @@ -8373,7 +8480,8 @@ "type": "tidelift" } ], - "time": "2026-09-25T15:46:47+00:00" + "time": "2026-09-25T15:46:47+00:00", + "published-time": "2026-09-30T10:23:32+00:00" }, { "name": "symfony/process", @@ -8438,20 +8546,21 @@ "type": "tidelift" } ], - "time": "2026-10-25T14:40:25+00:00" + "time": "2026-10-25T14:40:25+00:00", + "published-time": "2026-10-28T18:25:20+00:00" }, { "name": "symfony/service-contracts", - "version": "v3.7.3", + "version": "v3.8.1", "source": { "type": "git", "url": "https://github.com/symfony/service-contracts.git", - "reference": "15e6a07ec2a2c75ceb1b21dd98105ee8456d2257" + "reference": "052852a96fd5e5ebdb617dba22031e41a3300fb5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/service-contracts/zipball/15e6a07ec2a2c75ceb1b21dd98105ee8456d2257", - "reference": "15e6a07ec2a2c75ceb1b21dd98105ee8456d2257", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/052852a96fd5e5ebdb617dba22031e41a3300fb5", + "reference": "052852a96fd5e5ebdb617dba22031e41a3300fb5", "shasum": "" }, "require": { @@ -8469,7 +8578,7 @@ "name": "symfony/contracts" }, "branch-alias": { - "dev-main": "3.7-dev" + "dev-main": "3.8-dev" } }, "autoload": { @@ -8505,7 +8614,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/service-contracts/tree/v3.7.3" + "source": "https://github.com/symfony/service-contracts/tree/v3.8.1" }, "funding": [ { @@ -8525,7 +8634,8 @@ "type": "tidelift" } ], - "time": "2026-07-27T15:39:01+00:00" + "time": "2026-10-20T17:38:25+00:00", + "published-time": "2026-11-04T11:25:33+00:00" }, { "name": "symfony/string", @@ -8615,7 +8725,8 @@ "type": "tidelift" } ], - "time": "2026-10-23T17:06:53+00:00" + "time": "2026-10-23T17:06:53+00:00", + "published-time": "2026-10-28T18:24:27+00:00" }, { "name": "symfony/var-dumper", @@ -8702,20 +8813,21 @@ "type": "tidelift" } ], - "time": "2026-10-25T21:04:04+00:00" + "time": "2026-10-25T21:04:04+00:00", + "published-time": "2026-10-28T18:20:00+00:00" }, { "name": "symfony/yaml", - "version": "v8.1.10", + "version": "v8.1.11", "source": { "type": "git", "url": "https://github.com/symfony/yaml.git", - "reference": "9de4ff9874dd18b738b9f248398c3e36ef15a1d2" + "reference": "e8266f55160f674f290a5f567cb55e11e21f921b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/yaml/zipball/9de4ff9874dd18b738b9f248398c3e36ef15a1d2", - "reference": "9de4ff9874dd18b738b9f248398c3e36ef15a1d2", + "url": "https://api.github.com/repos/symfony/yaml/zipball/e8266f55160f674f290a5f567cb55e11e21f921b", + "reference": "e8266f55160f674f290a5f567cb55e11e21f921b", "shasum": "" }, "require": { @@ -8758,7 +8870,7 @@ "description": "Loads and dumps YAML files", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/yaml/tree/v8.1.10" + "source": "https://github.com/symfony/yaml/tree/v8.1.11" }, "funding": [ { @@ -8778,7 +8890,8 @@ "type": "tidelift" } ], - "time": "2026-10-18T14:56:39+00:00" + "time": "2026-11-04T08:39:20+00:00", + "published-time": "2026-11-04T11:31:03+00:00" }, { "name": "thecodingmachine/safe", @@ -8921,7 +9034,8 @@ "type": "github" } ], - "time": "2026-02-04T18:08:13+00:00" + "time": "2026-02-04T18:08:13+00:00", + "published-time": "2026-02-15T15:48:29+00:00" }, { "name": "webmozart/assert", @@ -8979,7 +9093,8 @@ "issues": "https://github.com/webmozarts/assert/issues", "source": "https://github.com/webmozarts/assert/tree/1.12.1" }, - "time": "2025-10-29T15:56:20+00:00" + "time": "2025-10-29T15:56:20+00:00", + "published-time": "2025-10-29T15:56:42+00:00" } ], "aliases": [], @@ -8995,5 +9110,5 @@ "ext-json": "*" }, "platform-dev": {}, - "plugin-api-version": "2.9.0" + "plugin-api-version": "2.11.0" } diff --git a/package-lock.json b/package-lock.json index 0987b56..ebc353a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -97,14 +97,14 @@ } }, "node_modules/@babel/generator": { - "version": "7.29.8", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", - "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", + "version": "7.29.12", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.12.tgz", + "integrity": "sha512-2W76mhjrpQFO1lK5e8IvGYB2D0YZ7S2R1Z2BjO2ZUYuW+amcpK/z60U6ZZmzskwoa7sJ6XohP/Wg8H5KxXI/IA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/parser": "^7.29.8", - "@babel/types": "^7.29.8", + "@babel/parser": "^7.29.12", + "@babel/types": "^7.29.11", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" @@ -154,9 +154,9 @@ } }, "node_modules/@babel/helper-create-class-features-plugin": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.29.7.tgz", - "integrity": "sha512-IY3ZD9Tmooqr3TUhc3DUWxiuo8xx1DWLhd5M7hQ+ZWJamqM2BbalrBJb2MisSLoYorOj75U03qULCxQTY9r3hg==", + "version": "7.29.12", + "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.29.12.tgz", + "integrity": "sha512-ACxGoWMV866q/QlZHeCOaQZAR5QclKsIvkAmlDemqjm6+6Bynijs1mH068BzH2N2e+wSuDmBWY87fqKfvhi1Lw==", "dev": true, "license": "MIT", "dependencies": { @@ -165,7 +165,7 @@ "@babel/helper-optimise-call-expression": "^7.29.7", "@babel/helper-replace-supers": "^7.29.7", "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7", - "@babel/traverse": "^7.29.7", + "@babel/traverse": "^7.29.12", "semver": "^6.3.1" }, "engines": { @@ -419,9 +419,9 @@ } }, "node_modules/@babel/parser": { - "version": "7.29.11", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.11.tgz", - "integrity": "sha512-OZ6+oaK7GMyg3IfCpj8JOmn8n+w2rJbDT0LLfVDTLb1ZFeKDh3AiNELgCMUw175IJHSkLl74hG0M0VLsvx+gag==", + "version": "7.29.12", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.12.tgz", + "integrity": "sha512-WJ7JBp/Q4+z/ImydtI7jS6NVco5++632ExBemSX+Ysb+G1lCwQ5BVo3SY+jGGOcZXI4ZccbZSUSJLNBW6eEpJg==", "dev": true, "license": "MIT", "dependencies": { @@ -698,9 +698,9 @@ } }, "node_modules/@babel/plugin-transform-block-scoping": { - "version": "7.29.11", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.29.11.tgz", - "integrity": "sha512-iARF4jjHSM0v8DImCACPXrwEltBP3DgqDpXTvjAMBAVpyEY0LiGss78wvI+KtmW+IJNEvZ5sOhPDCsOsXsIybA==", + "version": "7.29.12", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.29.12.tgz", + "integrity": "sha512-nEreZBofjfd6FG621xe9Rk3+tMGBXf1nAuFrR6NUGF67fkQbSQf3NrbRuhgWRn+zb0qcy9XVok1GVFI/t9wWVQ==", "dev": true, "license": "MIT", "dependencies": { @@ -1803,18 +1803,18 @@ } }, "node_modules/@babel/traverse": { - "version": "7.29.10", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.10.tgz", - "integrity": "sha512-B3lYAs2nkKskbgQ1nfy8Ma9ao9Q1rntTAQHrzzyDuj+TSO+X3le6PfWwqDvodsb+OumE10eMFfuMCTQSQ5iuIQ==", + "version": "7.29.12", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.12.tgz", + "integrity": "sha512-MscXe/NAZcOkYEsRajHZFt3ynsb22PUi6CTrp5U7+RYycR9By84mn9mFN54YgfITiqK4hFx3Ju3tZSyzfpS5ew==", "dev": true, "license": "MIT", "dependencies": { "@babel/code-frame": "^7.29.7", - "@babel/generator": "^7.29.8", + "@babel/generator": "^7.29.12", "@babel/helper-globals": "^7.29.7", - "@babel/parser": "^7.29.9", + "@babel/parser": "^7.29.12", "@babel/template": "^7.29.7", - "@babel/types": "^7.29.8", + "@babel/types": "^7.29.11", "debug": "^4.3.1" }, "engines": { @@ -1944,9 +1944,9 @@ } }, "node_modules/@csstools/css-syntax-patches-for-csstree": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.15.tgz", - "integrity": "sha512-J0u7HkVl2nzSlhsiTOp4AmwcUQ3D+mGEEKfBy/7To5/y7F2OHwyLrXfrhR0SMgr4p5Lo+eaMVSeai24zUcBIxA==", + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.16.tgz", + "integrity": "sha512-LsDbJPP/B8cPasUJyTA5S99F7C4G93ngW8hUluXz8CFeOaaRnBPtBiIlvgcPYU0HmHNz8CXg+1vwFrIB4F6Dgw==", "dev": true, "funding": [ { @@ -3110,125 +3110,370 @@ } }, "node_modules/@opentelemetry/api": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz", - "integrity": "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==", + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.10.0.tgz", + "integrity": "sha512-lMkyes8iHrG+mk6WH7X70MdCLI2RS0FwNBY1PyQ8UemaijE2m5mv5A0Niy3HtbYTFrgkUEQjFMXmSAk2hQDuPQ==", "dev": true, "license": "Apache-2.0", "engines": { "node": ">=8.0.0" } }, - "node_modules/@opentelemetry/api-logs": { - "version": "0.220.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.220.0.tgz", - "integrity": "sha512-CmVa4ImJ+ynfrPMNaAXHET6Bhb44SwzmfyVJFq9ni2jgXJR/l7C6gfVFddNmHP+ZOkP9cf4f9DBe68qVLTHc9w==", + "node_modules/@oxc-parser/binding-android-arm-eabi": { + "version": "0.152.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-android-arm-eabi/-/binding-android-arm-eabi-0.152.0.tgz", + "integrity": "sha512-393VtirINkae4XHBTkq/yQxhOwEr4V+r0et37P2fp+sYZSdYgZTRDPo5Um8vBh0QjMZ+RmaMy4PP0qwr4cTMwA==", + "cpu": [ + "arm" + ], "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/api": "^1.3.0" - }, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], "engines": { - "node": ">=8.0.0" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@opentelemetry/core": { - "version": "2.12.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.12.0.tgz", - "integrity": "sha512-1HsSAuvT4/my0QrXWsyXFdWjKaPeCHsismKZgEEIb8NK13LP6DrMRlsUbvD/0F9mDWK3xDom9AMv3IscxgOnOQ==", + "node_modules/@oxc-parser/binding-android-arm64": { + "version": "0.152.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-android-arm64/-/binding-android-arm64-0.152.0.tgz", + "integrity": "sha512-iYFcExATJNZ0rZj0o02THbD1Nc9jAW0nVgIQPee6ejbSASpbZXlIMw8jxtpzaG14JQTWwGSZ6U5dEtRJXE6V3Q==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/semantic-conventions": "^1.29.0" - }, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.0.0 <1.10.0" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@opentelemetry/instrumentation": { - "version": "0.220.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.220.0.tgz", - "integrity": "sha512-xQx3E2WxP1mDvKzxLxX+CTCtNLa560YJZ3087qYHerl2YmiKpv7AH+dAy7vmx+eVrZ5BwhfWUAVoKOoxCNHcpw==", + "node_modules/@oxc-parser/binding-darwin-arm64": { + "version": "0.152.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-darwin-arm64/-/binding-darwin-arm64-0.152.0.tgz", + "integrity": "sha512-fnU+DfUzi5/rFy0LzmipYRz06PAUUkOS8kCJQ3Eg6oqlunC1XZXoffFqxmdL51RmcbYtUG3P8DVG1U5fckoa2A==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/api-logs": "0.220.0", - "import-in-the-middle": "^3.0.0", - "require-in-the-middle": "^8.0.0" - }, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@opentelemetry/resources": { - "version": "2.12.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.12.0.tgz", - "integrity": "sha512-+rSRoOdln6NhXTVcBdHSlZxJDNzwxadeweK2ltZO3tVtfp6aklABc06xclDk6ovRR+EiGgIcSbh/6NgjFhnb3A==", + "node_modules/@oxc-parser/binding-darwin-x64": { + "version": "0.152.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-darwin-x64/-/binding-darwin-x64-0.152.0.tgz", + "integrity": "sha512-Y3U2okryWTs7hDld6UhjJwLNf8/do+x4g4C+1U5SiGwrTLwbO83WJYJA8+l9iFSilWBgnfpd/+U76tfeNxUHzA==", + "cpu": [ + "x64" + ], "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "2.12.0", - "@opentelemetry/semantic-conventions": "^1.29.0" - }, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.3.0 <1.10.0" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@opentelemetry/sdk-trace": { - "version": "2.12.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace/-/sdk-trace-2.12.0.tgz", - "integrity": "sha512-ImVuRQ6faOsTi1Wna88s2avgrrf/iDgXPigIeynkUhjstMuoQUDOJXgkez2j1mlU4btjXcXBGa+q7qKFYR3GsA==", + "node_modules/@oxc-parser/binding-freebsd-x64": { + "version": "0.152.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-freebsd-x64/-/binding-freebsd-x64-0.152.0.tgz", + "integrity": "sha512-XkhkRrrPffEw+ZLv/d/5pEni0buxinm/E/N6fLbJCunD1DBbeCTtVP23ESV+4CWCWdSz397asks1ViGslpmWiQ==", + "cpu": [ + "x64" + ], "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "2.12.0", - "@opentelemetry/resources": "2.12.0", - "@opentelemetry/semantic-conventions": "^1.29.0" - }, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.3.0 <1.10.0" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@opentelemetry/sdk-trace-base": { - "version": "2.12.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.12.0.tgz", - "integrity": "sha512-JOERgZCxRM06FEJ5kEWxlSRChah6MmXDx3KwCruqEv/iEyLkTokCSemf2y1w8oIHtfrr8EVTpzebq/31vn2Cpg==", - "deprecated": "Use @opentelemetry/sdk-trace package", + "node_modules/@oxc-parser/binding-linux-arm-gnueabihf": { + "version": "0.152.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-0.152.0.tgz", + "integrity": "sha512-MLzsRRbdfmdzo6JEhbC1yKU9ItfvM8tEf18HM32jCiNYZEz6J3bRBRl3cTkKR0vBdrk9ZVlcARXtwHccs+epDA==", + "cpu": [ + "arm" + ], "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "2.12.0", - "@opentelemetry/resources": "2.12.0", - "@opentelemetry/sdk-trace": "2.12.0", - "@opentelemetry/semantic-conventions": "^1.29.0" - }, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.3.0 <1.10.0" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@opentelemetry/semantic-conventions": { - "version": "1.43.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.43.0.tgz", - "integrity": "sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg==", + "node_modules/@oxc-parser/binding-linux-arm-musleabihf": { + "version": "0.152.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-0.152.0.tgz", + "integrity": "sha512-HHerTEJz7Iy10Ji6BGsk4gqG6PU0UzFuBu91jIEOX89FnrI5ECWSEr7BLpkYQhp7U0QFtMnBhNfQKI0hYZK78w==", + "cpu": [ + "arm" + ], "dev": true, - "license": "Apache-2.0", + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=14" + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-arm64-gnu": { + "version": "0.152.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-0.152.0.tgz", + "integrity": "sha512-9Bb8GyzZ2uSLzRDZ27usaKokr07mxiBe2dDF5b6bd2janmaJTT1gL51UfP7AU30yFiXrJvcRyFKREAgKwZYs0w==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-arm64-musl": { + "version": "0.152.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm64-musl/-/binding-linux-arm64-musl-0.152.0.tgz", + "integrity": "sha512-om7iDMUroI74A/lfRRWY9/Ev3VWrVsrUZ4tyvyh8raBey/sdcfyAD7tYWc+iykAiotnmftyKTpUKFr1QnaBrAQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-ppc64-gnu": { + "version": "0.152.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-0.152.0.tgz", + "integrity": "sha512-Lf1OL2lMTqwDMFC6jnRb30B+WYHKV0XT+bPHcFM6xHtNVwxpobiTzUIvKH6a0LWpvfwyru8Yu6zCGHqqFHH+gg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-riscv64-gnu": { + "version": "0.152.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-0.152.0.tgz", + "integrity": "sha512-BYWSMQK+YJ9ZILStPcmmiSVq8KEG9Qdq/c4BHPC3rUEq/fEDCVjUIwdj5ZzjgZ9P9is9PKu0tcAXzq1ZXg4urQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-riscv64-musl": { + "version": "0.152.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-0.152.0.tgz", + "integrity": "sha512-d2LqHNNNqdH185sRK5dPKWZu201JrNu9/CRFuVEuT+ht7n/QaVblEMWT9MALQPrDWYvhG10P+NSKJFquDWZ09w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-s390x-gnu": { + "version": "0.152.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-0.152.0.tgz", + "integrity": "sha512-8gBeHt5rA/8+pKhHl+zLggfa3L/V/L2at96p7ty6pBE7id6lFO19nFUc15u2PcjC4OYeYK465MKy1fhhbpFIGw==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-x64-gnu": { + "version": "0.152.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-x64-gnu/-/binding-linux-x64-gnu-0.152.0.tgz", + "integrity": "sha512-ltgrSF6L+yPTVsIdaAyqKq3zTqzj/1VlNbxWZuxAe8flR3sWdyNsKvdXukoO8/ucJbLickhmieRBge+o/e8Ugw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-x64-musl": { + "version": "0.152.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-x64-musl/-/binding-linux-x64-musl-0.152.0.tgz", + "integrity": "sha512-LcMp6BvrBnewSfdkLHoSqidhoAq4syKOTBV5TYHWd+2VnGkfD/nPRRVIzTBt/Lkbt7pfOFDGtkiTntFS0p6sQQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-openharmony-arm64": { + "version": "0.152.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-openharmony-arm64/-/binding-openharmony-arm64-0.152.0.tgz", + "integrity": "sha512-jbhoMCslVxzRCRNQ8TSHxjYdSKRvk8O6ciLVDkahJvmyHDzBqba/bMQHM6ULKj/rgHNNbSzlIiakdx8SEfkJzA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-win32-arm64-msvc": { + "version": "0.152.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-0.152.0.tgz", + "integrity": "sha512-JZYVlhv36mr/nertnRB7vchJECOBb1Lz6lltalILsB+o9M/is+H6BEDRLQKdqA4JKs6NjJszo46+iM5sfwj6jQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-win32-ia32-msvc": { + "version": "0.152.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-0.152.0.tgz", + "integrity": "sha512-1X9oganvVsunRH0lH10UU05N+H9atpX6WsOybByUjwnfcd3h/0tYwvuv16XuHglrIUOnxV2TodbLVEwwafd64g==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-win32-x64-msvc": { + "version": "0.152.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-x64-msvc/-/binding-win32-x64-msvc-0.152.0.tgz", + "integrity": "sha512-sw0M80/dn9rcpu+Rqqph/C+MjvDDce6MlXmFjsNQtPEymha0eJy3ml6+foSUTqFfpDIDyHZZ4eoJ43IoMe8bJw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.152.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.152.0.tgz", + "integrity": "sha512-oM/5rLBm2tPkg0iBgkH/FOeR3PCDpY19GTgAZjMFM8h9WI9VW7cLgzp6nwtarYKmovavIQZ+Fe/RKX/8C8O/Rw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/oxc-project" } }, "node_modules/@parcel/watcher": { @@ -3552,14 +3797,13 @@ } }, "node_modules/@paulirish/trace_engine": { - "version": "0.0.65", - "resolved": "https://registry.npmjs.org/@paulirish/trace_engine/-/trace_engine-0.0.65.tgz", - "integrity": "sha512-Qsm6F5C8xf6ZzQXbQc2+wcpe6sggfs/gvc/ytqSurdvYg3kyW0ECHCqE0CWBKZpqgjVfPNX9c7SCS3r2nEIRGg==", + "version": "0.0.67", + "resolved": "https://registry.npmjs.org/@paulirish/trace_engine/-/trace_engine-0.0.67.tgz", + "integrity": "sha512-AWZSKQDwbWKIO//xGvM8isSyDgEDZWDq12SbMQMxl6eE9ZgWaoc9n/2d7REjwcRANyA1Zjo1rVfJiSRFFGKUww==", "dev": true, "license": "BSD-3-Clause", "dependencies": { - "legacy-javascript": "latest", - "third-party-web": "latest" + "third-party-web": "*" } }, "node_modules/@peculiar/asn1-cms": { @@ -3874,163 +4118,64 @@ } } }, - "node_modules/@puppeteer/browsers/node_modules/ansi-regex": { - "version": "6.4.0", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.4.0.tgz", - "integrity": "sha512-KzTVk2tCWAHtYrvvvaP8bJKJq2pVinhLcGEQdtLIYPbmNGNyYe8QwNaTUYQp2J7/vIsUKt5QCqAfUkYyG9DkOw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, - "node_modules/@puppeteer/browsers/node_modules/ansi-styles": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", - "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/@puppeteer/browsers/node_modules/cliui": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz", - "integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==", - "dev": true, - "license": "ISC", - "dependencies": { - "string-width": "^7.2.0", - "strip-ansi": "^7.1.0", - "wrap-ansi": "^9.0.0" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/@puppeteer/browsers/node_modules/cliui/node_modules/string-width": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", - "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^10.3.0", - "get-east-asian-width": "^1.0.0", - "strip-ansi": "^7.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@puppeteer/browsers/node_modules/emoji-regex": { - "version": "10.6.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", - "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "node_modules/@rtsao/scc": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", + "integrity": "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==", "dev": true, "license": "MIT" }, - "node_modules/@puppeteer/browsers/node_modules/strip-ansi": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", - "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "node_modules/@sentry/bundler-plugins": { + "version": "11.10.0", + "resolved": "https://registry.npmjs.org/@sentry/bundler-plugins/-/bundler-plugins-11.10.0.tgz", + "integrity": "sha512-+ngJ485rIYll5uOhWSvSO+Vx4S24MGe5KcLKlUZghr+BXK+ib32uBOZNG294wGyeauLTDvdjncLEYwiaBnP/5w==", "dev": true, "license": "MIT", "dependencies": { - "ansi-regex": "^6.2.2" + "@sentry/core": "11.10.0", + "dotenv": "^17.4.2", + "glob": "^13.0.6", + "magic-string": "~0.30.8 || ^1.0.0", + "oxc-parser": "^0.152.0", + "sentry": "^0.45.0", + "supports-color": "^8.1.1" }, "engines": { - "node": ">=12" + "node": ">=20.19.0 <22.0.0 || >=22.12.0 <23.0.0 || >=23.2.0" }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, - "node_modules/@puppeteer/browsers/node_modules/wrap-ansi": { - "version": "9.0.2", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", - "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.2.1", - "string-width": "^7.0.0", - "strip-ansi": "^7.1.0" - }, - "engines": { - "node": ">=18" + "peerDependencies": { + "rollup": ">=3.2.0", + "webpack": ">=5.1.0" }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + "peerDependenciesMeta": { + "rollup": { + "optional": true + }, + "webpack": { + "optional": true + } } }, - "node_modules/@puppeteer/browsers/node_modules/wrap-ansi/node_modules/string-width": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", - "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "node_modules/@sentry/bundler-plugins/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", "dev": true, "license": "MIT", "dependencies": { - "emoji-regex": "^10.3.0", - "get-east-asian-width": "^1.0.0", - "strip-ansi": "^7.1.0" + "has-flag": "^4.0.0" }, "engines": { - "node": ">=18" + "node": ">=10" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@puppeteer/browsers/node_modules/yargs": { - "version": "18.2.0", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.2.0.tgz", - "integrity": "sha512-9OpKOLeaoNFecEp7P6iYbzze/5CqWoH7N3SMs/6y1XF4nMvFMspEGZzJ6uFi9MmQwXhyzqYoSEKO3tvA3Z/o2w==", - "dev": true, - "license": "MIT", - "dependencies": { - "cliui": "^9.0.1", - "escalade": "^3.1.1", - "get-caller-file": "^2.0.5", - "string-width": "^8.2.1", - "y18n": "^5.0.5", - "yargs-parser": "^22.0.0" - }, - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=23" - } - }, - "node_modules/@puppeteer/browsers/node_modules/yargs-parser": { - "version": "22.0.0", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz", - "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==", - "dev": true, - "license": "ISC", - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=23" + "url": "https://github.com/chalk/supports-color?sponsor=1" } }, - "node_modules/@rtsao/scc": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", - "integrity": "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==", - "dev": true, - "license": "MIT" - }, "node_modules/@sentry/conventions": { - "version": "0.16.0", - "resolved": "https://registry.npmjs.org/@sentry/conventions/-/conventions-0.16.0.tgz", - "integrity": "sha512-fO9PLmHdVURcSPUpWCItWAtgKiMwGdJHbovoSEyLplX5sxs2ugvI4CBPTrkkgqhObnZOD0CnWBKDzSVQYBKEyQ==", + "version": "0.29.0", + "resolved": "https://registry.npmjs.org/@sentry/conventions/-/conventions-0.29.0.tgz", + "integrity": "sha512-kYq9/HJ/l6MYP/srIc20dy1W4mWyJfCF0OWFF+RhBGZwyr/wCmtabRFKyw7kqM+DTwZXQOjSTSZUcJhDowcInQ==", "dev": true, "license": "MIT", "engines": { @@ -4038,110 +4183,81 @@ } }, "node_modules/@sentry/core": { - "version": "10.76.2", - "resolved": "https://registry.npmjs.org/@sentry/core/-/core-10.76.2.tgz", - "integrity": "sha512-vndrQalRiwYatTkjlLWvyf69KocXIb9TKJ4A9oiyWguL8PJYtqIRTlRe4OpkpjLrxS18fvRdV0jEm29c/9p3hQ==", + "version": "11.10.0", + "resolved": "https://registry.npmjs.org/@sentry/core/-/core-11.10.0.tgz", + "integrity": "sha512-l/OtTo1v+TQgZtHG2Q5N4IX+gFdAE1tZ27K/P21UCzJglWk54TPEm+d9T2lulPqMK06kZcLwDgRAhE2GO4YS5w==", "dev": true, "license": "MIT", "dependencies": { - "@sentry/conventions": "^0.16.0" + "@sentry/conventions": "^0.29.0" }, "engines": { - "node": ">=18" + "node": ">=20.19.0 <22.0.0 || >=22.12.0 <23.0.0 || >=23.2.0" } }, "node_modules/@sentry/node": { - "version": "10.76.2", - "resolved": "https://registry.npmjs.org/@sentry/node/-/node-10.76.2.tgz", - "integrity": "sha512-LSwGGE/GceTbkDHRoQQr3lq/dpxxA6RdooBbuGTJ/S6BdqKX2kw2FdV7Se9eJzZJJJ5AxCZum37+ejYS7N2pJg==", + "version": "11.10.0", + "resolved": "https://registry.npmjs.org/@sentry/node/-/node-11.10.0.tgz", + "integrity": "sha512-ZsndrSn1NjL9AslpUdxz/kYPcv/i+plVZoyPe1ivlr5XPUpocB8pUL5noYbIUl96Wd/yGvPBw6zqO8d69rRKQQ==", "dev": true, "license": "MIT", "dependencies": { "@opentelemetry/api": "^1.9.1", - "@opentelemetry/instrumentation": "^0.220.0", - "@opentelemetry/sdk-trace-base": "^2.9.0", - "@sentry/conventions": "^0.16.0", - "@sentry/core": "10.76.2", - "@sentry/node-core": "10.76.2", - "@sentry/opentelemetry": "10.76.2", - "@sentry/server-utils": "10.76.2", - "import-in-the-middle": "^3.0.0" + "@sentry/bundler-plugins": "11.10.0", + "@sentry/conventions": "^0.29.0", + "@sentry/core": "11.10.0", + "@sentry/opentelemetry": "11.10.0", + "@sentry/server-runtime-injection": "11.10.0", + "@sentry/server-utils": "11.10.0" }, "engines": { - "node": ">=18" + "node": ">=20.19.0 <22.0.0 || >=22.12.0 <23.0.0 || >=23.2.0" } }, - "node_modules/@sentry/node-core": { - "version": "10.76.2", - "resolved": "https://registry.npmjs.org/@sentry/node-core/-/node-core-10.76.2.tgz", - "integrity": "sha512-TrgAc/SvU5kQfjw8pz6I8LLIUiblOSf9BPPNkTbS0Dzcqa4yQLVbmsbS3rvGT7ot64m9Vjzn327ZRy63MlI6/w==", + "node_modules/@sentry/opentelemetry": { + "version": "11.10.0", + "resolved": "https://registry.npmjs.org/@sentry/opentelemetry/-/opentelemetry-11.10.0.tgz", + "integrity": "sha512-YMV4ZgaRlW7QPCcMScVpSBvYOdvDlY+alqRT3KOTcmPKFZtf8O2hXHWFhl/M33kKDEfqOk2XtEVeHs3mZFOCaw==", "dev": true, "license": "MIT", "dependencies": { - "@sentry/conventions": "^0.16.0", - "@sentry/core": "10.76.2", - "@sentry/opentelemetry": "10.76.2", - "import-in-the-middle": "^3.0.0" + "@sentry/conventions": "^0.29.0", + "@sentry/core": "11.10.0" }, "engines": { - "node": ">=18" + "node": ">=20.19.0 <22.0.0 || >=22.12.0 <23.0.0 || >=23.2.0" }, "peerDependencies": { - "@opentelemetry/api": "^1.9.0", - "@opentelemetry/core": "^1.30.1 || ^2.1.0", - "@opentelemetry/exporter-trace-otlp-http": ">=0.57.0 <1", - "@opentelemetry/instrumentation": ">=0.57.1 <1", - "@opentelemetry/sdk-trace-base": "^1.30.1 || ^2.1.0" - }, - "peerDependenciesMeta": { - "@opentelemetry/api": { - "optional": true - }, - "@opentelemetry/core": { - "optional": true - }, - "@opentelemetry/exporter-trace-otlp-http": { - "optional": true - }, - "@opentelemetry/instrumentation": { - "optional": true - }, - "@opentelemetry/sdk-trace-base": { - "optional": true - } + "@opentelemetry/api": "^1.9.0" } }, - "node_modules/@sentry/opentelemetry": { - "version": "10.76.2", - "resolved": "https://registry.npmjs.org/@sentry/opentelemetry/-/opentelemetry-10.76.2.tgz", - "integrity": "sha512-deqWrKJGaku3nhiZchg34z8W9v6kUwtzoQ4cmEq6CeT8/4X93X4YfeJgoh1dhbswFRpwor4pQ6kj0KttagKtfA==", + "node_modules/@sentry/server-runtime-injection": { + "version": "11.10.0", + "resolved": "https://registry.npmjs.org/@sentry/server-runtime-injection/-/server-runtime-injection-11.10.0.tgz", + "integrity": "sha512-1nc+HNPldrPEov16sL5m2cOLVgNJ+FsR7QtaWGOOv8qYFJkSp95lyc2qzLnaG2L72UzSMOv10peGRioAKrrZQQ==", "dev": true, "license": "MIT", "dependencies": { - "@sentry/conventions": "^0.16.0", - "@sentry/core": "10.76.2" + "@sentry/core": "11.10.0", + "@sentry/server-utils": "11.10.0" }, "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.9.0", - "@opentelemetry/core": "^1.30.1 || ^2.1.0", - "@opentelemetry/sdk-trace-base": "^1.30.1 || ^2.1.0" + "node": ">=20.19.0 <22.0.0 || >=22.12.0 <23.0.0 || >=23.2.0" } }, "node_modules/@sentry/server-utils": { - "version": "10.76.2", - "resolved": "https://registry.npmjs.org/@sentry/server-utils/-/server-utils-10.76.2.tgz", - "integrity": "sha512-wogj6Trj8fAu+aD7HbpvHEBlyANMOvvO9MP5NFJaiQbprLKugtCdE6rFTxDhofXvRo4nswL9m4Cvysg3k5KcvQ==", + "version": "11.10.0", + "resolved": "https://registry.npmjs.org/@sentry/server-utils/-/server-utils-11.10.0.tgz", + "integrity": "sha512-m/MVCvPzQJJZZCTFlVPCrTuoncT7SR80+QKg1K7ul25AsL0byABr5KsHCbXVpOYXLVz04Omk+pOOR5Cv2IAlrA==", "dev": true, "license": "MIT", "dependencies": { - "@sentry/conventions": "^0.16.0", - "@sentry/core": "10.76.2" + "@opentelemetry/api": "^1.9.1", + "@sentry/conventions": "^0.29.0", + "@sentry/core": "11.10.0" }, "engines": { - "node": ">=18" + "node": ">=20.19.0 <22.0.0 || >=22.12.0 <23.0.0 || >=23.2.0" } }, "node_modules/@sindresorhus/base62": { @@ -4627,9 +4743,9 @@ "license": "MIT" }, "node_modules/@types/estree": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", - "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.10.tgz", + "integrity": "sha512-lTyOMnOcmRBzdCv6/4zUZCrEhV0xrqQiUZBZtVcbmLHBXKLnN8q89v/wKRF/Q0HbwVsqbIG3o5HDoFl9sduM+g==", "dev": true, "license": "MIT" }, @@ -4708,9 +4824,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "26.6.5", - "resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.5.tgz", - "integrity": "sha512-uMzsv9stDdofIZBx4UGh4Z9ZgXnGC9seRDscilBcTHAeOFk67MZg/k9bWVfO+bk4qZmG+l52hRnLwUiH2LnJTg==", + "version": "26.6.6", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.6.tgz", + "integrity": "sha512-MzQj+8Kx3XI40OmUAE6Qoh58ZMNstNNMwn2nfd3qWsfe7nDNSqJNzes4njhz6AHwrSHOffSLwXU6yb474Q4aog==", "dev": true, "license": "MIT", "dependencies": { @@ -6861,9 +6977,9 @@ } }, "node_modules/autoprefixer": { - "version": "10.6.1", - "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.6.1.tgz", - "integrity": "sha512-cL1Qz6ADZhcEbny/8HPfe99J6HhNoYtpX2LFLIbhgGE7Q1hlQVkYFdetDN7Id3KiQxhDrHwzlHr/YQCnZ8+xSA==", + "version": "10.6.2", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.6.2.tgz", + "integrity": "sha512-VOw9/yGTGwPPgzFcODtcv6zhEPIeIWkRSe10L2ri2f5vZ7ID7lCt6jZzRF5/ubgOZ+szYydmEF5AYsA5jxX1tA==", "funding": [ { "type": "opencollective", @@ -7013,9 +7129,9 @@ } }, "node_modules/baseline-browser-mapping": { - "version": "2.11.33", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.33.tgz", - "integrity": "sha512-yiVKDX+qCMRKBE084lXvq9EcwFUhKZ2nU8TK1EQQuOgPNR/lmbGxXOeDdlGUqC2Zz11Cs9rk1d/8NfgBmlkt0w==", + "version": "2.11.34", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.34.tgz", + "integrity": "sha512-Aqr8mA1kIvyJ3UY5YAY7ib8LSo1DmDMbMnVqGPtsKW1LxaFQFwn9Eb3yvq2fUKJsRvaXjnAV7R5osqETapzNiA==", "license": "Apache-2.0", "bin": { "baseline-browser-mapping": "dist/cli.cjs" @@ -7111,9 +7227,9 @@ } }, "node_modules/browserslist": { - "version": "4.29.4", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.29.4.tgz", - "integrity": "sha512-2TcbbiwXEq+N0kD36tykpCfyyQigUXmuwNQbnPp4Xk4PX9JOqVIKjJlIDjpH7uY6tmRGoAd+apeT1p60T8wsdA==", + "version": "4.29.5", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.29.5.tgz", + "integrity": "sha512-khTmKu5NGYTotqcYo8Bew2k4RAE9PuXRSFRXG2lQBrCEQHyeQ/vX6gKMxfokwywBsLiXZMnHsmWMG1pnO8J7uQ==", "funding": [ { "type": "opencollective", @@ -7130,10 +7246,10 @@ ], "license": "MIT", "dependencies": { - "baseline-browser-mapping": "^2.11.29", - "caniuse-lite": "^1.0.30001816", - "electron-to-chromium": "^1.5.459", - "node-releases": "^2.0.59", + "baseline-browser-mapping": "^2.11.33", + "caniuse-lite": "^1.0.30001819", + "electron-to-chromium": "^1.5.464", + "node-releases": "^2.0.60", "update-browserslist-db": "^1.3.4" }, "bin": { @@ -7336,9 +7452,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001819", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001819.tgz", - "integrity": "sha512-LFUqIIAwpzsIuevnKnsR9qnStBcgzPShTX61lBwN9uFkkf8o8zLiebEjbW8AYtZiF+1YKDWfdt/pWDtMuG8Xwg==", + "version": "1.0.30001820", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001820.tgz", + "integrity": "sha512-gfWD6MxqVCqAOwmODfZf7DchWvlrA+SRdNX7ceLfg3xRqe5ssjjpF7EgslC/0tiLiNTajdaNN/q8c/5u/7Gz8w==", "funding": [ { "type": "opencollective", @@ -7545,48 +7661,73 @@ "devtools-protocol": "*" } }, - "node_modules/cjs-module-lexer": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-2.3.0.tgz", - "integrity": "sha512-lmNyBzi6iYiqyrExTctfmjd8tInx+dHu9PBomTfH5RXS53c27JMFJgzNv5TyAnm3PgGf+MLy0+Ws41/HlVxdKQ==", - "dev": true, - "license": "MIT" - }, "node_modules/cliui": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", - "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz", + "integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==", "dev": true, "license": "ISC", "dependencies": { - "string-width": "^4.2.0", - "strip-ansi": "^6.0.1", - "wrap-ansi": "^7.0.0" + "string-width": "^7.2.0", + "strip-ansi": "^7.1.0", + "wrap-ansi": "^9.0.0" }, + "engines": { + "node": ">=20" + } + }, + "node_modules/cliui/node_modules/ansi-regex": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.5.0.tgz", + "integrity": "sha512-zhvbbbdt1kbw7EJ9OQoxmQcuaNR9P5kTj7e/YdmOSU10aHWbycofJxA6jKfoEy3vlLdctOF9ZHaxLtmSm998wQ==", + "dev": true, + "license": "MIT", "engines": { "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" } }, "node_modules/cliui/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", "dev": true, "license": "MIT" }, "node_modules/cliui/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", "dev": true, "license": "MIT", "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" }, "engines": { - "node": ">=8" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cliui/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, "node_modules/clone-deep": { @@ -7751,19 +7892,20 @@ "license": "MIT" }, "node_modules/configstore": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/configstore/-/configstore-7.1.0.tgz", - "integrity": "sha512-N4oog6YJWbR9kGyXvS7jEykLDXIE2C0ILYqNBZBp9iwiJpoCBWYsuAdW6PPFn6w06jjnC+3JstVvWHO4cZqvRg==", + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/configstore/-/configstore-8.0.0.tgz", + "integrity": "sha512-U51WPZg+o6FDFhBCV6yYFEspGMCoHvCgJSGFw9AwsW2u75gYBPoDlYca5XfDs4TdMUu3/y0R6FFdvvgyH31mKQ==", "dev": true, "license": "BSD-2-Clause", "dependencies": { - "atomically": "^2.0.3", - "dot-prop": "^9.0.0", + "atomically": "^2.1.0", + "dot-prop": "^10.1.0", "graceful-fs": "^4.2.11", + "is-safe-filename": "^0.1.0", "xdg-basedir": "^5.1.0" }, "engines": { - "node": ">=18" + "node": ">=20" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -8408,13 +8550,16 @@ } }, "node_modules/define-lazy-prop": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz", - "integrity": "sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz", + "integrity": "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==", "dev": true, "license": "MIT", "engines": { - "node": ">=8" + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/define-properties": { @@ -8492,9 +8637,9 @@ } }, "node_modules/devtools-protocol": { - "version": "0.0.1687809", - "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1687809.tgz", - "integrity": "sha512-t7kSb+UKYdyxcmqy98U2P86ne6rCve3DIJ4+lp54i/hhClgFuG35JrLj23rO14Jw3sWDsp7i+22QhNU0+/qk8g==", + "version": "0.0.1727723", + "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1727723.tgz", + "integrity": "sha512-laxmpDN+zItbpy5klVbHqBsZ38O2rC6VwI9W5daM11Pc3Dj2gruc2wwnHW48CB8wYiWHALDkA6vRg92U2cfdLA==", "dev": true, "license": "BSD-3-Clause" }, @@ -8608,21 +8753,34 @@ } }, "node_modules/dot-prop": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-9.0.0.tgz", - "integrity": "sha512-1gxPBJpI/pcjQhKgIU91II6Wkay+dLcN3M6rf2uwP8hRur3HtQXjVrdAK3sjC0piaEuxzMwjXChcETiJl47lAQ==", + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-10.2.0.tgz", + "integrity": "sha512-BTJ9aZYL3vCfZlZOBLy9v8TUqWGQ0pzFnygKwFZt5udj6viBoFIBviKPUoZLDCPn1FoXffv6McQFDenrm5Krfw==", "dev": true, "license": "MIT", "dependencies": { - "type-fest": "^4.18.2" + "type-fest": "^5.0.0" }, "engines": { - "node": ">=18" + "node": ">=20" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/dotenv": { + "version": "17.4.2", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz", + "integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -8653,9 +8811,9 @@ "license": "MIT" }, "node_modules/electron-to-chromium": { - "version": "1.5.464", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.464.tgz", - "integrity": "sha512-nRa65aFge40viPAKxMKULIY8tcXKwKboJWNn4RoKwyWXJ6SP7BnrDGKUTzc64818jIciH0PK7LSF8QzZes19GA==", + "version": "1.5.468", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.468.tgz", + "integrity": "sha512-8XdEp3sIqO+PzmVPxhzqHq/3+yCxp44m27cm2WY1sRn3ZhwKyKmqSi2hx6gEhmnOXmh8NUuYE00z6Onzn5m6DA==", "license": "ISC" }, "node_modules/emoji-regex": { @@ -8676,13 +8834,17 @@ } }, "node_modules/encodeurl": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", - "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.1.tgz", + "integrity": "sha512-Ev/hIyePLZtpL98x562lvPtenrUfGr0lfaf44HFZ+hDEwso7ccV9QAZrEVLg0hxNAzygaAFxy2dYaAKKM0G06A==", "dev": true, "license": "MIT", "engines": { "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/encoding": { @@ -8918,9 +9080,9 @@ } }, "node_modules/es-module-lexer": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-3.0.4.tgz", - "integrity": "sha512-BldNY5mwkVu+cQ5Ymn3e6hB9Sx8XHx4ZevWbiUyZbfKxJxYO27Hf0wfzkxIaD5CAJMaGl3YspKELj0ff/7nCMA==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", "dev": true, "license": "MIT" }, @@ -10379,22 +10541,18 @@ } }, "node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", "dev": true, - "license": "ISC", + "license": "BlueOak-1.0.0", "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" }, "engines": { - "node": "*" + "node": "18 || 20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" @@ -10430,37 +10588,6 @@ "tslib": "2" } }, - "node_modules/glob/node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/glob/node_modules/brace-expansion": { - "version": "1.1.21", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", - "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/glob/node_modules/minimatch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, "node_modules/global-modules": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/global-modules/-/global-modules-2.0.0.tgz", @@ -11017,21 +11144,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/import-in-the-middle": { - "version": "3.5.3", - "resolved": "https://registry.npmjs.org/import-in-the-middle/-/import-in-the-middle-3.5.3.tgz", - "integrity": "sha512-usm/IiY63VtOyx/dm7uEoZV+EsanCDmwRUroQZIkFLGu1NVXZ8mZqIOhaN4QIJ7EsbMTuQbJXwCySCx+ozMA8g==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "cjs-module-lexer": "^2.2.0", - "es-module-lexer": "^3.0.3", - "module-details-from-path": "^1.0.4" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/import-local": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.2.0.tgz", @@ -11715,6 +11827,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-safe-filename": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/is-safe-filename/-/is-safe-filename-0.1.1.tgz", + "integrity": "sha512-4SrR7AdnY11LHfDKTZY1u6Ga3RuxZdl3YKWWShO5iyuG5h8QS4GD2tOb04peBJ5I7pXbR+CGBNEhTcwK+FzN3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-set": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", @@ -12191,13 +12316,6 @@ "shell-quote": "^1.10.0" } }, - "node_modules/legacy-javascript": { - "version": "0.0.4", - "resolved": "https://registry.npmjs.org/legacy-javascript/-/legacy-javascript-0.0.4.tgz", - "integrity": "sha512-UTmeKBVxnfJcpmpOvzzNnLsQT99BXHpPlc/ZKddIRmWmrR0fw6335I3lq4+sJoP60UTbBNaYsGYVPc65YB1x3A==", - "dev": true, - "license": "Apache-2.0" - }, "node_modules/levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", @@ -12213,40 +12331,34 @@ } }, "node_modules/lighthouse": { - "version": "13.5.0", - "resolved": "https://registry.npmjs.org/lighthouse/-/lighthouse-13.5.0.tgz", - "integrity": "sha512-w/yYIwg7mP9pCwkcbAXRztnvKHua2dJnZaNptPLa1cEw+akr6lbsIDiUOLsQwvaiAUr0xvxdmwvAzl3pj3Np0A==", + "version": "13.6.0", + "resolved": "https://registry.npmjs.org/lighthouse/-/lighthouse-13.6.0.tgz", + "integrity": "sha512-2/HUPUpQyGm01VEgYCUjSlNZY7Zkhk1v3QI4c7UTbnaoRB7HF0qYMPzzaiidNf5iqw5wnmtm9LpGawKKqCfLmg==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@paulirish/trace_engine": "0.0.65", - "@sentry/node": "^10.0.0", - "ajv": "^8.20.0", + "@paulirish/trace_engine": "0.0.67", + "@sentry/node": "^11.8.0", "ajv-formats": "^3.0.1", - "axe-core": "^4.13.0", - "chrome-launcher": "^1.2.1", - "configstore": "^7.0.0", + "axe-core": "4.14.0", + "chrome-launcher": "^1.2.2", + "configstore": "^8.0.0", "csp_evaluator": "1.1.8", - "devtools-protocol": "0.0.1687809", + "devtools-protocol": "0.0.1727723", "enquirer": "^2.3.6", "http-link-header": "^1.1.1", "intl-messageformat": "^10.5.3", - "jpeg-js": "^0.4.4", "js-library-detector": "^6.7.0", "lighthouse-logger": "^2.0.2", "lighthouse-stack-packs": "1.12.3", - "lodash-es": "^4.17.21", - "lookup-closest-locale": "6.2.0", - "open": "^8.4.0", - "puppeteer-core": "^25.9.0", + "open": "^11.0.4", + "puppeteer-core": "^25.16.0", "robots-parser": "^3.0.1", "speedline-core": "^1.4.3", "third-party-web": "^0.30.0", - "tldts-icann": "^7.4.13", - "web-features": "^3.39.0", - "ws": "^7.0.0", - "yargs": "^17.3.1", - "yargs-parser": "^21.0.0" + "tldts-icann": "^7.4.19", + "yargs": "^18.2.0", + "yargs-parser": "^22.0.0" }, "bin": { "chrome-debug": "core/scripts/manual-chrome-launcher.js", @@ -12275,30 +12387,6 @@ "dev": true, "license": "Apache-2.0" }, - "node_modules/lighthouse/node_modules/ajv": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", - "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.3", - "fast-uri": "^3.0.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/lighthouse/node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "dev": true, - "license": "MIT" - }, "node_modules/lilconfig": { "version": "3.1.4", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.4.tgz", @@ -12370,13 +12458,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/lodash-es": { - "version": "4.18.1", - "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz", - "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==", - "dev": true, - "license": "MIT" - }, "node_modules/lodash.debounce": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", @@ -12430,13 +12511,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/lookup-closest-locale": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/lookup-closest-locale/-/lookup-closest-locale-6.2.0.tgz", - "integrity": "sha512-/c2kL+Vnp1jnV6K6RpDTHK3dgg0Tu2VVp+elEiJpjfS1UyY7AjOYHohRug6wT0OpoX2qFgNORndE9RqesfVxWQ==", - "dev": true, - "license": "MIT" - }, "node_modules/loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", @@ -13523,6 +13597,16 @@ } } }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, "node_modules/mitt": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", @@ -13540,13 +13624,6 @@ "node": ">=18.0.0" } }, - "node_modules/module-details-from-path": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/module-details-from-path/-/module-details-from-path-1.0.4.tgz", - "integrity": "sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w==", - "dev": true, - "license": "MIT" - }, "node_modules/mousetrap": { "version": "1.6.5", "resolved": "https://registry.npmjs.org/mousetrap/-/mousetrap-1.6.5.tgz", @@ -13693,9 +13770,9 @@ } }, "node_modules/node-releases": { - "version": "2.0.60", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.60.tgz", - "integrity": "sha512-OmO8+GM1JUZqXET720VrJCwkjUCXmCMQm0Z3UDDRbfNLHS4NEAvY60j5jiFv0tkKw2PNNlqOLhahVmwJIOLDIQ==", + "version": "2.0.62", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.62.tgz", + "integrity": "sha512-A0Eq+qVDQI97IbAD6g/HriIuv256el9N8EwF4+xqJ49pwrdRYpdCSmLk0sO4I3fnS6hpYE5v22B73Bk+WArEoQ==", "license": "MIT", "engines": { "node": ">=18" @@ -13877,6 +13954,59 @@ "node": ">=10" } }, + "node_modules/npm-packlist/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/npm-packlist/node_modules/brace-expansion": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", + "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/npm-packlist/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/npm-packlist/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, "node_modules/nth-check": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", @@ -14065,18 +14195,21 @@ } }, "node_modules/open": { - "version": "8.4.2", - "resolved": "https://registry.npmjs.org/open/-/open-8.4.2.tgz", - "integrity": "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==", + "version": "11.0.4", + "resolved": "https://registry.npmjs.org/open/-/open-11.0.4.tgz", + "integrity": "sha512-++Zlftm0kVLPmzC06t6epuWmcRMDbI4z5P3NNX979WA/k23+NtSOynEGzsVfZwguKw2mi5umVgnBlJQMwRz4Pg==", "dev": true, "license": "MIT", "dependencies": { - "define-lazy-prop": "^2.0.0", - "is-docker": "^2.1.1", - "is-wsl": "^2.2.0" + "default-browser": "^5.5.1", + "define-lazy-prop": "^3.0.0", + "is-in-ssh": "^1.0.0", + "is-inside-container": "^1.0.0", + "powershell-utils": "^0.2.1", + "wsl-utils": "^1.0.0" }, "engines": { - "node": ">=12" + "node": ">=20" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -14129,6 +14262,43 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/oxc-parser": { + "version": "0.152.0", + "resolved": "https://registry.npmjs.org/oxc-parser/-/oxc-parser-0.152.0.tgz", + "integrity": "sha512-ZztsZgaCWmPeKYMdoTT5e0oV/oxD1NeLWNR3kAtr/G707cUPYaaozRlVDHLCGnszHJRszN9mJwmPNpqgwloC0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "^0.152.0" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/sponsors/oxc-project" + }, + "optionalDependencies": { + "@oxc-parser/binding-android-arm-eabi": "0.152.0", + "@oxc-parser/binding-android-arm64": "0.152.0", + "@oxc-parser/binding-darwin-arm64": "0.152.0", + "@oxc-parser/binding-darwin-x64": "0.152.0", + "@oxc-parser/binding-freebsd-x64": "0.152.0", + "@oxc-parser/binding-linux-arm-gnueabihf": "0.152.0", + "@oxc-parser/binding-linux-arm-musleabihf": "0.152.0", + "@oxc-parser/binding-linux-arm64-gnu": "0.152.0", + "@oxc-parser/binding-linux-arm64-musl": "0.152.0", + "@oxc-parser/binding-linux-ppc64-gnu": "0.152.0", + "@oxc-parser/binding-linux-riscv64-gnu": "0.152.0", + "@oxc-parser/binding-linux-riscv64-musl": "0.152.0", + "@oxc-parser/binding-linux-s390x-gnu": "0.152.0", + "@oxc-parser/binding-linux-x64-gnu": "0.152.0", + "@oxc-parser/binding-linux-x64-musl": "0.152.0", + "@oxc-parser/binding-openharmony-arm64": "0.152.0", + "@oxc-parser/binding-win32-arm64-msvc": "0.152.0", + "@oxc-parser/binding-win32-ia32-msvc": "0.152.0", + "@oxc-parser/binding-win32-x64-msvc": "0.152.0" + } + }, "node_modules/p-limit": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", @@ -14335,6 +14505,33 @@ "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", "license": "MIT" }, + "node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "11.5.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz", + "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/path-to-regexp": { "version": "8.4.2", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", @@ -14556,9 +14753,9 @@ } }, "node_modules/postcss": { - "version": "8.5.31", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.31.tgz", - "integrity": "sha512-YUbWYCgErwnMAVkjUnYa+KlMBzGpHwiIt/DE7f0G33AuX2LH9UcTk6KgWxd3zRU+pN3u/C+Xl3EnMLM4Hea7Sg==", + "version": "8.5.32", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.32.tgz", + "integrity": "sha512-LJlKCCAUFKOuZxipUdYw1NzIITEsPcgyOG9LkvRJvuKj/bRzbuRY0ARXvL0pWGpsZvn8jku+LMep2eU0seHp8g==", "funding": [ { "type": "opencollective", @@ -15401,28 +15598,6 @@ "dev": true, "license": "BSD-3-Clause" }, - "node_modules/puppeteer-core/node_modules/ws": { - "version": "8.22.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.22.0.tgz", - "integrity": "sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": ">=5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } - }, "node_modules/pvtsutils": { "version": "1.3.6", "resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.6.tgz", @@ -15464,9 +15639,9 @@ "license": "MIT" }, "node_modules/qs": { - "version": "6.16.1", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.1.tgz", - "integrity": "sha512-5Px6J7rfHBAGUhNQ3es4YzcOdBBw0SDcVtThJml4RdnKw4tKs2epA0kPZ+K8X+WiHktT93RHVcQ/3ym5aTCgtg==", + "version": "6.17.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.17.1.tgz", + "integrity": "sha512-jd3COkaKVgGUW+ZHyEHCG9bH7RoaaiWuMgzsIBjUMVjLAaWGWpoUAVwljVvBHxRu/2qEHnadLGZrkutYUBPQJA==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -15891,19 +16066,9 @@ "node_modules/requestidlecallback": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/requestidlecallback/-/requestidlecallback-0.3.0.tgz", - "integrity": "sha512-TWHFkT7S9p7IxLC5A1hYmAYQx2Eb9w1skrXmQ+dS1URyvR8tenMLl4lHbqEOUnpEYxNKpkVMXUgknVpBZWXXfQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/require-directory": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", - "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } + "integrity": "sha512-TWHFkT7S9p7IxLC5A1hYmAYQx2Eb9w1skrXmQ+dS1URyvR8tenMLl4lHbqEOUnpEYxNKpkVMXUgknVpBZWXXfQ==", + "dev": true, + "license": "MIT" }, "node_modules/require-from-string": { "version": "2.0.2", @@ -15915,20 +16080,6 @@ "node": ">=0.10.0" } }, - "node_modules/require-in-the-middle": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/require-in-the-middle/-/require-in-the-middle-8.0.1.tgz", - "integrity": "sha512-QT7FVMXfWOYFbeRBF6nu+I6tr2Tf3u0q8RIEjNob/heKY/nh7drD/k7eeMFmSQgnTtCzLDcCu/XEnpW2wk4xCQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "^4.3.5", - "module-details-from-path": "^1.0.3" - }, - "engines": { - "node": ">=9.3.0 || >=8.10.0 <9.0.0" - } - }, "node_modules/requireindex": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/requireindex/-/requireindex-1.2.0.tgz", @@ -16220,9 +16371,9 @@ "license": "MIT" }, "node_modules/sass": { - "version": "1.106.1", - "resolved": "https://registry.npmjs.org/sass/-/sass-1.106.1.tgz", - "integrity": "sha512-kD+Gi66vycZcbJt+42NC4z4PXNMcCajVrJPhqoKKXy5cOBMmJQt052CxIBcRjwYXeImHYOpu/ydWWGZQIenl7A==", + "version": "1.107.0", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.107.0.tgz", + "integrity": "sha512-+whPilvVNoAD57xvdIPLQT0gXo4RD1lHYw+Uw8zYHlojaeaMDxK14tc0JmEsz/XMns48h3lpypisMP+3E8eHQA==", "dev": true, "license": "MIT", "dependencies": { @@ -16460,6 +16611,19 @@ "upper-case-first": "^2.0.2" } }, + "node_modules/sentry": { + "version": "0.45.0", + "resolved": "https://registry.npmjs.org/sentry/-/sentry-0.45.0.tgz", + "integrity": "sha512-kgmEbLlC22lL7uneeUm9Sjz65rp5cvwYOMTxXbZCmBum4sbVre/lLT3zTV3kwXHJ9/BM8ZS0/GhZe9Cl1t6WyA==", + "dev": true, + "license": "FSL-1.1-Apache-2.0", + "bin": { + "sentry": "dist/bin.cjs" + }, + "engines": { + "node": ">=20.0" + } + }, "node_modules/serialize-javascript": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz", @@ -17051,9 +17215,9 @@ } }, "node_modules/string-width/node_modules/ansi-regex": { - "version": "6.4.0", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.4.0.tgz", - "integrity": "sha512-KzTVk2tCWAHtYrvvvaP8bJKJq2pVinhLcGEQdtLIYPbmNGNyYe8QwNaTUYQp2J7/vIsUKt5QCqAfUkYyG9DkOw==", + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.5.0.tgz", + "integrity": "sha512-zhvbbbdt1kbw7EJ9OQoxmQcuaNR9P5kTj7e/YdmOSU10aHWbycofJxA6jKfoEy3vlLdctOF9ZHaxLtmSm998wQ==", "dev": true, "license": "MIT", "engines": { @@ -17431,9 +17595,9 @@ } }, "node_modules/stylelint/node_modules/ansi-regex": { - "version": "6.4.0", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.4.0.tgz", - "integrity": "sha512-KzTVk2tCWAHtYrvvvaP8bJKJq2pVinhLcGEQdtLIYPbmNGNyYe8QwNaTUYQp2J7/vIsUKt5QCqAfUkYyG9DkOw==", + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.5.0.tgz", + "integrity": "sha512-zhvbbbdt1kbw7EJ9OQoxmQcuaNR9P5kTj7e/YdmOSU10aHWbycofJxA6jKfoEy3vlLdctOF9ZHaxLtmSm998wQ==", "dev": true, "license": "MIT", "engines": { @@ -17804,6 +17968,19 @@ "node": ">=8" } }, + "node_modules/tagged-tag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz", + "integrity": "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/tannin": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/tannin/-/tannin-1.2.0.tgz", @@ -17995,9 +18172,9 @@ } }, "node_modules/tldts-core": { - "version": "7.4.19", - "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.19.tgz", - "integrity": "sha512-goNXV6Ho9TIOfpJZanTd6CKG5DyYgYAL+k+22T0SLh8l6+KHIfBUPFuePuSZv05UttIaaLmI9tb7TmGIdK5AkQ==", + "version": "7.4.20", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.20.tgz", + "integrity": "sha512-C630TWXZCZmLq0F2bW4QqwnaaxCQ/svpAqvJwnC4JvgEM5ohhBnGsEzTLSOXcN+xjz+kEaHS5uoWIw2klnqd0A==", "dev": true, "license": "MIT" }, @@ -18168,13 +18345,16 @@ } }, "node_modules/type-fest": { - "version": "4.41.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", - "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "version": "5.10.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.10.0.tgz", + "integrity": "sha512-NoSdpq/WEiAg5sjmBkmV/hfxv6HJH4NqPNrqjtSO5CwRmpsDfaf4begxW34KdJykH/l1yHtwBWQkCRdoXO8mPA==", "dev": true, "license": "(MIT OR CC0-1.0)", + "dependencies": { + "tagged-tag": "^1.0.0" + }, "engines": { - "node": ">=16" + "node": ">=20" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -18655,13 +18835,6 @@ "node": ">=10.13.0" } }, - "node_modules/web-features": { - "version": "3.44.0", - "resolved": "https://registry.npmjs.org/web-features/-/web-features-3.44.0.tgz", - "integrity": "sha512-K/+YJI7uVE/b4G8SU67/F/4j81/ajJ6MfBCZyuQaqyuPlVXF8cd6dLqBznXIWJI9nwnvuY2dapKK08OHzTjYww==", - "dev": true, - "license": "Apache-2.0" - }, "node_modules/web-vitals": { "version": "4.2.4", "resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-4.2.4.tgz", @@ -18754,6 +18927,28 @@ "node": ">= 10" } }, + "node_modules/webpack-bundle-analyzer/node_modules/ws": { + "version": "7.5.13", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.13.tgz", + "integrity": "sha512-rsKI6xDBFVf4r/x8XyChGK04QR/XHroxs/jUcoWvtEZM8TPU/X/uIY9B1CsSzYws9ZJb/6bbBu7dPhFW00CAoA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.3.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": "^5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/webpack-cli": { "version": "5.1.4", "resolved": "https://registry.npmjs.org/webpack-cli/-/webpack-cli-5.1.4.tgz", @@ -18811,9 +19006,9 @@ } }, "node_modules/webpack-dev-middleware": { - "version": "8.5.0", - "resolved": "https://registry.npmjs.org/webpack-dev-middleware/-/webpack-dev-middleware-8.5.0.tgz", - "integrity": "sha512-+rXF28E5GKr7VnLH5qJaGIKLNPkthlA0VhqBFp/K6mjjCzOd6q34vYc2/3/ysRTjxmN01S98r1LwTTpWXDzWWA==", + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/webpack-dev-middleware/-/webpack-dev-middleware-8.6.0.tgz", + "integrity": "sha512-gDtuNFvzUys4RSkLAjXFCSbKz/MYIrCR9oXcIOnv4vFTmGsArddZoCb9LKlKWZO/JVL0COgK6hFBGPOt6EBg2Q==", "dev": true, "license": "MIT", "dependencies": { @@ -18851,29 +19046,6 @@ "node": ">= 0.6" } }, - "node_modules/webpack-dev-middleware/node_modules/ws": { - "version": "8.22.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.22.0.tgz", - "integrity": "sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==", - "dev": true, - "license": "MIT", - "optional": true, - "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": ">=5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } - }, "node_modules/webpack-dev-server": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/webpack-dev-server/-/webpack-dev-server-6.1.0.tgz", @@ -18928,62 +19100,6 @@ } } }, - "node_modules/webpack-dev-server/node_modules/define-lazy-prop": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz", - "integrity": "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/webpack-dev-server/node_modules/open": { - "version": "11.0.4", - "resolved": "https://registry.npmjs.org/open/-/open-11.0.4.tgz", - "integrity": "sha512-++Zlftm0kVLPmzC06t6epuWmcRMDbI4z5P3NNX979WA/k23+NtSOynEGzsVfZwguKw2mi5umVgnBlJQMwRz4Pg==", - "dev": true, - "license": "MIT", - "dependencies": { - "default-browser": "^5.5.1", - "define-lazy-prop": "^3.0.0", - "is-in-ssh": "^1.0.0", - "is-inside-container": "^1.0.0", - "powershell-utils": "^0.2.1", - "wsl-utils": "^1.0.0" - }, - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/webpack-dev-server/node_modules/ws": { - "version": "8.22.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.22.0.tgz", - "integrity": "sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": ">=5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } - }, "node_modules/webpack-merge": { "version": "5.10.0", "resolved": "https://registry.npmjs.org/webpack-merge/-/webpack-merge-5.10.0.tgz", @@ -19000,22 +19116,15 @@ } }, "node_modules/webpack-sources": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.6.0.tgz", - "integrity": "sha512-EIMmPVvNI0CYXyRPp34F2Qk7W37/BZVZIofS6LAUnrNjCVahS+hO4mxwFJDjQjODTGZDtVzLYoN2+1dYkhk9qA==", + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.7.0.tgz", + "integrity": "sha512-PdUhbCycwEgiS1hB/FBDksWKg/ashAIuJKqwdbPn8/dFVgU5LxQiBvG0js6VRRIzPujMrStYCczywp82P0KWng==", "dev": true, "license": "MIT", "engines": { "node": ">=10.13.0" } }, - "node_modules/webpack/node_modules/es-module-lexer": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", - "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", - "dev": true, - "license": "MIT" - }, "node_modules/webpack/node_modules/mime-db": { "version": "1.54.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", @@ -19156,43 +19265,88 @@ } }, "node_modules/wrap-ansi": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", "dev": true, "license": "MIT", "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" }, "engines": { - "node": ">=10" + "node": ">=18" }, "funding": { "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/wrap-ansi/node_modules/ansi-regex": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.5.0.tgz", + "integrity": "sha512-zhvbbbdt1kbw7EJ9OQoxmQcuaNR9P5kTj7e/YdmOSU10aHWbycofJxA6jKfoEy3vlLdctOF9ZHaxLtmSm998wQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/wrap-ansi/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", "dev": true, "license": "MIT" }, "node_modules/wrap-ansi/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", "dev": true, "license": "MIT", "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" }, "engines": { - "node": ">=8" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/wrap-ansi/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, "node_modules/wrappy": { @@ -19216,17 +19370,17 @@ } }, "node_modules/ws": { - "version": "7.5.13", - "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.13.tgz", - "integrity": "sha512-rsKI6xDBFVf4r/x8XyChGK04QR/XHroxs/jUcoWvtEZM8TPU/X/uIY9B1CsSzYws9ZJb/6bbBu7dPhFW00CAoA==", + "version": "8.22.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.22.0.tgz", + "integrity": "sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==", "dev": true, "license": "MIT", "engines": { - "node": ">=8.3.0" + "node": ">=10.0.0" }, "peerDependencies": { "bufferutil": "^4.0.1", - "utf-8-validate": "^5.0.2" + "utf-8-validate": ">=5.0.2" }, "peerDependenciesMeta": { "bufferutil": { @@ -19311,54 +19465,31 @@ } }, "node_modules/yargs": { - "version": "17.7.3", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.3.tgz", - "integrity": "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==", + "version": "18.2.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.2.0.tgz", + "integrity": "sha512-9OpKOLeaoNFecEp7P6iYbzze/5CqWoH7N3SMs/6y1XF4nMvFMspEGZzJ6uFi9MmQwXhyzqYoSEKO3tvA3Z/o2w==", "dev": true, "license": "MIT", "dependencies": { - "cliui": "^8.0.1", + "cliui": "^9.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", - "require-directory": "^2.1.1", - "string-width": "^4.2.3", + "string-width": "^8.2.1", "y18n": "^5.0.5", - "yargs-parser": "^21.1.1" + "yargs-parser": "^22.0.0" }, "engines": { - "node": ">=12" + "node": "^20.19.0 || ^22.12.0 || >=23" } }, "node_modules/yargs-parser": { - "version": "21.1.1", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", - "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "version": "22.0.0", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz", + "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==", "dev": true, "license": "ISC", "engines": { - "node": ">=12" - } - }, - "node_modules/yargs/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, - "license": "MIT" - }, - "node_modules/yargs/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" + "node": "^20.19.0 || ^22.12.0 || >=23" } }, "node_modules/yocto-queue": { From 275412b77421d2ee9fd510507e9812c85cb594fa Mon Sep 17 00:00:00 2001 From: Tony Hegyes Date: Fri, 6 Nov 2026 14:26:34 +0100 Subject: [PATCH 02/29] Test the scoping cases a run misses, misreports or rejects The scope run lets some broken output through, misreports some failures, and rejects some valid projects. Its declared-symbol check misses the code in a nowdoc that opens with a blank line, the class of a 'Class::MEMBER' string, a class satisfied only by a function or a namespace of its name, and a constant written in another case. It also lets a scoped package keep a class outside the prefix. It crashes on a nowdoc template that does not parse, and on an unparsable file without naming it. A patcher failure deletes the previous build. A text domain quoted inside another string turns into invalid PHP. A package that shares the project's text domain fails the run. The namespace-string patcher prefixes a host class the stubs exclude. A stubs file's uppercase DEFINE() and a class_alias() named by Foo::class are ignored. A dependency the root replaces or provides fails resolution. These rows fail until the fixes land. ScopedPackagesTest also pins the replace, provide, composer-plugin and metapackage branches, which pass today. Both tests now record installed.php beside installed.json, as Composer does, through one Support trait. Port audit A146-A154 and A182 (X rulings 184, 191), A493 (X ruling 240). Assisted-by: Claude Code:claude-opus-5-5 --- .../Support/RecordsInstalledPackagesTrait.php | 43 ++++++ tests/Unit/ScopePhpDependenciesTest.php | 132 ++++++++++++++++-- tests/Unit/ScopedPackagesTest.php | 129 +++++++++++++++++ 3 files changed, 295 insertions(+), 9 deletions(-) create mode 100644 tests/Support/RecordsInstalledPackagesTrait.php create mode 100644 tests/Unit/ScopedPackagesTest.php diff --git a/tests/Support/RecordsInstalledPackagesTrait.php b/tests/Support/RecordsInstalledPackagesTrait.php new file mode 100644 index 0000000..3bbf4fa --- /dev/null +++ b/tests/Support/RecordsInstalledPackagesTrait.php @@ -0,0 +1,43 @@ + $root + * @param list> $packages + */ + protected static function record_installed_packages( string $vendor_dir, array $root, array $packages ): void { + $installed_json = array( + 'packages' => $packages, + 'dev' => true, + 'dev-package-names' => array(), + ); + \file_put_contents( $vendor_dir . '/composer/installed.json', \json_encode( $installed_json, \JSON_THROW_ON_ERROR | \JSON_PRETTY_PRINT | \JSON_UNESCAPED_SLASHES ) ); + + // Composer records the root and every installed package, and every name one of them replaces or provides. + $records = array( + 'replace' => 'replaced', + 'provide' => 'provided', + ); + $versions = array(); + foreach ( array( $root, ...$packages ) as $package ) { + self::assertIsString( $package['name'] ); + $versions[ $package['name'] ]['dev_requirement'] = false; + foreach ( $records as $link => $record ) { + foreach ( (array) ( $package[ $link ] ?? array() ) as $target => $constraint ) { + $versions[ $target ]['dev_requirement'] = false; + $versions[ $target ][ $record ][] = $constraint; + } + } + } + $installed_php = array( + 'root' => array( + 'name' => $root['name'], + 'dev' => true, + ), + 'versions' => $versions, + ); + \file_put_contents( $vendor_dir . '/composer/installed.php', ' array( $return, "\t\tnew \\Date();\n" . $return, 'Acme\Scoped\Date' ), 'callable-of-a-php-function' => array( $return, "\t\t\\add_action( 'init', 'Brotli\\compress' );\n" . $return, 'Acme\Scoped\Brotli\compress' ), 'uppercased-function-exists' => array( $return, "\t\t\\FUNCTION_EXISTS( 'Brotli\\compress' );\n" . $return, 'Acme\Scoped\Brotli\compress' ), + 'nowdoc-after-a-blank-line' => array( $return, "\t\t\$code = <<<'PHP'\n\n\t\t\t array( $return, "\t\t\\constant( 'Automattic\\\\WooCommerce\\\\Future\\\\Feature::FLAG' );\n" . $return, 'Acme\Scoped\Automattic\WooCommerce\Future\Feature' ), + 'function-taken-for-a-class' => array( $return, "\t\tnew \\Acme\\Commerce\\get_label_prefix();\n" . $return, 'Acme\Scoped\Acme\Commerce\get_label_prefix' ), + 'namespace-taken-for-a-class' => array( $return, "\t\tnew \\Acme\\Commerce\\Formats();\n" . $return, 'Acme\Scoped\Acme\Commerce\Formats' ), + 'constant-in-another-case' => array( $return, "\t\t\$label = \\Acme\\Commerce\\Label;\n" . $return, 'Acme\Scoped\Acme\Commerce\Label' ), ); } - public function test_scope_run_fails_when_a_declared_text_domain_survives_the_rewrite(): void { + public function test_scope_run_fails_when_a_declared_text_domain_survives_the_rewrite_and_keeps_the_previous_build(): void { $project = self::make_project( array( 'acme/commerce' ) ); - $section = $project . '/vendor' . self::SECTION; + ScopePhpDependencies::scope( $project ); + $autoload = self::read( $project . '/vendor-prefixed/scoper-autoload.php' ); + $packages = self::package_dirs( $project . '/vendor-prefixed' ); + $section = $project . '/vendor' . self::SECTION; \file_put_contents( $section, \str_replace( "\t\treturn \\__(", "\t\t\$credit = 'Translated by acme-commerce';\n\t\treturn \\__(", self::read( $section ) ) ); + try { + ScopePhpDependencies::scope( $project ); + self::fail( 'The scope run did not fail.' ); + } catch ( \RuntimeException $exception ) { + self::assertStringContainsString( "still contains the text domain 'acme-commerce'", $exception->getMessage() ); + } + self::assertSame( $autoload, self::read( $project . '/vendor-prefixed/scoper-autoload.php' ) ); + self::assertSame( $packages, self::package_dirs( $project . '/vendor-prefixed' ) ); + } + + public function test_scope_run_reports_a_text_domain_quoted_inside_another_string(): void { + $project = self::make_project( array( 'acme/commerce' ) ); + $section = $project . '/vendor' . self::SECTION; + \file_put_contents( $section, \str_replace( "\t\treturn \\__(", "\t\t\$hint = 'Choose \"acme-commerce\" here';\n\t\treturn \\__(", self::read( $section ) ) ); + + $this->expectException( \RuntimeException::class ); + $this->expectExceptionMessageIsOrContains( "still contains the text domain 'acme-commerce'" ); + + ScopePhpDependencies::scope( $project ); + } + + public function test_a_double_quoted_text_domain_takes_the_project_s(): void { + $project = self::make_project( array( 'acme/commerce' ) ); + $section = $project . '/vendor' . self::SECTION; + \file_put_contents( $section, \str_replace( "\t\treturn \\__(", "\t\t\$other = \\__( 'Other', \"acme-commerce\" );\n\t\treturn \\__(", self::read( $section ) ) ); + + ScopePhpDependencies::scope( $project ); + + self::assertStringContainsString( "\\__('Other', 'acme-text-domain')", self::read( $project . '/vendor-prefixed' . self::SECTION ) ); + } + + public function test_a_package_keeps_the_text_domain_it_shares_with_the_project(): void { + $project = self::make_project( array( 'acme/commerce' ), array( 'text-domain' => 'acme-commerce' ) ); + + ScopePhpDependencies::scope( $project ); + + self::assertStringContainsString( "\\__('Commerce', 'acme-commerce')", self::read( $project . '/vendor-prefixed' . self::SECTION ) ); + } + + public function test_a_host_class_under_a_scoped_package_s_namespace_stays_global_in_strings(): void { + $project = self::make_project( array( 'acme/commerce' ), array( 'scoping-stubs' => array( './stubs/host-plugin.php', './stubs/gateway.php' ) ) ); + \file_put_contents( $project . '/stubs/gateway.php', " array( './stubs/host-plugin.php', './stubs/calls.php' ) ) ); + \file_put_contents( $project . '/stubs/calls.php', " + */ + public static function provide_symbols_stubs_declare_through_calls(): array { + return array( + 'constant-of-an-uppercase-define' => array( "DEFINE( 'Host\\\\Plugin\\\\FLAG', 1 );", '$flag = \\Host\\Plugin\\FLAG' ), + 'alias-named-by-a-class-constant' => array( "class Host_Plugin_Real {}\nclass_alias( 'Host_Plugin_Real', \\Host\\Plugin\\Alias::class );", '$alias = new \\Host\\Plugin\\Alias()' ), + ); + } + + public function test_scope_run_fails_when_the_scoped_code_declares_a_class_outside_the_prefix(): void { + $project = self::make_project( array( 'acme/commerce' ) ); + \file_put_contents( $project . '/vendor/acme/commerce/lib/Mailer.php', "getMessage() ); + self::assertMatchesRegularExpression( '/\nPHPMailer\\\\PHPMailer$/m', $exception->getMessage() ); + } + } + + public function test_a_polyfill_of_a_php_class_passes_the_check(): void { + $project = self::make_project( array( 'acme/commerce' ) ); + \file_put_contents( $project . '/vendor/acme/commerce/lib/Polyfill.php', "expectException( \RuntimeException::class ); - $this->expectExceptionMessageIsOrContains( 'acme-commerce' ); + $this->expectExceptionMessageMatches( '#acme/commerce/lib/Broken\.php#' ); ScopePhpDependencies::scope( $project ); } @@ -514,12 +633,7 @@ protected static function make_project( array $names = array( 'acme/commerce', ' new Filesystem()->mirror( $source, $project . '/' . $vendor . '/' . $name ); $packages[] = array( 'install-path' => '../' . $name ) + $package; } - $installed_json = array( - 'packages' => $packages, - 'dev' => true, - 'dev-package-names' => array(), - ); - \file_put_contents( $project . '/' . $vendor . '/composer/installed.json', \json_encode( $installed_json, \JSON_THROW_ON_ERROR | \JSON_PRETTY_PRINT | \JSON_UNESCAPED_SLASHES ) ); + self::record_installed_packages( $project . '/' . $vendor, $manifest, $packages ); return $project; } diff --git a/tests/Unit/ScopedPackagesTest.php b/tests/Unit/ScopedPackagesTest.php new file mode 100644 index 0000000..e9a4cdf --- /dev/null +++ b/tests/Unit/ScopedPackagesTest.php @@ -0,0 +1,129 @@ +remove( \dirname( $this->vendor_dir ) ); + } + + /** + * @param list> $packages + * @param array $root_links + * @param list $scoped + */ + #[DataProvider( 'provide_links_composer_resolved' )] + public function test_dependencies_follow_the_links_composer_resolved( array $packages, array $root_links, array $scoped ): void { + $this->install( $packages, $root_links ); + + self::assertSame( $scoped, \array_keys( ScopedPackages::resolve( $this->vendor_dir, array( 'acme/app' ) )['scoped'] ) ); + } + + /** + * @return array>, array, list}> + */ + public static function provide_links_composer_resolved(): array { + return array( + 'replaced-dependency' => array( + array( + self::package( 'acme/app', array( 'require' => array( 'acme/upstream' => '*' ) ) ), + self::package( 'acme/fork', array( 'replace' => array( 'acme/upstream' => '*' ) ) ), + ), + array(), + array( 'acme/app', 'acme/fork' ), + ), + 'provided-virtual-package' => array( + array( + self::package( 'acme/app', array( 'require' => array( 'acme/logger-implementation' => '*' ) ) ), + self::package( 'acme/adapter', array( 'provide' => array( 'acme/logger-implementation' => '1.0' ) ) ), + ), + array(), + array( 'acme/app' ), + ), + 'composer-plugin-dependency' => array( + array( + self::package( 'acme/app', array( 'require' => array( 'acme/installer' => '*' ) ) ), + self::package( 'acme/installer', array( 'type' => 'composer-plugin' ) ), + ), + array(), + array( 'acme/app' ), + ), + 'metapackage-dependency' => array( + array( + self::package( 'acme/app', array( 'require' => array( 'acme/bundle' => '*' ) ) ), + self::package( + 'acme/bundle', + array( + 'type' => 'metapackage', + 'require' => array( 'acme/loader' => '*' ), + ) + ), + self::package( 'acme/loader' ), + ), + array(), + array( 'acme/app', 'acme/loader' ), + ), + 'dependency-the-root-replaces' => array( + array( self::package( 'acme/app', array( 'require' => array( 'acme/polyfill' => '*' ) ) ) ), + array( 'replace' => array( 'acme/polyfill' => '*' ) ), + array( 'acme/app' ), + ), + 'virtual-the-root-provides' => array( + array( self::package( 'acme/app', array( 'require' => array( 'acme/cache-implementation' => '*' ) ) ) ), + array( 'provide' => array( 'acme/cache-implementation' => '1.0' ) ), + array( 'acme/app' ), + ), + ); + } + + public function test_a_dependency_nothing_installs_replaces_or_provides_fails(): void { + $this->install( array( self::package( 'acme/app', array( 'require' => array( 'acme/absent' => '*' ) ) ) ), array() ); + + $this->expectException( \RuntimeException::class ); + $this->expectExceptionMessageIsOrContains( "'acme/app' requires 'acme/absent', which is not installed" ); + + ScopedPackages::resolve( $this->vendor_dir, array( 'acme/app' ) ); + } + + /** + * @param list> $packages + * @param array $root_links + */ + protected function install( array $packages, array $root_links ): void { + $this->vendor_dir = ( \realpath( \sys_get_temp_dir() ) ?: \sys_get_temp_dir() ) . '/wp-configs-packages ' . \uniqid() . '/vendor'; + \mkdir( $this->vendor_dir . '/composer', 0777, true ); + foreach ( $packages as $package ) { + self::assertIsString( $package['name'] ); + \mkdir( $this->vendor_dir . '/' . $package['name'], 0777, true ); + } + self::record_installed_packages( $this->vendor_dir, array( 'name' => 'acme/project' ) + $root_links, $packages ); + } + + /** + * @param array $links + * + * @return array + */ + protected static function package( string $name, array $links = array() ): array { + return $links + array( + 'name' => $name, + 'version' => '1.0.0', + 'type' => 'library', + 'install-path' => '../' . $name, + ); + } +} From c98affaab0bdf0e213b4a7b65a650b17fb4e6617 Mon Sep 17 00:00:00 2001 From: Tony Hegyes Date: Fri, 6 Nov 2026 14:42:49 +0100 Subject: [PATCH 03/29] Count a requirement the root replaces or provides as met ScopedPackages read the packages to scope and their replace and provide links from installed.json, which never lists the root package. A project that replaces a polyfill at its root, a common idiom on a PHP floor the polyfill no longer serves, could therefore not scope any library that requires it: the run failed with "requires ..., which is not installed", and the README's ! drop could not help, because the package is not installed. Composer records in installed.php every name a package or the root replaces or provides. A requirement no installed package fills now counts as met when it is recorded there, which also covers the provide map installed.json fed. Port audit A146 (X ruling 184). Assisted-by: Claude Code:claude-opus-5-5 --- README.md | 2 +- php/composer/ScopedPackages.php | 15 ++++++++------- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 355bcb1..ba193a8 100644 --- a/README.md +++ b/README.md @@ -170,7 +170,7 @@ Create `scoper.inc.php` at the project root and name the packages to scope: return ( require __DIR__ . '/vendor/ahegyes/wordpress-configs/php/php-scoper/scoper-base.inc.php' )( __DIR__, 'dompdf/dompdf', 'php-di/php-di' ); ``` -A name is a package name or an `fnmatch()` pattern, such as `'your-vendor/your-library-*'`. The runtime dependencies of each named package follow from Composer's `installed.json`, through `require` and never `require-dev`, so naming a library scopes what it needs. A pattern matches every installed package, development tools included, so keep it as narrow as the packages it means. A name that starts with `!` keeps the packages it matches unscoped, such as a polyfill whose global functions must stay reachable (`'!symfony/polyfill-php80'`), and the symbols they declare stay global too. Keep the scoped packages in `require-dev`, so a production install never loads their unprefixed copies, and require an unscoped package in `require`, so a production install still ships it. +A name is a package name or an `fnmatch()` pattern, such as `'your-vendor/your-library-*'`. The runtime dependencies of each named package follow from Composer's `installed.json`, through `require` and never `require-dev`, so naming a library scopes what it needs; a requirement that another package or the project itself replaces or provides, as Composer records in `installed.php`, counts as met. A pattern matches every installed package, development tools included, so keep it as narrow as the packages it means. A name that starts with `!` keeps the packages it matches unscoped, such as a polyfill whose global functions must stay reachable (`'!symfony/polyfill-php80'`), and the symbols they declare stay global too. Keep the scoped packages in `require-dev`, so a production install never loads their unprefixed copies, and require an unscoped package in `require`, so a production install still ships it. A scope run: diff --git a/php/composer/ScopedPackages.php b/php/composer/ScopedPackages.php index dbb7305..c8e4484 100644 --- a/php/composer/ScopedPackages.php +++ b/php/composer/ScopedPackages.php @@ -16,7 +16,7 @@ final class ScopedPackages { * @param string $vendor_dir The project's Composer vendor directory. * @param list $names The packages to scope, as names or fnmatch() patterns, where a name that starts with ! drops the packages it matches. * - * @throws \RuntimeException Thrown when no name is given, installed.json is unreadable, a name matches no installed package, a dependency is not installed, or a scoped package is a symbolic link. + * @throws \RuntimeException Thrown when no name is given, installed.json or installed.php is unreadable, a name matches no installed package, a dependency is not installed, or a scoped package is a symbolic link. * * @return array{scoped: array, dropped: array} */ @@ -27,14 +27,16 @@ public static function resolve( string $vendor_dir, array $names ): array { $installed = self::installed( $vendor_dir ); $replacers = array(); - $provided = array(); foreach ( $installed as $name => $package ) { foreach ( \array_keys( (array) ( $package['replace'] ?? array() ) ) as $replaced ) { $replacers[ (string) $replaced ][] = $name; } - foreach ( \array_keys( (array) ( $package['provide'] ?? array() ) ) as $virtual ) { - $provided[ (string) $virtual ] = true; - } + } + // Composer records every name a package or the root replaces or provides, so a requirement no installed package fills is met when it is recorded there. + $versions_file = $vendor_dir . '/composer/installed.php'; + $versions = \is_file( $versions_file ) ? ( static fn (): mixed => require $versions_file )() : null; + if ( ! \is_array( $versions ) || ! \is_array( $versions['versions'] ?? null ) ) { + throw new \RuntimeException( "Could not read '$versions_file'. Install the project's dependencies first." ); } $included = array(); @@ -68,8 +70,7 @@ public static function resolve( string $vendor_dir, array $names ): array { continue; } $candidates = isset( $installed[ $required ] ) ? array( $required ) : \array_diff( $replacers[ $required ] ?? array(), $dropped ); - // A requirement another package only provides is a virtual package, such as an interface implementation the project picks itself. - if ( array() === $candidates && ! isset( $provided[ $required ] ) && ! isset( $replacers[ $required ] ) ) { + if ( array() === $candidates && ! isset( $versions['versions'][ $required ] ) ) { throw new \RuntimeException( "'$name' requires '$required', which is not installed in '$vendor_dir'." ); } \array_push( $queue, ...$candidates ); From c337d5fb2e37c3f2fa7b0fabb805bbdb97ef9e41 Mon Sep 17 00:00:00 2001 From: Tony Hegyes Date: Fri, 6 Nov 2026 14:42:50 +0100 Subject: [PATCH 04/29] Keep the previous build when php-scoper fails while scoping php-scoper empties its output directory, and deletes it again when a patcher throws, such as the text-domain check or a recipe whose search string is gone. vendor-prefixed/ lost its autoloader with it, so the project's autoloader fataled in PHPUnit, PHPStan and the development site until a run succeeded. The run now scopes into vendor-prefixed.tmp/ and replaces the previous build only once php-scoper succeeds. The move also absorbs the vendor directory php-scoper drops for a single-vendor closure. Port audit A149 (X ruling 184). Assisted-by: Claude Code:claude-opus-5-5 --- README.md | 2 +- php/composer/ScopePhpDependencies.php | 25 +++++++++++++------------ 2 files changed, 14 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index ba193a8..c217d9e 100644 --- a/README.md +++ b/README.md @@ -174,7 +174,7 @@ A name is a package name or an `fnmatch()` pattern, such as `'your-vendor/your-l A scope run: -- copies every file of each scoped package to `vendor-prefixed///`, after emptying `vendor-prefixed/` and refusing one that is a symbolic link; +- scopes every file of each scoped package into `vendor-prefixed.tmp/` and, once php-scoper succeeds, moves it to `vendor-prefixed///` in place of the previous build, refusing a `vendor-prefixed/` that is a symbolic link, so a failed run keeps the previous build; - leaves global the WordPress symbols of `sniccowp/php-scoper-wordpress-excludes`, Action Scheduler (`as_*` and `ActionScheduler*`), every symbol of the stubs files the project or a scoped package declares under `extra.scoping-stubs`, and their namespaces; - prefixes the strings that start with a scoped package's own namespace, which libraries use to build class names; - applies the recipe of each scoped package that has one in `php/php-scoper/recipes//.inc.php`; diff --git a/php/composer/ScopePhpDependencies.php b/php/composer/ScopePhpDependencies.php index 257a674..939fcfd 100644 --- a/php/composer/ScopePhpDependencies.php +++ b/php/composer/ScopePhpDependencies.php @@ -54,7 +54,8 @@ public static function scope( string $project_dir ): void { throw new \RuntimeException( "'$project_dir/composer.json' declares no extra.scoping-prefix." ); } - $output_dir = $project_dir . '/vendor-prefixed'; + $output_dir = $project_dir . '/vendor-prefixed'; + $scoping_dir = $output_dir . '.tmp'; if ( \is_link( $output_dir ) ) { throw new \RuntimeException( "Refusing to replace '$output_dir', which is a symbolic link." ); } @@ -68,7 +69,7 @@ public static function scope( string $project_dir ): void { } // Loading the config parses the declared stubs files, and large ones exceed PHP's default memory limit. - $command = array( \PHP_BINARY, '-d', 'memory_limit=1G', ( InstalledVersions::getInstallPath( 'humbug/php-scoper' ) ?? '' ) . '/bin/php-scoper', 'add-prefix', '--working-dir=' . $project_dir, '--config=' . $project_dir . '/scoper.inc.php', '--output-dir=' . $output_dir, '--force', '--no-interaction' ); + $command = array( \PHP_BINARY, '-d', 'memory_limit=1G', ( InstalledVersions::getInstallPath( 'humbug/php-scoper' ) ?? '' ) . '/bin/php-scoper', 'add-prefix', '--working-dir=' . $project_dir, '--config=' . $project_dir . '/scoper.inc.php', '--output-dir=' . $scoping_dir, '--force', '--no-interaction' ); // php-scoper's console wraps what it prints at the terminal width, which would split the paths and symbols in a failure message. $process = \proc_open( \implode( ' ', \array_map( 'escapeshellarg', $command ) ) . ' 2>&1', array( 1 => array( 'pipe', 'w' ) ), $pipes, null, array( 'COLUMNS' => '4096' ) + \getenv() ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.system_calls_proc_open -- Runs in Composer, never on a WordPress server. if ( false === $process ) { @@ -80,20 +81,20 @@ public static function scope( string $project_dir ): void { } // php-scoper writes every file below the deepest directory its inputs share, which drops the vendor directory of a single-vendor closure. - $manifests = \glob( $output_dir . '/*/composer.json' ); + $manifests = \glob( $scoping_dir . '/*/composer.json' ); if ( false === $manifests ) { - throw new \RuntimeException( "Could not list '$output_dir'." ); + throw new \RuntimeException( "Could not list '$scoping_dir'." ); } $dropped = match ( true ) { - \is_file( $output_dir . '/composer.json' ) => self::package_name( $output_dir . '/composer.json' ), - array() !== $manifests => \dirname( self::package_name( \current( $manifests ) ) ), - default => '', + \is_file( $scoping_dir . '/composer.json' ) => self::package_name( $scoping_dir . '/composer.json' ), + array() !== $manifests => \dirname( self::package_name( \current( $manifests ) ) ), + default => '', }; - if ( '' !== $dropped ) { - $filesystem->rename( $output_dir, $output_dir . '.nesting' ); - $filesystem->mkdir( \dirname( $output_dir . '/' . $dropped ) ); - $filesystem->rename( $output_dir . '.nesting', $output_dir . '/' . $dropped ); - } + + // The build replaces the previous one only once php-scoper succeeds, so a failed run keeps the project loading. + $filesystem->remove( $output_dir ); + $filesystem->mkdir( \dirname( $output_dir . '/' . $dropped ) ); + $filesystem->rename( $scoping_dir, \rtrim( $output_dir . '/' . $dropped, '/' ) ); // The autoloader goes first, so a project whose scoped code fails the check still loads. $filesystem->dumpFile( $output_dir . '/scoper-autoload.php', self::autoload( $output_dir ) ); From 7ab03c7c687b4fb147ad1d35e351717fa4a4b803 Mon Sep 17 00:00:00 2001 From: Tony Hegyes Date: Fri, 6 Nov 2026 14:42:50 +0100 Subject: [PATCH 05/29] Check each scoped name against the kind of symbol it names The declared-symbol check kept every declaration in one lowercase map and accepted a reference that matched any declaration or namespace. A function satisfied a missing class, a namespace satisfied new, and a constant written in another case passed, although PHP compares constant names by case. A Class::MEMBER string was never checked, though php-scoper prefixes its class. A nowdoc that opens with a blank line went unread although php-scoper scopes it, and a nowdoc template that does not parse, which php-scoper skips, crashed the check, as did an unparsable file, without naming it. The collector now keys declarations by kind, a constant by its exact name below a lowercase namespace, and gives each reference the kinds of symbol it can name where it stands: a function call a function, a constant fetch a constant, an import a class or a namespace, a group import's prefix a namespace, a string any of them, a Class::MEMBER string its class, and any other name a class. It reads a nowdoc as php-scoper does, after trimming it, and skips one that does not parse, and the run names a scoped file it cannot parse. define() and class_alias() recognition has one home on StubSymbolCollector, so a stubs file's uppercase DEFINE() and an alias named by Foo::class stay global too, and one SYMBOL_NAME_REGEX serves both visitors. Port audit A148, A151, A154 (X ruling 184) and A182 (X ruling 191). Assisted-by: Claude Code:claude-opus-5-5 --- README.md | 4 +- .../Internal/ScopedSymbolCollector.php | 103 +++++++++++++----- php/composer/Internal/StubSymbolCollector.php | 79 ++++++++------ php/composer/ScopePhpDependencies.php | 27 +++-- 4 files changed, 138 insertions(+), 75 deletions(-) diff --git a/README.md b/README.md index c217d9e..6d4808b 100644 --- a/README.md +++ b/README.md @@ -179,11 +179,11 @@ A scope run: - prefixes the strings that start with a scoped package's own namespace, which libraries use to build class names; - applies the recipe of each scoped package that has one in `php/php-scoper/recipes//.inc.php`; - rewrites the text domain a scoped package declares under `extra.scoping-text-domain` to the project's `extra.text-domain`; -- fails, naming each one, when the scoped code references a prefixed name it does not declare: a host symbol no stubs file declares, a dependency outside the scoped packages, or a string php-scoper took for a class name; +- fails, naming each one, when the scoped code references a prefixed name it does not declare as the kind of symbol the reference needs: a host symbol no stubs file declares, a dependency outside the scoped packages, a string php-scoper took for a class name, or the class of a `Class::MEMBER` string; - passes a `function_exists()` check of the prefixed name of a function it leaves global, PHP's own included, which php-scoper adds to every guard of that function and which is false while nothing declares the name; - writes `vendor-prefixed/scoper-autoload.php`, a class map over each scoped package's `psr-4`, `psr-0` and `classmap` paths that honors `exclude-from-classmap`, followed by its `files` entries. -The run also fails when `extra.scoping-prefix` is missing or empty, when a name matches no installed package, when a scoped package requires one that is not installed, when a declared stubs file is missing, when a scoped package declares a text domain and the project declares none, when a declared text domain survives outside a string literal of its own, and when a recipe's target is gone. +The run also fails when `extra.scoping-prefix` is missing or empty, when a name matches no installed package, when a scoped package requires one that is not installed, when a declared stubs file is missing, when a scoped file does not parse, when a scoped package declares a text domain and the project declares none, when a declared text domain survives outside a string literal of its own, and when a recipe's target is gone. The project or a scoped package declares the stubs files of the host symbols the scoped code calls, each as `./path/to/file.php` relative to its `composer.json`, as `vendor/package` for that package's `.php`, or as `vendor/package:path/to/file.php`: diff --git a/php/composer/Internal/ScopedSymbolCollector.php b/php/composer/Internal/ScopedSymbolCollector.php index 0e994fb..dc13668 100644 --- a/php/composer/Internal/ScopedSymbolCollector.php +++ b/php/composer/Internal/ScopedSymbolCollector.php @@ -2,6 +2,7 @@ namespace DeepWebSolutions\Config\Composer\Internal; +use PhpParser\Error; use PhpParser\Node; use PhpParser\NodeTraverser; use PhpParser\NodeVisitor\NameResolver; @@ -20,16 +21,20 @@ final class ScopedSymbolCollector extends NodeVisitorAbstract { // region FIELDS AND CONSTANTS /** - * The lowercase names of the declared classes, interfaces, traits, enums, functions and constants, keyed by themselves. + * The names of the declared classes, interfaces, traits and enums, functions and constants, by kind, each keyed by its lookup key. * - * @var array + * @var array{class: array, function: array, constant: array} */ - public array $declared = array(); + public array $declared = array( + 'class' => array(), + 'function' => array(), + 'constant' => array(), + ); /** - * The prefixed names the code references, each with its location and whether a function_exists() call checks it. + * The prefixed names the code references, each with its location, its lookup key for each kind of symbol it can name, and whether a function_exists() call checks it. * - * @var list + * @var list, checked: bool}> */ public array $references = array(); @@ -64,17 +69,29 @@ public function __construct( */ #[\Override] public function enterNode( Node $node ): int|null { - if ( $node instanceof Node\Stmt\ClassLike || $node instanceof Node\Stmt\Function_ ) { - $this->declare( $node->namespacedName ); + $parent = $node->getAttribute( 'parent' ); + if ( $node instanceof Node\Stmt\ClassLike ) { + $this->declare( 'class', $node->namespacedName?->toString() ); + } elseif ( $node instanceof Node\Stmt\Function_ ) { + $this->declare( 'function', $node->namespacedName?->toString() ); } elseif ( $node instanceof Node\Stmt\Const_ ) { foreach ( $node->consts as $const ) { - $this->declare( $const->namespacedName ); + $this->declare( 'constant', $const->namespacedName?->toString() ); } } elseif ( $node instanceof Node\Scalar\String_ ) { $this->string( $node ); - } elseif ( $node instanceof Node\Name && ! $node->getAttribute( 'parent' ) instanceof Node\Stmt\Namespace_ ) { - $use = $node->getAttribute( 'parent' ) instanceof Node\UseItem ? $node->getAttribute( 'parent' )->getAttribute( 'parent' ) : null; - $this->reference( $use instanceof Node\Stmt\GroupUse ? $use->prefix->toString() . '\\' . $node->toString() : $node->toString(), $node ); + } elseif ( $node instanceof Node\Name && ! $parent instanceof Node\Stmt\Namespace_ ) { + $use = $parent instanceof Node\UseItem ? $parent->getAttribute( 'parent' ) : null; + $type = $parent instanceof Node\UseItem && ( $use instanceof Node\Stmt\Use_ || $use instanceof Node\Stmt\GroupUse ) ? ( $parent->type ?: $use->type ) : null; + $kinds = match ( true ) { + $parent instanceof Node\Expr\FuncCall, Node\Stmt\Use_::TYPE_FUNCTION === $type => array( 'function' ), + $parent instanceof Node\Expr\ConstFetch, Node\Stmt\Use_::TYPE_CONSTANT === $type => array( 'constant' ), + $parent instanceof Node\Stmt\GroupUse => array( 'namespace' ), + // An import can name a namespace, which the code then extends. + $parent instanceof Node\UseItem => array( 'class', 'namespace' ), + default => array( 'class' ), + }; + $this->reference( $use instanceof Node\Stmt\GroupUse ? $use->prefix->toString() . '\\' . $node->toString() : $node->toString(), $node, $kinds ); } return null; @@ -86,13 +103,9 @@ public function enterNode( Node $node ): int|null { #[\Override] public function leaveNode( Node $node ): null { // The names in a call's arguments are resolved only once the traversal has visited them. - if ( $node instanceof Node\Expr\FuncCall && $node->name instanceof Node\Name && \in_array( $node->name->toLowerString(), array( 'define', 'class_alias' ), true ) ) { - $argument = $node->args[ 'define' === $node->name->toLowerString() ? 0 : 1 ] ?? null; - if ( $argument instanceof Node\Arg && $argument->value instanceof Node\Scalar\String_ ) { - $this->declare( new Node\Name( \ltrim( $argument->value->value, '\\' ) ) ); - } elseif ( $argument instanceof Node\Arg && $argument->value instanceof Node\Expr\ClassConstFetch && $argument->value->class instanceof Node\Name ) { - $this->declare( $argument->value->class ); - } + $declaration = $node instanceof Node\Expr\FuncCall ? StubSymbolCollector::declared_by_call( $node ) : null; + if ( ! \is_null( $declaration ) ) { + $this->declare( $declaration[0], $declaration[1] ); } return null; @@ -105,49 +118,79 @@ public function leaveNode( Node $node ): null { /** * Records a declared symbol. * - * @param Node\Name|null $name The resolved name of the symbol, or null for an anonymous class. + * @param 'class'|'function'|'constant' $kind The kind of symbol. + * @param string|null $name The name of the symbol, or null for an anonymous class. * * @return void */ - protected function declare( ?Node\Name $name ): void { + protected function declare( string $kind, ?string $name ): void { if ( ! \is_null( $name ) ) { - $this->declared[ $name->toLowerString() ] = $name->toLowerString(); + $this->declared[ $kind ][ self::key( $kind, $name ) ] = $name; } } /** - * Records a string literal shaped like a symbol name, and reads the PHP code of a nowdoc string that opens with a PHP tag, which php-scoper scopes like a file. + * Records a string literal shaped like a symbol name, or the class of one shaped like a class constant, and reads the PHP code of a nowdoc string that opens with a PHP tag, which php-scoper scopes like a file. * * @param Node\Scalar\String_ $node The string literal. * * @return void */ protected function string( Node\Scalar\String_ $node ): void { - if ( Node\Scalar\String_::KIND_NOWDOC === $node->getAttribute( 'kind' ) && \str_starts_with( $node->value, 'traverse( $this->parser->parse( $node->value ) ?? array() ); - } elseif ( 1 === \preg_match( '/^\\\\?([A-Za-z_\x80-\xff][\w\x80-\xff\\\\]*)$/', $node->value, $match ) ) { - $this->reference( $match[1], $node ); + $code = \ltrim( $node->value ); + if ( Node\Scalar\String_::KIND_NOWDOC === $node->getAttribute( 'kind' ) && \str_starts_with( $code, 'parser->parse( $code ) ?? array(); + } catch ( Error ) { + return; // php-scoper leaves a nowdoc that does not parse as PHP unscoped. + } + new NodeTraverser( new NameResolver(), new ParentConnectingVisitor(), $this )->traverse( $statements ); + } else { + $parts = \explode( '::', $node->value, 2 ); + if ( 1 === \preg_match( StubSymbolCollector::SYMBOL_NAME_REGEX, $parts[0] ) ) { + $this->reference( \ltrim( $parts[0], '\\' ), $node, isset( $parts[1] ) ? array( 'class' ) : array( 'class', 'function', 'constant', 'namespace' ) ); + } } } /** - * Records a name that carries the prefix, with its location and whether a function_exists() call checks it. + * Records a name that carries the prefix, with its location, its lookup key for each kind of symbol it can name, and whether a function_exists() call checks it. * - * @param string $name The name. - * @param Node $node The node that references it. + * @param string $name The name. + * @param Node $node The node that references it. + * @param non-empty-list<'class'|'function'|'constant'|'namespace'> $kinds The kinds of symbol the name can stand for where it appears. * * @return void */ - protected function reference( string $name, Node $node ): void { + protected function reference( string $name, Node $node, array $kinds ): void { if ( 0 === \stripos( $name, $this->prefix . '\\' ) ) { $call = $node->getAttribute( 'parent' ) instanceof Node\Arg ? $node->getAttribute( 'parent' )->getAttribute( 'parent' ) : null; $this->references[] = array( 'name' => $name, 'location' => $this->file . ':' . $node->getStartLine(), + 'keys' => \array_combine( $kinds, \array_map( static fn ( string $kind ): string => self::key( $kind, $name ), $kinds ) ), 'checked' => $call instanceof Node\Expr\FuncCall && $call->name instanceof Node\Name && 'function_exists' === $call->name->toString(), ); } } + /** + * Returns the key a symbol of a kind is looked up by: its lowercase name, but a constant keeps the case of its name below a lowercase namespace, as PHP compares them. + * + * @param string $kind The kind of symbol: class, function, constant or namespace. + * @param string $name The name of the symbol. + * + * @return string + */ + protected static function key( string $kind, string $name ): string { + $name = \trim( $name, '\\' ); + $separator = \strrpos( $name, '\\' ); + if ( 'constant' !== $kind ) { + return \strtolower( $name ); + } + + return false === $separator ? $name : \strtolower( \substr( $name, 0, $separator ) ) . \substr( $name, $separator ); + } + // endregion } diff --git a/php/composer/Internal/StubSymbolCollector.php b/php/composer/Internal/StubSymbolCollector.php index 3f1d83e..420fe6c 100644 --- a/php/composer/Internal/StubSymbolCollector.php +++ b/php/composer/Internal/StubSymbolCollector.php @@ -21,7 +21,7 @@ final class StubSymbolCollector extends NodeVisitorAbstract { * * @var string */ - protected const SYMBOL_NAME_REGEX = '/^\\\\?[A-Za-z_\x80-\xff][A-Za-z0-9_\x80-\xff\\\\]*$/'; + public const SYMBOL_NAME_REGEX = '/^\\\\?[A-Za-z_\x80-\xff][A-Za-z0-9_\x80-\xff\\\\]*$/'; /** * The class names the stubs declare, including the aliases class_alias() calls declare. @@ -74,23 +74,57 @@ public function enterNode( Node $node ): int|null { } } break; - case Node\Expr\FuncCall::class: - if ( ! $node->name instanceof Node\Name ) { - break; - } - // A define() name and a class_alias() alias are real symbols, so scoped code that references them must keep them unprefixed. - $function = $node->name->toString(); - if ( 'define' === $function ) { - $this->collect_string_argument( $this->constants, $node, 0 ); - } elseif ( 'class_alias' === $function ) { - $this->collect_string_argument( $this->classes, $node, 1 ); - } - break; } return null; } + /** + * {@inheritDoc} + */ + #[\Override] + public function leaveNode( Node $node ): null { + // A define() name and a class_alias() alias are real symbols, so scoped code that references them must keep them unprefixed. + $declaration = $node instanceof Node\Expr\FuncCall ? self::declared_by_call( $node ) : null; + if ( 'class' === ( $declaration[0] ?? null ) ) { + $this->collect( $this->classes, $declaration[1] ); + } elseif ( 'constant' === ( $declaration[0] ?? null ) ) { + $this->collect( $this->constants, $declaration[1] ); + } + + return null; + } + + // endregion + + // region METHODS + + /** + * Returns the kind and name of the symbol a define() or class_alias() call declares, or null when the call declares none by a string or a Foo::class name. + * + * The call's arguments must have been visited, since a NameResolver resolves a Foo::class name only when the traversal reaches it. + * + * @param Node\Expr\FuncCall $node The function call. + * + * @return array{'class'|'constant', string}|null + */ + public static function declared_by_call( Node\Expr\FuncCall $node ): ?array { + $function = $node->name instanceof Node\Name ? $node->name->toLowerString() : ''; + $argument = match ( $function ) { + 'define' => $node->args[0] ?? null, + 'class_alias' => $node->args[1] ?? null, + default => null, + }; + $value = $argument instanceof Node\Arg ? $argument->value : null; + $name = match ( true ) { + $value instanceof Node\Scalar\String_ => \ltrim( $value->value, '\\' ), + $value instanceof Node\Expr\ClassConstFetch && $value->class instanceof Node\Name && $value->name instanceof Node\Identifier && 'class' === $value->name->toLowerString() => $value->class->toString(), + default => null, + }; + + return \is_null( $name ) ? null : array( 'define' === $function ? 'constant' : 'class', $name ); + } + // endregion // region HELPERS @@ -109,24 +143,5 @@ protected function collect( array &$symbols, string $symbol ): void { } } - /** - * Adds the string literal a function call passes at a position, when there is one. - * - * @param list $symbols The symbol list to append to. - * @param Node\Expr\FuncCall $node The function call. - * @param int $index The zero-based argument position. - * - * @return void - */ - protected function collect_string_argument( array &$symbols, Node\Expr\FuncCall $node, int $index ): void { - if ( - isset( $node->args[ $index ] ) - && $node->args[ $index ] instanceof Node\Arg - && $node->args[ $index ]->value instanceof Node\Scalar\String_ - ) { - $this->collect( $symbols, $node->args[ $index ]->value->value ); - } - } - // endregion } diff --git a/php/composer/ScopePhpDependencies.php b/php/composer/ScopePhpDependencies.php index 939fcfd..1ffb9c5 100644 --- a/php/composer/ScopePhpDependencies.php +++ b/php/composer/ScopePhpDependencies.php @@ -9,6 +9,7 @@ use DeepWebSolutions\Config\Composer\Internal\ScopedSymbolCollector; use Humbug\PhpScoper\Configuration\RegexChecker; use JetBrains\PHPStormStub\PhpStormStubsMap; +use PhpParser\Error; use PhpParser\NodeTraverser; use PhpParser\NodeVisitor\NameResolver; use PhpParser\NodeVisitor\ParentConnectingVisitor; @@ -42,7 +43,7 @@ public static function postAutoloadDump( Event $event ): void { * * @param string $project_dir The root directory of the project. * - * @throws \RuntimeException Thrown when the project, its scoping prefix or php-scoper is missing, vendor-prefixed is a symbolic link, php-scoper fails, a prefixed name is declared by no scoped package, or a scoped package manifest cannot be read. + * @throws \RuntimeException Thrown when the project, its scoping prefix or php-scoper is missing, vendor-prefixed is a symbolic link, php-scoper fails, a scoped file does not parse, a prefixed name is declared by no scoped package, or a scoped package manifest cannot be read. * * @return void */ @@ -119,7 +120,7 @@ public static function scope( string $project_dir ): void { * @param string $prefix The scoping prefix. * @param list $global_functions The names and patterns of the functions the run leaves global. * - * @throws \RuntimeException Thrown when a prefixed name is declared by no scoped package. + * @throws \RuntimeException Thrown when a scoped file does not parse, or a prefixed name is declared by no scoped package. * * @return void */ @@ -128,15 +129,20 @@ protected static function verify( string $output_dir, string $prefix, array $glo $collector = new ScopedSymbolCollector( $prefix, $parser ); foreach ( Finder::create()->files()->in( $output_dir )->name( '*.php' )->sortByName() as $file ) { $collector->file = $file->getPathname(); - new NodeTraverser( new NameResolver(), new ParentConnectingVisitor(), $collector )->traverse( $parser->parse( $file->getContents() ) ?? array() ); + try { + $statements = $parser->parse( $file->getContents() ) ?? array(); + } catch ( Error $error ) { + throw new \RuntimeException( "Could not parse the scoped file '" . $file->getPathname() . "': " . $error->getMessage(), 0, $error ); + } + new NodeTraverser( new NameResolver(), new ParentConnectingVisitor(), $collector )->traverse( $statements ); } $namespaces = array(); - foreach ( $collector->declared as $name ) { + foreach ( \array_merge( ...\array_values( $collector->declared ) ) as $name ) { $namespace = ''; foreach ( \array_slice( \explode( '\\', $name ), 0, -1 ) as $segment ) { - $namespace = \ltrim( "$namespace\\$segment", '\\' ); - $namespaces[ $namespace ] = $namespace; + $namespace = \ltrim( "$namespace\\$segment", '\\' ); + $namespaces[ \strtolower( $namespace ) ] = $namespace; } } @@ -154,11 +160,10 @@ protected static function verify( string $output_dir, string $prefix, array $glo $undeclared = array(); foreach ( $collector->references as $reference ) { - $reference_name = \rtrim( $reference['name'], '\\' ); - $name = \strtolower( $reference_name ); - $function = \ltrim( \substr( $reference_name, \strlen( $prefix ) + 1 ), '\\' ); - $global = $reference['checked'] && ( isset( $names[ \strtolower( $function ) ] ) || \array_any( $patterns, static fn ( string $pattern ): bool => 1 === \preg_match( $pattern, $function ) ) ); - if ( ! $global && ! isset( $collector->declared[ $name ] ) && ! isset( $namespaces[ $name ] ) ) { + $function = \ltrim( \substr( \rtrim( $reference['name'], '\\' ), \strlen( $prefix ) + 1 ), '\\' ); + $global = $reference['checked'] && ( isset( $names[ \strtolower( $function ) ] ) || \array_any( $patterns, static fn ( string $pattern ): bool => 1 === \preg_match( $pattern, $function ) ) ); + $declared = \array_any( $reference['keys'], static fn ( string $key, string $kind ): bool => 'namespace' === $kind ? isset( $namespaces[ $key ] ) : isset( $collector->declared[ $kind ][ $key ] ) ); + if ( ! $global && ! $declared ) { $undeclared[] = $reference['name'] . ' in ' . $reference['location']; } } From 7f7182aef1dd015f59978c7b395dbc0987c172d1 Mon Sep 17 00:00:00 2001 From: Tony Hegyes Date: Fri, 6 Nov 2026 14:42:50 +0100 Subject: [PATCH 06/29] Fail on a class the scoped code declares outside the prefix php-scoper leaves a declaration global when its namespace is excluded, and CollectScopingStubs excludes the namespace of every host symbol. A scoped package that bundles its own copy of a library WordPress ships, such as PHPMailer or php-ai-client, therefore kept that copy unprefixed and registered it in the class map with no warning, so at runtime the host's copy and the plugin's replaced each other. The check looked only at prefixed references. verify() now fails when the scoped output declares a class, interface, trait or enum outside the prefix, naming its namespace, but for the polyfill of a PHP class, which php-scoper aliases to its global name. No consumer's scoped output in the workspace declares one. Port audit A147 (X ruling 184). Assisted-by: Claude Code:claude-opus-5-5 --- README.md | 1 + php/composer/ScopePhpDependencies.php | 17 +++++++++++++++-- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 6d4808b..67cb70c 100644 --- a/README.md +++ b/README.md @@ -180,6 +180,7 @@ A scope run: - applies the recipe of each scoped package that has one in `php/php-scoper/recipes//.inc.php`; - rewrites the text domain a scoped package declares under `extra.scoping-text-domain` to the project's `extra.text-domain`; - fails, naming each one, when the scoped code references a prefixed name it does not declare as the kind of symbol the reference needs: a host symbol no stubs file declares, a dependency outside the scoped packages, a string php-scoper took for a class name, or the class of a `Class::MEMBER` string; +- fails, naming its namespace, when the scoped code declares a class, interface, trait or enum outside the prefix, which a copy the host or another plugin loads can replace, other than the polyfill of a PHP class; - passes a `function_exists()` check of the prefixed name of a function it leaves global, PHP's own included, which php-scoper adds to every guard of that function and which is false while nothing declares the name; - writes `vendor-prefixed/scoper-autoload.php`, a class map over each scoped package's `psr-4`, `psr-0` and `classmap` paths that honors `exclude-from-classmap`, followed by its `files` entries. diff --git a/php/composer/ScopePhpDependencies.php b/php/composer/ScopePhpDependencies.php index 1ffb9c5..80c41c1 100644 --- a/php/composer/ScopePhpDependencies.php +++ b/php/composer/ScopePhpDependencies.php @@ -43,7 +43,7 @@ public static function postAutoloadDump( Event $event ): void { * * @param string $project_dir The root directory of the project. * - * @throws \RuntimeException Thrown when the project, its scoping prefix or php-scoper is missing, vendor-prefixed is a symbolic link, php-scoper fails, a scoped file does not parse, a prefixed name is declared by no scoped package, or a scoped package manifest cannot be read. + * @throws \RuntimeException Thrown when the project, its scoping prefix or php-scoper is missing, vendor-prefixed is a symbolic link, php-scoper fails, a scoped file does not parse, a scoped package declares a class outside the prefix, a prefixed name is declared by no scoped package, or a scoped package manifest cannot be read. * * @return void */ @@ -120,7 +120,7 @@ public static function scope( string $project_dir ): void { * @param string $prefix The scoping prefix. * @param list $global_functions The names and patterns of the functions the run leaves global. * - * @throws \RuntimeException Thrown when a scoped file does not parse, or a prefixed name is declared by no scoped package. + * @throws \RuntimeException Thrown when a scoped file does not parse, a scoped package declares a class outside the prefix, or a prefixed name is declared by no scoped package. * * @return void */ @@ -137,6 +137,19 @@ protected static function verify( string $output_dir, string $prefix, array $glo new NodeTraverser( new NameResolver(), new ParentConnectingVisitor(), $collector )->traverse( $statements ); } + // A class outside the prefix collides with the copy the host or another plugin loads, but for the polyfill of a PHP class, which php-scoper aliases to its global name. + $php_classes = \array_change_key_case( PhpStormStubsMap::CLASSES ); + $outside = array(); + foreach ( $collector->declared['class'] as $key => $class ) { + if ( 0 !== \stripos( $class, $prefix . '\\' ) && ! isset( $php_classes[ $key ] ) ) { + $separator = \strrpos( $class, '\\' ); + $outside[] = false === $separator ? 'the global namespace' : \substr( $class, 0, $separator ); + } + } + if ( array() !== $outside ) { + throw new \RuntimeException( "The scoped packages declare classes in these namespaces outside the prefix, where the copy the host or another plugin loads can replace them. Drop the package that declares one with !vendor/package, or stop excluding the namespace in scoper.inc.php:\n" . \implode( "\n", \array_unique( $outside ) ) ); + } + $namespaces = array(); foreach ( \array_merge( ...\array_values( $collector->declared ) ) as $name ) { $namespace = ''; From 99044dcc51b0db59f2d8f20bcb84bcdd16b29833 Mon Sep 17 00:00:00 2001 From: Tony Hegyes Date: Fri, 6 Nov 2026 14:42:50 +0100 Subject: [PATCH 07/29] Rewrite only text-domain literals, and keep a domain the project shares The text-domain patcher replaced every quoted occurrence of a package's domain across the file, so a domain quoted inside a string of the other quote style turned into invalid PHP, which then failed the declared-symbol parse instead of the residual-domain check meant to report it. A package whose domain equals the project's always failed, since the patcher replaced the domain with itself and then found it still there. The patcher now rewrites only the string-literal tokens that are exactly the domain, keeps the residual check for every other occurrence, and leaves a domain the project shares alone. Port audit A150, A152 (X ruling 184). Assisted-by: Claude Code:claude-opus-5-5 --- README.md | 2 +- php/php-scoper/scoper-base.inc.php | 10 +++++++--- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 67cb70c..2ee62db 100644 --- a/README.md +++ b/README.md @@ -178,7 +178,7 @@ A scope run: - leaves global the WordPress symbols of `sniccowp/php-scoper-wordpress-excludes`, Action Scheduler (`as_*` and `ActionScheduler*`), every symbol of the stubs files the project or a scoped package declares under `extra.scoping-stubs`, and their namespaces; - prefixes the strings that start with a scoped package's own namespace, which libraries use to build class names; - applies the recipe of each scoped package that has one in `php/php-scoper/recipes//.inc.php`; -- rewrites the text domain a scoped package declares under `extra.scoping-text-domain` to the project's `extra.text-domain`; +- rewrites each string literal of the text domain a scoped package declares under `extra.scoping-text-domain` to the project's `extra.text-domain`, unless the two are the same; - fails, naming each one, when the scoped code references a prefixed name it does not declare as the kind of symbol the reference needs: a host symbol no stubs file declares, a dependency outside the scoped packages, a string php-scoper took for a class name, or the class of a `Class::MEMBER` string; - fails, naming its namespace, when the scoped code declares a class, interface, trait or enum outside the prefix, which a copy the host or another plugin loads can replace, other than the polyfill of a PHP class; - passes a `function_exists()` check of the prefixed name of a function it leaves global, PHP's own included, which php-scoper adds to every guard of that function and which is false while nothing declares the name; diff --git a/php/php-scoper/scoper-base.inc.php b/php/php-scoper/scoper-base.inc.php index 231f456..469c3cc 100644 --- a/php/php-scoper/scoper-base.inc.php +++ b/php/php-scoper/scoper-base.inc.php @@ -103,6 +103,7 @@ if ( array() !== $domains && ( ! is_string( $text_domain ) || '' === $text_domain ) ) { throw new RuntimeException( "'$project_dir/composer.json' declares no extra.text-domain, which the strings of the scoped packages take." ); } + $domains = array_filter( $domains, static fn ( string $domain ): bool => $text_domain !== $domain ); // Libraries build class names in strings php-scoper cannot see, and a string that starts with a scoped package's own namespace names one of its classes. if ( array() !== $roots ) { @@ -139,12 +140,15 @@ if ( ! str_starts_with( $file_path, "$dir/" ) || ! str_ends_with( $file_path, '.php' ) ) { continue; } - $contents = str_replace( array( "'$domain'", "\"$domain\"" ), var_export( $text_domain, true ), $contents ); - foreach ( PhpToken::tokenize( $contents ) as $token ) { - if ( ! $token->is( array( T_COMMENT, T_DOC_COMMENT, T_INLINE_HTML ) ) && 1 === preg_match( '/(?text ) ) { + $tokens = PhpToken::tokenize( $contents ); + foreach ( $tokens as $token ) { + if ( $token->is( T_CONSTANT_ENCAPSED_STRING ) && in_array( $token->text, array( "'$domain'", "\"$domain\"" ), true ) ) { + $token->text = var_export( $text_domain, true ); + } elseif ( ! $token->is( array( T_COMMENT, T_DOC_COMMENT, T_INLINE_HTML ) ) && 1 === preg_match( '/(?text ) ) { throw new RuntimeException( "'$file_path' still contains the text domain '$domain' after scoping, outside a string literal of its own." ); } } + $contents = implode( '', array_column( $tokens, 'text' ) ); } return $contents; From 2c82f037662d1e48737422a17dc091d1df29e57b Mon Sep 17 00:00:00 2001 From: Tony Hegyes Date: Fri, 6 Nov 2026 14:42:51 +0100 Subject: [PATCH 08/29] Keep host namespaces global in a scoped package's namespace strings The namespace-string patcher prefixed every string that starts with a scoped package's PSR-4 root, even one naming a host class php-scoper keeps global, as when a library and its host share a namespace root. Declaring the host's stubs then still failed the run on an undeclared prefixed name. The patcher now leaves a string alone when the name it holds, or that name's namespace, matches a namespace the run excludes, which covers every namespaced symbol the stubs and the dropped packages declare. The run passes php-scoper the same merged list. Token reconstruction reads the texts with array_column(). The A153 row expected the string printed with doubled backslashes, where php-scoper prints single ones. Port audit A153 (X ruling 184), A192 (X ruling 192). Assisted-by: Claude Code:claude-opus-5-5 --- README.md | 2 +- php/php-scoper/scoper-base.inc.php | 25 ++++++++++++++++--------- tests/Unit/ScopePhpDependenciesTest.php | 2 +- 3 files changed, 18 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 2ee62db..cd77423 100644 --- a/README.md +++ b/README.md @@ -176,7 +176,7 @@ A scope run: - scopes every file of each scoped package into `vendor-prefixed.tmp/` and, once php-scoper succeeds, moves it to `vendor-prefixed///` in place of the previous build, refusing a `vendor-prefixed/` that is a symbolic link, so a failed run keeps the previous build; - leaves global the WordPress symbols of `sniccowp/php-scoper-wordpress-excludes`, Action Scheduler (`as_*` and `ActionScheduler*`), every symbol of the stubs files the project or a scoped package declares under `extra.scoping-stubs`, and their namespaces; -- prefixes the strings that start with a scoped package's own namespace, which libraries use to build class names; +- prefixes the strings that start with a scoped package's own namespace, which libraries use to build class names, unless they name a namespace the run leaves global or a symbol in one; - applies the recipe of each scoped package that has one in `php/php-scoper/recipes//.inc.php`; - rewrites each string literal of the text domain a scoped package declares under `extra.scoping-text-domain` to the project's `extra.text-domain`, unless the two are the same; - fails, naming each one, when the scoped code references a prefixed name it does not declare as the kind of symbol the reference needs: a host symbol no stubs file declares, a dependency outside the scoped packages, a string php-scoper took for a class name, or the class of a `Class::MEMBER` string; diff --git a/php/php-scoper/scoper-base.inc.php b/php/php-scoper/scoper-base.inc.php index 469c3cc..942f906 100644 --- a/php/php-scoper/scoper-base.inc.php +++ b/php/php-scoper/scoper-base.inc.php @@ -103,13 +103,14 @@ if ( array() !== $domains && ( ! is_string( $text_domain ) || '' === $text_domain ) ) { throw new RuntimeException( "'$project_dir/composer.json' declares no extra.text-domain, which the strings of the scoped packages take." ); } - $domains = array_filter( $domains, static fn ( string $domain ): bool => $text_domain !== $domain ); + $domains = array_filter( $domains, static fn ( string $domain ): bool => $text_domain !== $domain ); + $exclude_namespaces = array( ...$exclude_namespaces, ...$symbols['namespaces'] ); // Libraries build class names in strings php-scoper cannot see, and a string that starts with a scoped package's own namespace names one of its classes. if ( array() !== $roots ) { $separated = static fn ( string $name ): string => implode( '\\\\{1,2}', array_map( static fn ( string $segment ): string => preg_quote( $segment, '/' ), explode( '\\', $name ) ) ); $own = '/^(\\\\{0,2})((?:' . implode( '|', array_map( $separated, array_unique( $roots ) ) ) . ')(\\\\{1,2}))/'; - $patchers[] = static function ( string $file_path, string $prefix, string $contents ) use ( $own, $separated ): string { + $patchers[] = static function ( string $file_path, string $prefix, string $contents ) use ( $own, $separated, $exclude_namespaces ): string { if ( ! str_ends_with( $file_path, '.php' ) ) { return $contents; } @@ -122,15 +123,21 @@ } $opening = $token->is( T_CONSTANT_ENCAPSED_STRING ) ? strcspn( $token->text, '\'"' ) + 1 : 0; $body = substr( $token->text, $opening ); - if ( 1 !== preg_match( $scoped, $body ) && 1 === preg_match( $own, $body, $match ) ) { - // Double-quoted strings and heredocs read escape sequences, so a prefix segment such as Vendor\name takes doubled backslashes there. - $escapes = $token->is( T_CONSTANT_ENCAPSED_STRING ) ? str_contains( substr( $token->text, 0, $opening ), '"' ) : ! str_contains( $tokens[ $index - 1 ]->text, "'" ); - $separator = $escapes ? '\\\\' : $match[3]; - $token->text = substr( $token->text, 0, $opening ) . $match[1] . str_replace( '\\', $separator, $prefix ) . $separator . substr( $body, strlen( $match[1] ) ); + if ( 1 === preg_match( $scoped, $body ) || 1 !== preg_match( $own, $body, $match ) ) { + continue; + } + // A string that names a namespace php-scoper keeps global, or a symbol in one, names the host's symbol. + $named = 1 === preg_match( '/^\\\\{0,2}((?:[\w\x80-\xff]+\\\\{1,2})*[\w\x80-\xff]+)/', $body, $name_match ) ? (string) preg_replace( '/\\\\{2}/', '\\', $name_match[1] ) : ''; + if ( array_any( $exclude_namespaces, static fn ( string $pattern ): bool => 1 === preg_match( $pattern, $named ) || 1 === preg_match( $pattern, substr( $named, 0, (int) strrpos( $named, '\\' ) ) ) ) ) { + continue; } + // Double-quoted strings and heredocs read escape sequences, so a prefix segment such as Vendor\name takes doubled backslashes there. + $escapes = $token->is( T_CONSTANT_ENCAPSED_STRING ) ? str_contains( substr( $token->text, 0, $opening ), '"' ) : ! str_contains( $tokens[ $index - 1 ]->text, "'" ); + $separator = $escapes ? '\\\\' : $match[3]; + $token->text = substr( $token->text, 0, $opening ) . $match[1] . str_replace( '\\', $separator, $prefix ) . $separator . substr( $body, strlen( $match[1] ) ); } - return implode( '', array_map( static fn ( PhpToken $token ): string => $token->text, $tokens ) ); + return implode( '', array_column( $tokens, 'text' ) ); }; } @@ -160,7 +167,7 @@ // A package's tests run against development tools that are never scoped, so they would only ship dead, unresolvable code. 'finders' => array( Finder::create()->files()->in( array_values( $packages['scoped'] ) )->exclude( array( 'tests', 'Tests' ) ) ), 'exclude-files' => $exclude_files, - 'exclude-namespaces' => array( ...$exclude_namespaces, ...$symbols['namespaces'] ), + 'exclude-namespaces' => $exclude_namespaces, 'exclude-classes' => $symbols['classes'], 'exclude-functions' => $symbols['functions'], 'exclude-constants' => $symbols['constants'], diff --git a/tests/Unit/ScopePhpDependenciesTest.php b/tests/Unit/ScopePhpDependenciesTest.php index 05be328..1b122d6 100644 --- a/tests/Unit/ScopePhpDependenciesTest.php +++ b/tests/Unit/ScopePhpDependenciesTest.php @@ -385,7 +385,7 @@ public function test_a_host_class_under_a_scoped_package_s_namespace_stays_globa ScopePhpDependencies::scope( $project ); - self::assertStringContainsString( "\$checkout = 'Acme\\\\Commerce\\\\Gateway\\\\Checkout';", self::read( $project . '/vendor-prefixed' . self::SECTION ) ); + self::assertStringContainsString( "\$checkout = 'Acme\\Commerce\\Gateway\\Checkout';", self::read( $project . '/vendor-prefixed' . self::SECTION ) ); } #[DataProvider( 'provide_symbols_stubs_declare_through_calls' )] From 92229c0a02e6ac7e065652038efb2478fa469aa1 Mon Sep 17 00:00:00 2001 From: Tony Hegyes Date: Fri, 6 Nov 2026 14:42:51 +0100 Subject: [PATCH 09/29] Document that a metapackage loses its files' include order The scoped autoloader orders the packages' files entries by each package's direct require, and ScopedPackages gives a metapackage no directory, so a package that reaches a dependency only through a metapackage includes its files before that dependency's. No consumer depends that way, so the README's scoping section names the limitation, and flattening metapackage requires waits for a consumer that needs it. Port audit A728 (X rulings 284, 303). Assisted-by: Claude Code:claude-opus-5-5 --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index cd77423..a7cf033 100644 --- a/README.md +++ b/README.md @@ -186,6 +186,8 @@ A scope run: The run also fails when `extra.scoping-prefix` is missing or empty, when a name matches no installed package, when a scoped package requires one that is not installed, when a declared stubs file is missing, when a scoped file does not parse, when a scoped package declares a text domain and the project declares none, when a declared text domain survives outside a string literal of its own, and when a recipe's target is gone. +A package that reaches a dependency only through a metapackage includes its `files` entries before that dependency's, because the autoloader orders them by each package's direct `require`. + The project or a scoped package declares the stubs files of the host symbols the scoped code calls, each as `./path/to/file.php` relative to its `composer.json`, as `vendor/package` for that package's `.php`, or as `vendor/package:path/to/file.php`: ```json From dd5d6ef144297678349a3f42bd0d3678ddd392e3 Mon Sep 17 00:00:00 2001 From: Tony Hegyes Date: Fri, 6 Nov 2026 14:47:58 +0100 Subject: [PATCH 10/29] Read manifests and the scoping prefix in one place The scoping pipeline decoded Composer's JSON files four ways, read a manifest's extra entries three ways, and checked the scoping prefix twice, in ScopePhpDependencies, ScopedPackages, CollectScopingStubs and scoper-base.inc.php. The copies drifted: scoper-base's closure turned an unreadable manifest into a JsonException without a path. One internal final class, Manifest, now reads a JSON file, returns a manifest's extra entries and returns the checked scoping prefix, and every caller uses it, so scoper-base drops its $manifest and $extra closures and ScopePhpDependencies its extra() and manifest() helpers. Port audit A181 (X rulings 191, 302). Assisted-by: Claude Code:claude-opus-5-5 --- php/composer/CollectScopingStubs.php | 3 +- php/composer/Manifest.php | 71 +++++++++++++++++++++++++++ php/composer/ScopePhpDependencies.php | 44 ++--------------- php/composer/ScopedPackages.php | 9 ++-- php/php-scoper/scoper-base.inc.php | 23 +++------ 5 files changed, 86 insertions(+), 64 deletions(-) create mode 100644 php/composer/Manifest.php diff --git a/php/composer/CollectScopingStubs.php b/php/composer/CollectScopingStubs.php index 77a1441..8816e63 100644 --- a/php/composer/CollectScopingStubs.php +++ b/php/composer/CollectScopingStubs.php @@ -81,8 +81,7 @@ public static function collect( string $project_dir, array $package_dirs, array * @return list */ protected static function declared_stubs( string $dir ): array { - $manifest = \json_decode( self::read( $dir . '/composer.json' ), true, flags: \JSON_THROW_ON_ERROR ); - $extra = \is_array( $manifest ) && isset( $manifest['extra'] ) && \is_array( $manifest['extra'] ) ? $manifest['extra'] : array(); + $extra = Manifest::extra( Manifest::read( $dir . '/composer.json' ) ); $stubs_files = array(); foreach ( (array) ( $extra['scoping-stubs'] ?? array() ) as $entry ) { diff --git a/php/composer/Manifest.php b/php/composer/Manifest.php new file mode 100644 index 0000000..f48038b --- /dev/null +++ b/php/composer/Manifest.php @@ -0,0 +1,71 @@ + + */ + public static function read( string $file ): array { + $contents = \is_file( $file ) ? \file_get_contents( $file ) : false; + if ( false === $contents ) { + throw new \RuntimeException( "Could not read '$file'." ); + } + $decoded = \json_decode( $contents, true, flags: \JSON_THROW_ON_ERROR ); + return \is_array( $decoded ) ? $decoded : array(); + } + + /** + * Returns the extra entries a decoded manifest declares. + * + * @param array $manifest The decoded manifest. + * + * @return array + */ + public static function extra( array $manifest ): array { + return isset( $manifest['extra'] ) && \is_array( $manifest['extra'] ) ? $manifest['extra'] : array(); + } + + /** + * Returns the scoping prefix a project's manifest declares under extra.scoping-prefix, without leading or trailing backslashes. + * + * @param string $project_dir The root directory of the project. + * + * @throws \RuntimeException Thrown when the manifest cannot be read or declares no scoping prefix. + * + * @return string + */ + public static function scoping_prefix( string $project_dir ): string { + $prefix = self::extra( self::read( $project_dir . '/composer.json' ) )['scoping-prefix'] ?? null; + $prefix = \is_string( $prefix ) ? \trim( $prefix, '\\' ) : ''; + if ( '' === $prefix ) { + throw new \RuntimeException( "'$project_dir/composer.json' declares no extra.scoping-prefix." ); + } + + return $prefix; + } + + // endregion +} diff --git a/php/composer/ScopePhpDependencies.php b/php/composer/ScopePhpDependencies.php index 80c41c1..e3d75f1 100644 --- a/php/composer/ScopePhpDependencies.php +++ b/php/composer/ScopePhpDependencies.php @@ -49,11 +49,7 @@ public static function postAutoloadDump( Event $event ): void { */ public static function scope( string $project_dir ): void { $project_dir = \realpath( $project_dir ) ?: throw new \RuntimeException( "The project directory '$project_dir' does not exist." ); - $prefix = self::extra( $project_dir . '/composer.json' )['scoping-prefix'] ?? null; - $prefix = \is_string( $prefix ) ? \trim( $prefix, '\\' ) : ''; - if ( '' === $prefix ) { - throw new \RuntimeException( "'$project_dir/composer.json' declares no extra.scoping-prefix." ); - } + $prefix = Manifest::scoping_prefix( $project_dir ); $output_dir = $project_dir . '/vendor-prefixed'; $scoping_dir = $output_dir . '.tmp'; @@ -206,7 +202,7 @@ protected static function autoload( string $output_dir ): string { foreach ( $manifests as $manifest ) { $package_dir = \dirname( $manifest ); $relative = \substr( $package_dir, \strlen( $output_dir ) ); - $decoded = self::manifest( $manifest ); + $decoded = Manifest::read( $manifest ); $autoload = isset( $decoded['autoload'] ) && \is_array( $decoded['autoload'] ) ? $decoded['autoload'] : array(); $paths = self::strings( $autoload['classmap'] ?? array() ); @@ -277,21 +273,6 @@ protected static function excluded( string $package_dir, array $paths ): ?string return '#^' . \preg_quote( \strtr( $package_dir, '\\', '/' ), '#' ) . '/(?:' . \implode( '|', $patterns ) . ')(?:$|/)#'; } - /** - * Returns the extra entries a manifest declares. - * - * @param string $file The path of the manifest. - * - * @throws \RuntimeException Thrown when the manifest cannot be read. - * - * @return array - */ - protected static function extra( string $file ): array { - $extra = self::manifest( $file )['extra'] ?? array(); - - return \is_array( $extra ) ? $extra : array(); - } - /** * Returns the name a scoped package manifest declares. * @@ -302,7 +283,7 @@ protected static function extra( string $file ): array { * @return string */ protected static function package_name( string $file ): string { - $name = self::manifest( $file )['name'] ?? null; + $name = Manifest::read( $file )['name'] ?? null; if ( ! \is_string( $name ) || ! \str_contains( $name, '/' ) ) { throw new \RuntimeException( "'$file' declares no package name." ); } @@ -310,25 +291,6 @@ protected static function package_name( string $file ): string { return $name; } - /** - * Returns the decoded contents of a package manifest. - * - * @param string $file The path of the manifest. - * - * @throws \RuntimeException Thrown when the manifest cannot be read. - * - * @return array - */ - protected static function manifest( string $file ): array { - $contents = \file_get_contents( $file ); - if ( false === $contents ) { - throw new \RuntimeException( "Could not read '$file'." ); - } - $decoded = \json_decode( $contents, true, flags: \JSON_THROW_ON_ERROR ); - - return \is_array( $decoded ) ? $decoded : array(); - } - /** * Returns the strings of a value that holds one string or a list of them. * diff --git a/php/composer/ScopedPackages.php b/php/composer/ScopedPackages.php index c8e4484..4e5d089 100644 --- a/php/composer/ScopedPackages.php +++ b/php/composer/ScopedPackages.php @@ -110,13 +110,12 @@ public static function resolve( string $vendor_dir, array $names ): array { * @return array> */ protected static function installed( string $vendor_dir ): array { - $file = $vendor_dir . '/composer/installed.json'; - $contents = \is_file( $file ) ? \file_get_contents( $file ) : false; - if ( false === $contents ) { + $file = $vendor_dir . '/composer/installed.json'; + if ( ! \is_file( $file ) ) { throw new \RuntimeException( "Could not read '$file'. Install the project's dependencies first." ); } - $decoded = \json_decode( $contents, true, flags: \JSON_THROW_ON_ERROR ); - if ( ! \is_array( $decoded ) || ! \is_array( $decoded['packages'] ?? null ) ) { + $decoded = Manifest::read( $file ); + if ( ! \is_array( $decoded['packages'] ?? null ) ) { throw new \RuntimeException( "'$file' lists no packages." ); } diff --git a/php/php-scoper/scoper-base.inc.php b/php/php-scoper/scoper-base.inc.php index 942f906..cb10e99 100644 --- a/php/php-scoper/scoper-base.inc.php +++ b/php/php-scoper/scoper-base.inc.php @@ -6,6 +6,7 @@ */ use DeepWebSolutions\Config\Composer\CollectScopingStubs; +use DeepWebSolutions\Config\Composer\Manifest; use DeepWebSolutions\Config\Composer\ScopedPackages; use Symfony\Component\Finder\Finder; @@ -20,18 +21,8 @@ * @return array */ return static function ( string $project_dir, string ...$names ): array { - $manifest = static function ( string $file ): array { - $decoded = json_decode( (string) file_get_contents( $file ), true, flags: JSON_THROW_ON_ERROR ); - - return is_array( $decoded ) ? $decoded : array(); - }; - $extra = static fn ( array $decoded ): array => isset( $decoded['extra'] ) && is_array( $decoded['extra'] ) ? $decoded['extra'] : array(); - - $project = $manifest( "$project_dir/composer.json" ); - $prefix = $extra( $project )['scoping-prefix'] ?? null; - if ( ! is_string( $prefix ) || '' === trim( $prefix, '\\' ) ) { - throw new RuntimeException( "'$project_dir/composer.json' declares no extra.scoping-prefix." ); - } + $project = Manifest::read( "$project_dir/composer.json" ); + $prefix = Manifest::scoping_prefix( $project_dir ); $vendor = is_array( $project['config'] ?? null ) && is_string( $project['config']['vendor-dir'] ?? null ) ? $project['config']['vendor-dir'] : 'vendor'; $vendor_dir = str_starts_with( $vendor, '/' ) ? $vendor : "$project_dir/$vendor"; @@ -47,8 +38,8 @@ $roots = array(); $domains = array(); foreach ( $packages['scoped'] as $name => $dir ) { - $package = $manifest( "$dir/composer.json" ); - $domain = $extra( $package )['scoping-text-domain'] ?? null; + $package = Manifest::read( "$dir/composer.json" ); + $domain = Manifest::extra( $package )['scoping-text-domain'] ?? null; $autoload = isset( $package['autoload'] ) && is_array( $package['autoload'] ) ? $package['autoload'] : array(); if ( ! is_null( $domain ) ) { $domains[ $dir ] = is_string( $domain ) && '' !== $domain ? $domain : throw new RuntimeException( "'$dir/composer.json' declares an empty extra.scoping-text-domain." ); @@ -99,7 +90,7 @@ } } - $text_domain = $extra( $project )['text-domain'] ?? null; + $text_domain = Manifest::extra( $project )['text-domain'] ?? null; if ( array() !== $domains && ( ! is_string( $text_domain ) || '' === $text_domain ) ) { throw new RuntimeException( "'$project_dir/composer.json' declares no extra.text-domain, which the strings of the scoped packages take." ); } @@ -163,7 +154,7 @@ } return array( - 'prefix' => trim( $prefix, '\\' ), + 'prefix' => $prefix, // A package's tests run against development tools that are never scoped, so they would only ship dead, unresolvable code. 'finders' => array( Finder::create()->files()->in( array_values( $packages['scoped'] ) )->exclude( array( 'tests', 'Tests' ) ) ), 'exclude-files' => $exclude_files, From 41de97b3b682c39e3d683e1a2830a5437d527728 Mon Sep 17 00:00:00 2001 From: Tony Hegyes Date: Fri, 6 Nov 2026 14:52:49 +0100 Subject: [PATCH 11/29] Inline the scoping pipeline's single-caller helpers Seven protected helpers on final classes had one call site each, against the rule that inlines such a helper unless it is a match target, a hook callback, an override or an assertion: ScopePhpDependencies' verify(), autoload() and excluded(), ScopedPackages' installed(), CollectScopingStubs' declared_stubs() and ScopedSymbolCollector's string(). Each body now stands at its call site. scope() runs its steps in order, checking the project, running php-scoper, swapping the build in, writing the autoloader and verifying the scoped code, and nests no deeper than autoload() did on its own. Its inlined autoloader names $package_manifests and $package_name, and its check $scoped_file, so no name changes meaning within the method. phpstan-strict-rules also reports the autoload loops' reused $path, $class and $file, which take $autoload_path, $file_entry, $class_name and $class_file. Each docblock's why stays as a comment over its block. ScopedPackages reads installed.json and the replace links in one loop. Port audit A185 (X rulings 191, 301). Assisted-by: Claude Code:claude-opus-5-5 --- php/composer/CollectScopingStubs.php | 47 ++--- .../Internal/ScopedSymbolCollector.php | 40 ++-- php/composer/ScopePhpDependencies.php | 194 +++++++----------- php/composer/ScopedPackages.php | 48 ++--- 4 files changed, 124 insertions(+), 205 deletions(-) diff --git a/php/composer/CollectScopingStubs.php b/php/composer/CollectScopingStubs.php index 8816e63..23f15b3 100644 --- a/php/composer/CollectScopingStubs.php +++ b/php/composer/CollectScopingStubs.php @@ -34,9 +34,23 @@ public static function collect( string $project_dir, array $package_dirs, array 'constants' => self::read_list( $wordpress . 'constants.json' ), ); + // A manifest declares each stubs file as ./path.php relative to itself, as vendor/package for that package's conventional package.php file, or as vendor/package:path.php. $files = array(); foreach ( array( $project_dir, ...$package_dirs ) as $dir ) { - \array_push( $files, ...self::declared_stubs( $dir ) ); + foreach ( (array) ( Manifest::extra( Manifest::read( $dir . '/composer.json' ) )['scoping-stubs'] ?? array() ) as $entry ) { + $entry = \is_string( $entry ) ? $entry : ''; + if ( \str_starts_with( $entry, './' ) ) { + $path = $dir . \substr( $entry, 1 ); + } else { + $parts = \explode( ':', $entry, 2 ); + $install = InstalledVersions::isInstalled( $parts[0] ) ? InstalledVersions::getInstallPath( $parts[0] ) : null; + $path = \is_null( $install ) ? '' : $install . '/' . ( $parts[1] ?? \basename( $parts[0] ) . '.php' ); + } + if ( ! \is_file( $path ) ) { + throw new \RuntimeException( \sprintf( "'%s/composer.json' declares the scoping stubs %s, which do not exist.", $dir, \var_export( $entry, true ) ) ); + } + $files[] = $path; + } } foreach ( $dropped_dirs as $dir ) { foreach ( Finder::create()->files()->in( $dir )->exclude( array( 'tests', 'Tests' ) )->name( '*.php' ) as $file ) { @@ -71,37 +85,6 @@ public static function collect( string $project_dir, array $package_dirs, array // region HELPERS - /** - * Returns the stubs files a manifest declares: ./path.php relative to the manifest, vendor/package for that package's conventional package.php file, or vendor/package:path.php. - * - * @param string $dir The directory of the manifest. - * - * @throws \RuntimeException Thrown when a declared stubs file is missing or the manifest cannot be read. - * - * @return list - */ - protected static function declared_stubs( string $dir ): array { - $extra = Manifest::extra( Manifest::read( $dir . '/composer.json' ) ); - - $stubs_files = array(); - foreach ( (array) ( $extra['scoping-stubs'] ?? array() ) as $entry ) { - $entry = \is_string( $entry ) ? $entry : ''; - if ( \str_starts_with( $entry, './' ) ) { - $path = $dir . \substr( $entry, 1 ); - } else { - $parts = \explode( ':', $entry, 2 ); - $install = InstalledVersions::isInstalled( $parts[0] ) ? InstalledVersions::getInstallPath( $parts[0] ) : null; - $path = \is_null( $install ) ? '' : $install . '/' . ( $parts[1] ?? \basename( $parts[0] ) . '.php' ); - } - if ( ! \is_file( $path ) ) { - throw new \RuntimeException( \sprintf( "'%s/composer.json' declares the scoping stubs %s, which do not exist.", $dir, \var_export( $entry, true ) ) ); - } - $stubs_files[] = $path; - } - - return $stubs_files; - } - /** * Returns the strings of a JSON list file. * diff --git a/php/composer/Internal/ScopedSymbolCollector.php b/php/composer/Internal/ScopedSymbolCollector.php index dc13668..464b950 100644 --- a/php/composer/Internal/ScopedSymbolCollector.php +++ b/php/composer/Internal/ScopedSymbolCollector.php @@ -79,7 +79,21 @@ public function enterNode( Node $node ): int|null { $this->declare( 'constant', $const->namespacedName?->toString() ); } } elseif ( $node instanceof Node\Scalar\String_ ) { - $this->string( $node ); + // php-scoper scopes the PHP code of a nowdoc that opens with a PHP tag like a file, and a string that names a symbol or a class constant like a reference. + $code = \ltrim( $node->value ); + if ( Node\Scalar\String_::KIND_NOWDOC === $node->getAttribute( 'kind' ) && \str_starts_with( $code, 'parser->parse( $code ) ?? array(); + } catch ( Error ) { + return null; // php-scoper leaves a nowdoc that does not parse as PHP unscoped. + } + new NodeTraverser( new NameResolver(), new ParentConnectingVisitor(), $this )->traverse( $statements ); + } else { + $parts = \explode( '::', $node->value, 2 ); + if ( 1 === \preg_match( StubSymbolCollector::SYMBOL_NAME_REGEX, $parts[0] ) ) { + $this->reference( \ltrim( $parts[0], '\\' ), $node, isset( $parts[1] ) ? array( 'class' ) : array( 'class', 'function', 'constant', 'namespace' ) ); + } + } } elseif ( $node instanceof Node\Name && ! $parent instanceof Node\Stmt\Namespace_ ) { $use = $parent instanceof Node\UseItem ? $parent->getAttribute( 'parent' ) : null; $type = $parent instanceof Node\UseItem && ( $use instanceof Node\Stmt\Use_ || $use instanceof Node\Stmt\GroupUse ) ? ( $parent->type ?: $use->type ) : null; @@ -129,30 +143,6 @@ protected function declare( string $kind, ?string $name ): void { } } - /** - * Records a string literal shaped like a symbol name, or the class of one shaped like a class constant, and reads the PHP code of a nowdoc string that opens with a PHP tag, which php-scoper scopes like a file. - * - * @param Node\Scalar\String_ $node The string literal. - * - * @return void - */ - protected function string( Node\Scalar\String_ $node ): void { - $code = \ltrim( $node->value ); - if ( Node\Scalar\String_::KIND_NOWDOC === $node->getAttribute( 'kind' ) && \str_starts_with( $code, 'parser->parse( $code ) ?? array(); - } catch ( Error ) { - return; // php-scoper leaves a nowdoc that does not parse as PHP unscoped. - } - new NodeTraverser( new NameResolver(), new ParentConnectingVisitor(), $this )->traverse( $statements ); - } else { - $parts = \explode( '::', $node->value, 2 ); - if ( 1 === \preg_match( StubSymbolCollector::SYMBOL_NAME_REGEX, $parts[0] ) ) { - $this->reference( \ltrim( $parts[0], '\\' ), $node, isset( $parts[1] ) ? array( 'class' ) : array( 'class', 'function', 'constant', 'namespace' ) ); - } - } - } - /** * Records a name that carries the prefix, with its location, its lookup key for each kind of symbol it can name, and whether a function_exists() call checks it. * diff --git a/php/composer/ScopePhpDependencies.php b/php/composer/ScopePhpDependencies.php index e3d75f1..aed9eaa 100644 --- a/php/composer/ScopePhpDependencies.php +++ b/php/composer/ScopePhpDependencies.php @@ -94,41 +94,92 @@ public static function scope( string $project_dir ): void { $filesystem->rename( $scoping_dir, \rtrim( $output_dir . '/' . $dropped, '/' ) ); // The autoloader goes first, so a project whose scoped code fails the check still loads. - $filesystem->dumpFile( $output_dir . '/scoper-autoload.php', self::autoload( $output_dir ) ); + $package_manifests = \glob( $output_dir . '/*/*/composer.json' ); + if ( false === $package_manifests || array() === $package_manifests ) { + throw new \RuntimeException( "No scoped package exists in '$output_dir'." ); + } + + $classmap = array(); + $includes = array(); + $requires = array(); + foreach ( $package_manifests as $manifest ) { + $package_dir = \dirname( $manifest ); + $relative = \substr( $package_dir, \strlen( $output_dir ) ); + $decoded = Manifest::read( $manifest ); + $autoload = isset( $decoded['autoload'] ) && \is_array( $decoded['autoload'] ) ? $decoded['autoload'] : array(); + + // A class map over the psr-4, psr-0 and classmap paths serves PEAR-style psr-0 classes too. + $paths = self::strings( $autoload['classmap'] ?? array() ); + foreach ( array( 'psr-4', 'psr-0' ) as $standard ) { + foreach ( (array) ( $autoload[ $standard ] ?? array() ) as $namespace_paths ) { + \array_push( $paths, ...self::strings( $namespace_paths ) ); + } + } + $excluded = null; + $excluded_paths = self::strings( $autoload['exclude-from-classmap'] ?? array() ); + if ( array() !== $excluded_paths ) { + // Composer's exclude-from-classmap paths take * and ** wildcards. + $excluded = '#^' . \preg_quote( \strtr( $package_dir, '\\', '/' ), '#' ) . '/(?:' . \implode( + '|', + \array_map( + static fn ( string $path ): string => \strtr( + \preg_quote( \trim( (string) \preg_replace( '#^(?:\./)+#', '', \strtr( $path, '\\', '/' ) ), '/' ), '#' ), + array( + '\\*\\*' => '.+?', + '\\*' => '[^/]+?', + ) + ), + $excluded_paths + ) + ) . ')(?:$|/)#'; + } + $generator = new ClassMapGenerator(); + foreach ( $paths as $autoload_path ) { + if ( \str_contains( $autoload_path, '*' ) || \file_exists( "$package_dir/$autoload_path" ) ) { // Composer skips an autoload path a package does not ship. + $generator->scanPaths( "$package_dir/$autoload_path", $excluded ); + } + } + foreach ( $generator->getClassMap()->getMap() as $class => $file ) { + $classmap[ $class ] = \substr( $file, \strlen( $output_dir ) ); + } + $package_name = self::package_name( $manifest ); + $requires[ $package_name ] = \array_map( 'strval', \array_keys( (array) ( $decoded['require'] ?? array() ) ) ); + $includes[ $package_name ] = ''; + foreach ( self::strings( $autoload['files'] ?? array() ) as $file_entry ) { + $includes[ $package_name ] .= \sprintf( "require_once __DIR__ . %s;\n", \var_export( "$relative/$file_entry", true ) ); + } + } + + // Composer includes a package's files after those of the packages it requires, which they may call when loaded. + $files = ''; + while ( array() !== $includes ) { + $ready = \array_filter( \array_keys( $includes ), static fn ( string $package_name ): bool => array() === \array_intersect( $requires[ $package_name ], \array_keys( $includes ) ) ); + $package_name = \current( $ready ) ?: \array_key_first( $includes ); // Packages that require each other keep their name order. + $files .= $includes[ $package_name ]; + unset( $includes[ $package_name ] ); + } + + \ksort( $classmap ); + $classes = ''; + foreach ( $classmap as $class_name => $class_file ) { + $classes .= \sprintf( "\t%s => __DIR__ . %s,\n", \var_export( $class_name, true ), \var_export( $class_file, true ) ); + } + $filesystem->dumpFile( $output_dir . '/scoper-autoload.php', "addClassMap( array(\n$classes) );\n\$loader->register();\n\n$files" ); // The functions the run leaves global are those the config excludes and those PHP declares, whose map Composer's script autoloader does not load. The config runs in a closure, so the variables it sets stay out of this method. $config = ( static fn (): mixed => require $project_dir . '/scoper.inc.php' )(); $stubs_path = InstalledVersions::getInstallPath( 'jetbrains/phpstorm-stubs' ) ?? ''; require_once $stubs_path . '/PhpStormStubsMap.php'; - self::verify( $output_dir, $prefix, array( ...( \is_array( $config ) ? self::strings( $config['exclude-functions'] ?? array() ) : array() ), ...\array_keys( PhpStormStubsMap::FUNCTIONS ) ) ); - } - - // endregion - - // region HELPERS + $global_functions = array( ...( \is_array( $config ) ? self::strings( $config['exclude-functions'] ?? array() ) : array() ), ...\array_keys( PhpStormStubsMap::FUNCTIONS ) ); - /** - * Fails when the scoped code references a prefixed name it does not declare, which would fail at runtime: a host symbol no stubs file declares, a dependency outside the scoped packages, or a string php-scoper took for a class name. - * - * A function_exists() check of the prefixed copy of a function the run leaves global passes, because php-scoper adds one to every function_exists() guard of that function, and it is false while nothing declares the copy. - * - * @param string $output_dir The vendor-prefixed directory. - * @param string $prefix The scoping prefix. - * @param list $global_functions The names and patterns of the functions the run leaves global. - * - * @throws \RuntimeException Thrown when a scoped file does not parse, a scoped package declares a class outside the prefix, or a prefixed name is declared by no scoped package. - * - * @return void - */ - protected static function verify( string $output_dir, string $prefix, array $global_functions ): void { $parser = new ParserFactory()->createForNewestSupportedVersion(); $collector = new ScopedSymbolCollector( $prefix, $parser ); - foreach ( Finder::create()->files()->in( $output_dir )->name( '*.php' )->sortByName() as $file ) { - $collector->file = $file->getPathname(); + foreach ( Finder::create()->files()->in( $output_dir )->name( '*.php' )->sortByName() as $scoped_file ) { + $collector->file = $scoped_file->getPathname(); try { - $statements = $parser->parse( $file->getContents() ) ?? array(); + $statements = $parser->parse( $scoped_file->getContents() ) ?? array(); } catch ( Error $error ) { - throw new \RuntimeException( "Could not parse the scoped file '" . $file->getPathname() . "': " . $error->getMessage(), 0, $error ); + throw new \RuntimeException( "Could not parse the scoped file '" . $scoped_file->getPathname() . "': " . $error->getMessage(), 0, $error ); } new NodeTraverser( new NameResolver(), new ParentConnectingVisitor(), $collector )->traverse( $statements ); } @@ -167,6 +218,7 @@ protected static function verify( string $output_dir, string $prefix, array $glo } } + // A function_exists() check of the prefixed copy of a function the run leaves global passes, because php-scoper adds one to every function_exists() guard of that function, and it is false while nothing declares the copy. $undeclared = array(); foreach ( $collector->references as $reference ) { $function = \ltrim( \substr( \rtrim( $reference['name'], '\\' ), \strlen( $prefix ) + 1 ), '\\' ); @@ -181,97 +233,9 @@ protected static function verify( string $output_dir, string $prefix, array $glo } } - /** - * Returns the autoloader of the scoped packages: a class map over each package's psr-4, psr-0 and classmap paths, which serves PEAR-style psr-0 classes too, followed by its files entries. - * - * @param string $output_dir The vendor-prefixed directory. - * - * @throws \RuntimeException Thrown when no scoped package manifest exists or one cannot be read. - * - * @return string - */ - protected static function autoload( string $output_dir ): string { - $manifests = \glob( $output_dir . '/*/*/composer.json' ); - if ( false === $manifests || array() === $manifests ) { - throw new \RuntimeException( "No scoped package exists in '$output_dir'." ); - } - - $classmap = array(); - $includes = array(); - $requires = array(); - foreach ( $manifests as $manifest ) { - $package_dir = \dirname( $manifest ); - $relative = \substr( $package_dir, \strlen( $output_dir ) ); - $decoded = Manifest::read( $manifest ); - $autoload = isset( $decoded['autoload'] ) && \is_array( $decoded['autoload'] ) ? $decoded['autoload'] : array(); - - $paths = self::strings( $autoload['classmap'] ?? array() ); - foreach ( array( 'psr-4', 'psr-0' ) as $standard ) { - foreach ( (array) ( $autoload[ $standard ] ?? array() ) as $namespace_paths ) { - \array_push( $paths, ...self::strings( $namespace_paths ) ); - } - } - $excluded = self::excluded( $package_dir, self::strings( $autoload['exclude-from-classmap'] ?? array() ) ); - $generator = new ClassMapGenerator(); - foreach ( $paths as $path ) { - if ( \str_contains( $path, '*' ) || \file_exists( "$package_dir/$path" ) ) { // Composer skips an autoload path a package does not ship. - $generator->scanPaths( "$package_dir/$path", $excluded ); - } - } - foreach ( $generator->getClassMap()->getMap() as $class => $file ) { - $classmap[ $class ] = \substr( $file, \strlen( $output_dir ) ); - } - $name = self::package_name( $manifest ); - $requires[ $name ] = \array_map( 'strval', \array_keys( (array) ( $decoded['require'] ?? array() ) ) ); - $includes[ $name ] = ''; - foreach ( self::strings( $autoload['files'] ?? array() ) as $path ) { - $includes[ $name ] .= \sprintf( "require_once __DIR__ . %s;\n", \var_export( "$relative/$path", true ) ); - } - } - - // Composer includes a package's files after those of the packages it requires, which they may call when loaded. - $files = ''; - while ( array() !== $includes ) { - $ready = \array_filter( \array_keys( $includes ), static fn ( string $name ): bool => array() === \array_intersect( $requires[ $name ], \array_keys( $includes ) ) ); - $name = \current( $ready ) ?: \array_key_first( $includes ); // Packages that require each other keep their name order. - $files .= $includes[ $name ]; - unset( $includes[ $name ] ); - } - - \ksort( $classmap ); - $classes = ''; - foreach ( $classmap as $class => $file ) { - $classes .= \sprintf( "\t%s => __DIR__ . %s,\n", \var_export( $class, true ), \var_export( $file, true ) ); - } - - return "addClassMap( array(\n$classes) );\n\$loader->register();\n\n$files"; - } - - /** - * Returns the pattern of a package's exclude-from-classmap paths, with Composer's * and ** wildcards, or null when it excludes none. - * - * @param string $package_dir The directory of the scoped package. - * @param list $paths The paths, relative to the package. - * - * @return non-empty-string|null - */ - protected static function excluded( string $package_dir, array $paths ): ?string { - if ( array() === $paths ) { - return null; - } - $patterns = \array_map( - static fn ( string $path ): string => \strtr( - \preg_quote( \trim( (string) \preg_replace( '#^(?:\./)+#', '', \strtr( $path, '\\', '/' ) ), '/' ), '#' ), - array( - '\\*\\*' => '.+?', - '\\*' => '[^/]+?', - ) - ), - $paths - ); + // endregion - return '#^' . \preg_quote( \strtr( $package_dir, '\\', '/' ), '#' ) . '/(?:' . \implode( '|', $patterns ) . ')(?:$|/)#'; - } + // region HELPERS /** * Returns the name a scoped package manifest declares. diff --git a/php/composer/ScopedPackages.php b/php/composer/ScopedPackages.php index 4e5d089..bcdbeae 100644 --- a/php/composer/ScopedPackages.php +++ b/php/composer/ScopedPackages.php @@ -25,11 +25,22 @@ public static function resolve( string $vendor_dir, array $names ): array { throw new \RuntimeException( 'Name at least one package to scope.' ); } - $installed = self::installed( $vendor_dir ); + $installed_file = $vendor_dir . '/composer/installed.json'; + if ( ! \is_file( $installed_file ) ) { + throw new \RuntimeException( "Could not read '$installed_file'. Install the project's dependencies first." ); + } + $recorded = Manifest::read( $installed_file ); + if ( ! \is_array( $recorded['packages'] ?? null ) ) { + throw new \RuntimeException( "'$installed_file' lists no packages." ); + } + $installed = array(); $replacers = array(); - foreach ( $installed as $name => $package ) { - foreach ( \array_keys( (array) ( $package['replace'] ?? array() ) ) as $replaced ) { - $replacers[ (string) $replaced ][] = $name; + foreach ( $recorded['packages'] as $package ) { + if ( \is_array( $package ) && \is_string( $package['name'] ?? null ) ) { + $installed[ $package['name'] ] = $package; + foreach ( \array_keys( (array) ( $package['replace'] ?? array() ) ) as $replaced ) { + $replacers[ (string) $replaced ][] = $package['name']; + } } } // Composer records every name a package or the root replaces or provides, so a requirement no installed package fills is met when it is recorded there. @@ -100,35 +111,6 @@ public static function resolve( string $vendor_dir, array $names ): array { // region HELPERS - /** - * Returns the packages Composer recorded in the vendor directory's installed.json, keyed by name. - * - * @param string $vendor_dir The project's Composer vendor directory. - * - * @throws \RuntimeException Thrown when installed.json is missing or lists no packages. - * - * @return array> - */ - protected static function installed( string $vendor_dir ): array { - $file = $vendor_dir . '/composer/installed.json'; - if ( ! \is_file( $file ) ) { - throw new \RuntimeException( "Could not read '$file'. Install the project's dependencies first." ); - } - $decoded = Manifest::read( $file ); - if ( ! \is_array( $decoded['packages'] ?? null ) ) { - throw new \RuntimeException( "'$file' lists no packages." ); - } - - $installed = array(); - foreach ( $decoded['packages'] as $package ) { - if ( \is_array( $package ) && \is_string( $package['name'] ?? null ) ) { - $installed[ $package['name'] ] = $package; - } - } - - return $installed; - } - /** * Returns the real directory a package is installed in. * From 90de0360d3cb5316c7af4d0d930baec6a4ffdf96 Mon Sep 17 00:00:00 2001 From: Tony Hegyes Date: Fri, 6 Nov 2026 14:55:07 +0100 Subject: [PATCH 12/29] Mark the scoping pipeline's internals and name them by role Consumers call only ScopePhpDependencies::postAutoloadDump() and scoper-base.inc.php, yet ScopedPackages, CollectScopingStubs, scope() and the PHPStan profile's GlobalTagVisitor were public with no @internal tag, the static-only classes could be constructed, and the visitors' collections were writable from outside. The visitors sat in a namespace named after their visibility, and CollectScopingStubs was named for one of its five inputs. ScopedPackages, GlobalSymbols, scope() and GlobalTagVisitor carry @internal, the three static-only classes declare a protected constructor, and the collections are protected(set), with GlobalTagVisitor's promoted properties protected readonly. Composer\Internal\ becomes Composer\Visitors\, and CollectScopingStubs becomes GlobalSymbols, which the issue templates and AGENTS.md name. Port audit A183, A184, A186, A187 (X ruling 191). Assisted-by: Claude Code:claude-opus-5-5 --- .github/ISSUE_TEMPLATE/bug_report.yml | 2 +- .github/ISSUE_TEMPLATE/feature_request.yml | 2 +- AGENTS.md | 2 +- ...{CollectScopingStubs.php => GlobalSymbols.php} | 15 +++++++++++++-- php/composer/ScopePhpDependencies.php | 13 ++++++++++++- php/composer/ScopedPackages.php | 11 +++++++++++ .../ScopedSymbolCollector.php | 6 +++--- .../StubSymbolCollector.php | 8 ++++---- php/php-scoper/scoper-base.inc.php | 4 ++-- php/phpstan/GlobalTagVisitor.php | 6 ++++-- 10 files changed, 52 insertions(+), 17 deletions(-) rename php/composer/{CollectScopingStubs.php => GlobalSymbols.php} (96%) rename php/composer/{Internal => Visitors}/ScopedSymbolCollector.php (98%) rename php/composer/{Internal => Visitors}/StubSymbolCollector.php (95%) diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 782ab92..848443f 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -7,7 +7,7 @@ body: attributes: label: Affected area options: - - Composer scripts (CollectScopingStubs, ScopePhpDependencies) + - Composer scripts (GlobalSymbols, ScopePhpDependencies) - php-scoper base config (scoper-base) - PHPCS ruleset (phpcs.dist.xml) - PHPStan config (phpstan.dist.neon) diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml index 853ab5c..6b12ab4 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -7,7 +7,7 @@ body: attributes: label: Target area options: - - Composer scripts (CollectScopingStubs, ScopePhpDependencies) + - Composer scripts (GlobalSymbols, ScopePhpDependencies) - php-scoper base config (scoper-base) - PHPCS ruleset - PHPStan config diff --git a/AGENTS.md b/AGENTS.md index 566bcbe..3ae71e5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,7 +9,7 @@ wordpress-configs/ ├── php/ │ ├── quality-assurance/ # the shared PHPCS profiles and base, the PHPStan profile and base, opt-in path discovery │ ├── php-scoper/ # scoper-base.inc.php and per-library recipes//.inc.php -│ ├── composer/ # ScopePhpDependencies, ScopedPackages, CollectScopingStubs and their AST visitors +│ ├── composer/ # ScopePhpDependencies, ScopedPackages, GlobalSymbols, Manifest and their AST visitors │ ├── phpstan/ # GlobalTagVisitor, which the PHPStan profile registers │ └── release/ # the manifest and lock of the WP-CLI commands reusable-release.yml installs, which Dependabot keeps current ├── node/ # tsconfig, ESLint, Stylelint, PostCSS and Playwright baselines diff --git a/php/composer/CollectScopingStubs.php b/php/composer/GlobalSymbols.php similarity index 96% rename from php/composer/CollectScopingStubs.php rename to php/composer/GlobalSymbols.php index 23f15b3..3830490 100644 --- a/php/composer/CollectScopingStubs.php +++ b/php/composer/GlobalSymbols.php @@ -3,7 +3,7 @@ namespace DeepWebSolutions\Config\Composer; use Composer\InstalledVersions; -use DeepWebSolutions\Config\Composer\Internal\StubSymbolCollector; +use DeepWebSolutions\Config\Composer\Visitors\StubSymbolCollector; use PhpParser\NodeTraverser; use PhpParser\NodeVisitor\NameResolver; use PhpParser\ParserFactory; @@ -11,8 +11,19 @@ /** * Collects the WordPress, Action Scheduler, stub-declared and dropped-package symbols that scoping leaves global. + * + * @internal */ -final class CollectScopingStubs { +final class GlobalSymbols { + // region MAGIC METHODS + + /** + * Constructor. + */ + protected function __construct() {} + + // endregion + // region METHODS /** diff --git a/php/composer/ScopePhpDependencies.php b/php/composer/ScopePhpDependencies.php index aed9eaa..2201190 100644 --- a/php/composer/ScopePhpDependencies.php +++ b/php/composer/ScopePhpDependencies.php @@ -6,7 +6,7 @@ use Composer\Factory; use Composer\InstalledVersions; use Composer\Script\Event; -use DeepWebSolutions\Config\Composer\Internal\ScopedSymbolCollector; +use DeepWebSolutions\Config\Composer\Visitors\ScopedSymbolCollector; use Humbug\PhpScoper\Configuration\RegexChecker; use JetBrains\PHPStormStub\PhpStormStubsMap; use PhpParser\Error; @@ -21,6 +21,15 @@ * Scopes a project's dependencies into its vendor-prefixed directory with php-scoper and writes their autoloader. */ final class ScopePhpDependencies { + // region MAGIC METHODS + + /** + * Constructor. + */ + protected function __construct() {} + + // endregion + // region METHODS /** @@ -41,6 +50,8 @@ public static function postAutoloadDump( Event $event ): void { /** * Scopes a project's dependencies into its vendor-prefixed directory with the project's scoper.inc.php, writes vendor-prefixed/scoper-autoload.php, then checks that the scoped code declares every prefixed name it references. * + * @internal + * * @param string $project_dir The root directory of the project. * * @throws \RuntimeException Thrown when the project, its scoping prefix or php-scoper is missing, vendor-prefixed is a symbolic link, php-scoper fails, a scoped file does not parse, a scoped package declares a class outside the prefix, a prefixed name is declared by no scoped package, or a scoped package manifest cannot be read. diff --git a/php/composer/ScopedPackages.php b/php/composer/ScopedPackages.php index bcdbeae..4f8cf23 100644 --- a/php/composer/ScopedPackages.php +++ b/php/composer/ScopedPackages.php @@ -4,8 +4,19 @@ /** * Resolves the packages a project scopes from the names it gives and from Composer's record of the installed packages. + * + * @internal */ final class ScopedPackages { + // region MAGIC METHODS + + /** + * Constructor. + */ + protected function __construct() {} + + // endregion + // region METHODS /** diff --git a/php/composer/Internal/ScopedSymbolCollector.php b/php/composer/Visitors/ScopedSymbolCollector.php similarity index 98% rename from php/composer/Internal/ScopedSymbolCollector.php rename to php/composer/Visitors/ScopedSymbolCollector.php index 464b950..0e0521a 100644 --- a/php/composer/Internal/ScopedSymbolCollector.php +++ b/php/composer/Visitors/ScopedSymbolCollector.php @@ -1,6 +1,6 @@ , function: array, constant: array} */ - public array $declared = array( + protected(set) array $declared = array( 'class' => array(), 'function' => array(), 'constant' => array(), @@ -36,7 +36,7 @@ final class ScopedSymbolCollector extends NodeVisitorAbstract { * * @var list, checked: bool}> */ - public array $references = array(); + protected(set) array $references = array(); /** * The file the next traversal reads. diff --git a/php/composer/Internal/StubSymbolCollector.php b/php/composer/Visitors/StubSymbolCollector.php similarity index 95% rename from php/composer/Internal/StubSymbolCollector.php rename to php/composer/Visitors/StubSymbolCollector.php index 420fe6c..4ee8821 100644 --- a/php/composer/Internal/StubSymbolCollector.php +++ b/php/composer/Visitors/StubSymbolCollector.php @@ -1,6 +1,6 @@ */ - public array $classes = array(); + protected(set) array $classes = array(); /** * The function names the stubs declare. * * @var list */ - public array $functions = array(); + protected(set) array $functions = array(); /** * The constant names the stubs declare, through const statements and define() calls. * * @var list */ - public array $constants = array(); + protected(set) array $constants = array(); // endregion diff --git a/php/php-scoper/scoper-base.inc.php b/php/php-scoper/scoper-base.inc.php index cb10e99..a821625 100644 --- a/php/php-scoper/scoper-base.inc.php +++ b/php/php-scoper/scoper-base.inc.php @@ -5,7 +5,7 @@ * @package DeepWebSolutions\Config */ -use DeepWebSolutions\Config\Composer\CollectScopingStubs; +use DeepWebSolutions\Config\Composer\GlobalSymbols; use DeepWebSolutions\Config\Composer\Manifest; use DeepWebSolutions\Config\Composer\ScopedPackages; use Symfony\Component\Finder\Finder; @@ -30,7 +30,7 @@ if ( array() === $packages['scoped'] ) { throw new RuntimeException( "No package to scope is installed in '$vendor_dir'." ); // Without finders, php-scoper scopes the whole working directory. } - $symbols = CollectScopingStubs::collect( $project_dir, array_values( $packages['scoped'] ), array_values( $packages['dropped'] ) ); + $symbols = GlobalSymbols::collect( $project_dir, array_values( $packages['scoped'] ), array_values( $packages['dropped'] ) ); $exclude_files = array(); $exclude_namespaces = array(); diff --git a/php/phpstan/GlobalTagVisitor.php b/php/phpstan/GlobalTagVisitor.php index aa77faf..ea40d64 100644 --- a/php/phpstan/GlobalTagVisitor.php +++ b/php/phpstan/GlobalTagVisitor.php @@ -13,6 +13,8 @@ * Gives each global statement the types that the @global tags of its function's or method's docblock declare, as WordPress's documentation standard writes them. * * PHPStan types a global as mixed but reads an inline @var on the statement, so the visitor attaches one; a global no tag declares stays mixed, and a statement with its own docblock keeps it. + * + * @internal */ final class GlobalTagVisitor extends NodeVisitorAbstract { // region FIELDS AND CONSTANTS @@ -35,8 +37,8 @@ final class GlobalTagVisitor extends NodeVisitorAbstract { * @param PhpDocParser $parser PHPStan's PHPDoc parser. */ public function __construct( - protected Lexer $lexer, - protected PhpDocParser $parser + protected readonly Lexer $lexer, + protected readonly PhpDocParser $parser ) {} // endregion From c9381b9e82484e7ea82a24451718384149f4a241 Mon Sep 17 00:00:00 2001 From: Tony Hegyes Date: Fri, 6 Nov 2026 14:58:27 +0100 Subject: [PATCH 13/29] Write configs' PHP and rulesets the way the other repositories do Configs wrote the same things two ways. Five null checks compared with null where the rest use \is_null(), and one return kept a blank line after a one-line statement. The self-lint rulesets carried two exclusions the tests no longer need, a function-name exclusion for one Composer callback, comments whose reasons were wrong, em dashes and a that repeats the opening comment. The shared base disabled two WordPress-Core sniffs as severity-0 blocks beside the excludes that serve the same purpose, with its purpose comments inside the rules where v1 and a8csp-configs put them above. The scope run's ten-argument command was one 300-character line. The null checks use \is_null() and the blank line goes. The tests ruleset drops ValidVariableName and NoSilencedErrors, postAutoloadDump() takes a trailing phpcs:ignore with its reason, and the comments name what each exclusion covers. The base excludes PSR12's IncorrectOrder and Universal's DisallowShortTernary inside its WordPress rule, keeps WrongNumber's severity 0 because WordPress-Docs adds that sniff first, and puts each purpose comment above its rule. The command takes one argument per line. Port audit A188, A189, A190, A191 (X ruling 192). Assisted-by: Claude Code:claude-opus-5-5 --- php/composer/GlobalSymbols.php | 1 - php/composer/ScopePhpDependencies.php | 15 +++++++++++-- php/composer/Visitors/StubSymbolCollector.php | 6 ++--- php/phpstan/GlobalTagVisitor.php | 4 ++-- php/quality-assurance/phpcs.base.dist.xml | 22 +++++++------------ phpcs.dist.xml | 11 +++------- phpcs.tests.dist.xml | 10 ++------- 7 files changed, 31 insertions(+), 38 deletions(-) diff --git a/php/composer/GlobalSymbols.php b/php/composer/GlobalSymbols.php index 3830490..a089a41 100644 --- a/php/composer/GlobalSymbols.php +++ b/php/composer/GlobalSymbols.php @@ -88,7 +88,6 @@ public static function collect( string $project_dir, array $package_dirs, array } } $symbols['namespaces'] = array() === $namespaces ? array() : array( '/^(?:' . \implode( '|', \array_keys( $namespaces ) ) . ')$/i' ); - return $symbols; } diff --git a/php/composer/ScopePhpDependencies.php b/php/composer/ScopePhpDependencies.php index 2201190..b1b9204 100644 --- a/php/composer/ScopePhpDependencies.php +++ b/php/composer/ScopePhpDependencies.php @@ -41,7 +41,7 @@ protected function __construct() {} * * @return void */ - public static function postAutoloadDump( Event $event ): void { + public static function postAutoloadDump( Event $event ): void { // phpcs:ignore WordPress.NamingConventions.ValidFunctionName.MethodNameInvalid -- Consumer-facing Composer callback name. if ( $event->isDevMode() ) { self::scope( \dirname( Factory::getComposerFile() ) ); } @@ -77,7 +77,18 @@ public static function scope( string $project_dir ): void { } // Loading the config parses the declared stubs files, and large ones exceed PHP's default memory limit. - $command = array( \PHP_BINARY, '-d', 'memory_limit=1G', ( InstalledVersions::getInstallPath( 'humbug/php-scoper' ) ?? '' ) . '/bin/php-scoper', 'add-prefix', '--working-dir=' . $project_dir, '--config=' . $project_dir . '/scoper.inc.php', '--output-dir=' . $scoping_dir, '--force', '--no-interaction' ); + $command = array( + \PHP_BINARY, + '-d', + 'memory_limit=1G', + ( InstalledVersions::getInstallPath( 'humbug/php-scoper' ) ?? '' ) . '/bin/php-scoper', + 'add-prefix', + '--working-dir=' . $project_dir, + '--config=' . $project_dir . '/scoper.inc.php', + '--output-dir=' . $scoping_dir, + '--force', + '--no-interaction', + ); // php-scoper's console wraps what it prints at the terminal width, which would split the paths and symbols in a failure message. $process = \proc_open( \implode( ' ', \array_map( 'escapeshellarg', $command ) ) . ' 2>&1', array( 1 => array( 'pipe', 'w' ) ), $pipes, null, array( 'COLUMNS' => '4096' ) + \getenv() ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.system_calls_proc_open -- Runs in Composer, never on a WordPress server. if ( false === $process ) { diff --git a/php/composer/Visitors/StubSymbolCollector.php b/php/composer/Visitors/StubSymbolCollector.php index 4ee8821..dc28b9f 100644 --- a/php/composer/Visitors/StubSymbolCollector.php +++ b/php/composer/Visitors/StubSymbolCollector.php @@ -58,18 +58,18 @@ public function enterNode( Node $node ): int|null { case Node\Stmt\Interface_::class: case Node\Stmt\Trait_::class: case Node\Stmt\Enum_::class: - if ( null !== $node->namespacedName ) { + if ( ! \is_null( $node->namespacedName ) ) { $this->collect( $this->classes, $node->namespacedName->toString() ); } return NodeVisitor::DONT_TRAVERSE_CHILDREN; case Node\Stmt\Function_::class: - if ( null !== $node->namespacedName ) { + if ( ! \is_null( $node->namespacedName ) ) { $this->collect( $this->functions, $node->namespacedName->toString() ); } break; case Node\Stmt\Const_::class: foreach ( $node->consts as $const ) { - if ( null !== $const->namespacedName ) { + if ( ! \is_null( $const->namespacedName ) ) { $this->collect( $this->constants, $const->namespacedName->toString() ); } } diff --git a/php/phpstan/GlobalTagVisitor.php b/php/phpstan/GlobalTagVisitor.php index ea40d64..0162565 100644 --- a/php/phpstan/GlobalTagVisitor.php +++ b/php/phpstan/GlobalTagVisitor.php @@ -62,7 +62,7 @@ public function enterNode( Node $node ): null { if ( $node instanceof Node\FunctionLike ) { $types = array(); $doc = $node->getDocComment()?->getText(); - if ( null !== $doc ) { + if ( ! \is_null( $doc ) ) { // The parser reads @global as an unknown tag, so each one is parsed with @var's grammar of a type and a variable. $tokens = new TokenIterator( $this->lexer->tokenize( (string) \preg_replace( '/@global(?=\s)/', '@var', $doc ) ) ); foreach ( $this->parser->parse( $tokens )->getVarTagValues() as $tag ) { @@ -72,7 +72,7 @@ public function enterNode( Node $node ): null { } } $this->scopes[] = $types; - } elseif ( $node instanceof Node\Stmt\Global_ && null === $node->getDocComment() ) { + } elseif ( $node instanceof Node\Stmt\Global_ && \is_null( $node->getDocComment() ) ) { $types = \end( $this->scopes ) ?: array(); $lines = array(); foreach ( $node->vars as $var ) { diff --git a/php/quality-assurance/phpcs.base.dist.xml b/php/quality-assurance/phpcs.base.dist.xml index 7223d58..cc114e5 100644 --- a/php/quality-assurance/phpcs.base.dist.xml +++ b/php/quality-assurance/phpcs.base.dist.xml @@ -36,19 +36,23 @@ + + + + + - + - @@ -56,24 +60,14 @@ + - (?-i)*/index\.php - - - 0 - - - - - 0 - - + - 0 diff --git a/phpcs.dist.xml b/phpcs.dist.xml index 5843c9e..0ef6fbc 100644 --- a/phpcs.dist.xml +++ b/phpcs.dist.xml @@ -1,18 +1,13 @@ - Lints this repo's own PHP. Excludes WP-runtime sniffs from the shared ruleset since this code is Composer-time tooling, not WP code. - - + - + - - + diff --git a/phpcs.tests.dist.xml b/phpcs.tests.dist.xml index 099560e..80c9086 100644 --- a/phpcs.tests.dist.xml +++ b/phpcs.tests.dist.xml @@ -1,17 +1,11 @@ - Lints this repo's own tests. - - + (?-i)*/tests/fixtures/* - - - - + From d394b87246d349a63d30327433d4d69c0cea5055 Mon Sep 17 00:00:00 2001 From: Tony Hegyes Date: Fri, 6 Nov 2026 15:01:09 +0100 Subject: [PATCH 14/29] Align configs' test harness with the other repositories Configs' test classes opened with a blank line, ScopePhpDependenciesTest named no #[CoversClass] although it covers one class, three tests removed their temporary directories three ways, the workflow-step trait carried no Trait suffix, and the autoload row passed a 450-character script to php -r through ten sprintf placeholders while the dompdf row writes a readable nowdoc. The opening blank lines go, ScopePhpDependenciesTest covers ScopePhpDependencies, the temporary directories go through Filesystem::remove(), RunsWorkflowSteps becomes RunsWorkflowStepsTrait, and the autoload probe is a nowdoc file. tests/README and AGENTS.md name the renamed trait, ScopedPackagesTest and RecordsInstalledPackagesTrait. Port audit A495, A496, A498 (X ruling 241) and A191's probe (X ruling 192). Assisted-by: Claude Code:claude-opus-5-5 --- AGENTS.md | 2 +- tests/README.md | 3 +- ...owSteps.php => RunsWorkflowStepsTrait.php} | 14 ++---- tests/Unit/PhpcsProfilesTest.php | 12 +---- tests/Unit/PhpstanConfigTest.php | 4 +- tests/Unit/PhpunitWorkflowTest.php | 5 +-- tests/Unit/PluginCheckWorkflowTest.php | 5 +-- tests/Unit/ReleaseToolsTest.php | 1 - tests/Unit/ReleaseWorkflowTest.php | 5 +-- tests/Unit/ScopePhpDependenciesTest.php | 45 ++++++++++++------- tests/Unit/SupplyChainAuditWorkflowTest.php | 5 +-- 11 files changed, 48 insertions(+), 53 deletions(-) rename tests/Support/{RunsWorkflowSteps.php => RunsWorkflowStepsTrait.php} (88%) diff --git a/AGENTS.md b/AGENTS.md index 3ae71e5..5e97b80 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,7 +16,7 @@ wordpress-configs/ ├── docs/ # workflows.md (input reference) and scripts-contract.md (scripts, files, floors) ├── tests/ │ ├── Unit/ # PHPUnit tests that run the real tools -│ ├── Support/ # RunsWorkflowSteps, which runs a workflow step's script +│ ├── Support/ # RunsWorkflowStepsTrait, which runs a workflow step's script, and RecordsInstalledPackagesTrait │ └── fixtures/ # projects and captured tool output the tests use ├── phpcs.dist.xml # self-lint: the production profile minus WordPress-runtime sniffs ├── phpcs.tests.dist.xml # self-lint for tests/ diff --git a/tests/README.md b/tests/README.md index 16cef0e..5db818f 100644 --- a/tests/README.md +++ b/tests/README.md @@ -8,7 +8,8 @@ The tests prove that the shared configs, the scoping pipeline and the reusable w - `PhpcsProfilesTest` scans files with both PHPCS profiles: the case-sensitive vendor exclusions, the `index.php` stub waiver, the full `WordPress` standard, and the two ways a project lowers the PHP and WordPress floors. - `PhpstanConfigTest` analyzes fixtures with the shared PHPStan rules: level 10 findings, nested `vendor/` directories scanned but not analyzed, and the types `@global` tags declare. - `ScopePhpDependenciesTest` scopes `fixtures/scoping-project/` end to end and checks the output, the generated autoloader, the recipes, the `function_exists()` checks php-scoper adds to guards of global functions, and every failure the scope run reports. Its dompdf render test is in the `slow` group. - - `PhpunitWorkflowTest`, `PluginCheckWorkflowTest`, `ReleaseWorkflowTest` and `SupplyChainAuditWorkflowTest` run steps of `reusable-phpunit.yml`, `reusable-plugin-check.yml`, `reusable-release.yml` and `reusable-supply-chain-audit.yml`. `Support/RunsWorkflowSteps.php` loads a workflow with `symfony/yaml`, finds a step by its job and `name`, and runs its script with `bash -e`, as GitHub Actions does for a step that names no shell. Renaming a tested step breaks its test. + - `ScopedPackagesTest` resolves the packages to scope from inline packages through each link Composer records: a replaced dependency, a provided virtual package, a Composer plugin, a metapackage, and a requirement the root replaces or provides. `Support/RecordsInstalledPackagesTrait.php` writes `installed.json` and `installed.php` for both tests, as Composer does. + - `PhpunitWorkflowTest`, `PluginCheckWorkflowTest`, `ReleaseWorkflowTest` and `SupplyChainAuditWorkflowTest` run steps of `reusable-phpunit.yml`, `reusable-plugin-check.yml`, `reusable-release.yml` and `reusable-supply-chain-audit.yml`. `Support/RunsWorkflowStepsTrait.php` loads a workflow with `symfony/yaml`, finds a step by its job and `name`, and runs its script with `bash -e`, as GitHub Actions does for a step that names no shell. Renaming a tested step breaks its test. - **CI smokes** (`.github/workflows/quality.yml`). Checks that run a shared config as a consumer would: - the PHPCS production profile explains itself with `phpcs -e` and scans `fixtures/plugin-stub/`; - `npm run lint:config` loads each Node baseline against the installed toolchain through `fixtures/node-config/smoke.mjs`; diff --git a/tests/Support/RunsWorkflowSteps.php b/tests/Support/RunsWorkflowStepsTrait.php similarity index 88% rename from tests/Support/RunsWorkflowSteps.php rename to tests/Support/RunsWorkflowStepsTrait.php index 3956915..24daf36 100644 --- a/tests/Support/RunsWorkflowSteps.php +++ b/tests/Support/RunsWorkflowStepsTrait.php @@ -4,10 +4,10 @@ use PHPUnit\Framework\Attributes\After; use PHPUnit\Framework\Attributes\Before; +use Symfony\Component\Filesystem\Filesystem; use Symfony\Component\Yaml\Yaml; -trait RunsWorkflowSteps { - +trait RunsWorkflowStepsTrait { protected string $work_dir; #[Before] @@ -19,15 +19,7 @@ protected function create_work_dir(): void { #[After] protected function remove_work_dir(): void { - $entries = new \RecursiveIteratorIterator( - new \RecursiveDirectoryIterator( $this->work_dir, \FilesystemIterator::SKIP_DOTS ), - \RecursiveIteratorIterator::CHILD_FIRST - ); - foreach ( $entries as $entry ) { - self::assertInstanceOf( \SplFileInfo::class, $entry ); - $entry->isDir() ? \rmdir( $entry->getPathname() ) : \unlink( $entry->getPathname() ); - } - \rmdir( $this->work_dir ); + new Filesystem()->remove( $this->work_dir ); } /** diff --git a/tests/Unit/PhpcsProfilesTest.php b/tests/Unit/PhpcsProfilesTest.php index 8a4c14e..67a78ac 100644 --- a/tests/Unit/PhpcsProfilesTest.php +++ b/tests/Unit/PhpcsProfilesTest.php @@ -4,9 +4,9 @@ use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; +use Symfony\Component\Filesystem\Filesystem; final class PhpcsProfilesTest extends TestCase { - protected const string RULESET_DIR = __DIR__ . '/../../php/quality-assurance'; protected string $scan_dir; @@ -19,15 +19,7 @@ protected function setUp(): void { #[\Override] protected function tearDown(): void { - $entries = new \RecursiveIteratorIterator( - new \RecursiveDirectoryIterator( $this->scan_dir, \FilesystemIterator::SKIP_DOTS ), - \RecursiveIteratorIterator::CHILD_FIRST - ); - foreach ( $entries as $entry ) { - self::assertInstanceOf( \SplFileInfo::class, $entry ); - $entry->isDir() ? \rmdir( $entry->getPathname() ) : \unlink( $entry->getPathname() ); - } - \rmdir( $this->scan_dir ); + new Filesystem()->remove( $this->scan_dir ); } /** diff --git a/tests/Unit/PhpstanConfigTest.php b/tests/Unit/PhpstanConfigTest.php index 06a16fb..779a453 100644 --- a/tests/Unit/PhpstanConfigTest.php +++ b/tests/Unit/PhpstanConfigTest.php @@ -3,9 +3,9 @@ namespace DeepWebSolutions\Config\Tests\Unit; use PHPUnit\Framework\TestCase; +use Symfony\Component\Filesystem\Filesystem; final class PhpstanConfigTest extends TestCase { - protected const string FIXTURE_DIR = __DIR__ . '/../fixtures/phpstan'; public function test_shared_rules_report_implicit_mixed_and_missing_overrides(): void { @@ -35,7 +35,7 @@ public function test_the_path_discovery_adds_a_plugin_s_conventional_files_and_s $config = require \dirname( __DIR__, 2 ) . '/php/quality-assurance/phpstan.dist.neon.php'; } finally { \chdir( $cwd ); - \exec( 'rm -rf ' . \escapeshellarg( $project ) ); + new Filesystem()->remove( $project ); } self::assertSame( diff --git a/tests/Unit/PhpunitWorkflowTest.php b/tests/Unit/PhpunitWorkflowTest.php index d0f60ee..56cca1e 100644 --- a/tests/Unit/PhpunitWorkflowTest.php +++ b/tests/Unit/PhpunitWorkflowTest.php @@ -2,13 +2,12 @@ namespace DeepWebSolutions\Config\Tests\Unit; -use DeepWebSolutions\Config\Tests\Support\RunsWorkflowSteps; +use DeepWebSolutions\Config\Tests\Support\RunsWorkflowStepsTrait; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; final class PhpunitWorkflowTest extends TestCase { - - use RunsWorkflowSteps; + use RunsWorkflowStepsTrait; /** * @return array, string, string}> diff --git a/tests/Unit/PluginCheckWorkflowTest.php b/tests/Unit/PluginCheckWorkflowTest.php index 86b2e42..3996351 100644 --- a/tests/Unit/PluginCheckWorkflowTest.php +++ b/tests/Unit/PluginCheckWorkflowTest.php @@ -2,13 +2,12 @@ namespace DeepWebSolutions\Config\Tests\Unit; -use DeepWebSolutions\Config\Tests\Support\RunsWorkflowSteps; +use DeepWebSolutions\Config\Tests\Support\RunsWorkflowStepsTrait; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; final class PluginCheckWorkflowTest extends TestCase { - - use RunsWorkflowSteps; + use RunsWorkflowStepsTrait; /** * @return array> diff --git a/tests/Unit/ReleaseToolsTest.php b/tests/Unit/ReleaseToolsTest.php index 19f918e..8b45a85 100644 --- a/tests/Unit/ReleaseToolsTest.php +++ b/tests/Unit/ReleaseToolsTest.php @@ -5,7 +5,6 @@ use PHPUnit\Framework\TestCase; final class ReleaseToolsTest extends TestCase { - protected const string DIR = __DIR__ . '/../../php/release'; public function test_the_lock_is_current_with_its_manifest(): void { diff --git a/tests/Unit/ReleaseWorkflowTest.php b/tests/Unit/ReleaseWorkflowTest.php index 7a0d86c..e53f9a2 100644 --- a/tests/Unit/ReleaseWorkflowTest.php +++ b/tests/Unit/ReleaseWorkflowTest.php @@ -2,13 +2,12 @@ namespace DeepWebSolutions\Config\Tests\Unit; -use DeepWebSolutions\Config\Tests\Support\RunsWorkflowSteps; +use DeepWebSolutions\Config\Tests\Support\RunsWorkflowStepsTrait; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; final class ReleaseWorkflowTest extends TestCase { - - use RunsWorkflowSteps; + use RunsWorkflowStepsTrait; /** * @return array, ?string, string}> diff --git a/tests/Unit/ScopePhpDependenciesTest.php b/tests/Unit/ScopePhpDependenciesTest.php index 1b122d6..8e65e1e 100644 --- a/tests/Unit/ScopePhpDependenciesTest.php +++ b/tests/Unit/ScopePhpDependenciesTest.php @@ -4,13 +4,14 @@ use DeepWebSolutions\Config\Composer\ScopePhpDependencies; use DeepWebSolutions\Config\Tests\Support\RecordsInstalledPackagesTrait; +use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\TestCase; use Symfony\Component\Filesystem\Filesystem; +#[CoversClass( ScopePhpDependencies::class )] final class ScopePhpDependenciesTest extends TestCase { - use RecordsInstalledPackagesTrait; protected const string FIXTURE_DIR = __DIR__ . '/../fixtures/scoping-project'; @@ -88,20 +89,34 @@ public function test_non_php_files_ship_with_their_package(): void { } public function test_scoped_autoload_loads_prefixed_classes_and_files_without_global_aliases(): void { - $script = \sprintf( - 'namespace Automattic\WooCommerce\Admin\Settings { abstract class SettingsSection {} } namespace { require %s; require %s; echo json_encode( array( interface_exists( %s ), class_exists( %s ), class_exists( %s ), class_exists( %s ), class_exists( %s ), function_exists( %s ), function_exists( %s ), get_class( %s::format( "Csv" ) ), class_exists( "LegacyRenderer", false ), class_exists( "AcmeLegacy_Config", false ) ) ); }', - \var_export( self::VENDOR_DIR . '/composer/ClassLoader.php', true ), - \var_export( self::scoped_project() . '/vendor-prefixed/scoper-autoload.php', true ), - \var_export( 'Acme\Scoped\Psr\Log\LoggerInterface', true ), - \var_export( 'Acme\Scoped\LegacyRenderer', true ), - \var_export( 'Acme\Scoped\LegacyCanvas', true ), - \var_export( 'Acme\Scoped\AcmeLegacy_Config', true ), - \var_export( 'Acme\Scoped\DI\ContainerBuilder', true ), - \var_export( 'Acme\Scoped\DI\create', true ), - \var_export( 'Acme\Scoped\Acme\Commerce\get_label_prefix', true ), - '\Acme\Scoped\Acme\Commerce\SettingsSection' - ); - \exec( \escapeshellarg( \PHP_BINARY ) . ' -r ' . \escapeshellarg( $script ) . ' 2>&1', $output, $exit_code ); + $project = self::scoped_project(); + $probe = <<<'PHP' + &1', $output, $exit_code ); self::assertSame( 0, $exit_code, \implode( "\n", $output ) ); self::assertSame( '[true,true,true,true,true,true,true,"Acme\\\\Scoped\\\\Acme\\\\Commerce\\\\Formats\\\\Csv",false,false]', \implode( "\n", $output ) ); diff --git a/tests/Unit/SupplyChainAuditWorkflowTest.php b/tests/Unit/SupplyChainAuditWorkflowTest.php index 8c4fb65..62af789 100644 --- a/tests/Unit/SupplyChainAuditWorkflowTest.php +++ b/tests/Unit/SupplyChainAuditWorkflowTest.php @@ -2,13 +2,12 @@ namespace DeepWebSolutions\Config\Tests\Unit; -use DeepWebSolutions\Config\Tests\Support\RunsWorkflowSteps; +use DeepWebSolutions\Config\Tests\Support\RunsWorkflowStepsTrait; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; final class SupplyChainAuditWorkflowTest extends TestCase { - - use RunsWorkflowSteps; + use RunsWorkflowStepsTrait; /** * @return array From e9f5fc5fd8e5c46fe4317e13eb685348e886a337 Mon Sep 17 00:00:00 2001 From: Tony Hegyes Date: Fri, 6 Nov 2026 15:03:53 +0100 Subject: [PATCH 15/29] Pin the PHPCS profile rules consumers rely on The slash hook-name delimiter, the generic-type exclusion and the generated-file patterns had no test row, so removing one left configs' suite green and broke only each consumer's next lint. Nothing checked the set of files a profile scans as a whole, or that the tests profile scans a tests/ directory at all. PhpcsProfilesTest now scans a project laid out as a8csp-configs' exclusions smoke is, with configs' tooling, generated and case-twin paths, and asserts the scanned set; checks that the tests profile reports a tests/ file the production profile skips; and passes a slash-separated hook name and a class-string @param over a string hint. Each row fails when its rule leaves the base ruleset. Port audit A162 (X ruling 186), A712 (X ruling 279). Assisted-by: Claude Code:claude-opus-5-5 --- tests/README.md | 2 +- tests/Unit/PhpcsProfilesTest.php | 40 ++++++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/tests/README.md b/tests/README.md index 5db818f..5e35576 100644 --- a/tests/README.md +++ b/tests/README.md @@ -5,7 +5,7 @@ The tests prove that the shared configs, the scoping pipeline and the reusable w ## Tiers - **Unit** (`tests/Unit/`, `composer test`). PHPUnit tests that run PHPCS, PHPStan, php-scoper and workflow step scripts on fixtures copied to a temporary directory: - - `PhpcsProfilesTest` scans files with both PHPCS profiles: the case-sensitive vendor exclusions, the `index.php` stub waiver, the full `WordPress` standard, and the two ways a project lowers the PHP and WordPress floors. + - `PhpcsProfilesTest` scans files with both PHPCS profiles: the files each profile scans and the tooling, generated and case-sensitive vendor paths it skips, the `index.php` stub waiver, slash-separated hook names, generic docblock types over their native hints, the full `WordPress` standard, and the two ways a project lowers the PHP and WordPress floors. - `PhpstanConfigTest` analyzes fixtures with the shared PHPStan rules: level 10 findings, nested `vendor/` directories scanned but not analyzed, and the types `@global` tags declare. - `ScopePhpDependenciesTest` scopes `fixtures/scoping-project/` end to end and checks the output, the generated autoloader, the recipes, the `function_exists()` checks php-scoper adds to guards of global functions, and every failure the scope run reports. Its dompdf render test is in the `slow` group. - `ScopedPackagesTest` resolves the packages to scope from inline packages through each link Composer records: a replaced dependency, a provided virtual package, a Composer plugin, a metapackage, and a requirement the root replaces or provides. `Support/RecordsInstalledPackagesTrait.php` writes `installed.json` and `installed.php` for both tests, as Composer does. diff --git a/tests/Unit/PhpcsProfilesTest.php b/tests/Unit/PhpcsProfilesTest.php index 67a78ac..0a1276c 100644 --- a/tests/Unit/PhpcsProfilesTest.php +++ b/tests/Unit/PhpcsProfilesTest.php @@ -42,6 +42,46 @@ public function test_profile_excludes_vendor_directories_case_sensitively( strin self::assertSame( array( 'src/Vendor/x.php' ), \array_keys( $this->scan( self::RULESET_DIR . '/' . $profile ) ) ); } + public function test_production_profile_scans_a_project_s_own_files_and_skips_tooling_and_generated_ones(): void { + $scanned = array( 'includes/class-asset.php', 'patterns/example.php', 'src/Asset.php', 'src/Bin/Command.php', 'src/ScriptAsset.php', 'src/Search/Index.php', 'src/Tests/Harness.php', 'src/Vendor/Payout.php' ); + $skipped = array( 'bin/tool.php', 'blocks/build/blocks-manifest.php', 'build/index.asset.php', 'languages/acme-de_DE.l10n.php', 'node_modules/lib.php', 'tests/lib.php', 'vendor/lib.php', 'vendor-prefixed/acme/lib.php' ); + foreach ( array( ...$scanned, ...$skipped ) as $file ) { + if ( ! \is_dir( \dirname( $this->scan_dir . '/' . $file ) ) ) { + \mkdir( \dirname( $this->scan_dir . '/' . $file ), 0777, true ); + } + \file_put_contents( $this->scan_dir . '/' . $file, "scan( self::RULESET_DIR . '/phpcs.dist.xml' ) ); + \sort( $reported ); + + self::assertSame( $scanned, $reported ); + } + + public function test_tests_profile_scans_a_tests_directory_the_production_profile_skips(): void { + \mkdir( $this->scan_dir . '/tests' ); + \file_put_contents( $this->scan_dir . '/tests/x.php', "scan( self::RULESET_DIR . '/phpcs.tests.dist.xml' ) ) ); + self::assertSame( array(), $this->scan( self::RULESET_DIR . '/phpcs.dist.xml' ) ); + } + + public function test_production_profile_accepts_slashes_between_hook_name_levels(): void { + \file_put_contents( $this->scan_dir . '/slashes.php', "scan_dir . '/hyphens.php', "scan( self::RULESET_DIR . '/phpcs.dist.xml' ); + + self::assertArrayNotHasKey( 'slashes.php', $sources ); + self::assertArrayHasKey( 'WordPress.NamingConventions.ValidHookName.UseUnderscores', $sources['hyphens.php'] ?? array() ); + } + + public function test_production_profile_accepts_a_generic_type_over_its_erased_native_hint(): void { + \file_put_contents( $this->scan_dir . '/generic.php', " \$class The class.\n *\n * @return string\n */\nfunction acme_probe( string \$class ): string {\n\treturn \$class;\n}\n" ); + + self::assertArrayNotHasKey( 'Squiz.Commenting.FunctionComment.IncorrectTypeHint', $this->scan( self::RULESET_DIR . '/phpcs.dist.xml' )['generic.php'] ?? array() ); + } + public function test_production_profile_skips_docblock_sniffs_in_patterns_directories(): void { foreach ( array( 'patterns', 'src/Patterns' ) as $dir ) { \mkdir( $this->scan_dir . '/' . $dir, 0777, true ); From e1eed1388ab1932f467a3c55034926c32612bd48 Mon Sep 17 00:00:00 2001 From: Tony Hegyes Date: Fri, 6 Nov 2026 15:07:54 +0100 Subject: [PATCH 16/29] Pin the release workflow's input, archive and provenance checks Several of the release's assertion steps ran under no test. Nothing exercised the input checks: the version-tag pattern, publish from a branch, a .. segment, an absolute path or a slug out of pattern. The secret arms of the forbidden-entry check and the certificate arm that keeps bundled .pem files were untested, the override test looked for one substring of the JSON it writes, and the development-port fallback had no row. The provenance stub printed canned paths without reading its arguments, so the step's head_sha, status, event and conclusion filters could all be deleted with the suite green. ReleaseWorkflowTest now runs the input checks, the secret, node_modules and certificate arms, the override's whole JSON and the port fallback. The fake gh records its arguments and, as the runs API does, answers only the runs of the head_sha it is asked for, piping a canned page through the step's own --jq; rows for a pull_request run, a failed run and another commit's run fail, and each filter's removal fails a row. The harness gains write_archive(), run_step() returns the GitHub env beside the outputs and sets GITHUB_PATH, and write_executable() takes a path below the work directory, so the wp-env stubs use it. Port audit A164 (X ruling 186), A494 (X ruling 240), A497 (X ruling 241). Assisted-by: Claude Code:claude-opus-5-5 --- tests/Support/RunsWorkflowStepsTrait.php | 34 +++- tests/Unit/PhpunitWorkflowTest.php | 23 +-- tests/Unit/PluginCheckWorkflowTest.php | 2 +- tests/Unit/ReleaseWorkflowTest.php | 200 ++++++++++++++------ tests/Unit/SupplyChainAuditWorkflowTest.php | 4 +- 5 files changed, 186 insertions(+), 77 deletions(-) diff --git a/tests/Support/RunsWorkflowStepsTrait.php b/tests/Support/RunsWorkflowStepsTrait.php index 24daf36..0172a24 100644 --- a/tests/Support/RunsWorkflowStepsTrait.php +++ b/tests/Support/RunsWorkflowStepsTrait.php @@ -35,15 +35,18 @@ protected function write_project( array $files ): void { } } - protected function write_executable( string $name, string $script ): void { - \file_put_contents( $this->work_dir . '/bin/' . $name, $script ); - \chmod( $this->work_dir . '/bin/' . $name, 0755 ); + protected function write_executable( string $path, string $script ): void { + if ( ! \is_dir( \dirname( $this->work_dir . '/' . $path ) ) ) { + \mkdir( \dirname( $this->work_dir . '/' . $path ), 0777, true ); + } + \file_put_contents( $this->work_dir . '/' . $path, $script ); + \chmod( $this->work_dir . '/' . $path, 0755 ); } /** * @param array $env * - * @return array{status: int, output: string, outputs: array} + * @return array{status: int, output: string, outputs: array, env: array} */ protected function run_step( string $workflow, string $job, string $name, array $env ): array { $definition = Yaml::parseFile( __DIR__ . '/../../.github/workflows/' . $workflow ); @@ -63,7 +66,9 @@ protected function run_step( string $workflow, string $job, string $name, array $log = $this->work_dir . '/step.log'; \file_put_contents( $this->work_dir . '/step.sh', $script ); - \file_put_contents( $this->work_dir . '/github-output', '' ); + foreach ( array( 'github-output', 'github-env', 'github-path' ) as $file ) { + \file_put_contents( $this->work_dir . '/' . $file, '' ); + } \file_put_contents( $log, '' ); $process = \proc_open( array( 'bash', '-e', $this->work_dir . '/step.sh' ), // GitHub Actions runs a step that names no shell this way, so a script fails here exactly as it fails in CI. @@ -78,22 +83,31 @@ protected function run_step( string $workflow, string $job, string $name, array 'HOME' => $this->work_dir, 'GITHUB_OUTPUT' => $this->work_dir . '/github-output', 'GITHUB_ENV' => $this->work_dir . '/github-env', + 'GITHUB_PATH' => $this->work_dir . '/github-path', ) ); self::assertIsResource( $process ); $status = \proc_close( $process ); $output = (string) \file_get_contents( $log ); - $outputs = array(); - foreach ( \file( $this->work_dir . '/github-output', \FILE_IGNORE_NEW_LINES | \FILE_SKIP_EMPTY_LINES ) ?: array() as $line ) { - $pair = \explode( '=', $line, 2 ); - $outputs[ $pair[0] ] = $pair[1] ?? ''; + $files = array( + 'outputs' => 'github-output', + 'env' => 'github-env', + ); + $pairs = array(); + foreach ( $files as $key => $file ) { + $pairs[ $key ] = array(); + foreach ( \file( $this->work_dir . '/' . $file, \FILE_IGNORE_NEW_LINES | \FILE_SKIP_EMPTY_LINES ) ?: array() as $line ) { + $pair = \explode( '=', $line, 2 ); + $pairs[ $key ][ $pair[0] ] = $pair[1] ?? ''; + } } return array( 'status' => $status, 'output' => $output, - 'outputs' => $outputs, + 'outputs' => $pairs['outputs'], + 'env' => $pairs['env'], ); } } diff --git a/tests/Unit/PhpunitWorkflowTest.php b/tests/Unit/PhpunitWorkflowTest.php index 56cca1e..ff93b7b 100644 --- a/tests/Unit/PhpunitWorkflowTest.php +++ b/tests/Unit/PhpunitWorkflowTest.php @@ -10,29 +10,29 @@ final class PhpunitWorkflowTest extends TestCase { use RunsWorkflowStepsTrait; /** - * @return array, string, string}> + * @return array, string, array}> */ public static function provide_configs(): array { return array( - 'no-config' => array( array(), '', "WP_ENV_PHP_VERSION=8.5\n" ), - 'unpinned' => array( array( '.wp-env.json' => '{ "core": null }' ), '', "WP_ENV_PHP_VERSION=8.5\n" ), - 'pinned' => array( array( '.wp-env.json' => '{ "phpVersion": "7.4" }' ), '', '' ), + 'no-config' => array( array(), '', array( 'WP_ENV_PHP_VERSION' => '8.5' ) ), + 'unpinned' => array( array( '.wp-env.json' => '{ "core": null }' ), '', array( 'WP_ENV_PHP_VERSION' => '8.5' ) ), + 'pinned' => array( array( '.wp-env.json' => '{ "phpVersion": "7.4" }' ), '', array() ), 'pinned-in-the-override' => array( array( '.wp-env.json' => '{}', '.wp-env.override.json' => '{ "phpVersion": "7.4" }', ), '', - '', + array(), ), - 'pinned-for-one-environment' => array( array( '.wp-env.json' => '{ "env": { "tests": { "phpVersion": "7.4" } } }' ), '', '' ), + 'pinned-for-one-environment' => array( array( '.wp-env.json' => '{ "env": { "tests": { "phpVersion": "7.4" } } }' ), '', array() ), 'pinned-in-the-named-config' => array( array( '.wp-env.json' => '{}', '.wp-env.php74.json' => '{ "phpVersion": "7.4" }', ), '.wp-env.php74.json', - '', + array(), ), 'pinned-beside-the-named-override' => array( array( @@ -40,7 +40,7 @@ public static function provide_configs(): array { '.wp-env.php74.override.json' => '{ "phpVersion": "7.4" }', ), '.wp-env.php74.json', - '', + array(), ), 'unpinned-in-the-named-config' => array( array( @@ -48,16 +48,17 @@ public static function provide_configs(): array { '.wp-env.tests.json' => '{}', ), '.wp-env.tests.json', - "WP_ENV_PHP_VERSION=8.5\n", + array( 'WP_ENV_PHP_VERSION' => '8.5' ), ), ); } /** * @param array $files + * @param array $exported */ #[DataProvider( 'provide_configs' )] - public function test_the_site_runs_on_php_version_only_where_the_wp_env_config_pins_no_php( array $files, string $config_file, string $exported ): void { + public function test_the_site_runs_on_php_version_only_where_the_wp_env_config_pins_no_php( array $files, string $config_file, array $exported ): void { $this->write_project( $files ); $result = $this->run_step( @@ -71,6 +72,6 @@ public function test_the_site_runs_on_php_version_only_where_the_wp_env_config_p ); self::assertSame( 0, $result['status'], $result['output'] ); - self::assertSame( $exported, \is_file( $this->work_dir . '/github-env' ) ? (string) \file_get_contents( $this->work_dir . '/github-env' ) : '' ); + self::assertSame( $exported, $result['env'] ); } } diff --git a/tests/Unit/PluginCheckWorkflowTest.php b/tests/Unit/PluginCheckWorkflowTest.php index 3996351..ff9b8dc 100644 --- a/tests/Unit/PluginCheckWorkflowTest.php +++ b/tests/Unit/PluginCheckWorkflowTest.php @@ -75,7 +75,7 @@ protected static function in_each_workflow( array $rows ): array { } /** - * @return array{status: int, output: string, outputs: array} + * @return array{status: int, output: string, outputs: array, env: array} */ protected function resolve( string $workflow, string $job, string $wp_org, string $exclude_checks ): array { return $this->run_step( diff --git a/tests/Unit/ReleaseWorkflowTest.php b/tests/Unit/ReleaseWorkflowTest.php index e53f9a2..3624613 100644 --- a/tests/Unit/ReleaseWorkflowTest.php +++ b/tests/Unit/ReleaseWorkflowTest.php @@ -47,17 +47,16 @@ public function test_the_main_plugin_file_is_the_one_root_php_file_with_a_plugin $this->write_project( $files ); $result = $this->run_build_step( 'Find the main plugin file', array() ); - $env = \is_file( $this->work_dir . '/github-env' ) ? (string) \file_get_contents( $this->work_dir . '/github-env' ) : ''; if ( \is_null( $expected ) ) { self::assertSame( 1, $result['status'], $result['output'] ); self::assertMatchesRegularExpression( '/^::error::.*' . \preg_quote( $named, '/' ) . '/m', $result['output'] ); - self::assertSame( '', $env ); + self::assertSame( array(), $result['env'] ); return; } self::assertSame( 0, $result['status'], $result['output'] ); - self::assertSame( "ENTRY_FILE=$expected\n", $env ); + self::assertSame( array( 'ENTRY_FILE' => $expected ), $result['env'] ); } /** @@ -306,17 +305,16 @@ public static function provide_archive_entries(): array { 'hidden-tests-ruleset' => array( '.phpcs.tests.xml', 1 ), 'dist-suffixed-ruleset' => array( 'phpcs.xml.dist', 1 ), 'dist-named-phpunit' => array( 'phpunit.dist.xml', 1 ), + 'node-modules' => array( 'node_modules/x', 1 ), + 'secret-env-file' => array( '.env', 1 ), + 'nested-auth-json' => array( 'nested/auth.json', 1 ), + 'bundled-certificate' => array( 'vendor/acme/ca.pem', 0 ), ); } #[DataProvider( 'provide_archive_entries' )] public function test_archive_must_not_ship_development_files( string $entry, int $expected_status ): void { - $stage = $this->work_dir . '/stage/acme-plugin'; - \mkdir( \dirname( "$stage/$entry" ), 0777, true ); - \file_put_contents( "$stage/acme-plugin.php", "work_dir . '/project/acme-plugin.zip' ) ), $output, $zip_status ); - self::assertSame( 0, $zip_status ); + $this->write_archive( array( 'acme-plugin.php', $entry ) ); $result = $this->run_build_step( 'Assert forbidden entries absent', array() ); @@ -342,15 +340,7 @@ public static function provide_scoped_archives(): array { */ #[DataProvider( 'provide_scoped_archives' )] public function test_the_archive_must_ship_every_scoped_package( array $archived, bool $scoped, int $expected_status, string $named ): void { - $stage = $this->work_dir . '/stage/acme-plugin'; - foreach ( $archived as $file ) { - if ( ! \is_dir( \dirname( "$stage/$file" ) ) ) { - \mkdir( \dirname( "$stage/$file" ), 0777, true ); - } - \file_put_contents( "$stage/$file", "work_dir . '/project/acme-plugin.zip' ) ), $output, $zip_status ); - self::assertSame( 0, $zip_status ); + $this->write_archive( $archived ); if ( $scoped ) { $this->write_project( array( 'vendor-prefixed/acme/library/composer.json' => '{"name": "acme/library"}' ) ); } @@ -364,23 +354,33 @@ public function test_the_archive_must_ship_every_scoped_package( array $archived } /** - * @return array + * @return array>|null, int, string}> */ public static function provide_provenance_runs(): array { $required = '[".github/workflows/tests.yml", ".github/workflows/quality.yml"]'; + $tests = self::workflow_run( '.github/workflows/tests.yml' ); return array( - 'every-required-workflow-passed' => array( $required, ".github/workflows/quality.yml\n.github/workflows/tests.yml\n", 0, '' ), - 'one-required-workflow-missing' => array( $required, ".github/workflows/tests.yml\n", 1, '.github/workflows/quality.yml' ), - 'required-workflows-not-a-list' => array( '".github/workflows/tests.yml"', '', 1, 'non-empty JSON array' ), - 'actions-api-unavailable' => array( $required, 'FAIL', 1, 'Unable to list' ), + 'every-required-workflow-passed' => array( $required, array( $tests, self::workflow_run( '.github/workflows/quality.yml' ) ), 0, '' ), + 'one-required-workflow-missing' => array( $required, array( $tests ), 1, '.github/workflows/quality.yml' ), + 'pull-request-run' => array( $required, array( $tests, self::workflow_run( '.github/workflows/quality.yml', array( 'event' => 'pull_request' ) ) ), 1, '.github/workflows/quality.yml' ), + 'failed-run' => array( $required, array( $tests, self::workflow_run( '.github/workflows/quality.yml', array( 'conclusion' => 'failure' ) ) ), 1, '.github/workflows/quality.yml' ), + 'run-of-another-commit' => array( $required, array( $tests, self::workflow_run( '.github/workflows/quality.yml', array( 'head_sha' => 'def456' ) ) ), 1, '.github/workflows/quality.yml' ), + 'required-workflows-not-a-list' => array( '".github/workflows/tests.yml"', array(), 1, 'non-empty JSON array' ), + 'actions-api-unavailable' => array( $required, null, 1, 'Unable to list' ), ); } + /** + * @param list>|null $runs + */ #[DataProvider( 'provide_provenance_runs' )] - public function test_a_release_needs_a_successful_push_run_of_every_required_workflow( string $required, string $runs, int $expected_status, string $named ): void { - $this->write_executable( 'gh', "#!/usr/bin/env bash\nif [ \"\$(cat \"\$HOME/runs\")\" = FAIL ]; then exit 1; fi\ncat \"\$HOME/runs\"\n" ); - \file_put_contents( $this->work_dir . '/runs', $runs ); + public function test_a_release_needs_a_successful_push_run_of_every_required_workflow( string $required, ?array $runs, int $expected_status, string $named ): void { + // Like the runs API, the stub answers only the runs of the head_sha it is asked for, and it filters them through the step's own --jq. + $this->write_executable( 'bin/gh', "#!/usr/bin/env bash\nprintf '%s\\n' \"\$@\" > \"\$HOME/gh-arguments\"\nif [ ! -f \"\$HOME/runs.json\" ]; then exit 1; fi\nwhile [ \$# -gt 0 ]; do\n\tcase \"\$1\" in\n\t\t--raw-field) case \"\$2\" in head_sha=*) sha=\"\${2#head_sha=}\" ;; esac; shift 2 ;;\n\t\t--jq) filter=\"\$2\"; shift 2 ;;\n\t\t*) shift ;;\n\tesac\ndone\njq --arg sha \"\$sha\" '.workflow_runs |= map( select( .head_sha == \$sha ) )' \"\$HOME/runs.json\" | jq -r \"\$filter\"\n" ); + if ( ! \is_null( $runs ) ) { + \file_put_contents( $this->work_dir . '/runs.json', (string) \json_encode( array( 'workflow_runs' => $runs ) ) ); + } $result = $this->run_step( 'reusable-release.yml', @@ -398,6 +398,48 @@ public function test_a_release_needs_a_successful_push_run_of_every_required_wor if ( '' !== $named ) { self::assertMatchesRegularExpression( '/^::error::.*' . \preg_quote( $named, '/' ) . '/m', $result['output'] ); } + if ( \is_file( $this->work_dir . '/gh-arguments' ) ) { + $arguments = \file( $this->work_dir . '/gh-arguments', \FILE_IGNORE_NEW_LINES ) ?: array(); + self::assertContains( 'repos/acme/acme-plugin/actions/runs', $arguments ); + self::assertContains( 'head_sha=abc123', $arguments ); + self::assertContains( 'status=success', $arguments ); + } + } + + /** + * @return array + */ + public static function provide_release_inputs(): array { + return array( + 'version-tag' => array( 'tag', 'v1.2.3', '.', 'true', 'acme-plugin', 0, '1.2.3' ), + 'tag-with-a-leading-zero' => array( 'tag', 'v1.02.3', '.', 'true', 'acme-plugin', 1, 'no leading zeros' ), + 'branch-with-publish' => array( 'branch', 'trunk', '.', 'true', 'acme-plugin', 1, 'is not a tag' ), + 'branch-without-publish' => array( 'branch', 'trunk', 'plugins/acme-plugin', 'false', 'acme-plugin', 0, '' ), + 'dot-dot-path-segment' => array( 'tag', 'v1.2.3', 'plugins/../acme-plugin', 'true', 'acme-plugin', 1, 'must not contain a .. path segment' ), + 'absolute-path' => array( 'tag', 'v1.2.3', '/tmp/acme-plugin', 'true', 'acme-plugin', 1, 'must be relative' ), + 'slug-out-of-pattern' => array( 'tag', 'v1.2.3', '.', 'true', 'Acme_Plugin', 1, 'plugin-slug must match' ), + ); + } + + #[DataProvider( 'provide_release_inputs' )] + public function test_a_release_runs_from_a_version_tag_with_a_safe_slug_and_path( string $ref_type, string $ref_name, string $project_path, string $publish, string $slug, int $expected_status, string $expected ): void { + $result = $this->run_build_step( + 'Enforce release inputs', + array( + 'REF_TYPE' => $ref_type, + 'REF_NAME' => $ref_name, + 'PROJECT_PATH' => $project_path, + 'PUBLISH' => $publish, + 'PLUGIN_SLUG' => $slug, + ) + ); + + self::assertSame( $expected_status, $result['status'], $result['output'] ); + if ( 1 === $expected_status ) { + self::assertMatchesRegularExpression( '/^::error::.*' . \preg_quote( $expected, '/' ) . '/m', $result['output'] ); + } else { + self::assertSame( array( 'tag-version' => $expected ), $result['outputs'] ); + } } /** @@ -415,7 +457,7 @@ public static function provide_changelogs(): array { #[DataProvider( 'provide_changelogs' )] public function test_the_release_notes_are_the_version_s_changelog_section( ?string $changelog, int $expected_status, string $notes ): void { - $this->write_executable( 'gh', "#!/usr/bin/env bash\nprintf '%s\\n' \"\$@\" > \"\$HOME/gh-arguments\"\n" ); + $this->write_executable( 'bin/gh', "#!/usr/bin/env bash\nprintf '%s\\n' \"\$@\" > \"\$HOME/gh-arguments\"\n" ); if ( ! \is_null( $changelog ) ) { $this->write_project( array( 'CHANGELOG.md' => $changelog ) ); } @@ -455,15 +497,11 @@ public function test_the_release_notes_are_the_version_s_changelog_section( ?str } public function test_required_files_are_found_in_a_large_archive(): void { - $stage = $this->work_dir . '/stage/acme-plugin'; - \mkdir( "$stage/assets", 0777, true ); - \file_put_contents( "$stage/main.php", "work_dir . '/project/acme-plugin.zip' ); - \exec( \sprintf( 'cd %s && zip -q %s acme-plugin/main.php && zip -qr %s acme-plugin/assets', \escapeshellarg( \dirname( $stage ) ), $zip, $zip ), $output, $zip_status ); - self::assertSame( 0, $zip_status ); + $this->write_archive( $files ); $result = $this->run_build_step( 'Assert required files present', array( 'WP_ORG' => 'false' ) ); @@ -482,12 +520,7 @@ public static function provide_archived_main_files(): array { #[DataProvider( 'provide_archived_main_files' )] public function test_the_archive_must_ship_the_main_plugin_file( string $archived, int $expected_status ): void { - $stage = $this->work_dir . '/stage/acme-plugin'; - \mkdir( $stage, 0777, true ); - \file_put_contents( "$stage/$archived", "work_dir . '/project/acme-plugin.zip' ); - \exec( \sprintf( 'cd %s && zip -q %s %s', \escapeshellarg( \dirname( $stage ) ), $zip, \escapeshellarg( "acme-plugin/$archived" ) ), $output, $zip_status ); - self::assertSame( 0, $zip_status ); + $this->write_archive( array( $archived ) ); $result = $this->run_build_step( 'Assert required files present', array( 'WP_ORG' => 'false' ) ); @@ -524,7 +557,7 @@ public function test_a_plugin_that_does_not_require_configs_gets_no_release_tool } public function test_the_archive_command_runs_from_the_release_tools(): void { - $this->write_executable( 'wp', "#!/usr/bin/env bash\necho \"wp \$*\" >> \"\$HOME/calls\"\n" ); + $this->write_executable( 'bin/wp', "#!/usr/bin/env bash\necho \"wp \$*\" >> \"\$HOME/calls\"\n" ); $result = $this->run_build_step( 'Create plugin archive', array() ); @@ -544,8 +577,7 @@ public static function provide_wp_env_config_files(): array { #[DataProvider( 'provide_wp_env_config_files' )] public function test_wp_env_gets_a_config_flag_only_for_a_named_file( string $config_file, string $expected_arguments ): void { - $this->write_project( array( 'node_modules/.bin/wp-env' => "#!/usr/bin/env bash\necho \"\$*\" > wp-env-arguments\n" ) ); - \chmod( $this->work_dir . '/project/node_modules/.bin/wp-env', 0755 ); + $this->write_executable( 'project/node_modules/.bin/wp-env', "#!/usr/bin/env bash\necho \"\$*\" > wp-env-arguments\n" ); $result = $this->run_step( 'reusable-release.yml', 'test', 'Start wp-env', array( 'WP_ENV_CONFIG_FILE' => $config_file ) ); @@ -567,8 +599,43 @@ public function test_artifact_redirect_defaults_to_the_standard_config(): void { ) ); + $mapping = array( 'wp-content/plugins/acme-plugin' => '/built/acme-plugin' ); + self::assertSame( 0, $result['status'], $result['output'] ); + self::assertJsonStringEqualsJsonString( + (string) \json_encode( + array( + 'core' => null, + 'mappings' => $mapping, + 'env' => array( + 'development' => array( 'mappings' => $mapping ), + 'tests' => array( 'mappings' => $mapping ), + ), + ) + ), + (string) \file_get_contents( $this->work_dir . '/project/.wp-env.override.json' ) + ); + } + + /** + * @return array + */ + public static function provide_wp_env_ports(): array { + return array( + 'development-port' => array( '{ "port": 8811, "env": { "development": { "port": 8822 } } }', '8822' ), + 'root-port' => array( '{ "port": 8811 }', '8811' ), + 'no-port' => array( '{}', '8888' ), + ); + } + + #[DataProvider( 'provide_wp_env_ports' )] + public function test_the_site_is_requested_on_the_config_s_development_port( string $config, string $port ): void { + $this->write_project( array( '.wp-env.json' => $config ) ); + $this->write_executable( 'bin/curl', "#!/usr/bin/env bash\nprintf '%s\\n' \"\$@\" > \"\$HOME/curl-arguments\"\n" ); + + $result = $this->run_step( 'reusable-release.yml', 'test', 'Assert the site serves', array( 'WP_ENV_CONFIG_FILE' => '' ) ); + self::assertSame( 0, $result['status'], $result['output'] ); - self::assertStringContainsString( '/built/acme-plugin', (string) \file_get_contents( $this->work_dir . '/project/.wp-env.override.json' ) ); + self::assertContains( "http://localhost:$port/", \file( $this->work_dir . '/curl-arguments', \FILE_IGNORE_NEW_LINES ) ?: array() ); } /** @@ -584,9 +651,8 @@ public static function provide_expected_versions(): array { #[DataProvider( 'provide_expected_versions' )] public function test_the_active_plugin_must_carry_the_built_version( string $expected_version, int $expected_status ): void { // Like wp-env's own `run`, the stub consumes the first `--` and appends the arguments after it to the command. - $this->write_project( array( 'node_modules/.bin/wp-env' => "#!/usr/bin/env bash\nuntil [ \"\$1\" = run ]; do shift; done\nshift 2\ncommand=()\nfor argument in \"\$@\"; do\n\tif [ -z \"\$dashed\" ] && [ \"\$argument\" = -- ]; then dashed=1; continue; fi\n\tcommand+=( \"\$argument\" )\ndone\nexec \"\${command[@]}\"\n" ) ); - \chmod( $this->work_dir . '/project/node_modules/.bin/wp-env', 0755 ); - $this->write_executable( 'wp', "#!/usr/bin/env bash\ncase \"\$*\" in\n\t'plugin activate acme-plugin') ;;\n\t'plugin get acme-plugin --field=version') echo 1.2.3 ;;\n\t*) echo \"Error: The '\$3' plugin could not be found.\" >&2; exit 1 ;;\nesac\n" ); + $this->write_executable( 'project/node_modules/.bin/wp-env', "#!/usr/bin/env bash\nuntil [ \"\$1\" = run ]; do shift; done\nshift 2\ncommand=()\nfor argument in \"\$@\"; do\n\tif [ -z \"\$dashed\" ] && [ \"\$argument\" = -- ]; then dashed=1; continue; fi\n\tcommand+=( \"\$argument\" )\ndone\nexec \"\${command[@]}\"\n" ); + $this->write_executable( 'bin/wp', "#!/usr/bin/env bash\ncase \"\$*\" in\n\t'plugin activate acme-plugin') ;;\n\t'plugin get acme-plugin --field=version') echo 1.2.3 ;;\n\t*) echo \"Error: The '\$3' plugin could not be found.\" >&2; exit 1 ;;\nesac\n" ); $result = $this->run_step( 'reusable-release.yml', @@ -604,25 +670,53 @@ public function test_the_active_plugin_must_carry_the_built_version( string $exp self::assertStringContainsString( "Active plugin version: 1.2.3 (expected $expected_version)", $result['output'] ); } + /** + * @param list $files + */ + protected function write_archive( array $files ): void { + $stage = $this->work_dir . '/stage/acme-plugin'; + foreach ( $files as $file ) { + if ( ! \is_dir( \dirname( "$stage/$file" ) ) ) { + \mkdir( \dirname( "$stage/$file" ), 0777, true ); + } + \file_put_contents( "$stage/$file", "work_dir . '/stage/entries', \implode( "\n", \array_map( static fn ( string $file ): string => "acme-plugin/$file", $files ) ) . "\n" ); + \exec( \sprintf( 'cd %s && zip -q %s -@ < entries', \escapeshellarg( $this->work_dir . '/stage' ), \escapeshellarg( $this->work_dir . '/project/acme-plugin.zip' ) ), $output, $zip_status ); + self::assertSame( 0, $zip_status, \implode( "\n", $output ) ); + } + + /** + * @param array $overrides + * + * @return array + */ + protected static function workflow_run( string $path, array $overrides = array() ): array { + return $overrides + array( + 'path' => $path, + 'event' => 'push', + 'conclusion' => 'success', + 'head_sha' => 'abc123', + ); + } + // Answers `composer config vendor-dir` as Composer's default does, and records every other call. protected function write_fake_composer(): void { - $this->write_executable( 'composer', "#!/usr/bin/env bash\nif [ \"\$1\" = config ]; then echo vendor; exit 0; fi\necho \"composer \$*\" >> \"\$HOME/calls\"\n" ); + $this->write_executable( 'bin/composer', "#!/usr/bin/env bash\nif [ \"\$1\" = config ]; then echo vendor; exit 0; fi\necho \"composer \$*\" >> \"\$HOME/calls\"\n" ); } /** * @return array */ protected function release_tools_env(): array { - return array( - 'RUNNER_TEMP' => $this->work_dir . '/runner-temp', - 'GITHUB_PATH' => $this->work_dir . '/github-path', - ); + return array( 'RUNNER_TEMP' => $this->work_dir . '/runner-temp' ); } /** * @param array $env * - * @return array{status: int, output: string, outputs: array} + * @return array{status: int, output: string, outputs: array, env: array} */ protected function run_build_step( string $name, array $env ): array { return $this->run_step( diff --git a/tests/Unit/SupplyChainAuditWorkflowTest.php b/tests/Unit/SupplyChainAuditWorkflowTest.php index 62af789..a555568 100644 --- a/tests/Unit/SupplyChainAuditWorkflowTest.php +++ b/tests/Unit/SupplyChainAuditWorkflowTest.php @@ -49,11 +49,11 @@ public function test_failing_mode_fails_on_advisories(): void { } /** - * @return array{status: int, output: string, outputs: array} + * @return array{status: int, output: string, outputs: array, env: array} */ protected function audit( ?string $fixture, string $fail_on_findings ): array { $report = \is_null( $fixture ) ? '/dev/null' : __DIR__ . '/../fixtures/composer-audit/' . $fixture; - $this->write_executable( 'composer', "#!/usr/bin/env bash\ncat " . \escapeshellarg( $report ) . "\nexit 1\n" ); + $this->write_executable( 'bin/composer', "#!/usr/bin/env bash\ncat " . \escapeshellarg( $report ) . "\nexit 1\n" ); return $this->run_step( 'reusable-supply-chain-audit.yml', From 2902de1ff3d6159b17140b96808c41a72ca5ec69 Mon Sep 17 00:00:00 2001 From: Tony Hegyes Date: Fri, 6 Nov 2026 15:09:01 +0100 Subject: [PATCH 17/29] Assert what each Node baseline sets, not only that it loads The lint:config smoke compiled the tsconfig probe with --noEmit, which masks the base's own noEmit, and checked no other delta; it never looked for a @wordpress/stylelint-config rule in the printed config, a Playwright rule on a tests/e2e file, or the build/ and vendor/ ignores. A regression in any of them passed the smoke. The smoke compiles the probe without the flag and checks it writes no JavaScript, reads tsc --showConfig and checks each of the base's deltas, checks that the printed Stylelint config carries font-weight-notation, which @wordpress/stylelint-config sets, and that tests/e2e/probe.js gets the playwright/ rules while build/x.js and vendor/x.js are ignored. Port audit A492 (X ruling 240). Assisted-by: Claude Code:claude-opus-5-5 --- tests/fixtures/node-config/smoke.mjs | 58 ++++++++++++++++++++++++---- 1 file changed, 51 insertions(+), 7 deletions(-) diff --git a/tests/fixtures/node-config/smoke.mjs b/tests/fixtures/node-config/smoke.mjs index b8c276f..1b81a6f 100644 --- a/tests/fixtures/node-config/smoke.mjs +++ b/tests/fixtures/node-config/smoke.mjs @@ -5,7 +5,7 @@ */ import { execFileSync } from 'node:child_process'; -import { readFileSync } from 'node:fs'; +import { existsSync, readFileSync } from 'node:fs'; import { createRequire } from 'node:module'; import { resolve } from 'node:path'; import { pathToFileURL } from 'node:url'; @@ -81,6 +81,24 @@ probes.push( [ throw new Error( `${ name } is linted as a script` ); } } + + const e2e = await eslint.calculateConfigForFile( + resolve( 'tests/e2e/probe.js' ) + ); + if ( + ! Object.keys( e2e?.rules ?? {} ).some( ( rule ) => + rule.startsWith( 'playwright/' ) + ) + ) { + throw new Error( + 'tests/e2e/probe.js does not get the Playwright rules' + ); + } + for ( const path of [ 'build/x.js', 'vendor/x.js' ] ) { + if ( ! ( await eslint.isPathIgnored( resolve( path ) ) ) ) { + throw new Error( `${ path } is linted` ); + } + } }, ] ); @@ -111,6 +129,9 @@ probes.push( [ if ( config.rules[ 'selector-class-pattern' ] ) { throw new Error( 'selector-class-pattern is not turned off' ); } + if ( ! config.rules[ 'font-weight-notation' ] ) { + throw new Error( '@wordpress/stylelint-config is not extended' ); + } }, ] ); @@ -149,12 +170,35 @@ probes.push( [ probes.push( [ 'tsconfig.base.json', - () => - execFileSync( - './node_modules/.bin/tsc', - [ '--noEmit', '-p', 'tests/fixtures/node-config/tsconfig.json' ], - { stdio: 'pipe' } - ), + () => { + const project = [ '-p', 'tests/fixtures/node-config/tsconfig.json' ]; + // The base's noEmit stands in for the flag, so compiling the probe writes no JavaScript. + execFileSync( './node_modules/.bin/tsc', project, { stdio: 'pipe' } ); + if ( existsSync( 'tests/fixtures/node-config/probe.js' ) ) { + throw new Error( 'tsc emitted probe.js' ); + } + const options = JSON.parse( + execFileSync( + './node_modules/.bin/tsc', + [ '--showConfig', ...project ], + { stdio: 'pipe' } + ) + ).compilerOptions; + const deltas = { + jsx: 'react-jsx', + noImplicitReturns: true, + noFallthroughCasesInSwitch: true, + skipLibCheck: true, + isolatedModules: true, + resolveJsonModule: true, + noEmit: true, + }; + for ( const [ option, value ] of Object.entries( deltas ) ) { + if ( value !== options[ option ] ) { + throw new Error( `${ option } is ${ options[ option ] }` ); + } + } + }, ] ); probes.push( [ From 030f4e4b3744d08b87982de1da8b5415d4f93487 Mon Sep 17 00:00:00 2001 From: Tony Hegyes Date: Fri, 6 Nov 2026 15:09:14 +0100 Subject: [PATCH 18/29] Mutate only the code PHPUnit's coverage source lists Infection mutated php/phpstan, which PHPUnit's coverage source omits and only a PHPStan subprocess runs, so no PHPUnit process covers its code and every mutant there counted as uncovered, although PhpstanConfigTest's @global row pins the visitor. Infection's source now matches PHPUnit's, and tests/README says why php/phpstan stays out. Port audit A163 (X ruling 186). Assisted-by: Claude Code:claude-opus-5-5 --- infection.json | 1 - tests/README.md | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/infection.json b/infection.json index 574d330..dc7392a 100644 --- a/infection.json +++ b/infection.json @@ -4,7 +4,6 @@ "directories": [ "php/composer", "php/php-scoper", - "php/phpstan", "php/quality-assurance" ] }, diff --git a/tests/README.md b/tests/README.md index 5e35576..de61f2d 100644 --- a/tests/README.md +++ b/tests/README.md @@ -19,7 +19,7 @@ The tests prove that the shared configs, the scoping pipeline and the reusable w ## Mutation testing -Mutation testing is opt-in. `composer test:unit:mutation` runs Infection over `php/` with the configuration in `infection.json`, skipping the `slow` group, and reports the mutation score; no gate or floor enforces it. `composer test:all` runs the unit suite and then Infection, and `composer quality-check:all` runs the PHP lint before both. +Mutation testing is opt-in. `composer test:unit:mutation` runs Infection over the directories PHPUnit's coverage source lists, with the configuration in `infection.json`, skipping the `slow` group, and reports the mutation score; no gate or floor enforces it. `php/phpstan/` stays out, because only a PHPStan subprocess runs its visitor, which `PhpstanConfigTest` pins. `composer test:all` runs the unit suite and then Infection, and `composer quality-check:all` runs the PHP lint before both. ## Not covered here From 40bd9cf51dfc820a5bbe1a2f5fd4d241d7b4e983 Mon Sep 17 00:00:00 2001 From: Tony Hegyes Date: Fri, 6 Nov 2026 15:09:34 +0100 Subject: [PATCH 19/29] Name the PHPUnit workflow's wp-env branch as untested here reusable-phpunit.yml starts wp-env for a consumer's Integration lane, a branch no job in this repository runs, and neither AGENTS.md nor tests/README said so. Porting a8csp-configs' wp-env consumer fixture and job would cover it at the cost of a second plugin-shaped fixture and a Docker job in configs' CI, while every consumer's Integration lane already runs the branch and a pin-bump pull request catches a break. AGENTS.md Limitations and tests/README's "Not covered here" now name it. Port audit A713 (X ruling 279). Assisted-by: Claude Code:claude-opus-5-5 --- AGENTS.md | 1 + tests/README.md | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 5e97b80..9be8963 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -57,3 +57,4 @@ zizmor --config .github/zizmor.yml .github/ - `reusable-playwright-e2e.yml` and a full `reusable-release.yml` run have no target here: CI checks them with actionlint and zizmor, and the unit tests run the release's assertion steps. - `symfony/filesystem` and `symfony/finder` stay a major behind their latest release, because `humbug/php-scoper` caps them. - The release's PHP-floor check reads a strict `>` lower bound as inclusive. +- `reusable-phpunit.yml`'s wp-env branch runs in no job here: every consumer's Integration lane runs it, and a pull request that moves a consumer's pin catches a break. diff --git a/tests/README.md b/tests/README.md index de61f2d..3048e40 100644 --- a/tests/README.md +++ b/tests/README.md @@ -23,7 +23,7 @@ Mutation testing is opt-in. `composer test:unit:mutation` runs Infection over th ## Not covered here -This repository's own CI runs the PHP lint, PHP syntax, PHPUnit, scripts lint, supply-chain audit, block.json, Plugin Check, workflow checks and CodeQL workflows. `reusable-playwright-e2e.yml` and `reusable-release.yml` need a plugin to act on, so CI checks them only with actionlint and zizmor, and the unit tests run the release's assertion steps; a full run of either happens first in a consumer's CI. +This repository's own CI runs the PHP lint, PHP syntax, PHPUnit, scripts lint, supply-chain audit, block.json, Plugin Check, workflow checks and CodeQL workflows. `reusable-playwright-e2e.yml` and `reusable-release.yml` need a plugin to act on, so CI checks them only with actionlint and zizmor, and the unit tests run the release's assertion steps; a full run of either happens first in a consumer's CI. `reusable-phpunit.yml`'s wp-env branch runs in no job here either: every consumer's Integration lane runs it, and a pull request that moves a consumer's pin catches a break. ## Fixtures From 0e768c8cbf37e9f98d45cc5e2ea27263b6772393 Mon Sep 17 00:00:00 2001 From: Tony Hegyes Date: Fri, 6 Nov 2026 15:35:48 +0100 Subject: [PATCH 20/29] Prefix a namespace string whose parent namespace stays global The namespace-string patcher left a string alone when its name or that name's parent matched an excluded namespace, so it could skip a symbol in a host namespace. A string that ends in a separator names a namespace, though, and php-scoper's exclusions match a namespace exactly: with Acme kept global, php-scoper still prefixed the classes of a scoped Acme\Commerce, while the patcher left 'Acme\Commerce\' unprefixed, so class names built from it resolved to no class. The patcher checks the parent only for a name the string does not follow with a separator. A row scopes a package whose root sits below a namespace a stubs file declares, and fails without the change. Review of the branch; follows up A153 (X ruling 184). Assisted-by: Claude Code:claude-opus-5-5 --- php/php-scoper/scoper-base.inc.php | 7 ++++--- tests/Unit/ScopePhpDependenciesTest.php | 11 +++++++++++ 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/php/php-scoper/scoper-base.inc.php b/php/php-scoper/scoper-base.inc.php index a821625..cb0b21c 100644 --- a/php/php-scoper/scoper-base.inc.php +++ b/php/php-scoper/scoper-base.inc.php @@ -117,9 +117,10 @@ if ( 1 === preg_match( $scoped, $body ) || 1 !== preg_match( $own, $body, $match ) ) { continue; } - // A string that names a namespace php-scoper keeps global, or a symbol in one, names the host's symbol. - $named = 1 === preg_match( '/^\\\\{0,2}((?:[\w\x80-\xff]+\\\\{1,2})*[\w\x80-\xff]+)/', $body, $name_match ) ? (string) preg_replace( '/\\\\{2}/', '\\', $name_match[1] ) : ''; - if ( array_any( $exclude_namespaces, static fn ( string $pattern ): bool => 1 === preg_match( $pattern, $named ) || 1 === preg_match( $pattern, substr( $named, 0, (int) strrpos( $named, '\\' ) ) ) ) ) { + // A string that names a namespace php-scoper keeps global, or a symbol in one, names the host's symbol, and a name the string follows with a separator is a namespace. + $named = 1 === preg_match( '/^\\\\{0,2}((?:[\w\x80-\xff]+\\\\{1,2})*[\w\x80-\xff]+)(\\\\?)/', $body, $name_match ) ? (string) preg_replace( '/\\\\{2}/', '\\', $name_match[1] ) : ''; + $namespace = '' === ( $name_match[2] ?? '' ) ? substr( $named, 0, (int) strrpos( $named, '\\' ) ) : $named; + if ( array_any( $exclude_namespaces, static fn ( string $pattern ): bool => 1 === preg_match( $pattern, $named ) || 1 === preg_match( $pattern, $namespace ) ) ) { continue; } // Double-quoted strings and heredocs read escape sequences, so a prefix segment such as Vendor\name takes doubled backslashes there. diff --git a/tests/Unit/ScopePhpDependenciesTest.php b/tests/Unit/ScopePhpDependenciesTest.php index 8e65e1e..3bdb454 100644 --- a/tests/Unit/ScopePhpDependenciesTest.php +++ b/tests/Unit/ScopePhpDependenciesTest.php @@ -403,6 +403,17 @@ public function test_a_host_class_under_a_scoped_package_s_namespace_stays_globa self::assertStringContainsString( "\$checkout = 'Acme\\Commerce\\Gateway\\Checkout';", self::read( $project . '/vendor-prefixed' . self::SECTION ) ); } + public function test_a_scoped_package_s_namespace_string_is_prefixed_below_a_global_namespace(): void { + $project = self::make_project( array( 'acme/commerce' ), array( 'scoping-stubs' => array( './stubs/host-plugin.php', './stubs/acme.php' ) ) ); + \file_put_contents( $project . '/stubs/acme.php', " array( './stubs/host-plugin.php', './stubs/calls.php' ) ) ); From 199dbd44cbc04f266516874c5b0db800ed61947a Mon Sep 17 00:00:00 2001 From: Tony Hegyes Date: Fri, 6 Nov 2026 15:37:12 +0100 Subject: [PATCH 21/29] Rewrite the text domain in the PHP code a nowdoc holds The text-domain patcher rewrote only string-literal tokens and failed on the domain anywhere else in the code, so a scoped package whose nowdoc holds PHP code with a translation call, such as $domain ) { - if ( ! str_starts_with( $file_path, "$dir/" ) || ! str_ends_with( $file_path, '.php' ) ) { - continue; + $rewrite = static function ( string $code, string $domain, string $file_path ) use ( &$rewrite, $text_domain ): string { + $tokens = PhpToken::tokenize( $code ); + foreach ( $tokens as $index => $token ) { + if ( $token->is( T_CONSTANT_ENCAPSED_STRING ) && in_array( $token->text, array( "'$domain'", "\"$domain\"" ), true ) ) { + $token->text = var_export( $text_domain, true ); + } elseif ( $token->is( T_ENCAPSED_AND_WHITESPACE ) && $index > 0 && $tokens[ $index - 1 ]->is( T_START_HEREDOC ) && str_contains( $tokens[ $index - 1 ]->text, "'" ) && str_starts_with( ltrim( $token->text ), 'text = $rewrite( $token->text, $domain, $file_path ); + } elseif ( ! $token->is( array( T_COMMENT, T_DOC_COMMENT, T_INLINE_HTML ) ) && 1 === preg_match( '/(?text ) ) { + throw new RuntimeException( "'$file_path' still contains the text domain '$domain' after scoping, outside a string literal of its own." ); } - $tokens = PhpToken::tokenize( $contents ); - foreach ( $tokens as $token ) { - if ( $token->is( T_CONSTANT_ENCAPSED_STRING ) && in_array( $token->text, array( "'$domain'", "\"$domain\"" ), true ) ) { - $token->text = var_export( $text_domain, true ); - } elseif ( ! $token->is( array( T_COMMENT, T_DOC_COMMENT, T_INLINE_HTML ) ) && 1 === preg_match( '/(?text ) ) { - throw new RuntimeException( "'$file_path' still contains the text domain '$domain' after scoping, outside a string literal of its own." ); - } + } + + return implode( '', array_column( $tokens, 'text' ) ); + }; + $patchers[] = static function ( string $file_path, string $prefix, string $contents ) use ( $domains, $rewrite ): string { + foreach ( $domains as $dir => $domain ) { + if ( str_starts_with( $file_path, "$dir/" ) && str_ends_with( $file_path, '.php' ) ) { + $contents = $rewrite( $contents, $domain, $file_path ); } - $contents = implode( '', array_column( $tokens, 'text' ) ); } return $contents; diff --git a/tests/Unit/ScopePhpDependenciesTest.php b/tests/Unit/ScopePhpDependenciesTest.php index 3bdb454..5bfc87d 100644 --- a/tests/Unit/ScopePhpDependenciesTest.php +++ b/tests/Unit/ScopePhpDependenciesTest.php @@ -384,6 +384,18 @@ public function test_a_double_quoted_text_domain_takes_the_project_s(): void { self::assertStringContainsString( "\\__('Other', 'acme-text-domain')", self::read( $project . '/vendor-prefixed' . self::SECTION ) ); } + public function test_a_text_domain_in_the_php_code_of_a_nowdoc_takes_the_project_s(): void { + $project = self::make_project( array( 'acme/commerce' ) ); + $section = $project . '/vendor' . self::SECTION; + \file_put_contents( $section, \str_replace( "\t\treturn \\__(", "\t\t\$template = <<<'PHP'\n\t\t\t 'acme-commerce' ) ); From abb64039d90ea567a6f535cdda8d27a05ae31704 Mon Sep 17 00:00:00 2001 From: Tony Hegyes Date: Fri, 6 Nov 2026 15:37:25 +0100 Subject: [PATCH 22/29] Skip the nowdoc code php-scoper leaves unscoped The collector parsed a nowdoc's code without its leading whitespace, as php-scoper's own detection reads it, because php-scoper puts the prefix namespace after that whitespace and the result parses only trimmed. php-scoper scopes the code as written, though, and code with a namespace or a declare of its own does not parse behind leading whitespace, so php-scoper leaves it unscoped. The collector still read such a template, so a nowdoc holding "\nvalue && ! ( $first instanceof Node\Stmt\Namespace_ && $this->prefix === $first->name?->toString() ) ) { + return null; + } new NodeTraverser( new NameResolver(), new ParentConnectingVisitor(), $this )->traverse( $statements ); } else { $parts = \explode( '::', $node->value, 2 ); diff --git a/tests/Unit/ScopePhpDependenciesTest.php b/tests/Unit/ScopePhpDependenciesTest.php index 5bfc87d..c8c9991 100644 --- a/tests/Unit/ScopePhpDependenciesTest.php +++ b/tests/Unit/ScopePhpDependenciesTest.php @@ -470,6 +470,16 @@ public function test_a_polyfill_of_a_php_class_passes_the_check(): void { self::assertStringContainsString( "\\class_alias('Acme\\Scoped\\Stringable', 'Stringable', \\false);", self::read( $project . '/vendor-prefixed/acme/commerce/lib/Polyfill.php' ) ); } + public function test_scope_run_passes_a_nowdoc_php_scoper_leaves_unscoped(): void { + $project = self::make_project( array( 'acme/commerce' ) ); + $section = $project . '/vendor' . self::SECTION; + \file_put_contents( $section, \str_replace( "\t\treturn \\__(", "\t\t\$template = <<<'PHP'\n\n\t\t\t Date: Fri, 6 Nov 2026 15:37:37 +0100 Subject: [PATCH 23/29] Remove the partial build when php-scoper dies mid-run php-scoper removes its output directory when it throws, but a fatal error such as an exhausted memory limit, or a signal, ends it without that cleanup, which left a partial vendor-prefixed.tmp/ in the project. No PHPCS, PHPStan or .gitignore pattern covers that directory, so the next lint scanned the scoped files and a broad git add could commit them. The README also promised that a failed run keeps the previous build, while the checks that run after the swap leave the new build in place. The run removes vendor-prefixed.tmp/ whenever php-scoper exits with an error, and the README says a run php-scoper fails keeps the previous build. A row ends php-scoper from a patcher with exit( 1 ) and fails without the removal. Review of the branch; follows up A149 (X ruling 184). Assisted-by: Claude Code:claude-opus-5-5 --- README.md | 2 +- php/composer/ScopePhpDependencies.php | 1 + tests/Unit/ScopePhpDependenciesTest.php | 13 +++++++++++++ 3 files changed, 15 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index a7cf033..b38b51d 100644 --- a/README.md +++ b/README.md @@ -174,7 +174,7 @@ A name is a package name or an `fnmatch()` pattern, such as `'your-vendor/your-l A scope run: -- scopes every file of each scoped package into `vendor-prefixed.tmp/` and, once php-scoper succeeds, moves it to `vendor-prefixed///` in place of the previous build, refusing a `vendor-prefixed/` that is a symbolic link, so a failed run keeps the previous build; +- scopes every file of each scoped package into `vendor-prefixed.tmp/` and, once php-scoper succeeds, moves it to `vendor-prefixed///` in place of the previous build, refusing a `vendor-prefixed/` that is a symbolic link, so a run php-scoper fails keeps the previous build; - leaves global the WordPress symbols of `sniccowp/php-scoper-wordpress-excludes`, Action Scheduler (`as_*` and `ActionScheduler*`), every symbol of the stubs files the project or a scoped package declares under `extra.scoping-stubs`, and their namespaces; - prefixes the strings that start with a scoped package's own namespace, which libraries use to build class names, unless they name a namespace the run leaves global or a symbol in one; - applies the recipe of each scoped package that has one in `php/php-scoper/recipes//.inc.php`; diff --git a/php/composer/ScopePhpDependencies.php b/php/composer/ScopePhpDependencies.php index b1b9204..f67b45a 100644 --- a/php/composer/ScopePhpDependencies.php +++ b/php/composer/ScopePhpDependencies.php @@ -96,6 +96,7 @@ public static function scope( string $project_dir ): void { } $output = \stream_get_contents( $pipes[1] ); if ( 0 !== \proc_close( $process ) ) { + $filesystem->remove( $scoping_dir ); // php-scoper removes its output when it throws, but not when a fatal error or a signal ends it. throw new \RuntimeException( "php-scoper failed:\n" . $output ); } diff --git a/tests/Unit/ScopePhpDependenciesTest.php b/tests/Unit/ScopePhpDependenciesTest.php index c8c9991..5c47320 100644 --- a/tests/Unit/ScopePhpDependenciesTest.php +++ b/tests/Unit/ScopePhpDependenciesTest.php @@ -266,6 +266,19 @@ public function test_a_project_that_scopes_no_declaring_package_needs_no_text_do self::assertSame( array( 'acme/legacy' ), self::package_dirs( $project . '/vendor-prefixed' ) ); } + public function test_a_php_scoper_run_that_dies_leaves_no_partial_build(): void { + $project = self::make_project( array( 'acme/commerce' ) ); + \file_put_contents( $project . '/scoper.inc.php', \str_replace( 'return ( require', '$config = ( require', self::read( $project . '/scoper.inc.php' ) ) . "\$config['patchers'][] = static function ( string \$file_path, string \$prefix, string \$contents ): string {\n\tif ( str_ends_with( \$file_path, 'SettingsSection.php' ) ) {\n\t\texit( 1 );\n\t}\n\n\treturn \$contents;\n};\n\nreturn \$config;\n" ); + + try { + ScopePhpDependencies::scope( $project ); + self::fail( 'The scope run did not fail.' ); + } catch ( \RuntimeException $exception ) { + self::assertStringStartsWith( "php-scoper failed:\n", $exception->getMessage() ); + } + self::assertDirectoryDoesNotExist( $project . '/vendor-prefixed.tmp' ); + } + public function test_a_failed_scope_run_keeps_the_previous_build(): void { $project = self::make_project( array( 'acme/commerce' ) ); ScopePhpDependencies::scope( $project ); From 0b9e1631fc1efc76b33f88c466f476b50f93d62e Mon Sep 17 00:00:00 2001 From: Tony Hegyes Date: Fri, 6 Nov 2026 15:37:47 +0100 Subject: [PATCH 24/29] Delete the JavaScript tsc emits when the smoke catches it The tsconfig probe compiles the fixture without --noEmit and fails when probe.js appears, but it left the file in place, so once the base kept noEmit again the smoke still failed on the stale file until someone deleted it by hand. The probe deletes probe.js before it reports the emit. Review of the branch; follows up A492 (X ruling 240). Assisted-by: Claude Code:claude-opus-5-5 --- tests/fixtures/node-config/smoke.mjs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/fixtures/node-config/smoke.mjs b/tests/fixtures/node-config/smoke.mjs index 1b81a6f..a3c3a64 100644 --- a/tests/fixtures/node-config/smoke.mjs +++ b/tests/fixtures/node-config/smoke.mjs @@ -5,7 +5,7 @@ */ import { execFileSync } from 'node:child_process'; -import { existsSync, readFileSync } from 'node:fs'; +import { existsSync, readFileSync, rmSync } from 'node:fs'; import { createRequire } from 'node:module'; import { resolve } from 'node:path'; import { pathToFileURL } from 'node:url'; @@ -175,6 +175,7 @@ probes.push( [ // The base's noEmit stands in for the flag, so compiling the probe writes no JavaScript. execFileSync( './node_modules/.bin/tsc', project, { stdio: 'pipe' } ); if ( existsSync( 'tests/fixtures/node-config/probe.js' ) ) { + rmSync( 'tests/fixtures/node-config/probe.js' ); throw new Error( 'tsc emitted probe.js' ); } const options = JSON.parse( From b70a4eb2d56415e4af2fa1dc2fcdcf1ad01ebd3f Mon Sep 17 00:00:00 2001 From: Tony Hegyes Date: Fri, 6 Nov 2026 15:37:48 +0100 Subject: [PATCH 25/29] Trim a phantom example, a second comment sentence and a blank line StubSymbolCollector::declared_by_call()'s docblock named its argument forms through a placeholder class, Foo::class, which the docblock voice keeps out; the comment above the config load in scope() carried a second sentence explaining the closure idiom, which the inline-comment rule limits to one; and a test helper kept a blank line between a one-line statement and its return. The docblock reads "a string literal or a ::class constant", the comment keeps its one sentence, and the blank line goes. Review of the branch. Assisted-by: Claude Code:claude-opus-5-5 --- php/composer/ScopePhpDependencies.php | 2 +- php/composer/Visitors/StubSymbolCollector.php | 4 ++-- tests/Unit/ScopePhpDependenciesTest.php | 1 - 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/php/composer/ScopePhpDependencies.php b/php/composer/ScopePhpDependencies.php index f67b45a..a7122c8 100644 --- a/php/composer/ScopePhpDependencies.php +++ b/php/composer/ScopePhpDependencies.php @@ -189,7 +189,7 @@ public static function scope( string $project_dir ): void { } $filesystem->dumpFile( $output_dir . '/scoper-autoload.php', "addClassMap( array(\n$classes) );\n\$loader->register();\n\n$files" ); - // The functions the run leaves global are those the config excludes and those PHP declares, whose map Composer's script autoloader does not load. The config runs in a closure, so the variables it sets stay out of this method. + // The functions the run leaves global are those the config excludes and those PHP declares, whose map Composer's script autoloader does not load. $config = ( static fn (): mixed => require $project_dir . '/scoper.inc.php' )(); $stubs_path = InstalledVersions::getInstallPath( 'jetbrains/phpstorm-stubs' ) ?? ''; require_once $stubs_path . '/PhpStormStubsMap.php'; diff --git a/php/composer/Visitors/StubSymbolCollector.php b/php/composer/Visitors/StubSymbolCollector.php index dc28b9f..816473d 100644 --- a/php/composer/Visitors/StubSymbolCollector.php +++ b/php/composer/Visitors/StubSymbolCollector.php @@ -100,9 +100,9 @@ public function leaveNode( Node $node ): null { // region METHODS /** - * Returns the kind and name of the symbol a define() or class_alias() call declares, or null when the call declares none by a string or a Foo::class name. + * Returns the kind and name of the symbol a define() or class_alias() call declares, or null when the call declares none by a string literal or a ::class constant. * - * The call's arguments must have been visited, since a NameResolver resolves a Foo::class name only when the traversal reaches it. + * The call's arguments must have been visited, since a NameResolver resolves a ::class constant's class only when the traversal reaches it. * * @param Node\Expr\FuncCall $node The function call. * diff --git a/tests/Unit/ScopePhpDependenciesTest.php b/tests/Unit/ScopePhpDependenciesTest.php index 5c47320..0aae4e1 100644 --- a/tests/Unit/ScopePhpDependenciesTest.php +++ b/tests/Unit/ScopePhpDependenciesTest.php @@ -695,7 +695,6 @@ protected static function make_project( array $names = array( 'acme/commerce', ' $packages[] = array( 'install-path' => '../' . $name ) + $package; } self::record_installed_packages( $project . '/' . $vendor, $manifest, $packages ); - return $project; } From c6d78b74cd6ec4235698a7a6356f036e09ee2f7d Mon Sep 17 00:00:00 2001 From: Tony Hegyes Date: Fri, 6 Nov 2026 16:00:56 +0100 Subject: [PATCH 26/29] Keep each base-ruleset comment beside the setting it explains Moving the base ruleset's purpose comments above their rule blocks made each one read as the purpose of the whole block. Above Squiz.Commenting.FunctionComment, the @inheritDoc comment seemed to cover the IncorrectTypeHint exclude beside it as well, and the hook-name, file-comment and @throws comments read as describing the sniff rather than the one property, pattern or severity they explain. Each comment sits above the element it explains, as the WordPress-Docs pattern comment does, and a comment above a rule describes the whole reference. The WrongNumber comment keeps the reason it stays a severity-0 block. Review of the branch; follows up A190 (X ruling 192). Assisted-by: Claude Code:claude-opus-5-5 --- php/quality-assurance/phpcs.base.dist.xml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/php/quality-assurance/phpcs.base.dist.xml b/php/quality-assurance/phpcs.base.dist.xml index cc114e5..e68077d 100644 --- a/php/quality-assurance/phpcs.base.dist.xml +++ b/php/quality-assurance/phpcs.base.dist.xml @@ -44,15 +44,15 @@ - + - + @@ -60,14 +60,14 @@ - + (?-i)*/index\.php - + 0 From 1b335288b781f30ee38995eaf39be145a0ddca5e Mon Sep 17 00:00:00 2001 From: Tony Hegyes Date: Fri, 6 Nov 2026 16:01:41 +0100 Subject: [PATCH 27/29] Name php-scoper's expose options as unsupported php-scoper can expose a scoped class under its global name through a class_alias() line it appends, but the namespace-string patcher prefixes that line's global name too, since it starts with a scoped package's own namespace, so the alias names the prefixed class itself and exposes nothing. The README named no expose option either way, and exposure undoes the per-plugin copy that prefixing keeps. The README's "Dependency scoping" section says the run supports none of the expose-* options, beside the metapackage limitation. X ruling 304. Assisted-by: Claude Code:claude-opus-5-5 --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index b38b51d..f1e4244 100644 --- a/README.md +++ b/README.md @@ -188,6 +188,8 @@ The run also fails when `extra.scoping-prefix` is missing or empty, when a name A package that reaches a dependency only through a metapackage includes its `files` entries before that dependency's, because the autoloader orders them by each package's direct `require`. +The run supports none of php-scoper's `expose-*` options, since each plugin keeps its own prefixed copy of what it scopes: the namespace-string patcher prefixes the global name in the alias php-scoper writes for an exposed class, so the class stays unexposed. + The project or a scoped package declares the stubs files of the host symbols the scoped code calls, each as `./path/to/file.php` relative to its `composer.json`, as `vendor/package` for that package's `.php`, or as `vendor/package:path/to/file.php`: ```json From b784b1964beeb2410c4d3e4bc88b001350be895d Mon Sep 17 00:00:00 2001 From: Tony Hegyes Date: Fri, 6 Nov 2026 16:07:50 +0100 Subject: [PATCH 28/29] Separate the scoping pipeline's code into the groups it runs in Configs' PHP ran its steps together: in php/, 44 of the 104 places where a block closes before another statement at the same depth had no blank line, against 30 of 594 in the framework's packages, so a loop, the guard after it and the next step read as one block. scope() also opened its command's comment directly under the command array, the recipe loop ran its exclude-namespaces merge into the replacement patchers, and two values were computed among setup lines their only reader sits far below. A blank line follows each closing brace before the next statement, as in the framework, but for a case's break or return and the PHP code inside a test's nowdoc; scope()'s console comment and the recipe's patcher loop open groups of their own; $relative moves beside the files entries that read it, and the scoped package's $autoload beside the namespace loop that reads it. Only whitespace and those two assignments' positions change, which a token comparison against the previous commit confirms. Review of the branch. Assisted-by: Claude Code:claude-opus-5-5 --- php/composer/GlobalSymbols.php | 4 ++++ php/composer/Manifest.php | 1 + php/composer/ScopePhpDependencies.php | 14 +++++++++++++- php/composer/ScopedPackages.php | 9 +++++++++ .../Visitors/ScopedSymbolCollector.php | 2 ++ php/php-scoper/scoper-base.inc.php | 19 ++++++++++++++++--- php/phpstan/GlobalTagVisitor.php | 2 ++ .../Support/RecordsInstalledPackagesTrait.php | 1 + tests/Support/RunsWorkflowStepsTrait.php | 4 ++++ tests/Unit/PhpcsProfilesTest.php | 1 + tests/Unit/PhpstanConfigTest.php | 1 + tests/Unit/ReleaseToolsTest.php | 1 + tests/Unit/ReleaseWorkflowTest.php | 7 +++++++ tests/Unit/ScopePhpDependenciesTest.php | 10 ++++++++++ tests/Unit/ScopedPackagesTest.php | 1 + 15 files changed, 73 insertions(+), 4 deletions(-) diff --git a/php/composer/GlobalSymbols.php b/php/composer/GlobalSymbols.php index a089a41..a2cfb47 100644 --- a/php/composer/GlobalSymbols.php +++ b/php/composer/GlobalSymbols.php @@ -57,12 +57,15 @@ public static function collect( string $project_dir, array $package_dirs, array $install = InstalledVersions::isInstalled( $parts[0] ) ? InstalledVersions::getInstallPath( $parts[0] ) : null; $path = \is_null( $install ) ? '' : $install . '/' . ( $parts[1] ?? \basename( $parts[0] ) . '.php' ); } + if ( ! \is_file( $path ) ) { throw new \RuntimeException( \sprintf( "'%s/composer.json' declares the scoping stubs %s, which do not exist.", $dir, \var_export( $entry, true ) ) ); } + $files[] = $path; } } + foreach ( $dropped_dirs as $dir ) { foreach ( Finder::create()->files()->in( $dir )->exclude( array( 'tests', 'Tests' ) )->name( '*.php' ) as $file ) { $files[] = $file->getPathname(); @@ -87,6 +90,7 @@ public static function collect( string $project_dir, array $package_dirs, array $namespaces[ \preg_quote( \ltrim( \substr( $symbol, 0, $separator ), '\\' ), '/' ) ] = true; } } + $symbols['namespaces'] = array() === $namespaces ? array() : array( '/^(?:' . \implode( '|', \array_keys( $namespaces ) ) . ')$/i' ); return $symbols; } diff --git a/php/composer/Manifest.php b/php/composer/Manifest.php index f48038b..a835f83 100644 --- a/php/composer/Manifest.php +++ b/php/composer/Manifest.php @@ -33,6 +33,7 @@ public static function read( string $file ): array { if ( false === $contents ) { throw new \RuntimeException( "Could not read '$file'." ); } + $decoded = \json_decode( $contents, true, flags: \JSON_THROW_ON_ERROR ); return \is_array( $decoded ) ? $decoded : array(); } diff --git a/php/composer/ScopePhpDependencies.php b/php/composer/ScopePhpDependencies.php index a7122c8..6bf5844 100644 --- a/php/composer/ScopePhpDependencies.php +++ b/php/composer/ScopePhpDependencies.php @@ -67,6 +67,7 @@ public static function scope( string $project_dir ): void { if ( \is_link( $output_dir ) ) { throw new \RuntimeException( "Refusing to replace '$output_dir', which is a symbolic link." ); } + if ( ! InstalledVersions::isInstalled( 'humbug/php-scoper' ) ) { throw new \RuntimeException( 'Install humbug/php-scoper to scope dependencies.' ); } @@ -89,11 +90,13 @@ public static function scope( string $project_dir ): void { '--force', '--no-interaction', ); + // php-scoper's console wraps what it prints at the terminal width, which would split the paths and symbols in a failure message. $process = \proc_open( \implode( ' ', \array_map( 'escapeshellarg', $command ) ) . ' 2>&1', array( 1 => array( 'pipe', 'w' ) ), $pipes, null, array( 'COLUMNS' => '4096' ) + \getenv() ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.system_calls_proc_open -- Runs in Composer, never on a WordPress server. if ( false === $process ) { throw new \RuntimeException( 'Could not start php-scoper.' ); } + $output = \stream_get_contents( $pipes[1] ); if ( 0 !== \proc_close( $process ) ) { $filesystem->remove( $scoping_dir ); // php-scoper removes its output when it throws, but not when a fatal error or a signal ends it. @@ -105,6 +108,7 @@ public static function scope( string $project_dir ): void { if ( false === $manifests ) { throw new \RuntimeException( "Could not list '$scoping_dir'." ); } + $dropped = match ( true ) { \is_file( $scoping_dir . '/composer.json' ) => self::package_name( $scoping_dir . '/composer.json' ), array() !== $manifests => \dirname( self::package_name( \current( $manifests ) ) ), @@ -127,7 +131,6 @@ public static function scope( string $project_dir ): void { $requires = array(); foreach ( $package_manifests as $manifest ) { $package_dir = \dirname( $manifest ); - $relative = \substr( $package_dir, \strlen( $output_dir ) ); $decoded = Manifest::read( $manifest ); $autoload = isset( $decoded['autoload'] ) && \is_array( $decoded['autoload'] ) ? $decoded['autoload'] : array(); @@ -138,6 +141,7 @@ public static function scope( string $project_dir ): void { \array_push( $paths, ...self::strings( $namespace_paths ) ); } } + $excluded = null; $excluded_paths = self::strings( $autoload['exclude-from-classmap'] ?? array() ); if ( array() !== $excluded_paths ) { @@ -156,16 +160,20 @@ public static function scope( string $project_dir ): void { ) ) . ')(?:$|/)#'; } + $generator = new ClassMapGenerator(); foreach ( $paths as $autoload_path ) { if ( \str_contains( $autoload_path, '*' ) || \file_exists( "$package_dir/$autoload_path" ) ) { // Composer skips an autoload path a package does not ship. $generator->scanPaths( "$package_dir/$autoload_path", $excluded ); } } + foreach ( $generator->getClassMap()->getMap() as $class => $file ) { $classmap[ $class ] = \substr( $file, \strlen( $output_dir ) ); } + $package_name = self::package_name( $manifest ); + $relative = \substr( $package_dir, \strlen( $output_dir ) ); $requires[ $package_name ] = \array_map( 'strval', \array_keys( (array) ( $decoded['require'] ?? array() ) ) ); $includes[ $package_name ] = ''; foreach ( self::strings( $autoload['files'] ?? array() ) as $file_entry ) { @@ -187,6 +195,7 @@ public static function scope( string $project_dir ): void { foreach ( $classmap as $class_name => $class_file ) { $classes .= \sprintf( "\t%s => __DIR__ . %s,\n", \var_export( $class_name, true ), \var_export( $class_file, true ) ); } + $filesystem->dumpFile( $output_dir . '/scoper-autoload.php', "addClassMap( array(\n$classes) );\n\$loader->register();\n\n$files" ); // The functions the run leaves global are those the config excludes and those PHP declares, whose map Composer's script autoloader does not load. @@ -204,6 +213,7 @@ public static function scope( string $project_dir ): void { } catch ( Error $error ) { throw new \RuntimeException( "Could not parse the scoped file '" . $scoped_file->getPathname() . "': " . $error->getMessage(), 0, $error ); } + new NodeTraverser( new NameResolver(), new ParentConnectingVisitor(), $collector )->traverse( $statements ); } @@ -216,6 +226,7 @@ public static function scope( string $project_dir ): void { $outside[] = false === $separator ? 'the global namespace' : \substr( $class, 0, $separator ); } } + if ( array() !== $outside ) { throw new \RuntimeException( "The scoped packages declare classes in these namespaces outside the prefix, where the copy the host or another plugin loads can replace them. Drop the package that declares one with !vendor/package, or stop excluding the namespace in scoper.inc.php:\n" . \implode( "\n", \array_unique( $outside ) ) ); } @@ -251,6 +262,7 @@ public static function scope( string $project_dir ): void { $undeclared[] = $reference['name'] . ' in ' . $reference['location']; } } + if ( array() !== $undeclared ) { throw new \RuntimeException( "No scoped package declares these prefixed names, which would fail at runtime. Declare the host's stubs under extra.scoping-stubs, scope the package that declares the name, or exclude it in scoper.inc.php:\n" . \implode( "\n", \array_unique( $undeclared ) ) ); } diff --git a/php/composer/ScopedPackages.php b/php/composer/ScopedPackages.php index 4f8cf23..1eafb91 100644 --- a/php/composer/ScopedPackages.php +++ b/php/composer/ScopedPackages.php @@ -40,10 +40,12 @@ public static function resolve( string $vendor_dir, array $names ): array { if ( ! \is_file( $installed_file ) ) { throw new \RuntimeException( "Could not read '$installed_file'. Install the project's dependencies first." ); } + $recorded = Manifest::read( $installed_file ); if ( ! \is_array( $recorded['packages'] ?? null ) ) { throw new \RuntimeException( "'$installed_file' lists no packages." ); } + $installed = array(); $replacers = array(); foreach ( $recorded['packages'] as $package ) { @@ -54,6 +56,7 @@ public static function resolve( string $vendor_dir, array $names ): array { } } } + // Composer records every name a package or the root replaces or provides, so a requirement no installed package fills is met when it is recorded there. $versions_file = $vendor_dir . '/composer/installed.php'; $versions = \is_file( $versions_file ) ? ( static fn (): mixed => require $versions_file )() : null; @@ -69,6 +72,7 @@ public static function resolve( string $vendor_dir, array $names ): array { if ( array() === $matches ) { throw new \RuntimeException( "'$pattern' matches no package installed in '$vendor_dir'." ); } + if ( \str_starts_with( $name, '!' ) ) { \array_push( $dropped, ...$matches ); } else { @@ -84,6 +88,7 @@ public static function resolve( string $vendor_dir, array $names ): array { if ( isset( $scoped[ $name ] ) || 'composer-plugin' === ( $package['type'] ?? '' ) ) { // Composer plugins run at build time and never ship. continue; } + $scoped[ $name ] = $package; foreach ( \array_keys( (array) ( $package['require'] ?? array() ) ) as $required ) { @@ -91,13 +96,16 @@ public static function resolve( string $vendor_dir, array $names ): array { if ( ! \str_contains( $required, '/' ) || \in_array( $required, $dropped, true ) ) { // Platform requirements such as php and ext-json carry no vendor. continue; } + $candidates = isset( $installed[ $required ] ) ? array( $required ) : \array_diff( $replacers[ $required ] ?? array(), $dropped ); if ( array() === $candidates && ! isset( $versions['versions'][ $required ] ) ) { throw new \RuntimeException( "'$name' requires '$required', which is not installed in '$vendor_dir'." ); } + \array_push( $queue, ...$candidates ); } } + \ksort( $scoped ); $directories = array( @@ -109,6 +117,7 @@ public static function resolve( string $vendor_dir, array $names ): array { $directories['scoped'][ $name ] = self::directory( $vendor_dir, $name, $package, true ); } } + foreach ( \array_unique( $dropped ) as $name ) { if ( 'metapackage' !== ( $installed[ $name ]['type'] ?? '' ) ) { $directories['dropped'][ $name ] = self::directory( $vendor_dir, $name, $installed[ $name ], false ); diff --git a/php/composer/Visitors/ScopedSymbolCollector.php b/php/composer/Visitors/ScopedSymbolCollector.php index fe3e761..38d22cd 100644 --- a/php/composer/Visitors/ScopedSymbolCollector.php +++ b/php/composer/Visitors/ScopedSymbolCollector.php @@ -87,11 +87,13 @@ public function enterNode( Node $node ): int|null { } catch ( Error ) { return null; // php-scoper leaves a nowdoc that does not parse as PHP unscoped. } + // php-scoper parses the code as written, so behind leading whitespace it scopes only code without a namespace, which it puts in the prefix namespace. $first = $statements[0] ?? null; if ( $code !== $node->value && ! ( $first instanceof Node\Stmt\Namespace_ && $this->prefix === $first->name?->toString() ) ) { return null; } + new NodeTraverser( new NameResolver(), new ParentConnectingVisitor(), $this )->traverse( $statements ); } else { $parts = \explode( '::', $node->value, 2 ); diff --git a/php/php-scoper/scoper-base.inc.php b/php/php-scoper/scoper-base.inc.php index 14131c9..de90421 100644 --- a/php/php-scoper/scoper-base.inc.php +++ b/php/php-scoper/scoper-base.inc.php @@ -30,6 +30,7 @@ if ( array() === $packages['scoped'] ) { throw new RuntimeException( "No package to scope is installed in '$vendor_dir'." ); // Without finders, php-scoper scopes the whole working directory. } + $symbols = GlobalSymbols::collect( $project_dir, array_values( $packages['scoped'] ), array_values( $packages['dropped'] ) ); $exclude_files = array(); @@ -38,12 +39,13 @@ $roots = array(); $domains = array(); foreach ( $packages['scoped'] as $name => $dir ) { - $package = Manifest::read( "$dir/composer.json" ); - $domain = Manifest::extra( $package )['scoping-text-domain'] ?? null; - $autoload = isset( $package['autoload'] ) && is_array( $package['autoload'] ) ? $package['autoload'] : array(); + $package = Manifest::read( "$dir/composer.json" ); + $domain = Manifest::extra( $package )['scoping-text-domain'] ?? null; if ( ! is_null( $domain ) ) { $domains[ $dir ] = is_string( $domain ) && '' !== $domain ? $domain : throw new RuntimeException( "'$dir/composer.json' declares an empty extra.scoping-text-domain." ); } + + $autoload = isset( $package['autoload'] ) && is_array( $package['autoload'] ) ? $package['autoload'] : array(); foreach ( array( 'psr-4', 'psr-0' ) as $standard ) { foreach ( array_keys( (array) ( $autoload[ $standard ] ?? array() ) ) as $namespace ) { if ( str_contains( (string) $namespace, '\\' ) ) { @@ -56,6 +58,7 @@ if ( ! is_file( $recipe_file ) ) { continue; } + /** * A recipe returns php-scoper settings for one package, with paths relative to it. * @@ -68,20 +71,25 @@ throw new RuntimeException( "The '$name' recipe targets '$path', which the installed package no longer ships." ); } } + foreach ( $recipe['exclude-files'] ?? array() as $excluded_file ) { $exclude_files[] = "$dir/$excluded_file"; } + array_push( $exclude_namespaces, ...( $recipe['exclude-namespaces'] ?? array() ) ); + foreach ( $recipe['replacements'] ?? array() as $target => $replacements ) { $patchers[] = static function ( string $file_path, string $prefix, string $contents ) use ( $name, $dir, $target, $replacements ): string { if ( "$dir/$target" !== $file_path ) { return $contents; } + foreach ( $replacements as $search => $replace ) { $search = str_replace( '{prefix}', $prefix, (string) $search ); if ( ! str_contains( $contents, $search ) ) { throw new RuntimeException( "The '$name' recipe expects '$search' in '$target', which the installed package no longer contains." ); } + $contents = str_replace( $search, str_replace( '{prefix}', $prefix, $replace ), $contents ); } @@ -94,6 +102,7 @@ if ( array() !== $domains && ( ! is_string( $text_domain ) || '' === $text_domain ) ) { throw new RuntimeException( "'$project_dir/composer.json' declares no extra.text-domain, which the strings of the scoped packages take." ); } + $domains = array_filter( $domains, static fn ( string $domain ): bool => $text_domain !== $domain ); $exclude_namespaces = array( ...$exclude_namespaces, ...$symbols['namespaces'] ); @@ -105,6 +114,7 @@ if ( ! str_ends_with( $file_path, '.php' ) ) { return $contents; } + $scoped = '/^\\\\{0,2}' . $separated( $prefix ) . '\\\\{1,2}/'; $tokens = PhpToken::tokenize( $contents ); foreach ( $tokens as $index => $token ) { @@ -112,17 +122,20 @@ if ( ! $starts_string ) { continue; } + $opening = $token->is( T_CONSTANT_ENCAPSED_STRING ) ? strcspn( $token->text, '\'"' ) + 1 : 0; $body = substr( $token->text, $opening ); if ( 1 === preg_match( $scoped, $body ) || 1 !== preg_match( $own, $body, $match ) ) { continue; } + // A string that names a namespace php-scoper keeps global, or a symbol in one, names the host's symbol, and a name the string follows with a separator is a namespace. $named = 1 === preg_match( '/^\\\\{0,2}((?:[\w\x80-\xff]+\\\\{1,2})*[\w\x80-\xff]+)(\\\\?)/', $body, $name_match ) ? (string) preg_replace( '/\\\\{2}/', '\\', $name_match[1] ) : ''; $namespace = '' === ( $name_match[2] ?? '' ) ? substr( $named, 0, (int) strrpos( $named, '\\' ) ) : $named; if ( array_any( $exclude_namespaces, static fn ( string $pattern ): bool => 1 === preg_match( $pattern, $named ) || 1 === preg_match( $pattern, $namespace ) ) ) { continue; } + // Double-quoted strings and heredocs read escape sequences, so a prefix segment such as Vendor\name takes doubled backslashes there. $escapes = $token->is( T_CONSTANT_ENCAPSED_STRING ) ? str_contains( substr( $token->text, 0, $opening ), '"' ) : ! str_contains( $tokens[ $index - 1 ]->text, "'" ); $separator = $escapes ? '\\\\' : $match[3]; diff --git a/php/phpstan/GlobalTagVisitor.php b/php/phpstan/GlobalTagVisitor.php index 0162565..0214958 100644 --- a/php/phpstan/GlobalTagVisitor.php +++ b/php/phpstan/GlobalTagVisitor.php @@ -71,6 +71,7 @@ public function enterNode( Node $node ): null { } } } + $this->scopes[] = $types; } elseif ( $node instanceof Node\Stmt\Global_ && \is_null( $node->getDocComment() ) ) { $types = \end( $this->scopes ) ?: array(); @@ -80,6 +81,7 @@ public function enterNode( Node $node ): null { $lines[] = " * @var {$types[ $var->name ]} \${$var->name}"; } } + if ( array() !== $lines ) { $node->setDocComment( new Doc( "/**\n" . \implode( "\n", $lines ) . "\n */" ) ); } diff --git a/tests/Support/RecordsInstalledPackagesTrait.php b/tests/Support/RecordsInstalledPackagesTrait.php index 3bbf4fa..a28a312 100644 --- a/tests/Support/RecordsInstalledPackagesTrait.php +++ b/tests/Support/RecordsInstalledPackagesTrait.php @@ -31,6 +31,7 @@ protected static function record_installed_packages( string $vendor_dir, array $ } } } + $installed_php = array( 'root' => array( 'name' => $root['name'], diff --git a/tests/Support/RunsWorkflowStepsTrait.php b/tests/Support/RunsWorkflowStepsTrait.php index 0172a24..dc68eaa 100644 --- a/tests/Support/RunsWorkflowStepsTrait.php +++ b/tests/Support/RunsWorkflowStepsTrait.php @@ -31,6 +31,7 @@ protected function write_project( array $files ): void { if ( ! \is_dir( \dirname( $file ) ) ) { \mkdir( \dirname( $file ), 0777, true ); } + \file_put_contents( $file, $contents ); } } @@ -39,6 +40,7 @@ protected function write_executable( string $path, string $script ): void { if ( ! \is_dir( \dirname( $this->work_dir . '/' . $path ) ) ) { \mkdir( \dirname( $this->work_dir . '/' . $path ), 0777, true ); } + \file_put_contents( $this->work_dir . '/' . $path, $script ); \chmod( $this->work_dir . '/' . $path, 0755 ); } @@ -62,6 +64,7 @@ protected function run_step( string $workflow, string $job, string $name, array $script = $step['run'] ?? null; } } + self::assertIsString( $script, "No run step named '$name' in job '$job' of $workflow." ); $log = $this->work_dir . '/step.log'; @@ -69,6 +72,7 @@ protected function run_step( string $workflow, string $job, string $name, array foreach ( array( 'github-output', 'github-env', 'github-path' ) as $file ) { \file_put_contents( $this->work_dir . '/' . $file, '' ); } + \file_put_contents( $log, '' ); $process = \proc_open( array( 'bash', '-e', $this->work_dir . '/step.sh' ), // GitHub Actions runs a step that names no shell this way, so a script fails here exactly as it fails in CI. diff --git a/tests/Unit/PhpcsProfilesTest.php b/tests/Unit/PhpcsProfilesTest.php index 0a1276c..9101130 100644 --- a/tests/Unit/PhpcsProfilesTest.php +++ b/tests/Unit/PhpcsProfilesTest.php @@ -49,6 +49,7 @@ public function test_production_profile_scans_a_project_s_own_files_and_skips_to if ( ! \is_dir( \dirname( $this->scan_dir . '/' . $file ) ) ) { \mkdir( \dirname( $this->scan_dir . '/' . $file ), 0777, true ); } + \file_put_contents( $this->scan_dir . '/' . $file, "write_project( $files ); $result = $this->run_build_step( @@ -253,6 +254,7 @@ public function test_headers_must_cover_the_shipped_packages( array $headers, ar foreach ( $scoped as $name => $manifest ) { $files[ "vendor-prefixed/$name/composer.json" ] = (string) \json_encode( array( 'name' => $name ) + $manifest ); } + $this->write_project( $files ); $result = $this->run_build_step( 'Verify the headers cover the shipped packages', array() ); @@ -398,6 +400,7 @@ public function test_a_release_needs_a_successful_push_run_of_every_required_wor if ( '' !== $named ) { self::assertMatchesRegularExpression( '/^::error::.*' . \preg_quote( $named, '/' ) . '/m', $result['output'] ); } + if ( \is_file( $this->work_dir . '/gh-arguments' ) ) { $arguments = \file( $this->work_dir . '/gh-arguments', \FILE_IGNORE_NEW_LINES ) ?: array(); self::assertContains( 'repos/acme/acme-plugin/actions/runs', $arguments ); @@ -461,6 +464,7 @@ public function test_the_release_notes_are_the_version_s_changelog_section( ?str if ( ! \is_null( $changelog ) ) { $this->write_project( array( 'CHANGELOG.md' => $changelog ) ); } + \mkdir( $this->work_dir . '/runner-temp' ); $result = $this->run_step( @@ -501,6 +505,7 @@ public function test_required_files_are_found_in_a_large_archive(): void { for ( $i = 0; $i < 2500; $i++ ) { $files[] = \sprintf( 'assets/%s-%04d.txt', \str_repeat( 'a', 40 ), $i ); } + $this->write_archive( $files ); $result = $this->run_build_step( 'Assert required files present', array( 'WP_ORG' => 'false' ) ); @@ -679,8 +684,10 @@ protected function write_archive( array $files ): void { if ( ! \is_dir( \dirname( "$stage/$file" ) ) ) { \mkdir( \dirname( "$stage/$file" ), 0777, true ); } + \file_put_contents( "$stage/$file", "work_dir . '/stage/entries', \implode( "\n", \array_map( static fn ( string $file ): string => "acme-plugin/$file", $files ) ) . "\n" ); \exec( \sprintf( 'cd %s && zip -q %s -@ < entries', \escapeshellarg( $this->work_dir . '/stage' ), \escapeshellarg( $this->work_dir . '/project/acme-plugin.zip' ) ), $output, $zip_status ); diff --git a/tests/Unit/ScopePhpDependenciesTest.php b/tests/Unit/ScopePhpDependenciesTest.php index 0aae4e1..a59a6c2 100644 --- a/tests/Unit/ScopePhpDependenciesTest.php +++ b/tests/Unit/ScopePhpDependenciesTest.php @@ -244,6 +244,7 @@ public function test_scope_run_fails_without_a_text_domain_when_a_package_declar self::assertStringStartsWith( "php-scoper failed:\n", $exception->getMessage() ); self::assertStringContainsString( 'extra.text-domain', $exception->getMessage() ); } + self::assertFileExists( $project . '/vendor-prefixed/scoper-autoload.php' ); } @@ -276,6 +277,7 @@ public function test_a_php_scoper_run_that_dies_leaves_no_partial_build(): void } catch ( \RuntimeException $exception ) { self::assertStringStartsWith( "php-scoper failed:\n", $exception->getMessage() ); } + self::assertDirectoryDoesNotExist( $project . '/vendor-prefixed.tmp' ); } @@ -291,6 +293,7 @@ public function test_a_failed_scope_run_keeps_the_previous_build(): void { } catch ( \RuntimeException $exception ) { self::assertStringContainsString( 'extra.text-domain', $exception->getMessage() ); } + self::assertSame( $autoload, self::read( $project . '/vendor-prefixed/scoper-autoload.php' ) ); self::assertContains( 'acme/commerce', self::package_dirs( $project . '/vendor-prefixed' ) ); } @@ -330,6 +333,7 @@ public function test_scope_run_fails_on_a_prefixed_name_the_scoped_code_does_not self::assertStringStartsWith( 'No scoped package declares these prefixed names', $exception->getMessage() ); self::assertMatchesRegularExpression( '/\n' . \preg_quote( $name . ' in ', '/' ) . '.+SettingsSection\.php:\d+$/m', $exception->getMessage() ); } + self::assertStringContainsString( "'Acme\\\\Scoped\\\\Acme\\\\Commerce\\\\SettingsSection'", self::read( $project . '/vendor-prefixed/scoper-autoload.php' ) ); } @@ -372,6 +376,7 @@ public function test_scope_run_fails_when_a_declared_text_domain_survives_the_re } catch ( \RuntimeException $exception ) { self::assertStringContainsString( "still contains the text domain 'acme-commerce'", $exception->getMessage() ); } + self::assertSame( $autoload, self::read( $project . '/vendor-prefixed/scoper-autoload.php' ) ); self::assertSame( $packages, self::package_dirs( $project . '/vendor-prefixed' ) ); } @@ -532,6 +537,7 @@ public function test_scope_run_fails_before_php_scoper_without_a_scoping_prefix( } catch ( \RuntimeException $exception ) { self::assertStringContainsString( 'extra.scoping-prefix', $exception->getMessage() ); } + self::assertDirectoryDoesNotExist( $project . '/vendor-prefixed' ); } @@ -590,6 +596,7 @@ public function test_scope_run_refuses_an_output_directory_that_resolves_elsewhe } catch ( \RuntimeException $exception ) { self::assertStringContainsString( 'vendor-prefixed', $exception->getMessage() ); } + self::assertFileExists( $project . '/src/Plugin.php' ); } @@ -668,6 +675,7 @@ protected static function make_project( array $names = array( 'acme/commerce', ' if ( 'vendor' !== $vendor ) { $manifest['config'] = array( 'vendor-dir' => $vendor ); } + \file_put_contents( $project . '/composer.json', \json_encode( $manifest, \JSON_THROW_ON_ERROR | \JSON_PRETTY_PRINT | \JSON_UNESCAPED_SLASHES ) ); $locked = array(); @@ -691,9 +699,11 @@ protected static function make_project( array $names = array( 'acme/commerce', ' $source = self::VENDOR_DIR . '/' . $name; $package = $locked[ $name ]; } + new Filesystem()->mirror( $source, $project . '/' . $vendor . '/' . $name ); $packages[] = array( 'install-path' => '../' . $name ) + $package; } + self::record_installed_packages( $project . '/' . $vendor, $manifest, $packages ); return $project; } diff --git a/tests/Unit/ScopedPackagesTest.php b/tests/Unit/ScopedPackagesTest.php index e9a4cdf..680017d 100644 --- a/tests/Unit/ScopedPackagesTest.php +++ b/tests/Unit/ScopedPackagesTest.php @@ -110,6 +110,7 @@ protected function install( array $packages, array $root_links ): void { self::assertIsString( $package['name'] ); \mkdir( $this->vendor_dir . '/' . $package['name'], 0777, true ); } + self::record_installed_packages( $this->vendor_dir, array( 'name' => 'acme/project' ) + $root_links, $packages ); } From 0b5d65195b14e8e73d2587d66813d6a7c833ab35 Mon Sep 17 00:00:00 2001 From: Tony Hegyes Date: Fri, 6 Nov 2026 16:22:13 +0100 Subject: [PATCH 29/29] Name the stubs or dropped-package file that does not parse MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GlobalSymbols parsed each declared stubs file and each dropped package's PHP file without catching php-parser's error, so a file that did not parse failed the run with only "Syntax error, unexpected '{' on line 3" in php-scoper's output, while a dropped package can hold hundreds of files. A scoped file that does not parse is already named. The parse throws "Could not parse '', whose symbols the run keeps global: …" with php-parser's message, and the README lists the failure. A row declares a stubs file with a syntax error and fails without the change. X ruling 306. Assisted-by: Claude Code:claude-opus-5-5 --- README.md | 2 +- php/composer/GlobalSymbols.php | 11 +++++++++-- tests/Unit/ScopePhpDependenciesTest.php | 10 ++++++++++ 3 files changed, 20 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index f1e4244..f52ee35 100644 --- a/README.md +++ b/README.md @@ -184,7 +184,7 @@ A scope run: - passes a `function_exists()` check of the prefixed name of a function it leaves global, PHP's own included, which php-scoper adds to every guard of that function and which is false while nothing declares the name; - writes `vendor-prefixed/scoper-autoload.php`, a class map over each scoped package's `psr-4`, `psr-0` and `classmap` paths that honors `exclude-from-classmap`, followed by its `files` entries. -The run also fails when `extra.scoping-prefix` is missing or empty, when a name matches no installed package, when a scoped package requires one that is not installed, when a declared stubs file is missing, when a scoped file does not parse, when a scoped package declares a text domain and the project declares none, when a declared text domain survives outside a string literal of its own, and when a recipe's target is gone. +The run also fails when `extra.scoping-prefix` is missing or empty, when a name matches no installed package, when a scoped package requires one that is not installed, when a declared stubs file is missing, when a scoped file, a declared stubs file or a dropped package's file does not parse, when a scoped package declares a text domain and the project declares none, when a declared text domain survives outside a string literal of its own, and when a recipe's target is gone. A package that reaches a dependency only through a metapackage includes its `files` entries before that dependency's, because the autoloader orders them by each package's direct `require`. diff --git a/php/composer/GlobalSymbols.php b/php/composer/GlobalSymbols.php index a2cfb47..57ac3f5 100644 --- a/php/composer/GlobalSymbols.php +++ b/php/composer/GlobalSymbols.php @@ -4,6 +4,7 @@ use Composer\InstalledVersions; use DeepWebSolutions\Config\Composer\Visitors\StubSymbolCollector; +use PhpParser\Error; use PhpParser\NodeTraverser; use PhpParser\NodeVisitor\NameResolver; use PhpParser\ParserFactory; @@ -33,7 +34,7 @@ protected function __construct() {} * @param list $package_dirs The directories of the packages to scope. * @param list $dropped_dirs The directories of the packages that stay unscoped. * - * @throws \RuntimeException Thrown when a declared stubs file is missing or a file cannot be read. + * @throws \RuntimeException Thrown when a declared stubs file is missing, or a file cannot be read or does not parse. * * @return array{classes: list, functions: list, constants: list, namespaces: list} */ @@ -74,8 +75,14 @@ public static function collect( string $project_dir, array $package_dirs, array $parser = new ParserFactory()->createForNewestSupportedVersion(); foreach ( \array_unique( $files ) as $file ) { + try { + $statements = $parser->parse( self::read( $file ) ) ?? array(); + } catch ( Error $error ) { + throw new \RuntimeException( "Could not parse '$file', whose symbols the run keeps global: " . $error->getMessage(), 0, $error ); + } + $collector = new StubSymbolCollector(); - new NodeTraverser( new NameResolver(), $collector )->traverse( $parser->parse( self::read( $file ) ) ?? array() ); + new NodeTraverser( new NameResolver(), $collector )->traverse( $statements ); $symbols['classes'] = array( ...$symbols['classes'], ...$collector->classes ); $symbols['functions'] = array( ...$symbols['functions'], ...$collector->functions ); diff --git a/tests/Unit/ScopePhpDependenciesTest.php b/tests/Unit/ScopePhpDependenciesTest.php index a59a6c2..52bb18b 100644 --- a/tests/Unit/ScopePhpDependenciesTest.php +++ b/tests/Unit/ScopePhpDependenciesTest.php @@ -518,6 +518,16 @@ public function test_scope_run_names_a_scoped_file_it_cannot_parse(): void { ScopePhpDependencies::scope( $project ); } + public function test_scope_run_names_a_stubs_file_it_cannot_parse(): void { + $project = self::make_project( array( 'acme/commerce' ), array( 'scoping-stubs' => array( './stubs/host-plugin.php', './stubs/broken.php' ) ) ); + \file_put_contents( $project . '/stubs/broken.php', "expectException( \RuntimeException::class ); + $this->expectExceptionMessageMatches( "#Could not parse '[^']*/stubs/broken\.php'#" ); + + ScopePhpDependencies::scope( $project ); + } + public function test_scope_run_fails_when_a_recipe_target_is_missing(): void { $project = self::make_project( array( 'php-di/php-di' ) ); \unlink( $project . '/vendor/php-di/php-di/src/Compiler/Template.php' );