Ir al contenido

Crear una regla personalizada de regresión de versión para Rector

La pipeline de backport de NextPDF toma el código fuente en PHP 8.4 de nextpdf/nextpdf y reduce su versión para que se ejecute en PHP 8.1 (y, para el core, en PHP 7.4). Usa los conjuntos integrados de regresión de versión de Rector para la mayoría de las características del lenguaje, junto con un pequeño conjunto de reglas personalizadas para características que Rector no cubre de forma nativa.

Esta guía muestra cómo agregar una nueva regla personalizada. Conviene seguir la estructura de las tres reglas que ya están en el repositorio: DowngradeAsymmetricVisibilityRector, DowngradeCloneWithRector y DowngradeTraitConstantsRector. Las tres viven en rector/rules/, se registran en rector/config/ y están cubiertas por pruebas basadas en fixtures en tests/Rector/.

Usar esta guía cuando una característica de NextPDF emplee una sintaxis de PHP que el target de compilación no admite y Rector no tenga una regresión de versión integrada para ella. Antes de empezar, confirmar que Rector realmente carece de una regla. La cadena integrada withDowngradeSets() ya gestiona las clases readonly, las constantes de clase tipadas, el operador de tubería y muchas otras características.

Las reglas personalizadas se crean y se ejecutan dentro del repositorio nextpdf-backport. Las herramientas de desarrollo se declaran en su composer.json.

  1. Clonar el repositorio de backport e instalar sus dependencias.
  2. Confirmar que Rector y PHPUnit se resuelven.
Ventana de terminal
git clone https://github.com/nextpdf-labs/backport.git
cd backport
composer install
Ventana de terminal
vendor/bin/rector --version
vendor/bin/phpunit --version

El repositorio requiere PHP 8.4 para ejecutarse (las reglas manipulan árboles de sintaxis de la versión 8.4), aunque la salida de compilación tenga como target PHP 8.1 o 7.4. El composer.jsonrequire fija php en >=8.4 <9.0. Las reglas personalizadas se cargan automáticamente bajo el espacio de nombres NextPDF\Backport\, asignado a rector/rules/; las pruebas se cargan automáticamente bajo NextPDF\Backport\Tests\, asignado a tests/.

Cada regla personalizada extiende Rector\Rector\AbstractRector e implementa tres métodos. El contrato es el mismo en las tres reglas existentes.

MiembroTipoPropósito
getRuleDefinition()RuleDefinitionDescripción legible para humanos, junto con pares before/after de CodeSample para el generador de documentación de reglas.
getNodeTypes()array<class-string<Node>>Las clases de nodo del Abstract Syntax Tree (AST, árbol de sintaxis abstracta) que la regla quiere visitar. Rector llama a refactor() solo para estas.
refactor(Node $node)?Node o `Stmt[]null`

La declaración del tipo de nodo determina todo el flujo. DowngradeAsymmetricVisibilityRector apunta a [Property::class, Param::class] porque la visibilidad asimétrica (public private(set)) puede aparecer tanto en una propiedad de clase como en un parámetro de constructor promovido. DowngradeTraitConstantsRector apunta a [Trait_::class] porque reescribe todo el cuerpo del trait en una sola pasada. DowngradeCloneWithRector apunta a [FileNode::class, Return_::class, Expression::class] porque clone-with (clone($obj, [...])) aparece tanto en posiciones de return como de asignación; usa la visita a FileNode para reiniciar un contador por archivo.

Una regla que devuelve null desde refactor() indica «sin cambios». Una regla que devuelve un nodo indica un reemplazo. Una regla que devuelve Stmt[] (una lista de sentencias) expande una sentencia en varias. Así es como DowngradeCloneWithRector convierte un único return clone($this, [...]); en una asignación de clon, una asignación de propiedad por cada sobrescritura y un return final.

Las dos configuraciones de la pipeline llaman a ->withDowngradeSets(php81: true) y ->withDowngradeSets(php74: true). Esos conjuntos encadenan cada regla integrada de regresión de versión para el target. Las reglas personalizadas existen solo para los huecos: la visibilidad asimétrica de PHP 8.4, el clone-with de PHP 8.5 y las constantes de trait de PHP 8.2. Rector no reduce la versión de ninguna de estas por sí solo. Escribir una regla personalizada solo después de confirmar ese mismo hueco.

