diff --git a/tests/Instrument/Transformer/Php85AuditScratchTest.php b/tests/Instrument/Transformer/Php85AuditScratchTest.php new file mode 100644 index 00000000..fbdc1106 --- /dev/null +++ b/tests/Instrument/Transformer/Php85AuditScratchTest.php @@ -0,0 +1,275 @@ +unmount(); + } + + public function setUp(): void + { + $container = $this->getContainerMock(); + $loader = $this + ->getMockBuilder(AspectLoader::class) + ->setConstructorArgs([$container]) + ->getMock(); + + $this->kernel = $this->getKernelMock( + [ + 'appDir' => dirname(__DIR__), + 'cacheDir' => 'vfs://', + 'cacheFileMode' => 0770, + 'includePaths' => [], + 'excludePaths' => [] + ], + $container + ); + $this->cachePathManager = new CachePathManager($this->kernel); + + $this->transformer = new WeavingTransformer( + $this->kernel, + $this->getInterceptEverythingMatcher(), + $this->cachePathManager, + $loader + ); + } + + /** + * @return array + */ + public static function fixtureNames(): array + { + $names = []; + foreach (self::AUDIT_FIXTURES as $name) { + $names[$name] = [$name]; + } + + return $names; + } + + /** + * Fixtures currently known to produce a broken weave, keyed to their tracking issue. + * A fix PR that resolves one of these MUST remove the entry (the test then asserts success). + */ + private const KNOWN_GAPS = [ + // #598-#603 are all fixed on master. Remaining follow-ups (#615/#616, fixed by PR #617): + // #[\Attribute] on a trait only became a compile error in PHP 8.5, + // so these three are gaps on 8.5+ but weave cleanly on 8.4 + 'ConstAttr' => 'https://github.com/goaop/framework/issues/615', + 'ExprAttr' => 'https://github.com/goaop/framework/issues/615', + 'RichAttr' => 'https://github.com/goaop/framework/issues/615', + // new-in-initializer default copied onto the proxy hook property + 'Php81NewInInitializers' => 'https://github.com/goaop/framework/issues/616', + ]; + + /** Fixtures whose KNOWN_GAPS entry applies only on PHP >= 8.5 (see above). */ + private const GAP_ONLY_ON_85 = ['ConstAttr' => true, 'ExprAttr' => true, 'RichAttr' => true]; + + #[DataProvider('fixtureNames')] + public function testWeaveAndLint(string $name): void + { + // 8.5-only syntax cannot lint (nor natively reflect) on older runtimes + if (str_starts_with($name, 'Php85') && PHP_VERSION_ID < 80500) { + $this->markTestSkipped('Fixture uses PHP 8.5 syntax'); + } + + $problems = $this->weaveAndCollectProblems($name); + + $isKnownGap = isset(self::KNOWN_GAPS[$name]) + && (!isset(self::GAP_ONLY_ON_85[$name]) || PHP_VERSION_ID >= 80500); + + if ($isKnownGap) { + $issue = self::KNOWN_GAPS[$name]; + $this->assertNotSame( + [], + $problems, + "$name weaves cleanly now — the gap tracked in $issue looks fixed. " . + 'Remove it from KNOWN_GAPS so this stays asserted.' + ); + $this->addToAssertionCount(1); + + return; + } + + $this->assertSame([], $problems, "$name should weave cleanly:\n" . implode("\n---\n", $problems)); + } + + /** + * @return list Problems encountered (transform exception or lint failures); empty = clean weave + */ + private function weaveAndCollectProblems(string $name): array + { + $metadata = $this->loadAuditMetadata($name); + + try { + $this->transformer->transform($metadata); + } catch (\Throwable $e) { + file_put_contents(self::outDir() . "/$name.ERROR.txt", (string) $e); + + return ["TRANSFORM ERROR: {$e->getMessage()}"]; + } + + $problems = []; + $woven = $metadata->source; + file_put_contents(self::outDir() . "/$name-woven.php", $woven); + $problems = [...$problems, ...$this->lintProblems(self::outDir() . "/$name-woven.php", "$name woven trait")]; + + if (preg_match_all("/AOP_CACHE_DIR . '(.+)';$/m", $woven, $matches)) { + foreach ($matches[1] as $i => $proxyPath) { + $proxyContent = (string) file_get_contents('vfs://' . $proxyPath); + $suffix = $i > 0 ? "-$i" : ''; + file_put_contents(self::outDir() . "/$name-proxy$suffix.php", $proxyContent); + $problems = [...$problems, ...$this->lintProblems(self::outDir() . "/$name-proxy$suffix.php", "$name proxy #$i")]; + } + } + + return $problems; + } + + /** + * @return list + */ + private function lintProblems(string $file, string $label): array + { + exec(escapeshellarg(PHP_BINARY) . ' -l ' . escapeshellarg($file) . ' 2>&1', $output, $code); + if ($code !== 0) { + return ["$label does not lint:\n" . implode("\n", $output)]; + } + + return []; + } + + private function getInterceptEverythingMatcher(): AdviceMatcherInterface + { + $mock = $this->createMock(AdviceMatcherInterface::class); + $mock + ->method('getAdvicesForClass') + ->willReturnCallback(function (ReflectionClass $refClass) { + $advices = []; + foreach ($refClass->getMethods() as $method) { + if ($method->getDeclaringClass()->name !== $refClass->name) { + continue; + } + $advisorId = "advisor.{$refClass->name}->{$method->name}"; + $advices[AspectContainer::METHOD_PREFIX][$method->name][$advisorId] = true; + } + foreach ($refClass->getProperties() as $property) { + if ($property->getDeclaringClass()->name !== $refClass->name) { + continue; + } + // Mirror the real AdviceMatcher gates (static/readonly/hooked are not interceptable) + if ($property->isStatic() || $property->isReadOnly() || $property->hasHooks()) { + continue; + } + $advisorId = "advisor.{$refClass->name}->{$property->name}"; + $advices[AspectContainer::PROPERTY_PREFIX][$property->name][$advisorId] = true; + } + return $advices; + }); + $mock->method('getAdvicesForFunctions')->willReturn([]); + + return $mock; + } + + protected function getKernelMock(array $options, AspectContainer $container): AspectKernel + { + $mock = $this->getMockBuilder(AspectKernel::class) + ->disableOriginalConstructor() + ->onlyMethods(['configureAop', 'getOptions', 'getContainer', 'hasFeature']) + ->getMock(); + + $mock->method('getOptions')->willReturn($options); + $mock->method('getContainer')->willReturn($container); + + return $mock; + } + + private function loadAuditMetadata(string $name): StreamMetaData + { + $fileName = self::FIXTURE_DIR . '/' . $name . '.php'; + $stream = fopen('php://filter/string.tolower/resource=' . $fileName, 'r'); + $source = file_get_contents($fileName); + $metadata = new StreamMetaData($stream, $source); + fclose($stream); + + return $metadata; + } + + private function getContainerMock(): AspectContainer + { + $container = $this->createMock(AspectContainer::class); + $container + ->method('getServicesByInterface') + ->willReturnMap([ + [Advisor::class, []] + ]); + + return $container; + } +} diff --git a/tests/Stubs/Collaborator.php b/tests/Stubs/Collaborator.php new file mode 100644 index 00000000..ca9e5775 --- /dev/null +++ b/tests/Stubs/Collaborator.php @@ -0,0 +1,12 @@ +name . '=' . $this->value; + } +} diff --git a/tests/Stubs/Php81NewInInitializers.php b/tests/Stubs/Php81NewInInitializers.php new file mode 100644 index 00000000..a596a4a9 --- /dev/null +++ b/tests/Stubs/Php81NewInInitializers.php @@ -0,0 +1,20 @@ +service->tag . '/' . $helper->tag; + } +} diff --git a/tests/Stubs/Php81NonScalarAttributeArgs.php b/tests/Stubs/Php81NonScalarAttributeArgs.php new file mode 100644 index 00000000..a870d35b --- /dev/null +++ b/tests/Stubs/Php81NonScalarAttributeArgs.php @@ -0,0 +1,15 @@ + $name]); + } + + public function bump(): static + { + return clone($this, ['count' => $this->count + 1, 'name' => $this->name . '+']); + } +} diff --git a/tests/Stubs/Php85ClosuresInConstExpr.php b/tests/Stubs/Php85ClosuresInConstExpr.php new file mode 100644 index 00000000..0742f3f6 --- /dev/null +++ b/tests/Stubs/Php85ClosuresInConstExpr.php @@ -0,0 +1,25 @@ +identity . '#' . $this->version; + } +} diff --git a/tests/Stubs/Php85NoDiscard.php b/tests/Stubs/Php85NoDiscard.php new file mode 100644 index 00000000..f2d266d2 --- /dev/null +++ b/tests/Stubs/Php85NoDiscard.php @@ -0,0 +1,19 @@ +computeTotal(1, 2); + } +} diff --git a/tests/Stubs/Php85PipeOperator.php b/tests/Stubs/Php85PipeOperator.php new file mode 100644 index 00000000..dd9b7596 --- /dev/null +++ b/tests/Stubs/Php85PipeOperator.php @@ -0,0 +1,21 @@ + trim(...) + |> (fn(string $x) => strtoupper($x)) + |> strrev(...); + } + + public function withNewOnRhs(string $value): \ArrayObject + { + return [$value] |> (fn(array $items) => new \ArrayObject($items)); + } +} diff --git a/tests/Stubs/RichAttr.php b/tests/Stubs/RichAttr.php new file mode 100644 index 00000000..e67e19f9 --- /dev/null +++ b/tests/Stubs/RichAttr.php @@ -0,0 +1,15 @@ +