Skip to content

feat: add composer-based set binding every rule to an exact drupal/core version - #419

Open
TomasVotruba wants to merge 14 commits into
palantirnet:mainfrom
TomasVotruba:composer-based-set
Open

feat: add composer-based set binding every rule to an exact drupal/core version#419
TomasVotruba wants to merge 14 commits into
palantirnet:mainfrom
TomasVotruba:composer-based-set

Conversation

@TomasVotruba

@TomasVotruba TomasVotruba commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Hey, I'm improving the way Rector find composer-based rules and sets. All you need is:

  • 1 config with all the rules for all the version
  • make rule implement ComposerPackageConstraintInterface
  • or use ruleWithConfigurationComposerVersionBound - e.g. for generic rules like rename class:
  $rectorConfig->ruleWithConfigurationComposerVersionBound(
            RenameClassRector::class,
            [
                'SomeOldClass' => 'SomeNewClass',
            ],
            'phpunit/phpunit',
            '>=9.0'
        );

The set provider, SetList, versioned set list can be dropped (in one of next PRs, not now).

It's agent generated, but it works well on Doctrine/Symfony/PHPUnit sets, see e.g.

I'll open this for feedback if it's all done correctly :) do not merge yet.
I'll make sure it's part of next Rector release and everything works as smoothly as before.

Feedback welcomed 👍

I've also added custom PHPStan rule to make sure all the rules are registered in composer-based.php config and nothing is missed by accident 😉


Replaces DrupalSetProvider — Rector deprecated SetProviderInterface and ComposerTriggeredSet in favour of binding rules to a package version directly - with a single generated set, config/composer-based.php, exposed as DrupalSetList::COMPOSER_BASED.

…re version

Replaces `DrupalSetProvider` — Rector deprecated `SetProviderInterface` and
`ComposerTriggeredSet` in favour of binding rules to a package version
directly — with a single generated set, `config/composer-based.php`, exposed
as `DrupalSetList::COMPOSER_BASED`.

Every configurable rule is registered through Rector 2.6's
`RectorConfig::ruleWithConfigurationComposerVersionBound()` with the exact
version its deprecation was introduced in, taken from the value object it is
already configured with:

    -$rectorConfig->ruleWithConfiguration(FileSystemBasenameToNativeRector::class, [
    -    new DrupalIntroducedVersionConfiguration('11.3.0'),
    -]);
    +$rectorConfig->ruleWithConfigurationComposerVersionBound(FileSystemBasenameToNativeRector::class, [
    +    new DrupalIntroducedVersionConfiguration('11.3.0'),
    +], 'drupal/core', '>=11.3.0');

Rules that take no configuration cannot use that API, so they are registered
behind a `$ruleSince()` helper that checks the same constraint against the
installed `drupal/core`:

    -$rectorConfig->rule(LoadAllIncludesRector::class);
    +$ruleSince(LoadAllIncludesRector::class, '>=11.3.0');

Rector then activates only the rules the installed core satisfies — 34 of the
124 bound registrations on core 10.3, 63 on 11.2, 124 on 12.0 — and
`vendor/bin/rector composer-based` lists them with their constraint. Because
the installed version is known exactly, the opt-in "breaking" renames are
included; they cannot fatal on a core guaranteed to have the replacement.

The per-minor `Drupal*SetList` constants are unchanged and remain the way to
look ahead at a Drupal version that is not installed yet.

The set is generated by `scripts/generate-composer-based.php` from the
per-minor configs, so a rule only has to be registered once;
`ComposerBasedSetTest` asserts the committed file is in sync, that every
registration carries an exact `>=X.Y.Z` constraint, and that no rule of the
per-minor sets is missing from it.
…rupal: true)

Restores `DrupalSetProvider` so existing
`withSetProviders(DrupalSetProvider::class)` configs keep working; its
docblock now points at `DrupalSetList::COMPOSER_BASED` as the successor.

Drops the explicit `withSets([DrupalSetList::COMPOSER_BASED])` from the README
and from the generated set's docblock — `withComposerBased(drupal: true)` is
the documented entry point.
Adds the `@deprecated` annotation and a CHANGELOG "Deprecated" entry for
`DrupalSetProvider` and the `drupal` / `drupal (breaking)` set groups it
provides. The class stays registered so existing configs keep working, and is
removed in a later release.
@TomasVotruba
TomasVotruba marked this pull request as draft August 10, 2026 11:33
@TomasVotruba

