mirror of
https://github.com/safishamsi/graphify.git
synced 2026-09-22 13:35:46 +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:
committed by
safishamsi
co-authored by
Claude Opus 4.8
parent
e4ff54f4e0
commit
5d63aad596
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user