fix(commonlisp): mint sourceless stubs for import targets so edges don't dangle

The extractor emitted `imports` edges to _cl_id(mod_name) with no node created,
so a :use/require edge dangled (verified: sample.lisp left 'cl' and 'alexandria'
as edgeless targets) and never resolved to an in-corpus defpackage. Mint a
sourceless stub for each import target (the established cross-file pattern):
edges now have real targets, the corpus rewire collapses a stub onto a unique
in-corpus defpackage of the same name (:use :mylib -> the real package), and an
external one (cl) persists as a clean leaf. origin_file is stripped before
persist. Adds a no-dangling-edges regression test. Also adds the CHANGELOG entry.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
safishamsi
2026-08-17 16:08:55 +01:00
co-authored by Claude Opus 4.8
parent 038ada6f0f
commit 7765d610d8
3 changed files with 38 additions and 2 deletions
+2
View File
@@ -4,6 +4,8 @@ Full release notes with details on each version: [GitHub Releases](https://githu
## 0.9.46 (unreleased)
- Feature: Common Lisp `.lisp`/`.cl`/`.lsp`/`.asd` extraction via tree-sitter-commonlisp (optional `[commonlisp]` extra) — packages, classes, functions, methods, generics, macros, variable definers, and same-file calls; `open`ed/`:use`d packages resolve cross-file (thanks @fade).
- Fix: a `graphify query` whose node set fits the budget but whose edges push the total over now prints an honest "complete answer over budget" notice with the real size, instead of silently returning a payload several times the requested budget (and no longer advises raising the budget, which was the exact trigger); edges are still never dropped from a complete answer (#2784, thanks @AromalBiju1).
- Fix: a `.gitignore`/`.graphifyignore` saved in a non-UTF-8 encoding no longer silently drops its rules (which let an explicitly-excluded directory get scanned anyway); the file is decoded UTF-8-first, then by a UTF-16 BOM, then the host codepage/latin-1, so the rule survives intact with a warning instead of being truncated (#2798, thanks @abhay-codes07).
- Fix: when node dedup merges two nodes, any hyperedge that listed the merged-away node as a member now rewires that member to the survivor instead of silently dropping it, so a grouping no longer loses participants on dedup (#2805, thanks @abhay-codes07).
+21 -2
View File
@@ -135,6 +135,25 @@ def extract_commonlisp(path: Path) -> dict:
file_nid = _cl_id(stem)
add_node(file_nid, path.name, 1)
def add_import_stub(mod_name: str) -> str:
"""Mint a SOURCELESS stub for an imported package so its `imports` edge
has a real target instead of dangling. The corpus rewire collapses it
onto a unique in-corpus `defpackage` of the same name; an external one
(cl, alexandria) stays a sourceless leaf. `origin_file` is stripped
before persist (no #1899 leak); a sourced bare stub would salt the id."""
nid = _cl_id(mod_name)
if nid not in seen_ids:
seen_ids.add(nid)
nodes.append({
"id": nid,
"label": mod_name,
"file_type": "code",
"source_file": "",
"source_location": "",
"origin_file": str_path,
})
return nid
def _first_sym(node) -> str | None:
"""Get the first sym_lit text from a list_lit's children."""
for child in node.children:
@@ -178,7 +197,7 @@ def extract_commonlisp(path: Path) -> dict:
if uc.type == "kwd_lit" and uc != gc:
mod_name = _kwd_text(uc)
if mod_name != "use":
tgt_nid = _cl_id(mod_name)
tgt_nid = add_import_stub(mod_name)
add_edge(pkg_nid, tgt_nid, "imports",
child.start_point[0] + 1)
break
@@ -427,7 +446,7 @@ def extract_commonlisp(path: Path) -> dict:
if child.type in ("kwd_lit", "str_lit"):
mod_name = _kwd_text(child) if child.type == "kwd_lit" else _text(child).strip('"')
if mod_name:
tgt_nid = _cl_id(mod_name)
tgt_nid = add_import_stub(mod_name)
add_edge(file_nid, tgt_nid, "imports",
top.start_point[0] + 1)
break
+15
View File
@@ -3291,3 +3291,18 @@ def test_cl_defparameter_string_value_not_docstring():
path.unlink()
@_needs_commonlisp
def test_cl_import_edges_are_not_dangling():
"""Every `imports` edge must target a real node (a sourceless stub the corpus
rewire can collapse), not a nodeless id otherwise the edge dangles."""
r = extract_commonlisp(FIXTURES / "sample.lisp")
ids = {n["id"] for n in r["nodes"]}
dangling = [e for e in r["edges"] if e["source"] not in ids or e["target"] not in ids]
assert not dangling, f"dangling edges: {dangling}"
import_targets = [e["target"] for e in r["edges"] if e["relation"] == "imports"]
assert import_targets, "sample.lisp uses packages, so it must emit imports edges"
assert all(t in ids for t in import_targets)
# the import-target stubs are sourceless so the corpus rewire can collapse them
stub_labels = {n["label"] for n in r["nodes"] if n.get("source_file") == ""}
assert "cl" in stub_labels