Transpiling composer/semver: PHPUnit 9 via Symfony symlink and eval-generated version_compare
composer/semver is the version-constraint library that powers Composer's dependency resolver. Roughly 1,500 lines of PHP across a handful of classes (Semver, VersionParser, Comparator, the Constraint hierarchy, and Interval), but with the wide test surface that comes with parsing every shape a version string can take in the PHP ecosystem: pre-release identifiers, build metadata, branch aliases, caret and tilde operators, stability flags, and the normalisation rules that VersionParser applies before any of that runs.
Compared to brick/math, the library itself was uneventful. The interesting work was around the test suite: getting it to run at all, and getting it to run honestly. Two specific obstacles stood between Pext and a clean 100% result, both of which exposed gaps that were not really about composer/semver at all.
PHPUnit 9, bootstrapped via a Symfony symlink
The first surprise was the test runner. composer/semver doesn't pin the latest PHPUnit; it ships with PHPUnit 9, which predates a number of deprecations and behaviour changes that PHPUnit 10 and 11 introduced. Pext's PHPUnit work has tracked the latest line, so for this project we had to make sure the version actually vendored in vendor/phpunit/phpunit was the one transpiled and executed, not whatever happened to be the highest version in the runtime's cache.
The second surprise was how that binary is wired up. The entry point on disk is vendor/bin/phpunit, but in this project that path is not a real file: it is a symlink created by the Symfony binary-installer convention, pointing into vendor/phpunit/phpunit/phpunit. The transpiled tree faithfully mirrors the source layout, so the symlink survives conversion, but the pext CLI was resolving the path one level too eagerly and ending up at the wrong target file.
The fix was to teach the runner to follow the vendor/bin indirection the same way PHP does at the OS level: read the symlink, resolve it relative to its directory, and bootstrap from there. Once that landed, PHPUnit 9 booted cleanly and started discovering tests. No code change was needed in composer/semver or in the transpiled PHPUnit itself; it was purely an entry-point resolution issue in the runner.
A small thing in isolation, but it is exactly the kind of detail that decides whether a real-world project is "transpiled" or just "transpiled in theory". The vendor layout is part of the codebase. If the runner can't honour it, the test suite never starts, and you never find out what the actual conversion bugs are.
Eval-generated version_compare and scope passing
The deeper challenge was the test suite's heavy use of eval(). composer/semver ships with helpers and providers that build PHP source as a string and pass it to eval, with the generated code typically calling version_compare(PHP_VERSION, '...', '...') to branch on the runtime PHP version, plus a handful of constraint-construction helpers. There are dozens of these eval sites across the test suite, and many of them depend on variables defined in the calling test method, not in any global or class scope.
PHP's eval is not just "run this string as code". It runs the string in the local variable scope of the call site. Variables defined in the calling function are visible inside the evaluated code, and assignments inside the eval'd block are reflected back in the caller's scope. That is a non-trivial contract to honour from JavaScript, where eval exists but with very different semantics, and where the transpiled output has already turned PHP locals into managed runtime slots.
Pext now supports this end-to-end. The transpiler recognises eval(...) call sites and, instead of emitting a bare JavaScript eval, emits a runtime call that takes the source string and a reference to the caller's scope. The runtime side compiles the PHP source through the same transpilation pipeline at runtime, then executes it against the passed-in scope, so reads and writes inside the evaluated code see and mutate the same variable slots the caller is using. References, refcounts, and copy-on-write all behave the same way they would across a normal function call.
With that in place, version_compare(PHP_VERSION, '8.0.0', '>=') inside an eval string just works: version_compare resolves through the normal bootstrap, PHP_VERSION is read from the runtime context (the same one that gates a lot of the math module's behaviour, see the math audit), and the result drives the test exactly as it does in upstream PHP. A whole class of tests that had been skipped or failing because the eval'd code couldn't see the caller's data started passing the moment scope passing landed.
Two notes on cost. First, this is genuinely the most expensive way to run PHP in the runtime: every eval call pays for a fresh transpilation pass, and there is no caching across eval sites that pass the same source string. Second, the surface is small enough that this is fine for now. Production code rarely uses eval; test code does, and test code does not need to be fast. The trade-off is correctness over throughput, and the throughput is still well within phpunit's tolerance.
Where we are
composer/semver currently passes 100% of its test suite under Pext, the second package cleared in Phase 2 of the open source roadmap after brick/math, and now joined by doctrine/inflector. The library itself never needed a workaround; everything that mattered happened in the runner (the symlink resolution) and the runtime (eval scope passing). Both fixes generalise: every project that vendors PHPUnit through Symfony's bin layout, and every project whose tests reach for eval, benefits from the same work.
Next on the Phase 2 list are doctrine/lexer (currently at 76%) and dragonmantank/cron-expression. The showcase tracks live pass rates as each one closes.