Claude Cursor opencode Skill

ast-grep

Use ast-grep (sg) for AST-aware code search and rewrite across 25 languages. Trigger for structural code matching or deterministic codemods: find every function/call/class/import shaped like X, rewrite console.log to logger.info, strip `as any`, migrate require() to import, find

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

Full trust report

Download code-yeongyu-oh-my-openagent-packages_shared-skills_skills_ast-grep-05dcba6.zip · 51 KB
Part of code-yeongyu/oh-my-openagent — 51 skills

Install

skills CLI npx skills add https://github.com/code-yeongyu/oh-my-openagent/tree/dev/packages/shared-skills/skills/ast-grep
Claude Code claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install code-yeongyu-oh-my-openagent@llmmart
Git git clone https://github.com/code-yeongyu/oh-my-openagent.git

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

README

ast-grep-skill

LLM-neutral skill for AST-aware search and rewrite across 25 languages. Wraps the ast-grep (sg) CLI with offline pattern validation, the two-pass write trick, binary auto-resolution, and a per-OS installer.

Same shape as web-fetch and web-search, packaged as a standalone skill that any Bash-capable agent (Claude Code, OpenCode, pi, hermes, openclaw) can load.

Install

git clone https://github.com/code-yeongyu/ast-grep-skill ~/.agents/skills/ast-grep
bash ~/.agents/skills/ast-grep/install.sh        # installs the ast-grep binary

That is it. The wrapper script is single-file Python 3 stdlib; no pip install needed. The installer tries brew → npm → cargo → pip → nix → mise → GitHub release in priority order, picks the first that works, and falls back to a cached binary at <skill>/bin/sg.

Symlink for active development

ln -s /path/to/your/clone ~/.agents/skills/ast-grep

Other agents

  • Claude Code / OpenCode: drop the directory under ~/.agents/skills/ (or ~/.config/opencode/skills/) and the skill auto-registers via the name + description in SKILL.md frontmatter.
  • pi (~/.senpi/agent): not a pi extension — this is a skill. Pi consumes skills via ~/.agents/skills/ symlinks; the actual pi-ast-grep extension is at https://github.com/code-yeongyu/pi-extensions.
  • Direct CLI use: python3 ~/.agents/skills/ast-grep/scripts/ast_grep_helper.py <subcommand>.

Usage

# Search by AST pattern (the helper validates patterns offline first)
python3 scripts/ast_grep_helper.py search 'console.log($MSG)' --lang ts src/

# Rewrite (dry-run preview by default)
python3 scripts/ast_grep_helper.py replace 'console.log($MSG)' 'logger.info($MSG)' --lang ts src/

# Apply the rewrite (two-pass: preview JSON + then --update-all)
python3 scripts/ast_grep_helper.py replace 'console.log($MSG)' 'logger.info($MSG)' --lang ts src/ --apply

# Run YAML lint rules from sgconfig.yml
python3 scripts/ast_grep_helper.py scan src/

# Validate a pattern OFFLINE (no sg call, no filesystem)
python3 scripts/ast_grep_helper.py validate '\w+' --lang ts
# → exit 2: regex \w not supported. Use $VAR for identifiers.

# Doctor: check ast-grep binary availability
python3 scripts/ast_grep_helper.py doctor

# List 25 supported languages
python3 scripts/ast_grep_helper.py langs

# Install / re-install the ast-grep binary
python3 scripts/ast_grep_helper.py install

See SKILL.md for full agent-facing usage and the references/ directory for deep dives.

Project layout

ast-grep-skill/
├── SKILL.md                       agent-facing skill (loaded by Claude Code, OpenCode, pi, etc.)
├── README.md                      this file
├── LICENSE                        MIT
├── install.sh                     POSIX installer (macOS / Linux / WSL / Git Bash)
├── install.ps1                    Windows PowerShell installer
├── scripts/
│   └── ast_grep_helper.py         single-file Python 3 stdlib wrapper
├── references/
│   ├── install.md                 per-OS install methods + manual fallback
│   ├── patterns.md                meta-variables ($VAR, $$$) and pattern syntax
│   ├── pitfalls.md                regex anti-patterns + language-specific traps
│   ├── recipes.md                 copy-paste patterns by language (TS/JS/Py/Go/Rust/...)
│   ├── cli.md                     sg run / scan / test / new / lsp reference
│   ├── yaml-rules.md              YAML rule schema (atomic / relational / composite / transform / fix)
│   └── sgconfig.md                project configuration (ruleDirs, testConfigs, utilDirs)
├── tests/
│   ├── smoke.sh                   POSIX self-test
│   └── smoke.ps1                  PowerShell self-test (Windows CI)
└── .github/workflows/ci.yml       matrix CI: macos / ubuntu / windows × py 3.9-3.13

