~/blog/deep-dive-ast-grep-ast-grep
tech notes #terminal#cli Wakii team 8 min read

ast-grep: find and fix code by AST

ast-grep: find and fix code by AST

grep works on text: you hand it a string, it returns the lines containing that string. But code is not plain text — code is a syntax tree. Rename a variable, wrap a line, add parentheses: the meaning stays the same while grep’s result changes. The ast-grep/ast-grep repo — 15,799 stars, MIT licensed, per the GitHub API on 2026-09-08 — goes the other way: it finds and fixes code by structure, using patterns that look like the code you are looking for.

TL;DR:

  • Patterns are written as code: $A && $A() matches every expression with the same structure, regardless of formatting or variable names.
  • The $A variable is stored in an environment on match — reusable when rewriting.
  • Complex rules move into YAML files with severity and fix — rules become data, reviewable like code.
  • sg scan reads a rule directory and returns an ExitCode — the right shape for a command you install in CI.
  • Wakii runs the same principle in its blog pipeline: machine lint green first, human review second.

Patterns written as code, matched as structure

The repo’s README compresses the idea into one line: ast-grep is “a CLI tool for code structural search, lint, and rewriting” (ast-grep/ast-grep, README). What it matches is not text but nodes of the syntax tree produced by tree-sitter. The README’s own example:

ast-grep -p '$A && $A()' -l ts -r '$A?.()'

One command: find every place that calls the same value twice and rewrite it into the optional-call form $A?.(). A regex only catches this pattern cleanly if you predict the whitespace and variable names in advance; an AST pattern predicts nothing — $A matches any single node, and $A appearing twice in one pattern forces both nodes to be identical.

The mechanism lives in a fairly small struct inside crates/core. The repo’s own comment explains how the variable binds to real code:

/// a dictionary that stores metavariable instantiation
/// const a = 123 matched with const a = $A will produce env: $A => 123
pub struct MetaVarEnv<'tree, D: Doc> {
  single_matched: HashMap<MetaVariableID, Node<'tree, D>>,
  multi_matched: HashMap<MetaVariableID, Vec<Node<'tree, D>>>,
  transformed_var: HashMap<MetaVariableID, Underlying<D>>,
}

Source: crates/core/src/meta_var.rs, HEAD of main on 2026-09-08.

On every match, $A is recorded in MetaVarEnv together with the real node it stands for — the rewrite step reuses that record, so the replacement knows exactly which node to insert. Three dictionaries keep three kinds of variables apart: single matches, multi-node matches, and transformed variables. A pattern is therefore both easy to write (it looks like ordinary code) and informative enough to patch the code in the right place.

When one line is not enough: rules as YAML files

A one-line pattern handles simple find-and-replace. Banning a set of code patterns with conditions and scope needs somewhere to describe the rule outside the CLI. ast-grep picks YAML: each rule declares an id, a language, the match condition, a severity and a fix. The severity ladder comes straight from the code:

pub enum Severity {
  /// Turns off the rule.
  Off,
  #[default]
  /// A kind reminder for code with potential improvement.
  Hint,
  /// A suggestion that code can be improved or optimized.
  Info,
  /// A warning that code might produce bugs or does not follow best practice.
  Warning,
  /// An error that code produces bugs or has logic errors.
  Error,
}

Source: crates/config/src/rule_config.rs, HEAD of main on 2026-09-08.

Five levels from Off to Error — exactly the scale a CI gate needs to separate “just a hint” from “must fix”. The description of each level sits next to the enum like documentation: anyone reading a rule understands which level blocks and which only suggests.

The depth of the rule system lives in crates/config/src/rule (GitHub API on 2026-09-08) — the directory contains files named after their mechanisms:

relational_rule.rs   relational rules: inside / has
nth_child.rs         child position within a parent node
stop_by.rs           stop condition when walking the tree
referent_rule.rs     rules referencing other rules
selector.rs          picking nodes for other rules to apply to

Relational rules are the big win: a rule can say “find pattern X inside pattern Y” instead of cramming both into one pattern. A rule then becomes structured data — read, reviewed and versioned alongside the code, instead of a regex buried in a script nobody dares to touch.

