mcpField
A
ast-grep-practice

ast-grep-practice

github.com/mizchi测试与质量41664 次安装1 周前更新
代码仓库

关于 ast-grep-practice

ast-grep-practice 是一项技能,指导开发者在通用 Linter 无法表达规则时,用 ast-grep 编写可复现的结构化代码规则,并涵盖测试、修复与 CI 集成。

主要功能

  1. 01判断何时使用 ast-grep
  2. 02配置结构化规则
  3. 03测试驱动开发规则
  4. 04谨慎添加自动修复
  5. 05集成 CI 扫描与测试

适用场景

  1. 01检查项目专属 AST 模式
  2. 02检测并迁移弃用 API
  3. 03在 CI 前测试自定义规则
  4. 04将结构检查加入 CI

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/mizchi/skills --skill ast-grep-practice

安装后在 Claude Code 中输入/ast-grep-practice确认已加载。

包含的文件

除 SKILL.md 外,安装时一起下载的参考资料和脚本。

README.md
SKILL-ja.md
references/
cli.md
kind-catalog.md
rule-yaml.md
testing.md

详细介绍

ast-grep 实践

使用 ast-grep 补充通用 lint 工具(ESLint、oxlint、Biome、clippy 等),处理这些工具无法表达的模式。始终优先采用可复现的静态规则,而不是自然语言提示。

安装

# npm (project-local recommended)
npm install -D @ast-grep/cli
npx ast-grep --help

# or cargo
cargo install ast-grep --locked

# or brew
brew install ast-grep

包管理器选择:如果项目的 package.json 设置了 packageManager,请遵循其设置(pnpm / yarn / 等)。否则,请使用 npm 在本地安装。在 CI 中使用相同的工具(混用会导致锁文件和二进制文件解析路径分散)。全局安装仅用于开发机器;CI 和仓库内脚本必须始终使用本地引用。

快速入门

用于验证是否正常运行的最小配置:

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: Do not leave console.log behind.
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  # run tests
ast-grep scan src/                    # scan project

原则

  • 首先检查现有 lint 工具是否能覆盖该规则
  • 需要“结构化模式”时使用 ast-grep
  • 通过 TDD 开发规则:先编写测试 → 实现规则 → 集成到 CI
  • 能写 fix 时就写。优先选择带自动修复的规则,而不是仅检测规则

如何在 ast-grep 和现有 lint 工具之间做选择

情况工具
未使用的导入、no-console、命名约定ESLint / oxlint / Biome
类型错误、无法到达的代码TypeScript 编译器 / clippy
格式化Prettier / Biome / rustfmt
禁止特定的函数调用模式ast-grep
检测并重写已弃用的 APIast-grep(fix)
在特定上下文中禁止某种模式ast-grep(inside/has)
项目特定的结构约束ast-grep

以下情况表明 ast-grep 是合适的选择:

  • 无法通过配置现有规则来表达该规则
  • 规则依赖 AST 中的父级 / 子级 / 兄弟节点关系
  • 需要自动重写(迁移)

项目设置

sgconfig.yml
ruleDirs:            # required: directories holding rule files
  - rules
testConfigs:         # optional: test configuration
  - testDir: rule-tests
utilDirs:            # optional: shared utility rules
  - rule-utils
languageGlobs:       # optional: mapping for non-standard extensions (unnecessary for TS/JS/Python etc.)
  html: ['*.vue', '*.svelte', '*.astro']
目录结构
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 的目录开始,运行 ruleDirs 下的所有规则。

规则文件结构

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: Do not reference process.env directly. Go through getEnv().
note: Ensures type-safe access to environment variables.
fix: getEnv('$KEY')
files:
  - "src/**"
ignores:
  - "src/config.ts"
字段
字段必填描述
id是规则标识符
language是目标语言
rule是匹配条件
severity否hint、warning、error
message否单行描述
note否详细描述 / 迁移指南
fix否自动修复模板
constraints否对元变量的附加约束
transform否对元变量进行文本转换
files否目标 glob
ignores否排除的 glob
url否文档 URL
抑制注释
// ast-grep-ignore
someCode()

// ast-grep-ignore: no-direct-env-access
process.env.NODE_ENV

元变量的注意事项

模式匹配须知:

  • $OBJ.$PROP 只匹配点号访问,不匹配 obj['key'](方括号访问)
  • $VAR 只匹配一个 AST 节点
  • $$$VARS 匹配零个或多个 AST 节点(可变参数、多个语句等)
  • $_ 是通配符(不捕获内容)。相同名称可以匹配不同内容
  • 元变量必须占据完整节点:obj.on$EVENT 和 "hello $NAME" 均不起作用

fix(自动修复)

决定是否附加修复

fix 很方便,但由于它会自动应用,可能会改变语义。出现以下情况时,不要附加修复(仅检测):

  • 重写会改变类型安全性(例如,as any → as unknown 会改变类型推断结果)
  • 可能会改变副作用或求值顺序(短路行为、异常发生时机)
  • 由于依赖上下文,正确的替换方案并非唯一(例如,调整参数顺序的 API 迁移;必须进行审核)
  • 删除操作会与同一语句中的其他表达式产生牵连

