test(build): cover AST-twin shrink-guard fix end to end; sync alias docstrings

- Add to_json() and watch._check_shrink() regression tests that build a real
  graph where build_from_json folds a semantic ghost into its AST twin (#4389),
  plus a negative control showing a genuine semantic loss is still refused.
  Both positive tests fail on v8 and pass with this branch.
- Update _fold_node_aliases / _fold_edge_aliases docstrings and the build()
  dedup comment to list the new file/filepath and relationship aliases.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit 946333b2ab)
This commit is contained in:
AndresDiagoM authored and safishamsi committed 2026-10-12 22:58:40 +01:00
1 parent 3c5b1687fb
commit 61ce767dab
2 files changed
+52 -9

No files matched your search

+10 -8
View File
@@ -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"]:
+42 -1
View File
@@ -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