ข้ามไปยังเนื้อหา

การเขียนกฎ Rector downgrade แบบกำหนดเอง

NextPDF backport pipeline จะ downgrade ซอร์ส PHP 8.4 ของ nextpdf/nextpdf เพื่อให้ทำงานบน PHP 8.1 ได้ และให้ core ทำงานบน PHP 7.4 ได้ด้วย pipeline นี้ใช้ downgrade set ในตัวของ Rector สำหรับฟีเจอร์ภาษาส่วนใหญ่ ควบคู่กับชุดกฎกำหนดเองขนาดเล็กสำหรับฟีเจอร์ที่ Rector ยังไม่ครอบคลุม

คู่มือนี้อธิบายวิธีเพิ่มกฎกำหนดเองใหม่ โดยใช้โครงสร้างเดียวกับกฎสามตัวที่มีอยู่แล้วใน repository ได้แก่ DowngradeAsymmetricVisibilityRector, DowngradeCloneWithRector และ DowngradeTraitConstantsRector ทั้งสามตัวอยู่ใน rector/rules/ ลงทะเบียนไว้ใน rector/config/ และมีการทดสอบแบบ fixture อยู่ใน tests/Rector/

ใช้คู่มือนี้เมื่อฟีเจอร์ของ NextPDF ใช้ไวยากรณ์ PHP ที่ build target ไม่รองรับ และ Rector ไม่มี downgrade ในตัวสำหรับฟีเจอร์นั้น ก่อนเริ่ม ให้ยืนยันว่า Rector ยังไม่มีกฎดังกล่าวจริงๆ เพราะ withDowngradeSets() ในตัวรองรับ readonly class, typed class constant, pipe operator และฟีเจอร์อื่นๆ อีกมากอยู่แล้ว

เขียนและรันกฎกำหนดเองภายใน repository nextpdf-backport โดย composer.json ของ repository นี้ประกาศเครื่องมือสำหรับการพัฒนาไว้แล้ว

  1. โคลน backport repository และติดตั้ง dependency ของ repository นั้น
  2. ตรวจสอบว่า Rector และ PHPUnit ถูก resolve เรียบร้อยแล้ว
Terminal window
git clone https://github.com/nextpdf-labs/backport.git
cd backport
composer install
Terminal window
vendor/bin/rector --version
vendor/bin/phpunit --version

repository ต้องใช้ PHP 8.4 ในการรัน เพราะกฎเหล่านี้จัดการกับ syntax tree ของ 8.4 แม้ว่าผลลัพธ์ของ build จะกำหนดเป้าหมายเป็น PHP 8.1 หรือ 7.4 ก็ตาม composer.jsonrequire ตรึง php ไว้ที่ >=8.4 <9.0 กฎกำหนดเอง autoload ภายใต้ namespace NextPDF\Backport\ ซึ่ง map ไปยัง rector/rules/ ส่วนการทดสอบ autoload ภายใต้ NextPDF\Backport\Tests\ ซึ่ง map ไปยัง tests/

กฎกำหนดเองทุกตัว extends Rector\Rector\AbstractRector และ implement เมท็อดสามรายการต่อไปนี้ กฎทั้งสามตัวที่มีอยู่ใช้ contract เดียวกัน

สมาชิกชนิดวัตถุประสงค์
getRuleDefinition()RuleDefinitionคำอธิบายที่อ่านเข้าใจได้ พร้อมคู่ before/after CodeSample สำหรับตัวสร้างเอกสารของกฎ
getNodeTypes()array<class-string<Node>>คลาส node ของ Abstract Syntax Tree (AST) ที่กฎเข้าไปตรวจ Rector จะเรียก refactor() เฉพาะกับคลาสเหล่านี้เท่านั้น
refactor(Node $node)?Node หรือ `Stmt[]null`

