Use ast-grep to supplement patterns that cannot be expressed with general-purpose lint tools (ESLint, oxlint, Biome, clippy, etc.). Always prefer reproducible static rules over natural language prompts.
Installation
# npm (プロジェクトローカル推奨)
npm install -D @ast-grep/cli
npx ast-grep --help
# または cargo
cargo install ast-grep --locked
# または brew
brew install ast-grep
Package manager selection: Project package.json に packageManager If is configured, follow it (pnpm / yarn, etc.). If not, install locally with npm. Align CI to the same tool as well (mixing them splits the lockfile / binary reference path). Limit global installs to dev machines only, and always use local references in CI and repo scripts.
Quick Start
Verify operation with a minimal configuration:
mkdir -p rules rule-tests
cat > sgconfig.yml << 'EOF'
ruleDirs:
- rules
testConfigs:
- testDir: rule-tests
EOF
cat > rules/no-console-log.yml << 'EOF'
id: no-console-log
language: TypeScript
severity: warning
rule:
pattern: console.log($$$ARGS)
message: console.log を残さない。
fix: ''
EOF
cat > rule-tests/no-console-log-test.yml << 'EOF'
id: no-console-log
valid:
- logger.info('ok')
invalid:
- console.log('debug')
- "console.log('a', 'b')"
EOF
ast-grep test --skip-snapshot-tests # テスト実行
ast-grep scan src/ # プロジェクトスキャン
Principles
- First check whether existing linters can cover it
- Use ast-grep when a "structural pattern" is needed
- Develop rules with TDD: test first → implement rule → CI integration
fixIf you can write , write it. Prefer rules with autofix over detection-only rules
How to use it alongside existing linters
| Case | Tool |
|---|---|
| unused import, no-console, naming convention | ESLint / oxlint / Biome |
| type error, unreachable code | TypeScript compiler / clippy |
| formatting | Prettier / Biome / rustfmt |
| Prohibition of specific function call patterns | ast-grep |
| Detection and automatic rewriting of deprecated APIs | ast-grep (fix) |
| Prohibited patterns within specific contexts | ast-grep (inside/has) |
| Project-specific structural constraints | ast-grep |
Signs you should use ast-grep:
- It cannot be expressed by configuring existing rules alone
- It depends on AST parent-child or sibling relationships
- Automatic rewriting (migration) is needed
Project configuration
sgconfig.yml
ruleDirs: # 必須: ルールファイルの格納先
- rules
testConfigs: # 任意: テスト設定
- testDir: rule-tests
utilDirs: # 任意: 共有ユーティリティルール
- rule-utils
languageGlobs: # 任意: 非標準拡張子のマッピング (TS/JS/Python 等は不要)
html: ['*.vue', '*.svelte', '*.astro']
Directory structure
project/
sgconfig.yml
rules/
no-direct-env-access.yml
prefer-result-type.yml
rule-tests/
no-direct-env-access-test.yml
prefer-result-type-test.yml
__snapshots__/
rule-utils/
is-async-function.yml
ast-grep scan は sgconfig.yml Run all rules in from the location where exists. ruleDirs Run all rules in .
Rule file structure
id: no-direct-env-access
language: TypeScript
severity: warning
rule:
pattern: process.env.$KEY
not:
inside:
kind: function_declaration
has:
pattern: getEnv
stopBy: end
message: process.env を直接参照しない。getEnv() を経由する。
note: 環境変数の型安全なアクセスを保証するため。
fix: getEnv('$KEY')
files:
- "src/**"
ignores:
- "src/config.ts"
Field
| Field | Required | Description |
|---|---|---|
id |
Yes | Rule identifier |
language |
Yes | Target language |
rule |
Yes | Match condition |
severity |
No | hint, warning, error |
message |
No | One-line description |
note |
No | Detailed description / fix guide |
fix |
No | Autofix template |
constraints |
No | Additional constraints on metavariables |
transform |
No | Text transformation of metavariables |
files |
No | Target glob |
ignores |
No | Exclusion glob |
url |
No | Documentation URL |
Suppression comments
// ast-grep-ignore
someCode()
// ast-grep-ignore: no-direct-env-access
process.env.NODE_ENV
Notes on metavariables
Pitfalls of pattern matching:
$OBJ.$PROPは Dot access only matches.obj['key'](bracket access) does not match$VARmatches a single AST node$$$VARSmatches zero or more AST nodes (variadic arguments, multiple statements, etc.)$_is a wildcard (does not capture). It can match different content even with the same name- A metavariable must occupy an entire node:
obj.on$EVENTや"hello $NAME"does not work
fix (autofix)
How to decide whether to add fix
fix is convenient, but since it is applied automatically when added, there is a risk of changing semantics. In the following cases, do not add do not add it(Detection only):
- Rewriting changes type safety (e.g.,
as any→as unknownchanges the type inference result) - Side effects or evaluation order may change (presence/absence of short-circuit evaluation, timing of exception occurrence)
- Context-dependent, so the correct replacement cannot be uniquely determined (e.g., swapping argument order in API migration; review is mandatory)
- For deletion-type changes, the deletion target is entangled with other expressions in the same statement
If in doubt, write a "manual migration procedure" in a note without a fix. Only add a fix when you are confident that "replacing all occurrences is safe."
Basics
rule:
pattern: console.log($ARG)
fix: logger.info($ARG)
Metavariables can be used as-is in the fix template. Metavariables that did not match become empty strings.
Deletion
rule:
pattern: console.log($$$ARGS)
fix: ''
fix: '' deletes the matched node. However, blank lines may remain.If you also want to remove the statement terminator ; or trailing commas, always use expandEnd together(see "Range expansion" below). If leaving blank lines is acceptable, expandEnd is unnecessary. Criterion: if the project runs a formatter (such as Prettier) after deletion, blank lines are cleaned up automatically, so expandEnd is unnecessary; if not, expandEnd is recommended.
any: fix when bundling multiple patterns with
any: , they may be merged into one rule: same fix template can be used in each branch under
rule:
any:
- pattern: $ARR.filter($P).length === 0
- pattern: $ARR.filter($P).length == 0
fix: '!$ARR.some($P)' # 両分岐で共通のメタ変数 + 同一テンプレート
If the fix differs per branch, always split the rules(any: per-branch fixes cannot be written inside). Example: === 0 → !some()、!== 0 → some() should be a separate rule. Splitting rules is acceptable even for the same purpose (it is clearer if you align the ids with *-empty / *-nonempty , etc.).
Multiple lines
rule:
pattern: |
def foo($X):
$$$S
fix: |-
def bar($X):
$$$S
Indentation is preserved to match the position in the original code.
Range expansion (FixConfig)
When you want to delete including trailing commas, etc.:
fix:
template: ''
expandEnd:
regex: ','
Simple rewriting via CLI
ast-grep run --pattern 'oldFunc($$$ARGS)' --rewrite 'newFunc($$$ARGS)' --lang typescript .
# --update-all で確認なしに一括適用
constraints
Add extra conditions to metavariables.$ARG only ($$$ARGS is not allowed). Filtered after rule matching.
How to use constraints vs. structural constraints (has/inside/not):
- If you want to add a condition to the contents of a metavariable →
constraints(e.g.,$METHODがget/set/deleteeither) - If you want to add a condition to the structure outside/inside the pattern →
has/inside/not/precedes/follows(e.g., inside a specific parent element, having a specific child element) - If you can write a concrete literal in the pattern itself, that is the simplest (
pattern: new Set($X)ensures the Set exists)
rule , it is pattern と has / not directly under AND-evaluated (matches the pattern AND has is true).pattern alone in this form.
rule:
pattern: $OBJ.$METHOD($$$ARGS)
constraints:
METHOD:
regex: '^(get|set|delete)$'
OBJ:
kind: identifier
Available fields: kind, regex, pattern
Note: not constrained metavariables inside may not work as expected.
transform
After text-converting the matched metavariable, use it in fix .
replace (regex replacement)
transform:
NEW_NAME:
replace:
source: $NAME
replace: 'get(\w+)'
by: 'fetch$1'
fix: $NEW_NAME($$$ARGS)
substring (substring)
transform:
INNER:
substring:
source: $STR
startChar: 1
endChar: -1
Negative indices count from the end. Same as Python slicing.
convert (case conversion)
transform:
SNAKE:
convert:
source: $NAME
toCase: snakeCase
separatedBy: [caseChange]
Supported cases: camelCase, snakeCase, kebabCase, pascalCase, upperCase, lowerCase, capitalize
rewrite (experimental)
Recursively rewrite nodes within metavariables using rewriter rules.
transform:
REWRITTEN:
rewrite:
source: $$$BODY
rewriters: [migrate-api-call]
joinBy: "\n"
utils (utility rules)
utilDirs Reference the common rules defined in matches with
# rule-utils/is-async-function.yml
id: is-async-function
language: TypeScript
rule:
any:
- kind: function_declaration
has:
field: async
regex: async
- kind: arrow_function
has:
field: async
regex: async
# rules/async-no-try-catch.yml
id: async-no-try-catch
language: TypeScript
rule:
all:
- matches: is-async-function
- has:
pattern: await $EXPR
stopBy: end
- not:
has:
kind: try_statement
stopBy: end
message: async 関数に try-catch がない。
severity: warning
Test
There are two kinds of tests. Don't confuse them:
- Classification tests (
test --skip-snapshot-tests):valid/invalidOnly checks whether the code listed in is classified correctly. This is the one to run in CI. - Snapshot testing (
test/test -U): Fixes match positions for invalid code and fix application results as snapshots to detect regressions. Generate initially with-U, then human review thereafter. Run once before CI.
Test file format
Within the test file, id is in the rule file id must match. The filename is arbitrary (convention is {rule-id}-test.yml)。
# rule-tests/no-direct-env-access-test.yml
id: no-direct-env-access
valid:
- getEnv('NODE_ENV')
- "function setup() { return getEnv('PORT') }"
invalid:
- process.env.NODE_ENV
- process.env.PORT
Running tests
# 分類テスト (valid/invalid が正しいか)
ast-grep test --skip-snapshot-tests
# スナップショット生成・更新
ast-grep test -U
# スナップショット対話的レビュー
ast-grep test --interactive
Test results:
.: PathN: Noisy (false positive — matches valid code)M: Missing (false negative — does not match invalid code)
Workflow
rule-tests/Write a test file in (Red)rules/Write the rule in (Green)ast-grep test --skip-snapshot-testsVerify withast-grep test -UGenerate snapshots with- Review snapshot and commit
CI integration
justfile
ast-grep-test:
ast-grep test
ast-grep-lint:
ast-grep scan
check: format-check typecheck ast-grep-lint test
GitHub Actions
Match the dev environment and tools (if the project uses pnpm, use pnpm in CI too; if npm, use npm):
- uses: actions/setup-node@v4
with: { node-version: 24, cache: npm } # pnpm プロジェクトなら pnpm/action-setup@v4 + cache: pnpm
- run: npm ci # pnpm なら pnpm install --frozen-lockfile
- name: ast-grep rule tests
run: npx ast-grep test --skip-snapshot-tests
- name: ast-grep scan
run: npx ast-grep scan --error
severity and exit codes:
ast-grep scandefaults toerrorExits non-zero if there is even one severity--errorAdding makes it exit non-zero even forwarning/hint(useful when you want warnings to fail CI too)--error=errorYou can also specify severity like to tighten gradually--format jsonfor structured output (for integration with other tools)
How to find kind names
Kind names depend on the language's Tree-sitter grammar.
# AST ダンプ(名前付きノードのみ、ルール記述に使う)
ast-grep run --pattern 'YOUR_CODE' --lang typescript --debug-query=ast
# CST ダンプ(全ノード、anonymous トークン含む)
ast-grep run --pattern 'YOUR_CODE' --lang typescript --debug-query=cst
For a catalog of common kinds by language, see references/kind-catalog.md (covers TypeScript / Rust / Go / Python).
Practical rule examples
TypeScript: as any Banning casts (detection only, no fix)
id: no-as-any
language: TypeScript
severity: error
rule:
pattern: $EXPR as any
message: as any は型システムを無効化する。as unknown 経由か型ガードを使う。
note: |
自動 fix を付けない理由: `as any` → `as unknown` への機械置換は型推論結果が
変わるため、呼び出し側で新たなコンパイルエラーを生む。検出のみにして手動移行。
as any When matching a "type assertion" like$EXPR as any が as_expression it works as a node.$EXPR matches the entire left-hand side, soJSON.parse(raw) as any also matches (value as any) also matches.
TypeScript: rewriting deprecated APIs
id: migrate-old-api
language: TypeScript
severity: error
rule:
pattern: oldClient.fetch($URL, $OPTS)
fix: newClient.request($URL, $OPTS)
message: oldClient.fetch は廃止。newClient.request に移行する。
Banning specific imports
id: no-lodash-import
language: TypeScript
severity: warning
rule:
pattern: import $_ from 'lodash'
message: lodash の全体 import を禁止。lodash/xxx を使う。
fix: import $_ from 'lodash/xxx' // TODO: 正しいパスに修正
TypeScript: banning direct fetch inside React components
id: no-fetch-in-component
language: TypeScript
severity: warning
rule:
pattern: fetch($$$ARGS)
inside:
any:
- kind: function_declaration
has:
field: return_type
pattern: JSX.Element
- kind: arrow_function
inside:
kind: variable_declarator
regex: '^[A-Z]'
stopBy: end
message: コンポーネント内で直接 fetch しない。hooks か server action を使う。
Rust: banning unwrap()
id: no-unwrap
language: Rust
severity: warning
rule:
pattern: $EXPR.unwrap()
not:
inside:
kind: function_item
regex: '#\[test\]'
stopBy: end
message: テスト以外で unwrap() を使わない。? か expect() を使う。
note: unwrap() は panic するため、本番コードでは避ける。
Rust: detecting unsafe blocks
id: flag-unsafe-block
language: Rust
severity: warning
rule:
kind: unsafe_block
message: unsafe ブロック。安全性の根拠をコメントで示す。
Rust: migrating println! to the log macro
id: no-println-in-lib
language: Rust
severity: warning
rule:
pattern: println!($$$ARGS)
not:
inside:
kind: function_item
regex: 'fn main'
stopBy: end
message: ライブラリコードで println! を使わない。log::info! 等を使う。
fix: log::info!($$$ARGS)
files:
- "src/lib.rs"
- "src/**/mod.rs"
- "src/**/*.rs"
ignores:
- "src/main.rs"
- "src/bin/**"
Go: detecting ignored errors
id: no-ignored-error
language: Go
severity: error
rule:
kind: short_var_declaration
has:
kind: identifier
regex: '^_$'
field: left
has:
kind: call_expression
field: right
stopBy: end
message: エラーを _ で無視しない。適切にハンドリングする。
Go: preventing forgetting to Close with defer
id: defer-close-after-open
language: Go
severity: warning
rule:
kind: short_var_declaration
has:
pattern: os.Open($PATH)
field: right
stopBy: end
not:
precedes:
pattern: defer $_.Close()
stopBy:
kind: return_statement
message: os.Open の直後に defer Close() を入れる。
Python: banning bare except
id: no-bare-except
language: Python
severity: warning
rule:
kind: except_clause
not:
has:
kind: identifier
stopBy: neighbor
message: bare except を使わない。具体的な例外型を指定する。
Python: migrating print() to a logger
id: no-print-in-src
language: Python
severity: warning
rule:
pattern: print($$$ARGS)
not:
inside:
kind: function_definition
regex: 'def main'
stopBy: end
message: print() ではなく logger を使う。
fix: logger.info($$$ARGS)
files:
- "src/**"
References
Details within this skill
- references/rule-yaml.md — All rule YAML fields, evaluation order, metavariable binding scope,
any:+ fix merging/splitting,$$$ARGSempty matches, etc. - references/testing.md — Classification tests vs snapshots, multi-line code notation, snapshot operation
- references/cli.md — Subcommands and flags in general,
--error/ exit code /--format json - references/kind-catalog.md — kind catalog by language (TypeScript / Rust / Go / Python)
Official
- ast-grep docs: https://ast-grep.github.io/
- Rule reference: https://ast-grep.github.io/reference/yaml.html
- sgconfig: https://ast-grep.github.io/reference/sgconfig.html
- Playground: https://ast-grep.github.io/playground.html
- Rule catalog: https://ast-grep.github.io/catalog/