Claude Cursor Skill

ast-grep

Use when asked to run AST-based structural search, lint, or rewrite of code when regex is too fragile. Not for remote, credential, publish, deploy, or irreversible changes.

LLM Mart · 0 points · 8 views 0 listing impressions 0 install-command copies
Virus-scanned Reviewed automatically before listing.

Full trust report

Download outlinedriven-odin-claude-plugin-plugins_odin-code_skills_ast-grep-b05a1e3.zip · 23 KB
Part of outlinedriven/odin-claude-plugin — 120 skills

Install

skills CLI npx skills add https://github.com/OutlineDriven/odin-claude-plugin/tree/main/plugins/odin-code/skills/ast-grep
Claude Code claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install outlinedriven-odin-claude-plugin@llmmart
Git git clone https://github.com/OutlineDriven/odin-claude-plugin.git

The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole outlinedriven/odin-claude-plugin collection as a plugin from our marketplace. Git is the plain clone.

Skill manifest

ast-grep

Contract

Field Bound contract
Trigger AST-based modification, structural search, lint, or replacement too fragile for regex.
Authority Reversible local: writes only VCS-tracked source files (search is read-only; rewrites apply only through the helper after dry-run review); rollback is version control. No remote mutation.
Side effect Local file writes through the helper two-pass validate/dry-run/apply flow; no remote, credential, or published mutation.
Done Pattern validated and blast radius reviewed; any rewrite landed at the correct scope (search/lint-only runs are valid).

Inputs

  • An ast-grep pattern, single-quoted in the shell so $VAR reaches ast-grep unexpanded.
  • A language (--lang) or a single target path whose extension auto-detects it; required for stdin patterns.
  • For rewrites: a rewrite template and one or more target paths (defaults to the current directory).
  • Optional: include/exclude globs (repeatable, prefix ! to exclude), context lines, JSON output mode.

Procedure

  1. Confirm the task is structural (call, function, class, or import shaped like a pattern), not text/regex/filename matching (use grep) or semantic type/reference lookup (use LSP or the compiler). ast-grep matches syntax, not bytes. Done when: the task is confirmed structural.
  2. Validate the pattern before searching: python3 scripts/ast_grep_helper.py validate '<pattern>' --lang <L>. Exit 0 means ast-grep parses it cleanly; exit 2 means malformed (the helper prints the parsed pattern tree showing the ERROR node). Fix and re-validate. Done when: validate exits 0.
  3. For a rewrite, run the dry-run: python3 scripts/ast_grep_helper.py replace '<pattern>' '<rewrite>' --lang <L> <paths>. Read the diff and the N matches across M files count. If the blast radius is wrong, stop and refine the pattern (tighten meta-variables, add --lang, add context); re-run the dry-run. Done when: the dry-run diff and match count are correct.
  4. Apply only after the dry-run diff is correct: python3 scripts/ast_grep_helper.py replace '<pattern>' '<rewrite>' --lang <L> <paths> --apply. The helper writes via a separate --update-all pass. Done when: files are updated via --update-all.
  5. Invoke ast-grep, never sg: sg collides with the setgroups binary on many systems. Done when: ast-grep is invoked, not sg.

Pattern syntax: $VAR matches any single node; $$$ARGS matches zero or more nodes; $_ matches any node (non-capturing).

Invariants: validate before searching; dry-run before applying; single-quote patterns; --lang is required for stdin; a pattern is code, not regex; switch to grep the moment |, .*, \w, or [...] would be needed. The helper keeps --json and --update-all as separate passes because combining them makes --json silently win and the write is dropped with no error.

For complex tasks, ast-grep supports YAML rule files (sgconfig.yml, ast-grep new project) with rule, fix, inside, any, and matches fields; invoke ast-grep scan/run/test directly for these.

Failure and recovery

  • Malformed pattern: validate exits 2 and prints the pattern debug tree with the ERROR node. No files touched. Fix the pattern and re-validate.
  • Wrong blast radius: the dry-run diff or match count is not as expected. Do not --apply. Refine the pattern and re-run the dry-run. No files touched.
  • Zero matches unexpectedly: run the 0-matches ladder in order: (1) validate the pattern; (2) check --lang (tsx is not ts; the wrong dialect silently matches nothing); (3) ast-grep run -p '<pattern>' -l <L> --debug-query=pattern and look for ERROR; (4) inspect the target's actual tree with --debug-query=ast on a known-matching snippet; (5) reproduce in the online playground. No files touched until a correct match is confirmed.
  • ast-grep binary absent: validate skips the parse check (regex-smell only) and warns; replace exits 2 without running. Install ast-grep before proceeding.
  • Apply pass failure: the helper reports the ast-grep error and returns non-zero. Revert the partially-written VCS-tracked targets via version control and re-run the full dry-run/apply sequence.
  • Partial-result rule: a failed apply pass leaves whatever ast-grep wrote; never report done. Recover via version control and re-run from the dry-run.

Output

  • validate: exit 0 (valid) or 2 (malformed, with the pattern debug tree); advisory regex-smell hints on stderr.
  • replace dry-run: a compact unified diff and an N matches across M files summary; no files modified.
  • replace --apply: VCS-tracked files updated via --update-all plus a confirmation line.
  • Direct ast-grep search: matched code locations.
