Skip to content

feat: add Rector bridge for Testo/PHPUnit/Pest test conversion#248

Merged
roxblnfk merged 12 commits into
1.xfrom
bridge-rector
Jun 30, 2026
Merged

feat: add Rector bridge for Testo/PHPUnit/Pest test conversion#248
roxblnfk merged 12 commits into
1.xfrom
bridge-rector

Conversation

@roxblnfk

Copy link
Copy Markdown
Member

What was changed

New testo/bridge-rector package — Rector rules to convert test suites between Testo, PHPUnit and Pest, in three direction sets:

  • Testo → PHPUnit — assert calls (+ arg-order swap), Expect::exception, throw SkipTest, #[Covers], lifecycle/group attributes, typed-chain decomposition, group-inheritance flattening.
  • PHPUnit → Testo — assert calls (arg-order restored), expectException, markTestSkipped, #[CoversClass], lifecycle methods, @dataProvider/#[DataProvider], @group/#[Group], DoesNotPerformAssertions.
  • Pest → Testoexpect()->toX()Assert::*.

Conversions with no faithful counterpart (mocks, constraints, retry/repeat, Pest functional→class, …) are shipped as documented stub rules + TODO.md, not silently dropped. A FEATURE_PARITY.md matrix tracks coverage.

Supporting changes:

  • Reusable "inline tests for rules" harness (src/Testing/, RectorTestingPlugin): a rule carries #[TestRectorFixtures] pointing at co-located *.php.inc fixtures, each run through a freshly-booted Rector container and reported as its own data set — no PHPUnit dependency.
  • Core discovery: the *.php file-type gate moved out of Tokenizer\Finder into the locator middlewares (InlineFinder, BenchFinder, TestoAttributesLocatorInterceptor, NamingConventionLocator, RectorFixtureFinder), so each locator decides what it will load and co-located *.php.inc fixtures are never reflected.
  • Monorepo wiring: bridge/rector registered for release-please + subtree-split publishing to php-testo/bridge-rector.
  • Skills: testo-migrate-from-phpunit reworked into a phased dual-path flow (Rector / AI-agent) with automation scripts; testo-plugin-author sharpened.

See commit history for details.

Why?

Testo is self-hosted: the engine that discovers and runs tests is the same code Infection mutates, so a mutation on the run path can break discovery itself and produce spurious survivors/kills instead of a real mutation signal. Converting the unit-style self-tests to PHPUnit lets Infection's PHPUnit adapter run them on a runner that shares no code with the mutated engine — a mutation can then only be caught (or missed) by an assertion, never by breaking the harness.

The bridge doubles as a migration path for downstream users moving onto Testo (and the reverse, and from Pest), which is what the reworked testo-migrate-from-phpunit skill drives.

Checklist

  • Closes #
  • Tested
    • Tested manually
    • Unit tests added
  • Documentation

roxblnfk and others added 8 commits June 30, 2026 17:49
…ixture harness

Introduces the `testo/bridge-rector` package: Rector rules to convert test
suites between PEST, PHPUnit and Testo, starting with the Testo->PHPUnit
direction.

- `TestoToPhpunit\AssertCallToPhpUnitRector`: rewrites `Testo\Assert::*` static
  calls into PHPUnit `$this->assert*` calls, correcting the actual/expected
  argument order (Testo is actual-first, PHPUnit expected-first).
- `Testing\` harness -- "inline tests for rules": a rule carries
  `#[TestRectorFixtures(...)]` pointing at co-located `*.php.inc` fixtures; a
  finder discovers such rules and an interceptor fans the fixtures into one data
  set each, asserting via a freshly-booted, per-rule Rector container built from
  Rector's own framework-agnostic services. Public surface = the attribute plus
  `RectorTestingPlugin`; the rest lives under `Testing\Internal`.
