Skip to main content

Edit the Tree

Mar 18, 2026 · 5 min read

Structural editing got reliable once ast-grep stopped being the interface and became the engine. Operation-first contracts, scoping, and structured recovery changed the model's job from guessing patterns to expressing intent.

Text editing works until it doesn’t. The moment the task becomes “rename this identifier inside one helper” or “change this call shape without touching unrelated code,” it turns into guesswork. The change lands somewhere close to correct, and you have to read the diff to confirm it didn’t hit something it shouldn’t have.

That was the push to bring ast-grep in seriously. Structural editing only became reliable once the engine stopped being the interface.

Starting with the engine directly

The obvious integration is to pass pattern strings to ast-grep and let the model figure out the rest. That is the right call early on because it gets something working quickly and reveals how the model actually uses the capability. I am glad I started there, because the mistakes taught me what the real abstraction should be.

The cost shows up later. The model has to:

  • invent structural queries from scratch
  • decide how to scope them
  • decide when to use them over text editing
  • recover when a query fails

The engine is powerful, but the product feels brittle because the model is synthesizing too much that it should not have to. The lesson was straightforward: ast-grep is not the abstraction. It is the execution engine. The contract Acolyte needs looks like this:

  • rename an identifier inside this symbol
  • replace this structural pattern inside this scope
  • scan these files for this code shape

Those are editing tasks. Ast-grep executes them.

Queries and mutations

The broader principle behind Acolyte’s tooling is a strict query/mutation split.

Query tools (read-file, search-files, scan-code) are read-only. Their contracts stay simple because the caller is asking “what is there?”

Mutation tools (edit-file, edit-code) change workspace state. Their contracts are more expressive because precision matters. The cost of a wrong match is a bad edit.

The key point: do not unify query and mutation contracts just because they share an engine. A scan tool and an edit tool may both use ast-grep, but they serve different purposes. Their input models should be designed independently.

That split is what makes the edit-file / edit-code boundary work:

  • edit-file handles bounded, visible, text-first changes
  • edit-code handles structural refactors

On the query side:

  • search-files operates on text
  • scan-code operates on the AST

They share nothing except the underlying engine. When those responsibilities blur, both tools degrade. The text tool starts behaving like a refactor engine. The AST tool gets pulled into cleanup it was never meant to handle.

Keeping the boundary clean removes guesswork from the instruction layer. The model no longer has to decide whether every change should be structural. It chooses based on intent.

Operations, not patterns

The first edit-code interface was still too close to the engine. Pattern-based usage led to over-broad or weakly scoped edits because the model was still responsible for constructing the query.

Making the contract operation-first fixed that. Instead of “give ast-grep a pattern,” the model expresses intent directly:

{
  "op": "rename",
  "from": "result",
  "to": "patternResult",
  "withinSymbol": "scanFile"
}

For replacement:

{
  "op": "replace",
  "rule": { "any": ["console.log($ARG)", "console.info($ARG)"] },
  "replacement": "logger.debug($ARG)"
}

This replaces both call shapes in one edit. With text editing, the same task would require multiple passes and still lose structural guarantees.

The model decides what to change and where. The tool decides how that maps onto the tree.

Where scoping pays off

Without scope, structural editing is just a more complex search-and-replace.

The useful step was aligning scoping with how people describe refactors:

  • inside this helper
  • inside this class
  • inside this declaration

In Acolyte this becomes withinSymbol. Helper-scoped renames and class-field edits start behaving like the task asked for instead of renaming every matching node and hoping the diff looks reasonable.

The contract can stay language-agnostic (rename, replace, inside this symbol) while the engine still depends on the underlying grammar. Language-agnostic does not mean syntax-agnostic. The interface stays stable. The execution remains language-specific.

Recovery matters

Structural tools fail in precise ways, and the system should preserve that precision. If scan-code or edit-code fails, the response should not be “something went wrong.” It should describe what to do next.

That led to a ToolRecovery contract:

  • unsupported file type → fall back to search-files
  • no AST match → refine the pattern
  • invalid replacement → fix the shape

The model does not have to infer recovery strategy. The tool defines it. This keeps control with the host while reducing guesswork in failure cases.

Design around the problem

The important shift is not that Acolyte has scan-code and edit-code. It is that ast-grep is no longer the interface. That change drove everything else:

  • operation-first contracts instead of patterns
  • explicit scoping instead of global matches
  • query/mutation separation instead of shared abstractions
  • structured recovery instead of generic errors

In practice, this makes Acolyte reliable at a specific class of tasks:

  • bounded structural renames
  • helper-scoped refactors
  • class-scoped changes
  • rule-based replacements too awkward for text editing

Structural editing is not solved. The next work is obvious: richer operations and broader coverage. But the direction is right. The engine didn’t change. The interface did, and that is what made it usable.

Share

Read next

Follow the Thread

The trace tool started as a script so the AI could read its own runtime behavior. It became a first-class CLI command because the same observability that helps the model debug itself helps the developer understand what happened and why.