
LLMs sind großartige Werkzeuge, um die nervigen Aufgaben der Softwareentwicklung zu automatisieren, wie das Refactoring großer Projekte. Für vieles gab es auch schon früher sinnvolle Tools, aber manches musste immer noch von Hand erledigt werden. Ein Beispiel: Das Umbenennen einer Funktion und all ihrer Aufrufe ist schon seit Jahrzehnten Kernbestandteil vieler IDEs. Aufgaben, die Kontext über die zu überarbeitende Stelle brauchten, waren aber vor LLMs meistens Handarbeit.
Beispielsweise habe ich vor kurzem in einer von mir betreuten
Typescript-Applikation Informationen von einem durchgereichten Kontextobjekt in
einen
AsyncLocalStorage
verschoben. In diesem Zuge mussten alle HTTP-Handler in der Form umgebaut
werden, dass ihr gesamter Inhalt in einen withContext(() => ...) Callback
verschoben wird. In der gesamten Applikation waren hunderte Stellen betroffen.
Um diese Änderungen durchzuführen, habe ich einen Agent mit der
dementsprechenden Aufgabe losgeschickt.
Das Problem: So eine Aufgabe ist ganz schön token- und zeitintensiv. Der besagte Agent versagte mehrfach darin, alle Stellen zu finden, und musste mehrfach erneut losgeschickt werden, obwohl Tests bereitstanden, durch deren Ausführung alle betroffenen Stellen ausgegeben wurden. Begeistert war ich nicht.
Die notwendigen Änderungen waren dabei auf den ersten Blick gar nicht so kompliziert. Die betroffenen Dateien waren durch die Tests bekannt. Das notwendige Refactoring sah wie folgt aus:
// Ausgangssituation
class HandlerName implements Handler {
async handle(...) {
// content to be wrapped
}
}
// Ziel
class HandlerName implements Handler {
async handle(...) {
return await this.contextService.withContext(async () => {
// content to be wrapped
})
}
}
Da muss es doch etwas Besseres geben, dachte ich mir und sprach mit meinem langjährigen Freund Christian Rades über das Thema, der prompt die zündende Idee lieferte: “Hmmm, sowas wie einen CSS-Selector über den AST bräuchte man”.
Eine kurze Perplexity-Suche später stellt sich heraus – genau so etwas gibt es bereits: ast-grep.
ast-grep provides a more sophisticated way to find your code: Rules. Rules are like CSS selectors that can compose together to filter AST nodes based on certain criteria.
“Rule Essentials”, ast-grep Dokumentation
ast-grep unterstützt bereits dutzende Programmiersprachen und ja - natürlich gibt es bereits einen Skill für Agents, dazu aber später mehr. Denn bevor ich alles an das LLM abtrete, wollte ich erstmal verstehen, was das Tool so kann.
Um das herauszufinden, wollte ich die Lösung für einige Refactoring Probleme aus
näherer Vergangenheit reimplementieren, bei deren Umsetzung ich von den
Arbeitsweisen von LLMs genervt war. Eines dieser Probleme war das Konvertieren
eines Funktionskopfes mit vielen, teils optionalen Parametern in eine Funktion,
die nur ein options-Objekt akzeptiert, um das Handling der optionalen
Parameter einfach zu gestalten:
// Ausgangssituation
async myMethodWithOptionalParams(
myParam1: string,
myParam2: string,
myOptionalParam1?: string,
myOptionalParam2?: string
): Promise<void> {
// ...
}
// Ziel
async myMethodWithOptionalParams(
opts: {
myParam1: string,
myParam2: string,
myOptionalParam1?: string,
myOptionalParam2?: string
}
): Promise<void> {
// ...
}
ast-grep unterstützt bereits das Umschreiben von Code mittels sog.
rewrite-Rules - bevor ich allerdings mit dieser loslegen konnte, musste ich
erstmal einen Matcher definieren, der die Funktionsaufrufe findet, die ich
umschreiben wollte.
Meine naive, erste Lösung arbeitete rein mit dem pattern Konstrukt von
ast-grep:
ast-grep --pattern 'myMethodWithOptionalParams($$$)' ./src
Und mit diesem Pattern konnte ich genau gar nichts finden. Warum?
myMethodWithOptionalParams ist keine alleinstehende Funktion, sondern Member
eines Services. Die Aufrufe folgen der Form
await this.myAwesomeService.myMethodWithOptionalParams( ... );
ast-grep pattern arbeiten aber immer auf genau einer AST-Node. Ein Blick in
den sehr nützlichen
ast-grep playground zeigt:
myMethodWithOptionalParams ist im AST ein Property-Zugriff (AST-Node
property_identifier) auf den Service. Die Parameter sind einer anderen Node
beheimatet: Einer call_expression AST-Node. Die call_expression umfasst den
Variablennamen des Service, den Methodennamen, sowie die Parameter. Gleichzeitig
ist der Name der Variable, in welcher der Service abgelegt ist, in meiner
Codebase nicht vorgegeben und kann je nach Aufrufer anders sein. Das Pattern
muss daher so angepasst werden, dass die gesamte call_expression gematched
wird, unabhängig vom lokalen Service-Namen. Die Lösung: Den Variablennamen als
Metavariable in das Pattern aufnehmen:
ast-grep --pattern '$SERVICE.myMethodWithOptionalParams($P1,$P2,$P3,$P4)' ./src
Dieses Pattern führte zu den gewünschten Ergebnissen und war für meinen Anwendungsfall “gut genug”. Würde meine Codebase einen zweiten Service mit einer gleichlautenden Funktion und vier Parametern enthalten, wäre dieses Pattern zu naiv und müsste weiter verfeinert werden. So konnte ich allerdings mit der Rewrite-Vorschrift starten. Dafür habe ich die Regel der Übersicht halber in eine wiederverwendbare YAML-Datei überführt:
id: transform-to-opts
language: TypeScript
rule:
pattern: $SERVICE.myMethodWithOptionalParams($P1,$P2,$P3,$P4)
fix:
"$SERVICE.myMethodWithOptionalParams({ myParam1: $P1,$ myParam2: $P2,customerId: $P3,recipientId: $P4})"
In meiner neu hinzugefügten fix-Regel sortiere ich die vier Parameter der
Methode wie benötigt um, den Variablen-Namen des Services übernehme ich. Auf die
Formatierung der Ausgabe lege ich keinen Wert, darum kümmert sich später das
bereits vorhandene Linter-Tooling.
Auch, wenn ich mit dieser Lösung sehr zufrieden war, schien sie mir doch gerade
mal an der Oberfläche der Möglichkeiten zu kratzen, die ast-grep bietet. Um
tiefer einzusteigen, habe ich mich noch an einem weiteren Refactoring-Problem
aus den letzten Wochen versucht:
In den Unittests einiger HTTP-Request-Handler wurden Stub-Objekte verwendet, in
denen nicht alle notwendigen Felder eines HTTP-Requests, wie Header oder die
Route, gesetzt waren, weshalb diese Objekte direkt beim Aufruf explizit in any
gecastet wurden, um den Compiler zu beruhigen:
it("works", () => {
// ...
const result = myHandlerFunction({ requestBody: { attrib: 42 } } as any);
// ...
});
Um das Mocking sauberer zu gestalten, hatte ich eine buildStubRequest Funktion
eingeführt, die die fehlenden Attribute eines solchen Stub-Requests mit
sinnvollen Standardwerten auffüllt. Zukünftig sollte der Aufruf eines Handlers
in einem Test dementsprechend so aussehen:
it("works", () => {
// ...
const result = myHandlerFunction(
buildStubRequest({ requestBody: { attrib: 42 } }),
);
// ...
});
Die Aufgabe im Refactoring war es, das bestehende Stub-Objekt zu erhalten, als
Parameter in buildStubRequest zu übergeben und den any-Cast zu entfernen.
Die Herausforderung hierbei: Nicht jede beliebige Object-Construction zu
matchen. Folgende Lösung war für meine Codebase ausreichend, solange sie nur auf
die Tests angewendet wurde:
id: transform-mock-obj
language: TypeScript
rule:
pattern: "$OBJ as $TYPE"
kind: as_expression
has:
kind: object
precedes:
stopBy: end
kind: predefined_type
regex: "any"
has:
stopBy: end
kind: property_identifier
regex: "requestBody"
fix: "buildStubRequest($OBJ)"
Die obige Regel matcht alle Casts (alle as_expression AST-Nodes), aber nur
solche, die ein Objekt definieren. Diese Objektdefinition muss direkt vor einem
hartkodierten any Cast stehen und mindestens einen Property-Key requestBody
haben. Das pattern zerlegt die gefundene AST-Node in zwei Teile:
Objektdefinition und der Type aus dem Cast. Nur Ersterer wird in der fix
Vorschrift verwendet und als Parameter an buildStubRequest übergeben.
Mit der Erfahrung aus diesen einfacheren Beispielen bewaffnet ist es Zeit, zum
Ausgangsproblem zurückzukehren: Ich möchte den Inhalt der handle-Methode in
einen withContextHandler-Wrapper verschieben, solange diese sich in einer
Klasse befinden, die das Handler-Interface implementiert. Wie folgt meine
Lösung:
id: wrap-handler-context
language: TypeScript
rule:
pattern: "$BODY"
kind: statement_block
inside:
kind: method_definition
has:
kind: property_identifier
regex: "^handle$"
inside:
stopBy: end
kind: class_declaration
has:
stopBy: end
kind: implements_clause
has:
stopBy: end
kind: type_identifier
regex: "^Handler$"
fix: "{return await this.contextService.withContext(async () => $BODY)}"
Diese Regel matcht alle Statement Blocks einer Methodendefinition, solange die
Methode genau handle heißt. Diese Methodendefinition muss sich weiterhin in
einer Klassen-Deklaration befinden, die das Handler Interface definiert.
Aufgabe für den Leser ist es, eine zweite Regel zu definieren, die in den
betroffenen Dateien den contextService als Konstruktor-Parameter hinzufügt.
Bonuspunkte gibt es, wenn die Konstruktoren nicht geändert werden, die bereits
einen contextService als Parameter akzeptieren ;)
Nun, da ich ein Verständnis für die Möglichkeiten gewonnen hatte, die ast-grep bietet, will ich den bereits verfügbaren Claude Skill ausprobieren.
Statt Claude, wie in der README des Skills beschrieben, verwende ich allerdings Googles Gemini-Modelle. Wenn es um Coding-Aufgaben geht, sind diese aber auf dem Papier nicht weit von den aktuell verfügbaren Anthropic-Modellen entfernt (Vergleich der Modelle [Archivlink] ). Den Prompt halte ich bewusst vage:
please use the `ast-grep` skill to write a ast-grep rule that matches all
`handle` method inside a `Handler` and provide a `fix` to wrap the body inside a
`contextService.withContext` call
Im ersten Versuch verwende ich lediglich Gemini 3.5 Flash, ein kostengünstiges
Modell. Der erzeugte folgende Output
id: ai2-wrap-handle-with-context
language: typescript
rule:
kind: method_definition
all:
- has:
field: name
regex: ^handle$
- inside:
kind: class_declaration
has:
kind: implements_clause
has:
kind: type_identifier
regex: Handler
stopBy: end
- not:
has:
pattern: this.contextService.withContext($$$)
stopBy: end
# Capture the method components for the fix
- any:
- pattern: "async handle($$$ARGS)$RET { $$$BODY }"
- pattern: "public async handle($$$ARGS)$RET { $$$BODY }"
fix: |
async handle($$$ARGS)$RET {
return await this.contextService.withContext(async () => {
$$$BODY
});
}
… ist allerdings keine gültige ast-grep Regel:
✖ Caused by
╰▻ Fail to parse yaml as RuleConfig
╰▻ mapping values are not allowed in this context at line 11 column 26
ASTs sind komplexe Konstrukte. Statt den Fehler in das kleine Modell
zurückzufüttern, entscheide ich mich für einen Wechsel auf das größere
Gemini 3.1 Pro und wiederhole den Prompt:
id: ai3-wrap-handle-with-context
language: typescript
rule:
pattern: |
class $A implements $H {
$$$BEFORE
async handle($CNAME: $CTYPE, $ENAME: $ETYPE): $RET {
$$$BODY
}
$$$AFTER
}
constraints:
H:
regex: Handler
fix: |
class $A implements $H {
$$$BEFORE
async handle($CNAME: $CTYPE, $ENAME: $ETYPE): $RET {
return await this.contextService.withContext($CNAME, $ENAME, async () => {
$$$BODY
});
}
$$$AFTER
}
Dieser Output sieht direkt deutlich kompakter und eleganter aus, funktioniert aber auch nicht:
✖ Caused by
╰▻ Fail to parse yaml as RuleConfig
╰▻ unknown field `constraints`, expected one of `pattern`, `kind`, `regex`, `nthChild`, `range`, `inside`, `has`, `precedes`, `follows`, `all`, `any`, `not`, `matches`
Der Fehler wäre einfach zu beheben: Ein constraint darf nur auf top-level
existieren. Verschiebt man diese Anweisung dementsprechend, funktioniert die
Regel einwandfrei. Da unser Ziel aber nicht “denken”, sondern “prompten” ist,
spielen wir die Fehlermeldung zurück ins LLM und erhalten eine vereinfachte
Regel, die funktioniert:
id: wrap-handle-with-context
language: typescript
rule:
pattern: |
class $A implements Handler {
$$$BEFORE
async handle($CNAME: $CTYPE, $ENAME: $ETYPE): $RET {
$$$BODY
}
$$$AFTER
}
fix: |
class $A implements Handler {
$$$BEFORE
async handle($CNAME: $CTYPE, $ENAME: $ETYPE): $RET {
return await this.contextService.withContext($CNAME, $ENAME, async () => {
$$$BODY
});
}
$$$AFTER
}
Kostenpunkt für die beiden Prompts mit dem Pro-Modell auf meiner 300k LOC Codebase laut OpenCode: 0,45 $. Deutlich günstiger, als jeden betroffenen Handler einzulesen und den gesamten Inhalt als Output-Token erneut zu bezahlen.
ast-grep ist eines von den Tools, von denen ich mir wünschte, dass ich sie
schon vor Jahren gefunden hätte. Meiner Meinung nach hat es deutlich mehr als
die 14500 Sterne verdient, die es gerade auf GitHub innehat. Egal ob von Hand
bedient oder mit einem passend ausgestatteten LLM instrumentiert: Bei der Arbeit
in großen Softwareprojekten kann damit viel Zeit und/oder Geld (für Tokens)
gespart werden.
Dabei kann ast-grep noch vieles mehr, das nicht mehr in diesen Blogpost
gepasst hat: So können mit der gleichen Rule-Engine auch
linting Rules für die
eigene Codebasis angelegt werden, es gibt einen
json Modus und
APIs, um die Ausgabe
des Tools in eigenen Programmen verarbeiten zu können, und wenn es um
Code-Rewrites geht, haben wir noch nicht mal an der Oberfläche von dem gekratzt,
was
mit zusätzlichen transforms
noch möglich wäre.