From e3edfd43305a542044a89e2622a1d1726d352445 Mon Sep 17 00:00:00 2001 From: Brian O'Reilly Date: Sun, 13 Sep 2026 12:45:48 -0400 Subject: [PATCH] fix(commonlisp): keep specializes edge when the specializer is in another file A defmethod dispatching on a class defined in another file lost its specializes edge. The specializer resolved to a file-scoped id with no backing node, so the dangling-edge filter pruned the edge. Resolve through the same sourceless stub the cross-file superclass path uses, so the corpus rewire collapses it onto the real defclass; a same-file specializer still binds locally. Most methods in a Common Lisp system live apart from the class they dispatch on, so this removed nearly every specializes edge in a real codebase. Add specializes to the supertype relations at the same time. Functions and types occupy separate namespaces in Common Lisp, so one symbol is routinely both, and without the guard a specializer stub can collapse onto a same-named function and assert a dispatch relationship that does not exist. --- graphify/extract.py | 7 +++- graphify/extractors/commonlisp.py | 3 +- tests/test_commonlisp_specializer_binding.py | 32 +++++++++++++++++ tests/test_languages.py | 36 ++++++++++++++++++++ 4 files changed, 76 insertions(+), 2 deletions(-) create mode 100644 tests/test_commonlisp_specializer_binding.py 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")