- Fixtures (`*.php.inc`) and the dev-only suite wiring are `export-ignore`d; the
  reusable harness ships so downstream rule authors can use it (testo/* are
  require-dev + suggest).

Wires the package into the root config: path repo, require-dev, and a
`Bridge/Rector` suite scanning the rule sources.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The discovery finder matched every file under a scanned directory and then
eagerly reflected each class it found. Non-source files that happen to live
under the path — e.g. Rector `*.php.inc` fixtures that declare classes — were
tokenized and reflected, which aborted discovery for the whole suite.

Restrict the finder to `*.php` so only real PHP source is considered. This also
unblocks co-locating `*.php.inc` fixtures next to the rules they test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…to rules

Builds out testo/bridge-rector across all three directions, with a feature-parity
matrix (FEATURE_PARITY.md) and per-direction TODO/stub docs. Every rule ships
co-located `*.php.inc` fixtures exercised by the inline test harness.

Testo -> PHPUnit: assertion calls, typed-chain decomposition (Assert::int()->...
into separate assert* lines, 1->N where needed), Expect::exception (bare),
throw SkipTest -> markTestSkipped, #[Covers] -> #[CoversClass], lifecycle
attributes, #[Group] (variadic -> repeated) plus class-level inheritance
flattening via reflection, #[ExpectNoAssertions] -> #[DoesNotPerformAssertions].

PHPUnit -> Testo: assertion calls (actual/expected order restored), markTestSkipped
-> throw SkipTest, #[CoversClass] -> #[Covers], setUp/tearDown -> lifecycle
attributes, expectException (bare), both @dataProvider and #[DataProvider] ->
#[\Testo\Data\DataProvider] (no phpunit dependency), #[Group] (annotation and
attribute collapsed into one variadic #[\Testo\Filter\Group]),
#[DoesNotPerformAssertions] -> #[ExpectNoAssertions].

Pest -> Testo: expect()->toX() -> Assert::*. The functional->class restructuring
(test()/it() -> methods, hooks, datasets) is out of Rector's reach and shipped as
documented stubs.

Conversions with no faithful target (PHPUnit mocks/constraints/incomplete, Testo
memory-leak/retry/repeat/cancel, Pest arch()) are documented stub rules, not
silently dropped.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… rule workaround

The inline fixture harness reuses one Rector container across all of a rule's
fixtures, but the source locator caches the aggregate built from the first file
it sees (Rector only rebuilds it per file under PHPUnit). So reflection saw only
the first fixture's classes, and reflection-based rules had to compensate.

`RectorRunner` now resets the source locator before each fixture, so reflection —
and the node scope derived from it — sees that fixture's classes. With that,
`GroupInheritanceToPhpUnitRector` drops its self-compensating reset/restore of the
locator and resolves the leaf class via the normal node scope
(`ReflectionResolver::resolveClassReflection`).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ators

`e4707ac` hardcoded `->name('*.php')` in the core `Tokenizer\Finder`, baking
the "only PHP source" assumption into discovery itself. Move that decision to
where it belongs: each `FileLocatorInterceptor` now claims only `*.php` files
it could actually `include` and reflect, and defers anything else (e.g.
`*.php.inc` Rector fixtures, which declare classes but are not loadable PHP).

- Revert the Finder hardcode — it matches every file again.
- Gate `locateFile()` on `extension() === 'php'` in InlineFinder, BenchFinder,
  TestoAttributesLocatorInterceptor, NamingConventionLocator (also closes a
  `SomethingTest.inc` suffix hole) and the bridge's RectorFixtureFinder
  (required: otherwise its suite would load its own fixtures).

Deferring rather than hard-vetoing keeps the result independent of interceptor
order. Bridge/Rector 83/83, full suite unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Wire the Rector bridge into the same release-please + subtree-split pipeline
as the other bridges/plugins, and add the dev-only CI it ships to its split
repo. The dedicated repository php-testo/bridge-rector is created out of band;
on the first `bridge-rector-*` release tag the split action populates it.

- release-please: add the bridge/rector package (testo/bridge-rector,
  component bridge-rector) and seed resources/version.json at 0.1.0.
- split-publish: trigger on `bridge-rector-[0-9]*` tags.
- bridge/rector: add CHANGELOG.md (release-please target) and the
  close-foreign-prs workflow; export-ignore /.github from composer archive.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…-path flow

Rework the migration skill from a flat translation table into a phased
orchestrator that routes to one of two detailed approach references, with
deterministic automation scripts and verbatim subagent templates — modelled on
testo-mutation-testing / testo-increase-coverage.

- SKILL.md: 5 phases — restore point (BLOCKING), scope, tooling detection +
  approach choice, execution, verify & retire. Forces parallel subagents per
  file (independent work), contrasting the sequential sibling skills.
- references/phpunit-to-testo-map.md: authoritative construct map + pitfalls,
  split into "Rector handles" vs "needs AI/human" columns.
- references/migrate-with-rector.md (Approach A) and migrate-with-agents.md
  (Approach B): per-stage install/config/run/verify with gates and troubleshooting.
  testo.php is generated with `vendor/bin/testo init` before the subagent pass.
- references/subagent-port-prompt.md: fixed per-file porting template.
- scripts/: precheck.php (tooling + test-surface survey), scaffold-rector-config.php
  (generate a Rector config wired to the bridge set), scan-residuals.php (rank
  files into batched work-lists).
- README.md: refresh the skill's one-liner.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@roxblnfk roxblnfk requested a review from a team as a code owner June 30, 2026 18:38
# Conflicts:
#	composer.json
#	resources/version.json
@codecov

codecov Bot commented Jun 30, 2026

Copy link
Copy Markdown

roxblnfk and others added 3 commits June 30, 2026 23:16
Close every 🧩 item in bridge/rector/FEATURE_PARITY.md; what remains is now
either ✅, a 🟡 with a documented residual, or an intentional ⛔. Bridge/Rector
suite 83 → 107.

- Data providers (Testo → PHPUnit): new DataProviderToPhpUnitRector renames
  #[Data\DataProvider] → #[Attributes\DataProvider] and #[Data\DataSet([...],'l')]
  → #[Attributes\TestWith([...],'l')], preserving args verbatim.
- Exception fluent (both directions): ExpectExceptionToPhpUnitRector expands the
  Testo chain into separate $this->expect* statements (Node[] return);
  ExpectExceptionToTestoRector folds an uninterrupted run of sibling
  expectExceptionMessage/Code calls into the ->withMessage()/->withCode() chain.
  Residual: withMessageContaining is left untouched (substring vs PCRE).
- Method-level group inheritance (Testo → PHPUnit): GroupInheritanceToPhpUnitRector
  now also walks each leaf method's prototype chain and flattens the parent
  method's groups onto the leaf, idempotently. Traits are excluded at the method
  level to match Testo's own getPrototype()-based resolution.
- Structural (both directions): promote ExtendsTestCaseToTestoRector (remove
  `extends TestCase`, make discovery attribute-based via #[\Testo\Test]) and
  TestClassToTestCaseRector (the mirror) from stubs. Residual: methods are not
  renamed; scope is direct-TestCase / no-existing-base only.

Each rule ships co-located *.php.inc fixtures and is registered in its
direction set; TODO.md files updated accordingly.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…eatures

When a new user-facing feature lands (attribute, assertion, lifecycle hook,
exception, …), it almost always has a PHPUnit/Pest equivalent, so the
testo/bridge-rector conversion rules should gain a counterpart (+ fixtures) or a
documented stub. Mirrors the existing "update the skills" contract.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Pest -> Testo was previously declared intractable because Rector cannot
synthesize a host class for file-level test() closures. Targeting Testo's
function-based discovery instead of class methods dissolves that blocker:
each test()/it() becomes a #[Test] free function, no class required.

TestCallToFunctionRector (STMTS_AWARE, for collision-free naming):
- test('desc')/it('desc') -> #[\Testo\Test] function; name is the declarator
  prefix + snake(description) (test_/it_), description kept as the docblock;
  in-file collisions get a _2/_3 suffix.
- beforeEach/afterEach/beforeAll/afterAll -> functions carrying
  Testo\Lifecycle\{BeforeTest,AfterTest,BeforeClass,AfterClass}.
- folds the fluent chain in the same pass: ->group -> #[Group],
  ->covers -> #[Covers], ->throws(X[,msg]) -> Expect::exception()+never,
  ->skip('r') -> throw SkipTest('r'), ->with([rows]) -> repeated #[DataSet].
- bails (leaves the statement untouched) on a non-literal description, a
  use(...)-capturing closure, or any unrecognised modifier.

Synthesized Function_ nodes set ->namespacedName explicitly; otherwise
Rector's scope refresher fatals and silently rolls back the change.

Collapse the six now-subsumed stubs (test-function/lifecycle/throws/skip/
group/dataset) into this single rule; keep UsesToTraitRector and
ArchTestRector (no faithful target). Register the rule in the
pest-to-testo set and refresh TODO.md / FEATURE_PARITY.md. 17 fixtures;
Bridge/Rector suite 107 -> 124, green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@roxblnfk roxblnfk merged commit a1953b7 into 1.x Jun 30, 2026
23 of 24 checks passed
@roxblnfk roxblnfk deleted the bridge-rector branch June 30, 2026 20:06
@roxblnfk roxblnfk mentioned this pull request Jun 30, 2026
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.

1 participant