fix(export): guard to_canvas against dangling community members (#1236 follow-up)

The #1236 fix guarded to_obsidian's member loop but not to_canvas, so
`graphify export obsidian` (which also writes graph.canvas) still crashed with
KeyError on a community member id absent from G — after the notes exported,
leaving a partial mirror. Reported on 0.9.5 by @swells808.

Apply the same `m in G and m in node_filenames` filter in both to_canvas loops:
the box-sizing loop (so the group box matches the cards actually laid out) and
the card-layout loop (so the sort/label deref and the node_filenames fallback
never touch a dangling id). Regression test added alongside the to_obsidian one.
Full suite 2872.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
safishamsi
2026-07-03 18:34:41 +01:00
co-authored by Claude Opus 4.8
parent 4744dfefca
commit 2ba07e84e1
3 changed files with 29 additions and 1 deletions
+2
View File
@@ -4,6 +4,8 @@ Full release notes with details on each version: [GitHub Releases](https://githu
## Unreleased
- Fix: `graphify export obsidian` no longer crashes in `to_canvas` on a dangling community member (#1236 follow-up, thanks @swells808). The original #1236 fix guarded `to_obsidian` but not `to_canvas`, so a community member id with no backing node in the graph still raised `KeyError` while writing `graph.canvas` — after the notes had exported, leaving a partial mirror. `to_canvas` now applies the same dangling-member filter (`m in G and m in node_filenames`) in both the box-sizing and card-layout loops.
- Feat: TS/JS member calls on a local `new` binding or a type-annotated parameter now resolve (#1630, thanks @DanielC000). `const s = new Svc(); s.doThing()` and a call on a typed param — including inside a returned closure (`(svc: Svc) => () => svc.doThing()`) — now emit `calls` edges to the receiver type's method, so `affected` no longer silently under-reports. Extends the #1316 `this.field` resolver: the per-file type table now also learns local `new` bindings and bare-typed parameters, and `walk_calls` descends into inline/returned closures (attributing their calls to the enclosing function) instead of stopping at the arrow boundary. Resolution keeps the single-definition guard; an untyped or non-bare-typed (array/union/generic) receiver produces no edge.
- Fix: the `query` reference doc's inline vocab/fallback snippets now read and write files with `encoding="utf-8"` (#1619 A2, thanks @edtrackai). On Windows (default cp1252) the bare `read_text()`/`write_text()` calls crashed on exactly the cross-language corpora the doc demonstrates (e.g. Cyrillic labels like `обработчик`). Fixed across all generated skill variants.
+7 -1
View File
@@ -1278,7 +1278,10 @@ def to_canvas(
group_sizes: dict[int, tuple[int, int]] = {}
group_cols: dict[int, int] = {}
for cid in sorted_cids:
members = communities[cid]
# Skip dangling community members with no backing node / filename, so box
# sizing matches the cards actually laid out and `G.nodes[m]` never
# KeyErrors below — mirrors the to_obsidian guard (#1236).
members = [m for m in communities[cid] if m in G and m in node_filenames]
n = len(members)
inner_cols = max(1, math.ceil(math.sqrt(n)))
w = max(600, 220 * inner_cols)
@@ -1351,6 +1354,9 @@ def to_canvas(
# Node cards inside the group - laid out in the same ceil(sqrt(n))-column
# grid the box was sized for (group_cols[cid]), so cards fill the box.
inner_cols = group_cols[cid]
# Same dangling-member guard as the sizing loop and to_obsidian (#1236):
# a community id absent from G / node_filenames would KeyError the sort.
members = [m for m in members if m in G and m in node_filenames]
sorted_members = sorted(members, key=lambda n: G.nodes[n].get("label", n))
for m_idx, node_id in enumerate(sorted_members):
col = m_idx % inner_cols
+20
View File
@@ -48,3 +48,23 @@ def test_obsidian_community_of_only_dangling_members(tmp_path):
ghost_note = tmp_path / "_COMMUNITY_Community 1.md"
assert ghost_note.exists()
assert "**Members:** 0 nodes" in ghost_note.read_text(encoding="utf-8")
def test_canvas_dangling_community_member_does_not_crash(tmp_path):
"""#1236 follow-up: the fix landed in to_obsidian but not to_canvas, so
`graphify export obsidian` (which also writes graph.canvas) still crashed
with KeyError in to_canvas on a dangling member. The same guard now applies
to both the box-sizing loop and the card-layout loop."""
import json
from graphify.export import to_canvas
G, comms = _graph_with_dangling_member()
out = tmp_path / "graph.canvas"
to_canvas(G, comms, str(out)) # before the fix: KeyError: 'agents_doc'
assert out.exists()
canvas = json.loads(out.read_text(encoding="utf-8"))
node_ids = {n.get("id") for n in canvas.get("nodes", [])}
# real members get cards; the dangling id does not
assert "n_n0" in node_ids and "n_n1" in node_ids
assert "n_agents_doc" not in node_ids