Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
275 changes: 275 additions & 0 deletions tests/Instrument/Transformer/Php85AuditScratchTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,275 @@
<?php

declare(strict_types = 1);
/*
* PHP 8.5 AUDIT HARNESS (temporary, lives only on the audit branch).
* Weaves PHP 8.1-8.5 feature fixtures from _files/audit through the real
* WeavingTransformer and validates that both the woven trait and the
* generated proxy still lint and preserve the feature under test.
*/

namespace Go\Instrument\Transformer;

use Go\Aop\Advisor;
use Go\Core\AdviceMatcherInterface;
use Go\Core\AspectContainer;
use Go\Core\AspectKernel;
use Go\Core\AspectLoader;
use Go\Instrument\ClassLoading\CachePathManager;
use Go\VirtualFileSystem\FileSystem;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
use ReflectionClass;

#[\PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations]
class Php85AuditScratchTest extends TestCase
{
private const FIXTURE_DIR = __DIR__ . '/../../Stubs';

/** Audit fixture stubs living in tests/Stubs alongside the general-purpose stubs. */
private const AUDIT_FIXTURES = [
'Collaborator',
'ConstAttr',
'ExprAttr',
'Php80ClassAttrPlain',
'Php80GlobalConstAttrArg',
'Php81EnumConstExprCases',
'Php81NewInInitializers',
'Php81NonScalarAttributeArgs',
'Php85CloneWith',
'Php85ClosuresInConstExpr',
'Php85ConstAttributes',
'Php85FinalPromotionAsymStatic',
'Php85NoDiscard',
'Php85PipeOperator',
'RichAttr',
'Status',
];

protected static FileSystem $fileSystem;

protected WeavingTransformer $transformer;

protected ?AspectKernel $kernel;

protected ?CachePathManager $cachePathManager;

public static function setUpBeforeClass(): void
{
static::$fileSystem = FileSystem::mount('vfs');
if (!is_dir(self::outDir())) {
mkdir(self::outDir(), 0777, true);
}
}

private static function outDir(): string
{
return sys_get_temp_dir() . '/php85-audit-out';
}

public static function tearDownAfterClass(): void
{
static::$fileSystem->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<string, array{string}>
*/
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<string> 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<string>
*/
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;
}
}
12 changes: 12 additions & 0 deletions tests/Stubs/Collaborator.php
Comment thread
lisachenko marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<?php

declare(strict_types=1);

namespace Go\Stubs;

class Collaborator
{
public function __construct(public string $tag = 'default')
{
}
}
13 changes: 13 additions & 0 deletions tests/Stubs/ConstAttr.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<?php

declare(strict_types=1);

namespace Go\Stubs;

#[\Attribute(\Attribute::TARGET_CONSTANT | \Attribute::TARGET_CLASS_CONSTANT)]
class ConstAttr
{
public function __construct(public string $reason = '')
{
}
}
13 changes: 13 additions & 0 deletions tests/Stubs/ExprAttr.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<?php

declare(strict_types=1);

namespace Go\Stubs;

#[\Attribute(\Attribute::TARGET_ALL)]
class ExprAttr
{
public function __construct(public mixed $value = null)
{
}
}
14 changes: 14 additions & 0 deletions tests/Stubs/Php80ClassAttrPlain.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<?php

declare(strict_types=1);

namespace Go\Stubs;

#[ExprAttr]
class Php80ClassAttrPlain
{
public function run(): int
{
return 42;
}
}
14 changes: 14 additions & 0 deletions tests/Stubs/Php80GlobalConstAttrArg.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<?php

declare(strict_types=1);

namespace Go\Stubs;

class Php80GlobalConstAttrArg
{
#[ExprAttr(PHP_INT_MAX)]
public function limited(): int
{
return PHP_INT_MAX;
}
}
19 changes: 19 additions & 0 deletions tests/Stubs/Php81EnumConstExprCases.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<?php

declare(strict_types=1);

namespace Go\Stubs;

enum Php81EnumConstExprCases: int
{
private const int SHIFT = 2;

case Negative = -1;
case Shifted = 1 << 2;
case FromConst = self::SHIFT + 10;

public function describe(): string
{
return $this->name . '=' . $this->value;
}
}
20 changes: 20 additions & 0 deletions tests/Stubs/Php81NewInInitializers.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<?php

declare(strict_types=1);

namespace Go\Stubs;

class Php81NewInInitializers
{
public function __construct(
private Collaborator $service = new Collaborator('ctor-default'),
) {
}

public function run(Collaborator $helper = new Collaborator('method-default')): string
{
static $memo = new \ArrayObject();

return $this->service->tag . '/' . $helper->tag;
}
}
Loading