From 6eb8ca4278e20e5a2f3aa9477da4cb63fcdc00de Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Tue, 8 Sep 2026 14:15:41 +0400 Subject: [PATCH 01/14] refactor(core): share container-cached readonly services via #[ScopeShared] build(deps): require internal/container ^1.1 for the #[ScopeShared] attribute The container already keeps readonly services shared across scope() derivations by reflecting isReadOnly(), so this marks the same classes explicitly with #[ScopeShared]. The attribute states the sharing intent at the class and keeps the behaviour when readonly class is unavailable (PHP 8.1), where the reflection check no longer fires. Only classes that actually land in the container cache are marked, since scope() clones cache entries alone: the autowired runner chain, MessengerHub, ErrorReporter, ConsoleStreams, the ::class-registered interceptors (AttributesInterceptor, InjectInterceptor, AssertCollectorInterceptor, SuiteFilterInterceptor, BenchFinder and its BenchHandler dep), the bound Filter, the set() CoverageCollector, and the ApplicationConfig/RunConfiguration held in the container. make()-resolved services (SuiteLocator, SuiteFactory), new'd instances and attribute-made interceptors never enter the cache, so they are left untouched. The four plugins that now reference the attribute (assert, filter, bench, codecov) gain a direct internal/container ^1.1 requirement they previously pulled in only transitively. Assisted-By: Claude Opus 4.8 (1M context) --- composer.json | 2 +- core/Application/Config/ApplicationConfig.php | 2 ++ core/Application/Config/RunConfiguration.php | 2 ++ core/Application/Internal/MessengerHub.php | 2 ++ core/Application/Internal/Runner/CaseRunner.php | 2 ++ core/Application/Internal/Runner/SuiteRunner.php | 2 ++ core/Application/Internal/Runner/TestRunner.php | 2 ++ core/Common/ErrorReporter.php | 2 ++ core/Output/ConsoleStreams.php | 3 +++ core/Pipeline/Internal/AttributesInterceptor.php | 2 ++ core/Testing/Internal/InjectInterceptor.php | 2 ++ plugin/assert/composer.json | 1 + .../src/Internal/Middleware/AssertCollectorInterceptor.php | 2 ++ plugin/bench/composer.json | 1 + plugin/bench/src/Internal/BenchHandler.php | 2 ++ plugin/bench/src/Internal/Pipeline/BenchFinder.php | 2 ++ plugin/codecov/composer.json | 1 + plugin/codecov/src/Internal/CoverageCollector.php | 2 ++ plugin/filter/Filter.php | 2 ++ plugin/filter/composer.json | 1 + plugin/filter/src/Internal/SuiteFilterInterceptor.php | 2 ++ 21 files changed, 38 insertions(+), 1 deletion(-) diff --git a/composer.json b/composer.json index f144fae2..cbf9c216 100644 --- a/composer.json +++ b/composer.json @@ -30,7 +30,7 @@ "require": { "php": ">=8.2", "ext-tokenizer": "*", - "internal/container": "^1.0", + "internal/container": "^1.1", "internal/destroy": "^1.0", "internal/path": "^1.3", "psr/container": "1 - 2", diff --git a/core/Application/Config/ApplicationConfig.php b/core/Application/Config/ApplicationConfig.php index 8453323b..b576e77c 100644 --- a/core/Application/Config/ApplicationConfig.php +++ b/core/Application/Config/ApplicationConfig.php @@ -4,6 +4,7 @@ namespace Testo\Application\Config; +use Internal\Container\Attribute\ScopeShared; use Internal\Path; use Testo\Application\Config\Plugin\ApplicationPlugins; use Testo\Common\PluginConfigurator; @@ -13,6 +14,7 @@ * * @api */ +#[ScopeShared] final readonly class ApplicationConfig { /** diff --git a/core/Application/Config/RunConfiguration.php b/core/Application/Config/RunConfiguration.php index 67a8d904..02152c40 100644 --- a/core/Application/Config/RunConfiguration.php +++ b/core/Application/Config/RunConfiguration.php @@ -4,6 +4,7 @@ namespace Testo\Application\Config; +use Internal\Container\Attribute\ScopeShared; use Internal\Path; /** @@ -22,6 +23,7 @@ * @psalm-immutable * @api */ +#[ScopeShared] final readonly class RunConfiguration { /** diff --git a/core/Application/Internal/MessengerHub.php b/core/Application/Internal/MessengerHub.php index 854ae297..8090b1e9 100644 --- a/core/Application/Internal/MessengerHub.php +++ b/core/Application/Internal/MessengerHub.php @@ -4,6 +4,7 @@ namespace Testo\Application\Internal; +use Internal\Container\Attribute\ScopeShared; use Psr\EventDispatcher\EventDispatcherInterface; use Testo\Application\Internal\Messenger\State; use Testo\Application\Internal\Messenger\MutableContainer; @@ -23,6 +24,7 @@ * * @internal */ +#[ScopeShared] final readonly class MessengerHub implements Messenger { private MutableContainer $state; diff --git a/core/Application/Internal/Runner/CaseRunner.php b/core/Application/Internal/Runner/CaseRunner.php index 39af6a66..25d7d619 100644 --- a/core/Application/Internal/Runner/CaseRunner.php +++ b/core/Application/Internal/Runner/CaseRunner.php @@ -4,6 +4,7 @@ namespace Testo\Application\Internal\Runner; +use Internal\Container\Attribute\ScopeShared; use Psr\EventDispatcher\EventDispatcherInterface; use Testo\Core\Context\CaseInfo; use Testo\Core\Context\CaseResult; @@ -25,6 +26,7 @@ * @internal * @psalm-internal Testo\Application */ +#[ScopeShared] final readonly class CaseRunner { public function __construct( diff --git a/core/Application/Internal/Runner/SuiteRunner.php b/core/Application/Internal/Runner/SuiteRunner.php index 8bdc2132..607c185d 100644 --- a/core/Application/Internal/Runner/SuiteRunner.php +++ b/core/Application/Internal/Runner/SuiteRunner.php @@ -4,6 +4,7 @@ namespace Testo\Application\Internal\Runner; +use Internal\Container\Attribute\ScopeShared; use Psr\EventDispatcher\EventDispatcherInterface; use Testo\Application\Internal\SimpleCaseInstantiator; use Testo\Common\ErrorReporter; @@ -30,6 +31,7 @@ * @internal * @psalm-internal Testo\Application */ +#[ScopeShared] final readonly class SuiteRunner { public function __construct( diff --git a/core/Application/Internal/Runner/TestRunner.php b/core/Application/Internal/Runner/TestRunner.php index cd006e31..2c8ca2a0 100644 --- a/core/Application/Internal/Runner/TestRunner.php +++ b/core/Application/Internal/Runner/TestRunner.php @@ -4,6 +4,7 @@ namespace Testo\Application\Internal\Runner; +use Internal\Container\Attribute\ScopeShared; use Psr\EventDispatcher\EventDispatcherInterface; use Testo\Application\Exception\PipelineFailure; use Testo\Core\Context\TestInfo; @@ -37,6 +38,7 @@ * @internal * @psalm-internal Testo\Application */ +#[ScopeShared] final readonly class TestRunner { public function __construct( diff --git a/core/Common/ErrorReporter.php b/core/Common/ErrorReporter.php index b1f0aec9..e77cee35 100644 --- a/core/Common/ErrorReporter.php +++ b/core/Common/ErrorReporter.php @@ -4,6 +4,7 @@ namespace Testo\Common; +use Internal\Container\Attribute\ScopeShared; use Testo\Core\Log\Level; /** @@ -17,6 +18,7 @@ * * @internal */ +#[ScopeShared] final readonly class ErrorReporter { public function __construct( diff --git a/core/Output/ConsoleStreams.php b/core/Output/ConsoleStreams.php index c330e319..5298040c 100644 --- a/core/Output/ConsoleStreams.php +++ b/core/Output/ConsoleStreams.php @@ -4,6 +4,8 @@ namespace Testo\Output; +use Internal\Container\Attribute\ScopeShared; + /** * The process's stdout/stderr as an injectable pair of streams. * @@ -12,6 +14,7 @@ * * @api */ +#[ScopeShared] final readonly class ConsoleStreams { /** @var resource */ diff --git a/core/Pipeline/Internal/AttributesInterceptor.php b/core/Pipeline/Internal/AttributesInterceptor.php index 6e9042c5..f3720d95 100644 --- a/core/Pipeline/Internal/AttributesInterceptor.php +++ b/core/Pipeline/Internal/AttributesInterceptor.php @@ -4,6 +4,7 @@ namespace Testo\Pipeline\Internal; +use Internal\Container\Attribute\ScopeShared; use Testo\Common\Reflection; use Testo\Core\Context\CaseInfo; use Testo\Core\Context\CaseResult; @@ -25,6 +26,7 @@ * @psalm-internal Testo\Pipeline */ #[InterceptorOptions(order: InterceptorOptions::ORDER_ATTRIBUTES)] +#[ScopeShared] final readonly class AttributesInterceptor implements TestRunInterceptor, TestCaseRunInterceptor { public function __construct( diff --git a/core/Testing/Internal/InjectInterceptor.php b/core/Testing/Internal/InjectInterceptor.php index d0ed087b..206e2825 100644 --- a/core/Testing/Internal/InjectInterceptor.php +++ b/core/Testing/Internal/InjectInterceptor.php @@ -4,6 +4,7 @@ namespace Testo\Testing\Internal; +use Internal\Container\Attribute\ScopeShared; use Internal\Container\Container; use Testo\Core\Context\CaseInfo; use Testo\Core\Context\CaseResult; @@ -21,6 +22,7 @@ * @psalm-internal Testo */ #[InterceptorOptions(order: InterceptorOptions::ORDER_DEFAULT)] +#[ScopeShared] final readonly class InjectInterceptor implements TestCaseRunInterceptor { public function __construct( diff --git a/plugin/assert/composer.json b/plugin/assert/composer.json index 3f8407a4..d0cd4ac9 100644 --- a/plugin/assert/composer.json +++ b/plugin/assert/composer.json @@ -21,6 +21,7 @@ ], "require": { "php": ">=8.2", + "internal/container": "^1.1", "testo/testo": "0.10.44 - 1" }, "autoload": { diff --git a/plugin/assert/src/Internal/Middleware/AssertCollectorInterceptor.php b/plugin/assert/src/Internal/Middleware/AssertCollectorInterceptor.php index 6eea016a..3d780d48 100644 --- a/plugin/assert/src/Internal/Middleware/AssertCollectorInterceptor.php +++ b/plugin/assert/src/Internal/Middleware/AssertCollectorInterceptor.php @@ -4,6 +4,7 @@ namespace Testo\Assert\Internal\Middleware; +use Internal\Container\Attribute\ScopeShared; use Testo\Assert\AssertPlugin; use Testo\Assert\Internal\HistoryRenderer; use Testo\Assert\Internal\StaticState; @@ -28,6 +29,7 @@ * Supports both synchronous and asynchronous (Fiber-based) environments. */ #[InterceptorOptions(order: InterceptorOptions::ORDER_ASSERTIONS - 10)] +#[ScopeShared] final readonly class AssertCollectorInterceptor implements TestRunInterceptor { public function __construct( diff --git a/plugin/bench/composer.json b/plugin/bench/composer.json index 3a1cb7c6..4a9577f8 100644 --- a/plugin/bench/composer.json +++ b/plugin/bench/composer.json @@ -21,6 +21,7 @@ ], "require": { "php": ">=8.2", + "internal/container": "^1.1", "testo/data": "^0.1.9", "testo/filter": "^0.1.7", "testo/inline": "^0.1.9", diff --git a/plugin/bench/src/Internal/BenchHandler.php b/plugin/bench/src/Internal/BenchHandler.php index e736623a..f1747809 100644 --- a/plugin/bench/src/Internal/BenchHandler.php +++ b/plugin/bench/src/Internal/BenchHandler.php @@ -4,6 +4,7 @@ namespace Testo\Bench\Internal; +use Internal\Container\Attribute\ScopeShared; use Testo\Assert\Internal\StaticState; use Testo\Assert\State\Assertion\AssertionException; use Testo\Assert\State\Assertion\AssertionSuccess; @@ -21,6 +22,7 @@ /** * @internal */ +#[ScopeShared] final readonly class BenchHandler { public function __construct( diff --git a/plugin/bench/src/Internal/Pipeline/BenchFinder.php b/plugin/bench/src/Internal/Pipeline/BenchFinder.php index 590b7c83..4f4de489 100644 --- a/plugin/bench/src/Internal/Pipeline/BenchFinder.php +++ b/plugin/bench/src/Internal/Pipeline/BenchFinder.php @@ -4,6 +4,7 @@ namespace Testo\Bench\Internal\Pipeline; +use Internal\Container\Attribute\ScopeShared; use Testo\Bench; use Testo\Bench\Internal\BenchHandler; use Testo\Common\Reflection; @@ -22,6 +23,7 @@ * @internal */ #[InterceptorOptions(order: -20_000, testType: TestType::BenchInline)] +#[ScopeShared] final readonly class BenchFinder implements FileLocatorInterceptor, CaseLocatorInterceptor { /** @var \Closure(TestInfo): mixed Invoker for the test method. */ diff --git a/plugin/codecov/composer.json b/plugin/codecov/composer.json index 7fc9c567..7cc31021 100644 --- a/plugin/codecov/composer.json +++ b/plugin/codecov/composer.json @@ -22,6 +22,7 @@ "require": { "php": ">=8.2", "ext-xmlwriter": "*", + "internal/container": "^1.1", "testo/data": "^0.1.9", "testo/inline": "^0.1.8", "testo/testo": "0.10.44 - 1" diff --git a/plugin/codecov/src/Internal/CoverageCollector.php b/plugin/codecov/src/Internal/CoverageCollector.php index 4bf6779a..ac77b418 100644 --- a/plugin/codecov/src/Internal/CoverageCollector.php +++ b/plugin/codecov/src/Internal/CoverageCollector.php @@ -4,6 +4,7 @@ namespace Testo\Codecov\Internal; +use Internal\Container\Attribute\ScopeShared; use Internal\Destroy\Destroyable; use Psr\EventDispatcher\EventDispatcherInterface; use Testo\Codecov\Result\CoverageResult; @@ -21,6 +22,7 @@ * * @internal */ +#[ScopeShared] final readonly class CoverageCollector implements Destroyable { private Cache $cache; diff --git a/plugin/filter/Filter.php b/plugin/filter/Filter.php index 715f58dc..395a1968 100644 --- a/plugin/filter/Filter.php +++ b/plugin/filter/Filter.php @@ -4,6 +4,7 @@ namespace Testo; +use Internal\Container\Attribute\ScopeShared; use Internal\Path; use Testo\Core\Value\TestType; @@ -15,6 +16,7 @@ * * @api */ +#[ScopeShared] final readonly class Filter { /** diff --git a/plugin/filter/composer.json b/plugin/filter/composer.json index 11ec43a5..4494b487 100644 --- a/plugin/filter/composer.json +++ b/plugin/filter/composer.json @@ -21,6 +21,7 @@ ], "require": { "php": ">=8.2", + "internal/container": "^1.1", "testo/testo": "0.10.46 - 1" }, "autoload": { diff --git a/plugin/filter/src/Internal/SuiteFilterInterceptor.php b/plugin/filter/src/Internal/SuiteFilterInterceptor.php index 98c1c92a..230dc571 100644 --- a/plugin/filter/src/Internal/SuiteFilterInterceptor.php +++ b/plugin/filter/src/Internal/SuiteFilterInterceptor.php @@ -4,6 +4,7 @@ namespace Testo\Filter\Internal; +use Internal\Container\Attribute\ScopeShared; use Internal\Path; use Testo\Application\Config\ApplicationConfig; use Testo\Application\Config\FinderConfig; @@ -23,6 +24,7 @@ * @psalm-internal Testo\Filter */ #[InterceptorOptions(order: InterceptorOptions::ORDER_FILTER, onConflict: ConflictPolicy::First)] +#[ScopeShared] final readonly class SuiteFilterInterceptor implements SuiteLocatorInterceptor { public function __construct( From 1197f920a1215bea28afb4a04967a0db033e8a33 Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Tue, 8 Sep 2026 15:06:21 +0400 Subject: [PATCH 02/14] ci(tests): exercise the suite on PHP 8.1 via an ephemeral Rector downgrade MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sources stay on 8.2 (they use `readonly class`), so the 8.1 job installs with `--ignore-platform-req=php`, rewrites `readonly class` into per-property `readonly` in place with rector-downgrade.php, and only then runs the suite. `readonly class` is the single 8.2-exclusive construct here; the affected container services keep their cross-scope sharing through the explicit #[ScopeShared] attribute once the class-level `readonly` is gone. 8.1 runs against the lowest dependency set only — the highest set pulls Symfony 8, which needs PHP 8.4 and cannot run on 8.1. The lowest set resolves to Symfony 6.4/5.4 and internal/path 1.3, all 8.1-compatible. Assisted-By: Claude Opus 4.8 (1M context) --- .github/workflows/tests.yml | 16 +++++++++++++++- rector-downgrade.php | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 1 deletion(-) create mode 100644 rector-downgrade.php diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 411e674c..0f8e9428 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -10,6 +10,7 @@ on: - 'testo.php' - 'composer.json' - 'composer.lock' + - 'rector-downgrade.php' - '.github/workflows/tests.yml' pull_request: paths: @@ -20,6 +21,7 @@ on: - 'testo.php' - 'composer.json' - 'composer.lock' + - 'rector-downgrade.php' - '.github/workflows/tests.yml' jobs: @@ -29,8 +31,13 @@ jobs: strategy: fail-fast: false matrix: - php: [ 8.2, 8.3, 8.4, 8.5 ] + php: [ 8.1, 8.2, 8.3, 8.4, 8.5 ] dependencies: [ lowest , highest ] + exclude: + # 8.1 is exercised only against the lowest, 8.1-compatible dependency set: + # the highest set pulls Symfony 8 (PHP 8.4+), which can't run on 8.1. + - php: 8.1 + dependencies: highest max-parallel: 2 name: Tests PHP${{ matrix.php }}-${{ matrix.dependencies }} @@ -55,6 +62,13 @@ jobs: uses: ramsey/composer-install@v3 with: dependency-versions: ${{ matrix.dependencies }} + # On 8.1 the sources still declare PHP >=8.2 (they use `readonly class`); + # bypass the platform check here and downgrade the code before the tests run. + composer-options: ${{ matrix.php == '8.1' && '--ignore-platform-req=php' || '' }} + + - name: Downgrade sources to PHP 8.1 + if: matrix.php == '8.1' + run: php vendor/bin/rector process --config=rector-downgrade.php --no-progress-bar # Bin tools carry deps with a higher PHP floor than the matrix (e.g. tools/double). # They install everywhere but only load where the platform allows, so ignore the check. diff --git a/rector-downgrade.php b/rector-downgrade.php new file mode 100644 index 00000000..8cc0d7e6 --- /dev/null +++ b/rector-downgrade.php @@ -0,0 +1,34 @@ +withPaths([ + __DIR__ . '/core', + __DIR__ . '/plugin', + __DIR__ . '/bridge', + __DIR__ . '/tests', + __DIR__ . '/testo.php', + ]) + ->withSkip([ + // Resource stubs are templates, not loaded classes, and use newer syntax on purpose. + __DIR__ . '/bridge/symfony-console/resources/stubs', + // Rector rule fixtures carry intentional before/after snippets. + '*.php.inc', + ]) + ->withRules([ + DowngradeReadonlyClassRector::class, + ]); From 84575f73bfb8ef0895aa282be1d80a04395fdd40 Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Tue, 8 Sep 2026 15:18:07 +0400 Subject: [PATCH 03/14] ci(tests): drop the llm/skills plugin on the 8.1 job llm/skills registers a Composer plugin whose post-install hook loads code using `readonly class`, so it fatals during `composer install` on PHP 8.1 before the sources can be downgraded. It is dev-only tooling the suite never touches, so the 8.1 job removes it from the requirements before installing. Assisted-By: Claude Opus 4.8 (1M context) --- .github/workflows/tests.yml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 0f8e9428..3c1d6020 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -58,6 +58,13 @@ jobs: php-version: ${{ matrix.php }} coverage: none + # The llm/skills Composer plugin runs a post-install hook whose code uses + # `readonly class`, so it fatals on 8.1 during install itself — before the + # sources can be downgraded. It is dev-only tooling the suite never needs. + - name: Drop the llm/skills plugin on 8.1 + if: matrix.php == '8.1' + run: composer remove --dev llm/skills --no-update --no-interaction + - name: Install Composer dependencies uses: ramsey/composer-install@v3 with: From 44f8cdec127b0f918a7f33d51a3b5e44b0c64b84 Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Tue, 8 Sep 2026 15:39:51 +0400 Subject: [PATCH 04/14] ci(tests): downgrade for 8.1 via the shared downgrade-php action MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the in-repo rector-downgrade.php step, which ran the project's own Composer-installed Rector and shared the project autoloader — so it eager-loaded the `readonly class` sources and fataled on 8.1 before it could downgrade anything. The php-testo/actions/downgrade-php action installs Rector in isolation and never boots the project autoloader, so the transform runs cleanly. Assisted-By: Claude Opus 4.8 (1M context) --- .github/workflows/tests.yml | 7 ++++--- rector-downgrade.php | 34 ---------------------------------- 2 files changed, 4 insertions(+), 37 deletions(-) delete mode 100644 rector-downgrade.php diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 3c1d6020..975d56a8 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -10,7 +10,6 @@ on: - 'testo.php' - 'composer.json' - 'composer.lock' - - 'rector-downgrade.php' - '.github/workflows/tests.yml' pull_request: paths: @@ -21,7 +20,6 @@ on: - 'testo.php' - 'composer.json' - 'composer.lock' - - 'rector-downgrade.php' - '.github/workflows/tests.yml' jobs: @@ -75,7 +73,10 @@ jobs: - name: Downgrade sources to PHP 8.1 if: matrix.php == '8.1' - run: php vendor/bin/rector process --config=rector-downgrade.php --no-progress-bar + uses: php-testo/actions/downgrade-php@v1 + with: + paths: core plugin bridge tests testo.php + php-version: '8.1' # Bin tools carry deps with a higher PHP floor than the matrix (e.g. tools/double). # They install everywhere but only load where the platform allows, so ignore the check. diff --git a/rector-downgrade.php b/rector-downgrade.php deleted file mode 100644 index 8cc0d7e6..00000000 --- a/rector-downgrade.php +++ /dev/null @@ -1,34 +0,0 @@ -withPaths([ - __DIR__ . '/core', - __DIR__ . '/plugin', - __DIR__ . '/bridge', - __DIR__ . '/tests', - __DIR__ . '/testo.php', - ]) - ->withSkip([ - // Resource stubs are templates, not loaded classes, and use newer syntax on purpose. - __DIR__ . '/bridge/symfony-console/resources/stubs', - // Rector rule fixtures carry intentional before/after snippets. - '*.php.inc', - ]) - ->withRules([ - DowngradeReadonlyClassRector::class, - ]); From 3f97687b734313131e44eaa763dff847b9064cb3 Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Tue, 8 Sep 2026 15:43:05 +0400 Subject: [PATCH 05/14] ci(tests): skip the unparseable console stub during the 8.1 downgrade bridge/symfony-console/resources/stubs/testo.php is a template with syntax Rector can't parse, which aborted the downgrade. Pass it to the action's skip input. Assisted-By: Claude Opus 4.8 (1M context) --- .github/workflows/tests.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 975d56a8..c5b161ac 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -77,6 +77,7 @@ jobs: with: paths: core plugin bridge tests testo.php php-version: '8.1' + skip: bridge/symfony-console/resources/stubs # Bin tools carry deps with a higher PHP floor than the matrix (e.g. tools/double). # They install everywhere but only load where the platform allows, so ignore the check. From 2e7ff077c69a99e3a8b18debe6162c127ea1bbaf Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Tue, 8 Sep 2026 15:53:20 +0400 Subject: [PATCH 06/14] refactor(core): resolve method prototypes without ReflectionMethod::hasPrototype() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit hasPrototype() is a PHP 8.2 method, so on 8.1 (reachable via the downgrade job) the finder fataled with "Call to undefined method". Rector has no rule to rewrite the call, so a private methodPrototype() helper now resolves the overridden parent/interface method directly — parent first, then interfaces — returning null instead of relying on getPrototype()'s throw-on-absence. Behaviour is unchanged on 8.2+ and the self-test suite passes. Assisted-By: Claude Opus 4.8 (1M context) --- core/Common/Reflection.php | 38 ++++++++++++++++++++++++++++++++++---- 1 file changed, 34 insertions(+), 4 deletions(-) diff --git a/core/Common/Reflection.php b/core/Common/Reflection.php index b399a759..e41c4044 100644 --- a/core/Common/Reflection.php +++ b/core/Common/Reflection.php @@ -59,8 +59,11 @@ public static function fetchFunctionAttributes( } } - if ($includePrototypes && $function instanceof \ReflectionMethod && $function->hasPrototype()) { - $function = $function->getPrototype(); + $prototype = $includePrototypes && $function instanceof \ReflectionMethod + ? self::methodPrototype($function) + : null; + if ($prototype !== null) { + $function = $prototype; continue; } @@ -203,8 +206,9 @@ public static function findMethodsWithAttribute( break; } - if ($method->hasPrototype()) { - $method = $method->getPrototype(); + $prototype = self::methodPrototype($method); + if ($prototype !== null) { + $method = $prototype; continue; } @@ -303,4 +307,30 @@ public static function getAttributesFromCallStack( return $attributes; } + + /** + * The parent- or interface-declared method that $method overrides, or null when it declares none. + * + * Resolves the same layer {@see \ReflectionMethod::getPrototype()} points to, but returns null for a + * standalone method instead of throwing, and does so on any supported PHP version. The declaring + * class's parent takes precedence over its interfaces. + */ + private static function methodPrototype(\ReflectionMethod $method): ?\ReflectionMethod + { + $name = $method->getName(); + $declaring = $method->getDeclaringClass(); + + $parent = $declaring->getParentClass(); + if ($parent !== false && $parent->hasMethod($name)) { + return $parent->getMethod($name); + } + + foreach ($declaring->getInterfaces() as $interface) { + if ($interface->hasMethod($name)) { + return $interface->getMethod($name); + } + } + + return null; + } } From 8c99446d6fa85361b0488a7c75c014bcf0668005 Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Tue, 8 Sep 2026 16:00:05 +0400 Subject: [PATCH 07/14] test(output): use closures instead of never-returning arrow fns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fix(bench): guard memory_reset_peak_usage() for PHP 8.1 PHP 8.1 mis-fires the "never-returning function must not return" check on arrow functions whose body is a throw expression (fixed in 8.2), so the three `fn(): never => throw ...` helpers become regular closures that throw in a statement — behaviour is identical on 8.2+. memory_reset_peak_usage() is an 8.2 function with no 8.1 fallback and no Rector downgrade, so it is now called only when it exists; on 8.1 the per-iteration peak reset is simply skipped. Assisted-By: Claude Opus 4.8 (1M context) --- plugin/bench/src/Internal/BenchHandler.php | 2 +- tests/Output/Unit/Rendering/Diff/DifferTest.php | 4 +++- tests/Output/Unit/Terminal/HelperTest.php | 8 ++++++-- 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/plugin/bench/src/Internal/BenchHandler.php b/plugin/bench/src/Internal/BenchHandler.php index f1747809..cc8fa002 100644 --- a/plugin/bench/src/Internal/BenchHandler.php +++ b/plugin/bench/src/Internal/BenchHandler.php @@ -241,7 +241,7 @@ private static function runCase(\Closure $function, int $calls): Snap # peak is reset first so it reflects this iteration only, and the collection cycle keeps # garbage from a previous case out of the window. \gc_collect_cycles(); - \memory_reset_peak_usage(); + \function_exists('memory_reset_peak_usage') and \memory_reset_peak_usage(); $beforeMem = \memory_get_peak_usage(); $beforeTime = \hrtime(true); for ($i = 0; $i < $calls; ++$i) { diff --git a/tests/Output/Unit/Rendering/Diff/DifferTest.php b/tests/Output/Unit/Rendering/Diff/DifferTest.php index 6a6021b4..4c3660ed 100644 --- a/tests/Output/Unit/Rendering/Diff/DifferTest.php +++ b/tests/Output/Unit/Rendering/Diff/DifferTest.php @@ -87,7 +87,9 @@ public function myersScalesToLargeNearlyIdenticalInput(): void public function lcsEmitsNoWarnings(): void { $previous = \set_error_handler( - static fn(int $errno, string $errstr): never => throw new \ErrorException($errstr, $errno), + static function (int $errno, string $errstr): never { + throw new \ErrorException($errstr, $errno); + }, \E_WARNING | \E_NOTICE, ); try { diff --git a/tests/Output/Unit/Terminal/HelperTest.php b/tests/Output/Unit/Terminal/HelperTest.php index 2a055c20..2a3bd6d5 100644 --- a/tests/Output/Unit/Terminal/HelperTest.php +++ b/tests/Output/Unit/Terminal/HelperTest.php @@ -114,7 +114,9 @@ private static function boundaryReflection(): \ReflectionMethod private static function throwViaBoundary(int $code = 0): \Throwable { try { - self::boundaryMarker(static fn(): never => throw new \RuntimeException('boom', $code)); + self::boundaryMarker(static function () use ($code): never { + throw new \RuntimeException('boom', $code); + }); } catch (\Throwable $e) { return $e; } @@ -129,7 +131,9 @@ private static function throwViaBoundary(int $code = 0): \Throwable private static function throwViaArrayMap(): \Throwable { try { - \array_map(static fn(): never => throw new \LogicException('via-map'), [1]); + \array_map(static function (): never { + throw new \LogicException('via-map'); + }, [1]); } catch (\Throwable $e) { return $e; } From 44c5f5df277c1b5c837b82727fa436bc8587d0bd Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Tue, 8 Sep 2026 16:03:17 +0400 Subject: [PATCH 08/14] test(output): skip the memory-comparison diff test on PHP 8.1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit hirschbergUsesFarLessMemoryThanTheLcsTable measures per-call peak allocation, which needs memory_reset_peak_usage() (PHP 8.2+) to reset the peak between the two runs. Without it the second measurement reads the peak the first already raised, so the comparison is meaningless — mark the test skipped there rather than assert on noise. Assisted-By: Claude Opus 4.8 (1M context) --- tests/Output/Unit/Rendering/Diff/DifferStrategiesTest.php | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/Output/Unit/Rendering/Diff/DifferStrategiesTest.php b/tests/Output/Unit/Rendering/Diff/DifferStrategiesTest.php index ff9a9579..4158a106 100644 --- a/tests/Output/Unit/Rendering/Diff/DifferStrategiesTest.php +++ b/tests/Output/Unit/Rendering/Diff/DifferStrategiesTest.php @@ -6,6 +6,7 @@ use Testo\Assert; use Testo\Codecov\Covers; +use Testo\Core\Exception\SkipTest; use Testo\Data\DataCross; use Testo\Data\DataProvider; use Testo\Output\Rendering\Diff\DiffLine; @@ -242,6 +243,10 @@ public function ratcliffStaysCorrectWithPopularLines(): void */ public function hirschbergUsesFarLessMemoryThanTheLcsTable(): void { + \function_exists('memory_reset_peak_usage') or throw new SkipTest( + 'Per-call peak measurement needs memory_reset_peak_usage() (PHP 8.2+).', + ); + $expected = \implode("\n", \array_map(static fn(int $i): string => "row {$i}", \range(1, 400))); $actual = \str_replace('row 200', 'row CHANGED', $expected); From 3664e1bcbe13950cb6a56c7872d8139edca6fe68 Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Tue, 8 Sep 2026 16:51:07 +0400 Subject: [PATCH 09/14] refactor(core): back methodPrototype() with getPrototype() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The hand-rolled parent-then-interface walk only approximated getPrototype() and could diverge on non-trivial hierarchies. Delegate to getPrototype() — the authoritative resolution, available since well before 8.2 — and just normalise its throw-on-absence to null, keeping the call sites unchanged. Assisted-By: Claude Opus 4.8 (1M context) --- core/Common/Reflection.php | 23 ++++++----------------- 1 file changed, 6 insertions(+), 17 deletions(-) diff --git a/core/Common/Reflection.php b/core/Common/Reflection.php index e41c4044..4b5450dc 100644 --- a/core/Common/Reflection.php +++ b/core/Common/Reflection.php @@ -311,26 +311,15 @@ public static function getAttributesFromCallStack( /** * The parent- or interface-declared method that $method overrides, or null when it declares none. * - * Resolves the same layer {@see \ReflectionMethod::getPrototype()} points to, but returns null for a - * standalone method instead of throwing, and does so on any supported PHP version. The declaring - * class's parent takes precedence over its interfaces. + * {@see \ReflectionMethod::getPrototype()} throws rather than returning null for a method that has + * no prototype; this normalises the absence so callers can branch on it directly. */ private static function methodPrototype(\ReflectionMethod $method): ?\ReflectionMethod { - $name = $method->getName(); - $declaring = $method->getDeclaringClass(); - - $parent = $declaring->getParentClass(); - if ($parent !== false && $parent->hasMethod($name)) { - return $parent->getMethod($name); + try { + return $method->getPrototype(); + } catch (\ReflectionException) { + return null; } - - foreach ($declaring->getInterfaces() as $interface) { - if ($interface->hasMethod($name)) { - return $interface->getMethod($name); - } - } - - return null; } } From c0bd969eaecd915324c46e09f012b5fb5dce72c0 Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Tue, 8 Sep 2026 17:16:38 +0400 Subject: [PATCH 10/14] refactor(core): restore ReflectionMethod::hasPrototype() usage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit test(output): restore the never-returning arrow-fn helpers The downgrade path now carries Rector rules for both `ReflectionMethod::hasPrototype()` and `fn(): never => throw ...` (rectorphp/rector-downgrade-php#397, #398), so these hand-written 8.1 workarounds are redundant and the source returns to its plain 8.2 form. The memory_reset_peak_usage() guard and the memory-comparison skip stay — no Rector rule covers those. Assisted-By: Claude Opus 4.8 (1M context) --- core/Common/Reflection.php | 27 +++---------------- .../Output/Unit/Rendering/Diff/DifferTest.php | 4 +-- tests/Output/Unit/Terminal/HelperTest.php | 8 ++---- 3 files changed, 7 insertions(+), 32 deletions(-) diff --git a/core/Common/Reflection.php b/core/Common/Reflection.php index 4b5450dc..b399a759 100644 --- a/core/Common/Reflection.php +++ b/core/Common/Reflection.php @@ -59,11 +59,8 @@ public static function fetchFunctionAttributes( } } - $prototype = $includePrototypes && $function instanceof \ReflectionMethod - ? self::methodPrototype($function) - : null; - if ($prototype !== null) { - $function = $prototype; + if ($includePrototypes && $function instanceof \ReflectionMethod && $function->hasPrototype()) { + $function = $function->getPrototype(); continue; } @@ -206,9 +203,8 @@ public static function findMethodsWithAttribute( break; } - $prototype = self::methodPrototype($method); - if ($prototype !== null) { - $method = $prototype; + if ($method->hasPrototype()) { + $method = $method->getPrototype(); continue; } @@ -307,19 +303,4 @@ public static function getAttributesFromCallStack( return $attributes; } - - /** - * The parent- or interface-declared method that $method overrides, or null when it declares none. - * - * {@see \ReflectionMethod::getPrototype()} throws rather than returning null for a method that has - * no prototype; this normalises the absence so callers can branch on it directly. - */ - private static function methodPrototype(\ReflectionMethod $method): ?\ReflectionMethod - { - try { - return $method->getPrototype(); - } catch (\ReflectionException) { - return null; - } - } } diff --git a/tests/Output/Unit/Rendering/Diff/DifferTest.php b/tests/Output/Unit/Rendering/Diff/DifferTest.php index 4c3660ed..6a6021b4 100644 --- a/tests/Output/Unit/Rendering/Diff/DifferTest.php +++ b/tests/Output/Unit/Rendering/Diff/DifferTest.php @@ -87,9 +87,7 @@ public function myersScalesToLargeNearlyIdenticalInput(): void public function lcsEmitsNoWarnings(): void { $previous = \set_error_handler( - static function (int $errno, string $errstr): never { - throw new \ErrorException($errstr, $errno); - }, + static fn(int $errno, string $errstr): never => throw new \ErrorException($errstr, $errno), \E_WARNING | \E_NOTICE, ); try { diff --git a/tests/Output/Unit/Terminal/HelperTest.php b/tests/Output/Unit/Terminal/HelperTest.php index 2a3bd6d5..2a055c20 100644 --- a/tests/Output/Unit/Terminal/HelperTest.php +++ b/tests/Output/Unit/Terminal/HelperTest.php @@ -114,9 +114,7 @@ private static function boundaryReflection(): \ReflectionMethod private static function throwViaBoundary(int $code = 0): \Throwable { try { - self::boundaryMarker(static function () use ($code): never { - throw new \RuntimeException('boom', $code); - }); + self::boundaryMarker(static fn(): never => throw new \RuntimeException('boom', $code)); } catch (\Throwable $e) { return $e; } @@ -131,9 +129,7 @@ private static function throwViaBoundary(int $code = 0): \Throwable private static function throwViaArrayMap(): \Throwable { try { - \array_map(static function (): never { - throw new \LogicException('via-map'); - }, [1]); + \array_map(static fn(): never => throw new \LogicException('via-map'), [1]); } catch (\Throwable $e) { return $e; } From eb686628541631a3847d27ea39102ab1ba62982a Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Tue, 8 Sep 2026 20:26:56 +0400 Subject: [PATCH 11/14] refactor(console): give the init config template a .stub extension The init command scaffolds testo.php from resources/stubs/testo.php, a template carrying __SRC_PATH__ and __SUITES__ placeholders that is deliberately not valid PHP. Its .php extension made every PHP tool treat it as code: Rector, psalm and php-cs-fixer all had to skip the path, and a downstream downgrade of the shipped package chokes on it. Rename it to testo.php.stub so no PHP tooling ever parses it, and drop the now-redundant Rector skip. Assisted-By: Claude Opus 4.8 (1M context) --- .../resources/stubs/{testo.php => testo.php.stub} | 0 bridge/symfony-console/src/Command/Init.php | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) rename bridge/symfony-console/resources/stubs/{testo.php => testo.php.stub} (100%) diff --git a/bridge/symfony-console/resources/stubs/testo.php b/bridge/symfony-console/resources/stubs/testo.php.stub similarity index 100% rename from bridge/symfony-console/resources/stubs/testo.php rename to bridge/symfony-console/resources/stubs/testo.php.stub diff --git a/bridge/symfony-console/src/Command/Init.php b/bridge/symfony-console/src/Command/Init.php index a57b96c5..33303d5e 100644 --- a/bridge/symfony-console/src/Command/Init.php +++ b/bridge/symfony-console/src/Command/Init.php @@ -27,7 +27,7 @@ )] final class Init extends Command { - private const STUB = __DIR__ . '/../../resources/stubs/testo.php'; + private const STUB = __DIR__ . '/../../resources/stubs/testo.php.stub'; private const CONFIG_FILENAME = 'testo.php'; private const KNOWN_SUITES = ['Unit', 'Integration', 'Functional', 'Acceptance', 'Feature', 'E2E', 'Contract']; private const SCRIPT_ALL_KEY = 'test'; From 72f149a5a9cd113e0b34d7fe69050adc6e5b4b5a Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Tue, 8 Sep 2026 22:56:40 +0400 Subject: [PATCH 12/14] ci: downgrade sources and vendor for the PHP 8.1 leg via install-php MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the inline downgrade (which lowered only the sources, leaving 8.2-only dependencies such as internal/container to fatal at runtime) with php-internal/actions/install-php. It pins the platform to 8.1, so Composer caps every dependency to its newest 8.1-compatible version and downgrades — via a throwaway Rector — both the sources and any dependency with no 8.1 version, including the path-mapped plugins and bridges. Drop the llm/skills plugin on this leg since the self-test does not need it. Assisted-By: Claude Opus 4.8 (1M context) --- .github/workflows/tests.yml | 27 +++++++++++++-------------- 1 file changed, 13 insertions(+), 14 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index c5b161ac..d6f20531 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -56,28 +56,27 @@ jobs: php-version: ${{ matrix.php }} coverage: none - # The llm/skills Composer plugin runs a post-install hook whose code uses - # `readonly class`, so it fatals on 8.1 during install itself — before the - # sources can be downgraded. It is dev-only tooling the suite never needs. - - name: Drop the llm/skills plugin on 8.1 - if: matrix.php == '8.1' - run: composer remove --dev llm/skills --no-update --no-interaction - - name: Install Composer dependencies + if: matrix.php != '8.1' uses: ramsey/composer-install@v3 with: dependency-versions: ${{ matrix.dependencies }} - # On 8.1 the sources still declare PHP >=8.2 (they use `readonly class`); - # bypass the platform check here and downgrade the code before the tests run. - composer-options: ${{ matrix.php == '8.1' && '--ignore-platform-req=php' || '' }} - - name: Downgrade sources to PHP 8.1 + # On 8.1 the sources and several dependencies target 8.2+. Drop the dev tooling the self-test + # does not need, then install-php resolves everything as if on 8.1 (capping each dependency + # to its newest 8.1-compatible version) and downgrades both the sources and any dependency — + # including the path-mapped plugins, bridges and internal/container — that has no 8.1 version. + - name: Slim dev tooling for the PHP 8.1 leg if: matrix.php == '8.1' - uses: php-testo/actions/downgrade-php@v1 + run: composer remove --dev llm/skills --no-update --no-interaction + + - name: Install and downgrade for PHP 8.1 + if: matrix.php == '8.1' + uses: php-internal/actions/install-php@v1 with: - paths: core plugin bridge tests testo.php php-version: '8.1' - skip: bridge/symfony-console/resources/stubs + dependency-versions: ${{ matrix.dependencies }} + paths: core plugin bridge tests testo.php # Bin tools carry deps with a higher PHP floor than the matrix (e.g. tools/double). # They install everywhere but only load where the platform allows, so ignore the check. From 0ec35ef00a62487a62ea51ce0b7f83c416c949fb Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Tue, 8 Sep 2026 23:50:02 +0400 Subject: [PATCH 13/14] ci: point the 8.1 leg at the renamed downgrade action MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit php-internal/actions/install-php was renamed to .../downgrade. The action installs with --no-scripts, so dropping llm/skills beforehand to avoid its post-install hooks is no longer needed — remove that step too. Assisted-By: Claude Opus 4.8 (1M context) --- .github/workflows/tests.yml | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index d6f20531..7341f5a0 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -62,17 +62,9 @@ jobs: with: dependency-versions: ${{ matrix.dependencies }} - # On 8.1 the sources and several dependencies target 8.2+. Drop the dev tooling the self-test - # does not need, then install-php resolves everything as if on 8.1 (capping each dependency - # to its newest 8.1-compatible version) and downgrades both the sources and any dependency — - # including the path-mapped plugins, bridges and internal/container — that has no 8.1 version. - - name: Slim dev tooling for the PHP 8.1 leg - if: matrix.php == '8.1' - run: composer remove --dev llm/skills --no-update --no-interaction - - name: Install and downgrade for PHP 8.1 if: matrix.php == '8.1' - uses: php-internal/actions/install-php@v1 + uses: php-internal/actions/downgrade@v1 with: php-version: '8.1' dependency-versions: ${{ matrix.dependencies }} From ce25bc678b37ba5313488943a882bd5430273b15 Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Fri, 11 Sep 2026 13:04:55 +0400 Subject: [PATCH 14/14] ci: update PHP version matrix to exclude 8.1 and adjust dependencies --- .github/workflows/tests.yml | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 7341f5a0..c79c0155 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -29,11 +29,9 @@ jobs: strategy: fail-fast: false matrix: - php: [ 8.1, 8.2, 8.3, 8.4, 8.5 ] + php: [ 8.2, 8.3, 8.4, 8.5 ] dependencies: [ lowest , highest ] - exclude: - # 8.1 is exercised only against the lowest, 8.1-compatible dependency set: - # the highest set pulls Symfony 8 (PHP 8.4+), which can't run on 8.1. + include: - php: 8.1 dependencies: highest max-parallel: 2