What it does

  1. Wraps sg with a single Python 3 stdlib script that works the same on macOS, Linux, Windows, WSL, Git Bash.
  2. Validates patterns offline before calling sg — catches the regex-misuse class of mistakes (\w, .*, |, [a-z]) plus language-specific traps (Python trailing colons, JS/Go/Rust missing function bodies).
  3. Resolves the binary through 6 candidate paths: cached → PATH (with Linux setgroups collision detection) → Homebrew. Falls through to a clear install hint with copy-paste commands.
  4. Runs the two-pass write trick when applying rewrites — sg run silently ignores --update-all when --json is set, so replace --apply runs two invocations: pass 1 collects JSON matches, pass 2 mutates files.
  5. Ships per-OS installers that try every reasonable package manager and fall back to a GitHub release tarball.
  6. Documents the failure modes the model will hit (regex misuse, incomplete patterns, --update-all + --json trap, scope/type questions ast-grep can't answer) in references/pitfalls.md.

What it does NOT do

  • No type inference, scope analysis, or data flow. ast-grep is a structural matcher; for type-aware questions use TypeScript LSP, Pyright, Semgrep with type inference, or CodeQL.
  • No multi-repo federation. Run the helper once per repo.
  • No automatic sgconfig.yml discovery — it does what sg scan does (walk up from cwd looking for one).
  • No JS/Python rewriter authoring environment — for that, write YAML rules and use sg test for snapshot testing (see references/yaml-rules.md).

Limits

  • 5-minute timeout per sg invocation (configurable in the helper).
  • ast-grep itself supports 25 languages out-of-the-box. For anything else, use customLanguages in sgconfig.yml.
  • Pattern hint detection is heuristic; pass --force to skip validation when you know the pattern is correct.

Requirements

  • Python ≥ 3.9 (stdlib only — no pip install).
  • ast-grep binary, installed via install.sh / install.ps1 or one of:
    • brew install ast-grep (macOS / linuxbrew)
    • npm install -g @ast-grep/cli (any OS with Node)
    • cargo install ast-grep --locked (any OS with Rust)
    • pip install ast-grep-cli (any OS with Python)
    • scoop install main/ast-grep (Windows)

For older systems and Windows-specific setup, see references/install.md.

Testing

bash tests/smoke.sh        # POSIX (macOS / Linux / WSL / Git Bash)
pwsh tests/smoke.ps1       # Windows (PowerShell 5.1+ or 7+)

CI runs the matrix on every push: {macos-latest, ubuntu-latest, ubuntu-22.04, windows-latest} × {Python 3.9, 3.10, 3.11, 3.12, 3.13} plus a syntax-floor check on Python 3.9 and 3.10.

License

MIT.

Acknowledgments

  • omo (oh-my-opencode) — src/tools/ast-grep/ is the original tool implementation; this skill is a port of its pattern-hint detection and two-pass-write strategy.
  • pi-extensions/pi-ast-grep — sibling Node port; the helper's binary-resolution cascade is modelled on it.
  • ast-grep — the CLI. All structural matching power comes from it.
  • Anthropic skills — the SKILL.md + references/ packaging convention.

Skill manifest

ast-grep

sg (also installed as ast-grep) is an AST-aware search and rewrite tool across 25 languages. It treats your pattern as code, parses it the same way it parses your project, and matches structurally. It is the right tool whenever your question depends on code shape rather than text bytes.

This skill ships a Python wrapper at scripts/ast_grep_helper.py and platform install scripts at install.sh (POSIX) and install.ps1 (Windows). The helper adds offline pattern validation, the two-pass write trick, and binary auto-resolution. Use it as your default entry point.


OmO native: the ast-grep MCP tools (fastest path, no CLI needed)

OmO native also registers a bundled ast-grep MCP server. Its three tools are deferred: they are absent from the resident tool list and activate automatically the first time you call them by name — no tool_search round trip needed.

  • mcp__ast_grep_search({ pattern, language, paths }) — AST-shape search, the MCP equivalent of sg run -p. Prefer this over shelling out to sg for one-off lookups: no install, no PATH issues.
  • mcp__ast_grep_rewrite({ pattern, rewrite, language, paths, apply }) — preview matches (apply: false, the default) or apply the rewrite (apply: true). Same two-pass discipline as sg run -r: preview first, then apply.
  • mcp__ast_grep_scan({ paths }) — run project YAML rules (sgconfig.yml, rules/), the MCP equivalent of sg scan.

Same pattern language as the CLI: $VAR matches one AST node, $$$VAR matches zero or more, and the pattern is code, not regex. paths accepts a file or directory list; language is required for patterns that are ambiguous across languages.


When to use this skill

Use it whenever the user's question is about code structure, not bytes:

  • "Find every function that takes a Request parameter."
  • "Rewrite every console.log(x) to logger.info(x)."
  • "Strip every as any cast."
  • "Replace require(...) with import across the repo."
  • "Find empty catch blocks."
  • "Migrate Optional[X] to X | None."
  • "Apply this codemod across these 200 files."
  • "Run our YAML lint rules and surface violations."

Switch to plain grep / rg when the question is text-shaped (string literal contents, comments, license headers, file names, cross-language regex). When in doubt, ask: "does the answer depend on the language's syntax tree, or just on the file's bytes?" If the former, ast-grep. If the latter, grep.


Three things the agent must internalize

1. ast-grep is NOT regex

The wildcards are $VAR (one AST node) and $$$ (zero or more nodes). Regex syntax fails silently:

You wrote What ast-grep saw What you wanted
foo\|bar bitwise-or of foo and bar run two separate searches
.*foo not parseable $$$ foo (if $$$ is a list of nodes) or use rg
\w+ not parseable $VAR to capture any identifier
[a-z] character class, not parseable switch to rg

The full anti-pattern table is in references/pitfalls.md §1. The helper's validate subcommand catches these mechanically — call it before debugging "no matches" by hand.

2. Patterns must be valid code

The pattern itself must parse. def $FN($$$): fails because the trailing : makes it incomplete; use def $FN($$$). function $NAME without params/body fails; use function $NAME($$$) { $$$ }. Full table per language in references/pitfalls.md §2.

3. --update-all and --json are mutually exclusive (silently)

This is the single biggest gotcha when scripting. sg run -p P -r R --json --update-all returns the JSON but does not mutate files. To both preview AND apply, run two passes:

sg run -p P -r R --json=compact .   # pass 1: see what would change
sg run -p P -r R --update-all .     # pass 2: actually apply

The helper does this automatically when you call replace --apply. Read references/pitfalls.md §9.


The helper script — scripts/ast_grep_helper.py

A single-file Python 3 stdlib wrapper. Same on every OS. The agent's default entry point.

search — find all matches of a pattern

python3 scripts/ast_grep_helper.py search 'console.log($MSG)' --lang ts src/

Validates the pattern offline first. If the pattern looks like regex (\w, .*, |, etc.) the helper exits with a hint and never calls sg — saves a round-trip. Pass --force to skip validation.

Flags:

  • --lang ts (or any of the 25 languages; aliases like js, py, rs, kt accepted)
  • --globs '!**/*.test.ts' (repeatable; prefix ! to exclude)
  • -C 3 (context lines)
  • --json-out (raw JSON instead of human format)

replace — rewrite by pattern, dry-run by default

# Dry-run preview (default — no files mutated)
python3 scripts/ast_grep_helper.py replace 'console.log($MSG)' 'logger.info($MSG)' --lang ts src/

# Actually apply
python3 scripts/ast_grep_helper.py replace 'console.log($MSG)' 'logger.info($MSG)' --lang ts src/ --apply

The helper:

  1. Validates both pattern and rewrite for hint-detectable mistakes.
  2. Runs pass 1 with --json=compact to collect matches and show a preview.
  3. If --apply is set, runs pass 2 with --update-all to mutate files.

scan — run YAML rules

# Discover sgconfig.yml from cwd and run all rules
python3 scripts/ast_grep_helper.py scan src/

# Run a single rule file
python3 scripts/ast_grep_helper.py scan -r rules/no-console.yml src/

# Apply auto-fixes
python3 scripts/ast_grep_helper.py scan -U src/

# CI-friendly GitHub annotations
python3 scripts/ast_grep_helper.py scan --report-style short src/

validate — offline pattern check (no sg call)

Useful for CI lints, pre-commit hooks, and quick sanity checks:

python3 scripts/ast_grep_helper.py validate '\w+' --lang ts
# → exit 2: regex \w not supported. Use $VAR for identifiers.

python3 scripts/ast_grep_helper.py validate 'console.log($MSG)' --lang ts
# → exit 0: pattern looks plausible for ast-grep.

langs / doctor / install

python3 scripts/ast_grep_helper.py langs       # list 25 supported languages and aliases
python3 scripts/ast_grep_helper.py doctor      # check ast-grep binary availability
python3 scripts/ast_grep_helper.py install     # delegate to install.sh / install.ps1

new and test subcommands proxy directly to sg new and sg test.


Direct sg use (when the helper isn't enough)

The helper is opinionated. For full control, drop to sg. The skill ships a CLI cheat sheet in references/cli.md. The minimal idioms:

# Search
sg run -p 'console.log($MSG)' --lang ts src/

# Search with JSON for scripting
sg run -p 'console.log($MSG)' --lang ts --json=compact src/ | jq '.[] | .file'

# Rewrite, dry-run
sg run -p 'console.log($MSG)' -r 'logger.info($MSG)' --lang ts --json=compact src/

# Rewrite, apply
sg run -p 'console.log($MSG)' -r 'logger.info($MSG)' --lang ts --update-all src/

# Pattern from stdin (great for ad-hoc experiments)
echo 'console.log("hi")' | sg run -p 'console.log($MSG)' --lang js --stdin

# Debug a pattern that returns 0 matches
sg run -p '<your pattern>' --lang <lang> --debug-query=ast --stdin <<< '<sample-code>'

# Run YAML rules
sg scan src/

# Inline YAML rule (one-off)
sg scan --inline-rules '
id: no-todo
language: TypeScript
severity: warning
rule: { pattern: TODO }' src/

When using sg directly in a shell, always single-quote patterns so $VAR is not expanded by the shell.


Decision tree — what to use, when

USER asks for "find/rewrite/codemod"
│
├─ structural pattern (function shape, call, class, import, control flow)
│  └→ ast-grep (this skill)
│
├─ text pattern (regex, alternation, character classes, file names)
│  └→ rg / grep
│
├─ semantic question (what variable does this refer to? does this throw?)
│  └→ LSP tools, TypeScript compiler, Pyright, Semgrep with type inference
│
└─ multiple repos / federated search
   └→ a search engine + then ast-grep / rg / LSP per-repo

If the user says "find all" or "every", default to ast-grep when the target is shaped (function, class, call, import, statement). Default to rg when the target is text (string content, comment, license header, file name, identifier substring).


Always run dry-run first when rewriting

A bad pattern silently rewrites the wrong thing. The helper's replace defaults to dry-run for this reason. The flow is:

  1. Search to confirm matches: helper search '<pattern>' --lang X .
  2. Dry-run rewrite: helper replace '<pattern>' '<rewrite>' --lang X . (no --apply)
  3. Inspect the dry-run summary: number of matches, files affected, the per-location preview.
  4. If wrong: refine pattern, go back to step 1.
  5. If right: helper replace '<pattern>' '<rewrite>' --lang X . --apply.

Never apply a rewrite that you have not first dry-run.


When sg returns 0 matches but you know the code is there

In priority order:

  1. Run helper validate '<pattern>' --lang <lang> — catches regex misuse, missing function bodies, Python trailing colons.
  2. Check --lang — sg infers from extension; if you pass a .tsx file with --lang ts (not tsx), JSX won't parse.
  3. Inspect the parsed pattern: sg run -p '<pattern>' --lang <lang> --debug-query=ast --stdin <<< '<sample>'. If it shows ERROR nodes, the pattern is malformed.
  4. Check the AST of the target file: sg run -p '$_' --lang <lang> --debug-query=cst path/to/file | head -40 — find the kind you're trying to match.
  5. Try the playground: https://ast-grep.github.io/playground.html — paste code + pattern, see what's happening.

Do not blindly retry with variations. Each failure has a reason; surface it.


When to use YAML rules vs inline -p patterns

Use inline -p when:

  • One-off ad-hoc query.
  • The pattern is simple (no constraints, no fix template).
  • You're exploring.

Use YAML rules (file under rules/, run via sg scan) when:

  • The pattern is reused (lint rule, codemod that runs in CI).
  • You need constraints, transform, complex inside/has, or composite logic.
  • You want auto-fix (fix: field).
  • You want to test the rule (snapshot tests via sg test).

The full YAML rule schema is in references/yaml-rules.md. Project setup (sgconfig.yml, ruleDirs, utilDirs) is in references/sgconfig.md.


Output discipline

  • sg run --json=compact produces an array of match objects: { file, range: {start, end}, text, replacement?, lines, language, ... }. Pipe through jq for further processing.
  • Without --json, sg produces human-readable colored output suitable for terminals.
  • The helper's default output is human-readable (file:line:column + match preview). Pass --json-out for raw JSON.
  • The helper's replace always summarizes: number of matches, number of files, per-location preview.

When summarizing for the user, always include the count of files affected, not just the count of matches. Users care about blast radius.


Required reading (in order of priority)

  1. references/patterns.md — meta-variables, naming rules, strictness levels. Read when you're unsure why a pattern doesn't match.
  2. references/pitfalls.md — the failure-mode field guide. Read when 0 matches surprises you.
  3. references/recipes.md — copy-paste patterns by language. Read first when you start a new task.
  4. references/cli.md — sg run, sg scan, sg test, sg new, sg lsp. Read when the helper isn't enough.
  5. references/yaml-rules.md — YAML rule schema. Read when you outgrow inline patterns.
  6. references/sgconfig.md — project-level configuration. Read when you set up sg scan for a real project.
  7. references/install.md — per-OS install methods. Read only if install.sh / install.ps1 fail.

Invariants (do not break)

  • Validate before searching. When emitting a pattern programmatically, call helper validate first. It catches the regex-misuse class of mistakes that account for ~70% of "0 matches" debug sessions.
  • Dry-run before applying. Never run sg run -r ... --update-all without first inspecting the matches. The helper's replace enforces this by default.
  • Two-pass writes. When using sg directly to both preview and apply, run two invocations — --json ignores --update-all.
  • Single-quote patterns in shell. '$VAR' not "$VAR". The shell expands $VAR to the empty string in double quotes, breaking the pattern.
  • Pattern is code, not regex. When the pattern would need |, .*, \w, or [a-z], switch to rg instead. Don't try to force ast-grep into a regex shape.
  • --lang is required for stdin. When piping with --stdin, set --lang explicitly; sg cannot infer from extension.
  • Linux: prefer ast-grep over sg because sg collides with setgroups. The helper handles this; if you call sg directly, alias it: alias sg=ast-grep.
Files (oh-my-openagent)
  • references
    • cli.md 7.1 KB
      # CLI reference — `sg` / `ast-grep`
      
      Compact reference for the underlying `sg` binary that the helper wraps. Use this when the helper isn't enough or when you want to invoke `sg` directly.
      
      > **Binary name on Linux**: prefer `ast-grep` over `sg` because `sg` collides with `setgroups` from `util-linux`.
      
      ---
      
      ## `sg run` — one-shot search/rewrite
      
      The default subcommand. `sg -p 'foo'` is shorthand for `sg run -p 'foo'`.
      
      ```bash
      sg run [OPTIONS] --pattern <PATTERN> [PATHS...]
      ```
      
      | Flag | Purpose |
      |---|---|
      | `-p, --pattern <P>` | AST pattern to match. **Always single-quote** in shell to prevent `$VAR` expansion. |
      | `-r, --rewrite <R>` | Replacement pattern. Used with `-U` to apply. |
      | `-l, --lang <LANG>` | Language. Inferred from path extension if omitted. |
      | `--selector <KIND>` | When the pattern is ambiguous, extract only this AST kind. |
      | `--strictness <S>` | `cst` \| `smart` (default) \| `ast` \| `relaxed` \| `signature` |
      | `--debug-query[=<F>]` | Print parsed pattern. F: `pattern` \| `ast` \| `cst` \| `sexp` |
      | `--stdin` | Read code from stdin instead of files. Lang must be set. |
      | `--globs <G>` | Include/exclude glob (repeatable; prefix `!` to exclude). |
      | `--follow` | Follow symlinks. |
      | `--no-ignore <T>` | Disable a class of ignore: `hidden`, `dot`, `exclude`, `global`, `parent`, `vcs`. |
      | `-i, --interactive` | Step through matches and confirm each rewrite. |
      | `-U, --update-all` | Apply all rewrites without confirmation. **Mutually exclusive with `--json`** (silently). |
      | `--json[=<S>]` | Emit JSON. S: `pretty` \| `stream` \| `compact` (compact is best for piping). |
      | `--color <W>` | `auto` \| `always` \| `ansi` \| `never` |
      | `--inspect <G>` | Detail level: `nothing` \| `summary` \| `entity` |
      | `-A, -B, -C <N>` | Context lines after / before / around each match. |
      | `-j, --threads <N>` | Thread count (default: heuristic; `0` = auto). |
      
      ### `--update-all` + `--json` — the trap
      
      `sg` silently ignores `--update-all` when `--json` is set. To preview AND apply, run **two passes**:
      
      ```bash
      # Pass 1: preview
      sg run -p 'foo()' -r 'bar()' --json=compact src/
      
      # Pass 2: apply
      sg run -p 'foo()' -r 'bar()' --update-all src/
      ```
      
      The `ast_grep_helper.py replace --apply` subcommand does this automatically.
      
      ### Examples
      
      ```bash
      # Basic search
      sg run -p 'console.log($MSG)' --lang ts src/
      
      # Search with context lines
      sg run -p 'eval($CODE)' --lang js -C 3 .
      
      # Rewrite, dry-run preview as JSON
      sg run -p 'console.log($MSG)' -r 'logger.info($MSG)' --json=compact --lang ts src/
      
      # Rewrite, apply
      sg run -p 'console.log($MSG)' -r 'logger.info($MSG)' --update-all --lang ts src/
      
      # Pattern from stdin
      echo 'console.log("x")' | sg run -p 'console.log($MSG)' --lang js --stdin
      
      # Limit to specific files
      sg run -p 'foo()' --lang ts --globs 'src/**/*.ts' --globs '!**/*.test.ts' .
      
      # Debug a pattern that returns 0 matches
      sg run -p 'def $F($$$):' --lang py --debug-query=ast --stdin <<< 'def foo(): pass'
      ```
      
      ---
      
      ## `sg scan` — YAML rule scanner
      
      Run a configuration of YAML rules across files. Used for project-wide lints and codemods.
      
      ```bash
      sg scan [OPTIONS] [PATHS...]
      ```
      
      | Flag | Purpose |
      |---|---|
      | `-c, --config <C>` | Path to `sgconfig.yml` (default: walk up from cwd looking for one). |
      | `-r, --rule <F>` | Run a **single** rule file. Mutually exclusive with `--config`. |
      | `--inline-rules <Y>` | Pass YAML rule text inline. Use `---` to separate multiple rules. |
      | `--filter <RE>` | Only run rules whose `id` matches this regex. |
      | `--include-metadata` | Include rule `metadata` field in JSON output. |
      | `-U, --update-all` | Apply fixes from `fix:` automatically. |
      | `--report-style <S>` | `rich` \| `medium` \| `short` |
      | `--format <F>` | `github` \| `sarif` (CI-friendly outputs). |
      | `--error[=ID]`, `--warning[=ID]`, `--info[=ID]`, `--hint[=ID]`, `--off[=ID]` | Promote/demote severity. |
      | `-i, --interactive` | Confirm each fix interactively. |
      | `--json[=<S>]` | JSON output. |
      
      ### Examples
      
      ```bash
      # Run all rules in sgconfig.yml-discovered ruleDirs
      sg scan src/
      
      # Run a single rule file (no sgconfig.yml needed)
      sg scan -r rules/no-console.yml src/
      
      # Inline rule (great for one-offs and CI)
      sg scan --inline-rules '
      id: no-todo
      language: TypeScript
      severity: warning
      rule: { pattern: TODO }' src/
      
      # Apply all auto-fixes
      sg scan -U src/
      
      # CI-friendly GitHub annotations
      sg scan --format github src/
      
      # SARIF for security scanners
      sg scan --format sarif src/ > sarif.json
      ```
      
      ---
      
      ## `sg test` — run rule snapshot tests
      
      ```bash
      sg test [OPTIONS]
      ```
      
      | Flag | Purpose |
      |---|---|
      | `-c, --config <C>` | Path to `sgconfig.yml`. |
      | `-t, --test-dir <D>` | Test directory. |
      | `--snapshot-dir <D>` | Snapshot directory (default: `__snapshots__`). |
      | `--skip-snapshot-tests` | Validate test code parses; don't compare snapshots. |
      | `-U, --update-all` | Update all changed snapshots. |
      | `-f, --filter <G>` | Filter test cases by glob on rule id. |
      | `--include-off` | Include rules with severity `off`. |
      | `-i, --interactive` | Step through changed snapshots and accept/reject each. |
      
      A test directory looks like:
      
      ```
      test/
      ├── no-console.yml          # `valid:` and `invalid:` snippets
      └── no-console-test.yml     # alternative test file format
      __snapshots__/
      └── no-console-snapshot.yml # expected match locations
      ```
      
      ---
      
      ## `sg new` — scaffold
      
      ```bash
      sg new <COMMAND> [NAME] [OPTIONS]
      ```
      
      | Subcommand | Creates |
      |---|---|
      | `project` | `sgconfig.yml`, `rules/`, `utils/`, `__snapshots__/` directory tree |
      | `rule` | A new YAML rule file in the first `ruleDirs` entry |
      | `test` | A new test file in `testConfigs[0].testDir` |
      | `util` | A new utility rule in the first `utilDirs` entry |
      
      ```bash
      # New project in current dir
      sg new project --yes
      
      # New rule
      sg new rule no-console --lang typescript
      
      # New test
      sg new test no-console --yes
      ```
      
      ---
      
      ## `sg lsp` — language server
      
      ```bash
      sg lsp -c sgconfig.yml
      ```
      
      Speak LSP over stdin/stdout. Configure your editor (VS Code extension, Neovim `nvim-lspconfig`, Helix `languages.toml`) to spawn this command for live diagnostics.
      
      ---
      
      ## `sg completions` — shell completions
      
      ```bash
      sg completions bash >> ~/.bashrc
      sg completions zsh > "${fpath[1]}/_sg"
      sg completions fish > ~/.config/fish/completions/sg.fish
      sg completions powershell >> $PROFILE
      ```
      
      ---
      
      ## Useful one-liners
      
      ```bash
      # Count matches per file
      sg run -p 'console.log($_)' --lang ts --json=compact . \
        | jq -r '.[].file' | sort | uniq -c | sort -rn
      
      # Find all unique kinds in a file (great for figuring out kind names)
      sg run -p '$_' --lang ts --debug-query=cst src/foo.ts \
        | grep -oE 'kind: [a-z_]+' | sort -u
      
      # Rewrite only in a subset of files
      sg run -p 'foo()' -r 'bar()' --update-all --globs 'src/**/*.ts' --globs '!src/legacy/**' .
      
      # Apply fixes from many rules but only ones matching a pattern in their id
      sg scan --filter 'no-' -U src/
      
      # Use ast-grep as a linter in pre-commit
      sg scan --format github src/ || exit 1
      ```
      
      ---
      
      ## See also
      
      - `references/yaml-rules.md` — rule schema (`pattern`, `kind`, `regex`, `inside`, `has`, `all`, `any`, `not`, `matches`, `transform`, `fix`).
      - `references/sgconfig.md` — project configuration.
      - Official: <https://ast-grep.github.io/reference/cli.html>
      
    • install.md 5.5 KB
      # Install ast-grep
      
      The skill ships an `install.sh` (POSIX) and `install.ps1` (Windows) that try every reasonable method in priority order and fall back to a GitHub release download as a last resort. **You usually do not need to read this page.** Run the installer:
      
      ```bash
      bash install.sh                        # macOS / Linux / WSL / Git Bash
      pwsh -File install.ps1                 # Windows PowerShell
      ```
      
      This page exists for the (rare) case the installer cannot find a working method, or you want to install ast-grep manually.
      
      ---
      
      ## Per-OS install commands (verbatim, copy-paste)
      
      ### macOS
      
      ```bash
      brew install ast-grep                  # Homebrew - the primary path
      sudo port install ast-grep             # MacPorts
      npm install -g @ast-grep/cli           # if you have Node already
      cargo install ast-grep --locked        # if you have Rust already
      ```
      
      ### Linux
      
      ```bash
      # Universal (works on every distro)
      npm install -g @ast-grep/cli
      cargo install ast-grep --locked
      pip install ast-grep-cli
      
      # Distro-specific
      nix-env -iA nixpkgs.ast-grep           # NixOS / Nix
      brew install ast-grep                  # Linuxbrew
      
      # NixOS shell.nix
      nix-shell -p ast-grep
      ```
      
      > **Linux gotcha**: the binary is named `sg`, but on most Linux systems `sg` is also the **`setgroups` command** from `util-linux`. The shell sees `setgroups` first and ignores ast-grep. Two options:
      >
      > 1. Always invoke `ast-grep` (full name).
      > 2. Add an alias: `alias sg=ast-grep` in your `~/.bashrc` / `~/.zshrc`.
      >
      > The `ast_grep_helper.py` script in `scripts/` already handles this — when it sees `sg` on PATH on Linux, it runs `--version` and rejects the binary if it isn't ast-grep.
      
      ### Windows
      
      ```powershell
      scoop install main/ast-grep            # Scoop (most common on dev machines)
      winget install --id ast-grep.ast-grep  # Winget (Microsoft built-in)
      choco install ast-grep                 # Chocolatey
      npm install -g @ast-grep/cli           # any OS with Node
      cargo install ast-grep --locked        # any OS with Rust
      ```
      
      ### WSL / Git Bash on Windows
      
      Treat as Linux. Use `npm`, `cargo`, `pip`, or `bash install.sh`.
      
      ---
      
      ## Cross-platform / language-ecosystem methods
      
      These work on every OS:
      
      | Method | Command | Pros | Cons |
      |---|---|---|---|
      | **npm** | `npm install -g @ast-grep/cli` | Fast, prebuilt platform binaries | Needs Node 18+ |
      | **cargo** | `cargo install ast-grep --locked` | Always builds latest from source | Slow (~3-5 min compile) |
      | **cargo binstall** | `cargo binstall ast-grep` | Fast (downloads release binary) | Needs `cargo-binstall` first |
      | **pip** | `pip install ast-grep-cli` | Works in any Python venv | Needs Python 3.8+ |
      | **pipx** | `pipx install ast-grep-cli` | Isolated install | Needs pipx |
      | **mise** | `mise use -g ast-grep` | asdf successor, version-pinning | Needs mise |
      | **GitHub release** | manual download | Pure binary, no toolchain | Manual PATH setup |
      
      ---
      
      ## GitHub release manual install
      
      If every package manager fails:
      
      ```bash
      # 1. Pick the right asset for your OS+arch from the latest release:
      #    https://github.com/ast-grep/ast-grep/releases/latest
      #
      #    Naming pattern:
      #      app-aarch64-apple-darwin.zip          macOS Apple Silicon
      #      app-x86_64-apple-darwin.zip           macOS Intel
      #      app-aarch64-unknown-linux-gnu.zip     Linux ARM64 (glibc)
      #      app-x86_64-unknown-linux-gnu.zip      Linux x86_64 (glibc)
      #      app-x86_64-pc-windows-msvc.zip        Windows x86_64
      #      app-aarch64-pc-windows-msvc.zip       Windows ARM64
      
      # 2. Download and extract:
      VERSION=0.45.0
      TRIPLE=aarch64-apple-darwin
      curl -fsSL "https://github.com/ast-grep/ast-grep/releases/download/${VERSION}/app-${TRIPLE}.zip" -o /tmp/ast-grep.zip
      unzip /tmp/ast-grep.zip -d /tmp/ast-grep
      sudo mv /tmp/ast-grep/ast-grep /usr/local/bin/sg
      sudo chmod +x /usr/local/bin/sg
      
      # 3. Verify:
      sg --version
      ```
      
      The skill's `install.sh` does steps 1-3 automatically and drops the binary in `<skill_root>/bin/sg` so you can use it without sudo.
      
      ---
      
      ## Build from source
      
      ```bash
      git clone https://github.com/ast-grep/ast-grep.git
      cd ast-grep
      cargo install --path ./crates/cli --locked
      ```
      
      Requires Rust 1.74+. Slowest path; only useful when you need a specific commit or unreleased fix.
      
      ---
      
      ## Verifying the install
      
      ```bash
      ast-grep --version            # or `sg --version`
      # ast-grep 0.45.0
      ```
      
      Then sanity-check a real query:
      
      ```bash
      echo 'console.log("hello")' | sg run -p 'console.log($MSG)' --lang js --stdin
      ```
      
      Expected: a single match with the `console.log("hello")` call highlighted.
      
      ---
      
      ## Editor integration
      
      After installing the CLI, set up your editor:
      
      - **VS Code**: install the [`ast-grep`](https://marketplace.visualstudio.com/items?itemName=ast-grep.ast-grep-vscode) extension. Requires `sgconfig.yml` in workspace root for live diagnostics.
      - **Neovim**: configure `nvim-lspconfig` with `ast_grep` server, or install [`telescope-ast-grep.nvim`](https://github.com/ray-x/telescope-ast-grep.nvim).
      - **Helix**: add `ast-grep lsp` as a language server in `languages.toml`.
      - **Emacs**: install [`ast-grep.el`](https://github.com/SunskyXH/ast-grep.el).
      
      See `references/cli.md` for `ast-grep lsp` flags.
      
      ---
      
      ## Uninstall
      
      | Method | Command |
      |---|---|
      | brew | `brew uninstall ast-grep` |
      | npm | `npm uninstall -g @ast-grep/cli` |
      | cargo | `cargo uninstall ast-grep` |
      | pip | `pip uninstall ast-grep-cli` |
      | pipx | `pipx uninstall ast-grep-cli` |
      | scoop | `scoop uninstall ast-grep` |
      | winget | `winget uninstall --id ast-grep.ast-grep` |
      | choco | `choco uninstall ast-grep` |
      | GitHub binary | `rm <skill_root>/bin/sg` |
      
    • patterns.md 5.4 KB
      # Pattern syntax — meta-variables and how patterns parse
      
      ast-grep is **not regex**. Patterns are written in the **same syntax as the target language** (TypeScript, Python, Go, etc.), and ast-grep matches them against the AST of every file. The wildcards are called **meta-variables**.
      
      This page is the canonical primer. If a pattern fails, 90% of the time it is one of the issues on this page.
      
      ---
      
      ## The three meta-variables
      
      | Syntax | Matches | Capture |
      |---|---|---|
      | `$VAR` | exactly **one** AST node | yes, by name |
      | `$$$` | **zero or more** AST nodes (a list) | no (anonymous) |
      | `$$$VAR` | zero or more AST nodes | yes, by name |
      | `$_` | one AST node | no (anonymous) |
      
      A meta-variable always replaces a **whole AST node**, never a substring of a node. `$VAR` cannot match the first three characters of an identifier, only an entire identifier (or expression, or statement, depending on context).
      
      ### Naming rules
      
      - Must start with `$`.
      - Then uppercase letters `A-Z`, digits, or underscores.
      - **Valid**: `$X`, `$VAR`, `$VAR_1`, `$_`, `$_VAR`, `$ARG1`.
      - **Invalid**: `$lower`, `$kebab-case`, `$1` (digit first), `$$single` (use `$_` for anonymous).
      
      ### Same-name = same content
      
      Two occurrences of the same metavariable in a pattern must capture **identical text**:
      
      ```ts
      // Pattern
      $X === $X
      
      // Matches
      a === a
      foo.bar === foo.bar
      
      // Does NOT match
      a === b
      foo === foo.bar
      ```
      
      Useful for finding redundant comparisons, double assignments, etc.
      
      ### `$$$` is greedy
      
      When you write `foo($$$A, b, $$$C)`, the matcher does **not** backtrack or try every possible split. It greedily fills `$$$A` until the pattern can match `b`, then everything left goes into `$$$C`.
      
      ```ts
      // Pattern
      foo($$$A, b, $$$C)
      
      // Input
      foo(a, c, b, b, c)
      
      // Capture
      $$$A = [a, c]
      $$$C = [b, c]
      ```
      
      If you need a different split, restructure the pattern (e.g. add a constraint).
      
      ---
      
      ## Patterns must be valid code
      
      The pattern itself must parse with the target language's grammar. ast-grep treats `$VAR` and `$$$` as identifiers/argument lists during parsing, then matches structurally.
      
      ### What goes wrong
      
      | Bad pattern | Why it fails | Fix |
      |---|---|---|
      | `function $NAME` | Function declaration without body — not a valid AST node in JS/TS/Go/Rust. | `function $NAME($$$) { $$$ }` |
      | `def $FN($$$):` | Trailing colon. ast-grep parses as a complete function definition; the colon makes it a statement. | `def $FN($$$)` |
      | `class Foo:` | Same — Python class without body. | `class Foo($$$)` |
      | `fn $NAME` | Rust fn without signature. | `fn $NAME($$$) -> $RET { $$$ }` |
      | `if x` | Incomplete `if` — most languages require the body. | `if x { $$$ }` (curly-brace languages) or `if x: $$$` (Python uses `pattern.context`/`selector` instead) |
      | `"key": "$VAL"` | JSON pattern — a key/value pair on its own isn't valid JSON. | Use `pattern: { context: '{"key": "$VAL"}', selector: pair }` |
      
      ### When a sub-expression isn't valid on its own
      
      Sometimes you want to match an *expression* that the language only allows inside a larger context. Use the `pattern` object form:
      
      ```yaml
      pattern:
        context: 'class A { $FIELD = $INIT }'
        selector: field_definition
      ```
      
      This says: parse `class A { $FIELD = $INIT }` as a whole, then keep only the `field_definition` sub-tree as the actual pattern.
      
      ---
      
      ## Strictness levels
      
      When CST nodes don't match exactly (extra whitespace, different unnamed punctuation), ast-grep can be more or less forgiving. Pass `--strictness <LEVEL>` on the CLI, or set it in a YAML rule:
      
      | Level | Matches |
      |---|---|
      | `cst` | Every node, including unnamed (commas, parens, etc.) |
      | `smart` (default) | All except unnamed nodes in the **target** that aren't in the pattern |
      | `ast` | Only named AST nodes |
      | `relaxed` | Named AST nodes, ignoring comments |
      | `signature` | Only node kinds — text and unnamed nodes ignored |
      
      `smart` is almost always what you want. Reach for `signature` when you want to match "any function called `foo`" regardless of arguments.
      
      ---
      
      ## Testing a pattern
      
      Two tools help you confirm a pattern parses the way you expect:
      
      ```bash
      # Print the AST of the pattern itself
      sg run -p 'console.log($MSG)' --lang ts --debug-query=ast
      
      # Print the parsed CST of a file (great for figuring out kind names)
      sg run -p '$_' --lang ts --debug-query=cst src/example.ts | head -40
      ```
      
      `--debug-query=ast` shows the named AST nodes only (cleaner). `--debug-query=cst` shows everything including punctuation. Both go to stderr, so they don't interfere with stdout JSON.
      
      The web playground is also fast: <https://ast-grep.github.io/playground.html>.
      
      ---
      
      ## When ast-grep is the wrong tool
      
      If your pattern is fundamentally text-shaped, switch to `grep` / `rg`:
      
      - Match across multiple files **for any text** → `rg`
      - Cross-language regex with alternation → `rg -e foo -e bar`
      - Match comments only → `rg --type ts '^\s*//.*TODO'`
      - Match URLs, emails, license headers → `rg`
      
      ast-grep is for **code structure**: function shapes, call patterns, control flow, type annotations, imports, error handling. If your "pattern" only depends on the bytes of the file and not on the syntax, regex is the right tool.
      
      ---
      
      ## See also
      
      - `references/pitfalls.md` — concrete regex anti-patterns and language-specific traps.
      - `references/recipes.md` — copy-paste-ready patterns for TS/JS/Py/Go/Rust.
      - `references/yaml-rules.md` — `kind`, `regex`, `inside`, `has`, `all`, `any`, `not`, `matches`.
      - Official: <https://ast-grep.github.io/guide/pattern-syntax.html>
      
    • pitfalls.md 8.7 KB
      # Pitfalls — what breaks patterns and how to fix them
      
      This is the failure-mode field guide. The `scripts/ast_grep_helper.py validate` subcommand mechanically checks for the items in §1 before calling `sg`; the rest are lower-frequency but still common.
      
      ---
      
      ## 1. Regex syntax does not work
      
      ast-grep does **not** interpret regex inside patterns. The following all fail:
      
      | Bad | Why | Use instead |
      |---|---|---|
      | `foo\|bar` | `\|` is regex alternation. ast-grep does not alternate. | Two separate calls, OR `any: [pattern: foo, pattern: bar]` in a YAML rule, OR `rg -e foo -e bar`. |
      | `foo.*bar` | `.*` is a regex wildcard. | `foo($$$) bar` if the gap is a list of nodes; otherwise switch to `rg`. |
      | `\w+`, `\d+`, `\s` | Regex character classes. | `$VAR` to capture any identifier. For digits-only, use `kind: number_literal`. |
      | `[a-z]+` | Regex character class. | No AST equivalent — switch to `rg`. |
      | `^foo$` | Regex anchors. | Anchor by AST: use `kind: program > expression_statement` or use `inside`/`not has`. |
      
      **Why this happens**: LLMs default to regex thinking. The mental switch is "ast-grep patterns are *code*, not *strings*."
      
      When you genuinely need regex, use the `regex` rule field in YAML (matches node text with Rust regex):
      
      ```yaml
      rule:
        all:
          - kind: identifier
          - regex: '^[A-Z][a-z]+$'   # CamelCase identifiers only
      ```
      
      Note: `regex` matches the **whole node text** — no partial matches. Combine with `kind` or `pattern` for performance.
      
      ---
      
      ## 2. Incomplete AST nodes
      
      Patterns must be valid code that the parser accepts as a complete node. Common mistakes:
      
      ```text
      # JS/TS
      function foo                              ❌ no params, no body
      function $NAME($$$) { $$$ }               ✅
      
      async function $NAME                      ❌
      async function $NAME($$$) { $$$ }         ✅
      
      # Python
      def foo:                                  ❌ trailing colon makes it a statement
      def $FN($$$)                              ✅
      class Foo:                                ❌
      class $C($$$)                             ✅
      
      # Go
      func foo                                  ❌
      func $NAME($$$) { $$$ }                   ✅
      
      # Rust
      fn foo                                    ❌
      fn $NAME($$$) -> $RET { $$$ }             ✅
      fn $NAME($$$) { $$$ }                     ✅ (-> () inferred)
      
      # Java
      public void foo                           ❌
      public void $NAME($$$) { $$$ }            ✅
      ```
      
      If a pattern returns 0 matches and looks correct, run `sg run -p '<pattern>' --lang <lang> --debug-query=ast --stdin <<< 'echo'` and see what the parser thinks the pattern is. If it returns an `ERROR` node, the pattern is malformed.
      
      ---
      
      ## 3. Pattern parses as the wrong kind
      
      A class field initializer `a = 123` *also* parses as an assignment expression. If you want only field definitions, you must disambiguate:
      
      ```yaml
      # WRONG — pattern parses as assignment_expression, not field_definition
      pattern: a = 123
      kind: field_definition
      
      # CORRECT — use pattern object with context + selector
      pattern:
        context: 'class C { a = 123 }'
        selector: field_definition
      ```
      
      `kind` and `pattern` are **independent constraints**, not modifiers of each other. ast-grep does not change *how* it parses based on `kind`.
      
      ---
      
      ## 4. The `|` ambiguity
      
      A bare `|` in a pattern is interpreted as bitwise-or in most languages, **not** alternation. So:
      
      ```yaml
      pattern: foo | bar          # parses as: foo bitwise-or'd with bar
      ```
      
      …matches expressions like `x | y`, not "either foo or bar". To get alternation, use `any`:
      
      ```yaml
      rule:
        any:
          - pattern: foo
          - pattern: bar
      ```
      
      In TypeScript union types (`A | B`), `|` is part of the type syntax — `pattern: A | B` correctly parses as a union type and matches that.
      
      ---
      
      ## 5. Same-name metavars collide
      
      ```ts
      // Pattern: $X = $X
      // Captures only when both sides are TEXTUALLY identical.
      
      // Matches:
      a = a
      foo.bar = foo.bar
      
      // Does NOT match:
      a = b
      let x = compute()      // because $X needs to bind once and re-use
      ```
      
      If you actually want two independent captures, name them differently: `$X = $Y`.
      
      ---
      
      ## 6. `$$$` is greedy then commits
      
      `$$$` does **not** backtrack. It captures as much as possible, then commits. If your pattern needs a non-greedy match, structure it differently:
      
      ```ts
      // You want "match foo($X), where $X is any single arg"
      // BAD:  foo($$$X)        // matches foo(a), foo(a, b), foo(a, b, c) - too broad
      // GOOD: foo($X)          // matches only single-arg calls
      
      // You want "match foo() with at least one arg"
      // BAD:  foo($$$X)        // also matches foo()
      // GOOD: foo($X, $$$REST) // forces at least one arg
      ```
      
      ---
      
      ## 7. `kind` names depend on tree-sitter grammar
      
      `kind: function_declaration` works for JavaScript, but Python uses `function_definition`, Rust uses `function_item`, Go uses `function_declaration` (same as JS by coincidence). To find the right name, parse a known-good file:
      
      ```bash
      sg run -p '$_' --lang python --debug-query=cst path/to/example.py | grep -i function
      ```
      
      Or open <https://ast-grep.github.io/playground.html> and click on a node to see its `kind`.
      
      ---
      
      ## 8. `inside` / `has` defaults to `stopBy: neighbor`
      
      ```yaml
      inside:
        kind: function_declaration   # only checks the IMMEDIATE parent
      ```
      
      If you want "anywhere inside a function (any depth)":
      
      ```yaml
      inside:
        kind: function_declaration
        stopBy: end                  # walks up to the file root
      ```
      
      Same for `has` (descendants):
      
      ```yaml
      has:
        kind: return_statement
        stopBy: end                  # walks down the whole subtree
      ```
      
      Without `stopBy: end`, `has` only matches direct children.
      
      ---
      
      ## 9. CLI silently ignores `--update-all` when `--json` is set
      
      This is the single biggest gotcha when scripting ast-grep. If you run:
      
      ```bash
      sg run -p 'foo()' -r 'bar()' --json=compact --update-all .
      ```
      
      …you get the JSON output but **no files are mutated**. ast-grep silently drops `--update-all` when `--json` is on. To both preview and apply, run **two passes**:
      
      ```bash
      # Pass 1: preview as JSON
      sg run -p 'foo()' -r 'bar()' --json=compact .
      
      # Pass 2: actually apply
      sg run -p 'foo()' -r 'bar()' --update-all .
      ```
      
      `scripts/ast_grep_helper.py replace` does this automatically when `--apply` is set.
      
      ---
      
      ## 10. Composite rules apply to a single node
      
      `all` and `any` evaluate against **one target node** at a time:
      
      ```yaml
      # WRONG — wants "node has BOTH a number child AND a string child"
      has:
        all:
          - kind: number       # impossible: one node cannot be both at once
          - kind: string
      
      # CORRECT
      all:
        - has: { kind: number }
        - has: { kind: string }
      ```
      
      Lift relational rules out of composites when the relation is "the surrounding node has X children matching Y."
      
      ---
      
      ## 11. Field order is not guaranteed
      
      When a rule object has multiple fields:
      
      ```yaml
      rule:
        pattern: $X = compute()
        has: { kind: number }
      ```
      
      …ast-grep evaluates them as an implicit `all`, but the **order** in which metavariables are captured is not guaranteed. If your `transform` or `fix` depends on capture order, use an explicit `all` array:
      
      ```yaml
      rule:
        all:
          - pattern: function $F() { $$$ }
          - has: { pattern: $F() }     # $F captured by pattern first; here we just check
      ```
      
      ---
      
      ## 12. `regex` without `kind` is slow
      
      `regex` alone scans every node text in the file. On large repos this is noticeably slow. Always combine:
      
      ```yaml
      # Slow
      rule:
        regex: '^TODO'
      
      # Fast
      rule:
        all:
          - kind: comment
          - regex: '^//\s*TODO'
      ```
      
      ---
      
      ## 13. No scope / type / data-flow analysis
      
      ast-grep is a **structural** matcher. It does NOT know:
      
      - Whether two `foo` references point to the same variable.
      - Whether a variable is shadowed.
      - Whether a function is async, throws, returns a Promise.
      - Whether a value flows from input to output.
      
      For those questions, use a real type-aware tool: TypeScript LSP, Pyright, Semgrep with type inference, CodeQL, etc.
      
      ast-grep is great when *the syntactic shape* is what you care about: "find every call to `eval(...)`", "find every `as any`", "find every empty catch block." It is weak for "find every variable that's never used."
      
      ---
      
      ## 14. Pattern testing is the fastest debugger
      
      When a pattern returns 0 matches and you can't see why:
      
      1. Open <https://ast-grep.github.io/playground.html>.
      2. Paste your code into the left pane, your pattern into the top-right.
      3. The bottom-right shows the parsed AST and which nodes matched (highlighted) or failed.
      
      Or locally:
      
      ```bash
      sg run -p '<pattern>' --lang <lang> --debug-query=ast --stdin <<< '<sample-code>'
      ```
      
      stderr shows the parsed pattern; stdout shows the JSON match result. If the pattern shows up as `ERROR (XXX)`, it doesn't parse.
      
      ---
      
      ## See also
      
      - `references/patterns.md` — meta-variables, strictness, naming rules.
      - `references/recipes.md` — known-good patterns by language.
      - `references/cli.md` — `--debug-query`, `--strictness`, `--update-all`.
      
    • recipes.md 7.7 KB
      # Recipes — copy-paste patterns by language
      
      Every pattern in this file has been verified against the canonical syntax. They are starting points; tweak metavariable names and constraints to fit your case.
      
      Use them with the helper:
      
      ```bash
      ast-grep-helper search '<PATTERN>' --lang <LANG> [path]
      ast-grep-helper replace '<PATTERN>' '<REWRITE>' --lang <LANG> [path]   # dry-run
      ast-grep-helper replace '<PATTERN>' '<REWRITE>' --lang <LANG> [path] --apply
      ```
      
      Or directly:
      
      ```bash
      sg run -p '<PATTERN>' --lang <LANG> [path]
      sg run -p '<PATTERN>' -r '<REWRITE>' --update-all --lang <LANG> [path]
      ```
      
      ---
      
      ## TypeScript / TSX / JavaScript
      
      ### Find structural patterns
      
      ```typescript
      // Every function declaration
      function $NAME($$$PARAMS) { $$$BODY }
      
      // Every async function
      async function $NAME($$$PARAMS) { $$$BODY }
      
      // Every arrow function (any param shape)
      ($$$PARAMS) => $$$BODY
      
      // Every method on a class
      class $C { $$$ $METHOD($$$P) { $$$B } $$$ }
      
      // Every import statement
      import { $$$NAMES } from '$MOD'
      import $DEFAULT from '$MOD'
      import * as $NS from '$MOD'
      
      // Every console.* call
      console.$METHOD($$$ARGS)
      
      // Every JSX element of a given name
      <MyComponent $$$PROPS>$$$CHILDREN</MyComponent>
      
      // Every try/catch
      try { $$$BODY } catch ($E) { $$$HANDLER }
      
      // Every throw
      throw $EXPR
      
      // Every new expression
      new $CLASS($$$ARGS)
      
      // Every type assertion to any (anti-pattern!)
      $EXPR as any
      $EXPR as unknown as $T
      ```
      
      ### Common rewrites
      
      ```bash
      # console.log -> logger.info
      sg run -p 'console.log($$$A)' -r 'logger.info($$$A)' --lang ts --update-all .
      
      # require -> import (one-arg case)
      sg run -p 'const $V = require($M)' -r 'import $V from $M' --lang ts --update-all .
      
      # .then(callback) -> await on the same line (use with caution; needs async function context)
      sg run -p '$P.then($CB)' -r 'const $TMP = await $P; $CB($TMP)' --lang ts --update-all .
      
      # Strip `as any`
      sg run -p '$E as any' -r '$E' --lang ts --update-all .
      
      # Rename a function call site
      sg run -p 'oldName($$$A)' -r 'newName($$$A)' --lang ts --update-all .
      ```
      
      ---
      
      ## Python
      
      > **Reminder**: never end a Python pattern with `:`. Patterns parse as a complete statement, so `def foo($$$):` is invalid.
      
      ```python
      # Every function definition
      def $FN($$$PARAMS)
      
      # Every class definition
      class $C($$$BASES)
      
      # Every decorator usage
      @$DEC
      def $FN($$$P)
      
      # Every print call (Python 3)
      print($$$ARGS)
      
      # Every f-string
      f"$STR"
      
      # Every with-statement
      with $CTX as $VAR: $$$BODY
      
      # Every try/except
      try: $$$BODY
      except $EXC: $$$HANDLER
      
      # Every list comprehension
      [$EXPR for $VAR in $ITER]
      
      # Every async def
      async def $FN($$$PARAMS)
      
      # Type hints — Optional[X]
      Optional[$T]
      
      # Type hints — X | None (PEP 604)
      $T | None
      ```
      
      ### Common rewrites
      
      ```bash
      # print(...) -> logger.info(...)
      sg run -p 'print($$$A)' -r 'logger.info($$$A)' --lang py --update-all .
      
      # Optional[X] -> X | None
      sg run -p 'Optional[$T]' -r '$T | None' --lang py --update-all .
      
      # from typing import List -> remove (built-in list works in 3.9+)
      sg run -p 'from typing import List' -r 'from typing import List  # TODO: remove, use list' --lang py --update-all .
      ```
      
      ---
      
      ## Go
      
      ```go
      // Every function
      func $NAME($$$PARAMS) $$$RET { $$$BODY }
      
      // Every method
      func ($RECV $TYPE) $NAME($$$PARAMS) $$$RET { $$$BODY }
      
      // The classic err nil-check
      if err != nil { $$$BODY }
      
      // Every fmt.Println / fmt.Printf / fmt.Sprintf
      fmt.$METHOD($$$ARGS)
      
      // Every defer
      defer $EXPR
      
      // Every goroutine
      go $EXPR
      
      // Every channel send/recv
      $CH <- $VAL
      $VAL := <-$CH
      
      // Every type assertion
      $EXPR.($TYPE)
      ```
      
      ### Common rewrites
      
      ```bash
      # fmt.Println -> log.Println
      sg run -p 'fmt.Println($$$A)' -r 'log.Println($$$A)' --lang go --update-all .
      
      # Add error wrapping
      sg run -p 'return $ERR' -r 'return fmt.Errorf("operation failed: %w", $ERR)' --lang go --update-all .
      ```
      
      ---
      
      ## Rust
      
      ```rust
      // Every fn
      fn $NAME($$$PARAMS) -> $RET { $$$BODY }
      fn $NAME($$$PARAMS) { $$$BODY }       // no return type
      
      // Every async fn
      async fn $NAME($$$PARAMS) -> $RET { $$$BODY }
      
      // Every method on impl
      impl $TYPE { fn $METHOD($$$P) -> $R { $$$B } }
      
      // Every trait impl
      impl $TRAIT for $TYPE { $$$ITEMS }
      
      // Every match expression
      match $EXPR { $$$ARMS }
      
      // Every Result-returning fn that uses ?
      fn $N($$$P) -> Result<$T, $E> { $$$ }
      
      // .unwrap() / .expect() (anti-patterns)
      $EXPR.unwrap()
      $EXPR.expect($MSG)
      
      // Every println!/eprintln!/format!
      println!($$$ARGS)
      format!($$$ARGS)
      ```
      
      ### Common rewrites
      
      ```bash
      # unwrap() -> ? in Result-returning fns (caution: needs context)
      sg run -p '$E.unwrap()' -r '$E?' --lang rust --update-all .
      
      # eprintln! -> log::error!
      sg run -p 'eprintln!($$$A)' -r 'log::error!($$$A)' --lang rust --update-all .
      ```
      
      ---
      
      ## Java
      
      ```java
      // Every public class
      public class $NAME { $$$BODY }
      
      // Every method (any modifier)
      $$$MOD $RET $NAME($$$P) { $$$BODY }
      
      // Every System.out.println / System.err.println
      System.$STREAM.println($$$ARGS)
      
      // Every try-with-resources
      try ($$$RES) { $$$BODY } catch ($EXC $E) { $$$HANDLER }
      
      // Every annotation usage
      @$ANNOTATION
      $DECL
      ```
      
      ---
      
      ## C / C++
      
      ```cpp
      // Every printf-family call
      printf($$$ARGS)
      sprintf($$$ARGS)
      fprintf($$$ARGS)
      
      // Every malloc / free pair (find-only — pairing requires data flow)
      malloc($SIZE)
      free($PTR)
      
      // Every for-loop
      for ($INIT; $COND; $POST) { $$$BODY }
      
      // C++ smart pointer make
      std::make_shared<$T>($$$ARGS)
      std::make_unique<$T>($$$ARGS)
      ```
      
      ### Rewrites
      
      ```bash
      # malloc(N * sizeof(T)) -> calloc(N, sizeof(T)) - safer
      sg run -p 'malloc($N * sizeof($T))' -r 'calloc($N, sizeof($T))' --lang c --update-all .
      ```
      
      ---
      
      ## CSS
      
      ```css
      /* Every rule with a specific property */
      { $$$ color: $VAL; $$$ }
      
      /* Every @media query */
      @media $QUERY { $$$BODY }
      
      /* Every var() reference */
      var($NAME)
      ```
      
      ---
      
      ## HTML
      
      ```html
      <!-- Every img without alt -->
      <img $$$ />
      
      <!-- Every script tag -->
      <script $$$>$$$BODY</script>
      
      <!-- Every link to stylesheet -->
      <link rel="stylesheet" href=$URL />
      ```
      
      ---
      
      ## Bash / Shell
      
      ```bash
      # Every for-loop
      for $VAR in $$$LIST; do $$$BODY; done
      
      # Every if-statement
      if $$$COND; then $$$BODY; fi
      
      # Every function definition
      $NAME() { $$$BODY }
      
      # Every subshell call
      $( $$$CMD )
      ```
      
      ---
      
      ## YAML rule recipes (for `sg scan`)
      
      These are full YAML rules you can drop in `rules/*.yml` and run via `sg scan`. See `references/yaml-rules.md` for the full schema.
      
      ### no-console (TypeScript)
      
      ```yaml
      id: no-console
      language: TypeScript
      severity: warning
      message: "Avoid console.* in production"
      rule:
        pattern: console.$METHOD($$$ARGS)
      fix: logger.$METHOD($$$ARGS)
      ```
      
      ### no-as-any (TypeScript)
      
      ```yaml
      id: no-as-any
      language: TypeScript
      severity: error
      message: "`as any` defeats type safety. Use a proper type."
      rule:
        pattern: $EXPR as any
      fix: $EXPR
      ```
      
      ### empty-catch (JavaScript)
      
      ```yaml
      id: empty-catch
      language: JavaScript
      severity: error
      message: "Empty catch swallows errors silently."
      rule:
        all:
          - pattern: try { $$$T } catch ($E) { $$$H }
          - has:
              kind: catch_clause
              has:
                kind: statement_block
                not:
                  has:
                    kind: statement
                    stopBy: end
      ```
      
      ### print-to-logger (Python)
      
      ```yaml
      id: print-to-logger
      language: Python
      severity: hint
      message: "Use logger.info instead of print"
      rule:
        pattern: print($$$ARGS)
      fix: logger.info($$$ARGS)
      ```
      
      ### no-unwrap (Rust)
      
      ```yaml
      id: no-unwrap
      language: Rust
      severity: warning
      message: "Avoid .unwrap() in production code; propagate or handle the error."
      rule:
        pattern: $EXPR.unwrap()
      ```
      
      ---
      
      ## See also
      
      - `references/patterns.md` — meta-variable rules.
      - `references/yaml-rules.md` — full YAML rule schema (atomic / relational / composite / transform / fix).
      - `references/cli.md` — `sg run`, `sg scan`, `sg test`, `sg new`.
      - Official catalog: <https://ast-grep.github.io/catalog/> (community-maintained, browse by language).
      
    • sgconfig.md 6.3 KB
      # sgconfig.yml — project configuration
      
      `sgconfig.yml` lives at your project root (the same place as `package.json`, `Cargo.toml`, `pyproject.toml`, etc.) and tells `sg scan`/`sg test` where to find rules and tests.
      
      `sg` walks **upward** from the current directory until it finds an `sgconfig.yml`. You can also pass `--config <path>` explicitly.
      
      ---
      
      ## Minimal project layout
      
      ```
      my-project/
      ├── sgconfig.yml
      ├── rules/
      │   ├── no-console.yml
      │   └── no-as-any.yml
      ├── utils/
      │   └── is-literal.yml
      ├── tests/
      │   ├── no-console.yml
      │   └── __snapshots__/
      │       └── no-console-snapshot.yml
      └── src/
          └── ...
      ```
      
      ```yaml
      # sgconfig.yml
      ruleDirs:
        - rules
      
      testConfigs:
        - testDir: tests
          snapshotDir: __snapshots__
      
      utilDirs:
        - utils
      ```
      
      That's it. `sg scan src/` will load every `.yml` in `rules/`, find every `.ts`/`.py`/whatever matching the rule's `language`, and report violations.
      
      ---
      
      ## Full schema
      
      ```yaml
      # Rule directories — required
      ruleDirs:
        - rules
        - team-rules
        - vendor/sg-rules
      
      # Test directories — optional
      testConfigs:
        - testDir: tests
          snapshotDir: __snapshots__
        - testDir: integration-tests
      
      # Utility rule directories — optional
      # Files here become global utilities accessible via `matches: <id>` from any rule.
      utilDirs:
        - utils
        - team-utils
      
      # Override file-extension -> language mapping — optional
      # Useful when your code uses non-standard extensions.
      languageGlobs:
        html:
          - '*.vue'
          - '*.svelte'
          - '*.astro'
        json:
          - '.eslintrc'
          - '.prettierrc'
        cpp:
          - '*.c'                  # treat C as C++
        tsx:
          - '*.ts'                 # treat all .ts as TSX (so TSX rules work everywhere)
      
      # Custom tree-sitter languages (experimental) — optional
      customLanguages:
        mojo:
          libraryPath: tree-sitter-mojo.so
          extensions: [mojo, '🔥']
          expandoChar: _           # Replace $ in patterns when language uses $ syntactically
          languageSymbol: tree_sitter_mojo
      
      # Language injection — embedded code in another language (experimental) — optional
      # Example: CSS inside styled-components template literals.
      languageInjections:
        - hostLanguage: js
          rule:
            pattern: 'styled.$TAG`$CONTENT`'
          injected: css
      ```
      
      ---
      
      ## Field-by-field
      
      ### `ruleDirs` (required)
      
      `Array<string>` — directories containing rule YAML files. Resolved relative to `sgconfig.yml`.
      
      Each `.yml`/`.yaml` file in these directories is loaded as a rule. One file can contain multiple rules separated by `---`.
      
      ### `testConfigs`
      
      `Array<TestConfig>` where each entry has:
      
      - `testDir` (required): directory of test YAML files.
      - `snapshotDir` (optional, default `__snapshots__`): directory for snapshots.
      
      Each test file looks like:
      
      ```yaml
      id: no-console
      valid:
        - 'logger.info("hi")'
      invalid:
        - 'console.log("hi")'
      ```
      
      `sg test` runs every test, compares matches against the snapshot, and fails on diff. Snapshots are created on first run with `-U`.
      
      ### `utilDirs`
      
      `Array<string>` — directories with global utility rules. Each util file must have `id` and `language`. Utils become referenceable via `matches: <id>` from any rule in the project.
      
      ### `languageGlobs`
      
      `HashMap<string, Array<string>>` — override which extensions map to which language. Takes precedence over the built-in defaults.
      
      Useful for:
      
      - Custom file extensions (`.eslintrc` is JSON).
      - Force-treating `.ts` files as TSX (so JSX-shaped patterns work).
      - Vue/Svelte/Astro files (HTML host language).
      
      ### `customLanguages` (experimental)
      
      Register a tree-sitter parser that ast-grep doesn't ship with. Requires:
      
      - `libraryPath`: path to a built `.so` / `.dylib` / `.dll` containing the grammar.
      - `extensions`: file extensions to recognize.
      - `languageSymbol`: the C symbol exported by the grammar (typically `tree_sitter_<name>`).
      - `expandoChar` (optional): character to substitute for `$` in patterns when the host language uses `$` syntactically (PHP, jQuery, etc.).
      
      This is **rarely needed** — ast-grep already supports 25 languages out of the box.
      
      ### `languageInjections` (experimental)
      
      Match patterns inside embedded languages. Example: CSS inside JS template literals (styled-components, emotion).
      
      ```yaml
      languageInjections:
        - hostLanguage: js
          rule:
            pattern: 'styled.$TAG`$CONTENT`'
          injected: css
      ```
      
      After this, a `css` rule with pattern `color: $C` will match `$CONTENT` strings.
      
      ---
      
      ## Common configurations
      
      ### Monorepo with shared rules
      
      ```
      monorepo/
      ├── sgconfig.yml          # root config — applies to entire monorepo
      ├── shared-rules/
      │   ├── no-todo.yml
      │   └── no-as-any.yml
      └── packages/
          ├── frontend/
          │   ├── sgconfig.yml  # extends root with frontend-specific rules
          │   └── rules/
          └── backend/
              ├── sgconfig.yml  # extends root with backend-specific rules
              └── rules/
      ```
      
      Each package's `sgconfig.yml` references both the package-local rules and the shared ones:
      
      ```yaml
      # packages/frontend/sgconfig.yml
      ruleDirs:
        - rules
        - ../../shared-rules
      ```
      
      ### Single rule file (no project)
      
      For one-offs, skip `sgconfig.yml` entirely:
      
      ```bash
      sg scan -r path/to/single-rule.yml src/
      ```
      
      ### Inline rule (no file)
      
      ```bash
      sg scan --inline-rules '
      id: no-todo
      language: TypeScript
      severity: warning
      rule: { pattern: TODO }' src/
      ```
      
      Multiple rules separated by `---`:
      
      ```bash
      sg scan --inline-rules '
      id: no-todo
      language: TypeScript
      rule: { pattern: TODO }
      ---
      id: no-fixme
      language: TypeScript
      rule: { pattern: FIXME }' src/
      ```
      
      ---
      
      ## Editor integration
      
      VS Code / Neovim / Helix detect `sgconfig.yml` automatically and surface diagnostics from every rule. Without `sgconfig.yml`, the LSP runs without any rules loaded.
      
      To enable schema validation in your editor, add a header to each rule file:
      
      ```yaml
      # yaml-language-server: $schema=https://raw.githubusercontent.com/ast-grep/ast-grep/main/schemas/rule.json
      id: no-console
      language: TypeScript
      rule:
        pattern: console.log($_)
      ```
      
      ---
      
      ## See also
      
      - `references/yaml-rules.md` — rule schema (atomic / relational / composite / transform / fix).
      - `references/cli.md` — `sg scan`, `sg test`, `sg new project`.
      - Official: <https://ast-grep.github.io/reference/sgconfig.html>, <https://ast-grep.github.io/guide/project/project-config.html>
      
    • yaml-rules.md 11.1 KB
      # YAML rule reference — atomic, relational, composite, transform, fix
      
      Use this when you outgrow inline `sg run -p ...` patterns and need a reusable, testable rule. A YAML rule is the unit of work for `sg scan`. Drop one or more files in `ruleDirs/` (configured via `sgconfig.yml`) and they get loaded automatically.
      
      This page is the practical reference. The full upstream docs live at:
      
      - <https://ast-grep.github.io/reference/yaml.html>
      - <https://ast-grep.github.io/reference/rule.html>
      - <https://ast-grep.github.io/cheatsheet/rule.html>
      
      ---
      
      ## Skeleton
      
      A single YAML file can hold multiple rules separated by `---`.
      
      ```yaml
      id: no-console
      language: TypeScript
      severity: warning
      message: "Avoid console.* in production"
      note: |
        Use a proper logger so we can route logs to stderr in production
        and silence them in tests.
      url: https://internal.docs/rules/no-console
      
      rule:
        pattern: console.$METHOD($$$ARGS)
      
      fix: logger.$METHOD($$$ARGS)
      
      constraints:
        METHOD:
          not:
            regex: '^(error|warn)$'
      
      files:
        - 'src/**/*.ts'
      ignores:
        - 'src/**/*.test.ts'
      
      metadata:
        category: logging
      ```
      
      ---
      
      ## Top-level fields
      
      | Field | Required | Description |
      |---|---|---|
      | `id` | yes | Unique identifier. Use `kebab-case`. |
      | `language` | yes | One of: `Bash`, `C`, `Cpp`, `CSharp`, `Css`, `Elixir`, `Go`, `Haskell`, `Html`, `Java`, `JavaScript`, `Json`, `Kotlin`, `Lua`, `Nix`, `Php`, `Python`, `Ruby`, `Rust`, `Scala`, `Solidity`, `Swift`, `TypeScript`, `Tsx`, `Yaml`. **Capitalized PascalCase** is canonical, but lowercase often works. |
      | `rule` | yes | The matching logic. Object containing one or more atomic / relational / composite rules. |
      | `constraints` | no | Filter on captured single-metavariables (`$VAR`, not `$$$`). |
      | `utils` | no | Local utility rules referenced by `matches:` in this file. |
      | `transform` | no | Manipulate metavariable strings before `fix`. |
      | `fix` | no | String or `FixConfig` for auto-rewrite. |
      | `rewriters` | no | Rewriter rules for the `rewrite` transform. |
      | `severity` | no | `hint` \| `info` \| `warning` \| `error` \| `off` (default: `hint`). |
      | `message` | no | Concise lint message. May reference `$VAR` capture text. |
      | `note` | no | Detailed markdown explanation (no `$VAR` interpolation). |
      | `labels` | no | Custom diagnostic highlighting per-metavariable. |
      | `files` | no | Glob include list. |
      | `ignores` | no | Glob exclude list. |
      | `url` | no | Doc link shown in editor diagnostics. |
      | `metadata` | no | Free-form data ignored by `sg`, useful for external tooling. |
      
      ---
      
      ## Atomic rules — match a single node
      
      ### `pattern`
      
      Match by structural pattern. The most common rule.
      
      ```yaml
      # String form
      rule:
        pattern: console.log($MSG)
      
      # Object form (when context is needed)
      rule:
        pattern:
          context: 'class C { $FIELD = $INIT }'
          selector: field_definition
          strictness: relaxed   # optional, default: smart
      ```
      
      ### `kind`
      
      Match by AST node type name. Tree-sitter grammar-specific.
      
      ```yaml
      rule:
        kind: call_expression
      ```
      
      ast-grep 0.39+ supports limited ESQuery selectors:
      
      ```yaml
      rule:
        kind: call_expression > identifier   # direct child
        kind: call_expression + identifier   # next sibling
        kind: call_expression ~ identifier   # following sibling
        kind: call_expression identifier     # descendant
      ```
      
      To find the right `kind`, parse a known-good file:
      
      ```bash
      sg run -p '$_' --lang ts --debug-query=cst src/foo.ts | head -40
      ```
      
      ### `regex`
      
      Match node text against a Rust regex. Whole-text match (no partial). Always combine with `kind` or `pattern` for performance.
      
      ```yaml
      rule:
        all:
          - kind: identifier
          - regex: '^[A-Z][a-z]+$'   # PascalCase
      ```
      
      Inline flags work: `(?i)apple`, `(?m)^foo`. No look-around, no backreferences.
      
      ### `nthChild`
      
      Match by 1-based index among **named** siblings. Inspired by CSS `:nth-child`.
      
      ```yaml
      rule:
        nthChild: 1                # first sibling
      
      # Functional form
      rule:
        nthChild: 2n+1             # odd siblings
      
      # With reverse and ofRule
      rule:
        nthChild:
          position: 1
          reverse: true            # last
          ofRule:
            kind: function_declaration
      ```
      
      ### `range`
      
      Match by character range. Useful for tooling that pinpoints a known location.
      
      ```yaml
      rule:
        range:
          start: { line: 0, column: 0 }
          end:   { line: 0, column: 11 }
      ```
      
      ---
      
      ## Relational rules — match by relation to other nodes
      
      All four take a sub-rule object plus optional `stopBy` and (for `inside`/`has`) `field`.
      
      ### `inside` — target is inside parent/ancestor matching sub-rule
      
      ```yaml
      rule:
        pattern: this.$PROP
        inside:
          kind: class_body
          stopBy: end                # walk up to file root, default: neighbor
      ```
      
      ### `has` — target has child/descendant matching sub-rule
      
      ```yaml
      rule:
        kind: function_declaration
        has:
          kind: throw_statement
          stopBy: end
      ```
      
      ### `precedes` — target appears before sibling matching sub-rule
      
      ```yaml
      rule:
        kind: import_statement
        precedes:
          kind: function_declaration
      ```
      
      ### `follows` — target appears after sibling matching sub-rule
      
      ```yaml
      rule:
        pattern: super($$$)
        follows:
          pattern: $X = $Y
      ```
      
      ### `stopBy`
      
      | Value | Behavior |
      |---|---|
      | `"neighbor"` (default) | Stop at immediate parent/child/sibling. |
      | `"end"` | Walk all the way to root / leaf / sequence boundary. |
      | Rule object | Stop when sub-rule matches (inclusive). |
      
      ### `field`
      
      Specify the semantic role of the target inside its parent (e.g. `name`, `body`, `value`, `key`).
      
      ```yaml
      rule:
        kind: pair
        has:
          field: key
          regex: '^password$'
      ```
      
      ---
      
      ## Composite rules — combine sub-rules
      
      | Rule | Meaning |
      |---|---|
      | `all` | All sub-rules must match the same target node. Metavariables from all sub-rules merge. |
      | `any` | At least one sub-rule must match. Only metavars from the matched branch survive. |
      | `not` | Inverse: target must NOT match the sub-rule. |
      | `matches` | Reference a utility rule by id. |
      
      ```yaml
      rule:
        all:
          - kind: call_expression
          - pattern: $FN($$$ARGS)
          - inside:
              kind: function_declaration
              stopBy: end
      
      rule:
        any:
          - pattern: console.log($X)
          - pattern: console.warn($X)
          - pattern: console.error($X)
      
      rule:
        all:
          - pattern: $E.unwrap()
          - not:
              inside:
                kind: function_item
                has:
                  kind: result_type
                  stopBy: end
      
      rule:
        matches: is-react-component
      ```
      
      > Composites apply to a **single** target. To express "node X has BOTH a number child AND a string child," use two relational rules at the top level, not `all` inside `has`. See `references/pitfalls.md` §10.
      
      ---
      
      ## Implicit `all` — multiple rule fields
      
      A rule object with multiple fields is treated as an implicit `all`:
      
      ```yaml
      # These two are equivalent
      rule:
        pattern: this.$PROP
        inside: { kind: class_body }
      
      rule:
        all:
          - pattern: this.$PROP
          - inside: { kind: class_body }
      ```
      
      Use the explicit `all` array when capture order matters (rare, but possible with downstream `transform`).
      
      ---
      
      ## `constraints` — post-match metavariable filtering
      
      After the main `rule` matches, additional checks on captured single metavariables:
      
      ```yaml
      rule:
        pattern: function $NAME($$$P) { $$$B }
      
      constraints:
        NAME:
          regex: '^[a-z][a-zA-Z0-9]*$'   # camelCase only
          not:
            regex: '^_'                   # not starting with _
      ```
      
      Constraints **only apply to single metavars** (`$VAR`), not multi (`$$$VAR`).
      
      ---
      
      ## `utils` — local reusable sub-rules
      
      ```yaml
      utils:
        is-literal:
          any:
            - kind: number
            - kind: string
            - kind: 'true'
            - kind: 'false'
      
      rule:
        all:
          - pattern: $X = $Y
          - has:
              matches: is-literal      # references utils.is-literal
      ```
      
      For utils accessible across multiple rule files, use `utilDirs` in `sgconfig.yml` and put each util in its own YAML file with `id` and `language`.
      
      ---
      
      ## `transform` — manipulate captures before `fix`
      
      Operations: `replace`, `substring`, `convert`, `rewrite`.
      
      ### `replace` — regex search/replace on a captured string
      
      ```yaml
      rule:
        pattern: $OLD_FN($$$A)
      constraints:
        OLD_FN:
          regex: '^debug_'
      transform:
        NEW_FN:
          replace:
            source: $OLD_FN
            replace: '^debug_'
            by: 'release_'
      fix: $NEW_FN($$$A)
      ```
      
      ### `substring` — character slicing (negative indices supported)
      
      ```yaml
      transform:
        INNER:
          substring:
            source: $WRAPPED
            startChar: 1
            endChar: -1
      ```
      
      ### `convert` — case conversion
      
      ```yaml
      transform:
        KEBAB:
          convert:
            source: $CAMEL
            toCase: kebabCase   # camelCase | snakeCase | kebabCase | pascalCase | upperCase | lowerCase | capitalize
            separatedBy: [underscore]   # optional: dash | dot | space | slash | underscore | caseChange
      ```
      
      ### `rewrite` — apply other rewriter rules (experimental)
      
      ```yaml
      rewriters:
        - id: stringify
          rule: { pattern: "'' + $A" }
          fix: "String($A)"
      
      rule:
        pattern: stringify-all($EXPR)
      transform:
        REWRITTEN:
          rewrite:
            source: $EXPR
            rewriters: [stringify]
            joinBy: "\n"
      fix: $REWRITTEN
      ```
      
      ### Transforms can chain
      
      Later transforms can reference variables produced by earlier ones:
      
      ```yaml
      transform:
        KEBABED:
          convert: { source: $X, toCase: kebabCase }
        PREFIXED:
          replace:
            source: $KEBABED
            replace: '^'
            by: 'css-'
      fix: $PREFIXED
      ```
      
      ---
      
      ## `fix` — auto-rewrite
      
      ### String form
      
      ```yaml
      fix: logger.log($$$ARGS)
      
      # Empty string deletes the match
      fix: ""
      ```
      
      ### FixConfig form (for list-item deletion that needs to expand the range)
      
      When deleting one item from a comma-separated list, you also need to remove the trailing comma. Use `expandEnd`:
      
      ```yaml
      rule:
        kind: pair
        has:
          field: key
          regex: '^password$'
      
      fix:
        template: ''
        expandEnd:
          regex: ','
      ```
      
      `expandStart` and `expandEnd` accept `regex` matching characters that should be absorbed into the rewrite range.
      
      ---
      
      ## `rewriters` — sub-rule library for `rewrite` transform
      
      Top-level field defining one or more named rewriters:
      
      ```yaml
      rewriters:
        - id: nullable-to-optional
          rule: { pattern: $X | null }
          fix: '$X | undefined'
      
        - id: stringify
          rule: { pattern: "'' + $A" }
          fix: 'String($A)'
      ```
      
      Used inside `transform` via the `rewrite` operation (see above).
      
      ---
      
      ## `labels` — custom diagnostic highlighting
      
      ```yaml
      rule:
        pattern: $FN($$$ARGS)
      
      labels:
        FN:
          style: primary
          message: "this function shouldn't be called"
        ARGS:
          style: secondary
          message: "with these arguments"
      ```
      
      Editor extensions render the diagnostic with these labels. Defaults are usually fine.
      
      ---
      
      ## `files` and `ignores` — file selection per-rule
      
      ```yaml
      files:
        - 'src/**/*.ts'
        - 'lib/**/*.ts'
      
      ignores:
        - 'src/**/*.test.ts'
        - '**/__generated__/**'
      ```
      
      If omitted, the rule runs on every file matching its `language`. These globs override `sgconfig.yml`-level globs for this rule only.
      
      Object form (rare):
      
      ```yaml
      files:
        - pattern: 'src/**/*.ts'
          case_sensitive: true
      ```
      
      ---
      
      ## See also
      
      - `references/recipes.md` — copy-paste rules by language.
      - `references/cli.md` — `sg scan`, `sg test`.
      - `references/sgconfig.md` — project-level configuration.
      - Official rule reference: <https://ast-grep.github.io/reference/rule.html>
      - Cheat sheets: <https://ast-grep.github.io/cheatsheet/rule.html>, <https://ast-grep.github.io/cheatsheet/yaml.html>
      
  • scripts
    • ast_grep_helper.py 26.3 KB
      #!/usr/bin/env python3
      """ast-grep-helper: a thin LLM-friendly wrapper around `sg` (ast-grep).
      
      Single-file Python 3 stdlib. No deps. Works on macOS, Linux, Windows, WSL.
      
      WHAT IT ADDS over plain `sg`:
        1. Binary auto-resolution: cached -> @ast-grep/cli -> PATH -> Homebrew -> error with install hint
        2. Pattern hint validation: detects regex misuse (\\w, .*, |, [a-z]) and language-specific
           mistakes (Python trailing colon, JS/Go/Rust missing function body) BEFORE calling sg
        3. Two-pass replace: ast-grep silently ignores --update-all when --json is set, so we run
           a JSON pass to collect matches, then a separate --update-all pass to mutate files
        4. Stable JSON output: parses sg --json=compact, salvages truncated output, normalizes shape
        5. Cross-OS path handling: works the same on POSIX and Windows (uses pathlib + shutil)
      
      USAGE
          ast_grep_helper.py search PATTERN [PATH...] [--lang LANG] [--globs GLOB ...] [-C N]
          ast_grep_helper.py replace PATTERN REWRITE [PATH...] [--lang LANG] [--apply] [--globs GLOB ...]
          ast_grep_helper.py scan RULE_FILE [PATH...] [--apply] [--report-style STYLE]
          ast_grep_helper.py test [-c CONFIG] [-t TEST_DIR] [-U]
          ast_grep_helper.py new {project,rule,test,util} [NAME] [--lang LANG]
          ast_grep_helper.py langs                # list 25 supported languages
          ast_grep_helper.py doctor               # check binary availability + version
          ast_grep_helper.py install              # delegate to ../install.sh / install.ps1
          ast_grep_helper.py validate PATTERN [--lang LANG]   # offline pattern hint check only
          ast_grep_helper.py --version
          ast_grep_helper.py --help
      
      EXAMPLES
          # Find all console.log calls in TypeScript
          ast_grep_helper.py search 'console.log($MSG)' --lang ts src/
      
          # Migrate console.log -> logger.info (dry-run preview)
          ast_grep_helper.py replace 'console.log($MSG)' 'logger.info($MSG)' --lang ts src/
      
          # Apply the same replacement
          ast_grep_helper.py replace 'console.log($MSG)' 'logger.info($MSG)' --lang ts src/ --apply
      
          # Validate a pattern offline (no sg call, no filesystem access)
          ast_grep_helper.py validate '\\w+' --lang ts
          # -> exit 2, hint: "regex \\w not supported. Use $VAR for identifiers."
      
      EXIT CODES
          0  Success (matches found OR replacement applied OR validation passed)
          1  Argument error
          2  Pattern hint failure (regex misuse, missing body, etc.) - call would have failed
          3  ast-grep binary not found and auto-install declined
          4  ast-grep call failed (returned non-zero, with stderr forwarded)
          5  Timeout (5 minutes per call by default)
      """
      
      from __future__ import annotations
      
      import argparse
      import json
      import os
      import platform
      import re
      import shutil
      import subprocess
      import sys
      from pathlib import Path
      from typing import Optional
      
      VERSION = "0.1.0"
      
      # 25 CLI languages supported by ast-grep, with their aliases (mirrors official docs)
      LANGUAGES: dict[str, list[str]] = {
          "bash": [".bash", ".sh", ".zsh"],
          "c": [".c", ".h"],
          "cpp": [".cc", ".cpp", ".cxx", ".hpp", ".hxx"],
          "csharp": [".cs"],
          "css": [".css"],
          "elixir": [".ex", ".exs"],
          "go": [".go"],
          "haskell": [".hs"],
          "html": [".html", ".htm"],
          "java": [".java"],
          "javascript": [".js", ".jsx", ".cjs", ".mjs"],
          "json": [".json"],
          "kotlin": [".kt", ".kts"],
          "lua": [".lua"],
          "nix": [".nix"],
          "php": [".php"],
          "python": [".py", ".pyi"],
          "ruby": [".rb"],
          "rust": [".rs"],
          "scala": [".scala"],
          "solidity": [".sol"],
          "swift": [".swift"],
          "typescript": [".ts", ".cts", ".mts"],
          "tsx": [".tsx"],
          "yaml": [".yml", ".yaml"],
      }
      
      # Aliases that ast-grep CLI accepts; we normalize to the canonical name.
      LANG_ALIASES: dict[str, str] = {
          "js": "javascript", "jsx": "javascript",
          "ts": "typescript",
          "py": "python", "py3": "python",
          "rb": "ruby",
          "rs": "rust",
          "kt": "kotlin",
          "ex": "elixir",
          "hs": "haskell",
          "sh": "bash", "zsh": "bash",
          "cc": "cpp", "c++": "cpp", "cxx": "cpp",
          "cs": "csharp",
          "yml": "yaml",
          "sol": "solidity",
          "golang": "go",
      }
      
      # Default search timeout (5 min). ast-grep calls can be slow on huge repos.
      DEFAULT_TIMEOUT_S = 300
      
      
      # ---------- logging ----------
      
      def trace(msg: str) -> None:
          """Print a trace line to stderr (suppressible via --quiet, default off)."""
          if not _QUIET:
              print(f"[ast-grep-helper] {msg}", file=sys.stderr, flush=True)
      
      
      def err(msg: str) -> None:
          """Print an error line to stderr (always shown)."""
          print(f"[ast-grep-helper] error: {msg}", file=sys.stderr, flush=True)
      
      
      _QUIET = False
      
      
      # ---------- binary resolution ----------
      
      def script_dir() -> Path:
          return Path(__file__).resolve().parent
      
      
      def skill_root() -> Path:
          return script_dir().parent
      
      
      def cached_binary() -> Optional[Path]:
          """Look in <skill_root>/bin/ for a previously downloaded binary."""
          binname = "sg.exe" if os.name == "nt" else "sg"
          altname = "ast-grep.exe" if os.name == "nt" else "ast-grep"
          for name in (binname, altname):
              p = skill_root() / "bin" / name
              if p.is_file() and os.access(p, os.X_OK):
                  return p
          return None
      
      
      def npm_binary() -> Optional[Path]:
          """If @ast-grep/cli is installed globally via npm, find its binary."""
          # `sg` shipped by @ast-grep/cli is on PATH when npm prefix bin is on PATH.
          # We rely on shutil.which for that case.
          return None  # handled by which_binary
      
      
      def which_binary() -> Optional[Path]:
          """Use shutil.which to find sg or ast-grep on PATH.
      
          On Linux, plain `sg` collides with the setgroups command from util-linux
          (sometimes called via /usr/bin/sg) which has flag --version that returns
          non-zero, so we prefer `ast-grep` when both are on PATH and the `sg` we find
          is the wrong one.
          """
          for name in ("ast-grep", "sg"):
              found = shutil.which(name)
              if found:
                  p = Path(found)
                  # On Linux, double-check by trying --version. The util-linux `sg`
                  # rejects --version, while ast-grep prints "ast-grep <version>".
                  if name == "sg" and platform.system() == "Linux":
                      try:
                          out = subprocess.run(
                              [str(p), "--version"],
                              capture_output=True,
                              text=True,
                              timeout=5,
                          )
                          if out.returncode != 0 or "ast-grep" not in (out.stdout + out.stderr).lower():
                              continue
                      except Exception:
                          continue
                  return p
          return None
      
      
      def homebrew_binary() -> Optional[Path]:
          """Common Homebrew install paths."""
          candidates = [
              Path("/opt/homebrew/bin/ast-grep"),
              Path("/opt/homebrew/bin/sg"),
              Path("/usr/local/bin/ast-grep"),
              Path("/usr/local/bin/sg"),
          ]
          for p in candidates:
              if p.is_file() and os.access(p, os.X_OK):
                  return p
          return None
      
      
      # --- OMO runtime resolution (vendored patch) ---
      
      def omo_env_binary() -> Optional[Path]:
          raw_path = os.environ.get("OMO_AST_GREP_SG_PATH")
          if not raw_path:
              return None
          path = Path(raw_path).expanduser()
          if path.is_file() and os.access(path, os.X_OK):
              return path
          return None
      
      
      def omo_runtime_slug() -> str:
          if sys.platform.startswith("win"):
              os_slug = "win32"
          elif sys.platform == "darwin":
              os_slug = "darwin"
          else:
              os_slug = "linux"
      
          machine = platform.machine().lower()
          arch_slug = "arm64" if machine in {"arm64", "aarch64"} else "x64"
          return f"{os_slug}-{arch_slug}"
      
      
      def omo_runtime_binary() -> Optional[Path]:
          binary_name = "sg.exe" if sys.platform.startswith("win") else "sg"
          slug = omo_runtime_slug()
          candidates: list[Path] = []
      
          codex_home = os.environ.get("CODEX_HOME")
          if codex_home:
              candidates.append(Path(codex_home) / "runtime" / "ast-grep" / slug / binary_name)
          candidates.append(Path.home() / ".omo" / "runtime" / "ast-grep" / slug / binary_name)
      
          for path in candidates:
              if path.is_file() and os.access(path, os.X_OK):
                  return path
          return None
      
      
      def resolve_binary() -> Optional[Path]:
          """Resolve the ast-grep binary in priority order.
      
          1. OMO_AST_GREP_SG_PATH override
          2. OMO runtime dirs
          3. Cached binary in <skill>/bin/
          4. PATH (via shutil.which)
          5. Homebrew default paths
          """
          for fn in (omo_env_binary, omo_runtime_binary, cached_binary, which_binary, homebrew_binary):
              result = fn()
              if result:
                  return result
          return None
      
      
      def require_binary() -> Path:
          """Resolve binary, or print an actionable install hint and exit 3."""
          p = resolve_binary()
          if p:
              return p
          err("ast-grep binary not found.")
          err("")
          err("Install via one of:")
          err(f"  bash {skill_root()}/install.sh           # POSIX (auto-detects best method)")
          err(f"  pwsh {skill_root()}/install.ps1          # Windows")
          err("")
          err("Or manually:")
          err("  brew install ast-grep                      # macOS / linuxbrew")
          err("  npm install -g @ast-grep/cli               # any OS with Node")
          err("  cargo install ast-grep --locked            # any OS with Rust")
          err("  pip install ast-grep-cli                   # any OS with Python")
          err("  scoop install main/ast-grep                # Windows / Scoop")
          err("")
          err("See references/install.md for the full table.")
          sys.exit(3)
      
      
      # ---------- pattern hint validation ----------
      
      # Regex anti-patterns that ast-grep does NOT support but LLMs frequently emit.
      # Each tuple: (regex_to_detect, hint_message)
      REGEX_ANTIPATTERNS: list[tuple[re.Pattern[str], str]] = [
          (re.compile(r"\\w|\\d|\\s|\\b"),
           "Backslash escapes (\\w, \\d, \\s, \\b) are regex syntax, not ast-grep. "
           "Use $VAR to capture any identifier, or switch to grep for text patterns."),
          (re.compile(r"(?<!\$)\.\*|(?<!\$)\.\+"),
           "'.*' and '.+' are regex wildcards, not ast-grep. "
           "Use $$$ between AST fragments to match many nodes, or $VAR for one node."),
          (re.compile(r"\[[a-zA-Z0-9-]+\]"),
           "Character classes like '[a-z]' are regex syntax. "
           "ast-grep has no AST equivalent - use grep for character-level patterns."),
      ]
      
      
      def find_alternation(pattern: str) -> bool:
          """Detect a literal '|' that is not inside a string/template literal.
      
          Heuristic - mark as alternation if `|` appears outside obvious string contexts.
          """
          # Strip simple string contents to reduce false positives in patterns like
          # `'a|b'` or `"x|y"`. This is a heuristic, not a parser.
          stripped = re.sub(r"'[^']*'|\"[^\"]*\"|`[^`]*`", "", pattern)
          # Require word chars on both sides to avoid catching bitwise or ||
          return bool(re.search(r"\w\s*\|\s*\w", stripped)) and "||" not in stripped
      
      
      def lang_specific_hints(pattern: str, lang: Optional[str]) -> list[str]:
          """Return a list of hints for language-specific common mistakes."""
          if not lang:
              return []
          canonical = LANG_ALIASES.get(lang.lower(), lang.lower())
          hints: list[str] = []
      
          if canonical == "python":
              # def foo($$$):  <-- trailing colon breaks the parse
              if re.search(r"^\s*(def|class)\s+\$?\w+[^:]*:\s*$", pattern, re.MULTILINE):
                  hints.append(
                      "Python pattern has trailing ':'. ast-grep parses pattern as a complete "
                      "definition - drop the trailing colon. Try: 'def $FUNC($$$)' or 'class $C($$$)'."
                  )
      
          if canonical in ("javascript", "typescript", "tsx"):
              if re.search(r"^\s*(async\s+)?function\s+\$?\w+\s*$", pattern):
                  hints.append(
                      "JS/TS function pattern is incomplete. Add params and body: "
                      "'function $NAME($$$) { $$$ }'."
                  )
      
          if canonical == "go":
              if re.search(r"^\s*func\s+\$?\w+\s*$", pattern):
                  hints.append(
                      "Go function pattern is incomplete. Add params and body: "
                      "'func $NAME($$$) { $$$ }'."
                  )
      
          if canonical == "rust":
              if re.search(r"^\s*fn\s+\$?\w+\s*$", pattern):
                  hints.append(
                      "Rust fn pattern is incomplete. Add params, return type, and body: "
                      "'fn $NAME($$$) -> $RET { $$$ }' (or '-> ()' if returning unit)."
                  )
      
          return hints
      
      
      def validate_pattern(pattern: str, lang: Optional[str]) -> list[str]:
          """Return a list of hints. Empty list = pattern looks plausible."""
          hints: list[str] = []
      
          for rx, msg in REGEX_ANTIPATTERNS:
              if rx.search(pattern):
                  hints.append(msg)
      
          if find_alternation(pattern):
              hints.append(
                  "Literal '|' alternation is regex syntax, not ast-grep. "
                  "Run two separate ast-grep calls (one per alternative), or switch to grep."
              )
      
          hints.extend(lang_specific_hints(pattern, lang))
      
          return hints
      
      
      def normalize_lang(lang: Optional[str]) -> Optional[str]:
          if not lang:
              return None
          canonical = LANG_ALIASES.get(lang.lower(), lang.lower())
          if canonical not in LANGUAGES:
              err(f"unknown language '{lang}'. Run 'ast_grep_helper.py langs' for the full list.")
              sys.exit(1)
          return canonical
      
      
      # ---------- subprocess helpers ----------
      
      def run_sg(
          binary: Path,
          args: list[str],
          *,
          timeout: int = DEFAULT_TIMEOUT_S,
          capture: bool = True,
      ) -> subprocess.CompletedProcess[str]:
          """Spawn `sg <args>` with a hard timeout. Capture stdout/stderr by default."""
          cmd = [str(binary), *args]
          trace(f"exec: {' '.join(cmd)}")
          try:
              return subprocess.run(
                  cmd,
                  capture_output=capture,
                  text=True,
                  timeout=timeout,
              )
          except subprocess.TimeoutExpired:
              err(f"ast-grep call timed out after {timeout}s")
              sys.exit(5)
      
      
      # ---------- subcommands ----------
      
      def cmd_search(args: argparse.Namespace) -> int:
          pattern: str = args.pattern
          lang = normalize_lang(args.lang)
          hints = validate_pattern(pattern, lang)
          if hints:
              err("pattern looks invalid for ast-grep:")
              for h in hints:
                  err(f"  - {h}")
              if not args.force:
                  err("(pass --force to call ast-grep anyway)")
                  return 2
      
          binary = require_binary()
          sg_args = ["run", "-p", pattern, "--json=compact"]
          if lang:
              sg_args.extend(["--lang", lang])
          if args.context:
              sg_args.extend(["-C", str(args.context)])
          for g in args.globs or []:
              sg_args.extend(["--globs", g])
          sg_args.extend(args.paths or ["."])
      
          proc = run_sg(binary, sg_args)
          if proc.returncode not in (0, 1):  # 0=match, 1=no match - both fine
              sys.stderr.write(proc.stderr or "")
              return 4
      
          matches = parse_compact_json(proc.stdout)
          if args.json_out:
              json.dump(matches, sys.stdout, indent=2)
              print()
          else:
              format_matches(matches)
      
          if not matches:
              # Re-run pattern hints in case empty result was caused by something subtle.
              # Already done above; here we just give a generic suggestion.
              trace("no matches. If you expected matches, double-check --lang and the pattern shape.")
          return 0
      
      
      def cmd_replace(args: argparse.Namespace) -> int:
          pattern: str = args.pattern
          rewrite: str = args.rewrite
          lang = normalize_lang(args.lang)
      
          pattern_hints = validate_pattern(pattern, lang)
          rewrite_hints = validate_pattern(rewrite, lang)
          all_hints = []
          if pattern_hints:
              all_hints.append("pattern issues:")
              all_hints.extend(f"  - {h}" for h in pattern_hints)
          if rewrite_hints:
              all_hints.append("rewrite issues:")
              all_hints.extend(f"  - {h}" for h in rewrite_hints)
          if all_hints:
              err("input looks invalid for ast-grep:")
              for line in all_hints:
                  err(line)
              if not args.force:
                  err("(pass --force to call ast-grep anyway)")
                  return 2
      
          binary = require_binary()
      
          # Pass 1: dry-run via JSON to collect what would change.
          sg_args1 = ["run", "-p", pattern, "-r", rewrite, "--json=compact"]
          if lang:
              sg_args1.extend(["--lang", lang])
          for g in args.globs or []:
              sg_args1.extend(["--globs", g])
          sg_args1.extend(args.paths or ["."])
      
          proc1 = run_sg(binary, sg_args1)
          if proc1.returncode not in (0, 1):
              sys.stderr.write(proc1.stderr or "")
              return 4
      
          matches = parse_compact_json(proc1.stdout)
          if not matches:
              trace("no matches; nothing to replace.")
              return 0
      
          if not args.apply:
              # Show the dry-run preview and exit.
              print(f"DRY-RUN: would rewrite {len(matches)} match(es) across "
                    f"{len({m['file'] for m in matches})} file(s):")
              format_matches(matches, show_replacement=True)
              print()
              print("Re-run with --apply to mutate files.")
              return 0
      
          # Pass 2: apply with --update-all (no --json; sg silently ignores --update-all
          # when --json is present, so we MUST run a second invocation).
          sg_args2 = ["run", "-p", pattern, "-r", rewrite, "--update-all"]
          if lang:
              sg_args2.extend(["--lang", lang])
          for g in args.globs or []:
              sg_args2.extend(["--globs", g])
          sg_args2.extend(args.paths or ["."])
      
          proc2 = run_sg(binary, sg_args2)
          if proc2.returncode not in (0, 1):
              sys.stderr.write(proc2.stderr or "")
              return 4
      
          print(f"APPLIED: rewrote {len(matches)} match(es) across "
                f"{len({m['file'] for m in matches})} file(s).")
          return 0
      
      
      def cmd_scan(args: argparse.Namespace) -> int:
          binary = require_binary()
          sg_args = ["scan"]
          if args.config:
              sg_args.extend(["-c", args.config])
          if args.rule:
              sg_args.extend(["-r", args.rule])
          if args.inline_rules:
              sg_args.extend(["--inline-rules", args.inline_rules])
          if args.report_style:
              sg_args.extend(["--report-style", args.report_style])
          if args.apply:
              sg_args.append("-U")
          sg_args.extend(args.paths or [])
      
          proc = run_sg(binary, sg_args, capture=False)
          return proc.returncode
      
      
      def cmd_test(args: argparse.Namespace) -> int:
          binary = require_binary()
          sg_args = ["test"]
          if args.config:
              sg_args.extend(["-c", args.config])
          if args.test_dir:
              sg_args.extend(["-t", args.test_dir])
          if args.update:
              sg_args.append("-U")
          proc = run_sg(binary, sg_args, capture=False)
          return proc.returncode
      
      
      def cmd_new(args: argparse.Namespace) -> int:
          binary = require_binary()
          sg_args = ["new", args.what]
          if args.name:
              sg_args.append(args.name)
          if args.lang:
              sg_args.extend(["--lang", args.lang])
          if args.yes:
              sg_args.append("--yes")
          proc = run_sg(binary, sg_args, capture=False)
          return proc.returncode
      
      
      def cmd_langs(_args: argparse.Namespace) -> int:
          print("ast-grep supported languages (25):")
          for lang, exts in sorted(LANGUAGES.items()):
              print(f"  {lang:<12} {' '.join(exts)}")
          print()
          print("Aliases accepted by --lang:")
          for alias, canonical in sorted(LANG_ALIASES.items()):
              print(f"  {alias:<8} -> {canonical}")
          return 0
      
      
      def cmd_doctor(_args: argparse.Namespace) -> int:
          print(f"ast-grep-helper v{VERSION}")
          print(f"Python:   {sys.version.split()[0]}")
          print(f"Platform: {platform.system()} {platform.release()} ({platform.machine()})")
          print(f"Skill:    {skill_root()}")
          print()
          binary = resolve_binary()
          if not binary:
              print("ast-grep binary: NOT FOUND")
              print("  -> run: bash install.sh   (POSIX)  or   pwsh install.ps1   (Windows)")
              return 1
          print(f"ast-grep binary: {binary}")
          proc = run_sg(binary, ["--version"], timeout=5)
          if proc.returncode == 0:
              print(f"  version: {proc.stdout.strip()}")
          else:
              print(f"  --version returned exit {proc.returncode}")
              print(f"  stderr: {proc.stderr.strip()}")
              return 1
          return 0
      
      
      def cmd_install(_args: argparse.Namespace) -> int:
          """Delegate to install.sh / install.ps1 in the skill root."""
          if os.name == "nt":
              installer = skill_root() / "install.ps1"
              cmd = ["pwsh", "-File", str(installer)]
          else:
              installer = skill_root() / "install.sh"
              cmd = ["bash", str(installer)]
          if not installer.is_file():
              err(f"installer not found: {installer}")
              return 1
          trace(f"running installer: {' '.join(cmd)}")
          return subprocess.run(cmd).returncode
      
      
      def cmd_validate(args: argparse.Namespace) -> int:
          """Offline pattern validation. No sg call. Useful for CI / quick checks."""
          lang = normalize_lang(args.lang) if args.lang else None
          hints = validate_pattern(args.pattern, lang)
          if hints:
              for h in hints:
                  print(f"hint: {h}")
              return 2
          print("pattern looks plausible for ast-grep.")
          return 0
      
      
      # ---------- output formatting ----------
      
      def parse_compact_json(text: str) -> list[dict]:
          """Parse `sg --json=compact` output. Salvages partial output when truncated."""
          if not text.strip():
              return []
          try:
              data = json.loads(text)
              if isinstance(data, list):
                  return data
              return []
          except json.JSONDecodeError:
              # Try line-by-line salvage for truncated output.
              results = []
              for line in text.splitlines():
                  line = line.strip().rstrip(",")
                  if not line.startswith("{"):
                      continue
                  try:
                      obj = json.loads(line)
                      if isinstance(obj, dict):
                          results.append(obj)
                  except json.JSONDecodeError:
                      continue
              return results
      
      
      def format_matches(matches: list[dict], *, show_replacement: bool = False) -> None:
          if not matches:
              print("(no matches)")
              return
          by_file: dict[str, list[dict]] = {}
          for m in matches:
              by_file.setdefault(m.get("file", "?"), []).append(m)
          for path, items in sorted(by_file.items()):
              print(f"{path} ({len(items)} match{'es' if len(items) != 1 else ''})")
              for m in items:
                  r = m.get("range", {})
                  start = r.get("start", {})
                  line = start.get("line", "?")
                  col = start.get("column", "?")
                  text = (m.get("text") or "").splitlines()
                  preview = text[0] if text else ""
                  print(f"  {path}:{line}:{col}  {preview}")
                  if show_replacement and "replacement" in m:
                      rep = (m.get("replacement") or "").splitlines()
                      rep_preview = rep[0] if rep else ""
                      print(f"    -> {rep_preview}")
      
      
      # ---------- argparse ----------
      
      def build_parser() -> argparse.ArgumentParser:
          p = argparse.ArgumentParser(
              prog="ast-grep-helper",
              description="LLM-friendly wrapper around ast-grep (sg).",
              formatter_class=argparse.RawDescriptionHelpFormatter,
          )
          p.add_argument("--version", action="version", version=f"ast-grep-helper {VERSION}")
          p.add_argument("--quiet", "-q", action="store_true", help="Suppress trace lines on stderr.")
          sub = p.add_subparsers(dest="cmd", required=True, metavar="COMMAND")
      
          s = sub.add_parser("search", help="Search code by AST pattern.")
          s.add_argument("pattern", help="AST pattern, e.g. 'console.log($MSG)'")
          s.add_argument("paths", nargs="*", help="Paths to search (default: '.')")
          s.add_argument("--lang", "-l", help="Language (e.g. ts, py, go, rust). See: langs subcommand.")
          s.add_argument("--globs", action="append", help="Include/exclude glob (repeat; prefix '!' to exclude).")
          s.add_argument("--context", "-C", type=int, help="Lines of context around each match.")
          s.add_argument("--json-out", action="store_true", help="Emit raw JSON instead of human format.")
          s.add_argument("--force", action="store_true", help="Skip pattern hint validation.")
          s.set_defaults(func=cmd_search)
      
          r = sub.add_parser("replace", help="Rewrite code by AST pattern (dry-run by default).")
          r.add_argument("pattern", help="AST pattern.")
          r.add_argument("rewrite", help="Replacement pattern (can reuse $VAR from pattern).")
          r.add_argument("paths", nargs="*", help="Paths (default: '.')")
          r.add_argument("--lang", "-l", help="Language.")
          r.add_argument("--globs", action="append", help="Include/exclude glob.")
          r.add_argument("--apply", action="store_true", help="Mutate files (default: dry-run preview).")
          r.add_argument("--force", action="store_true", help="Skip pattern hint validation.")
          r.set_defaults(func=cmd_replace)
      
          sc = sub.add_parser("scan", help="Run YAML-rule-based scan.")
          sc.add_argument("paths", nargs="*", help="Paths to scan.")
          sc.add_argument("--config", "-c", help="Path to sgconfig.yml.")
          sc.add_argument("--rule", "-r", help="Single rule file.")
          sc.add_argument("--inline-rules", help="Inline YAML rule string.")
          sc.add_argument("--report-style", choices=["rich", "medium", "short"], help="Report style.")
          sc.add_argument("--apply", "-U", action="store_true", help="Apply fixes (default: report only).")
          sc.set_defaults(func=cmd_scan)
      
          t = sub.add_parser("test", help="Run ast-grep snapshot tests.")
          t.add_argument("--config", "-c", help="Path to sgconfig.yml.")
          t.add_argument("--test-dir", "-t", help="Test directory.")
          t.add_argument("--update", "-U", action="store_true", help="Update snapshots.")
          t.set_defaults(func=cmd_test)
      
          n = sub.add_parser("new", help="Scaffold a new project / rule / test / util.")
          n.add_argument("what", choices=["project", "rule", "test", "util"], help="What to create.")
          n.add_argument("name", nargs="?", help="Name of the artifact.")
          n.add_argument("--lang", "-l", help="Language.")
          n.add_argument("--yes", "-y", action="store_true", help="Accept defaults.")
          n.set_defaults(func=cmd_new)
      
          sub.add_parser("langs", help="List supported languages.").set_defaults(func=cmd_langs)
          sub.add_parser("doctor", help="Check ast-grep binary availability.").set_defaults(func=cmd_doctor)
          sub.add_parser("install", help="Run the install script for this OS.").set_defaults(func=cmd_install)
      
          v = sub.add_parser("validate", help="Validate a pattern offline (pattern hint check only).")
          v.add_argument("pattern", help="AST pattern.")
          v.add_argument("--lang", "-l", help="Language for language-specific hints.")
          v.set_defaults(func=cmd_validate)
      
          return p
      
      
      def main(argv: Optional[list[str]] = None) -> int:
          global _QUIET
          parser = build_parser()
          args = parser.parse_args(argv)
          _QUIET = bool(getattr(args, "quiet", False))
          return args.func(args)
      
      
      if __name__ == "__main__":
          sys.exit(main())
      
  • tests
    • smoke.ps1 5.6 KB · in bundle
    • smoke.sh 7.6 KB
      #!/usr/bin/env bash
      # Smoke test for the ast-grep skill on POSIX (macOS / Linux / WSL / Git Bash).
      #
      # Tests:
      #   1. helper --version
      #   2. helper langs                              (lists 25 languages)
      #   3. helper validate '\w+' --lang ts           (must exit 2 with hint)
      #   4. helper validate 'console.log($MSG)'       (must exit 0 plausible)
      #   5. helper validate 'def $F($$$):' --lang py  (must exit 2 - trailing colon)
      #   6. helper validate 'function $N' --lang ts   (must exit 2 - incomplete)
      #   7. helper validate 'foo|bar' --lang ts       (must exit 2 - alternation)
      #   8. helper doctor                             (informational; tolerates no-binary)
      #   9. helper search w/o binary => exit 3 with install hint
      #  10. install.sh --help                         (parses)
      #  11. SKILL.md frontmatter shape                (canonical)
      #
      # Runs against the helper using ONLY stdlib python3 - no ast-grep needed.
      
      set -euo pipefail
      
      SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
      SKILL_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
      HELPER="python3 $SKILL_DIR/scripts/ast_grep_helper.py"
      
      OUTPUT_DIR="$(mktemp -d -t ast-grep-skill-smoke-XXXXXX)"
      trap 'rm -rf "$OUTPUT_DIR"' EXIT
      
      fail() { echo "FAIL: $*" >&2; exit 1; }
      pass() { echo "PASS: $*"; }
      
      omo_runtime_slug() {
        case "$(uname -s)" in
          Darwin) local os_slug="darwin" ;;
          MINGW*|MSYS*|CYGWIN*) local os_slug="win32" ;;
          *) local os_slug="linux" ;;
        esac
      
        case "$(uname -m | tr '[:upper:]' '[:lower:]')" in
          arm64|aarch64) local arch_slug="arm64" ;;
          *) local arch_slug="x64" ;;
        esac
      
        printf '%s-%s' "$os_slug" "$arch_slug"
      }
      
      fake_sg() {
        local target="$1"
        mkdir -p "$(dirname "$target")"
        cat > "$target" <<'SH'
      #!/usr/bin/env bash
      if [ "${1:-}" = "--version" ]; then
        printf 'ast-grep 0.45.0 fake\n'
      else
        printf 'fake ast-grep\n'
      fi
      SH
        chmod +x "$target"
      }
      
      # 1. --version
      $HELPER --version | grep -q "ast-grep-helper" || fail "--version output missing"
      pass "--version"
      
      # 2. langs (must list 25)
      LANG_LINES=$($HELPER langs | grep -E '^  [a-z]' | wc -l | tr -d ' ')
      [ "$LANG_LINES" -ge 25 ] || fail "langs listed only $LANG_LINES (expected >=25)"
      pass "langs lists at least 25 languages"
      
      # 3. regex misuse: \w+
      set +e
      $HELPER validate '\w+' --lang ts > "$OUTPUT_DIR/v1.out" 2>&1
      RC=$?
      set -e
      [ $RC -eq 2 ] || fail "validate '\\w+' should exit 2, got $RC (output: $(cat $OUTPUT_DIR/v1.out))"
      grep -qi 'regex' "$OUTPUT_DIR/v1.out" || fail "validate '\\w+' should mention regex (output: $(cat $OUTPUT_DIR/v1.out))"
      pass "validate detects \\w regex misuse"
      
      # 4. valid pattern
      set +e
      $HELPER validate 'console.log($MSG)' --lang ts > "$OUTPUT_DIR/v2.out" 2>&1
      RC=$?
      set -e
      [ $RC -eq 0 ] || fail "validate 'console.log(\$MSG)' should exit 0, got $RC (output: $(cat $OUTPUT_DIR/v2.out))"
      pass "validate accepts plausible pattern"
      
      # 5. Python trailing colon
      set +e
      $HELPER validate 'def $F($$$):' --lang py > "$OUTPUT_DIR/v3.out" 2>&1
      RC=$?
      set -e
      [ $RC -eq 2 ] || fail "validate 'def \$F(\$\$\$):' should exit 2, got $RC"
      grep -qi 'colon\|trailing' "$OUTPUT_DIR/v3.out" || fail "validate should mention trailing colon"
      pass "validate detects Python trailing colon"
      
      # 6. Incomplete TS function
      set +e
      $HELPER validate 'function $N' --lang ts > "$OUTPUT_DIR/v4.out" 2>&1
      RC=$?
      set -e
      [ $RC -eq 2 ] || fail "validate 'function \$N' should exit 2, got $RC"
      grep -qi 'incomplete\|params\|body' "$OUTPUT_DIR/v4.out" || fail "validate should hint about params/body"
      pass "validate detects incomplete TS function"
      
      # 7. Alternation pipe
      set +e
      $HELPER validate 'foo|bar' --lang ts > "$OUTPUT_DIR/v5.out" 2>&1
      RC=$?
      set -e
      [ $RC -eq 2 ] || fail "validate 'foo|bar' should exit 2, got $RC"
      grep -qi 'alternation\|regex' "$OUTPUT_DIR/v5.out" || fail "validate should mention alternation"
      pass "validate detects literal | alternation"
      
      # 8. doctor (informational)
      $HELPER doctor > "$OUTPUT_DIR/doc.out" 2>&1 || true
      grep -q "ast-grep-helper" "$OUTPUT_DIR/doc.out" || fail "doctor missing helper version line"
      pass "doctor produces output"
      
      # Given OMO_AST_GREP_SG_PATH points at a fake executable.
      # When doctor resolves ast-grep, then it reports that exact path.
      FAKE_ENV_SG="$OUTPUT_DIR/fake-env/sg"
      fake_sg "$FAKE_ENV_SG"
      OMO_AST_GREP_SG_PATH="$FAKE_ENV_SG" $HELPER doctor > "$OUTPUT_DIR/omo-env.out" 2>&1
      grep -Fq "ast-grep binary: $FAKE_ENV_SG" "$OUTPUT_DIR/omo-env.out" || fail "OMO_AST_GREP_SG_PATH was not preferred: $(cat "$OUTPUT_DIR/omo-env.out")"
      pass "OMO_AST_GREP_SG_PATH resolves first"
      
      # Given HOME has an OMO runtime sg executable.
      # When doctor resolves ast-grep, then it reports the HOME runtime path.
      RUNTIME_HOME="$OUTPUT_DIR/home"
      RUNTIME_SLUG="$(omo_runtime_slug)"
      RUNTIME_BIN="sg"
      case "$RUNTIME_SLUG" in
        win32-*) RUNTIME_BIN="sg.exe" ;;
      esac
      FAKE_RUNTIME_SG="$RUNTIME_HOME/.omo/runtime/ast-grep/$RUNTIME_SLUG/$RUNTIME_BIN"
      fake_sg "$FAKE_RUNTIME_SG"
      HOME="$RUNTIME_HOME" CODEX_HOME= OMO_AST_GREP_SG_PATH= $HELPER doctor > "$OUTPUT_DIR/omo-runtime.out" 2>&1
      grep -Fq "ast-grep binary: $FAKE_RUNTIME_SG" "$OUTPUT_DIR/omo-runtime.out" || fail "OMO HOME runtime was not resolved: $(cat "$OUTPUT_DIR/omo-runtime.out")"
      pass "OMO HOME runtime resolves before standalone fallback"
      
      # 9. search w/o binary => either runs successfully (binary found) OR exits 3 with hint
      # The CI may or may not have ast-grep installed; both are valid.
      set +e
      $HELPER -q search 'foo()' --lang ts /nonexistent-path-xyzzy > "$OUTPUT_DIR/s1.out" 2>&1
      RC=$?
      set -e
      case "$RC" in
        0|1|4)
          # Binary present but no matches OR sg returned non-fatal error - both fine
          pass "search runs (rc=$RC, ast-grep available)"
          ;;
        3)
          grep -qi 'install' "$OUTPUT_DIR/s1.out" || fail "search rc=3 should print install hint"
          pass "search without binary prints install hint"
          ;;
        *)
          fail "search returned unexpected rc=$RC: $(cat $OUTPUT_DIR/s1.out)"
          ;;
      esac
      
      # 10. install.sh syntax check + --help
      bash -n "$SKILL_DIR/install.sh" || fail "install.sh has syntax errors"
      $SKILL_DIR/install.sh --help > "$OUTPUT_DIR/inst.out" 2>&1 || true
      grep -qi 'install' "$OUTPUT_DIR/inst.out" || fail "install.sh --help missing keyword 'install'"
      pass "install.sh syntax + --help"
      
      # 11. SKILL.md frontmatter
      python3 - <<PY
      import re, sys
      src = open("$SKILL_DIR/SKILL.md").read()
      assert src.startswith("---\n"), "SKILL.md must start with YAML frontmatter"
      end = src.find("\n---\n", 4)
      assert end > 0, "SKILL.md missing closing ---"
      fm = src[4:end]
      assert re.search(r"^name:\s*ast-grep\s*$", fm, re.M), "frontmatter missing name: ast-grep"
      assert re.search(r"^description:", fm, re.M), "frontmatter missing description"
      PY
      pass "SKILL.md frontmatter shape"
      
      # 12. All required reference files exist
      for f in references/install.md references/patterns.md references/pitfalls.md \
               references/recipes.md references/cli.md references/yaml-rules.md \
               references/sgconfig.md; do
        test -f "$SKILL_DIR/$f" || fail "missing $f"
      done
      pass "all references present"
      
      python3 - "$SKILL_DIR" <<'PY' || fail "Korean characters found in skill content"
      import pathlib, re, sys
      root = pathlib.Path(sys.argv[1])
      hangul = re.compile(r"[\uac00-\ud7a3]")
      targets = [root / "SKILL.md", root / "README.md"]
      for d in ("references", "scripts", "tests", ".github"):
          targets.extend((root / d).rglob("*"))
      targets.extend([root / "install.sh", root / "install.ps1"])
      hits = 0
      for p in targets:
          if not p.is_file():
              continue
          try:
              text = p.read_text(encoding="utf-8")
          except (UnicodeDecodeError, OSError):
              continue
          for n, line in enumerate(text.splitlines(), 1):
              if hangul.search(line):
                  print(f"{p}:{n}: {line.rstrip()}")
                  hits += 1
      sys.exit(1 if hits else 0)
      PY
      pass "no Korean in skill content"
      
      echo ""
      echo "all smoke tests passed"
      
  • .gitignore 343 B · in bundle
  • AGENTS.md 3.1 KB
    # ast-grep — Vendored Structural-Search Skill
    
    **Generated:** 2026-08-24 (f3642fcda)
    
    ## OVERVIEW
    
    Vendored skill teaching agents ast-grep (`sg`) structural search/rewrite, plus a stdlib-Python helper CLI that wraps the `sg` binary. Vendoring pin in `SOURCE`: upstream `code-yeongyu/ast-grep-skill @ 3148c69` (ast-grep 0.45.0), "vendored as a sync; do not fork-drift". Earned this file: own executable surface (749-LOC helper + installers + smoke tests) and a no-fork-drift upstream contract that has no analogue elsewhere in `shared-skills`.
    
    ## STRUCTURE
    
    ```
    ast-grep/
    ├── SKILL.md / README.md          # agent workflow / user overview
    ├── SOURCE                        # upstream pin — the no-fork-drift contract
    ├── scripts/ast_grep_helper.py    # 749-LOC stdlib CLI (argparse; no deps)
    ├── install.sh / install.ps1      # macOS/Linux + Windows installers
    ├── references/                   # cli, install, patterns, pitfalls, recipes, yaml-rules (510 LOC), sgconfig
    └── tests/smoke.{sh,ps1}          # POSIX + PowerShell smoke (also shell syntax + content checks)
    ```
    
    ## HELPER CLI (`scripts/ast_grep_helper.py`)
    
    Subcommands: `search`, `replace` (dry-run; `--apply` to write), `scan`, `test`, `new`, `langs`, `doctor`, `install`, `validate`. Resolves the `sg`/`ast-grep` binary across PATH, Homebrew, npm, and OMO caches, validates patterns before executing, and shells out — it never links ast-grep as a library. Internal handlers `cmd_*`; utilities `validate_pattern`, `normalize_lang`, `resolve_binary`, `run_sg`. Nothing in-repo imports it; docs and skills invoke it by path. `omo-opencode/src/cli/install-ast-grep-sg.ts` locates this skill dir to install the binary.
    
    ## CONVENTIONS
    
    - AST patterns, never regex, for syntax-shaped queries; `$VAR` singles, `$$$` multis; patterns must parse as complete code in the target language.
    - Two-pass rewrites: preview with `--json=compact`, apply separately with `--update-all` — the flags are mutually exclusive in `sg`.
    - `sgconfig.yml` discovered upward; `ruleDirs`, optional `testConfigs`/`utilDirs`, language globs, custom languages, injections.
    - Upstream syncs land as whole vendored refreshes matching `SOURCE`; local edits ride along until the next sync absorbs or reverts them.
    
    ## ANTI-PATTERNS
    
    - NO regex constructs (`.*`, `.+`, `\w`, `\d`, literal `|`) inside AST patterns — that is the skill's #1 pitfall.
    - No incomplete patterns (e.g. Python `def $F($$$):` without a body).
    - NEVER `--apply` a rewrite without the dry-run preview first.
    - Text/comment/byte queries go to `rg`, not structural matching.
    - Do not fork-drift from the `SOURCE` pin; a sync is a vendored refresh, not a merge.
    
    ## COMMANDS
    
    ```bash
    # from packages/shared-skills/skills/ast-grep/
    python3 scripts/ast_grep_helper.py search '<PATTERN>' --lang ts [path]
    python3 scripts/ast_grep_helper.py replace '<PATTERN>' '<REWRITE>' --lang ts   # dry-run; add --apply
    python3 scripts/ast_grep_helper.py doctor
    bash tests/smoke.sh          # or: pwsh tests/smoke.ps1
    # direct:  sg run -p '<PATTERN>' --lang ts path
    ```
    
    - Parent: [`packages/shared-skills/AGENTS.md`](../../AGENTS.md).
    
  • install.ps1 7 KB · in bundle
  • install.sh 7.4 KB
    #!/usr/bin/env bash
    #
    # install.sh - install the ast-grep binary on POSIX systems (macOS, Linux, WSL, Git Bash).
    #
    # Tries package managers in priority order, then falls back to downloading a
    # pinned release binary from GitHub into <skill_root>/bin/sg.
    #
    # Order:
    #   1. Already installed?  -> nothing to do
    #   2. Homebrew (brew)
    #   3. npm (@ast-grep/cli)
    #   4. cargo binstall (faster) or cargo install (slower)
    #   5. pip (ast-grep-cli)
    #   6. nix-env (NixOS / Nix users)
    #   7. mise (asdf successor)
    #   8. GitHub release tarball -> <skill_root>/bin/sg
    #
    # Flags:
    #   --method=<m>     Force one method: brew | npm | cargo | pip | nix | mise | github
    #   --version=<v>    Pin a specific version when downloading from GitHub
    #   --no-fallback    Don't fall back to GitHub tarball; fail if all package managers miss
    #   --quiet, -q      Suppress non-error output
    #
    # Exit codes:
    #   0  Installed (or already present)
    #   1  Argument error
    #   2  All install methods failed
    #   3  Network failure during GitHub fallback
    
    set -euo pipefail
    
    SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
    SKILL_ROOT="$SCRIPT_DIR"
    CACHE_BIN_DIR="${OMO_AST_GREP_BIN_DIR:-$SKILL_ROOT/bin}"
    
    PINNED_VERSION="0.45.0"
    FORCED_METHOD=""
    USE_FALLBACK=1
    QUIET=0
    
    log() {
      if [ "$QUIET" -eq 0 ]; then
        printf '[install.sh] %s\n' "$*" >&2
      fi
    }
    
    err() {
      printf '[install.sh] error: %s\n' "$*" >&2
    }
    
    usage() {
      sed -n '2,/^set -/p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//;/^set -/d'
      exit "${1:-0}"
    }
    
    while [ "$#" -gt 0 ]; do
      case "$1" in
        --method=*) FORCED_METHOD="${1#*=}" ;;
        --version=*) PINNED_VERSION="${1#*=}" ;;
        --no-fallback) USE_FALLBACK=0 ;;
        --quiet|-q) QUIET=1 ;;
        --help|-h) usage 0 ;;
        *) err "unknown argument: $1"; usage 1 ;;
      esac
      shift
    done
    
    # --- detect platform -----------------------------------------------------
    
    detect_os() {
      case "$(uname -s)" in
        Darwin) echo "darwin" ;;
        Linux) echo "linux" ;;
        MINGW*|MSYS*|CYGWIN*) echo "windows" ;;
        *) echo "unknown" ;;
      esac
    }
    
    detect_arch() {
      case "$(uname -m)" in
        arm64|aarch64) echo "aarch64" ;;
        x86_64|amd64) echo "x86_64" ;;
        *) echo "unknown" ;;
      esac
    }
    
    OS="$(detect_os)"
    ARCH="$(detect_arch)"
    
    # --- already installed? --------------------------------------------------
    
    ast_grep_present() {
      if command -v ast-grep >/dev/null 2>&1; then
        return 0
      fi
      if command -v sg >/dev/null 2>&1; then
        if [ "$OS" = "linux" ]; then
          if "$(command -v sg)" --version 2>/dev/null | grep -qi 'ast-grep'; then
            return 0
          fi
          return 1
        fi
        return 0
      fi
      if [ -x "$CACHE_BIN_DIR/sg" ] || [ -x "$CACHE_BIN_DIR/ast-grep" ]; then
        return 0
      fi
      return 1
    }
    
    if [ -z "$FORCED_METHOD" ] && ast_grep_present; then
      log "ast-grep already installed: $(command -v ast-grep 2>/dev/null || command -v sg)"
      exit 0
    fi
    
    # --- per-method installers -----------------------------------------------
    
    try_brew() {
      command -v brew >/dev/null 2>&1 || return 1
      log "trying: brew install ast-grep"
      brew install ast-grep && return 0 || return 1
    }
    
    try_npm() {
      command -v npm >/dev/null 2>&1 || return 1
      log "trying: npm install -g @ast-grep/cli"
      npm install -g @ast-grep/cli && return 0 || return 1
    }
    
    try_cargo() {
      if command -v cargo-binstall >/dev/null 2>&1; then
        log "trying: cargo binstall ast-grep"
        cargo binstall -y ast-grep && return 0 || true
      fi
      if command -v cargo >/dev/null 2>&1; then
        log "trying: cargo install ast-grep --locked"
        cargo install ast-grep --locked && return 0 || return 1
      fi
      return 1
    }
    
    try_pip() {
      if command -v pipx >/dev/null 2>&1; then
        log "trying: pipx install ast-grep-cli"
        pipx install ast-grep-cli && return 0 || true
      fi
      command -v pip3 >/dev/null 2>&1 || command -v pip >/dev/null 2>&1 || return 1
      PIP="$(command -v pip3 || command -v pip)"
      log "trying: $PIP install --user ast-grep-cli"
      $PIP install --user ast-grep-cli && return 0 || return 1
    }
    
    try_nix() {
      command -v nix-env >/dev/null 2>&1 || return 1
      log "trying: nix-env -iA nixpkgs.ast-grep"
      nix-env -iA nixpkgs.ast-grep && return 0 || return 1
    }
    
    try_mise() {
      command -v mise >/dev/null 2>&1 || return 1
      log "trying: mise use -g ast-grep"
      mise use -g ast-grep && return 0 || return 1
    }
    
    # Tarball assets are named like:
    #   app-aarch64-apple-darwin.zip
    #   app-x86_64-apple-darwin.zip
    #   app-aarch64-unknown-linux-gnu.zip
    #   app-x86_64-unknown-linux-gnu.zip
    #   app-x86_64-pc-windows-msvc.zip   (.zip only on windows)
    
    triple_for() {
      case "$OS-$ARCH" in
        darwin-aarch64) echo "aarch64-apple-darwin" ;;
        darwin-x86_64)  echo "x86_64-apple-darwin" ;;
        linux-aarch64)  echo "aarch64-unknown-linux-gnu" ;;
        linux-x86_64)   echo "x86_64-unknown-linux-gnu" ;;
        *) echo "" ;;
      esac
    }
    
    try_github() {
      TRIPLE="$(triple_for)"
      if [ -z "$TRIPLE" ]; then
        err "no GitHub release asset for $OS-$ARCH; install via package manager or build from source."
        return 1
      fi
    
      command -v curl >/dev/null 2>&1 || command -v wget >/dev/null 2>&1 || {
        err "need curl or wget for GitHub fallback"
        return 1
      }
    
      ASSET="app-${TRIPLE}.zip"
      URL="https://github.com/ast-grep/ast-grep/releases/download/${PINNED_VERSION}/${ASSET}"
      TMP="$(mktemp -d -t ast-grep-install-XXXXXX)"
      trap 'rm -rf "$TMP"' RETURN
    
      log "downloading $URL"
      if command -v curl >/dev/null 2>&1; then
        curl -fsSL "$URL" -o "$TMP/$ASSET" || return 3
      else
        wget -q "$URL" -O "$TMP/$ASSET" || return 3
      fi
    
      command -v unzip >/dev/null 2>&1 || {
        err "need 'unzip' to extract GitHub release archives"
        return 1
      }
    
      unzip -q "$TMP/$ASSET" -d "$TMP/extract"
      mkdir -p "$CACHE_BIN_DIR"
      if [ -f "$TMP/extract/ast-grep" ]; then
        mv "$TMP/extract/ast-grep" "$CACHE_BIN_DIR/sg"
      elif [ -f "$TMP/extract/sg" ]; then
        mv "$TMP/extract/sg" "$CACHE_BIN_DIR/sg"
      else
        err "no ast-grep or sg binary found inside $ASSET"
        return 1
      fi
      chmod +x "$CACHE_BIN_DIR/sg"
    
      log "installed cached binary: $CACHE_BIN_DIR/sg"
      log "verify: $CACHE_BIN_DIR/sg --version"
      log ""
      log "Add to PATH for direct sg use:"
      log "  export PATH=\"$CACHE_BIN_DIR:\$PATH\""
      return 0
    }
    
    # --- dispatch ------------------------------------------------------------
    
    run_method() {
      case "$1" in
        brew) try_brew ;;
        npm) try_npm ;;
        cargo) try_cargo ;;
        pip) try_pip ;;
        nix) try_nix ;;
        mise) try_mise ;;
        github) try_github ;;
        *) err "unknown method: $1"; return 1 ;;
      esac
    }
    
    if [ -n "$FORCED_METHOD" ]; then
      if run_method "$FORCED_METHOD"; then
        exit 0
      else
        err "method '$FORCED_METHOD' failed"
        exit 2
      fi
    fi
    
    # Try methods in OS-aware priority order.
    case "$OS" in
      darwin) METHODS=(brew npm cargo pip mise) ;;
      linux)  METHODS=(npm cargo pip nix mise brew) ;;
      windows) METHODS=(npm cargo pip mise) ;;
      *) METHODS=(npm cargo pip) ;;
    esac
    
    for m in "${METHODS[@]}"; do
      if run_method "$m"; then
        log "installed via $m"
        exit 0
      fi
      log "$m unavailable or failed; trying next"
    done
    
    if [ "$USE_FALLBACK" -eq 1 ]; then
      log "all package managers failed; falling back to GitHub release"
      if try_github; then
        exit 0
      fi
    fi
    
    err "all install methods failed."
    err ""
    err "Manual options:"
    err "  brew install ast-grep                 # macOS / linuxbrew"
    err "  npm install -g @ast-grep/cli          # any OS with Node"
    err "  cargo install ast-grep --locked       # any OS with Rust"
    err "  pip install ast-grep-cli              # any OS with Python"
    err "  https://github.com/ast-grep/ast-grep/releases   # manual binary"
    exit 2
    
  • LICENSE 1 KB · in bundle
  • README.md 7.5 KB
    # ast-grep-skill
    
    LLM-neutral skill for **AST-aware search and rewrite** across 25 languages. Wraps the [`ast-grep`](https://ast-grep.github.io/) (`sg`) CLI with offline pattern validation, the two-pass write trick, binary auto-resolution, and a per-OS installer.
    
    Same shape as [`web-fetch`](https://github.com/code-yeongyu/web-fetch) and [`web-search`](https://github.com/code-yeongyu/web-search), packaged as a standalone skill that any Bash-capable agent (Claude Code, OpenCode, pi, hermes, openclaw) can load.
    
    ## Install
    
    ```bash
    git clone https://github.com/code-yeongyu/ast-grep-skill ~/.agents/skills/ast-grep
    bash ~/.agents/skills/ast-grep/install.sh        # installs the ast-grep binary
    ```
    
    That is it. The wrapper script is single-file Python 3 stdlib; no `pip install` needed. The installer tries `brew` → `npm` → `cargo` → `pip` → `nix` → `mise` → GitHub release in priority order, picks the first that works, and falls back to a cached binary at `<skill>/bin/sg`.
    
    ### Symlink for active development
    
    ```bash
    ln -s /path/to/your/clone ~/.agents/skills/ast-grep
    ```
    
    ### Other agents
    
    - **Claude Code / OpenCode**: drop the directory under `~/.agents/skills/` (or `~/.config/opencode/skills/`) and the skill auto-registers via the `name` + `description` in `SKILL.md` frontmatter.
    - **pi (`~/.senpi/agent`)**: not a `pi` extension — this is a skill. Pi consumes skills via `~/.agents/skills/` symlinks; the actual `pi-ast-grep` extension is at <https://github.com/code-yeongyu/pi-extensions>.
    - **Direct CLI use**: `python3 ~/.agents/skills/ast-grep/scripts/ast_grep_helper.py <subcommand>`.
    
    ## Usage
    
    ```bash
    # Search by AST pattern (the helper validates patterns offline first)
    python3 scripts/ast_grep_helper.py search 'console.log($MSG)' --lang ts src/
    
    # Rewrite (dry-run preview by default)
    python3 scripts/ast_grep_helper.py replace 'console.log($MSG)' 'logger.info($MSG)' --lang ts src/
    
    # Apply the rewrite (two-pass: preview JSON + then --update-all)
    python3 scripts/ast_grep_helper.py replace 'console.log($MSG)' 'logger.info($MSG)' --lang ts src/ --apply
    
    # Run YAML lint rules from sgconfig.yml
    python3 scripts/ast_grep_helper.py scan src/
    
    # Validate a pattern OFFLINE (no sg call, no filesystem)
    python3 scripts/ast_grep_helper.py validate '\w+' --lang ts
    # → exit 2: regex \w not supported. Use $VAR for identifiers.
    
    # Doctor: check ast-grep binary availability
    python3 scripts/ast_grep_helper.py doctor
    
    # List 25 supported languages
    python3 scripts/ast_grep_helper.py langs
    
    # Install / re-install the ast-grep binary
    python3 scripts/ast_grep_helper.py install
    ```
    
    See [SKILL.md](./SKILL.md) for full agent-facing usage and the [`references/`](./references/) directory for deep dives.
    
    ## Project layout
    
    ```
    ast-grep-skill/
    ├── SKILL.md                       agent-facing skill (loaded by Claude Code, OpenCode, pi, etc.)
    ├── README.md                      this file
    ├── LICENSE                        MIT
    ├── install.sh                     POSIX installer (macOS / Linux / WSL / Git Bash)
    ├── install.ps1                    Windows PowerShell installer
    ├── scripts/
    │   └── ast_grep_helper.py         single-file Python 3 stdlib wrapper
    ├── references/
    │   ├── install.md                 per-OS install methods + manual fallback
    │   ├── patterns.md                meta-variables ($VAR, $$$) and pattern syntax
    │   ├── pitfalls.md                regex anti-patterns + language-specific traps
    │   ├── recipes.md                 copy-paste patterns by language (TS/JS/Py/Go/Rust/...)
    │   ├── cli.md                     sg run / scan / test / new / lsp reference
    │   ├── yaml-rules.md              YAML rule schema (atomic / relational / composite / transform / fix)
    │   └── sgconfig.md                project configuration (ruleDirs, testConfigs, utilDirs)
    ├── tests/
    │   ├── smoke.sh                   POSIX self-test
    │   └── smoke.ps1                  PowerShell self-test (Windows CI)
    └── .github/workflows/ci.yml       matrix CI: macos / ubuntu / windows × py 3.9-3.13
    ```
    
    ## What it does
    
    1. **Wraps `sg`** with a single Python 3 stdlib script that works the same on macOS, Linux, Windows, WSL, Git Bash.
    2. **Validates patterns offline** before calling `sg` — catches the regex-misuse class of mistakes (`\w`, `.*`, `|`, `[a-z]`) plus language-specific traps (Python trailing colons, JS/Go/Rust missing function bodies).
    3. **Resolves the binary** through 6 candidate paths: cached → PATH (with Linux `setgroups` collision detection) → Homebrew. Falls through to a clear install hint with copy-paste commands.
    4. **Runs the two-pass write trick** when applying rewrites — `sg run` silently ignores `--update-all` when `--json` is set, so `replace --apply` runs two invocations: pass 1 collects JSON matches, pass 2 mutates files.
    5. **Ships per-OS installers** that try every reasonable package manager and fall back to a GitHub release tarball.
    6. **Documents the failure modes** the model will hit (regex misuse, incomplete patterns, `--update-all` + `--json` trap, scope/type questions ast-grep can't answer) in `references/pitfalls.md`.
    
    ## What it does NOT do
    
    - No type inference, scope analysis, or data flow. ast-grep is a structural matcher; for type-aware questions use TypeScript LSP, Pyright, Semgrep with type inference, or CodeQL.
    - No multi-repo federation. Run the helper once per repo.
    - No automatic `sgconfig.yml` discovery — it does what `sg scan` does (walk up from cwd looking for one).
    - No JS/Python rewriter authoring environment — for that, write YAML rules and use `sg test` for snapshot testing (see [`references/yaml-rules.md`](./references/yaml-rules.md)).
    
    ## Limits
    
    - 5-minute timeout per `sg` invocation (configurable in the helper).
    - ast-grep itself supports 25 languages out-of-the-box. For anything else, use [`customLanguages`](./references/sgconfig.md#customlanguages-experimental) in `sgconfig.yml`.
    - Pattern hint detection is heuristic; pass `--force` to skip validation when you know the pattern is correct.
    
    ## Requirements
    
    - Python ≥ 3.9 (stdlib only — no pip install).
    - `ast-grep` binary, installed via `install.sh` / `install.ps1` or one of:
      - `brew install ast-grep` (macOS / linuxbrew)
      - `npm install -g @ast-grep/cli` (any OS with Node)
      - `cargo install ast-grep --locked` (any OS with Rust)
      - `pip install ast-grep-cli` (any OS with Python)
      - `scoop install main/ast-grep` (Windows)
    
    For older systems and Windows-specific setup, see [`references/install.md`](./references/install.md).
    
    ## Testing
    
    ```bash
    bash tests/smoke.sh        # POSIX (macOS / Linux / WSL / Git Bash)
    pwsh tests/smoke.ps1       # Windows (PowerShell 5.1+ or 7+)
    ```
    
    CI runs the matrix on every push: `{macos-latest, ubuntu-latest, ubuntu-22.04, windows-latest}` × `{Python 3.9, 3.10, 3.11, 3.12, 3.13}` plus a syntax-floor check on Python 3.9 and 3.10.
    
    ## License
    
    [MIT](./LICENSE).
    
    ## Acknowledgments
    
    - [`omo` (oh-my-opencode)](https://github.com/code-yeongyu/oh-my-opencode) — `src/tools/ast-grep/` is the original tool implementation; this skill is a port of its pattern-hint detection and two-pass-write strategy.
    - [`pi-extensions/pi-ast-grep`](https://github.com/code-yeongyu/pi-extensions) — sibling Node port; the helper's binary-resolution cascade is modelled on it.
    - [ast-grep](https://github.com/ast-grep/ast-grep) — the CLI. All structural matching power comes from it.
    - [Anthropic skills](https://docs.anthropic.com/en/docs/claude-code/skills) — the `SKILL.md` + `references/` packaging convention.
    
  • SKILL.md 13.1 KB
    ---
    name: ast-grep
    description: "Searches and rewrites code by AST shape across 25 languages. Use when the target is a syntax pattern (every call/class/import shaped like X, a codemod, a YAML rule) rather than literal text; for plain strings, comments, or filenames, use rg."
    ---
    
    # ast-grep
    
    `sg` (also installed as `ast-grep`) is an **AST-aware search and rewrite tool** across 25 languages. It treats your pattern as code, parses it the same way it parses your project, and matches structurally. It is the right tool whenever your question depends on **code shape** rather than text bytes.
    
    This skill ships a Python wrapper at `scripts/ast_grep_helper.py` and platform install scripts at `install.sh` (POSIX) and `install.ps1` (Windows). The helper adds offline pattern validation, the two-pass write trick, and binary auto-resolution. Use it as your default entry point.
    
    ---
    
    ## OmO native: the ast-grep MCP tools (fastest path, no CLI needed)
    
    OmO native also registers a bundled ast-grep MCP server. Its three tools are **deferred**: they are absent from the resident tool list and activate automatically the first time you call them by name — no `tool_search` round trip needed.
    
    - `mcp__ast_grep_search({ pattern, language, paths })` — AST-shape search, the MCP equivalent of `sg run -p`. Prefer this over shelling out to `sg` for one-off lookups: no install, no PATH issues.
    - `mcp__ast_grep_rewrite({ pattern, rewrite, language, paths, apply })` — preview matches (`apply: false`, the default) or apply the rewrite (`apply: true`). Same two-pass discipline as `sg run -r`: preview first, then apply.
    - `mcp__ast_grep_scan({ paths })` — run project YAML rules (`sgconfig.yml`, `rules/`), the MCP equivalent of `sg scan`.
    
    Same pattern language as the CLI: `$VAR` matches one AST node, `$$$VAR` matches zero or more, and the pattern is code, not regex. `paths` accepts a file or directory list; `language` is required for patterns that are ambiguous across languages.
    
    ---
    
    ## When to use this skill
    
    Use it whenever the user's question is about **code structure**, not bytes:
    
    - "Find every function that takes a `Request` parameter."
    - "Rewrite every `console.log(x)` to `logger.info(x)`."
    - "Strip every `as any` cast."
    - "Replace `require(...)` with `import` across the repo."
    - "Find empty catch blocks."
    - "Migrate `Optional[X]` to `X | None`."
    - "Apply this codemod across these 200 files."
    - "Run our YAML lint rules and surface violations."
    
    Switch to plain `grep` / `rg` when the question is text-shaped (string literal contents, comments, license headers, file names, cross-language regex). When in doubt, ask: "does the answer depend on the language's syntax tree, or just on the file's bytes?" If the former, ast-grep. If the latter, grep.
    
    ---
    
    ## Three things the agent must internalize
    
    ### 1. ast-grep is NOT regex
    
    The wildcards are `$VAR` (one AST node) and `$$$` (zero or more nodes). Regex syntax fails silently:
    
    | You wrote | What ast-grep saw | What you wanted |
    |---|---|---|
    | `foo\|bar` | bitwise-or of `foo` and `bar` | run two separate searches |
    | `.*foo` | not parseable | `$$$ foo` (if `$$$` is a list of nodes) or use `rg` |
    | `\w+` | not parseable | `$VAR` to capture any identifier |
    | `[a-z]` | character class, not parseable | switch to `rg` |
    
    The full anti-pattern table is in `references/pitfalls.md` §1. The helper's `validate` subcommand catches these mechanically — call it before debugging "no matches" by hand.
    
    ### 2. Patterns must be valid code
    
    The pattern itself must parse. `def $FN($$$):` fails because the trailing `:` makes it incomplete; use `def $FN($$$)`. `function $NAME` without params/body fails; use `function $NAME($$$) { $$$ }`. Full table per language in `references/pitfalls.md` §2.
    
    ### 3. `--update-all` and `--json` are mutually exclusive (silently)
    
    This is the single biggest gotcha when scripting. `sg run -p P -r R --json --update-all` returns the JSON but **does not mutate files**. To both preview AND apply, run **two passes**:
    
    ```bash
    sg run -p P -r R --json=compact .   # pass 1: see what would change
    sg run -p P -r R --update-all .     # pass 2: actually apply
    ```
    
    The helper does this automatically when you call `replace --apply`. Read `references/pitfalls.md` §9.
    
    ---
    
    ## The helper script — `scripts/ast_grep_helper.py`
    
    A single-file Python 3 stdlib wrapper. Same on every OS. The agent's default entry point.
    
    ### `search` — find all matches of a pattern
    
    ```bash
    python3 scripts/ast_grep_helper.py search 'console.log($MSG)' --lang ts src/
    ```
    
    Validates the pattern offline first. If the pattern looks like regex (`\w`, `.*`, `|`, etc.) the helper exits with a hint and never calls `sg` — saves a round-trip. Pass `--force` to skip validation.
    
    Flags:
    - `--lang ts` (or any of the 25 languages; aliases like `js`, `py`, `rs`, `kt` accepted)
    - `--globs '!**/*.test.ts'` (repeatable; prefix `!` to exclude)
    - `-C 3` (context lines)
    - `--json-out` (raw JSON instead of human format)
    
    ### `replace` — rewrite by pattern, dry-run by default
    
    ```bash
    # Dry-run preview (default — no files mutated)
    python3 scripts/ast_grep_helper.py replace 'console.log($MSG)' 'logger.info($MSG)' --lang ts src/
    
    # Actually apply
    python3 scripts/ast_grep_helper.py replace 'console.log($MSG)' 'logger.info($MSG)' --lang ts src/ --apply
    ```
    
    The helper:
    1. Validates both `pattern` and `rewrite` for hint-detectable mistakes.
    2. Runs pass 1 with `--json=compact` to collect matches and show a preview.
    3. If `--apply` is set, runs pass 2 with `--update-all` to mutate files.
    
    ### `scan` — run YAML rules
    
    ```bash
    # Discover sgconfig.yml from cwd and run all rules
    python3 scripts/ast_grep_helper.py scan src/
    
    # Run a single rule file
    python3 scripts/ast_grep_helper.py scan -r rules/no-console.yml src/
    
    # Apply auto-fixes
    python3 scripts/ast_grep_helper.py scan -U src/
    
    # CI-friendly GitHub annotations
    python3 scripts/ast_grep_helper.py scan --report-style short src/
    ```
    
    ### `validate` — offline pattern check (no `sg` call)
    
    Useful for CI lints, pre-commit hooks, and quick sanity checks:
    
    ```bash
    python3 scripts/ast_grep_helper.py validate '\w+' --lang ts
    # → exit 2: regex \w not supported. Use $VAR for identifiers.
    
    python3 scripts/ast_grep_helper.py validate 'console.log($MSG)' --lang ts
    # → exit 0: pattern looks plausible for ast-grep.
    ```
    
    ### `langs` / `doctor` / `install`
    
    ```bash
    python3 scripts/ast_grep_helper.py langs       # list 25 supported languages and aliases
    python3 scripts/ast_grep_helper.py doctor      # check ast-grep binary availability
    python3 scripts/ast_grep_helper.py install     # delegate to install.sh / install.ps1
    ```
    
    `new` and `test` subcommands proxy directly to `sg new` and `sg test`.
    
    ---
    
    ## Direct `sg` use (when the helper isn't enough)
    
    The helper is opinionated. For full control, drop to `sg`. The skill ships a CLI cheat sheet in `references/cli.md`. The minimal idioms:
    
    ```bash
    # Search
    sg run -p 'console.log($MSG)' --lang ts src/
    
    # Search with JSON for scripting
    sg run -p 'console.log($MSG)' --lang ts --json=compact src/ | jq '.[] | .file'
    
    # Rewrite, dry-run
    sg run -p 'console.log($MSG)' -r 'logger.info($MSG)' --lang ts --json=compact src/
    
    # Rewrite, apply
    sg run -p 'console.log($MSG)' -r 'logger.info($MSG)' --lang ts --update-all src/
    
    # Pattern from stdin (great for ad-hoc experiments)
    echo 'console.log("hi")' | sg run -p 'console.log($MSG)' --lang js --stdin
    
    # Debug a pattern that returns 0 matches
    sg run -p '<your pattern>' --lang <lang> --debug-query=ast --stdin <<< '<sample-code>'
    
    # Run YAML rules
    sg scan src/
    
    # Inline YAML rule (one-off)
    sg scan --inline-rules '
    id: no-todo
    language: TypeScript
    severity: warning
    rule: { pattern: TODO }' src/
    ```
    
    When using `sg` directly in a shell, **always single-quote patterns** so `$VAR` is not expanded by the shell.
    
    ---
    
    ## Decision tree — what to use, when
    
    ```
    USER asks for "find/rewrite/codemod"
    │
    ├─ structural pattern (function shape, call, class, import, control flow)
    │  └→ ast-grep (this skill)
    │
    ├─ text pattern (regex, alternation, character classes, file names)
    │  └→ rg / grep
    │
    ├─ semantic question (what variable does this refer to? does this throw?)
    │  └→ LSP tools, TypeScript compiler, Pyright, Semgrep with type inference
    │
    └─ multiple repos / federated search
       └→ a search engine + then ast-grep / rg / LSP per-repo
    ```
    
    If the user says "find all" or "every", default to ast-grep when the target is shaped (function, class, call, import, statement). Default to rg when the target is text (string content, comment, license header, file name, identifier substring).
    
    ---
    
    ## Always run dry-run first when rewriting
    
    A bad pattern silently rewrites the wrong thing. The helper's `replace` defaults to dry-run for this reason. The flow is:
    
    1. Search to confirm matches: `helper search '<pattern>' --lang X .`
    2. Dry-run rewrite: `helper replace '<pattern>' '<rewrite>' --lang X .` (no `--apply`)
    3. Inspect the dry-run summary: number of matches, files affected, the per-location preview.
    4. If wrong: refine pattern, go back to step 1.
    5. If right: `helper replace '<pattern>' '<rewrite>' --lang X . --apply`.
    
    Never apply a rewrite that you have not first dry-run.
    
    ---
    
    ## When `sg` returns 0 matches but you know the code is there
    
    In priority order:
    
    1. **Run `helper validate '<pattern>' --lang <lang>`** — catches regex misuse, missing function bodies, Python trailing colons.
    2. **Check `--lang`** — `sg` infers from extension; if you pass a `.tsx` file with `--lang ts` (not `tsx`), JSX won't parse.
    3. **Inspect the parsed pattern**: `sg run -p '<pattern>' --lang <lang> --debug-query=ast --stdin <<< '<sample>'`. If it shows `ERROR` nodes, the pattern is malformed.
    4. **Check the AST of the target file**: `sg run -p '$_' --lang <lang> --debug-query=cst path/to/file | head -40` — find the `kind` you're trying to match.
    5. **Try the playground**: <https://ast-grep.github.io/playground.html> — paste code + pattern, see what's happening.
    
    Do not blindly retry with variations. Each failure has a reason; surface it.
    
    ---
    
    ## When to use YAML rules vs inline `-p` patterns
    
    **Use inline `-p`** when:
    - One-off ad-hoc query.
    - The pattern is simple (no constraints, no fix template).
    - You're exploring.
    
    **Use YAML rules** (file under `rules/`, run via `sg scan`) when:
    - The pattern is reused (lint rule, codemod that runs in CI).
    - You need `constraints`, `transform`, complex `inside`/`has`, or composite logic.
    - You want auto-fix (`fix:` field).
    - You want to test the rule (snapshot tests via `sg test`).
    
    The full YAML rule schema is in `references/yaml-rules.md`. Project setup (`sgconfig.yml`, `ruleDirs`, `utilDirs`) is in `references/sgconfig.md`.
    
    ---
    
    ## Output discipline
    
    - `sg run --json=compact` produces an array of match objects: `{ file, range: {start, end}, text, replacement?, lines, language, ... }`. Pipe through `jq` for further processing.
    - Without `--json`, `sg` produces human-readable colored output suitable for terminals.
    - The helper's default output is human-readable (file:line:column + match preview). Pass `--json-out` for raw JSON.
    - The helper's `replace` always summarizes: number of matches, number of files, per-location preview.
    
    When summarizing for the user, **always include the count of files affected**, not just the count of matches. Users care about blast radius.
    
    ---
    
    ## Required reading (in order of priority)
    
    1. `references/patterns.md` — meta-variables, naming rules, strictness levels. Read when you're unsure why a pattern doesn't match.
    2. `references/pitfalls.md` — the failure-mode field guide. Read when 0 matches surprises you.
    3. `references/recipes.md` — copy-paste patterns by language. Read first when you start a new task.
    4. `references/cli.md` — `sg run`, `sg scan`, `sg test`, `sg new`, `sg lsp`. Read when the helper isn't enough.
    5. `references/yaml-rules.md` — YAML rule schema. Read when you outgrow inline patterns.
    6. `references/sgconfig.md` — project-level configuration. Read when you set up `sg scan` for a real project.
    7. `references/install.md` — per-OS install methods. Read only if `install.sh` / `install.ps1` fail.
    
    ---
    
    ## Invariants (do not break)
    
    - **Validate before searching.** When emitting a pattern programmatically, call `helper validate` first. It catches the regex-misuse class of mistakes that account for ~70% of "0 matches" debug sessions.
    - **Dry-run before applying.** Never run `sg run -r ... --update-all` without first inspecting the matches. The helper's `replace` enforces this by default.
    - **Two-pass writes.** When using `sg` directly to both preview and apply, run two invocations — `--json` ignores `--update-all`.
    - **Single-quote patterns in shell.** `'$VAR'` not `"$VAR"`. The shell expands `$VAR` to the empty string in double quotes, breaking the pattern.
    - **Pattern is code, not regex.** When the pattern would need `|`, `.*`, `\w`, or `[a-z]`, switch to `rg` instead. Don't try to force ast-grep into a regex shape.
    - **`--lang` is required for stdin.** When piping with `--stdin`, set `--lang` explicitly; `sg` cannot infer from extension.
    - **Linux: prefer `ast-grep` over `sg`** because `sg` collides with `setgroups`. The helper handles this; if you call `sg` directly, alias it: `alias sg=ast-grep`.
    
  • SOURCE 125 B · in bundle

Comments (0)

Sign in to join the conversation.

No comments yet.

Reviews (0)

No reviews yet.

Related