Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion graphify/extract.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"):
Expand Down
3 changes: 2 additions & 1 deletion graphify/extractors/commonlisp.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
32 changes: 32 additions & 0 deletions tests/test_commonlisp_specializer_binding.py
Original file line number Diff line number Diff line change
@@ -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"
)
36 changes: 36 additions & 0 deletions tests/test_languages.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Loading