การประกาศชนิด node คือสิ่งที่ขับเคลื่อนกฎ DowngradeAsymmetricVisibilityRector กำหนดเป้าหมายเป็น [Property::class, Param::class] เพราะ asymmetric visibility (public private(set)) อาจปรากฏได้ทั้งบน property ของคลาสและบนพารามิเตอร์คอนสตรักเตอร์แบบ promoted DowngradeTraitConstantsRector กำหนดเป้าหมายเป็น [Trait_::class] เพราะต้องเขียนทับเนื้อหาทั้งหมดของ trait ในรอบเดียว DowngradeCloneWithRector กำหนดเป้าหมายเป็น [FileNode::class, Return_::class, Expression::class] เพราะ clone-with (clone($obj, [...])) อาจปรากฏได้ทั้งในตำแหน่ง return และตำแหน่งของการกำหนดค่า โดยใช้การ visit FileNode เพื่อรีเซ็ตตัวนับแบบต่อไฟล์

กฎที่คืนค่า null จาก refactor() หมายถึง “ไม่มีการเปลี่ยนแปลง” กฎที่คืนค่า node หมายถึงมีการแทนที่ ส่วนกฎที่คืนค่า Stmt[] ซึ่งเป็นรายการของ statement จะขยายหนึ่ง statement ออกเป็นหลายรายการ นี่คือวิธีที่ DowngradeCloneWithRector เปลี่ยน return clone($this, [...]); เพียงรายการเดียวให้เป็นการกำหนดค่า clone ตามด้วยการกำหนดค่า property หนึ่งรายการต่อหนึ่ง override และ return สุดท้าย

config ของ pipeline ทั้งสองตัวเรียก ->withDowngradeSets(php81: true) และ ->withDowngradeSets(php74: true) set เหล่านั้นจะเรียกใช้กฎ downgrade ในตัวทุกตัวสำหรับ target ดังกล่าว กฎกำหนดเองมีไว้เฉพาะสำหรับช่องว่างเท่านั้น ได้แก่ asymmetric visibility ของ PHP 8.4, clone-with ของ PHP 8.5 และ trait constants ของ PHP 8.2 Rector ไม่ downgrade รายการเหล่านี้ด้วยตัวเอง ให้เขียนกฎกำหนดเองหลังจากยืนยันแล้วว่ามีช่องว่างลักษณะเดียวกันเท่านั้น

ขั้นตอนนี้ใช้สำหรับเพิ่มกฎใหม่ ตัวอย่างประกอบสะท้อนรูปแบบของกฎที่มีอยู่เดิม ให้แทนที่ด้วยฟีเจอร์ที่ต้องการรองรับ

  1. สร้างคลาสของกฎใน rector/rules/ ตั้งชื่อว่า Downgrade<Feature>Rector และวางไว้ใน namespace NextPDF\Backport เพื่อให้การ map autoload ที่มีอยู่เดิมรับไปใช้ได้
  2. extend Rector\Rector\AbstractRector และทำเครื่องหมายคลาสเป็น final ด้วย
  3. implement getNodeTypes() ให้คืนชุดคลาส node ของ AST ที่แคบที่สุดเท่าที่กฎต้องการ ชุดที่แคบกว่าจะทำให้ Rector visit node น้อยลง
  4. implement refactor() โดย assert ว่า node เป็นหนึ่งในชนิดที่ประกาศไว้ ใส่ guard สำหรับไวยากรณ์ที่ต้องการกำหนดเป้าหมายโดยเฉพาะ แปลงไวยากรณ์นั้น แล้วคืนค่า node ใหม่ Stmt[] หรือ null เมื่อไม่มีการเปลี่ยนแปลง
  5. implement getRuleDefinition() โดยมี before/after CodeSample หนึ่งชุดสำหรับแต่ละกรณีที่กฎจัดการแตกต่างกัน
  6. รักษาไฟล์ให้อยู่ที่ PHPStan Level 10: กำหนดชนิดให้กับทุกพารามิเตอร์ ค่าคืน และ property พร้อมใช้ PHPDoc generics เพื่ออธิบายรูปแบบของอาร์เรย์

