Eine Fotomontage aus einer Baumkrone und einer Schraubzwinge

Mit ast-grep Zeit und Geld sparen

Sebastian Staffa

Alternative Language Available

This blog post is also available in a language that matches your settings. Click here to go to the translated version.

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.

ast-grep

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.

Komplexere Refactoring-Probleme

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.

Lösung des Ausgangsproblems

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 ;)

Agent Skill

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.

Zusammenfassung

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.