如果拿不准,就跳过 fix,并在 note 中记录手动迁移步骤。只有确信“在所有位置替换都是安全的”时才附加 fix。

基础
rule:
  pattern: console.log($ARG)
fix: logger.info($ARG)

在 fix 模板中可以直接使用元变量。未匹配的元变量会变成空字符串。

删除
rule:
  pattern: console.log($$$ARGS)
fix: ''

fix: '' 会删除匹配的节点。请注意,可能会留下空行。如果还想删除语句末尾的 ; 或尾随逗号,务必与 expandEnd 配合使用(见下文“范围扩展”)。如果可以接受留下空行,则不需要 expandEnd。经验法则:在后续会运行格式化工具(如 Prettier 等)的项目中,空行会被自动整理,因此不需要 expandEnd;如果不会运行格式化工具,则建议使用 expandEnd。

使用 any: 合并多个模式时的修复

如果 any: 下的每个分支都可以使用相同的修复模板,就可以合并为一条规则:

rule:
  any:
    - pattern: $ARR.filter($P).length === 0
    - pattern: $ARR.filter($P).length == 0
fix: '!$ARR.some($P)'   # metavariables shared across both branches + identical template

如果各分支的修复不同,务必拆分为单独的规则(不能在 any: 中为每个分支编写单独的修复)。例如,=== 0 → !some() 和 !== 0 → some() 应拆分。即使意图相同,拆分也可以接受(将 id 对齐为 *-empty / *-nonempty 可提高可读性)。

多行
rule:
  pattern: |
    def foo($X):
      $$$S
fix: |-
  def bar($X):
    $$$S

缩进相对于原代码中的位置保持不变。

范围扩展(FixConfig)

如果想在删除时包含尾随逗号等内容:

fix:
  template: ''
  expandEnd:
    regex: ','
使用 CLI 快速重写
ast-grep run --pattern 'oldFunc($$$ARGS)' --rewrite 'newFunc($$$ARGS)' --lang typescript .
# --update-all applies everywhere without confirmation

constraints

为元变量添加额外条件。仅支持 $ARG(不支持 $$$ARGS)。规则匹配后,会根据这些条件筛选匹配项。

在 constraints 和结构约束(has/inside/not)之间进行选择:

  • 想要约束元变量的内容 → 使用 constraints(例如,$METHOD 是 get / set / delete 之一)
  • 想要约束模式外部或内部的结构 → 使用 has / inside / not / precedes / follows(例如,位于特定父节点内,或包含特定子节点)
  • 如果可以直接在模式中写出具体字面值,这是最简单的方式(pattern: new Set($X) 可确保存在 Set)

在 rule 下直接同时编写 pattern 和 has / not 时,会按 AND 进行求值(匹配模式 AND has 为 true)。使用这种结构可以添加 pattern 单独无法表达的结构约束。

rule:
  pattern: $OBJ.$METHOD($$$ARGS)
constraints:
  METHOD:
    regex: '^(get|set|delete)$'
  OBJ:
    kind: identifier

可用字段:kind、regex、pattern

注意:not 中受约束的元变量可能无法按预期运行。

transform

在 fix 中使用匹配到的元变量之前,对其进行文本转换。

replace(正则替换)
transform:
  NEW_NAME:
    replace:
      source: $NAME
      replace: 'get(\w+)'
      by: 'fetch$1'
fix: $NEW_NAME($$$ARGS)
substring
transform:
  INNER:
    substring:
      source: $STR
      startChar: 1
      endChar: -1

负索引从末尾开始计数。语义与 Python 切片相同。

convert(大小写转换)
transform:
  SNAKE:
    convert:
      source: $NAME
      toCase: snakeCase
      separatedBy: [caseChange]

支持的格式:camelCase、snakeCase、kebabCase、pascalCase、upperCase、lowerCase、capitalize

rewrite(实验性)

使用重写规则递归重写元变量中的节点。

transform:
  REWRITTEN:
    rewrite:
      source: $$$BODY
      rewriters: [migrate-api-call]
      joinBy: "\n"

utils(工具规则)

使用 matches 引用 utilDirs 下定义的共享规则。

# 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 function lacks try-catch.
severity: warning

测试

测试分为两种,不要混为一谈:

  • 分类测试(test --skip-snapshot-tests):仅验证 valid / invalid 下列出的代码是否被正确分类。这是在 CI 中运行的测试。
  • 快照测试(test / test -U):将无效代码中的匹配位置和修复结果保存为快照,以检测回归。首次使用 -U 生成快照,之后由人工审核。提交 CI 前至少运行一次。
测试文件格式