กฎ asymmetric-visibility เป็นตัวอย่างที่สมบูรณ์และเล็กที่สุด กฎนี้ลบ flag ของ set-visibility และตรวจสอบว่ายังคงมี read visibility พื้นฐานเหลืออยู่:

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;
}
}

การ guard ที่ if ตัวแรกมีความสำคัญอย่างยิ่ง เมื่อ property ไม่มี flag ของ set-visibility กฎจะคืนค่า null และปล่อยให้ node คงเดิม กฎที่แปลงโดยไม่มีเงื่อนไขจะเขียนทับโค้ดที่ควรปล่อยไว้ตามเดิม

กฎแต่ละตัวมีการทดสอบแบบ fixture ที่รันกฎกับไฟล์ .php.inc และ assert ว่าผลลัพธ์ตรงกัน harness มาจาก Rector\Testing\PHPUnit\AbstractRectorTestCase ของ Rector

test case มีขนาดเล็กและใช้รูปแบบสอดคล้องกันในกฎทั้งสามตัวที่มีอยู่:

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';
}
}

การทดสอบชี้ไปยังไฟล์ config แบบต่อกฎใน tests/Rector/config/ ซึ่งลงทะเบียนเฉพาะกฎที่กำลังทดสอบเท่านั้น ดังนั้นแต่ละ fixture จึงทดสอบกฎเพียงตัวเดียวแบบแยกอิสระ:

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,
]);

fixture คือไฟล์ .php.inc ที่มีอินพุต ตัวคั่น ----- และผลลัพธ์ที่คาดหวัง เมื่อกฎไม่ได้ทำการเปลี่ยนแปลง ให้ละตัวคั่นและบล็อกที่สองไว้ fixture ที่มีการแปลงสำหรับกฎ trait-constants มีลักษณะดังนี้:

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;
}
}
?>

ในการเขียนการทดสอบสำหรับกฎใหม่:

  1. สร้าง tests/Rector/Fixtures/Downgrade<Feature>/ และเพิ่ม .php.inc หนึ่งไฟล์ต่อหนึ่งกรณี
  2. ครอบคลุมทั้งกรณีที่มีการแปลงด้วยตัวคั่น ----- และกรณีที่ข้ามโดยไม่มีตัวคั่น เช่น property ที่ไม่มี set visibility ซึ่งต้องผ่านไปโดยไม่เปลี่ยนแปลง
  3. เพิ่ม tests/Rector/config/downgrade_<feature>.php ที่ลงทะเบียนเฉพาะกฎใหม่เท่านั้น
  4. เพิ่ม tests/Rector/Downgrade<Feature>RectorTest.php ที่ yield ไดเรกทอรี fixture และชี้ไปที่ config
  5. รันชุดทดสอบ
Terminal window
composer test

repository ยังมี RectorRulesBehaviorTest และ RectorRulesMetadataTest ซึ่ง assert พฤติกรรมข้ามกฎ และยืนยันว่า getRuleDefinition() ของกฎแต่ละตัวมีรูปแบบที่ถูกต้อง ให้รัน composer test แบบเต็ม เพื่อให้ gate เหล่านั้นเห็นกฎใหม่

กฎจะยังไม่ทำงานใน build จนกว่าจะลงทะเบียนใน config ของ pipeline มี build target สองตัว และแต่ละตัวมี config ของตัวเองใน rector/config/ ดังนี้

  1. เปิด rector/config/rector-php81.php และเพิ่มคลาสของกฎใหม่ลงในรายการ ->withRules([...])
  2. หากฟีเจอร์ต้องถูก downgrade สำหรับ core build ของ PHP 7.4 ด้วย ให้เพิ่มคลาสเดียวกันลงใน rector/config/rector-php74.php เช่นกัน
  3. เพิ่มคอมเมนต์ระบุเวอร์ชัน PHP ที่นำฟีเจอร์นั้นเข้ามา โดยให้สอดคล้องกับรายการที่มีอยู่เดิม
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,
]);

