diff --git a/CHANGELOG.md b/CHANGELOG.md index b16a8615..c0c0c28c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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). diff --git a/graphify/extractors/commonlisp.py b/graphify/extractors/commonlisp.py index 15088090..dd3c08ae 100644 --- a/graphify/extractors/commonlisp.py +++ b/graphify/extractors/commonlisp.py @@ -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 diff --git a/tests/test_languages.py b/tests/test_languages.py index fcadfcd2..add764b4 100644 --- a/tests/test_languages.py +++ b/tests/test_languages.py @@ -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