sg scan in CI: the machine gates before the human reads

The CLI carries the short name sg (the README titles the tool “ast-grep(sg)”). The scan command has its own file in the CLI — and its signature shows a CI-first design:

pub fn run_with_config(arg: ScanArg, project: Result<ProjectConfig>) -> Result<ExitCode>

Source: crates/cli/src/scan.rs, HEAD of main on 2026-09-08 — the function returns std::process::ExitCode, meaning the exit status of sg scan reflects the scan result, exactly what a CI job needs to pass or fail. The ast-grep organization also maintains the ast-grep/action repo for embedding the scan step into GitHub Actions (exists, 12 stars, per the GitHub API on 2026-09-08).

The release cadence shows a tool in steady use: 5 of the 10 most recent releases (per the GitHub API on 2026-09-08):

TagDate
0.45.32026-08-31
0.45.22026-08-23
0.45.12026-08-07
0.45.02026-07-23
0.44.12026-07-04

Continuous patches, a minor roughly once a month — the rhythm of a tool used in anger, not a showcase repo.

The lesson is not ast-grep itself but the shape it chose: the machine scans first, the human reads second. A mediocre YAML rule can still be fixed while it runs quietly in CI; no rule survives if every violation needs a human eyeball. Wakii runs the same shape for its blog pipeline — the post CI gates: the machine blocks first, the human closes describes exactly this layering: vocabulary lint, word band, and two-locale parity run first; human review only receives what the machine cannot judge.

One engine, many faces

The whole repo is a Rust workspace split along the right boundaries: core is the engine, config is the rule system, the rest are faces for individual environments (per the GitHub API on 2026-09-08):

crates/core     pattern matching engine on the tree-sitter tree
crates/config   loads YAML rules, severity, fix
crates/cli      the sg command line
crates/language language definitions
crates/napi     Node.js binding (npm @ast-grep/cli)
crates/wasm     runs in the browser (online playground)
crates/lsp      language server for editors
crates/pyo3     Python binding

Source: crates/, GitHub API on 2026-09-08.

Separating the engine from the interfaces is why this tool shows up in so many places without duplicating logic: the same MetaVarEnv, the same YAML rules, running in a terminal, in an npm script, on a playground page, and inside editors over LSP. Wakii layers its checks the same way: one rule core (the topic matrix manifest plus the lint script) serves several faces — lint while writing, audit at convergence, gate at build — instead of each face inventing its own rules.

If you want to see where these check layers sit inside a real story — the machine runs lint in every SF, the human decides at gates — read the story workflow: from idea to release.

What Wakii learns

  • ADOPT — the “machine gate before human review” principle is one Wakii already applies and keeps: content lint (word band, FORBIDDEN list, grading marker) and two-locale parity run in the build chain before a reviewer opens the post; the B0-B5 gates have the same shape at the story level. The three-layer watchdog is another machine gate (idle is not dead). This post confirms the direction with a tool that runs the same principle at industry scale.
  • DIRECTION — AST-level checks for content: today’s lint checks line by line (band counting, literal grep, heading level comparison) and is enough for data-style rules like the FORBIDDEN list. When checks need to go beyond the line — nested section structure, frontmatter schemas with many constraints — the AST direction is feasible in principle because tree-sitter has a markdown grammar (tree-sitter-grammars/tree-sitter-markdown, MIT, per the GitHub API on 2026-09-08). Being honest about the present: no current blog check needs an AST — this is a direction, not a task worth doing today.
  • WATCH — a pattern-level scanner for scripts in the site repo: ast-grep supports JavaScript out of the box; if the number of code-level rules to enforce on the check scripts grows until regex breaks down, an sg scan-style scanner with YAML rules becomes the candidate. Condition to change the grade: the first code-level rule that regex cannot express correctly.

ast-grep ships an online playground so you can try patterns without installing anything. If you are building a content pipeline or a multi-gate agent workflow, download Wakii and run your first story following the docs.

Wakii is an agentic IDE with a built-in superpowers team.

get wakii ← all posts