diff --git a/graphify/extractors/commonlisp.py b/graphify/extractors/commonlisp.py index dd3c08aee..f43e72c01 100644 --- a/graphify/extractors/commonlisp.py +++ b/graphify/extractors/commonlisp.py @@ -154,6 +154,31 @@ def extract_commonlisp(path: Path) -> dict: }) return nid + def ensure_class_ref(name: str, line: int) -> str: + """Resolve a superclass name to a node id. + + If the class is defined in THIS file, bind to its local node. Otherwise + mint a SOURCELESS stub (like `add_import_stub`) so the corpus-level + rewire can collapse it onto the real `defclass` in another file. Without + this, a cross-file superclass had no matching node and its `inherits` + edge was pruned by the dangling-edge filter — silently erasing every + inheritance edge whose parent lives in a different file.""" + local = _cl_id(stem, name) + if local in seen_ids: + return local + stub = _cl_id(name) + if stub not in seen_ids: + seen_ids.add(stub) + nodes.append({ + "id": stub, + "label": name, + "file_type": "code", + "source_file": "", + "source_location": "", + "origin_file": str_path, + }) + return stub + def _first_sym(node) -> str | None: """Get the first sym_lit text from a list_lit's children.""" for child in node.children: @@ -227,7 +252,7 @@ def extract_commonlisp(path: Path) -> dict: for sc in superclass_list.children: if sc.type == "sym_lit": sc_name = _text(sc) - sc_nid = _cl_id(stem, sc_name) + sc_nid = ensure_class_ref(sc_name, superclass_list.start_point[0] + 1) add_edge(class_nid, sc_nid, "inherits", superclass_list.start_point[0] + 1) diff --git a/tests/test_languages.py b/tests/test_languages.py index c3bef5645..80ea43865 100644 --- a/tests/test_languages.py +++ b/tests/test_languages.py @@ -3497,6 +3497,39 @@ def test_cl_inherits(): assert len(inherit_edges) >= 1 assert any("ssl_server" in e["source"] and "server" in e["target"] for e in inherit_edges) +@_needs_commonlisp +def test_cl_crossfile_superclass_inherits_edge_survives(tmp_path): + """A superclass defined in another file must still yield an inherits edge. + + The edge target was a file-scoped id with no backing node, so when the + parent class lived in a different file the dangling-edge filter pruned the + inherits edge entirely. Cross-file references must resolve to a sourceless + stub (like imports do) so the corpus rewire can collapse it onto the real + defclass — while a same-file parent still binds to its local node. + """ + f = tmp_path / "dogs.lisp" + f.write_text( + "(defclass animal () ())\n" + "(defclass dog (animal) ())\n" + "(defclass service-dog (base-animal) ())\n" + ) + r = extract_commonlisp(f) + assert "error" not in r + id_to_node = {n["id"]: n for n in r["nodes"]} + inherits = { + (id_to_node[e["source"]]["label"], id_to_node[e["target"]]["label"]) + for e in r["edges"] + if e["relation"] == "inherits" + and e["source"] in id_to_node and e["target"] in id_to_node + } + # same-file parent binds locally + assert ("dog", "animal") in inherits + # cross-file parent survives via a sourceless stub instead of being dropped + assert ("service-dog", "base-animal") in inherits + base = next(n for n in r["nodes"] if n["label"] == "base-animal") + assert base["source_file"] == "", "cross-file superclass must be a sourceless stub" + + @_needs_commonlisp def test_cl_imports(): r = extract_commonlisp(FIXTURES / "sample.lisp")