From 0c0bdb1ae826d53203ab83b60a0d7d4db975f91a Mon Sep 17 00:00:00 2001 From: Ichi Date: Thu, 15 Oct 2026 19:46:02 +0500 Subject: [PATCH] fix(rector): leave custom assertion methods unchanged Require a resolved PHPUnit Assert/TestCase declaration before rewriting assertion methods. Preserve custom, magic, trait-required and unresolved calls. Give existing method-call fixtures PHPUnit ancestry and scan reflection-only assertion declarations without loading fake runtime PHPUnit classes. Refs #390 Assisted-By: GPT-6 --- bridge/rector/FEATURE_PARITY.md | 3 + bridge/rector/README.md | 5 + .../src/Internal/PhpunitAssertionCall.php | 19 +- .../assert_contains.php.inc | 4 +- .../assert_count.php.inc | 4 +- .../assert_equals.php.inc | 4 +- .../assert_false.php.inc | 4 +- .../assert_instance_of.php.inc | 4 +- .../assert_not_equals.php.inc | 4 +- .../assert_not_null.php.inc | 4 +- .../assert_not_same.php.inc | 4 +- .../assert_not_true_not_false.php.inc | 4 +- .../assert_null.php.inc | 4 +- .../assert_same_swaps_args.php.inc | 4 +- .../assert_true.php.inc | 4 +- .../custom_methods_unchanged.php.inc | 24 ++ .../AssertCallToTestoRector/fail.php.inc | 4 +- .../message_kept_last.php.inc | 4 +- .../self_static_call.php.inc | 4 +- .../static_static_call.php.inc | 4 +- bridge/rector/src/PhpunitToTesto/TODO.md | 4 + .../array_key.php.inc | 4 +- .../comparison_group.php.inc | 4 +- .../contains_list_property.php.inc | 4 +- .../contains_only.php.inc | 4 +- .../custom_methods_unchanged.php.inc | 50 ++++ .../empty_array_subject.php.inc | 4 +- .../empty_scalar_subject.php.inc | 4 +- .../equals_canonicalizing.php.inc | 4 +- .../TypedAssertCallToTestoRector/json.php.inc | 4 +- .../math_and_resource.php.inc | 4 +- .../not_instance_of_not_has_property.php.inc | 4 +- .../path_permissions.php.inc | 4 +- .../regular_expression.php.inc | 4 +- .../same_size.php.inc | 4 +- .../self_static_call.php.inc | 4 +- .../string_modifiers.php.inc | 4 +- .../string_not_starts_ends_with.php.inc | 4 +- .../type_checks.php.inc | 4 +- .../rector/tests/Stub/PhpunitAssertions.php | 214 ++++++++++++++++++ bridge/rector/tests/Stub/phpstan.neon | 3 + bridge/rector/tests/suites.php | 3 + .../references/phpunit-to-testo-map.md | 4 + 43 files changed, 394 insertions(+), 67 deletions(-) create mode 100644 bridge/rector/src/PhpunitToTesto/AssertCallToTestoRector/custom_methods_unchanged.php.inc create mode 100644 bridge/rector/src/PhpunitToTesto/TypedAssertCallToTestoRector/custom_methods_unchanged.php.inc create mode 100644 bridge/rector/tests/Stub/PhpunitAssertions.php create mode 100644 bridge/rector/tests/Stub/phpstan.neon diff --git a/bridge/rector/FEATURE_PARITY.md b/bridge/rector/FEATURE_PARITY.md index ee441d48..88c4389e 100644 --- a/bridge/rector/FEATURE_PARITY.md +++ b/bridge/rector/FEATURE_PARITY.md @@ -150,6 +150,9 @@ convert as a documented 🟡 (`RepeatRetryRector` / `RepeatRetryToTestoRector`). PHPUnit's `markTestIncomplete` moved off this list — it now converts to a Skipped throw with an `Incomplete: ` reason prefix (`MarkTestIncompleteRector`), a documented lossy 🟡 rather than a ⛔. +PHPUnit assertion methods require a resolved PHPUnit Assert/TestCase declaring class. Custom or +unresolved methods remain manual. + Also done: **declarative skip** — Testo's `#[Skip]` attribute (`testo/skip`) splits the skip rows in two. A skip that states something about the test rather than about a path through it now converts as an attribute in every direction: `SkipAttributeToPhpUnitRector` unrolls it into the leading diff --git a/bridge/rector/README.md b/bridge/rector/README.md index 2062365a..4ee9f4e4 100644 --- a/bridge/rector/README.md +++ b/bridge/rector/README.md @@ -66,6 +66,11 @@ memory-leak / retry / repeat, Pest higher-order & `arch()` tests, etc.) are **not silently dropped**: each is a documented stub rule plus an entry in the direction's `TODO.md`. +### PHPUnit assertion ownership + +Assertion methods must resolve to a declaration on PHPUnit's `Assert` or `TestCase`; keep PHPUnit +resolvable while migrating. An unrelated, overridden, magic or unresolved method is left untouched. + ### Argument order (important) Testo's comparison assertions are `(actual, expected)`; PHPUnit's are `(expected, actual)`. diff --git a/bridge/rector/src/Internal/PhpunitAssertionCall.php b/bridge/rector/src/Internal/PhpunitAssertionCall.php index 8e3eaf99..3bfc6d5b 100644 --- a/bridge/rector/src/Internal/PhpunitAssertionCall.php +++ b/bridge/rector/src/Internal/PhpunitAssertionCall.php @@ -18,6 +18,8 @@ * * Only a call inside a class counts, which is where a test method or a `static` data provider * lives; a call in a free function or at namespace level is left alone. + * Method calls must resolve to PHPUnit's Assert/TestCase declaration. An unresolved receiver, + * magic method, trait requirement, or a user declaration with the same name stays untouched. * * @internal */ @@ -50,7 +52,22 @@ public function name(Node $node): ?string return null; } - return $name; + if ($node instanceof FuncCall) { + return $name; + } + + $scope = $node->getAttribute(AttributeKey::SCOPE); + \assert($scope instanceof Scope); + $class = $scope->getClassReflection(); + if ($class === null || !$class->hasNativeMethod($name)) { + return null; + } + + $method = $class->getNativeMethod($name); + return \in_array($method->getDeclaringClass()->getName(), [ + 'PHPUnit\\Framework\\Assert', + 'PHPUnit\\Framework\\TestCase', + ], true) ? $method->getName() : null; } private function functionName(FuncCall $call): ?string diff --git a/bridge/rector/src/PhpunitToTesto/AssertCallToTestoRector/assert_contains.php.inc b/bridge/rector/src/PhpunitToTesto/AssertCallToTestoRector/assert_contains.php.inc index cfe05540..2ac54765 100644 --- a/bridge/rector/src/PhpunitToTesto/AssertCallToTestoRector/assert_contains.php.inc +++ b/bridge/rector/src/PhpunitToTesto/AssertCallToTestoRector/assert_contains.php.inc @@ -1,6 +1,6 @@ assertSame(1, 2); } +} + +class CustomFlatStaticAssertions +{ + public static function assertTrue($actual): string { return 'custom'; } + public function run(): string { return self::assertTrue(false); } +} + +class MagicFlatAssertions +{ + public function __call(string $name, array $arguments): string { return 'magic'; } + public function run(): string { return $this->assertSame(1, 2); } +} + +class UnresolvedFlatAssertions extends \MissingTesting\TestCase +{ + public function run(): void { $this->assertSame(1, 2); } +} diff --git a/bridge/rector/src/PhpunitToTesto/AssertCallToTestoRector/fail.php.inc b/bridge/rector/src/PhpunitToTesto/AssertCallToTestoRector/fail.php.inc index 58159e48..59d4db70 100644 --- a/bridge/rector/src/PhpunitToTesto/AssertCallToTestoRector/fail.php.inc +++ b/bridge/rector/src/PhpunitToTesto/AssertCallToTestoRector/fail.php.inc @@ -1,6 +1,6 @@ assertIsString(123); + $this->assertIsString(123); + self::assertStringContainsString('x', 123); + return static::assertStringContainsString('x', 123); + } +} + +class InheritedUserAssertions extends UserAssertions +{ + public function run($receiver): string + { + return $this->assertIsString(123); + } +} + +class OverriddenUserAssertions extends UserAssertions +{ + public function assertIsString($actual): string { return 'overridden'; } + + public function run($receiver): string + { + return $this->assertIsString(123); + } +} + +class MagicAssertions +{ + public function __call(string $name, array $arguments): string { return 'magic'; } + public function run(): string { return $this->assertIsString(123); } +} + +class UnresolvedTypedAssertions extends \MissingTesting\TestCase +{ + public function run(): void { $this->assertIsString(123); } +} + +trait RequiredUserAssertions +{ + abstract public function assertIsString($actual): mixed; + public function run(): mixed { return $this->assertIsString(123); } +} diff --git a/bridge/rector/src/PhpunitToTesto/TypedAssertCallToTestoRector/empty_array_subject.php.inc b/bridge/rector/src/PhpunitToTesto/TypedAssertCallToTestoRector/empty_array_subject.php.inc index d601150e..7420df1e 100644 --- a/bridge/rector/src/PhpunitToTesto/TypedAssertCallToTestoRector/empty_array_subject.php.inc +++ b/bridge/rector/src/PhpunitToTesto/TypedAssertCallToTestoRector/empty_array_subject.php.inc @@ -1,6 +1,6 @@ phpstanConfig(__DIR__ . '/Stub/phpstan.neon'); + /** * Test suites for the Rector bridge. * diff --git a/skills/testo-migrate-from-phpunit/references/phpunit-to-testo-map.md b/skills/testo-migrate-from-phpunit/references/phpunit-to-testo-map.md index 25f43595..b1122ad5 100644 --- a/skills/testo-migrate-from-phpunit/references/phpunit-to-testo-map.md +++ b/skills/testo-migrate-from-phpunit/references/phpunit-to-testo-map.md @@ -7,6 +7,10 @@ row by hand. When in doubt about an attribute, read its class in the installed ` Testo is similar in spirit to PHPUnit but **not source-compatible**. Never run a blind regex pass — the assertion **argument order flips** (see the pitfalls), and discovery is attribute-based. +Rector converts assertion methods only when their declaring class resolves to PHPUnit's `Assert` +or `TestCase`. Keep PHPUnit available during migration; custom, overridden, magic and unresolved +methods remain untouched. + ## Which rows Rector handles | Handled automatically by the `phpunit-to-testo` Rector set | Needs AI/human work (no faithful rule) |