diff --git a/graphify/__main__.py b/graphify/__main__.py index 49c2474..895f626 100644 --- a/graphify/__main__.py +++ b/graphify/__main__.py @@ -1825,6 +1825,8 @@ def main() -> None: tokens, str(watch_path), suggested_questions=questions, min_community_size=min_community_size, built_at_commit=_commit) (out / "GRAPH_REPORT.md").write_text(report, encoding="utf-8") + from graphify.export import backup_if_protected as _backup + _backup(out) to_json(G, communities, str(out / "graph.json")) labels_path.write_text(json.dumps({str(k): v for k, v in labels.items()}, ensure_ascii=False), encoding="utf-8") @@ -2769,6 +2771,8 @@ def main() -> None: if no_cluster: # --no-cluster: dump the raw merged extraction as graph.json. # No NetworkX, no community detection, no analysis sidecar. + from graphify.export import backup_if_protected as _backup + _backup(graphify_out) graph_json_path.write_text( json.dumps(merged, indent=2), encoding="utf-8" ) @@ -2846,7 +2850,13 @@ def main() -> None: except Exception: surprises = [] + from graphify.export import backup_if_protected as _backup + _backup(graphify_out) _to_json(G, communities, str(graph_json_path), force=True) + if merged.get("output_tokens", 0) > 0: + (graphify_out / ".graphify_semantic_marker").write_text( + json.dumps({"output_tokens": merged["output_tokens"]}), encoding="utf-8" + ) if global_merge: from graphify.global_graph import global_add as _global_add _tag = global_repo_tag or target.name diff --git a/graphify/export.py b/graphify/export.py index 8ddf2bd..a71c927 100644 --- a/graphify/export.py +++ b/graphify/export.py @@ -3,8 +3,11 @@ from __future__ import annotations import html as _html import json import math +import os import re +import shutil from collections import Counter +from datetime import date from pathlib import Path import networkx as nx from networkx.readwrite import json_graph @@ -12,6 +15,77 @@ from graphify.security import sanitize_label from graphify.analyze import _node_community_map from graphify.build import edge_data + +# Artifacts worth preserving across rebuilds (non-regenerable without LLM or curation). +_BACKUP_ARTIFACTS = [ + "graph.json", + "GRAPH_REPORT.md", + ".graphify_labels.json", + ".graphify_analysis.json", + "manifest.json", + ".graphify_semantic_marker", + "cost.json", +] + + +def backup_if_protected(out_dir: Path) -> "Path | None": + """Snapshot graph artifacts to a dated subfolder before an overwrite. + + Triggers when graph.json exists AND either: + - .graphify_semantic_marker is present (graph cost real LLM tokens), or + - .graphify_labels.json contains at least one non-default community label + (graph has been curated by a human or skill). + + Returns the backup folder path, or None if no backup was taken. + Never raises — backup failure prints a warning but never blocks the write. + Set GRAPHIFY_NO_BACKUP=1 to disable. + """ + if os.environ.get("GRAPHIFY_NO_BACKUP"): + return None + out = Path(out_dir) + if not (out / "graph.json").exists(): + return None + + is_semantic = (out / ".graphify_semantic_marker").exists() + is_curated = False + labels_file = out / ".graphify_labels.json" + if labels_file.exists(): + try: + labels = json.loads(labels_file.read_text(encoding="utf-8")) + is_curated = any(v != f"Community {k}" for k, v in labels.items()) + except Exception: + pass + + if not is_semantic and not is_curated: + return None + + reason = "+".join(filter(None, ["semantic" if is_semantic else "", "curated" if is_curated else ""])) + today = date.today().isoformat() + backup_dir = out / today + suffix = 2 + while backup_dir.exists(): + backup_dir = out / f"{today}_{suffix}" + suffix += 1 + + try: + backup_dir.mkdir(parents=True, exist_ok=True) + copied = 0 + for name in _BACKUP_ARTIFACTS: + src = out / name + if src.exists(): + try: + shutil.copy2(src, backup_dir / name) + copied += 1 + except Exception: + pass + if copied: + print(f"[graphify] backed up {reason} graph ({copied} files) → {backup_dir.name}/") + return backup_dir + except Exception as exc: + import sys + print(f"[graphify] warning: backup failed ({exc}) — continuing with overwrite", file=sys.stderr) + return None + def _obsidian_tag(name: str) -> str: """Sanitize a community name for use as an Obsidian tag. diff --git a/graphify/watch.py b/graphify/watch.py index 2447e24..ade55a8 100644 --- a/graphify/watch.py +++ b/graphify/watch.py @@ -543,6 +543,8 @@ def _rebuild_code( else: if not _check_shrink(force, existing_graph_data, candidate_graph_data, tmp=graph_tmp): return False + from graphify.export import backup_if_protected as _backup + _backup(out) graph_tmp.replace(existing_graph) report_path.write_text(report, encoding="utf-8") labels_file.write_text(labels_json, encoding="utf-8") diff --git a/tests/test_export.py b/tests/test_export.py index e93ba81..832c870 100644 --- a/tests/test_export.py +++ b/tests/test_export.py @@ -151,3 +151,73 @@ def test_to_canvas_file_paths_relative_to_vault(): for node in file_nodes: assert "/" not in node["file"], f"file path should not contain '/': {node['file']}" assert node["file"].endswith(".md") + + +# ── Issue #834: backup_if_protected ────────────────────────────────────────── + +def test_backup_no_graph_json(tmp_path): + """No graph.json → no backup.""" + from graphify.export import backup_if_protected + assert backup_if_protected(tmp_path) is None + + +def test_backup_no_markers(tmp_path): + """graph.json present but no sentinel and no curated labels → no backup.""" + from graphify.export import backup_if_protected + (tmp_path / "graph.json").write_text('{"nodes":[],"links":[]}') + assert backup_if_protected(tmp_path) is None + + +def test_backup_semantic_marker(tmp_path): + """graph.json + .graphify_semantic_marker → backup taken.""" + from graphify.export import backup_if_protected + (tmp_path / "graph.json").write_text('{"nodes":[],"links":[]}') + (tmp_path / "GRAPH_REPORT.md").write_text("# Report") + (tmp_path / ".graphify_semantic_marker").write_text('{"output_tokens": 1234}') + result = backup_if_protected(tmp_path) + assert result is not None + assert result.is_dir() + assert (result / "graph.json").exists() + assert (result / "GRAPH_REPORT.md").exists() + assert (result / ".graphify_semantic_marker").exists() + + +def test_backup_curated_labels(tmp_path): + """graph.json + non-default label in .graphify_labels.json → backup taken.""" + import json + from graphify.export import backup_if_protected + (tmp_path / "graph.json").write_text('{"nodes":[],"links":[]}') + (tmp_path / ".graphify_labels.json").write_text(json.dumps({"0": "Auth Pipeline", "1": "Community 1"})) + result = backup_if_protected(tmp_path) + assert result is not None + + +def test_backup_default_labels_only(tmp_path): + """All-default labels → no backup (not curated).""" + import json + from graphify.export import backup_if_protected + (tmp_path / "graph.json").write_text('{"nodes":[],"links":[]}') + (tmp_path / ".graphify_labels.json").write_text(json.dumps({"0": "Community 0", "1": "Community 1"})) + assert backup_if_protected(tmp_path) is None + + +def test_backup_same_day_collision(tmp_path): + """Second backup on same day gets _2 suffix.""" + from graphify.export import backup_if_protected + from datetime import date + (tmp_path / "graph.json").write_text('{"nodes":[],"links":[]}') + (tmp_path / ".graphify_semantic_marker").write_text("{}") + b1 = backup_if_protected(tmp_path) + b2 = backup_if_protected(tmp_path) + assert b1 is not None and b2 is not None + assert b1 != b2 + assert b2.name == f"{date.today().isoformat()}_2" + + +def test_backup_env_disable(tmp_path, monkeypatch): + """GRAPHIFY_NO_BACKUP=1 disables backup entirely.""" + from graphify.export import backup_if_protected + monkeypatch.setenv("GRAPHIFY_NO_BACKUP", "1") + (tmp_path / "graph.json").write_text('{"nodes":[],"links":[]}') + (tmp_path / ".graphify_semantic_marker").write_text("{}") + assert backup_if_protected(tmp_path) is None