ตัว orchestrator ของ build (scripts/build.php) จะ merge ซอร์สจาก repository รัน Rector ด้วย config เหล่านี้ ปรับ composer.json ที่สร้างขึ้น และรันการตรวจไวยากรณ์ php -l ทั่วผลลัพธ์ ตรวจสอบกฎใหม่ด้วย PHPStan และการ build แบบเต็มก่อนนำไปใช้จริง

Terminal window
composer analyse
composer build:dry
  • ลำดับการลงทะเบียนไม่สำคัญ แต่ลำดับของกฎสำคัญในเชิงแนวคิด กลไกแบบหลายรอบ (multi-pass) ของ Rector จะวนซ้ำจนกว่าจะไม่มีกฎใดทำการเปลี่ยนแปลงอีก ดังนั้นจึงไม่ต้องเรียงลำดับกฎด้วยมือใน config อย่างไรก็ตาม ให้บันทึก dependency ด้านลำดับใดๆ ไว้ใน docblock ของคลาส เช่นที่ DowngradeCloneWithRector ทำไว้: การขยายของกฎนี้สร้าง $clone->prop = $val ซึ่งจะล้มเหลวบน readonly property ดังนั้น DowngradeReadonlyPropertyRector จึงต้องรันสำหรับ target เดียวกัน
  • ส่ง attribute ว่างเมื่อสร้าง node แทนที่จาก node ต่างชนิดกัน DowngradeTraitConstantsRector สร้าง Property จาก ClassConst และส่ง [] เป็น attribute แทนการใช้ attribute ของ node ต้นทาง หากสืบทอด attribute เดิมมา ตัวชี้ origNode จะชี้ไปยัง node ผิดชนิด และไปสะดุด assertion ใน printer ที่รักษารูปแบบ (format-preserving printer)
  • รีเซ็ตสถานะแบบต่อไฟล์เมื่อ visit FileNode DowngradeCloneWithRector ประกาศ FileNode::class ใน getNodeTypes() เพียงเพื่อรีเซ็ตตัวนับตัวแปรชั่วคราวเมื่อเริ่มต้นแต่ละไฟล์ เพื่อให้ชื่อตัวแปรที่สร้างขึ้นไม่ชนกันข้ามไฟล์
  • Guard อย่างแม่นยำ แล้วจึงคืนค่า null การแปลง clone-with ต้องยืนยันว่าชื่อของการเรียกคือ clone และอาร์กิวเมนต์ที่สองเป็น array literal ก่อนดำเนินการ การ clone $obj แบบธรรมดาจะไม่เคยไปถึงกฎในฐานะการเรียกฟังก์ชัน และการเรียกแบบสองอาร์กิวเมนต์ที่อาร์กิวเมนต์ที่สองไม่ใช่ array จะถูกปล่อยไว้ตามเดิม
  • ถอด modifier ที่ target ไม่สามารถแสดงออกได้ เมื่อกฎ trait-constants เปลี่ยน constant ให้เป็น static property กฎจะคงระดับ visibility และเพิ่ม static แต่ต้องไม่นำ modifier final ติดไปด้วย เพราะ property ของ PHP 8.1 ไม่สามารถเป็น final ได้
  • รักษากฎให้อยู่ที่ PHPStan Level 10 repository รัน composer analyse ที่ level 10 ทั่วทั้ง rector/rules และ scripts กำหนดชนิดให้ทุก signature และ annotate รูปแบบของอาร์เรย์ กฎที่ไม่ผ่านตัว analyser ถือเป็นข้อบกพร่อง ไม่ใช่ฉบับร่าง