0.8.0: Refactoring, das sich prüfen lässt – festhalten, belegen oder fragen

· Release · geschrieben für 0.8.0

Bei 0.8.0 geht es darum, wie die Suite Code ändert: was sie vor, während und nach einem Umbau prüft, und wann sie aufhört. Es ist kein Breaking Release. Ein Projekt auf 0.7 muss nichts tun, bevor es die Suite wieder aufruft; was dir auffallen wird, steht am Ende unter Was sich für dein Projekt ändert.

Woher dieses Release kommt

Der größte Teil von 0.8.0 begann mit einem Blick auf pstack, ein Plugin von Lauren Tan für die Arbeit mit Agenten in einer Codebasis. Es ist anders gebaut: interaktiv, ein Router-Skill mit vielen Playbooks und Skills für je ein Prinzip, während diese Suite eine Kette von Entscheidungspunkten ist, die dein Tracker antreibt. Aber viele seiner Ideen zum Refactoring lassen sich übertragen: Verhalten festhalten, bevor sich Struktur bewegt, eine Änderung daran messen, wie viel leichter der Code zu lesen ist, sagen, warum eine Änderung sicher ist, aufhören, wenn man festhängt, statt es ewig weiter zu versuchen.

Ich bin diese Ideen durchgegangen, habe jede gegen die Skills der Suite geprüft und die passenden in die eigene Planung, Umsetzung und das Review der Suite eingebaut. Die meisten Abschnitte unten sind eine davon.

Eines vorweg: Seit 0.7 nutzt ein Run die eigenen Skills deines Projekts fürs Planen und Umsetzen, wo es welche hat, und die Verfahren der Suite nur als Rückfall. Was dieser Beitrag beschreibt, ist, was diese Verfahren tun. Plant und setzt dein Projekt mit eigenen Skills um, siehst du weniger davon.

Derselbe Vorrang sollte es erlauben, pstack und diese Suite nebeneinander zu nutzen: die Skills von pstack für die Planung und die Änderung, diese Suite, um die Arbeit zu finden und bis zum Merge Request zu bringen. Ausprobiert habe ich diese Kombination noch nicht.

Ein Test, bevor sich etwas bewegt

Ein Refactoring verspricht, dass sich das Verhalten nicht ändert. Das lässt sich nur gegen Verhalten prüfen, das vor der Änderung festgehalten wurde. Deshalb beginnt ein strukturelles Ticket jetzt mit einem Pinning Test: einem Test, der festhält, was der Code heute tut, und grün ist, bevor sich irgendeine Struktur bewegt. Der Plan nennt ihn, die Umsetzung committet ihn zuerst, das Review prüft, dass er zuerst kam, und der Merge Request berichtet, dass er vor der Änderung grün war. Wo es schon Tests gibt, die den Code erreichen, sind diese der Pin.

Die Idee ist Michael Feathers' Characterization Test aus Working Effectively with Legacy Code: ein Test, der festhält, was der Code tatsächlich tut, nicht was er tun sollte (Überblick auf Wikipedia, englisch).

Ein Typcheck oder ein Linter ist kein Pin. Wo sich ein Pin nicht mit vertretbarem Aufwand schreiben lässt, baut der Run nicht ungeprüft um: Er fragt dich.

Für einen Umbau, der über mehrere Schritte reicht und etwas Vergleichbares erzeugt, etwa Text, eine Datei oder Daten, kann der Pin ein Vergleichsskript sein. Es lässt den alten und den neuen Code auf den eigenen Beispieleingaben deines Projekts laufen (Fixtures, Beispieldateien, Eingaben deiner Tests, nie Produktionsdaten) und vergleicht die Ausgaben. Der Merge Request sagt, bei wie vielen Eingaben sie übereinstimmten.

Ein Tooling-Ticket bekommt keinen neuen Test: Dort ändert ein Tool den Code, und deine bestehende Testsuite ist das, was einen Unterschied bemerkt.

Leichter nachvollziehbar, oder gar nicht

Eine Änderung kann korrekt sein und trotzdem nichts verbessern. 0.8.0 führt dafür ein Maß ein: die Leselast, den Aufwand, den es kostet, dem Code zu folgen. Der Plan nennt die Reibung, um die es im Ticket geht; das Review prüft jeden Teil der Änderung dagegen. Ein Teil, der die Leselast nirgends senkt, wird nicht nachgebessert, sondern herausgenommen. Bleibt nichts übrig, endet der Run ohne Merge Request und hinterlässt auf dem Ticket einen Kommentar, der sagt, warum.

Die Leselast folgt Artem Zakirullins Cognitive load is what matters (englisch): Ein Teil des Aufwands kommt aus dem Problem selbst und bleibt; der Teil, der aus dem Aufbau des Codes kommt, etwa Schichten, denen man folgen muss, und Zustand, den man im Kopf behalten muss, ist der Teil, den ein Refactoring senken kann.