Este procedimiento agrega una nueva regla. El ejemplo de referencia refleja la estructura de las reglas existentes; sustituirlo por la característica correspondiente.

  1. Crear la clase de la regla en rector/rules/. Nombrarla Downgrade<Feature>Rector y colocarla en el espacio de nombres NextPDF\Backport para que la recoja la asignación de autocarga existente.
  2. Extender Rector\Rector\AbstractRector y marcar la clase como final.
  3. Implementar getNodeTypes() para que devuelva el conjunto más reducido de clases de nodo AST que necesita la regla. Un conjunto más reducido significa que Rector visita menos nodos.
  4. Implementar refactor(). Asegurar que el nodo sea uno de los tipos declarados, proteger la sintaxis exacta a la que apunta la regla, transformarla y devolver el nuevo nodo (o Stmt[], o null para no hacer ningún cambio).
  5. Implementar getRuleDefinition() con un par before/after de CodeSample por cada caso distinto que la regla gestione.
  6. Mantener el archivo en PHPStan Level 10: cada parámetro, retorno y propiedad está tipado, y los genéricos de PHPDoc describen las formas de los arrays.

La regla de visibilidad asimétrica es el ejemplo completo más compacto. Elimina los indicadores de visibilidad de escritura y garantiza que se mantenga una visibilidad de lectura base:

rector/rules/DowngradeAsymmetricVisibilityRector.php
<?php
declare(strict_types=1);
namespace NextPDF\Backport;
use PhpParser\Modifiers;
use PhpParser\Node;
use PhpParser\Node\Param;
use PhpParser\Node\Stmt\Property;
use Rector\Rector\AbstractRector;
use Symplify\RuleDocGenerator\ValueObject\CodeSample\CodeSample;
use Symplify\RuleDocGenerator\ValueObject\RuleDefinition;
final class DowngradeAsymmetricVisibilityRector extends AbstractRector
{
public function getRuleDefinition(): RuleDefinition
{
return new RuleDefinition(
'Remove asymmetric visibility modifiers (public private(set) -> public)',
[
new CodeSample(
'public private(set) float $x = 0.0;',
'public float $x = 0.0;',
),
],
);
}
/**
* @return array<class-string<Node>>
*/
public function getNodeTypes(): array
{
return [Property::class, Param::class];
}
public function refactor(Node $node): ?Node
{
\assert($node instanceof Property || $node instanceof Param);
if (($node->flags & Modifiers::VISIBILITY_SET_MASK) === 0) {
return null;
}
$node->flags &= ~Modifiers::VISIBILITY_SET_MASK;
if (($node->flags & Modifiers::VISIBILITY_MASK) === 0) {
$node->flags |= Modifiers::PUBLIC;
}
return $node;
}
}

La protección en el primer if es la parte crítica. Cuando la propiedad no tiene indicador de visibilidad de escritura, la regla devuelve null y deja el nodo intacto. Una regla que transforma sin condiciones reescribiría código que no debe tocar.

Cada regla tiene una prueba basada en fixtures que ejecuta la regla contra archivos .php.inc y comprueba que la salida coincida. El arnés proviene de Rector\Testing\PHPUnit\AbstractRectorTestCase de Rector.

Un caso de prueba es pequeño y uniforme en las tres reglas existentes:

tests/Rector/DowngradeTraitConstantsRectorTest.php
<?php
declare(strict_types=1);
namespace NextPDF\Backport\Tests\Rector;
use Iterator;
use PHPUnit\Framework\Attributes\DataProvider;
use Rector\Testing\PHPUnit\AbstractRectorTestCase;
final class DowngradeTraitConstantsRectorTest extends AbstractRectorTestCase
{
#[DataProvider('provideData')]
public function test(string $filePath): void
{
$this->doTestFile($filePath);
}
public static function provideData(): Iterator
{
return self::yieldFilesFromDirectory(__DIR__ . '/Fixtures/DowngradeTraitConstants');
}
public function provideConfigFilePath(): string
{
return __DIR__ . '/config/downgrade_trait_constants.php';
}
}

La prueba apunta a un archivo de configuración por regla en tests/Rector/config/, que registra solo la regla bajo prueba, de modo que cada fixture ejercita una sola regla de forma aislada:

tests/Rector/config/downgrade_trait_constants.php
<?php
declare(strict_types=1);
use NextPDF\Backport\DowngradeTraitConstantsRector;
use Rector\Config\RectorConfig;
return RectorConfig::configure()
->withRules([
DowngradeTraitConstantsRector::class,
]);

Un fixture es un archivo .php.inc con la entrada, un separador ----- y la salida esperada. Cuando la regla no hace ningún cambio, se omiten el separador y el segundo bloque. Un fixture que transforma para la regla de constantes de trait tiene este aspecto:

tests/Rector/Fixtures/DowngradeTraitConstants/private_constant.php.inc
<?php
trait HasLimit
{
private const MAX_SIZE = 1024;
public function getLimit(): int
{
return self::MAX_SIZE;
}
}
?>
-----
<?php
trait HasLimit
{
private static $MAX_SIZE = 1024;
public function getLimit(): int
{
return self::$MAX_SIZE;
}
}
?>

