diff --git a/graphify/build.py b/graphify/build.py index a8e69899f..64fd6c556 100644 --- a/graphify/build.py +++ b/graphify/build.py @@ -450,10 +450,11 @@ def _normalize_hyperedge_members(he: object) -> None: def _fold_node_aliases(node: dict) -> None: """Fold legacy node field aliases onto canonical keys, in place (#2194). - ``name`` -> ``label`` and ``path`` -> ``source_file``. Uses an empty-check - (not mere key presence) so a node carrying ``label: ""``/``None`` next to a - real ``name`` is healed too. When the canonical field already holds a value - it wins and the alias key is left untouched. Without this fold an alias-only + ``name`` -> ``label`` and ``file``/``path``/``filepath`` -> ``source_file`` + (first non-blank alias in that order wins). Uses an empty-check (not mere + key presence) so a node carrying ``label: ""``/``None`` next to a real + ``name`` is healed too. When the canonical field already holds a value it + wins and the alias key is left untouched. Without this fold an alias-only node enters the graph with no label/source_file: it fails validation, gets ``norm_label == ""`` (invisible to query/explain), and is excluded from every label-keyed merge/dedup — a permanent ghost that ``graphify update`` @@ -472,9 +473,10 @@ def _fold_node_aliases(node: dict) -> None: def _fold_edge_aliases(edge: dict) -> None: """Fold legacy edge field aliases onto canonical keys, in place (#2194). - ``type`` -> ``relation``. A ``confidence_score`` float with no ``confidence`` - enum backfills ``confidence: "INFERRED"`` — never EXTRACTED (alias recovery - is not provenance) and never a threshold mapping of the float. The + ``type``/``relationship`` -> ``relation`` (``type`` checked first). A + ``confidence_score`` float with no ``confidence`` enum backfills + ``confidence: "INFERRED"`` — never EXTRACTED (alias recovery is not + provenance) and never a threshold mapping of the float. The ``confidence_score`` key itself is NOT popped: it is a legitimate companion field that the edge loop sanitizes and to_json round-trips. @@ -1830,7 +1832,7 @@ def build( # coerces too, but that runs after dedup — too late for this path. _coerce_non_string_ids(combined) # Fold legacy node field aliases before dedup (#2194): dedup runs BEFORE - # build_from_json and keys on `label`, so a `name`/`path` alias node + # build_from_json and keys on `label`, so a `name`/`file`/`path` alias node # would be invisible to it and only label-dedup one build later, after # build_from_json's own fold has healed the persisted graph.json. for n in combined["nodes"]: diff --git a/tests/test_lost_saved_semantic_nodes.py b/tests/test_lost_saved_semantic_nodes.py index 8312b7bab..3926074a4 100644 --- a/tests/test_lost_saved_semantic_nodes.py +++ b/tests/test_lost_saved_semantic_nodes.py @@ -2,7 +2,12 @@ (#1145 extended) is preserved, not lost.""" from __future__ import annotations -from graphify.build import _lost_saved_semantic_nodes +import json +from pathlib import Path + +from graphify.build import _lost_saved_semantic_nodes, build_from_json +from graphify.export import to_json +from graphify.watch import _check_shrink def _sem(node_id: str, label: str, sf: str) -> dict: @@ -99,3 +104,39 @@ def test_malformed_non_string_source_file_does_not_crash(): new = [{"id": "n1", "label": "N1", "source_file": True}] lost = _lost_saved_semantic_nodes(saved, new) assert [n["id"] for n in lost] == ["s1"] + + +# --- end-to-end: the write paths that call the guard (#4389) --- + + +def _saved_graph(tmp_path: Path, sem: dict) -> tuple[Path, dict]: + out = tmp_path / "graph.json" + assert to_json(build_from_json({"nodes": [sem], "edges": []}), {}, str(out), force=True) + return out, json.loads(out.read_text(encoding="utf-8")) + + +def _node_data(G) -> dict: + return {"nodes": [{"id": nid, **attrs} for nid, attrs in G.nodes(data=True)], "links": []} + + +def test_to_json_writes_after_ghost_dedup(tmp_path): + sem = _sem("src_models_user_ts_user", "User", "src/models/user.ts") + out, _ = _saved_graph(tmp_path, sem) + G = build_from_json({"nodes": [sem, _ast("user_user", "User", "src/models/user.ts")], "edges": []}) + assert list(G.nodes) == ["user_user"] # ghost id folded into its AST twin + assert to_json(G, {}, str(out)) is True + + +def test_to_json_still_refuses_genuine_loss(tmp_path, capsys): + sem = _sem("src_models_user_ts_user", "User", "src/models/user.ts") + out, _ = _saved_graph(tmp_path, sem) + G = build_from_json({"nodes": [_ast("user_account", "Account", "src/models/user.ts")], "edges": []}) + assert to_json(G, {}, str(out)) is False + assert "src_models_user_ts_user" in capsys.readouterr().err + + +def test_check_shrink_allows_ghost_dedup(tmp_path): + sem = _sem("src_models_user_ts_user", "User", "src/models/user.ts") + _, existing = _saved_graph(tmp_path, sem) + G = build_from_json({"nodes": [sem, _ast("user_user", "User", "src/models/user.ts")], "edges": []}) + assert _check_shrink(force=False, existing_data=existing, new_data=_node_data(G)) is True