diff --git a/CHANGELOG.md b/CHANGELOG.md index 79d3da40..daa2bf43 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ Full release notes with details on each version: [GitHub Releases](https://githu ## Unreleased +- Fix: `to_obsidian` / `to_canvas` / `to_wiki` no longer silently overwrite notes whose labels differ only by case (e.g. a class `References` and a prose heading `references`). The filename dedup was keyed on the exact-case name, so two such labels counted as non-colliding and the second write clobbered the first on case-insensitive filesystems (macOS/APFS, Windows/NTFS) — no suffix, no warning. Dedup now folds case (keyed on the lowercased name) while still emitting the original-case filename, so any pair that would collide on disk gets a numeric suffix. The obsidian/canvas dedup is shared in one helper so they can't drift, `wiki`'s slug dedup gets the matching fix, the `_COMMUNITY_*.md` overview notes (which had no dedup) are covered, and a generated `base_1` is itself re-checked so it can't overwrite a node literally labelled `base_1` (#1453, thanks @TPAteeq). + ## 0.8.49 (2026-06-24) - Fix: the `get_community` MCP tool now shows the community name in its header (`Community 12 — Auth & Sessions (8 nodes)`), matching `get_node` and the query-traversal output, which already read the `community_name` attribute `to_json` writes onto every node. `get_community` was the only graph tool still returning a bare numeric id. The name is read from the community's member nodes (they share it), sanitised like every other LLM-derived field, and skipped when it is just the `Community N` placeholder so the header never doubles to `Community 12 — Community 12` (#1448, thanks @rmart1308). diff --git a/graphify/export.py b/graphify/export.py index 17798d6b..084ba52d 100644 --- a/graphify/export.py +++ b/graphify/export.py @@ -840,6 +840,28 @@ def _cap_filename(s: str, limit: int = 200) -> str: return f"{truncated}_{digest}" +def _dedup_node_filenames(G: nx.Graph, safe_name) -> dict[str, str]: + """Map each node_id to a unique note filename, appending a numeric suffix on + collision. The collision set is keyed on the lowercased name so two labels + differing only by case (e.g. "References" vs "references") still get distinct + filenames - on case-insensitive filesystems (macOS/APFS, Windows/NTFS) they + would otherwise resolve to one path and silently overwrite each other on disk. + The suffixed candidate is itself re-checked, so a generated "base_1" never + silently overwrites a node whose literal label is already "base_1".""" + node_filenames: dict[str, str] = {} + used: set[str] = set() + for node_id, data in G.nodes(data=True): + base = safe_name(data.get("label", node_id)) + candidate = base + n = 1 + while candidate.lower() in used: + candidate = f"{base}_{n}" + n += 1 + used.add(candidate.lower()) + node_filenames[node_id] = candidate + return node_filenames + + def to_obsidian( G: nx.Graph, communities: dict[int, list[str]], @@ -875,16 +897,7 @@ def to_obsidian( return "unnamed" return _cap_filename(cleaned) - node_filename: dict[str, str] = {} - seen_names: dict[str, int] = {} - for node_id, data in G.nodes(data=True): - base = safe_name(data.get("label", node_id)) - if base in seen_names: - seen_names[base] += 1 - node_filename[node_id] = f"{base}_{seen_names[base]}" - else: - seen_names[base] = 0 - node_filename[node_id] = base + node_filename = _dedup_node_filenames(G, safe_name) # Helper: compute dominant confidence for a node across all its edges def _dominant_confidence(node_id: str) -> str: @@ -982,13 +995,33 @@ def to_obsidian( } return len(neighbor_cids) - community_notes_written = 0 - for cid, all_members in communities.items(): - community_name = ( + def _community_name(cid) -> str: + return ( community_labels.get(cid, f"Community {cid}") if community_labels and cid is not None else f"Community {cid}" ) + + # One case-folded-deduped filename per community, computed once so the note we + # write and every [[_COMMUNITY_...]] cross-reference resolve to the same file. + # Two community labels differing only by case (e.g. LLM labels "API" vs "Api") + # would otherwise overwrite each other on case-insensitive filesystems - and + # this path had no dedup at all, so even same-case duplicate labels collided. + community_filename: dict = {} + used_community: set[str] = set() + for cid in communities: + base = f"_COMMUNITY_{safe_name(_community_name(cid))}" + candidate = base + n = 1 + while candidate.lower() in used_community: + candidate = f"{base}_{n}" + n += 1 + used_community.add(candidate.lower()) + community_filename[cid] = candidate + + community_notes_written = 0 + for cid, all_members in communities.items(): + community_name = _community_name(cid) # A community's member list can contain ids with no backing node in G # (e.g. pruned nodes, stale community assignments from a prior run, or # synthesized/merge-artifact ids). Dereferencing those via G.nodes[n] or @@ -1052,13 +1085,8 @@ def to_obsidian( if cross: lines.append("## Connections to other communities") for other_cid, edge_count in sorted(cross.items(), key=lambda x: -x[1]): - other_name = ( - community_labels.get(other_cid, f"Community {other_cid}") - if community_labels and other_cid is not None - else f"Community {other_cid}" - ) - other_safe = safe_name(other_name) - lines.append(f"- {edge_count} edge{'s' if edge_count != 1 else ''} to [[_COMMUNITY_{other_safe}]]") + other_fname = community_filename.get(other_cid) or f"_COMMUNITY_{safe_name(_community_name(other_cid))}" + lines.append(f"- {edge_count} edge{'s' if edge_count != 1 else ''} to [[{other_fname}]]") lines.append("") # Top bridge nodes - highest degree nodes that connect to other communities @@ -1078,8 +1106,7 @@ def to_obsidian( f"{'community' if reach == 1 else 'communities'}" ) - community_safe = safe_name(community_name) - fname = f"_COMMUNITY_{community_safe}.md" + fname = community_filename[cid] + ".md" (out / fname).write_text("\n".join(lines), encoding="utf-8") # nosec community_notes_written += 1 @@ -1130,16 +1157,7 @@ def to_canvas( # Build node_filenames if not provided (same dedup logic as to_obsidian) if node_filenames is None: - node_filenames = {} - seen_names: dict[str, int] = {} - for node_id, data in G.nodes(data=True): - base = safe_name(data.get("label", node_id)) - if base in seen_names: - seen_names[base] += 1 - node_filenames[node_id] = f"{base}_{seen_names[base]}" - else: - seen_names[base] = 0 - node_filenames[node_id] = base + node_filenames = _dedup_node_filenames(G, safe_name) # Fallback: with no community data (e.g. --no-cluster builds or a missing # analysis sidecar) the grid below produces nothing and the canvas is written diff --git a/graphify/wiki.py b/graphify/wiki.py index eb662317..4212fb7f 100644 --- a/graphify/wiki.py +++ b/graphify/wiki.py @@ -248,12 +248,16 @@ def to_wiki( used_slugs: set[str] = set() def _unique_slug(base: str) -> str: + # Fold case in the collision check: two labels differing only by case + # (e.g. "Parser" vs "parser") resolve to one path on case-insensitive + # filesystems (macOS/APFS, Windows/NTFS), so they must dedup against each + # other while still emitting the original-case filename. slug = base n = 2 - while slug in used_slugs: + while slug.lower() in used_slugs: slug = f"{base}_{n}" n += 1 - used_slugs.add(slug) + used_slugs.add(slug.lower()) return slug # Community articles diff --git a/tests/test_export.py b/tests/test_export.py index a77f64dd..e5dbfa43 100644 --- a/tests/test_export.py +++ b/tests/test_export.py @@ -237,6 +237,110 @@ def test_to_canvas_never_emits_punctuation_only_filenames(): assert not bad, f"punctuation-only canvas filenames: {bad}" +# ── Case-only-distinct labels must not collide on case-insensitive filesystems ── + +def _case_collision_graph(): + """Two nodes whose labels differ only by case - on macOS/APFS and Windows/NTFS + their notes resolve to the same path unless the dedup map folds case.""" + return build_from_json({ + "nodes": [ + {"id": "n1", "label": "References", "file_type": "code", "source_file": "a.py"}, + {"id": "n2", "label": "references", "file_type": "document", "source_file": "b.md"}, + ], + "edges": [], + }) + + +def test_to_obsidian_case_only_distinct_labels_dont_overwrite(): + """Both notes must survive as separate files. On a case-insensitive filesystem + a missing suffix silently overwrites the first note (fewer files than nodes); + on a case-sensitive one it writes two stems equal under .lower(). Assert both: + every node note is on disk, and no two stems collide case-insensitively.""" + G = _case_collision_graph() + communities = cluster(G) + with tempfile.TemporaryDirectory() as tmp: + to_obsidian(G, communities, tmp) + notes = [p for p in Path(tmp).rglob("*.md") if not p.name.startswith("_COMMUNITY")] + assert len(notes) == G.number_of_nodes(), [p.name for p in notes] + lowered = [p.stem.lower() for p in notes] + assert len(set(lowered)) == len(lowered), [p.name for p in notes] + # the suffixed name must be the expected one, not merely distinct + assert sorted(p.stem for p in notes) == ["References", "references_1"], [p.name for p in notes] + + +def test_to_obsidian_generated_suffix_doesnt_overwrite_literal(): + """A generated `_1` suffix must not collide with a node whose literal label is + already that suffixed name. With labels [dup, dup, dup_1] the second `dup` + becomes `dup_1`, which would clobber the third node unless the candidate is + re-checked. This collides on case-sensitive filesystems too, so it guards the + dedup loop independently of case-folding.""" + G = build_from_json({ + "nodes": [ + {"id": "a", "label": "dup", "file_type": "code", "source_file": "a.py"}, + {"id": "b", "label": "dup", "file_type": "code", "source_file": "b.py"}, + {"id": "c", "label": "dup_1", "file_type": "code", "source_file": "c.py"}, + ], + "edges": [], + }) + communities = cluster(G) + with tempfile.TemporaryDirectory() as tmp: + to_obsidian(G, communities, tmp) + notes = [p for p in Path(tmp).rglob("*.md") if not p.name.startswith("_COMMUNITY")] + assert len(notes) == 3, [p.name for p in notes] + assert len({p.stem.lower() for p in notes}) == 3, [p.name for p in notes] + + +def test_to_canvas_case_only_distinct_labels_get_distinct_files(): + """Canvas file-node references for case-only-distinct labels must be distinct + case-insensitively, else both cards point at one overwritten note.""" + G = _case_collision_graph() + communities = cluster(G) + with tempfile.TemporaryDirectory() as tmp: + out = Path(tmp) / "graph.canvas" + to_canvas(G, communities, str(out)) + data = json.loads(out.read_text()) + files = [n["file"] for n in data["nodes"] if n.get("type") == "file"] + lowered = [f.lower() for f in files] + assert len(set(lowered)) == len(lowered), files + + +def test_obsidian_canvas_filenames_agree(): + """The CLI calls to_obsidian and to_canvas separately with no shared map, so + they must independently produce the same node->filename mapping - otherwise a + canvas card points at a note file that doesn't exist on disk.""" + G = _case_collision_graph() + communities = cluster(G) + with tempfile.TemporaryDirectory() as tmp: + to_obsidian(G, communities, tmp) + note_stems = {p.stem for p in Path(tmp).rglob("*.md") if not p.name.startswith("_COMMUNITY")} + out = Path(tmp) / "graph.canvas" + to_canvas(G, communities, str(out)) + data = json.loads(out.read_text()) + canvas_stems = {Path(n["file"]).stem for n in data["nodes"] if n.get("type") == "file"} + assert canvas_stems <= note_stems, (sorted(canvas_stems), sorted(note_stems)) + + +def test_to_obsidian_community_notes_case_collision(): + """Two community labels differing only by case must each get their own + `_COMMUNITY_*.md` overview note. This path had no dedup at all, so even + same-case duplicate labels previously overwrote silently.""" + G = build_from_json({ + "nodes": [ + {"id": "n1", "label": "alpha", "file_type": "code", "source_file": "a.py"}, + {"id": "n2", "label": "beta", "file_type": "code", "source_file": "b.py"}, + ], + "edges": [], + }) + communities = {0: ["n1"], 1: ["n2"]} + labels = {0: "API", 1: "Api"} + with tempfile.TemporaryDirectory() as tmp: + to_obsidian(G, communities, tmp, community_labels=labels) + comm = [p for p in Path(tmp).rglob("_COMMUNITY_*.md")] + assert len(comm) == 2, [p.name for p in comm] + lowered = [p.stem.lower() for p in comm] + assert len(set(lowered)) == len(lowered), [p.name for p in comm] + + # ── Issue #834: backup_if_protected ────────────────────────────────────────── def test_backup_no_graph_json(tmp_path): diff --git a/tests/test_wiki.py b/tests/test_wiki.py index 063d47ed..b4fc97cd 100644 --- a/tests/test_wiki.py +++ b/tests/test_wiki.py @@ -210,3 +210,41 @@ def test_community_article_handles_null_source_file(tmp_path): # Must not raise TypeError to_wiki(G, communities, tmp_path, community_labels=labels) assert (tmp_path / "index.md").exists() + + +def test_to_wiki_case_only_distinct_labels_dont_overwrite(tmp_path): + """Two community labels differing only by case must each get their own + article. The slug-dedup set folds case, so on case-insensitive filesystems + (macOS/APFS, Windows/NTFS) the second article gets a numeric suffix instead + of silently overwriting the first.""" + G = nx.Graph() + G.add_node("n1", label="parse", file_type="code", source_file="a.py", community=0) + G.add_node("n2", label="render", file_type="code", source_file="b.py", community=1) + G.add_edge("n1", "n2", relation="calls", confidence="EXTRACTED", weight=1.0) + communities = {0: ["n1"], 1: ["n2"]} + labels = {0: "Parser", 1: "parser"} + n = to_wiki(G, communities, tmp_path, community_labels=labels) + articles = [p for p in tmp_path.glob("*.md") if p.name != "index.md"] + # both communities survive as separate files on disk (no silent overwrite) + assert len(articles) == n == 2, [p.name for p in articles] + # filenames are distinct even when compared case-insensitively + lowered = [p.stem.lower() for p in articles] + assert len(set(lowered)) == len(lowered), [p.name for p in articles] + + +def test_to_wiki_god_node_label_case_collides_with_community(tmp_path): + """Community and god-node articles share one slug-dedup set, so a god-node + label differing only by case from a community label must still get its own + file rather than overwriting the community article.""" + G = nx.Graph() + G.add_node("n1", label="parse", file_type="code", source_file="a.py", community=0) + G.add_node("n2", label="run", file_type="code", source_file="b.py", community=0) + G.add_edge("n1", "n2", relation="calls", confidence="EXTRACTED", weight=1.0) + communities = {0: ["n1", "n2"]} + labels = {0: "Parser"} + god_nodes = [{"id": "n1", "label": "parser", "degree": 1}] + n = to_wiki(G, communities, tmp_path, community_labels=labels, god_nodes_data=god_nodes) + articles = [p for p in tmp_path.glob("*.md") if p.name != "index.md"] + assert len(articles) == n == 2, [p.name for p in articles] + lowered = [p.stem.lower() for p in articles] + assert len(set(lowered)) == len(lowered), [p.name for p in articles]