diff --git a/graphify/extract.py b/graphify/extract.py index 90a6ab0267..151b97572b 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -2809,7 +2809,12 @@ def _rewire_unique_stub_nodes(nodes: list[dict], edges: list[dict]) -> None: stub_ids = {str(s.get("id")) for s in stubs if s.get("id")} stub_families: dict[str, set] = {} supertype_stub_ids: set[str] = set() # stubs used as a base type — never a function - _SUPERTYPE_RELATIONS = {"inherits", "implements", "extends"} + # `specializes` joins these because a Common Lisp method dispatches on a + # TYPE, and functions and types occupy separate namespaces there: `list`, + # `stream` and `condition` are all routinely both. Without it a specializer + # stub can bind to a same-named function, which asserts a dispatch + # relationship that does not exist. + _SUPERTYPE_RELATIONS = {"inherits", "implements", "extends", "specializes"} for edge in edges: rel = edge.get("relation") for endpoint in ("source", "target"): diff --git a/graphify/extractors/commonlisp.py b/graphify/extractors/commonlisp.py index 2ed9d9fbdd..45b3aaaf98 100644 --- a/graphify/extractors/commonlisp.py +++ b/graphify/extractors/commonlisp.py @@ -319,7 +319,8 @@ def _handle_defun_node(node) -> None: syms = [c for c in param.children if c.type == "sym_lit"] if len(syms) >= 2: specializer_name = _text(syms[1]) - spec_nid = _cl_id(stem, specializer_name) + spec_nid = ensure_class_ref( + specializer_name, param.start_point[0] + 1) add_edge(func_nid, spec_nid, "specializes", param.start_point[0] + 1) break diff --git a/tests/test_commonlisp_specializer_binding.py b/tests/test_commonlisp_specializer_binding.py new file mode 100644 index 0000000000..20dd7e8379 --- /dev/null +++ b/tests/test_commonlisp_specializer_binding.py @@ -0,0 +1,32 @@ +"""A Common Lisp method specializer must never bind to a same-named function. + +Functions and types occupy separate namespaces in Common Lisp, so one symbol is +routinely both: `list`, `stream`, `condition`, `pathname`. When a specializer +names a type the corpus does not define, the cross-file stub must stay +unresolved rather than collapse onto a function that happens to share the name, +which would assert a dispatch relationship that does not exist. +""" +from __future__ import annotations + +from graphify.extract import extract + + +def test_specializer_does_not_bind_to_same_named_function(tmp_path): + a = tmp_path / "a.lisp" + a.write_text("(defun square (x) (* x x))\n") # a function named square + b = tmp_path / "b.lisp" + b.write_text( + "(defgeneric area (obj))\n" + "(defmethod area ((obj square)) 1)\n" # dispatches on a TYPE named square + ) + graph = extract([a, b], cache_root=tmp_path, parallel=False) + by_id = {n["id"]: n for n in graph["nodes"]} + for e in graph["edges"]: + if e.get("relation") != "specializes": + continue + target = by_id.get(e.get("target"), {}) + label = str(target.get("label", "")) + assert not label.endswith("()"), ( + f"specializes bound to the function {label!r}; " + "no class of that name exists in the corpus" + ) diff --git a/tests/test_languages.py b/tests/test_languages.py index aa7d49486f..ee93dbfda8 100644 --- a/tests/test_languages.py +++ b/tests/test_languages.py @@ -3811,6 +3811,42 @@ def test_cl_crossfile_superclass_inherits_edge_survives(tmp_path): assert base["source_file"] == "", "cross-file superclass must be a sourceless stub" +@_needs_commonlisp +def test_cl_crossfile_specializer_specializes_edge_survives(tmp_path): + """A method specialising on a class from another file must keep its edge. + + The specializer was resolved with a file-scoped id, so a method dispatching + on a class defined elsewhere produced an edge to a node that did not exist + and the dangling-edge filter removed it. Since most methods in a CL system + live apart from the class they dispatch on, that erased nearly every + specializes edge in a real codebase. Resolve through the same sourceless + stub the cross-file superclass path uses. + """ + f = tmp_path / "shapes.lisp" + f.write_text( + "(defclass circle () ())\n" + "(defgeneric area (obj))\n" + "(defmethod area ((obj circle)) 1)\n" + "(defmethod area ((obj square)) 2)\n" + ) + r = extract_commonlisp(f) + assert "error" not in r + id_to_node = {n["id"]: n for n in r["nodes"]} + targets = { + id_to_node[e["target"]]["label"] + for e in r["edges"] + if e["relation"] == "specializes" + and e["source"] in id_to_node and e["target"] in id_to_node + } + # same-file specializer still binds locally + assert "circle" in targets + # cross-file specializer survives via a sourceless stub instead of being dropped + assert "square" in targets + square = next(n for n in r["nodes"] if n["label"] == "square") + assert square["source_file"] == "", "cross-file specializer must be a sourceless stub" + + + @_needs_commonlisp def test_cl_imports(): r = extract_commonlisp(FIXTURES / "sample.lisp")