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.
Install
npx skills add https://github.com/OutlineDriven/odin-claude-plugin/tree/main/plugins/odin-code/skills/ast-grep
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install outlinedriven-odin-claude-plugin@llmmart
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
$VARreaches 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
- 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.
- 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. - For a rewrite, run the dry-run:
python3 scripts/ast_grep_helper.py replace '<pattern>' '<rewrite>' --lang <L> <paths>. Read the diff and theN matches across M filescount. 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. - 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-allpass. Done when: files are updated via--update-all. - Invoke
ast-grep, neversg:sgcollides with thesetgroupsbinary on many systems. Done when:ast-grepis invoked, notsg.
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:
validateexits 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(tsxis notts; the wrong dialect silently matches nothing); (3)ast-grep run -p '<pattern>' -l <L> --debug-query=patternand look forERROR; (4) inspect the target's actual tree with--debug-query=aston a known-matching snippet; (5) reproduce in the online playground. No files touched until a correct match is confirmed. - ast-grep binary absent:
validateskips the parse check (regex-smell only) and warns;replaceexits 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.replacedry-run: a compact unified diff and anN matches across M filessummary; no files modified.replace --apply: VCS-tracked files updated via--update-allplus a confirmation line.- Direct
ast-grepsearch: 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.
Reviews (0)
No reviews yet.
No comments yet.