Copy link
Copy Markdown
Contributor Author

Still WIP... need a bit config tuning. But check the linked PRs in the description to get the idea ahead 👍

The generator rewrote the per-minor configs by walking their PHP tokens and
patching the registration calls, which made the set a build artifact of a
parser rather than a config you can read.

Keeps the set as a plain config of real rules, in the shape rector-doctrine
uses (rectorphp/rector-doctrine#497), and accepts that a rule is registered
both here and in its per-minor config. `ComposerBasedSetTest` fails when a rule
of a per-minor config is missing here, so the duplication cannot drift
unnoticed.
…e shape

Follows rectorphp/rector-doctrine#497: a rule that takes no configuration
declares the version its target API landed in on the rule class, instead of
being gated by the set that registers it.

    -$ruleSince(LoadAllIncludesRector::class, '>=11.3.0');
    +$rectorConfig->rule(LoadAllIncludesRector::class);

    +final class LoadAllIncludesRector extends AbstractRector implements ComposerPackageConstraintInterface, DocumentedRuleInterface
    +{
    +    public function provideComposerPackageConstraint(): ComposerPackageConstraint
    +    {
    +        return new ComposerPackageConstraint('drupal/core', '>=11.3.0');
    +    }

76 rules are bonded this way, which drops the `$ruleSince()` helper and the
`Semver` lookup from the set. The three rector-phpunit rules the Drupal 10.1
config registers are dropped from the set as well: they are already bonded to
`phpunit/phpunit` by rector-phpunit's own composer-based set, which is the
accurate constraint for them.

Rector applies the constraint filter globally rather than per set, so these
rules no longer run against a core older than the deprecation, including when a
`Drupal*SetList` set is loaded by hand. Verified against a project on 11.2.3 and
one on 11.3.0: `LoadAllIncludesRector` fires only on the latter.

Rule tests point `provideComposerJsonFilePath()` at a stub composer.json that
requires drupal/core, as this package does not — without it every bonded rule
would be filtered out of its own test.
- `ComposerBasedSetTest` used `new ReflectionClass(…)->…`, which only parses on
  PHP 8.4; the phpunit and phpstan workflows run 8.3.
- Both workflows installed `rector/rector:^2` before running, which can resolve
  below the 2.6 that ships `ruleWithConfigurationComposerVersionBound()` and
  `ComposerPackageConstraintInterface`. Bumped the matrix to `^2.6`, matching
  the constraint in composer.json.
- `ShouldCallParentMethodsRector` and
  `AddSymfonyConstraintValidatorTypeDeclarationsRector` were each registered by
  two per-minor configs. A bonded rule carries one constraint, so each is bonded
  to the lower of its two versions and registered once, under that version.

Verified on PHP 8.3 with `composer require rector/rector:^2.6 --dev` applied the
way the workflows do it: php-cs-fixer 0/523, PHPStan no errors, PHPUnit 699
tests.
The checks are static properties of the config and the rule classes, so PHPStan
is where they belong — they now report at the offending line rather than as a
regex assertion over the file contents, and cover every rule class rather than
only what the set file happens to reference.

- BoundRuleConfigurationRule — a ruleWithConfigurationComposerVersionBound()
  call must name "drupal/core" and an exact ">=X.Y.Z" version.
- ComposerPackageConstraintRule — the same for every ComposerPackageConstraint
  a rule class constructs.
- PlainlyRegisteredRuleRule — a rule registered with a plain rule() call in the
  composer-based set must implement ComposerPackageConstraintInterface,
  otherwise it runs on every Drupal version.
- ComposerBasedSetCoverageRule — with RegisteredRectorClassCollector, fails when
  a per-minor config registers a rule the composer-based set does not.

Each rule is covered by a RuleTestCase, and all four were verified against a
deliberately broken tree: a ">=8.0" constraint, a rule class stripped of the
interface, a "drupal/coder" package name, and a registration deleted from the
set each produce the expected error.
@TomasVotruba
TomasVotruba marked this pull request as ready for review August 10, 2026 12:48
@TomasVotruba
TomasVotruba marked this pull request as draft August 10, 2026 12:48
@TomasVotruba
TomasVotruba marked this pull request as ready for review August 10, 2026 12:53
@TomasVotruba

Copy link
Copy Markdown
Contributor Author

Ready now 👍

@TomasVotruba

Copy link
Copy Markdown
Contributor Author

Ping @bbrala

@tobiasbaehr

Copy link
Copy Markdown
Contributor

We should bump the required rector version and make \DrupalRector\Set\DrupalSetProvider a no-op class, because it tries to call constant which was removed in rector.

So that ->withSetProviders(DrupalSetProvider::class) does nothing, until rector removes also withSetProviders method.

Or all sets needs to check that the constant like PHPUnitSetList::PHPUNIT_90 are defined, but without to throw an exception when not, like what it does in drupal-10.0-deprecations.php.

bbrala added a commit that referenced this pull request Sep 3, 2026
* fix: conflict with rector/rector >=2.6.2 (#420)

Rector 2.6.2 removed every version-specific set constant from its first-party
extension packages in favour of the new composer-based sets
(rectorphp/rector-phpunit#760). The Drupal 8, 9 and 10 configs reference those
constants directly, so from 2.6.2 on they abort before any rule runs.

Bisected against real installs; 2.6.1 is the last release on which every
Drupal set loads:

  2.5.9 / 2.6.0 / 2.6.1   ok
  2.6.2 / 2.6.3 / 2.6.6   fail

Breakage map on 2.6.6:

  DRUPAL_90, DRUPAL_100     Could not detect twig set.
  DRUPAL_91, DRUPAL_92      Undefined constant PHPUnitSetList::PHPUNIT_90
  DRUPAL_101                Undefined constant SymfonySetList::SYMFONY_63
  DRUPAL_102                Undefined constant SymfonySetList::SYMFONY_64

plus the DRUPAL_8 / DRUPAL_9 / DRUPAL_10 aggregates that include them. The
Drupal 11 and 12 sets reference no third-party sets and are unaffected.

A conflict rather than a narrowed require constraint, so the supported range
stays documented as ^2 and composer reports the incompatibility by name.

This is a stopgap to keep installs working. The real fix is porting the sets
to the composer-based mechanism (#419), which Rector 2.6.6 already expects:
withComposerBased(drupal: true) resolves DrupalRector\Set\DrupalSetList::
COMPOSER_BASED, and SetGroup::DRUPAL is now marked deprecated upstream in
favour of a composer-based.php set. The conflict is lifted once that lands.

Reported by ptmkenny in #420

* style: use ?int over null|int in RemoveToolkitArgFromImageToolkitOperationConstructor

Unrelated to #420, but the codestyle job is failing on main's code and blocks
this branch.

The @symfony ruleset in php-cs-fixer 3.95.24 enforces nullable_type_declaration
with the question_mark style. The dev constraint is ^3.95.1, so the rule arrived
by a floating minor rather than by any change here — the file has been unchanged
since it was merged green. Only the php-cs-fixer job would have caught it, and
the scheduled runs cover phpstan, phpunit and functional tests only, so it went
unnoticed.

No behavior change; ?int and null|int are the same type.
@bbrala

bbrala commented Sep 3, 2026

Copy link
Copy Markdown
Collaborator

Ok, back from vacation. I did a ninja release for this package to lock it out of >2.6.1 so it doesnt break while this is worked on.

There is another path in this, and that is removing the specific rules for twig and phpunit maybe. Also kinda interested in how this works.

A rule tells us: ">=10.1.0", does that mean it is selected on anything above, or anything above but belore next major?

So does >=10.1.0 equal >=10.1.0 <= 10.999.999 or just >=10.1.0.

I wouldn't mind to much to drop the set references, but am a little worried to throw things around again with how the sets work.

@TomasVotruba

Copy link
Copy Markdown
Contributor Author

Welcome back :) many news in Rector.

Just the >=.
If it changes in 11 differently, it should be limited from upper bound: < 11

Checkout my PRs in Laravel Rector package, where I did the same upgrade.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants