mirror of
https://github.com/safishamsi/graphify.git
synced 2026-10-16 08:59:26 +00:00
fix(export): case-fold filename dedup so case-only labels don't overwrite (#1453)
to_obsidian / to_canvas / to_wiki keyed filename dedup on the exact-case name, so two labels differing only by case (e.g. `References` vs `references`) counted as non-colliding and the second write clobbered the first on case-insensitive filesystems (macOS/APFS, Windows/NTFS) — silently, no suffix, no warning. Dedup now folds case (keyed on the lowercased name) while emitting the original-case filename, so any pair that would collide on disk gets a numeric suffix. The obsidian/canvas dedup is one shared helper (`_dedup_node_filenames`) so they can't drift; wiki's slug dedup gets the matching fix; the `_COMMUNITY_*` overview notes (which had no dedup at all) are covered; and a generated `base_1` is re-checked so it can't overwrite a node literally labelled `base_1`. Ported from PR #1457 by @TPAteeq onto current v8. Verified with a rigorous edge-case battery (case-only collision, base_1 literal re-check -> base_1_1, community-label case fold, determinism) plus the PR's tests; full suite 2404 passed, ruff + skillgen clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
e4ff54f4e0
commit
5d63aad596
5 files changed
+200
-34
No files matched your search
+50
-32
@@ -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
|
||||
|
||||
Reference in new issue
Block a user