Dahinter steht eine Aufteilung der Vorgaben der Suite in Regeln und Prinzipien. Regeln sind wenige und gelten ohne Ausnahme: Ein Refactoring erhält das Verhalten, ein Test hält es zuerst fest, Arbeit passiert auf einem eigenen Branch. Prinzipien helfen bei Abwägungen, die vom Code abhängen, und der Plan nennt jedes Prinzip, das eine Entscheidung verändert hat. Zwei davon:

  • Eine API, von der etwas außerhalb des Projekts abhängt, wird Stück für Stück ersetzt, Alt und Neu nebeneinander. Eine interne wird in einem Schritt ersetzt: Alle Aufrufer ziehen um und die alte API wird gelöscht, sodass keine Kompatibilitätsschicht zurückbleibt. Kann die Suite es nicht sagen, nimmt sie an, dass jemand außerhalb davon abhängt.
  • Toter Code wird vor dem Umbau entfernt, und nach jeder Umbenennung wird in Strings, Docs und Konfiguration gesucht, nicht nur dort, wo ein Tool sie findet.

Warum die Änderung sicher ist

Der Pin deckt ab, was ein Nutzer des Codes sieht. Eine Umbenennung, eine Verschiebung oder eine Löschung kann trotzdem etwas brechen, das er nie erreicht: einen Namen in einem String, eine Konfigurationsdatei, einen Container-Eintrag, gespeicherte Daten. Wo eine Änderung einen solchen Namen berührt, nennt der Merge Request jetzt die eine Tatsache, derentwegen die Änderung sicher ist, und wie weit das belegt wurde: im Quellcode nachgelesen, gezeigt, dass der schlechte Fall nicht eintreten kann, ausgeführt oder in der Anwendung nachgestellt. Was sich nicht belegen ließ, steht als unbelegt da, damit du weißt, wo du hinschauen musst. Betrifft eine solche Tatsache Sicherheit, Berechtigungen, eine Migration oder Nebenläufigkeit und bleibt unbelegt, fragt der Run, bevor er weitermacht.

Merge Requests, die ein Mensch reviewen kann

0.7 hat es möglich gemacht, ein Ticket in mehreren Merge Requests zu liefern. 0.8 nutzt das für Tooling-Tickets: Der Plan schätzt zuerst die Größe der Änderung, mit dem Probelauf des Tools oder einer Zählung der Dateien. Wäre ein Merge Request zu viel zum Reviewen, wird der Plan an einer Naht von Art oder Risiko in Teile geschnitten: Umschreibungen, die den Code nur anders ausdrücken, auf der einen Seite, Regeln, die ändern, was er tut, auf der anderen. Jeder Teil lohnt sich allein zu mergen, und der nächste Run nimmt den ersten Teil, der noch nicht gemergt ist. Es gibt keine feste Dateianzahl, denn dieselbe Anzahl kann leicht oder schwer zu reviewen sein.

Was das Review findet, und was ein Bot sagt

Das eigene Review der Suite sortiert jetzt seine Befunde:

  • beheben: Die Arbeit geht dafür zurück. Der Normalfall.
  • vermerken: stimmt, geht aber über den Plan hinaus. Der Befund wird im Merge Request genannt und liegen gelassen, damit die Änderung die Größe behält, der du zugestimmt hast.
  • verwerfen: doch kein Befund, mit konkreter Begründung. Verworfene Befunde stehen im Abschlussbericht, sodass du einen davon kippen kannst.

Ein Befund gegen den Plan, oder eine Änderung an dem, was die Anwendung tut, wird nie verworfen.

Dieselbe Sorgfalt gilt für Kommentare auf einem bestehenden Merge Request. Nimmt ein Run ein Ticket auf, dessen Merge Request Review-Kommentare hat, behebt er nicht mehr einfach alle:

  • Der Kommentar eines Bots wird zuerst geprüft, indem der Test oder Befehl ausgeführt wird, den er nennt, wo das billig ist. Dann wird er behoben, mit Begründung verworfen oder erfragt. Alles zu Sicherheit, Daten, Berechtigungen, Migrationen oder Nebenläufigkeit wird immer erfragt.
  • Der Kommentar eines Menschen wird behoben oder beantwortet, nie verworfen.

Jeder bearbeitete Kommentar bekommt eine kurze Antwort. Threads als gelöst markieren bleibt Sache des Reviewers.

Aufhören, wenn es nicht weitergeht

Ein Run hört jetzt auf, wenn derselbe rote Check oder Review-Befund zwei Fix-Versuche übersteht. Keine Anzahl von Runden entscheidet das, nur dass der Run nicht weiterkommt. Der Branch bleibt lokal und wird nicht gepusht; das Ticket bekommt eine offene Frage in klaren Worten, die sagt, was versucht wurde und wo es scheitert. Kein Check wird gelöscht, gelockert oder ignoriert, um ihn grün zu bekommen.

