Porting ECS to Go
After 4 previous parts in this series, it should be clear why we want Go in our PHP ecosystem. ECS or PHP-CS-Fixer runs over 2 mins on a Symfony repository.
The Go version? 4 seconds. Zero cache, 100 % project.
Today we'll look at the porting.
Proof of Concept
Before I put most of my AI subscription to PHP <-> Go porting, I wanted to have a minimal proof of concept to verify the idea. Easy CS (ECS) is a fix-only coding standard tool that includes standard sets like PSR-12 and PER CS, and 10 prepared sets that go beyond the standard, but everyone wants anyway.
We already have a php-parser in Go, but we won't touch it today. Here we have to emulate the token_get_all() function.
Let's start with PHP 5.4 classic - convert all array() to []:
Hey agent, can you emulate this token_get_all() function and parse a PHP file?
Then make a simple rule, similar to Fixer, that would fix all array() occurrences to [].
Agent made it. So I asked it to compare speed on one of my internal projects. I was amazed: 30x faster.
POC validated. So I went deeper and added a couple more rules:
- let's always add brackets to a
new X() - add spacing around
if (...)properly - use uppercase constant for
TRUE,FALSEandNULL - ...
2 repositories for 2 languages?
More and more ideas about which rules to add to the Go version of ECS - we need a better plan. Let's port existing rules, one by one.
Gamify the work! I added a CI check that counted rules that were written in PHP (mostly php-cs-fixer rules + dozens of ECS ones) and how many are in Go.
At first, the stats were a bit frustrating:
PHP: 230 fixers
Go: 1
CI kept failing and would for a long time. I wasn't sure how this would go. I never ported one tool to another language in my life, so I shared these stats as amusement on Twitter through August/September. Success is not guaranteed, but excitement is!
At first, I wanted the ECS repository to be clean and PHP only. So I made another one, private, where the agent worked. 20-30 rules in, it was obvious the work is slow. The agent had to work in the ECS repository and then move to the standalone ecs-go directory and do the work on a Go port. The agent was losing context (and so did I), making mistakes, and the repositories soon became out of sync.
So I decided to merge them into one repository, to improve results and whole workflow.
Mirror rule and Mirror tests
Using a single repository came in handy not only for closer context, but also for mirroring work: the rules were in /src or /vendor. The agent then had to fetch the source for php-cs-fixer from Github to see tests as well.
Agent, show me how many rules are not ported yet, pick top 5 to convert.
...
Okay, start with those 5, make a PR for each of those in parallel and make them pass.
I tried to ask agent for 20 rules, but it was reluctant to do it. Either it took much more time, or it was a back-and-forth mess. So I went with 5 rules at a time. Later I added the list of missing-go-rules.txt directly to the codebase. That way the agent had context right in the repository, without needing to fetch some memory. Running multiple parallel agents was also easier, as they shared a single source of truth, and could remove the fixers from missing-go-rules.txt as they finished them.
This was a part time project (work, Rector, family, house reconstruction), so I went with low hanging fruit first. It was easier for me to review and for the agent to build.
Set a "Done" Goal
Goal was to have all the sets that are in a typical ecs.php and apply exactly 100 % of rules in the exact configuration you had in ecs.php. That means the Go version would parse the ecs.php and map set + rule configuration to its Golang version.
- ECS Go has sets, has rules, configurable rules with defaults.
- It doesn't have cache (duh?), it doesn't have @magic PHP CS Fixer sets that are better handled by Rector.
- It has dry run and
--fix.
Once this is done, we can publish a beta release.
Parity between PHP and Go
So how do we know it's finished? At first, I added CI criteria of PHP <-> Go parity. That means this table:
PHP: 230 fixers
Go: 230 fixers
If a single fixer were missing in Go, that would not be a port, would it? I didn't want to make a brand new tool people would have to learn, understand and figure out how it works differently - the way Mago is a brand new tool next to PHPStan. I want devs/agents to have the same experience as before, just 30x faster. If the port is done correctly, you should only notice the speed improvement, nothing else.
In 3-4 weeks of afternoon agent prompting, the Go port was nearly done. Sometimes the agent got stuck. This proved to be the most effective nudge:
Hey, look at this X fixer, find all its tests in PHP, port it to Golang. Do not fix it yet, just make the Golang Fixer test fail.
Oh, I forgot: since the very beginning, tests were a priority. Something between these lines was part of the CLAUDE.md:
* every PHP + Go lang rules must be tested
* test battery should contain example of before/after PHP code
* make tests complete, but not overly verbose
We have 230 out of 230 done... ready to publish?
Not yet.
Parity on Open Source Projects
The next step is parity on a real project. Not a tiny package/tool, but a big real-life, 10+ year old codebase. I've been working on a Mautic upgrade since June as a hobby project, to see how much one can upgrade with proper Rector/Golang tooling, so it was the first real target for the Go port:
I added a --blink CLI option, that would run ECS in Golang mode:
# php
vendor/bin/ecs
# go
vendor/bin/ecs --blink
Some things were fixed as expected, others not quite right. Fortunately, the parity goal was simple:
Make both of these commands pass with identical output. They should either both change the same files in the same way, or not touch anything at all.
The result: the Golang output was 1:1 - the diff format, the spacing, the colors... all on purpose, to make the porting easy for agents. Devs should be able to read the diff the same way they're used to.
At first, Mautic parity was 25 %. That means the diff of file changes by ECS and the diff by ECS Go was mismatching in 25 % of cases. In a couple of days, it was nearly 1 %. We could push under the 1 %... but agents struggled. To give you an example, this edge case was on 1-2 files with deeply nested bracket spacing and multiple indents.
There was also a rule here and there that was missed by rule parity. To prevent this, the Golang ECS reports all the rules and their configurations that were missed on load.
It looks like this:
blink: 2 unsupported ECS rule(s) (no blink fixer), skipped:
- PhpCsFixer\Fixer\Comment\HeaderCommentFixer
- PhpCsFixer\Fixer\Import\GlobalNamespaceImportFixer
blink: 1 configured ECS rule(s) applied with blink's built-in behaviour (config not modelled):
- PhpCsFixer\Fixer\Phpdoc\PhpdocAlignFixer
This is part of the ECS now, so if there is some rule that is configurable in ECS PHP, but not in ECS Go, it will be reported (make an issue and tell me to fix it). The ECS Go is now tested on dozens of projects, so it should be 1:1 parity, but you never know.
That said: it's a known limitation that when you have a custom local PHP fixer rule, it will not work. I haven't seen that in years, but it might happen. In that case, ask your agent to find a native PHP CS Fixer or ECS rule instead. There is a high chance the rule is already part of ECS sets/rules.
Avoid Grinding
Instead of grinding the 1 %, there is a more effective way to improve the parity: let's run it on another project. Bigger, more popular and easier to surface weak spots. Like Laravel codebase.
Now the parity started at 8 %. It took a few rounds to get under 2 %.
These are running in CI on every run, to avoid regression. Conflicting parity is a constant that we keep lowering.
Ready for Public Beta
On September 30th, the Golang port was ready for public beta.
Give it a go:
composer require symplify/easy-coding-standard:^13.3.11
To run the Go version, just add --blink to your command.
vendor/bin/ecs --blink
If the speed up is smaller than 30x or something is not working right, let me know in the issues.
Happy coding!