Para crear las pruebas de una nueva regla:

  1. Crear tests/Rector/Fixtures/Downgrade<Feature>/ y agregar un .php.inc por cada caso.
  2. Cubrir tanto los casos que transforman (con un separador -----) como los casos que se omiten (sin separador); por ejemplo, una propiedad sin visibilidad de escritura debe pasar sin cambios.
  3. Agregar un tests/Rector/config/downgrade_<feature>.php que registre solo la regla.
  4. Agregar un tests/Rector/Downgrade<Feature>RectorTest.php que produzca el directorio de fixtures y apunte a la configuración.
  5. Ejecutar la suite.
Ventana de terminal
composer test

El repositorio también incluye RectorRulesBehaviorTest y RectorRulesMetadataTest, que comprueban el comportamiento entre reglas y que el getRuleDefinition() de cada regla esté bien formado. Ejecutar el composer test completo para que esos controles incluyan la nueva regla.

Una regla no está activa en la compilación hasta que se registra en las configuraciones de la pipeline. Hay dos targets de compilación, cada uno con su propia configuración en rector/config/.

  1. Abrir rector/config/rector-php81.php y agregar la clase de regla a la lista ->withRules([...]).
  2. Si la característica también debe reducir su versión para la compilación del core en PHP 7.4, agregar la misma clase a rector/config/rector-php74.php.
  3. Agregar un comentario que nombre la versión de PHP que introdujo la característica, igual que las entradas existentes.
rector/config/rector-php81.php
<?php
declare(strict_types=1);
use NextPDF\Backport\DowngradeAsymmetricVisibilityRector;
use NextPDF\Backport\DowngradeCloneWithRector;
use NextPDF\Backport\DowngradeTraitConstantsRector;
use Rector\Config\RectorConfig;
use Rector\DowngradePhp81\Rector\Property\DowngradeReadonlyPropertyRector;
return RectorConfig::configure()
->withDowngradeSets(php81: true)
->withRules([
// PHP 8.4 — asymmetric visibility (not covered by built-in sets)
DowngradeAsymmetricVisibilityRector::class,
// PHP 8.4 — clone-with syntax (not covered by built-in sets)
DowngradeCloneWithRector::class,
// PHP 8.2 — trait constants (not covered by built-in sets)
DowngradeTraitConstantsRector::class,
// PHP 8.1 — readonly property removal (for clone-with expansion safety)
DowngradeReadonlyPropertyRector::class,
]);

El orquestador de compilación (scripts/build.php) fusiona los repositorios fuente, ejecuta Rector con estas configuraciones, ajusta el composer.json generado y ejecuta una comprobación de sintaxis php -l sobre la salida. Verificar la regla con PHPStan y con la compilación completa antes de depender de ella.

Ventana de terminal
composer analyse
composer build:dry
  • El orden de registro no importa, pero el orden de las reglas sí importa conceptualmente. El mecanismo de múltiples pasadas de Rector vuelve a recorrer el árbol hasta que ninguna regla hace un cambio adicional, así que no es necesario ordenar las reglas a mano en la configuración. Aun así, documentar cualquier dependencia de orden en el docblock de la clase, como hace DowngradeCloneWithRector: su expansión produce $clone->prop = $val, que fallaría en una propiedad readonly, así que DowngradeReadonlyPropertyRector debe ejecutarse para el mismo target.
  • Pasar atributos vacíos al construir un nodo de reemplazo a partir de un tipo de nodo distinto. DowngradeTraitConstantsRector construye una Property a partir de un ClassConst y pasa [] para los atributos en lugar de los atributos del nodo fuente. Arrastrar los atributos originales dejaría un puntero origNode hacia el tipo de nodo equivocado y dispararía una aserción en el impresor que preserva el formato.
  • Reiniciar el estado por archivo en la visita a FileNode. DowngradeCloneWithRector declara FileNode::class en getNodeTypes() únicamente para reiniciar su contador de variables temporales al inicio de cada archivo, de modo que los nombres de variable generados no choquen entre archivos.
  • Proteger con precisión y luego devolver null. Una transformación clone-with debe confirmar que el nombre de la llamada es clone y que el segundo argumento es un literal de array antes de actuar; un clone $obj simple nunca llega a la regla como una llamada de función, y una llamada de dos argumentos cuyo segundo argumento no es un array se deja intacta.
  • Eliminar los modificadores que el target no puede expresar. Cuando la regla de constantes de trait convierte una constante en una propiedad estática, conserva la visibilidad y agrega static, pero no debe llevar un modificador final, porque las propiedades de PHP 8.1 no pueden ser final.
  • Mantener la regla en PHPStan Level 10. El repositorio ejecuta composer analyse en el nivel 10 sobre rector/rules y scripts. Tipar cada firma y anotar las formas de los arrays; una regla que no sobreviviría al analizador es un defecto, no un borrador.