测试文件中的 id 必须与规则文件的 id 相同。文件名可自行决定(惯例:{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
运行测试
# classification test (is valid/invalid correct?)
ast-grep test --skip-snapshot-tests

# generate / update snapshots
ast-grep test -U

# interactive snapshot review
ast-grep test --interactive

测试结果标记:

  • .:通过
  • N:有噪声(误报——匹配到有效代码)
  • M:缺失(漏报——未能匹配到无效代码)
工作流
  1. 在 rule-tests/ 下编写测试文件(Red)
  2. 在 rules/ 下编写规则(Green)
  3. 使用 ast-grep test --skip-snapshot-tests 验证
  4. 使用 ast-grep test -U 生成快照
  5. 审核快照并提交

CI 集成

justfile
ast-grep-test:
  ast-grep test

ast-grep-lint:
  ast-grep scan

check: format-check typecheck ast-grep-lint test
GitHub Actions

使工具与开发环境保持一致(如果项目使用 pnpm,则在 CI 中使用 pnpm;如果使用 npm,则使用 npm):

- uses: actions/setup-node@v7
  with: { node-version: 24, cache: npm }   # for pnpm projects: pnpm/action-setup@v6 + cache: pnpm

- run: npm ci   # for 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

严重性和退出代码:

  • 默认情况下,如果至少有一项发现的严重性为 error,ast-grep scan 会以非零状态退出
  • 传入 --error 后,warning / hint 也会导致非零状态退出(当 CI 也应在出现警告时失败时使用)
  • 可以指定严重性,例如 --error=error,逐步收紧规则
  • --format json 可生成结构化输出(用于与其他工具集成)

查找 kind 名称

kind 名称取决于语言的 Tree-sitter 语法。

# AST dump (named nodes only, use these when writing rules)
ast-grep run --pattern 'YOUR_CODE' --lang typescript --debug-query=ast

# CST dump (all nodes, including anonymous tokens)
ast-grep run --pattern 'YOUR_CODE' --lang typescript --debug-query=cst

请参阅 references/kind-catalog.md,了解各语言常见 kind 的目录(涵盖 TypeScript / Rust / Go / Python)。

实用规则示例

TypeScript:禁止 as any 类型转换(仅检测,不修复)
id: no-as-any
language: TypeScript
severity: error
rule:
  pattern: $EXPR as any
message: as any disables the type system. Go through as unknown or a type guard.
note: |
  Why no auto-fix: mechanically replacing `as any` → `as unknown` changes type
  inference results and introduces new compile errors at call sites. Detection-only,
  migrate manually.

匹配 as any 这样的类型断言时,$EXPR as any 可作用于 as_expression 节点。$EXPR 会匹配整个左侧部分,因此既能匹配 JSON.parse(raw) as any,也能匹配 (value as any)。

TypeScript:替换已弃用的 API
id: migrate-old-api
language: TypeScript
severity: error
rule:
  pattern: oldClient.fetch($URL, $OPTS)
fix: newClient.request($URL, $OPTS)
message: oldClient.fetch is deprecated. Migrate to newClient.request.
禁止特定导入
id: no-lodash-import
language: TypeScript
severity: warning
rule:
  pattern: import $_ from 'lodash'
message: Do not import lodash wholesale. Use lodash/xxx.
fix: import $_ from 'lodash/xxx' // TODO: fix the correct path
TypeScript:禁止在 React 组件中直接调用 fetch
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: Do not fetch directly inside a component. Use hooks or a server action.
Rust:禁止使用 unwrap()
id: no-unwrap
language: Rust
severity: warning
rule:
  pattern: $EXPR.unwrap()
  not:
    inside:
      kind: function_item
      regex: '#\[test\]'
      stopBy: end
message: Do not use unwrap() outside tests. Use ? or expect().
note: unwrap() panics, so avoid it in production code.
Rust:标记 unsafe 代码块
id: flag-unsafe-block
language: Rust
severity: warning
rule:
  kind: unsafe_block
message: unsafe block. Explain the safety rationale in a comment.
Rust:将 println! 迁移到日志宏
id: no-println-in-lib
language: Rust
severity: warning
rule:
  pattern: println!($$$ARGS)
  not:
    inside:
      kind: function_item
      regex: 'fn main'
      stopBy: end
message: Do not use println! in library code. Use log::info! etc.
fix: log::info!($$$ARGS)
files:
  - "src/lib.rs"
  - "src/**/mod.rs"
  - "src/**/*.rs"
ignores:
  - "src/main.rs"
  - "src/bin/**"
Go:检测被忽略的错误
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: Do not ignore errors with _. Handle them appropriately.
Go:防止遗漏 defer Close()
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: Add defer Close() immediately after os.Open.
Python:禁止裸 except
id: no-bare-except
language: Python
severity: warning
rule:
  kind: except_clause
  not:
    has:
      kind: identifier
      stopBy: neighbor
message: Do not use bare except. Specify the exception type.
Python:将 print() 迁移到日志记录器
id: no-print-in-src
language: Python
severity: warning
rule:
  pattern: print($$$ARGS)
  not:
    inside:
      kind: function_definition
      regex: 'def main'
      stopBy: end
message: Use logger instead of print().
fix: logger.info($$$ARGS)
files:
  - "src/**"

参考资料

技能内详情
官方资料

评论

支持 Markdown