What this repo delivers: a tree-sitter grammar for Svelte 5 single-file
components, oracle-proven against svelte/compiler 5.56.4, registered as an
ast-grep custom language with JS/TS + CSS language injection. It is
strict-green: bun run gates proves every coverage leaf against the compiler
oracle and the grammar, EBNF ⇄ grammar parity, the full 41-type Svelte-AST
denominator, and zero ERROR/MISSING nodes over a 4900-file real-world
corpus. The output is a .so you register in any repo's sgconfig.yml so
ast-grep can structurally search, lint, and rewrite .svelte files by node kind
and field.
This README is an integration guide for an AGENT. It is written so another coding agent can digest this repo and wire the proven grammar into a different target repo's ast-grep setup, end to end, from copy-pasteable commands. The guide leads; the "Working ON the grammar" section at the bottom is for hacking on the grammar itself. Every command below was verified live against the real artifacts in this repo (
@ast-grep/cli0.42.3, tree-sitter 0.26.9, macOS/arm64).
You are integrating a compiled tree-sitter parser (svelte.so) into a target
repo's ast-grep. The parser is produced from vendored C sources in this repo and
compiled locally with a system C compiler — there is no tree-sitter-cli at consume
time. Once registered, ast-grep parses .svelte files into a concrete syntax tree
whose node kinds and fields your rules target (kind: each_block,
has: {field: key, ...}), and language injections re-parse the embedded
<script> / <style> / {…} regions as TypeScript / CSS so rules can match
inside them.
| Tool | Version | Role in integration |
|---|---|---|
@ast-grep/cli |
0.42.3 | Consumes svelte.so via customLanguages. Must be ABI-15-compatible (see below). |
C compiler (cc — Apple clang / gcc) |
system | Compiles svelte.so from the vendored parser.c + scanner.c. |
tree-sitter-cli |
0.26.9 | Upstream only — regenerates parser.c from grammar.js inside this repo. NOT needed in the target repo. |
svelte / svelte-check / typescript |
5.56.4 / 4.4.8 / 6.0.3 | The oracle that proved the grammar here. Not a consume-time dependency of the target repo. |
The vendored/compiled svelte.so is tree-sitter ABI 15 (verified:
grep LANGUAGE_VERSION tree-sitter-svelte/src/parser.c → #define LANGUAGE_VERSION 15).
ast-grep can only load a parser whose ABI it supports. The target repo's
@ast-grep/cli MUST be an ABI-15-compatible release — the 0.42.x line
(0.42.3 here; 0.42.2 is also proven, see below). An ABI mismatch is the #1
integration failure, and its symptom is unmistakable:
Incompatible language version N. Compatible range: X - X. Got: Y
If you see that, the target's ast-grep is too old/new for this ABI — align it to
0.42.x (or rebuild the grammar against the target's ast-grep ABI, see
Refreshing). Version-skew
note: the injection/metavar contract was probed on ast-grep 0.42.3; the full
customLanguages + languageInjections surface used here was independently
re-verified inert on 0.42.2 (project-xavier's pin). The customLanguages /
languageInjections surface is unchanged across that patch delta.
- Target repo has no ast-grep → do Case A first, then Case B.
- Target repo already runs ast-grep (has an
sgconfig.yml, alint:astscript) → go straight to Case B. This is the core case.
Goal: get an ABI-compatible ast-grep runnable and a minimal sgconfig.yml, then
hand off to Case B.
Node repo (has package.json) — preferred:
bun add -D @ast-grep/cli@0.42.3 # or, if your target repo uses npm/pnpm: npm install --save-dev / pnpm add -D @ast-grep/cli@0.42.3WHY exact 0.42.3: it is the ABI-15 release the grammar was proven against. A
floating range risks pulling an ABI-incompatible ast-grep and the
Incompatible language version failure above.
Non-Node repo (no package.json) — pick one:
cargo install ast-grep --locked --version 0.42.3 # single binary, `ast-grep` (alias `sg`)
# or run without installing, per-invocation:
bunx @ast-grep/cli@0.42.3 --versionIf you install globally instead of as a devDep, make sure the same pinned version is what CI runs — a globally-newer
ast-grepis exactly how the ABI mismatch sneaks in.
bunx ast-grep --version # expect: ast-grep 0.42.3 (or your pinned 0.42.x)At the target repo root:
# sgconfig.yml — minimal starting point
ruleDirs:
- rules # create this dir; put *.yml rules here (can be empty for now)bunx ast-grep --version && echo "ast-grep ready"Now proceed to Case B to add the Svelte language.
This is the core integration. Five steps: obtain the .so → guard it with a
sync check → register it → wire install/CI → verify. All paths below are relative
to the target repo root unless stated. The convention used here (mirroring the
project-xavier reference integration) is a tools/ast-grep-svelte/ directory in the
target repo holding the vendored sources + build/verify scripts.
Approach: vendor the grammar's C source inputs and compile the .so locally.
WHY build-from-source rather than committing a prebuilt binary:
parser.c/scanner.care platform-independent C — one vendored copy builds on every dev machine and CI runner (Linux.so, macOS Mach-O dylib) with the system compiler. No per-target binary matrix, no runtime resolver.- The compiled
.sois a per-target binary — gitignore it and rebuild on install (the same pattern native.nodebindings use). Reviewers diff readable C sources in git, never an opaque binary. - No tree-sitter-cli at consume time — the CLI is only used upstream in this repo
to regenerate
parser.cfromgrammar.js.
Vendor this exact source set from this repo's tree-sitter-svelte/ into the
target's tools/ast-grep-svelte/ (same relative layout). These 8 files fully
determine svelte.so:
grammar.js # human grammar source (regeneration input / reference)
src/parser.c # GENERATED parser (platform-independent C, ~1.6 MB)
src/scanner.c # external scanner (emits the embedded-region tokens)
src/grammar.json # grammar metadata
src/node-types.json # every node kind + field (the rule-author reference)
src/tree_sitter/alloc.h # compile headers
src/tree_sitter/array.h
src/tree_sitter/parser.h
Do NOT vendor svelte.so, test/, node bindings, tree-sitter.json, or
package.json — they are not compile inputs.
Compile command (this is the exact invocation tree-sitter build runs
internally; verified live — produces a loadable Mach-O arm64 / ELF .so):
cd tools/ast-grep-svelte
cc -shared -fPIC -fno-exceptions -g -O2 \
-I src \
-o svelte.so \
src/scanner.c \
-xc src/parser.c-fPIC -shared→ adlopen-able dynamic library. macOS clang emits a Mach-O dylib under the.soname; ast-grep's loaderdlopens it regardless of extension, so a singlesvelte.soname works cross-platform.- The vendored
scanner.cemits a few benign-Wincompatible-pointer-typeswarnings under Apple clang (an upstreamArray()-macro typing quirk). Do NOT "fix" them in the vendored copy — that forks it from source-of-truth and breaks the sync check (B2). Any real fix lands upstream in this repo and flows back via the refresh process.
Package this as tools/ast-grep-svelte/build.sh so install/CI can call it. Make
the missing-compiler case non-fatal (skip with guidance, exit 0) so a
frontend-only pnpm install doesn't hard-break — but note (B4) that an absent
svelte.so still hard-fails ast-grep scan:
#!/usr/bin/env bash
set -euo pipefail
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SRC="$HERE/src"; OUT="${AST_GREP_SVELTE_OUT:-$HERE/svelte.so}"
CC="${CC:-cc}"
if ! command -v "$CC" >/dev/null 2>&1; then
echo "[ast-grep-svelte] no C compiler ('$CC') — skipping svelte.so build (ast-grep will report svelte unavailable)."
exit 0 # non-fatal, like the native-binding postinstall precedent
fi
[ -f "$SRC/parser.c" ] && [ -f "$SRC/scanner.c" ] || { echo "vendored sources missing under $SRC" >&2; exit 1; }
"$CC" -shared -fPIC -fno-exceptions -g -O2 -I "$SRC" -o "$OUT" "$SRC/scanner.c" -xc "$SRC/parser.c"
echo "[ast-grep-svelte] built: $OUT"Gitignore the artifact (tools/ast-grep-svelte/.gitignore):
svelte.so
svelte.dylib
*.o
*.dSYM/ # clang -g debug bundles emitted alongside svelte.so on macOSThe vendored copy can silently drift from this repo's proven grammar. Ship a content-hash check so the target's copy provably matches a known-good grammar. Combined-hash method (order-stable, path-relative):
# combined SHA-256 of the vendored source set, computed from inside tools/ast-grep-svelte/
printf '%s\n' grammar.js src/parser.c src/scanner.c src/grammar.json \
src/node-types.json src/tree_sitter/alloc.h src/tree_sitter/array.h \
src/tree_sitter/parser.h | LC_ALL=C sort | xargs shasum -a 256 | shasum -a 256 | awk '{print $1}'Package as tools/ast-grep-svelte/verify-sync.sh with three behaviours:
- Vendored vs recorded hash — recompute the combined hash; compare to a
RECORDED_SHA256pinned in the script. Mismatch → exit 1 (the vendored copy changed without re-recording). - Peer present (a sibling checkout of this grammar repo, or
AST_GREP_SVELTE_PEER=<path>) → also hash the peer'stree-sitter-svelte/and cross-check. Drift → exit 1. - Peer absent (CI and most checkouts have no peer checkout) → print
peer:absent, exit 0. WHY: an absent peer is not drift; the recorded-hash check still guarantees the vendored copy is the one you recorded.
Record the hash of the grammar you actually vendor. As of this writing, the combined
hash of this repo's current proven tree-sitter-svelte/ is:
cdefbbdfd0d13c25789f94681e853193ab97b90b941e62d68237db24d1ae742f
Recompute it yourself at vendor time (grammar advances) and pin that value.
Add the customLanguages block and the languageInjections matrix. Both blocks
below are the frozen contract — copy verbatim, adjusting only libraryPath to
where B1 writes the .so (path is resolved relative to sgconfig.yml):
customLanguages:
svelte:
libraryPath: tools/ast-grep-svelte/svelte.so # relative to THIS file
extensions: [svelte]
languageInjections:
- hostLanguage: svelte
rule: { kind: raw_text_css, pattern: $CONTENT }
injected: css
- hostLanguage: svelte
rule: { kind: raw_text_js, pattern: $CONTENT }
injected: typescript
- hostLanguage: svelte
rule: { kind: raw_text_expr, pattern: $CONTENT }
injected: typescriptThe load-bearing details, each with its WHY:
- Map key
svelte= the grammarname. ast-grep therefore loads the default exported symboltree_sitter_svelte— nolanguageSymboloverride needed. pattern: $CONTENTis mandatory on every injection. A barerule: { kind: raw_text_js }with no pattern injects nothing (probed on 0.42.x — the scan returns empty). The$CONTENTcapture of the whole region is what makes injection fire.<script>bodies injecttypescript— the SUPERSET policy, NOT per-attribute ts/js. Valid JS ⊆ valid TS, so alang="ts"body and a plain<script>body both parse error-free and are always scanned. Per-attribute selection (ts whenlang="ts", else js) is expressible via a relational rule, but was rejected: on a file with BOTH alang="ts"and a plain<script>, ast-grep realizes only one injection per host kind, leaving the other body silently unscanned — a completeness gap for a linter. The relational alternative is a documented one-edit-away migration if a consumer genuinely needs the split.raw_text_expr(interpolations / block headers /{@html}/ directive values) injectstypescriptso rules match$state, handler calls, member expressions inside{…}. Bounded caveat: a destructuring each-header{#each x as {a, b}}reparses as a TS block, not anobject_pattern, so rules targeting destructured names in each-headers won't match reliably — no host-tree corruption, hostkind: each_blockscanning unaffected.- No
expandoChar. The standard$VAR/$$$metavar sigils stand. Svelte's$-runes are lowercase ($state) so they never collide with the$UPPERsigil, and legacy$$props/$$restPropsappear only inside opaqueraw_text_exprregions.
Full probe evidence for every one of these: this repo's
docs/decisions/0002-injection-and-metavars.md.
The critical fact: ast-grep loads a registered custom language's library
eagerly. With svelte.so absent, ast-grep scan does not degrade — it
hard-fails with exit 79, "Cannot load custom language library" (verified live).
Therefore svelte.so MUST exist before any ast-grep scan (lint:ast) runs, on
every machine and CI job. Two required hooks:
-
Install / postinstall — build the
.soon dependency install, guarded non-fatally (frontend-only contributors and pre-Rust-step CI have no compiler; the build self-skips there):# in the repo's postinstall step bash tools/ast-grep-svelte/build.sh || \ echo "WARNING: svelte.so build failed — lint:ast will fail until it is built."
-
CI (the
lint:astjob) — run the build before the scan (GitHub runners ship a C compiler; caching the.sois an optional optimization, not a requirement):- run: bash tools/ast-grep-svelte/build.sh # BEFORE the ast-grep scan step - run: bunx ast-grep scan # your lint:ast
-
Optional hardening — wrap the
lint:astscript to build-if-missing so a stale checkout self-heals:bash tools/ast-grep-svelte/build.sh && ast-grep scan.
Co-land these with the
sgconfig.ymlchange in the same commit/PR. Registering the language without the build wiring breakslint:aston every fresh checkout and CI job (exit 79). This is the single most important sequencing rule of the integration. (Mirrors ADR 0006 §6 in the project-xavier reference integration.)
Three checks. The first proves you didn't break the existing scan; the rest prove the language is live.
a. lint:ast is green and INERT — mere registration adds zero findings (no
rule targets language: svelte yet). Confirm the target's scan still exits 0 and its
finding set is unchanged vs before registration:
bunx ast-grep scan # exit 0; identical finding set to pre-registrationb. Inline each_block smoke — proves the grammar loads and parses .svelte:
bunx ast-grep scan -c sgconfig.yml <dir-with-svelte> --inline-rules 'id: smoke
language: svelte
rule: {kind: each_block}'Expect matches on any repo with {#each …} blocks. (Verified live in this repo:
111 each_block matches over corpus/; against a real frontend, project-xavier's
X1 integration reported 469 matches across 391 .svelte files.)
c. Injection smoke — proves {…} expressions re-parse as TypeScript:
bunx ast-grep scan -c sgconfig.yml <dir-with-svelte> --inline-rules 'id: inj
language: typescript
rule: {kind: member_expression}'Expect matches inside .svelte interpolations (verified live: 116 over corpus/).
Everything a rule-author agent in the target repo needs to target the grammar. Node
kinds are read off tree-sitter-svelte/src/node-types.json (55 named kinds);
discover the exact kinds/fields any snippet produces with tree-sitter parse (see
Working ON the grammar) or
ast-grep run --debug-query=ast.
| Category | Kinds |
|---|---|
| Elements / components | regular_element, component, svelte_element, svelte_component, svelte_self, svelte_window, svelte_body, svelte_document, svelte_head, svelte_options, svelte_fragment, svelte_boundary, slot_element, script_element, style_element, title_element |
| Blocks | each_block, if_block, await_block, key_block, snippet_block, else_clause, then_clause, catch_clause |
| Tags | html_tag ({@html}), const_tag ({@const}), debug_tag ({@debug}), render_tag ({@render}), attach_tag ({@attach}), expression_tag ({expr}) |
| Directives | animate_directive, bind_directive, class_directive, let_directive, on_directive, style_directive, transition_directive, use_directive, directive_name, directive_modifier, modifier_name |
| Attributes | attribute, attribute_name, spread_attribute, quoted_attribute_value, unquoted_attribute_value |
| Structural | root, fragment, text, comment, tag_name |
| Injection bodies (host tokens that injection re-parses) | raw_text_js (<script>) → typescript, raw_text_css (<style>) → css, raw_text_expr ({…}) → typescript, raw_text (title/textarea literal bodies) |
| To match code in… | write language: |
via host kind |
|---|---|---|
| template / elements / blocks / directives / tags | svelte |
(host — no injection) |
<style> bodies |
css |
raw_text_css → css |
<script> bodies (ts AND js — TS superset) |
typescript |
raw_text_js → typescript |
{…} interpolations / block-header exprs / {@html} etc. |
typescript |
raw_text_expr → typescript |
- Prefer
kind:/field:over bare patterns — the grammar has intentional named kinds and fields; that is the whole reason it was authored from scratch. - A
field:constraint needs a sub-matcher.field: keyalone matches nothing on 0.42.x; writehas: {field: key, kind: raw_text_expr}. - Metavariables: standard
$VAR(single) and$$$(variadic). NoexpandoChar. - Anonymous tokens (string literals like keywords/punctuation) are not reliably
kind-matchable — capture with$$VARor target the enclosing named kind.
@ast-grep/cli 0.42.3 (ABI-15; 0.42.x compatible) · grammar ABI 15 ·
tree-sitter-cli 0.26.9 (upstream regeneration only) · oracle svelte
5.56.4 / svelte-check 4.4.8 / typescript 6.0.3.
When this grammar repo advances and the target repo needs the newer grammar:
- In this repo, regenerate + rebuild to confirm
src/is current, then run the gates green:bunx tree-sitter generate && bunx tree-sitter build --output svelte.so bun run gates - Copy the source set back into the target's
tools/ast-grep-svelte/(the 8 files from B1):PEER=<path-to>/ast-grep-svelte/tree-sitter-svelte cp "$PEER/grammar.js" tools/ast-grep-svelte/grammar.js cp "$PEER/src/"{parser.c,scanner.c,grammar.json,node-types.json} tools/ast-grep-svelte/src/ cp "$PEER/src/tree_sitter/"{alloc,array,parser}.h tools/ast-grep-svelte/src/tree_sitter/
- Recompute + re-record the combined hash (B2) in
verify-sync.sh'sRECORDED_SHA256. - Rebuild
svelte.so, run the target'slint:ast(stays inert until svelte rules exist), and re-run any svelte rules'ast-grep test. - Re-run
verify-sync.sh— it must exit 0 (vendored == recorded, and == peer if a peer checkout is present).
If the target's ast-grep changed ABI, regenerate with an explicit
tree-sitter generate --abi <N> to target the ABI that ast-grep supports, then
rebuild.
The above is for integrating the grammar elsewhere. This section is for hacking on
the grammar itself, in this repo. Full orientation:
CLAUDE.md; build methodology:
.claude/skills/ast-grep-custom-language/SKILL.md
(auto-triggers on grammar.js / sgconfig.yml).
HARD CONSTRAINT — no commits in THIS repo. Working-tree-only: no
git commit/branch/worktree/ PR here. The operator handles all commits. Leave a clean working tree that makesbun run gatesgreen. (This constraint is local to this grammar repo; it does not apply to the target repo you are integrating into.)
bun install # installs the pinned toolchain (devDeps) + oracle
bunx tree-sitter --version # tree-sitter 0.26.9
bunx ast-grep --version # ast-grep 0.42.3grammar/spec/*.ebnf ─► grammar.js ─► generated C parser ─► svelte.so ─► ast-grep customLanguage
▲ (Svelte's context-sensitive lexing lives in an external src/scanner.c)
└── oracle-as-ground-truth: run corpus through svelte-check + the Svelte compiler,
record accept/reject in grammar/oracle-facts/ (the oracle always wins over a grammar guess)
The compiled parser is a native, host-platform artifact — tree-sitter build
cannot cross-compile, so the mac library must be built on macOS and the linux
library on Linux. The platform-keyed build scripts assert the host OS and fail
LOUD on a mismatch instead of silently producing a wrong-OS artifact:
bun run build:svelte-so # build for THIS host -> tree-sitter-svelte/svelte-<plat>-<arch>.so
bun run build:svelte-so:mac # assert macOS, then build (svelte-darwin-arm64.so on Apple Silicon)
bun run build:svelte-so:linux # assert Linux, then build (svelte-linux-x64.so on x86_64)All three wrap scripts/build-so.sh, which compiles the
checked-in generated parser (tree-sitter-svelte/src/parser.c + scanner.c)
with the pinned tree-sitter-cli and then also refreshes
tree-sitter-svelte/svelte.so — the host-resolved library sgconfig.yml
points at — so local ast-grep scan / ast-grep test runs pick up the build
immediately.
The platform-keyed artifacts (svelte-darwin-arm64.so, svelte-linux-x64.so)
are committed (a .gitignore negation exempts them from the blanket *.so
ignore), so consumers get a working prebuilt parser without a native toolchain.
The host-resolved svelte.so copy stays ignored. After rebuilding on a grammar
change, commit the refreshed platform artifact(s) alongside the grammar source.
When to rebuild:
- after any change to
grammar.jsorsrc/scanner.c— runcd tree-sitter-svelte && bunx tree-sitter generatefirst ifgrammar.jschanged (the script compilessrc/parser.c; it does not regenerate it); - when producing a fresh platform artifact for a consumer (project-xavier's
tools/ast-grep-svelte/rebuild.shruns this same build against this repo's committed HEAD — commit grammar changes here first or its artifact won't include them).
bun run build:parser remains the grammar-dev loop variant: it builds the
host svelte.so plus svelte.wasm (for web-tree-sitter tooling), without
the platform-keyed artifact.
bun run gates --allow-incomplete # every checker green on the current state
bun run gates # STRICT: additionally every leaf proven, zero unmapped
# denominator types, corpus scan > 0 filesThe umbrella runs the coverage/oracle/grammar/parity/denominator/real-world gates in
order; each is individually bun gates/<gate>.ts-runnable and prints scanned N, failed M (a zero from a run that scanned nothing is a FAILURE, not a pass).
# scan the saved ruleset (rules/lint/) — rules/tests/ holds the ast-grep test cases
bunx ast-grep scan -c sgconfig.yml corpus/
bunx ast-grep test -c sgconfig.yml # add -U to (re)generate snapshots
# inspect how ast-grep parses a pattern (ESSENTIAL when a pattern matches nothing)
bunx ast-grep run --debug-query=ast -p '<pattern>' -l svelte <file>
# read the real kinds/fields a snippet produces (the selectors you write rules against)
node_modules/.bin/tree-sitter parse --lib-path tree-sitter-svelte/svelte.so --lang-name svelte <file>- ast-grep custom languages — https://ast-grep.github.io/advanced/custom-language.html
- ast-grep language injection — https://ast-grep.github.io/advanced/language-injection.html
- ast-grep
sgconfig.ymlreference — https://ast-grep.github.io/reference/sgconfig.html - tree-sitter ABI versions — https://tree-sitter.github.io/tree-sitter/using-parsers/7-abi-versions.html
- Injection + metavar decisions (this repo) —
docs/decisions/0002-injection-and-metavars.md - Baseline-grammar strategy (this repo) —
docs/decisions/0001-baseline-grammar-strategy.md - Reference integration (build-from-source vendoring, CI wiring) — project-xavier
docs/adr/0006-ast-grep-svelte-language.md