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 \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 "

{count}

" in template + + +def test_scan_regions_is_not_fooled_by_commented_out_script(tmp_path): + src = "\n
hi
\n" + scripts, _template = _scan_regions(src) + assert scripts == [] + + +def test_props_runes_functions_and_snippets_become_nodes(tmp_path): + path = _write( + tmp_path / "Widget.svelte", + '\n" + "\n" + "{#snippet row(x)}
  • {x}
  • {/snippet}\n" + "\n", + ) + result = extract_svelte(path) + + assert _by_kind(result, "prop") == {"title", "onSave"} + assert _by_kind(result, "state") == {"open"} + assert _by_kind(result, "derived") == {"label"} + assert _by_kind(result, "function") == {"toggle()", "reset()"} + assert _by_kind(result, "snippet") == {"row()"} + + +def test_renamed_prop_uses_the_public_name(tmp_path): + """`class: className` exposes `class`; the script refers to `className`.""" + path = _write( + tmp_path / "Styled.svelte", + "\n" + "
    \n", + ) + result = extract_svelte(path) + assert _by_kind(result, "prop") == {"class"} + prop = next(n for n in result["nodes"] if n.get("kind") == "prop") + assert prop["local_name"] == "className" + + +def test_rune_variants_share_one_kind(tmp_path): + path = _write( + tmp_path / "Raw.svelte", + "\n", + ) + result = extract_svelte(path) + kinds = {n["label"]: (n["kind"], n.get("rune")) for n in result["nodes"] + if n.get("kind") in ("state", "derived")} + assert kinds == {"a": ("state", "$state.raw"), "b": ("derived", "$derived.by")} + + +def test_component_usage_emits_uses_edge_with_renders_context(tmp_path): + _write(tmp_path / "Child.svelte", "

    child

    \n") + path = _write( + tmp_path / "Parent.svelte", + "\n" + "\n", + ) + result = extract_svelte(path) + # `uses`, not `renders`: DEFAULT_AFFECTED_RELATIONS does not include + # `renders`, so such an edge would be invisible to `graphify affected`. + uses = _edges(result, "uses", "renders") + assert len(uses) == 1 + assert uses[0]["symbol"] == "Child" + assert uses[0]["target_file"].endswith("Child.svelte") + + +def test_less_than_operator_is_not_a_component_tag(tmp_path): + """`{#if i < items.length}` must not read as a component named items.length.""" + path = _write( + tmp_path / "Cmp.svelte", + "\n" + "{#if i < items.length}more{/if}\n", + ) + result = extract_svelte(path) + assert _edges(result, "uses") == [] + + +def test_barrel_siblings_each_get_their_own_usage_edge(tmp_path): + """Alert/AlertTitle resolve to one module but are two distinct usages.""" + _write(tmp_path / "ui/index.js", "export const Alert = 1\nexport const AlertTitle = 2\n") + path = _write( + tmp_path / "Uses.svelte", + "\n" + "hi\n", + ) + result = extract_svelte(path) + assert {e["symbol"] for e in _edges(result, "uses", "renders")} == {"Alert", "AlertTitle"} + + +def test_case_colliding_type_and_variable_both_survive(tmp_path): + """make_id casefolds, so `type State` and `let state` hash alike (#id-collision).""" + path = _write( + tmp_path / "Health.svelte", + '\n", + ) + result = extract_svelte(path) + assert _by_kind(result, "type") == {"State"} + assert _by_kind(result, "state") == {"state"} + ids = [n["id"] for n in result["nodes"]] + assert len(ids) == len(set(ids)) + + +def test_imported_call_binds_to_the_symbol_not_just_the_file(tmp_path): + _write(tmp_path / "fmt.ts", "export function formatOre(x: number) { return x }\n") + path = _write( + tmp_path / "Money.svelte", + "\n" + "{formatOre(1)}\n", + ) + result = extract_svelte(path) + calls = _edges(result, "calls") + assert len(calls) == 1 + # The corpus can hold many `formatOre`; the import says which one this is. + assert calls[0]["target"] == _make_id(str((tmp_path / "fmt").as_posix()), "formatOre") + + +def test_dynamic_import_in_markup_is_recovered(tmp_path): + """`{#await import('./X.svelte')}` lives in markup; no JS parser sees it.""" + _write(tmp_path / "Lazy.svelte", "

    lazy

    \n") + path = _write( + tmp_path / "Host.svelte", + "{#await import('./Lazy.svelte') then M}{/await}\n", + ) + result = extract_svelte(path) + dyn = _edges(result, "dynamic_import") + assert len(dyn) == 1 + assert dyn[0]["target_file"].endswith("Lazy.svelte") + + +def test_scalar_locals_and_anonymous_effects_are_not_nodes(tmp_path): + """Node count is not the goal — a node per `const x = 1` makes the graph worse.""" + path = _write( + tmp_path / "Quiet.svelte", + "\n", + ) + result = extract_svelte(path) + labels = {n["label"] for n in result["nodes"]} + assert "LIMIT" not in labels + assert _by_kind(result, "state") == {"n"} + # One component node plus the single rune binding. + assert len(result["nodes"]) == 2 + + +def test_recursive_component_records_a_self_edge(tmp_path): + """`import Self from './ChainTree.svelte'` used as is recursion.""" + path = _write( + tmp_path / "ChainTree.svelte", + "\n" + "{#each nodes as n}{/each}\n", + ) + result = extract_svelte(path) + uses = _edges(result, "uses", "renders") + assert [e["symbol"] for e in uses] == ["Self"] + # Self-import: source and target are the same component node. + assert uses[0]["source"] == uses[0]["target"] + + +def test_quoted_prop_key_keeps_only_the_prop_name(tmp_path): + """A hyphenated prop must be quoted; the quotes are syntax, not the name.""" + path = _write( + tmp_path / "Slot.svelte", + "\n", + ) + result = extract_svelte(path) + assert _by_kind(result, "prop") == {"data-slot"} + prop = next(n for n in result["nodes"] if n.get("kind") == "prop") + assert prop["local_name"] == "dataSlot" + + +def test_regex_literal_in_an_expression_does_not_swallow_later_components(tmp_path): + """A miscounted brace is not a local error — it drops the rest of the file. + + `replace(/^https?:\\/\\//, '')` puts two adjacent slashes inside an + expression; reading them as a comment used to pin the depth above zero so + every following component silently vanished. + """ + _write(tmp_path / "Icon.svelte", "\n") + path = _write( + tmp_path / "Host.svelte", + "\n" + "{site.replace(/^https?:\\/\\//, '')}\n" + "\n", + ) + result = extract_svelte(path) + assert [e["symbol"] for e in _edges(result, "uses", "renders")] == ["Icon"] + + +def test_apostrophe_in_markup_text_does_not_swallow_later_components(tmp_path): + _write(tmp_path / "Icon.svelte", "\n") + path = _write( + tmp_path / "Text.svelte", + "\n" + "

    Don't panic

    \n" + "\n", + ) + result = extract_svelte(path) + assert [e["symbol"] for e in _edges(result, "uses", "renders")] == ["Icon"] + + +def test_call_sites_are_reported_at_file_lines_not_block_lines(tmp_path): + """A `\n" # 3 + "\n" # 4 + '\n", + ) + result = extract_svelte(path) + call = next(e for e in _edges(result, "calls") if e.get("context") == "call") + assert call["source_location"] == "L8"