From bcf9624477c58f79d2f22240e01d48ce6494aea7 Mon Sep 17 00:00:00 2001 From: Mahaboob pasha Date: Tue, 6 Oct 2026 22:54:33 +0530 Subject: [PATCH] feat(scala): link classes to typeclasses in derives clauses (#2048) `case class Point(x: Int) derives Show, Eq` (and the same on enums) now emits a `references` edge with the `derives` context from the type to each derived typeclass, next to the class-level context bounds it resembles. A qualified name (`derives cats.Show`) links by its last segment. Co-Authored-By: Grok Bot (cherry picked from commit e6ccd59f20dd2a5071e4667ad3ec2c562a3d6d3d) --- graphify/extractors/engine.py | 14 +++++++++ tests/test_scala_context_bounds.py | 49 ++++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+) diff --git a/graphify/extractors/engine.py b/graphify/extractors/engine.py index 6f1185ed7..cff0c0687 100644 --- a/graphify/extractors/engine.py +++ b/graphify/extractors/engine.py @@ -5640,6 +5640,20 @@ def _extract_generic( if target_nid != class_nid: add_edge(class_nid, target_nid, "references", line, context=ctx) + # `derives Show, cats.Eq` (on classes and enums) names typeclasses + # the type depends on, like a context bound (#2048). A qualified + # name links by its last segment. + derive = node.child_by_field_name("derive") + for d in derive.children_by_field_name("type") if derive is not None else (): + tail = d if d.type == "type_identifier" else next( + (x for x in reversed(d.children) if x.type == "type_identifier"), None) + if tail is None: + continue + d_line = d.start_point[0] + 1 + target_nid = ensure_named_node(_read_text(tail, source), d_line) + if target_nid != class_nid: + add_edge(class_nid, target_nid, "references", + d_line, context="derives") # C#: a primary constructor (`class Foo(IBar bar)`, C# 12+) declares # its dependencies on the type declaration itself rather than in a diff --git a/tests/test_scala_context_bounds.py b/tests/test_scala_context_bounds.py index ba6739a2b..1783bcc40 100644 --- a/tests/test_scala_context_bounds.py +++ b/tests/test_scala_context_bounds.py @@ -228,3 +228,52 @@ def test_class_bound_edge_is_tagged_extracted_with_a_location(tmp_path): for e in edges: assert e.get("confidence") == "EXTRACTED", e assert e.get("source_location") == "L7", e + + +# `derives` clauses (#2048) name typeclasses the type depends on, the same kind +# of dependency as a context bound, so each one is a `references` edge with the +# `derives` context. Grammar: a `derive` field on class_definition and +# enum_definition, one `type` child per derived typeclass. +DERIVES_SRC = '''\ +trait Show +trait Eq + +case class Point(x: Int) derives Show, Eq + +enum Color derives Eq { case Red } + +case class Wrapped(x: Int) derives cats.Show + +class Bare(x: Int) +''' + + +def _derives_targets(tmp_path, label): + (tmp_path / "Derives.scala").write_text(DERIVES_SRC) + r = extract_scala(tmp_path / "Derives.scala") + nid = {n["label"]: n["id"] for n in r["nodes"]} + targets = {e["target"] for e in r["edges"] + if e["relation"] == "references" + and e["source"] == nid[label] + and e.get("context") == "derives"} + return targets, nid + + +def test_case_class_derives_each_typeclass(tmp_path): + targets, nid = _derives_targets(tmp_path, "Point") + assert targets == {nid["Show"], nid["Eq"]} + + +def test_enum_derives_typeclass(tmp_path): + targets, nid = _derives_targets(tmp_path, "Color") + assert targets == {nid["Eq"]} + + +def test_qualified_derives_links_by_last_segment(tmp_path): + targets, nid = _derives_targets(tmp_path, "Wrapped") + assert targets == {nid["Show"]} + + +def test_class_without_derives_emits_no_derives_edge(tmp_path): + targets, _ = _derives_targets(tmp_path, "Bare") + assert not targets