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-filehandles bounded, visible, text-first changesedit-codehandles structural refactors
On the query side:
search-filesoperates on textscan-codeoperates 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.