Imported from bsamiee/Parametric_Forge (
.claude/skills/ast-grep/SKILL.md). Install upstream withnpx skills add bsamiee/Parametric_Forge --skill ast-grep. Copyright stays with the author.
[AST_GREP]
Structural code work — map, find, prove, lint, or rewrite code by its syntax tree — runs on ast-grep. Read the section owning the task in full and follow its steps before touching a source file. MCP tools own search and rule development; the CLI owns structural maps (ast-grep outline), project rule scans (ast-grep scan), and applied rewrites.
find_code: pattern search over a path.find_code_by_rule: inline YAML rule search for structures a bare pattern cannot express.dump_syntax_tree: node kinds and structure of a snippet — the debugging surface for every non-match.test_match_code_rule: proves a rule against representative code before it runs anywhere.
Every rule proves through test_match_code_rule at omitted or warning severity — an error diagnostic exits nonzero and the tool discards the JSON, so the shipped severity returns only at landing. Every non-match debugs through dump_syntax_tree against the target snippet. Search rules ride inline; durable rules are project rule files discovered through sgconfig.yml.
[01]-[ROUTING]
[TEMPLATES]:
- [01]-SGCONFIG: Project config — six-key surface, snapshot custody, parser overrides, injection rows.
- [02]-RULE: Lint rule with the full field set and a
---sibling — the topic-file form every durable rule authors from. - [03]-RULE_REWRITE: Patch algebra — rewriters,
rewrite()transform,joinBy, theFixConfigdeletion doc. - [04]-UTIL: Global utility rule — parameterized
arguments:, kind guard, call form, local-shadow law. - [05]-RULE_TEST: Test file — id-bound
valid:/invalid:fixtures; snapshots write viatest -U.
[EXAMPLES] — proven rules, test files beside them under rule-tests/; pick by mechanism and re-shape, never copy whole:
- [01]-YAML: Required shape by depth — relational descent to the owner, direct-child absence chain.
- [02]-BASH: Container-anchored absence — the file is the match, a direct-child
not: hasproves it. - [03]-TYPESCRIPT: Missing option — neighbor-
hasprecision no nested callback satisfies. - [04]-TYPESCRIPT: Construct conversion — rewriters fold rows, guards refuse unfixable variants.
- [05]-CSHARP: Ordering proof — capture unification across
hasandprecedes. - [06]-CSHARP: Dispatch conversion — context/selector, derived slots, dual guards.
- [07]-PYTHON: Signature-scoped ban — nearest-enclosing
stopByanchors on the return type. - [08]-PYTHON: Statement-to-expression fold — totality closure, parenthesized rungs.
[02]-[OUTLINE]
ast-grep outline maps source structure before any full read or edit: line-numbered top-level items (imports, functions, classes, structs, interfaces, modules, enums — flagged imported/exported) with their direct members (fields, methods, constructors, variants — flagged public). Output is syntax-local — no reference resolution, type inference, re-export chains, or call graphs; those questions route through [03]-[SEARCH] after outline names the files.
Every outline task runs one sequence: resolve target paths from the task, search hits, or git diff --name-only; run the owning row; escalate the located symbol with --match <symbol> --view expanded; then Read only the printed line range.
| [INDEX] | [TASK] | [COMMAND] |
|---|---|---|
| [01] | Map a directory surface | ast-grep outline <dir> — grouped exported names; --type <t1>,<t2> narrows |
| [02] | Understand a file before editing | ast-grep outline <file> — local structure with member digests |
| [03] | List a file's dependencies | ast-grep outline <file> --items imports |
| [04] | Find importers of a module | ast-grep outline <dir> --items imports --match <module> --view signatures |
| [05] | Enumerate public entry points | ast-grep outline <dir> --items exports --view signatures |
| [06] | Zoom into one symbol | ast-grep outline <file> --match <symbol> --type <type> --view expanded |
| [07] | Map structure after edits | ast-grep outline $(git diff --name-only HEAD) --items exports |
| [08] | Outline piped code | <producer> | ast-grep outline --stdin -l <lang> |
| [09] | Post-process entries | ast-grep outline <path> --json=stream — one file object per line, jq rails |
--items structure\|exports\|imports\|allselects top-level entries; defaults key on input — file/stdinstructure, any directoryexports.--view names\|signatures\|digest\|expandedsets detail ascending; defaults key on input — file/stdindigest, any directorynames.--matchis case-sensitive Rust regex over item names, signatures, and first source lines;--typefilters symbol types; neither reaches members.--pub-membershides private members; a member without extractable visibility counts as public.- JSON entries carry
symbolType,role, zero-basedrangewith byte offsets,signature,astKind, and import/export/public flags. - Uncovered syntax registers extractors via
--outline-rules <file>orcustomLanguages.<name>.outlineRulesinsgconfig.yml.
[03]-[SEARCH]
Structural search runs on the MCP tools; rules ride inline YAML. Patterns are valid code under the language's tree-sitter grammar, carrying whole-node metavariables: $VAR one named node, $$VAR one unnamed node, $$$MULTI lazy zero-or-more, $_ non-capturing. Smart matching skips unnamed target nodes, so the less a pattern specifies, the more it matches — anchor only what the query fixes.
Every search runs one sequence:
- Query fits one AST node —
find_codewith the pattern; done. - Anything structural — compose the scaffold: anchor the most specific positive rule, refine relationally, filter captures.
- Unknown node kind —
dump_syntax_treeon representative target code withformat=cst; a mis-parsing pattern takesformat=pattern. - Prove with
test_match_code_ruleagainst one matching and one non-matching snippet; every non-match returns to step 3. - Run
find_code_by_rulewith absoluteproject_folderand boundedmax_results;output_format=jsonwhen captures or ranges feed the next step.
id: <query-id>
language: <language>
utils:
<util-id>: # define once, reference via matches; recursion lands through has/inside
any: [ { kind: <kind-a> }, { kind: <kind-b> } ]
rule:
all: # explicit all — capture order is law, the defining pattern rides first
- pattern: <code with $VAR and $$$ARGS>
- has: { pattern: <sub-pattern>, stopBy: end } # stopBy picks the axis: neighbor pins depth, end opens it, a rule bounds the walk
- not: { inside: { kind: <kind>, stopBy: end } }
constraints:
<VAR>: { regex: '<rust-regex>' } # post-rule text filter on one single-node capture
- Kind-only structure rides ESQuery in
kind:<a> > <b>,<a> <b>,<a>:has(> <b>),:not(<b>),:is(<a>,<b>),:nth-child(2n+1 of <b>). patternandkindnever combine to reparse; a wrong-kind pattern repairs throughpattern: { context: <full-code>, selector: <kind> }.- Prefix name hunts capture whole nodes:
$NAME($$$)withconstraints: NAME: { regex: '^<prefix>' }—use$HOOKis no metavariable. field: <role>onhas/insidepins same-kind children by parent relation —field: keysplits an object key from its value.- Rules anchor on at least one positive field,
patternorkind;regex,field, or a negation alone never anchors. - Capture threading: the first rule naming
$VARdefines its content, later rules only re-match it — orderallaccordingly. strictness: relaxedwidens past comments and unnamed nodes;signaturealone drops text — take either only when a proven match is blocked.
[04]-[REWRITE]
Rewrite extends a proven [03]-[SEARCH] rule with patching fields — each match replaces exactly one target node's text with the instantiated template. Templates are unparsed text: metavariables land anywhere, an undefined metavariable fails the parse under scan and lands empty under run -r, a declared-but-unmatched one lands empty in both, and $VARName lexes as $VARN + ame — appended text rides a replace transform. Multiline templates re-indent relative to the match's column. run carries the pattern-only path; the full algebra — fix, FixConfig, transform, rewriters — rides scan --inline-rules.
Every rewrite runs one sequence:
- Prove the match set per [03]-[SEARCH] until results are exactly the edit set;
--jsonmatch and file counts bound the blast radius. - Attach the owning modality row; derived text lands in
transform, multi-node edits inrewriters. - Prove the fix through
test_match_code_rule— the JSON carriesreplacementandreplacementOffsets; the replacement must re-parse. - Preview the tree diff:
ast-grep scan --inline-rules '<yaml>' <paths>prints diffs and writes nothing;--jsonstays read-only even with-U. - Apply with
-U(-Uoverrides-i); nested matches rewrite outer-first, so re-run until zero changes — depth and idempotence prove together. - Close with the repository's formatter (
nix fmt -- <target>here); comments never rewrite — sweep leftovers through [03]-[SEARCH]regex.
| [INDEX] | [REWRITE] | [SHAPE] |
|---|---|---|
| [01] | Rename/replace | ast-grep run -l <lang> -p '<pattern>' -r '<template>' -U <paths> |
| [02] | Mass removal | fix: ''; a dangling separator dies via fix: { template: '', expandEnd: { regex: ',' } } |
| [03] | Derived names | NEW: replace($VAR, replace=<re>, by=<txt>) or convert($VAR, toCase=<case>); fix takes $NEW |
| [04] | List rewrite | rewriters: [{id: <r>, rule: <sub>, fix: <t>}] + OUT: rewrite($$$L, rewriters=[<r>], joinBy=<sep>) + fix: $OUT |
| [05] | Element filter | rewriter emits survivors — rule: { pattern: $ARG, not: <dropped-shape> }, fix: $ARG, joinBy: ', ' |
| [06] | Recursion | rewriter transform names its own id over a strictly smaller source; a non-descending self-match overflows |
| [07] | Bundle | one inline YAML, rules split by ---; identical-range fix collisions resolve by ascending rule id |
| [08] | Stream | <producer> | ast-grep scan --inline-rules '<yaml>' --stdin -U — rewritten source on stdout, disk untouched |
rewritersis a sequence with load-bearing order: per node the first matching rewriter wins; a consumed subtree never re-matches.- Rewriter scope seals — metavariables,
transform,utilsstay inside their rewriter; cross-reference rides only ids insiderewrite(). - Optional trailing commas take a second pattern variant under
any:;joinByabsent splices in place, present discards unmatched text. - Risk splits into sibling docs: the guarded rule carries
fix, the residual carriesseverity: errorplusnotefor manual action. - A capture landing in an operator, member-access, or return position takes parentheses in the template — precedence dies in substitution.
runexits 1 on zero matches and 0 on a hit — inverted againstscan; shell chains over the two verbs read opposite ways.
[05]-[RULE_CRAFT]
Rule precision is engineered: each device below is the standing form for its problem, developed per [03]-[SEARCH] and landing unchanged in durable files.
- Anchor every
regexto the ends its law fixes — matching is unanchored substring search;Exemptionalso matchesNoExemptionHere. - Captures unify by default: one
$VARacross clauses proves sameness,not:on a rebound pattern proves difference,$_VARopts out of both. nthChildtakesofRulefor nth-of-kind — bare positions count all named siblings;{position: 1, reverse: true, ofRule}pins the last arm.$$$captures take structural guards or arewritetransform;constraintson them parses and does nothing.stopByaxes three ways: default neighbor direct relations,endthe whole axis, a rule a bounded walk — same-kind stopper pins the nearest owner.field:binds the final relation and survivesstopBy: end— callee vs argument, key vs value; field names read off thedump_syntax_treecst.rule:demands a positive anchor;constraints:does not — negative and relational capture guards live there.- Totality closures (
not: {has: {not: {any: [...]}}}) need a positive member whitelist in delimiter grammars — unnamed children defeat the negation. - A totality closure proves the container's children only; an allowed member's own interior — an
elifarm, a nested body — takes its own arm. - Constraints run after the whole rule — a capture guard cannot narrow a
not:; the negation matches everything and the rule dies silent. - Marker exemptions anchor structurally — a comment
precedes: { kind: <body> }marks its owner; a mark on a descendant proves nothing. - Ordering rules bind statement nodes: a bare expression pattern has no statement siblings — wrap
precedes/followsincontext/selector. - A fix fires behind a guard stack: every unfixable variant — guards, discards, exports, valueless members — is a
not:arm before the template. expandStart/expandEndconsume exactly one adjacent sibling matching the sub-rule (stopByinert); a sibling-less inner node no-ops silently.- Metavariables bound anywhere export to
fix,message, andtransform;noteinterpolates nothing,labelstake rule/constraints captures only. - Suppression binds to the match's first or last line, never a middle one; report the tight offender node so the waiver lands beside the defect.
strictness: signaturematches shape while keeping capture identity — a duplicate-shape hunt, never a name ban.- Precision proves empirically before landing: scan a real corpus and count — a rule firing wide of its law is a semantic invariant in disguise.
- Grammar truth beats intuition:
dump_syntax_treedecides node wrapping and field names; a construct parsing asERRORis unenforceable.
[06]-[DURABLE_RULES]
Durable rules are project structural law as a scanned gate: sgconfig.yml at the root, every YAML under ruleDirs a rule. A rule earns admission on three proofs — the violation is provable by node shape alone, no standing gate owns it (linter, analyzer, type checker, compiler, generator diagnostic), and it encodes a project law rather than generic hygiene; a scope-, type-, or cross-file-dependent invariant never becomes a rule. One rule owns a whole violation family through any: and utils:, splitting only when message or fix diverges; shared deep structure across split rules lands in a parameterized global util carrying an explicit kind guard. Every rule is severity: error — the scan exits nonzero and blocks.
sgconfig.yml # keys: ruleDirs, utilDirs, testConfigs, languageGlobs, customLanguages, languageInjections
rules/<language>/<area>.yml # one topic file per law family; rules split by ---; id equals filename, unique project-wide
utils/<util-id>.yml # global utils: explicit id + language, own constraints; a local utils: block shadows its homonym
rule-tests/<rule-id>-test.yml # binds by id, never filename; snapshots land beside it in __snapshots__/
id: <rule-id> # imperative grammar: no-<construct> / require-<shape>
language: <language>
severity: error
files: ['<scope-glob>'] # ignores: carves out sanctioned seams; both relative to sgconfig.yml, never ./-prefixed
utils:
<util-id>: { <family-shape> }
rule: { <[03] rule> }
constraints: { <VAR>: { regex: '<grammar>' } }
fix: <template> # only when the repair is mechanical; [04] algebra applies
message: <one line naming the violated law>
note: <the exact repair — the shape to produce>
labels: { <VAR>: { style: primary, message: '<span fact>' } } # rule/constraints vars only
Every rule lands through one sequence:
- Derive the candidate from a standing project law; run the three admission proofs — a failed proof ends the candidate.
- Prove the violating node shape with
dump_syntax_treeon real violating code; a shape the grammar parses asERRORis unenforceable — stop. - Author from the owning
assets/templates/file; develop the rule per [03]-[SEARCH] and the fix per [04]-[REWRITE], proved both ways. - Ship the test file with matching id — conforming cases under
valid:, violating underinvalid:— thenast-grep test -Uwrites snapshots. - Land the rule in its owning folder;
ast-grep scan --inspect entityproves registration,--filter '<rule-id>'iterates it alone. - Gate with
ast-grep scan --no-ignore hidden --error=unused-suppression --error=no-suppress-all;testrides the same gate.
| [INDEX] | [RULE_CLASS] | [MECHANISM] |
|---|---|---|
| [01] | Banned construct | construct kinds + ignores: on sanctioned seam globs, or not: { inside: <marker-comment rule> } |
| [02] | Required shape | owner kind + not: { has: <required child, argument, or modifier> } |
| [03] | Entry-point discipline | declaration-name regex over modality-suffix and knob grammars; single-hop forwarders as patterns |
| [04] | Layer boundary | one rule per forbidden edge: import kind + path regex, files: scoping the consumer stratum |
| [05] | Policy literal | literal kinds inside policy argument and initializer positions — named rows own the values |
| [06] | Dispatch shape | dispatch kind inside a dispatch arm; catch-all arms beside closed-family arms |
| [07] | Naming grammar | name-position regex: word budget, banned generic suffixes, role-suffix bijection via not: has |
- One unparseable rule or duplicate id aborts the whole scan — inline
---bundles tolerate both; a draft never sits underruleDirs. sgconfig.ymlaccepts unknown keys silently; scoping rides per-rulefiles:/ignores:only.language:is single-valued — similar languages share rules via alanguageGlobssuperset row; embedded languages ridelanguageInjections.- Suppression is rule-scoped —
ast-grep-ignore: <rule-id>, next or same line; the two--errorbuilt-ins keep bare and stale waivers fatal. - A whole-file rule waives only file-wide: the suppression comment on line 1 over an empty line 2 — a line-scoped comment joins the match.
files:takes no!negation (silently inert) — exclusion is anignores:row; a dot-directory scope is dead without--no-ignore hidden.- Green proves nothing three ways: omitted
severitylandshint,testpasses zero cases, afiles:rule underscan -rscans nothing. - A rule matching no
invalid:fixture is dead;testnever notices a missing test file, so rule-to-test pairing proves outside the gate. metadata:carries routing facts (owner, decision id) and surfaces only under--json --include-metadata.