skills/structural-refactor/SKILL.md (5043 bytes)
1 --- 2 name: structural-refactor 3 description: AST-aware structural search and replace across a codebase using ast-grep. Use for renames, function signature changes, API migrations, deprecation sweeps, or codemods spanning more than two files; for finding all call sites of a pattern rather than a string; and for writing project-specific lint rules that encode team conventions. Use instead of sed, awk, or regex whenever the target is code rather than plain text. 4 --- 5 6 # Structural refactor 7 8 Regex does not understand code. It matches inside strings, comments, and 9 unrelated identifiers, and it misses anything reformatted across lines. That is 10 how half-finished renames ship. `ast-grep` matches on the syntax tree. 11 12 Order of preference for a rename or signature change: 13 14 1. **LSP rename** (`rename_symbol`) if the language server supports it — it 15 understands scope and imports, and is always correct. 16 2. **ast-grep** for anything the LSP cannot express: cross-language sweeps, 17 call-pattern rewrites, argument reordering, API migrations. 18 3. **sed** never, for code. 19 20 ## Preflight 21 22 ```bash 23 command -v ast-grep || command -v sg 24 ast-grep --version 25 ``` 26 27 Install: `cargo install ast-grep`, `npm i -g @ast-grep/cli`, `brew install 28 ast-grep`. The binary is `ast-grep`, aliased `sg` (which collides with 29 `sg(1)` on some Linux systems — prefer the full name in scripts). 30 31 ## Pattern syntax 32 33 | Token | Matches | 34 |---|---| 35 | `$VAR` | exactly one named node, captured | 36 | `$$$ARGS` | zero or more nodes (argument lists, statement bodies), captured | 37 | `$_` | one node, not captured | 38 39 Patterns are written as code, not as regex. Whitespace and formatting are 40 irrelevant; structure is what matters. 41 42 ## Workflow — always in this order 43 44 **1. Search first. Read the matches before rewriting anything.** 45 46 ```bash 47 ast-grep run -p 'db.query($SQL, $$$ARGS)' -l python 48 ``` 49 50 Count them, and check for false positives: 51 52 ```bash 53 ast-grep run -p 'db.query($SQL, $$$ARGS)' -l python --json=compact | jq length 54 ``` 55 56 **2. Preview the rewrite as a diff.** `run` with `--rewrite` prints a diff and 57 changes nothing until told to apply. 58 59 ```bash 60 ast-grep run -p 'db.query($SQL, $$$ARGS)' \ 61 --rewrite 'db.execute($SQL, params=[$$$ARGS])' \ 62 -l python 63 ``` 64 65 **3. Apply.** Use `--interactive` to accept or reject each hunk when the pattern 66 is loose, or `--update-all` for a bulk apply once the diff has been reviewed. 67 68 ```bash 69 ast-grep run -p '...' --rewrite '...' -l python --update-all 70 ``` 71 72 **4. Verify.** Formatter, type checker, tests. A structural rewrite that 73 type-checks is usually correct; one that does not, is not. Commit the codemod 74 separately from any behavioural change so the diff stays reviewable. 75 76 Work on a clean tree — `git status` before, `git diff` after. `--update-all` 77 edits files in place with no undo. 78 79 ## Rule files 80 81 Anything run more than once, or enforced on every PR, belongs in a rule file 82 rather than a shell command. Rules support constraints patterns cannot express: 83 `inside`, `has`, `not`, `follows`, and metavariable regex filters. 84 85 ```yaml 86 # rules/no-bare-except.yml 87 id: no-bare-except 88 language: python 89 severity: error 90 message: Bare except swallows KeyboardInterrupt and SystemExit. 91 note: Catch a specific exception type, or `except Exception` if truly generic. 92 rule: 93 pattern: | 94 try: 95 $$$BODY 96 except: 97 $$$HANDLER 98 ``` 99 100 ```bash 101 ast-grep scan --rule rules/no-bare-except.yml 102 ast-grep scan # all rules, via sgconfig.yml 103 ``` 104 105 `sgconfig.yml` at the repo root registers the rule directories: 106 107 ```yaml 108 ruleDirs: 109 - rules 110 ``` 111 112 Verify exact rule schema fields against `ast-grep scan --help` and the 113 installed version's docs — the YAML schema has evolved. 114 115 This is the cheapest way to encode a convention that keeps recurring in review. 116 When the same correction is made twice, write a rule instead of a third comment. 117 118 ## Common patterns 119 120 ```bash 121 # Find all callers of a method, regardless of receiver name 122 ast-grep run -p '$OBJ.deprecated_method($$$)' -l ts 123 124 # Add an argument to every call 125 ast-grep run -p 'connect($HOST, $PORT)' \ 126 --rewrite 'connect($HOST, $PORT, timeout=30)' -l python 127 128 # Unwrap a redundant wrapper 129 ast-grep run -p 'str($X)' --rewrite '$X' -l python # review each: too loose 130 131 # Locate every unsafe block in a Rust crate 132 ast-grep run -p 'unsafe { $$$BODY }' -l rust 133 ``` 134 135 The `str($X)` example is deliberately included as a warning: it matches cases 136 where the conversion is load-bearing. Loose patterns need `--interactive`, not 137 `--update-all`. 138 139 ## Limits 140 141 - ast-grep is syntactic, not semantic. It cannot resolve imports, aliases, or 142 types. `foo.bar()` and `from x import bar; bar()` are different patterns. 143 - For type-directed refactors, use the language's own tooling: `cargo fix`, 144 `tsc` codemods via ts-morph, `gofmt -r` or `gopls rename`. 145 - After any large sweep, grep for the old name anyway to catch what the pattern 146 missed — string literals, docs, config, comments. Those are text, and text 147 tools are correct for them.