Ein Abschlussbericht, der mit dir beginnt

Der Abschlussbericht eines Runs beginnt jetzt damit, wie viele Dinge auf dich warten, oder dass keines wartet. Zu jedem sagt er, was er empfiehlt und was passiert, wenn du nicht antwortest. Und wo sich eine Frage zu Verhalten, Ausgabe oder Timing klären lässt, indem man etwas Harmloses ausführt, führt der Entwurf das jetzt aus, statt dich zu fragen.

Kleinere Änderungen

  • Der Merge Request eines PHPStan-Baseline-Tickets darf jetzt einen neuen Baseline-Eintrag hinzufügen, solange die Baseline insgesamt kürzer wird; er nennt den Eintrag. Ein Befund, der dauerhaft ignoriert werden soll, kommt erst in die phpstan.neon, wenn du es erlaubst.

Was sich für dein Projekt ändert

Du musst nichts tun. Was dir auffallen wird:

  • Merge Requests sagen mehr: den Pin und dass er vor der Änderung grün war, wo der Code leichter nachvollziehbar wurde, die Vermerke des Reviews und bei Umbenennungen und Verschiebungen, warum die Änderung sicher ist.
  • Große Tooling-Änderungen kommen in Teilen, jeder als eigener Merge Request.
  • Manche Runs enden ohne Merge Request: wenn das Review findet, dass kein Teil der Änderung den Code leichter nachvollziehbar macht, oder wenn ein Fix nicht hält. Das Ticket trägt dann einen Kommentar oder eine offene Frage.
  • Review-Kommentare bekommen Antworten, und Bot-Kommentare werden unter Umständen mit Begründung verworfen statt behoben.

Die FAQ erklärt die Gründe hinter den meisten dieser Änderungen, zum Beispiel warum ein strukturelles Ticket mit einem Test beginnt und warum die Suite nicht einfach tut, was ein Review-Bot verlangt.

Changelog 0.8.0

Die Einträge aus dem Upstream-Changelog zu 0.8.0, unverändert auf Englisch. Auch auf GitHub: Release 0.8.0.

Changelog-Einträge anzeigen
  • A structural refactoring ticket now starts with a Pinning Test — a test that holds today's behaviour and passes before any restructuring — which the plan names, the review checks, and the merge request reports; where none can be written at a reasonable cost, the run asks instead of restructuring unchecked.
  • For a restructuring that reaches over several steps behind one edge, the plan can pin the behaviour with a script that runs the old and the new code on the project's own sample inputs and compares the outputs; the merge request says on how many inputs they agreed.
  • A structural refactoring now has to make the code easier to follow: the review takes off any change that lowers the reader's effort nowhere, and the run ends without a merge request when none is left. Judgement aids — reader load, the Strangler Fig, the technique vocabulary — now live in their own principles reference, apart from the few binding rules.
  • Plans now tell apart an API someone outside depends on (replaced piece by piece, old and new side by side) from an internal one (all callers moved and the old API deleted in one step), remove dead code before reshaping, and every rename is searched for across strings, docs and configuration.
  • When a restructuring renames, moves or deletes something that can be referred to from outside the tested interface, the merge request now states the one fact the change is safe because of and how far it was proved — or that it is not proved.
  • A plan for a tooling ticket now estimates how big its merge request would be and, where a person could not review it, is cut into parts at a seam of kind or risk, each worth merging alone.
  • The suite's own review now sorts its findings into fix, note and dismiss: what the plan cannot fix is named in the merge request, a dismissal carries its reason in the closing report, and a finding against the plan is never dismissed.
  • When a run works a ticket that already has a merge request, it now sorts the review comments instead of fixing all of them: a bot's comment is checked and then fixed, dismissed with a reason, or asked about (security, data and permissions are always asked); a person's comment is fixed or answered, never dismissed; each handled comment gets a short reply, and threads are left for the reviewer to resolve.
  • A run now stops when the same failing check or review finding survives two fix attempts: the branch stays local, the ticket gets an open question in plain words, and no check is deleted, loosened or ignored to get green.
  • The design now settles a question about behaviour, output or timing by running something harmless instead of asking you, and a run's closing report opens with how many things wait for you and says what is recommended and what happens if you do not answer.
  • A PHPStan baseline ticket's merge request may now add a new baseline entry as long as the baseline gets shorter overall, and names it; a finding to ignore for good, or a baseline that would not get shorter, becomes an ignore rule in phpstan.neon only after the human allows it.
  • Contributors are told to turn a rule that is broken twice into a check, and the validator now checks that README and docs cite no ADR or PR numbers and no scratch files.

Fragen oder Anmerkungen zu diesem Beitrag? Gern in den GitHub Discussions (Q&A).