Files (odin-claude-plugin)
  • agents
    • openai.yaml 164 B
      interface:
        display_name: "Ast Grep"
        short_description: "Use when asked to run AST-based structural search, lint, or rewrite of code when regex is too fragile."
      
  • references
    • cli.md 1.2 KB
      # CLI reference
      
      ## Common commands
      
      ### `ast-grep run` (default)
      Run one-time search or rewrite.
      - `-p, --pattern <PAT>`: Search pattern.
      - `-r, --rewrite <STR>`: Replacement string.
      - `-l, --lang <LANG>`: Language (inferred from ext if omitted).
      - `-i, --interactive`: Interactive mode for applying fixes.
      - `-U, --update-all`: Apply fixes non-interactively.
      - `--json`: Output results as JSON.
      
      ### `ast-grep scan`
      Scan project using rule files.
      - `-c, --config <FILE>`: Path to `sgconfig.yml` (default).
      - `-r, --rule <FILE>`: Scan with specific rule file.
      - `--inline-rules <YAML>`: Pass rule YAML directly.
      - `--report-style <STYLE>`: Output format (rich, medium, short).
      
      ### `ast-grep test`
      Test rules against test cases.
      - `-t, --test-dir <DIR>`: Directory containing test cases.
      - `-U, --update-all`: Update snapshots.
      
      ### `ast-grep new`
      Scaffold new projects or rules.
      - `project`: New project structure.
      - `rule`: New rule file.
      - `test`: New test case.
      
      ### `ast-grep lsp`
      Start Language Server for editor integration.
      
      ## Configuration (`sgconfig.yml`)
      
      Root configuration file for projects.
      - `ruleDirs`: Directories containing rules.
      - `testConfigs`: Test configuration.
      - `utilsDirs`: Directories containing utility rules.
      
    • core-concepts.md 2.5 KB
      # Core concepts
      
      A few concepts from the underlying Tree-sitter parser explain how ast-grep matches code.
      
      ## AST vs CST
      
      - CST (Concrete Syntax Tree): Includes all details of the source code, including punctuation, parentheses, and whitespace.
      - AST (Abstract Syntax Tree): A simplified tree that keeps only "named" nodes, omitting trivial details.
      
      ast-grep parses code into a CST, but the default `smart` algorithm skips unnamed nodes in the target that are absent from the pattern, so a concise pattern still matches verbose source. Use `cst` strictness to require every node, including unnamed trivia, to match.
      
      ## Named vs unnamed nodes
      
      Tree-sitter distinguishes between:
      - Named Nodes: Have a specific `kind` (e.g., `identifier`, `function_declaration`). Usually important.
      - Unnamed Nodes: Anonymous tokens like `+`, `(`, `;`. Usually trivial.
      
      Note: Meta-variables match nodes in the pattern: `$VAR` matches any single **named** node (expression, statement, identifier, etc.); `$$VAR` also matches **unnamed** nodes (e.g., `;`, `+`), useful when you need to capture trivia; `$$$VAR` matches **zero or more** nodes (e.g., `foo($$$ARGS)` matches `foo()`, `foo(1)`, `foo(1, 2)`).
      
      ## Kind vs field
      
      - Kind: The type of the node itself (e.g., `binary_expression`, `string_literal`).
      - Field: The role of a node relative to its parent (e.g., `lhs`, `rhs` in a binary expression, or `key`, `value` in a pair).
      
      In YAML rules:
      ```yaml
      rule:
        kind: string_literal # Matches node kind
        inside:
          field: key # Matches node's role in parent
          kind: pair
      ```
      
      ## Matching algorithms (strictness)
      
      ast-grep offers different "strictness" levels for matching patterns.
      
      | Level | Description | Behavior |
      |-------|-------------|----------|
      | `smart` | **Default**. Matches pattern structure but ignores unnamed nodes in target code. | Good for most cases. `foo()` matches `foo();`. |
      | `cst` | Exact match. | Requires strict punctuation/whitespace match. |
      | `ast` | Match only named nodes. | Skips all punctuation/unnamed nodes in both pattern and target. |
      | `relaxed` | Like `ast` but also ignores comments. | |
      | `signature` | Matches only named node **kinds**. | Ignores text content (identifiers, literals). |
      
      ### Configuring strictness
      
      CLI:
      ```bash
      ast-grep run -p '$A' --strictness ast
      ```
      
      YAML:
      ```yaml
      rule:
        pattern:
          context: $A
          strictness: ast
      ```
      
      `strictness` is a field of the **pattern object**, not the rule level. Placing it as a sibling of `rule` is silently ignored on ast-grep 0.45.x.
      
    • pattern-syntax.md 2 KB
      # Pattern syntax
      
      ast-grep uses "pattern code" to construct an AST tree and match it against target code. Patterns look like the source code but can contain meta-variables.
      
      ## Meta-variables
      
      Meta-variables act like wildcards that match AST nodes.
      
      ### Single-node match (`$VAR`)
      Matches any single AST node (expression, statement, etc.).
      - Syntax: `$NAME` (starts with `$`, followed by uppercase letters, `_`, or digits).
      - Examples: `$A`, `$VAR`, `$NODE_1`.
      - Invalid: `$a` (lowercase), `$kebab-case`.
      
      ### Multi-node match (`$$$VAR`)
      Matches zero or more AST nodes. Useful for arguments, parameters, or statement blocks.
      - Syntax: `$$$NAME`
      - Example: `foo($$$ARGS)` matches `foo()`, `foo(1)`, `foo(1, 2)`.
      
      ### Non-capturing match (`$_`)
      Matches nodes but does not bind them to a named variable (optimization).
      - Syntax: `$_` or `$_NAME`.
      - Example: `$_FUNC($_ARGS)` matches function calls without saving the function name or arguments.
      
      ### Unnamed-node match (`$$VAR`)
      Matches a single node the way `$VAR` does, but also matches unnamed nodes, which
      `$VAR` skips. It is not a list form: `foo($$A)` matches `foo(a)` and not `foo(a, b)`.
      - Syntax: `$$` or `$$NAME`.
      - Source: [Capture Unnamed Nodes](https://ast-grep.github.io/guide/pattern-syntax.html), read 2026-09-01.
      
      ## Capturing behavior
      
      - Consistency: If a meta-variable `$A` appears multiple times in a pattern (e.g., `$A == $A`), it enforces that the matched nodes are structurally identical.
      - Unification: Used to find redundant code or specific logic patterns.
      
      ## Pattern parsing
      
      Patterns must be valid code in the target language.
      - Object-style Pattern: If a code snippet is ambiguous (e.g., `{ key: val }` could be an object literal or a block), use the object format in YAML rules to specify context:
      
      ```yaml
      rule:
        pattern:
          context: "{ key: value }"
          selector: pair
      ```
      
      ## Best practices
      
      - Use `$$$ARGS` for function arguments to handle variable arity.
      - Use `$$$BODY` for function bodies or block statements.
      - Use `$_` when you don't need to reference the node in `fix` or `transform`.
      
    • pitfalls.md 6.7 KB
      # ast-grep pitfalls: failure-mode field guide
      
      Read this when a search returns 0 matches unexpectedly, or before running a rewrite. ast-grep matches *AST shape*, not text. Most "0 matches" surprises trace to one of the sections below.
      
      ## §1: ast-grep is not regex
      
      A pattern is parsed as code, then matched structurally. Regex metacharacters are not interpreted: they either parse to literal code with the wrong meaning, or fail to parse and produce an `(ERROR ...)` node that silently matches nothing.
      
      The only wildcards are meta-variables:
      
      - `$VAR`: exactly one **named** AST node, captured under `VAR`.
      - `$$$`: zero or more nodes (a node list).
      - `$_`: one node, **anonymous** (matches but does not capture).
      - `$$`: one **unnamed** node (operators, punctuation, keywords).
      
      | You wrote | ast-grep saw | What you wanted |
      | --- | --- | --- |
      | `foo\|bar` | bitwise-or of `foo` and `bar` (valid code, wrong intent) | run two searches, or an `any:` YAML rule |
      | `.*foo` | not parseable as code | `$$$ foo`, or use `rg` |
      | `\w+` | not parseable → ERROR node | `$VAR` to capture any identifier |
      | `[a-z]` | char class → not code | switch to `rg` |
      
      Note: the helper's `validate` catches the `\w` / `\d` / `\s` / `.*` class as a hint, but its authoritative check is whether the pattern parses to a clean CST (no `(ERROR` nodes). A clean parse with the wrong intent, like `foo|bar`, passes validation yet still matches the wrong thing.
      
      ## §2: Patterns must be valid code
      
      The pattern is fed to the language's parser before matching. If the fragment is syntactically incomplete, it produces an ERROR node and matches nothing.
      
      - `def $FN($$$):` fails: the trailing colon makes it an incomplete statement. Use `def $FN($$$)`.
      - `function $NAME` fails: no parameter list or body. Use `function $NAME($$$) { $$$ }`.
      
      Validate a suspect pattern:
      
      ```
      ast-grep run -p '<pattern>' -l <lang> --debug-query=cst
      ```
      
      If the output shows `(ERROR ...)` anywhere, the pattern is malformed; fix it before trusting the (empty) result.
      
      ## §3: `--json` and `--update-all` conflict; preview and apply are separate passes
      
      `--update-all` conflicts with `--json` (see `-U` in `ast-grep run --help`: "It conflicts with both the `--interactive`, `--json` ... flags"). You cannot get a JSON preview and a mutation from one invocation: combine them and files stay untouched while JSON still prints, so a script that expects the write to land silently never applies it.
      
      Run two separate passes; never pass both flags together:
      
      ```
      ast-grep run -p P -r R -l L --json=compact .   # pass 1: preview
      ast-grep run -p P -r R -l L --update-all .     # pass 2: apply
      ```
      
      The skill's `scripts/ast_grep_helper.py replace` does this automatically: dry-run (preview) by default, and `--apply` triggers the second pass that actually writes.
      
      ## §4: Named vs unnamed nodes
      
      `$VAR` captures **named** nodes: identifiers, expressions, statements. Operators, punctuation, and keywords are **unnamed** in the grammar; `$VAR` will not bind to them. Capture unnamed nodes with `$$`.
      
      This bites on punctuation-heavy syntax:
      
      - Kotlin `!!` (the non-null postfix operator, often unnamed).
      - C `==` and other operators.
      - Anything where the token you want is punctuation rather than an identifier or expression.
      
      If a pattern aimed at an operator returns nothing, the operator is almost certainly an unnamed node; reach for `$$`.
      
      ## §5: Contextual patterns for ambiguous grammars
      
      A bare fragment can parse as the wrong node type because the parser picks whatever production fits a standalone snippet. The match then never fires against real code where the fragment is a different kind of node.
      
      Disambiguate with a context wrapper: the pattern-object form pairs a full-statement `context` with a `selector` naming the node kind you actually want:
      
      ```yaml
      { context: "func t() { $CALL }", selector: call_expression }
      ```
      
      Common offenders that need a context wrapper:
      
      - Go and C function calls.
      - Python `Optional[$T]` (parses as subscript vs. type context).
      - JS object literals (`{ $K: $V }` parses as a block, not an object).
      
      Concrete trap: bare `foo($X)` in C parses as `macro_type_specifier`, not a call expression, so it silently misses every real call.
      
      ## §6: Meta-variable naming
      
      Meta-variable names must match `[A-Z_][A-Z_0-9]*`: start with an uppercase letter or underscore, then uppercase letters, digits, or the underscore character.
      
      - Lowercase names like `$foo` **silently fail to match**. No error; just zero results.
      - Digit-start names like `$123` are invalid: the name must begin with `[A-Z_]`, not a digit.
      - `$_` is the anonymous, non-capturing wildcard (one node, not bound to a name).
      - Using the **same** variable name twice requires both occurrences to bind to **identical** code. `$A == $A` matches `x == x` but not `x == y`.
      
      If a pattern with a lowercase meta-variable returns nothing, rename it to uppercase first.
      
      ## §7: stdin needs `--lang`; tsx ≠ ts; single-pass rewriting
      
      - **stdin has no extension to infer from.** For file arguments, `run` / `scan` infer the language from the file extension. For `--stdin`, `--lang` is **required**; there is nothing to infer, and omitting it errors or mis-parses.
      - **`tsx` ≠ `ts`.** Use `--lang tsx` for any file containing JSX. `--lang ts` mis-parses JSX (the `<Tag>` syntax collides with type assertions / generics), so JSX patterns silently miss.
      - **Rewrites are single-pass.** `fix:` / `-r` rewrites only the **outermost** matching node. Nested transforms (e.g. rewriting `Optional[Union[...]]` where both the outer and inner type need changing) require a `rewriters` array to recurse into the captured sub-nodes.
      
      ## §8: `sg` ↔ `setgroups` collision (Linux)
      
      The short binary name `sg` collides with shadow-utils' `sg` (the `setgroups` / run-a-command-with-group binary) on Linux. Invoking `sg` may run the wrong program.
      
      Always invoke `ast-grep` by its full name, never `sg`. The skill's helper does this unconditionally.
      
      ## 0 matches but the code is there: the debug ladder
      
      Work down the rungs in order; stop at the first that explains the miss.
      
      1. **Validate the pattern.** `helper validate '<pattern>' --lang L`: catches regex metacharacters, lowercase meta-variables, and parse errors.
      2. **Check `--lang`.** Is it `tsx` vs `ts`? stdin without `--lang`? Wrong language parses to the wrong tree.
      3. **Dump the pattern's CST.** `ast-grep run -p '<pattern>' -l L --debug-query=cst`: look for `(ERROR ...)` nodes that mean the pattern is malformed (§2).
      4. **Inspect the target's CST.** `ast-grep run -p '$_' -l L --debug-query=cst <file> | head -40`: find the real node `kind` of the code you expected to match, then rebuild the pattern (or add a `selector`) around it.
      5. **Reach for the playground.** Paste pattern + code at <https://ast-grep.github.io/playground.html> for an interactive CST view when the CLI dumps aren't enough.
      
    • project-setup.md 1.7 KB
      # Project setup and testing
      
      Set up a project structure before using ast-grep to lint or scan a codebase.
      
      ## Scaffolding
      
      Use the CLI to create a new project:
      
      ```bash
      ast-grep new project
      ```
      
      This creates the standard directory structure:
      
      ```
      project-root/
      ├── sgconfig.yml         # Root configuration
      ├── rules/               # Rule definitions (.yml)
      ├── rule-tests/          # Test cases (.yml)
      └── utils/               # Reusable utility rules
      ```
      
      ## Configuration (`sgconfig.yml`)
      
      The `sgconfig.yml` file defines where ast-grep looks for rules and tests.
      
      ```yaml
      # List of directories containing rule files
      ruleDirs:
        - rules
      
      # List of directories containing utility rules
      utilDirs:
        - utils
      
      # Configuration for tests
      testConfigs:
        - testDir: rule-tests
      ```
      
      ## Testing rules
      
      Test rules to confirm they match what you expect and produce neither false positives (noisy matches) nor false negatives (missing matches).
      
      ### Test file structure
      
      Test files (e.g., `rule-tests/my-rule-test.yml`) map test cases to a rule ID.
      
      ```yaml
      id: my-rule-id # Must match the 'id' in your rule YAML
      valid:
        - "const x = 1;" # Code that should NOT trigger the rule
        - "var y = 2;"
      invalid:
        - "const x = eval('1');" # Code that SHOULD trigger the rule
        - "eval(foo);"
      ```
      
      ### Running tests
      
      ```bash
      # Run all tests
      ast-grep test
      
      # Update snapshots (for error messages/fixes)
      ast-grep test -U
      
      # Interactive mode
      ast-grep test -i
      ```
      
      ### Snapshots
      
      When you run tests with `-U`, ast-grep creates a `__snapshots__` directory. This stores the expected output (error messages, fix replacements) for your invalid cases. This checks that the rule triggers and produces the correct diagnostic/fix.
      
    • recipes.md 8 KB
      # ast-grep recipes: per-language copy-paste reference
      
      Structural search/rewrite recipes adapted from the official ast-grep catalog (https://ast-grep.github.io/catalog/). Rows flagged **(community)** are not in the official catalog and need closer review. Meta-var rules across every language: meta-vars must be UPPERCASE (`$A`, not `$a`); `$VAR` binds one named node; `$$$` binds zero-or-more nodes; `$$` binds unnamed nodes.
      
      ## TypeScript / JavaScript
      
      Aliases `ts`/`tsx`/`js`/`jsx`. Use `--lang tsx` for files containing JSX; `--lang ts` mis-parses JSX angle brackets as type assertions.
      
      | Pattern (-p) | Rewrite (-r) | Catches / Does | Gotcha |
      | --- | --- | --- | --- |
      | `console.log($$$A)` | `logger.info($$$A)` | Debug prints; `$$$A` swallows all args | To keep `console.error` inside a `catch`, drop the bare pattern and use a YAML rule with a `regex` constraint on the method name |
      | `$A == $B` | `$A === $B` | Loose equality | `== null` checks are often intentional null+undefined guards; do not blind-apply |
      | `var $A = $B` | `const $A = $B` | var→const modernization | Only sound when the binding is never reassigned; verify before `-U` |
      | `require($A)` | (detection / manual import) | CommonJS `require` inventory | Bare `require` may sit inside an assignment or call that needs a contextual wrapper to match reliably |
      | `$A \|\| $B` | `$A ?? $B` | Nullish-coalescing migration (detection-first) | `\|\|` and `??` diverge on falsy `0`/`''`/`false`; review each site, never bulk-apply |
      | `useState<string>($A)` | `useState($A)` | Drops an inferrable primitive generic (TSX) | `--lang tsx` required; the generic only drops safely when the initializer fixes the type |
      
      ## Python
      
      Alias `py`.
      
      | Pattern (-p) | Rewrite (-r) | Catches / Does | Gotcha |
      | --- | --- | --- | --- |
      | `print($$$A)` | `logger.info($$$A)` | Debug prints | `$$$A` captures all positional args; keyword args (`sep=`, `end=`) ride along and may not translate |
      | `Optional[$T]` | `$T \| None` | PEP 604 union syntax | Bare `Optional[$T]` parses as a subscript, not a generic type; needs a pattern object `{ context: 'a: Optional[$T]', selector: generic_type }`. Nested `Optional[Union[...]]` needs a `rewriters` array; a single pass rewrites only the outermost |
      | `$B = lambda: $R` | `def $B():\n    return $R` | Named zero-arg lambda → def | Python block patterns need literal newlines + indentation; the `def` line's trailing colon is part of the grammar, not decoration |
      | `except $E:\n    pass` | (detection) | Empty `except` clause | Node kind is `except_clause`; test emptiness with `not has` rather than matching `pass` text |
      
      Python meta-var note: `$` is not a valid Python identifier char, so ast-grep's own engine parses meta-var patterns fine, but raw tree-sitter CST debug views may render `$` oddly. Trust an actual `ast-grep run` over raw CST dumps for Python.
      
      ## Rust
      
      Alias `rs`.
      
      | Pattern (-p) | Rewrite (-r) | Catches / Does | Gotcha |
      | --- | --- | --- | --- |
      | `$VAR.unwrap()` | (detection) | Panic-prone `unwrap` audit | Add `not inside: kind: test_item` to permit `unwrap` in tests while flagging production code |
      | `$A.chars().enumerate()` | `$A.char_indices()` | Correct multibyte byte offsets | `enumerate()` yields char counts, `char_indices()` yields byte offsets; only swap when byte offsets are what the caller wants |
      | `$VAR.clone()` | (detection) | Clone-cost audit | Matches any receiver indiscriminately; narrow with `inside`/`has` to the type or scope you care about |
      | `pub use $B::$C;` | (relational detection) | Redundant re-export when preceded by `pub mod $A;` | Express as a relational rule pairing the `pub use` with the sibling `pub mod`; a bare pattern can't see the relationship |
      
      ## Go
      
      Aliases `go`/`golang`.
      
      | Pattern (-p) | Rewrite (-r) | Catches / Does | Gotcha |
      | --- | --- | --- | --- |
      | `if $ERR != nil { $$$BODY }` | (detection / inventory) | Canonical error-check shape | Inventory-grade; the same shape appears thousands of times, so scope with `inside` before acting |
      | `fmt.Println($A)` | `log.Println($A)` | print→logger | Bare `fmt.Println($A)` can mis-parse; wrap contextually: `{ context: 'func t() { fmt.Println($A) }', selector: call_expression }` |
      | (YAML rule) | (test discovery) | Test-func discovery | Use a YAML rule with `regex: '^Test'` on the `name` field; the `Test$_` meta-var prefix does not work (it tokenizes separately) |
      | `kind: import_spec` | (import matching) | Match a specific import | Pair `kind: import_spec` with `has: { field: path, regex: ... }`; a string pattern alone is unreliable for import paths |
      
      ## Java
      
      Alias `java`.
      
      | Pattern (-p) | Rewrite (-r) | Catches / Does | Gotcha |
      | --- | --- | --- | --- |
      | `System.out.println($MSG)` | `logger.info($MSG)` | print→logger | `logger` must already be in scope; the rule rewrites the call but cannot add the import or field |
      | `kind: field_declaration` | (typed-field detection) | Field declared with a given type | Use `has: { field: type, regex: '^String$' }`; `$MOD String $F;` fails because a meta-var can't stand in for the modifier node |
      | `catch ($E) {}` | (detection) | Empty `catch` block | Detect emptiness with `not has: kind: expression_statement` rather than matching `{}` literally |
      
      ## Kotlin
      
      Alias `kt`. **Catalog coverage is thin; every row below is community-derived (community).**
      
      | Pattern (-p) | Rewrite (-r) | Catches / Does | Gotcha |
      | --- | --- | --- | --- |
      | `$EXPR!!` | (detection) | Non-null assertion (NPE risk) audit **(community)** | `!!` is a postfix/unnamed node; may need `kind: postfix_expression` + `has`, or a `$$` capture, rather than the bare pattern |
      | `$A?.let { $$$BODY }` | (detection) | Safe-call + `let` idiom audit **(community)** | `$$$BODY` captures the lambda body; the receiver binding `it` is implicit and won't appear as a meta-var |
      | `data class $NAME($$$PROPS)` | (detection) | Data-class property audit **(community)** | `$$$PROPS` captures the primary-constructor params; secondary constructors and body members are not in this capture |
      
      ## C
      
      Alias `c`.
      
      | Pattern (-p) | Rewrite (-r) | Catches / Does | Gotcha |
      | --- | --- | --- | --- |
      | `$M($$$)` | (call detection) | Function-call discovery | Requires `selector: call_expression`; a bare `foo(bar)` fragment parses as `macro_type_specifier` in tree-sitter-c fragment mode |
      | `$A == $B` | `$B == $A` | Yoda-condition enforcement (const on right, inside `if`) | Constrain with `has: { field: right, kind: number_literal }` so only literal-on-right comparisons flip |
      
      ## C++
      
      Aliases `cpp`/`cc`/`cxx`/`c++`.
      
      | Pattern (-p) | Rewrite (-r) | Catches / Does | Gotcha |
      | --- | --- | --- | --- |
      | `NULL` | `nullptr` | C++11 null migration | `NULL` is a macro; match via `identifier` kind + `regex: '^NULL$'`, then rewrite, rather than matching `NULL` as a keyword |
      | `$PRINTF($S, $VAR)` | `$PRINTF($S, "%s", $VAR)` | Format-string vuln when `$S` is not a string literal | Official C++ catalog rule; also applies to C. Guard so it only fires when `$S` is a non-literal expression |
      | `struct $S: $INHERITS { $$$BODY; }` | (detection) | Struct-inheritance discovery | Bare `struct $X: $Y` won't match; include the `{ $$$BODY; }` body so the pattern spans a full struct definition |
      | `case_statement` `not has: break_statement` | (relational detection) | Missing-`break` fall-through in `switch` **(community)** | Relational rule, not in the official catalog; intentional fall-through will be flagged as a false positive; review each hit |
      
      ## OCaml: not supported
      
      ast-grep ships no built-in OCaml grammar (OCaml is absent from https://ast-grep.github.io/reference/languages.html). Support would require registering the `tree-sitter-ocaml` grammar through a custom-language config, and no catalog recipes exist for it. OCaml is out of scope for this reference.
      
      ---
      
      Cross-cutting: single-quote patterns in the shell so `$VAR` is not expanded by the shell; prefer the `ast-grep` binary over the `sg` alias (`sg` collides with shadow-utils' `sg`, the setgroups/newgrp "log in to a new group" binary, on Linux); always dry-run (default) before passing `-U`/`--update-all` to write changes.
      
    • rewriting.md 1.7 KB
      # Rewriting and transformations
      
      ast-grep replaces matched code with a `fix` string, or reshapes captured values with `transform` and `rewriters`.
      
      **Grounded: 2026-08-26**
      
      ## Basic rewrite (`fix`)
      
      The `fix` field in YAML or `--rewrite` CLI flag specifies the replacement string.
      
      ```yaml
      rule:
        pattern: console.log($MSG)
      fix: logger.info($MSG)
      ```
      
      - Meta-variables: Preserved from pattern match.
      - Indentation: Automatically adjusted to match context.
      
      ## Range expansion (`fix` object)
      
      Expand the range of code to be replaced (e.g., to remove trailing commas).
      
      ```yaml
      fix:
        template: '' # Replace with empty string
        expandEnd:
          regex: ',' # Extend deletion to include comma
      ```
      
      ## Transformations (`transform`)
      
      Modify meta-variables before using them in `fix`.
      
      ```yaml
      transform:
        NEW_VAR:
          # String-style syntax (ast-grep 0.45.x)
          substring($OLD_VAR, startChar=1, endChar=-1)
      ```
      
      ### Supported transformations
      - substring: Extract part of string.
      - replace: Regex replacement.
        ```yaml
        replace:
          source: $VAR
          replace: 'regex'
          by: 'replacement'
        ```
      - convert: Case conversion (`camelCase`, `snake_case`, `PascalCase`, `kebab-case`, `UPPER_CASE`).
      
      ## Rewriters
      
      For transforming sub-nodes (e.g., elements in a list) individually.
      
      1. **Define rewriter**: top-level `rewriters` list.
      2. **Apply rewriter**: use `rewrite` in `transform`.
      
      ### Example: dict args to literal
      
      ```yaml
      rewriters:
      - id: arg-to-pair
        rule:
          kind: keyword_argument
          pattern: $KEY=$VAL
        fix: "'$KEY': $VAL"
      
      rule:
        pattern: dict($$$ARGS)
      
      transform:
        DICT_BODY:
          rewrite:
            rewriters: [arg-to-pair]
            source: $$$ARGS
            joinBy: ', ' # Optional joiner
      
      fix: '{ $DICT_BODY }'
      ```
      
      This converts `dict(a=1, b=2)` into `{ 'a': 1, 'b': 2 }`.
      
    • rule-config.md 2.5 KB
      # Rule configuration (YAML)
      
      YAML rules allow precise targeting of code using Atomic, Relational, and Composite matching.
      
      ## Anatomy of a rule
      
      ```yaml
      id: my-rule-id
      language: TypeScript # or other supported languages
      severity: warning # error, warning, info, hint, off
      rule:
        # ... rule logic ...
      fix: # ... optional rewrite string ...
      ```
      
      ## Rule categories
      
      ### 1. Atomic rules
      Match individual nodes based on intrinsic properties.
      - pattern: Matches code structure (e.g., `console.log($MSG)`).
      - kind: Matches AST node kind (e.g., `function_declaration`, `identifier`).
      - regex: Matches text content against Rust regex.
      - range: Matches specific line/column range.
      
      ### 2. Relational rules
      Match nodes based on their relationship to other nodes.
      - inside: Target is descendant of match.
        ```yaml
        inside:
          kind: function_declaration
          stopBy: end # Optional: search boundary
        ```
      - has: Target has a descendant matching rule.
        ```yaml
        has:
          pattern: return $VAL
        ```
      - follows: Target must **follow** (come after) a sibling node matching the sub-rule. The target and the surrounding node must be **siblings** (same parent). `stopBy: neighbor` (default) checks only the direct preceding sibling; `stopBy: end` searches all preceding siblings.
        ```yaml
        # Matches baz(2) only because it follows bar(1) as a sibling statement.
        # In Python, top-level calls are wrapped in expression_statement, so the
        # target and sub-rule must match at the statement level.
        rule:
          kind: expression_statement
          has:
            pattern: baz($A)
          follows:
            kind: expression_statement
            has:
              pattern: bar($B)
            stopBy: end
        ```
      - precedes: Target must **precede** (come before) a sibling node matching the sub-rule. Same sibling constraint and `stopBy` options as `follows`.
        ```yaml
        # Matches bar(1) because it precedes baz(2) as a sibling statement.
        rule:
          kind: expression_statement
          has:
            pattern: bar($A)
          precedes:
            kind: expression_statement
            has:
              pattern: baz($B)
        ```
      
      ### 3. Composite rules
      Combine multiple rules.
      - all: AND logic. Match all sub-rules.
      - any: OR logic. Match any sub-rule.
      - not: NOT logic. Invert match.
      - matches: Reference a utility rule.
      
      ## Utility rules
      Reusable rule definitions.
      
      ```yaml
      utils:
        is-literal:
          any:
            - kind: string_literal
            - kind: number_literal
      
      rule:
        matches: is-literal
      ```
      
      ## Constraints
      Add conditions to meta-variables.
      
      ```yaml
      rule:
        pattern: $A + $B
      constraints:
        A:
          regex: ^const_
      ```
      
    • utility-rules.md 1.4 KB
      # Utility rules
      
      Utility rules are reusable rule components: they cut duplication and enable patterns like recursion.
      
      ## Local utility rules
      
      Defined within a rule file under the `utils` key. Accessible only within that file.
      
      ```yaml
      id: my-rule
      language: TypeScript
      utils:
        is-literal:
          any:
            - kind: string_literal
            - kind: number_literal
      rule:
        matches: is-literal # Reference the utility
      ```
      
      ## Global utility rules
      
      Defined in separate files in a dedicated directory (configured in `sgconfig.yml`), accessible across the entire project.
      
      1. **Configure `sgconfig.yml`**:
         ```yaml
         utilsDirs:
           - utils
         ```
      
      2. **Define the global util** (e.g., `utils/is-literal.yml`):
         ```yaml
         id: is-literal
         language: TypeScript
         rule:
           any:
             - kind: string_literal
             - kind: number_literal
         ```
      
      3. **Use it in a rule**:
         ```yaml
         # rules/my-rule.yml
         id: use-global-util
         language: TypeScript
         rule:
           matches: is-literal
         ```
      
      ## Recursive rules
      
      You can use utility rules to match recursive structures (like nested parentheses).
      
      ```yaml
      utils:
        is-number:
          any:
            - kind: number
            - kind: parenthesized_expression
              has:
                matches: is-number # Recursive reference
      rule:
        matches: is-number
      ```
      
      **Note**: Direct cyclic dependency in `rule` or `matches` causes infinite recursion. Use recursion inside relational components like `has` or `inside`.
      
    • yaml-reference.md 1.8 KB
      # Configuration reference (YAML)
      
      Detailed reference for rule configuration fields.
      
      ## Basic information
      
      - **`id`** (Required): Unique identifier string (kebab-case recommended).
      - **`language`** (Required): Target language (e.g., `TypeScript`, `Python`, `Rust`).
        - See [Supported languages](#supported-languages) below.
      
      ## Finding and patching
      
      - **`rule`** (Required): The [Atomic/Relational/Composite rule](rule-config.md) to match.
      - **`constraints`**: Filter meta-variables.
      - **`transform`**: Transform meta-variables (string operations).
      - **`fix`**: Replacement string or object.
      - **`rewriters`**: List of rewriter rules for sub-node transformation.
      
      ## Linting and reporting
      
      - **`severity`**: `error`, `warning`, `info`, `hint`, or `off`.
      - **`message`**: Concise message explaining the issue. Can use meta-variables (e.g., `Found $VAR`).
      - **`note`**: markdown-formatted detailed explanation.
      - **`url`**: Link to documentation.
      - **`metadata`**: Custom key-value pairs (e.g., CVE ID).
      
      ## File filtering (globbing)
      
      - **`files`**: Only apply rule to matching files.
      - **`ignores`**: Exclude matching files (checked before `files`).
      
      Both accept strings or objects:
      ```yaml
      files:
        - "src/**/*.ts"
        - glob: "*.test.ts"
          caseInsensitive: true
      ```
      
      ## Supported languages
      
      Common languages and their aliases:
      
      | Language | Aliases |
      |----------|---------|
      | JavaScript | `js`, `javascript`, `jsx` |
      | TypeScript | `ts`, `typescript` |
      | TSX | `tsx` |
      | Python | `py`, `python` |
      | Java | `java` |
      | Go | `go`, `golang` |
      | Rust | `rs`, `rust` |
      | C | `c` |
      | C++ | `cpp`, `c++`, `cxx` |
      | C# | `cs`, `csharp` |
      | Ruby | `rb`, `ruby` |
      | PHP | `php` |
      | HTML | `html` |
      | CSS | `css` |
      | JSON | `json` |
      | YAML | `yaml`, `yml` |
      | Bash | `bash`, `sh` |
      
      Full list available in `ast-grep --help` or official docs.
      
  • scripts
    • ast_grep_helper.py 14.5 KB
      #!/usr/bin/env python3
      """Validate-first / dry-run-first wrapper around the ast-grep CLI.
      
      Two subcommands enforce a safety discipline for an agent skill:
      
        validate <pattern> --lang L
            Lint an ast-grep PATTERN without touching any file. Two layers:
              1. Pattern-parse check (AUTHORITATIVE): compile the pattern with
                 `ast-grep run --debug-query=pattern` -- ast-grep's own pattern
                 tree, built after metavariables are substituted -- and look for
                 an ERROR node. ERROR present -> malformed -> exit 2. Clean parse
                 -> valid -> exit 0, regardless of which metacharacters or `$`
                 metavariables the pattern contains.
              2. Regex-smell warning (ADVISORY): if the pattern carries
                 regex-only escapes (\\w \\d \\s \\b) or a bare .*/.+ quantifier,
                 print a hint. This NEVER changes the exit code on its own.
      
        replace <pattern> <rewrite> --lang L [paths...] [--apply]
            Dry-run by DEFAULT. Validates the SEARCH pattern (pattern-parse
            authoritative),
            then runs a JSON dry-run that previews a compact diff + blast radius
            and mutates nothing. Only with --apply does a second pass write files
            via --update-all (the documented workaround for --json + --update-all
            not co-operating).
      
      The `ast-grep` binary is invoked UNCONDITIONALLY -- never `sg`, which
      collides with shadow-utils `setgroups` on Linux.
      
      Exit codes: 0 ok, 2 validation/usage failure, other non-zero = ast-grep
      runtime error.
      
      Stdlib only.
      """
      
      import argparse
      import json
      import re
      import shutil
      import subprocess
      import sys
      from pathlib import Path
      
      BINARY = "ast-grep"
      
      # Canonical language name per accepted alias. ast-grep accepts both the
      # short alias and the full name; we normalize to the full name for clarity.
      LANG_ALIASES = {
          "ts": "typescript", "typescript": "typescript",
          "tsx": "tsx",
          "js": "javascript", "javascript": "javascript",
          "jsx": "jsx",
          "py": "python", "python": "python",
          "rs": "rust", "rust": "rust",
          "go": "go", "golang": "go",
          "java": "java",
          "kt": "kotlin", "kotlin": "kotlin",
          "c": "c",
          "cpp": "cpp", "c++": "cpp", "cxx": "cpp",
      }
      
      # Extension -> alias, for single-path language auto-detection.
      EXT_TO_LANG = {
          ".ts": "ts", ".tsx": "tsx", ".js": "js", ".jsx": "jsx",
          ".mjs": "js", ".cjs": "js",
          ".py": "py", ".rs": "rs", ".go": "go", ".java": "java",
          ".kt": "kt", ".kts": "kt",
          ".c": "c", ".h": "c",
          ".cpp": "cpp", ".cc": "cpp", ".cxx": "cpp", ".hpp": "cpp", ".hh": "cpp",
      }
      
      # Regex-only escapes / quantifiers that signal a misused regex where an
      # ast-grep structural pattern was meant. `$`, `|`, `[`, `]`, `^` are legal
      # in real patterns and are deliberately NOT flagged.
      REGEX_ESCAPE_RE = re.compile(r"\\[wdsb]")
      BARE_QUANTIFIER_RE = re.compile(r"\.[*+]")
      
      def warn(msg):
          """Print an advisory line to stderr."""
          print(msg, file=sys.stderr)
      
      
      def find_binary():
          """Absolute path to the ast-grep binary, or None if not on PATH."""
          return shutil.which(BINARY)
      
      
      def run_ast_grep(args, *, capture=True):
          """Single funnel for every ast-grep invocation.
      
          Returns a CompletedProcess. Raises FileNotFoundError if the binary is
          absent (callers that require it must check find_binary() first).
          """
          cmd = [BINARY, *args]
          return subprocess.run(
              cmd,
              capture_output=capture,
              text=True,
          )
      
      
      def normalize_lang(value):
          """Map an alias/full name to ast-grep's canonical language, or None."""
          if value is None:
              return None
          return LANG_ALIASES.get(value.strip().lower())
      
      
      def detect_lang_from_paths(paths):
          """Infer a language alias from a single unambiguous path/glob extension.
      
          Returns an alias string, or None when detection is not unambiguous.
          """
          if not paths or len(paths) != 1:
              return None
          suffix = Path(paths[0]).suffix.lower()
          return EXT_TO_LANG.get(suffix)
      
      
      def resolve_lang(explicit, paths, *, from_stdin):
          """Resolve the effective language alias or exit(2) with a clear message.
      
          Patterns from stdin always require an explicit --lang. Otherwise fall
          back to single-path extension auto-detection.
          """
          chosen = explicit
          if chosen is None and not from_stdin:
              chosen = detect_lang_from_paths(paths)
          if chosen is None:
              warn("error: --lang/-l is required "
                   "(no unambiguous single-path extension to auto-detect from)")
              sys.exit(2)
          canonical = normalize_lang(chosen)
          if canonical is None:
              warn(f"error: unknown language {chosen!r}; accepted: "
                   + ", ".join(sorted(LANG_ALIASES)))
              sys.exit(2)
          return canonical
      
      
      def regex_smells(pattern):
          """List human-readable regex-smell findings for a pattern (advisory)."""
          findings = []
          if REGEX_ESCAPE_RE.search(pattern):
              findings.append(r"regex-only escape (\w \d \s \b)")
          if BARE_QUANTIFIER_RE.search(pattern):
              findings.append("bare .*/.+ quantifier")
          return findings
      
      
      def emit_regex_smell(pattern, label):
          """Print the non-blocking regex-smell hint if the pattern looks regexy."""
          findings = regex_smells(pattern)
          if not findings:
              return
          warn(f"hint: {label} looks like regex ({'; '.join(findings)}); "
               "did you mean $VAR / $$$, or switch to rg?")
      
      
      def pattern_has_error(pattern, lang):
          """Parse pattern via --debug-query=pattern; True if it contains an ERROR.
      
          `--debug-query=pattern` dumps ast-grep's OWN pattern tree -- the view
          built AFTER metavariables ($A, $$V, $$$, $_) are substituted internally.
          A `$` therefore never raises a spurious tree-sitter ERROR node in
          languages where it is not a legal identifier char (Go, Python, C); only a
          genuinely malformed pattern (regex misuse like `\\w+`, an incomplete form
          like `def FN(`) yields an ERROR node. This is ast-grep's authoritative
          judgement of whether the pattern compiles, not a raw-CST heuristic.
      
          The debug tree is written to stderr and labels error nodes on their own
          line (`ERROR (...)`), with an incidental "contains an ERROR node" warning
          alongside. We scan combined output for an ERROR node label. Returns
          (has_error: bool, raw_tree: str).
          """
          proc = run_ast_grep(
              ["run", "-p", pattern, "-l", lang, "--debug-query=pattern", "--stdin"],
              capture=True,
          )
          raw = (proc.stdout or "") + (proc.stderr or "")
          has_error = re.search(r"(?:\(ERROR\b|\bERROR\b)", raw) is not None
          return has_error, raw
      
      
      def validate_pattern(pattern, lang, *, role="pattern"):
          """Run advisory regex-smell + authoritative pattern-parse check.
      
          Returns one of: "ok", "error" (parse ERROR found), "skipped" (binary
          absent). Prints findings. Attaches the raw pattern tree on error.
          """
          emit_regex_smell(pattern, role)
          if find_binary() is None:
              warn(f"warning: {BINARY} not found on PATH; "
                   "pattern-parse validation skipped (regex-smell check only).")
              return "skipped"
          has_error, raw = pattern_has_error(pattern, lang)
          if has_error:
              warn(f"verdict: {role} is MALFORMED -- ERROR node in ast-grep's "
                   "parsed pattern tree.")
              warn("--- pattern debug tree ---")
              warn(raw.rstrip("\n"))
              warn("--- end pattern tree ---")
              return "error"
          return "ok"
      
      
      def cmd_validate(ns):
          """`validate` subcommand: lint a pattern, never touch files."""
          from_stdin = ns.pattern == "-"
          pattern = sys.stdin.read().strip() if from_stdin else ns.pattern
          lang = resolve_lang(ns.lang, ns.paths, from_stdin=from_stdin)
          outcome = validate_pattern(pattern, lang, role="pattern")
          if outcome == "error":
              return 2
          if outcome == "skipped":
              print("pattern: parse check skipped (ast-grep unavailable); "
                    "regex-smell layer ran.")
              return 0
          print("verdict: pattern is VALID (ast-grep parses it cleanly).")
          return 0
      
      
      def build_globs_args(globs):
          """Flatten repeatable --globs values into ast-grep CLI args.
      
          A value prefixed with `!` is passed through verbatim as an exclude.
          """
          out = []
          for g in globs or []:
              out += ["--globs", g]
          return out
      
      
      def base_run_args(pattern, rewrite, lang, paths, ns):
          """Common positional/flag args shared by dry-run and apply passes."""
          args = ["run", "-p", pattern, "-r", rewrite, "-l", lang]
          args += build_globs_args(ns.globs)
          if ns.context is not None:
              args += ["-C", str(ns.context)]
          args += list(paths)
          return args
      
      
      def render_diff(matches, context):
          """Render a compact unified-style diff + blast-radius summary line."""
          files = set()
          lines_out = []
          for m in matches:
              path = m.get("file", "<unknown>")
              files.add(path)
              old = m.get("lines", "")
              new = m.get("replacement", "")
              start = m.get("range", {}).get("start", {}).get("line", 0) + 1
              lines_out.append(f"@@ {path}:{start} @@")
              for line in old.splitlines() or [old]:
                  lines_out.append(f"- {line}")
              for line in new.splitlines() or [new]:
                  lines_out.append(f"+ {line}")
          summary = f"{len(matches)} matches across {len(files)} files"
          return "\n".join(lines_out), summary
      
      
      def replace_dry_run(pattern, rewrite, lang, paths, ns):
          """Pass 1: JSON dry-run. Returns (matches, proc). Mutates nothing."""
          args = base_run_args(pattern, rewrite, lang, paths, ns) + ["--json=compact"]
          proc = run_ast_grep(args, capture=True)
          raw = (proc.stdout or "").strip()
          try:
              matches = json.loads(raw) if raw else []
          except json.JSONDecodeError:
              matches = None
          return matches, proc
      
      
      def replace_apply(pattern, rewrite, lang, paths, ns):
          """Pass 2: write changes via --update-all. Returns CompletedProcess."""
          args = base_run_args(pattern, rewrite, lang, paths, ns) + ["--update-all"]
          return run_ast_grep(args, capture=True)
      
      
      def cmd_replace(ns):
          """`replace` subcommand: dry-run by default; --apply to write."""
          if find_binary() is None:
              warn(f"error: {BINARY} not found on PATH; replace cannot run "
                   "(pattern validation and rewrite both require the binary).")
              return 2
      
          from_stdin = ns.pattern == "-"
          pattern = sys.stdin.read().strip() if from_stdin else ns.pattern
          lang = resolve_lang(ns.lang, ns.paths, from_stdin=from_stdin)
      
          # Validate the SEARCH pattern only (pattern-parse authoritative). The
          # rewrite is frequently a non-standalone fragment, so it gets an advisory
          # smell warning but is NEVER parse-checked.
          outcome = validate_pattern(pattern, lang, role="search pattern")
          if outcome == "error":
              return 2
          emit_regex_smell(ns.rewrite, "rewrite")
      
          paths = ns.paths or ["."]
          matches, proc = replace_dry_run(pattern, ns.rewrite, lang, paths, ns)
          if matches is None:
              warn("error: ast-grep failed during dry-run (malformed rewrite?).")
              if proc.stderr:
                  warn(proc.stderr.rstrip("\n"))
              return proc.returncode or 1
      
          if ns.json_out:
              print(json.dumps(matches, indent=2))
          else:
              if not matches:
                  print("0 matches across 0 files (nothing to rewrite).")
              else:
                  diff, summary = render_diff(matches, ns.context)
                  print(diff)
                  print(summary)
      
          if not ns.apply:
              if matches and not ns.json_out:
                  print("(dry-run: no files modified; re-run with --apply to write)")
              return 0
      
          if not matches:
              return 0
          apply_proc = replace_apply(pattern, ns.rewrite, lang, paths, ns)
          if apply_proc.returncode != 0:
              warn("error: ast-grep failed during apply pass.")
              if apply_proc.stderr:
                  warn(apply_proc.stderr.rstrip("\n"))
              return apply_proc.returncode
          out = (apply_proc.stdout or "").strip()
          if out:
              print(out)
          print("(applied: files updated via --update-all)")
          return 0
      
      
      def add_shared_flags(p):
          """Attach flags common to both subcommands."""
          p.add_argument("-l", "--lang",
                         help="language: ts/tsx/js/jsx/py/rs/go/java/kt/c/cpp "
                              "or a full name. Auto-detected from a single path's "
                              "extension when omitted; required for stdin patterns.")
          p.add_argument("--globs", action="append", metavar="GLOB",
                         help="include/exclude glob (repeatable); prefix with ! "
                              "to exclude.")
          p.add_argument("-C", "--context", type=int, metavar="N",
                         help="context lines around each match.")
          p.add_argument("--json-out", action="store_true",
                         help="machine mode: emit raw JSON instead of human text.")
      
      
      def build_parser():
          parser = argparse.ArgumentParser(
              prog="ast_grep_helper.py",
              description="Validate-first / dry-run-first wrapper around ast-grep.",
          )
          sub = parser.add_subparsers(dest="command", required=True)
      
          pv = sub.add_parser(
              "validate",
              help="lint an ast-grep pattern (parse authoritative; no file access).",
              description="Validate an ast-grep PATTERN. Parse ERROR -> exit 2; "
                          "clean -> exit 0. Regex-smell hints are advisory only. "
                          "Use '-' as the pattern to read it from stdin.",
          )
          pv.add_argument("pattern", help="the ast-grep pattern (or '-' for stdin).")
          pv.add_argument("paths", nargs="*",
                          help="optional path(s) used only for language "
                               "auto-detection.")
          add_shared_flags(pv)
          pv.set_defaults(func=cmd_validate)
      
          pr = sub.add_parser(
              "replace",
              help="dry-run a rewrite (default); --apply to write files.",
              description="Preview an ast-grep rewrite as a compact diff + blast "
                          "radius. Dry-run by default (no mutation). Pass --apply "
                          "to write via --update-all. The search pattern is parse "
                          "validated; the rewrite is not. Use '-' as the pattern "
                          "to read it from stdin.",
          )
          pr.add_argument("pattern", help="the search pattern (or '-' for stdin).")
          pr.add_argument("rewrite", help="the rewrite template.")
          pr.add_argument("paths", nargs="*",
                          help="path(s) to search; defaults to current directory.")
          pr.add_argument("--apply", action="store_true",
                          help="write changes (second pass via --update-all). "
                               "Without it, nothing is mutated.")
          add_shared_flags(pr)
          pr.set_defaults(func=cmd_replace)
      
          return parser
      
      
      def main(argv=None):
          parser = build_parser()
          ns = parser.parse_args(argv)
          return ns.func(ns)
      
      
      if __name__ == "__main__":
          sys.exit(main())
      
  • tests
    • smoke.sh 3.1 KB
      #!/usr/bin/env sh
      # Lightweight POSIX-sh smoke test for scripts/ast_grep_helper.py.
      # Run by hand:  sh smoke.sh
      # Skips gracefully (exit 0) when the ast-grep binary is not on PATH, since
      # the helper's authoritative pattern-parse checks require it.
      set -eu
      
      HELPER="$(cd "$(dirname "$0")/.." && pwd)/scripts/ast_grep_helper.py"
      
      PASSED=0
      
      # expect_exit <expected> <desc> -- <cmd...>
      # Runs the command, compares its exit code, prints PASS/FAIL. Exits 1 on
      # mismatch.
      expect_exit() {
          expected="$1"
          desc="$2"
          shift 2
          # consume the literal -- separator
          if [ "$1" = "--" ]; then
              shift
          fi
          actual=0
          "$@" >/dev/null 2>&1 || actual=$?
          if [ "$actual" -eq "$expected" ]; then
              echo "PASS: $desc"
              PASSED=$((PASSED + 1))
          else
              echo "FAIL: $desc (expected exit $expected, got $actual)"
              exit 1
          fi
      }
      
      # 1. Binary guard: skip cleanly when ast-grep is unavailable.
      if ! command -v ast-grep >/dev/null 2>&1; then
          echo "SKIP: ast-grep not on PATH"
          exit 0
      fi
      
      # 2. validate ACCEPTS real patterns, including non-$-identifier languages
      #    (the regression the helper's oracle fix targets).
      expect_exit 0 "validate accepts console.log(\$MSG) --lang ts" -- \
          python3 "$HELPER" validate 'console.log($MSG)' --lang ts
      expect_exit 0 "validate accepts print(\$MSG) --lang python" -- \
          python3 "$HELPER" validate 'print($MSG)' --lang python
      expect_exit 0 "validate accepts fmt.Println(\$A) --lang go" -- \
          python3 "$HELPER" validate 'fmt.Println($A)' --lang go
      
      # 3. validate REJECTS garbage.
      expect_exit 2 "validate rejects \\w+ --lang ts" -- \
          python3 "$HELPER" validate '\w+' --lang ts
      expect_exit 2 "validate rejects 'def FN(' --lang python" -- \
          python3 "$HELPER" validate 'def FN(' --lang python
      
      # 4. replace dry-run is non-mutating, then --apply writes.
      TMPFILE="${TMPDIR:-/tmp}/ast_grep_smoke_$$.py"
      printf 'print("hi")\n' > "$TMPFILE"
      # Remove the single temp FILE by its exact path. The EXIT trap covers the
      # normal path and any early exit from a failed assertion; the signal traps
      # clean up AND re-exit 128+signo so cancellation is not swallowed. This
      # removes one exact file, never a recursive directory wipe.
      trap 'rm -f "$TMPFILE"' EXIT
      trap 'rm -f "$TMPFILE"; trap - HUP; exit 129' HUP
      trap 'rm -f "$TMPFILE"; trap - INT; exit 130' INT
      trap 'rm -f "$TMPFILE"; trap - TERM; exit 143' TERM
      
      before="$(cksum < "$TMPFILE")"
      expect_exit 0 "replace dry-run exits 0" -- \
          python3 "$HELPER" replace 'print($A)' 'log.info($A)' --lang python "$TMPFILE"
      after="$(cksum < "$TMPFILE")"
      if [ "$before" = "$after" ]; then
          echo "PASS: replace dry-run leaves file byte-identical"
          PASSED=$((PASSED + 1))
      else
          echo "FAIL: replace dry-run mutated the file"
          exit 1
      fi
      
      expect_exit 0 "replace --apply exits 0" -- \
          python3 "$HELPER" replace 'print($A)' 'log.info($A)' --lang python --apply "$TMPFILE"
      if grep -q 'log.info("hi")' "$TMPFILE"; then
          echo "PASS: replace --apply wrote log.info(\"hi\")"
          PASSED=$((PASSED + 1))
      else
          echo "FAIL: replace --apply did not write the rewrite"
          exit 1
      fi
      
      echo "smoke: $PASSED passed"
      
  • SKILL.md 4.8 KB
    ---
    name: ast-grep
    description: 'Use when asked to run AST-based structural search, lint, or rewrite of code when regex is too fragile. Not for remote, credential, publish, deploy, or irreversible changes.'
    ---
    
    # ast-grep
    
    ## Contract
    
    | Field | Bound contract |
    |---|---|
    | Trigger | AST-based modification, structural search, lint, or replacement too fragile for regex. |
    | Authority | Reversible local: writes only VCS-tracked source files (search is read-only; rewrites apply only through the helper after dry-run review); rollback is version control. No remote mutation. |
    | Side effect | Local file writes through the helper two-pass validate/dry-run/apply flow; no remote, credential, or published mutation. |
    | Done | Pattern validated and blast radius reviewed; any rewrite landed at the correct scope (search/lint-only runs are valid). |
    
    ## Inputs
    
    - An ast-grep pattern, single-quoted in the shell so `$VAR` reaches ast-grep unexpanded.
    - A language (`--lang`) or a single target path whose extension auto-detects it; required for stdin patterns.
    - For rewrites: a rewrite template and one or more target paths (defaults to the current directory).
    - Optional: include/exclude globs (repeatable, prefix `!` to exclude), context lines, JSON output mode.
    
    ## Procedure
    
    1. Confirm the task is structural (call, function, class, or import shaped like a pattern), not text/regex/filename matching (use grep) or semantic type/reference lookup (use LSP or the compiler). ast-grep matches syntax, not bytes. Done when: the task is confirmed structural.
    2. Validate the pattern before searching: `python3 scripts/ast_grep_helper.py validate '<pattern>' --lang <L>`. Exit 0 means ast-grep parses it cleanly; exit 2 means malformed (the helper prints the parsed pattern tree showing the ERROR node). Fix and re-validate. Done when: validate exits 0.
    3. For a rewrite, run the dry-run: `python3 scripts/ast_grep_helper.py replace '<pattern>' '<rewrite>' --lang <L> <paths>`. Read the diff and the `N matches across M files` count. If the blast radius is wrong, stop and refine the pattern (tighten meta-variables, add `--lang`, add context); re-run the dry-run. Done when: the dry-run diff and match count are correct.
    4. Apply only after the dry-run diff is correct: `python3 scripts/ast_grep_helper.py replace '<pattern>' '<rewrite>' --lang <L> <paths> --apply`. The helper writes via a separate `--update-all` pass. Done when: files are updated via `--update-all`.
    5. Invoke `ast-grep`, never `sg`: `sg` collides with the `setgroups` binary on many systems. Done when: `ast-grep` is invoked, not `sg`.
    
    Pattern syntax: `$VAR` matches any single node; `$$$ARGS` matches zero or more nodes; `$_` matches any node (non-capturing).
    
    Invariants: validate before searching; dry-run before applying; single-quote patterns; `--lang` is required for stdin; a pattern is code, not regex; switch to grep the moment `|`, `.*`, `\w`, or `[...]` would be needed. The helper keeps `--json` and `--update-all` as separate passes because combining them makes `--json` silently win and the write is dropped with no error.
    
    For complex tasks, ast-grep supports YAML rule files (`sgconfig.yml`, `ast-grep new project`) with `rule`, `fix`, `inside`, `any`, and `matches` fields; invoke `ast-grep scan`/`run`/`test` directly for these.
    
    ## Failure and recovery
    - Malformed pattern: `validate` exits 2 and prints the pattern debug tree with the ERROR node. No files touched. Fix the pattern and re-validate.
    - Wrong blast radius: the dry-run diff or match count is not as expected. Do not `--apply`. Refine the pattern and re-run the dry-run. No files touched.
    - Zero matches unexpectedly: run the 0-matches ladder in order: (1) validate the pattern; (2) check `--lang` (`tsx` is not `ts`; the wrong dialect silently matches nothing); (3) `ast-grep run -p '<pattern>' -l <L> --debug-query=pattern` and look for `ERROR`; (4) inspect the target's actual tree with `--debug-query=ast` on a known-matching snippet; (5) reproduce in the online playground. No files touched until a correct match is confirmed.
    - ast-grep binary absent: `validate` skips the parse check (regex-smell only) and warns; `replace` exits 2 without running. Install ast-grep before proceeding.
    - Apply pass failure: the helper reports the ast-grep error and returns non-zero. Revert the partially-written VCS-tracked targets via version control and re-run the full dry-run/apply sequence.
    - Partial-result rule: a failed apply pass leaves whatever ast-grep wrote; never report done. Recover via version control and re-run from the dry-run.
    
    ## Output
    - `validate`: exit 0 (valid) or 2 (malformed, with the pattern debug tree); advisory regex-smell hints on stderr.
    - `replace` dry-run: a compact unified diff and an `N matches across M files` summary; no files modified.
    - `replace --apply`: VCS-tracked files updated via `--update-all` plus a confirmation line.
    - Direct `ast-grep` search: matched code locations.
    

Comments (0)

Sign in to join the conversation.

No comments yet.

Reviews (0)

No reviews yet.

Related