diff --git a/graphify/extract.py b/graphify/extract.py index f68757a85..ac8709173 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -137,6 +137,7 @@ _ts_collect_type_refs, _ts_heritage_clause_entries, _ts_walk_class_members, + _sfc_mask_non_script, _vue_mask_non_script, _walk_js_tree, _walk_python_tree, @@ -1599,16 +1600,40 @@ def _emit_rescued_import( def extract_svelte(path: Path) -> dict: - """Extract imports from .svelte files: script-block via JS AST + template regex fallback. + """Extract imports, symbols, and type refs from a ``.svelte`` component. - Tree-sitter only sees the close tag pos = m.end() if lang is None: - lang_m = _VUE_SCRIPT_LANG_RE.search(m.group(1)) + lang_m = _SFC_SCRIPT_LANG_RE.search(m.group(1)) if lang_m: lang = lang_m.group(1).lower() out.append(_blank(src[pos:])) return "".join(out), lang +_vue_mask_non_script = _sfc_mask_non_script + +# Single-file-component suffixes whose script blocks need masking before a +# JS/TS grammar can parse them. +_SFC_SUFFIXES = (".vue", ".svelte") + def _source_key(source_file: str, root: Path) -> str: if not source_file: return "" @@ -1104,18 +1118,19 @@ def resolve_exported_origin(target_path: Path, imported_name: str, seen: set[tup def _parse_js_tree(path: Path): try: from tree_sitter import Language, Parser - # .vue embeds the script in non-JS markup; mask it out and parse the - # \n" + "\n" + "
{msg}
\n" + "\n" + "\n" + ) + masked, lang = _sfc_mask_non_script(src) + assert lang == "ts" + # Same number of lines (newlines preserved) so line numbers are stable. + assert masked.count("\n") == src.count("\n") + # Markup and style are gone; the script body survives verbatim. + assert "div" not in masked + assert "color: red" not in masked + assert "const msg = 'hi'" in masked + # The script body sits on the same line it does in the source (line 2). + assert masked.splitlines()[1].strip() == "const msg = 'hi'" + + +def test_static_imports_resolve(tmp_path): + _write(tmp_path / "Icon.svelte", "\n") + _write(tmp_path / "format.ts", "export const fmt = (s: string) => s\n") + component = _write( + tmp_path / "Card.svelte", + '\n" + "\n" + "{fmt('x')}\n", + ) + result = extract_svelte(component) + targets = _targets(result, relation="imports_from") + assert _make_id(str(tmp_path / "Icon.svelte")) in targets + # Extensionless specifier probes real on-disk extensions (./format -> .ts). + assert _make_id(str(tmp_path / "format.ts")) in targets + + +def test_symbols_extracted_with_correct_lines(tmp_path): + component = _write( + tmp_path / "Band.svelte", + '\n" + "\n" + "\n", + ) + result = extract_svelte(component) + labels = _labels(result) + assert "Level" in labels + assert "toggle()" in labels + # Masking keeps newlines, so reported lines match the real source lines. + lines = { + str(n.get("label")): n.get("line") + for n in result.get("nodes", []) + if n.get("line") is not None + } + if "toggle()" in lines: + assert lines["toggle()"] == 4 + + +def test_module_and_instance_scripts_both_parsed(tmp_path): + """Svelte 5 ``\n" + "\n" + '\n" + "\n" + "

hi

\n", + ) + result = extract_svelte(component) + targets = _targets(result, relation="imports_from") + assert _make_id(str(tmp_path / "shared.ts")) in targets + assert _make_id(str(tmp_path / "local.ts")) in targets + labels = _labels(result) + assert "helper()" in labels + assert "render()" in labels + + +def test_svelte_4_context_module_script_parsed(tmp_path): + """Svelte 4 spells the module block ``context="module"``.""" + _write(tmp_path / "shared.ts", "export const shared = 1\n") + component = _write( + tmp_path / "Legacy.svelte", + '\n" + "\n" + "

hi

\n", + ) + result = extract_svelte(component) + assert _make_id(str(tmp_path / "shared.ts")) in _targets( + result, relation="imports_from" + ) + + +def test_dynamic_import_in_template_recovered(tmp_path): + """``{#await import('./X.svelte')}`` lives in markup the mask blanks out, + so the regex pass must scan the raw source, not the masked one.""" + _write(tmp_path / "Heavy.svelte", "\n") + component = _write( + tmp_path / "Lazy.svelte", + "\n" + "\n" + "{#await import('./Heavy.svelte') then Mod}\n" + " \n" + "{/await}\n", + ) + result = extract_svelte(component) + assert _make_id(str(tmp_path / "Heavy.svelte")) in _targets( + result, relation="dynamic_import" + ) + + +def test_typed_props_reference_imported_type(tmp_path): + _write(tmp_path / "types.ts", "export type Risk = { id: string }\n") + component = _write( + tmp_path / "RiskCard.svelte", + '\n", + ) + result = extract_svelte(component) + assert _make_id(str(tmp_path / "types.ts")) in _targets( + result, relation="imports_from" + ) + + +def test_plain_js_script_block(tmp_path): + """No ``lang`` attribute: the TS grammar is a superset, so JS still parses.""" + _write(tmp_path / "util.js", "export const u = 1\n") + component = _write( + tmp_path / "Plain.svelte", + "\n" + "\n" + "{go()}\n", + ) + result = extract_svelte(component) + assert _make_id(str(tmp_path / "util.js")) in _targets( + result, relation="imports_from" + ) + assert "go()" in _labels(result) + + +def test_markup_only_file_does_not_crash(tmp_path): + component = _write(tmp_path / "Static.svelte", "

hello

\n") + result = extract_svelte(component) + assert isinstance(result.get("nodes"), list) + assert isinstance(result.get("edges"), list) + + +def test_runes_do_not_break_the_ts_grammar(tmp_path): + """Svelte 5 runes (``$state``/``$derived``/``$props``) are syntactically + ordinary calls, so the TS grammar walks past them to the real symbols.""" + component = _write( + tmp_path / "Runes.svelte", + '\n" + "\n" + "\n", + ) + result = extract_svelte(component) + assert "bump()" in _labels(result) + + +def test_whole_file_to_js_grammar_would_extract_nothing(tmp_path): + """Regression guard for #713: the unmasked path loses everything but the + file node, which is what made every .svelte file a stub in the graph.""" + from graphify.extract import _JS_CONFIG, _extract_generic + + component = _write( + tmp_path / "Guard.svelte", + '\n" + "\n" + "
{visible()}
\n" + "\n" + "\n", + ) + unmasked = _extract_generic(component, _JS_CONFIG) + assert "visible()" not in _labels(unmasked) + + masked = extract_svelte(component) + assert "visible()" in _labels(masked) + + +def test_svelte_joins_cross_file_symbol_resolution(tmp_path): + """A ``.svelte`` calling an imported function wires to the real symbol across + files, so the component is a participant in the call graph rather than a + leaf stub. Exercises the masked ``_parse_js_tree`` path. + """ + helper = _write(tmp_path / "helper.ts", "export function helper() {}\n") + comp = _write( + tmp_path / "Caller.svelte", + ''' + + +''', + ) + result = extract([comp, helper], cache_root=tmp_path) + by_label = {n["label"]: n["id"] for n in result["nodes"]} + edges = {(e["source"], e["target"], e["relation"]) for e in result["edges"]} + assert (by_label["go()"], by_label["helper()"], "calls") in edges