diff --git a/graphify/extract.py b/graphify/extract.py
index b834fb42c..f612b8504 100644
--- a/graphify/extract.py
+++ b/graphify/extract.py
@@ -52,6 +52,7 @@
from graphify.extractors.rust import extract_rust # noqa: F401
from graphify.extractors.sln import extract_sln # noqa: F401
from graphify.extractors.sql import extract_sql # noqa: F401
+from graphify.extractors.svelte import extract_svelte # noqa: F401
from graphify.extractors.terraform import extract_terraform # noqa: F401
from graphify.extractors.verilog import extract_verilog # noqa: F401
from graphify.extractors.zig import extract_zig # noqa: F401
@@ -1398,62 +1399,6 @@ def _emit_rescued_import(
existing_ids.add(node_id)
-def extract_svelte(path: Path) -> dict:
- """Extract imports from .svelte files: script-block via JS AST + template regex fallback.
-
- Tree-sitter only sees the ", _re.IGNORECASE
- )
- static_import_re = _re.compile(
- r"""import\s+(?:[^'"`;]+?\s+from\s+)?['"]([^'"]+)['"]"""
- )
- for script_match in script_re.finditer(src):
- script_body = script_match.group(1)
- for m in static_import_re.finditer(script_body):
- raw = m.group(1)
- if not raw:
- continue
- _emit_rescued_import(
- result, existing_ids, file_node_id, path, raw,
- "imports_from", aliases, base_url,
- )
- except Exception:
- pass
- return result
-
-
def extract_astro(path: Path) -> dict:
"""Extract imports from .astro files: frontmatter (TS) + template regex fallback.
diff --git a/graphify/extractors/svelte.py b/graphify/extractors/svelte.py
new file mode 100644
index 000000000..e0452bdda
--- /dev/null
+++ b/graphify/extractors/svelte.py
@@ -0,0 +1,754 @@
+"""Svelte 5 extractor for Graphify (Airpipe fork).
+
+Replaces the stock ``extract_svelte`` in ``graphify/extract.py``, which fed the
+WHOLE ``.svelte`` file to the tree-sitter-javascript parser. Svelte markup is not
+valid JS, so the parse produced a top-level ERROR node, no declaration was ever
+reached, and a 280-line component collapsed into a single file node (measured
+baseline on airpipe-web: 2.79 nodes per .svelte file, and those were almost all
+import stubs — the real symbol count was ~0).
+
+Design (v1), after review by Grok 4.5 and Kimi k3:
+
+* **Region scanner, not a naive regex split.** ````, ```` where ``Foo``
+ is a variable) are emitted with ``confidence: "INFERRED"`` and context
+ ``renders_dynamic``.
+* Template call detection resolves an identifier ONLY when it matches a symbol
+ declared or imported in the same file. That trades recall for precision: an
+ unknown name is dropped rather than guessed.
+* No cross-repo / HTTP-boundary edges. A ``fetch('/api/...')`` in a component is
+ not linked to the backend route that serves it.
+"""
+from __future__ import annotations
+
+import re
+from pathlib import Path
+
+from graphify.extractors.base import (
+ _LANGUAGE_BUILTIN_GLOBALS,
+ _file_stem,
+ _make_id,
+ _read_text,
+)
+from graphify.extractors.resolution import _resolve_js_import_target
+
+EXTRACTOR_VERSION = "airpipe-svelte-1.1.0"
+
+# Runes that BIND a value to a name. The dict maps the callee text to the node
+# kind; the exact spelling is kept on the node as `rune` so `$state` and
+# `$state.raw` stay one kind instead of fragmenting the graph (Kimi #5).
+_BINDING_RUNES = {
+ "$state": "state",
+ "$state.raw": "state",
+ "$derived": "derived",
+ "$derived.by": "derived",
+ "$props": "props",
+ "$props.id": "state",
+ "$bindable": "state",
+}
+
+# Runes that are statements rather than bindings. Their bodies are walked for
+# calls (attributed to the component) but they do NOT become nodes: both
+# reviewers flagged anonymous effect nodes as pure noise.
+_STATEMENT_RUNES = {"$effect", "$effect.pre", "$effect.root", "$effect.tracking", "$inspect"}
+
+# Svelte template block keywords and special elements — never component targets.
+_TEMPLATE_KEYWORDS = frozenset({
+ "if", "else", "each", "await", "then", "catch", "key", "snippet",
+ "render", "const", "html", "debug", "attach",
+})
+
+_SVELTE_SPECIAL = frozenset({
+ "svelte:head", "svelte:window", "svelte:document", "svelte:body",
+ "svelte:options", "svelte:fragment", "svelte:boundary", "svelte:self",
+ "svelte:element",
+})
+
+_IDENT_CALL_RE = re.compile(r"(? tuple[list[dict], str]:
+ """Split a .svelte source into script regions plus a masked template.
+
+ Returns ``(scripts, template)`` where each script is
+ ``{"start", "end", "attrs", "module"}`` (byte-equivalent character offsets of
+ the CONTENT, not the tags) and ``template`` is ``src`` with every script,
+ style and comment region replaced by spaces of identical length — so offsets
+ into ``template`` are still valid offsets into ``src``.
+
+ A scanner rather than a regex, because ```` and
+ ``attr="\n"
+ '\n"
+ "
{count}
\n"
+ )
+ scripts, template = _scan_regions(src)
+ assert len(scripts) == 2
+ assert scripts[0]["module"] is True
+ assert scripts[1]["module"] is False
+ # Masking keeps length and newlines, so offsets into the template are still
+ # valid offsets into the source.
+ assert len(template) == len(src)
+ assert template.count("\n") == src.count("\n")
+ assert "$state" not in template
+ assert "