mirror of
https://github.com/safishamsi/graphify.git
synced 2026-07-12 10:27:11 +00:00
bd885cc97d
A hyperedge's member list is canonically keyed `nodes`, but producers (LLM/subagent drift, externally-supplied graph.json) sometimes emit `members` or `node_ids` — graphify only read `nodes`, so those hyperedges silently lost their members, and semantic_cleanup's prune dropped them entirely. Normalize the member key to `nodes` at one ingest chokepoint in build_from_json (and in semantic_cleanup, which runs pre-build), deduping and warning, so every downstream consumer sees the canonical key. Mirrors the existing from/to edge-endpoint aliasing. Reported by @askalot-io. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
378 lines
15 KiB
Python
378 lines
15 KiB
Python
"""Tests for graphify.semantic_cleanup.validate_semantic_fragment (#825)."""
|
|
|
|
import json
|
|
|
|
from graphify import semantic_cleanup as sc
|
|
|
|
|
|
def _valid_fragment():
|
|
return {
|
|
"nodes": [{"id": "module_func", "label": "func", "file_type": "code"}],
|
|
"edges": [{"source": "module_func", "target": "other_node"}],
|
|
"hyperedges": [],
|
|
}
|
|
|
|
|
|
def test_validate_semantic_fragment_accepts_valid():
|
|
assert sc.validate_semantic_fragment(_valid_fragment()) == []
|
|
|
|
|
|
def test_validate_semantic_fragment_rejects_non_object():
|
|
errors = sc.validate_semantic_fragment(["not", "an", "object"])
|
|
assert any("object" in e.lower() for e in errors)
|
|
|
|
|
|
def test_validate_semantic_fragment_rejects_oversize_payload(monkeypatch):
|
|
monkeypatch.setattr(sc, "MAX_SEMANTIC_FRAGMENT_BYTES", 64)
|
|
fragment = _valid_fragment()
|
|
fragment["nodes"][0]["label"] = "x" * 128
|
|
errors = sc.validate_semantic_fragment(fragment)
|
|
assert any("payload" in e.lower() for e in errors)
|
|
|
|
|
|
def test_validate_semantic_fragment_rejects_too_many_nodes(monkeypatch):
|
|
monkeypatch.setattr(sc, "MAX_SEMANTIC_FRAGMENT_NODES", 1)
|
|
fragment = _valid_fragment()
|
|
fragment["nodes"].append({"id": "extra", "label": "extra", "file_type": "code"})
|
|
errors = sc.validate_semantic_fragment(fragment)
|
|
assert any("nodes" in e.lower() for e in errors)
|
|
|
|
|
|
def test_validate_semantic_fragment_rejects_too_many_edges(monkeypatch):
|
|
monkeypatch.setattr(sc, "MAX_SEMANTIC_FRAGMENT_EDGES", 0)
|
|
errors = sc.validate_semantic_fragment(_valid_fragment())
|
|
assert any("edges" in e.lower() for e in errors)
|
|
|
|
|
|
def test_validate_semantic_fragment_rejects_path_separator_in_id():
|
|
fragment = _valid_fragment()
|
|
fragment["nodes"][0]["id"] = "../etc/passwd"
|
|
errors = sc.validate_semantic_fragment(fragment)
|
|
assert any("nodes[0].id" in e for e in errors)
|
|
|
|
|
|
def test_validate_semantic_fragment_rejects_invalid_file_type():
|
|
fragment = _valid_fragment()
|
|
fragment["nodes"][0]["file_type"] = "executable"
|
|
errors = sc.validate_semantic_fragment(fragment)
|
|
assert any("file_type" in e for e in errors)
|
|
|
|
|
|
def test_validate_semantic_fragment_accepts_rationale_file_type():
|
|
"""LLM output with file_type='rationale' must pass validation so the cleanup
|
|
pass can convert or remove it. Validation must not reject it before cleanup runs."""
|
|
fragment = _valid_fragment()
|
|
fragment["nodes"][0]["file_type"] = "rationale"
|
|
errors = sc.validate_semantic_fragment(fragment)
|
|
assert not any("file_type" in e for e in errors), (
|
|
f"'rationale' must be accepted by validate_semantic_fragment; got errors: {errors}"
|
|
)
|
|
|
|
|
|
def test_validate_semantic_fragment_accepts_concept_file_type():
|
|
"""LLM output with file_type='concept' must pass validation for the same reason."""
|
|
fragment = _valid_fragment()
|
|
fragment["nodes"][0]["file_type"] = "concept"
|
|
errors = sc.validate_semantic_fragment(fragment)
|
|
assert not any("file_type" in e for e in errors), (
|
|
f"'concept' must be accepted by validate_semantic_fragment; got errors: {errors}"
|
|
)
|
|
|
|
|
|
def test_load_validated_semantic_fragment_accepts_valid(tmp_path):
|
|
chunk = tmp_path / ".graphify_chunk_00.json"
|
|
chunk.write_text(json.dumps(_valid_fragment()))
|
|
fragment, errors = sc.load_validated_semantic_fragment(chunk)
|
|
assert errors == []
|
|
assert fragment == _valid_fragment()
|
|
|
|
|
|
def test_load_validated_semantic_fragment_rejects_oversize_before_parse(tmp_path, monkeypatch):
|
|
"""Oversize files are rejected by stat() — payload is never parsed."""
|
|
monkeypatch.setattr(sc, "MAX_SEMANTIC_FRAGMENT_BYTES", 64)
|
|
chunk = tmp_path / ".graphify_chunk_99.json"
|
|
# Write something that would PARSE successfully if read, but exceeds the size guard.
|
|
chunk.write_text("[" + ",".join(['"x"'] * 50) + "]")
|
|
fragment, errors = sc.load_validated_semantic_fragment(chunk)
|
|
assert fragment is None
|
|
assert any("payload" in e.lower() for e in errors)
|
|
|
|
|
|
def test_load_validated_semantic_fragment_rejects_invalid_json(tmp_path):
|
|
"""Invalid JSON returns an error instead of raising."""
|
|
chunk = tmp_path / ".graphify_chunk_bad.json"
|
|
chunk.write_text("{not valid json")
|
|
fragment, errors = sc.load_validated_semantic_fragment(chunk)
|
|
assert fragment is None
|
|
assert any("invalid json" in e.lower() for e in errors)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Hyperedge validation (F2)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_validate_hyperedge_rejects_bad_id():
|
|
fragment = _valid_fragment()
|
|
fragment["hyperedges"] = [
|
|
{"id": "../escape", "label": "x", "nodes": ["module_func", "module_func"]}
|
|
]
|
|
errors = sc.validate_semantic_fragment(fragment)
|
|
assert any("hyperedges[0].id" in e for e in errors)
|
|
|
|
|
|
def test_validate_hyperedge_rejects_bad_node_ref():
|
|
fragment = _valid_fragment()
|
|
fragment["hyperedges"] = [
|
|
{"id": "valid_he", "label": "x", "nodes": ["module_func", "../bad_ref"]}
|
|
]
|
|
errors = sc.validate_semantic_fragment(fragment)
|
|
assert any("hyperedges[0].nodes[1]" in e for e in errors)
|
|
|
|
|
|
def test_validate_hyperedge_requires_list():
|
|
fragment = _valid_fragment()
|
|
fragment["hyperedges"] = [{"id": "valid_he", "label": "x", "nodes": "not a list"}]
|
|
errors = sc.validate_semantic_fragment(fragment)
|
|
assert any("hyperedges[0].nodes" in e for e in errors)
|
|
|
|
|
|
def test_validate_hyperedge_caps_count(monkeypatch):
|
|
monkeypatch.setattr(sc, "MAX_SEMANTIC_FRAGMENT_HYPEREDGES", 1)
|
|
fragment = _valid_fragment()
|
|
fragment["hyperedges"] = [
|
|
{"id": f"he_{i}", "label": "x", "nodes": ["module_func", "module_func"]} for i in range(3)
|
|
]
|
|
errors = sc.validate_semantic_fragment(fragment)
|
|
assert any("hyperedges has 3" in e for e in errors)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Sanitizer behavior (F3 + F4 + rationale conversion)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_sanitize_drops_rationale_filetype_node():
|
|
"""A node with file_type='rationale' is removed wholesale."""
|
|
fragment = {
|
|
"nodes": [
|
|
{"id": "real_node", "label": "Real", "file_type": "code"},
|
|
{"id": "garbage", "label": "junk", "file_type": "rationale"},
|
|
],
|
|
"edges": [],
|
|
"hyperedges": [],
|
|
}
|
|
out = sc.sanitize_semantic_fragment(fragment)
|
|
ids = {n["id"] for n in out["nodes"]}
|
|
assert "real_node" in ids
|
|
assert "garbage" not in ids
|
|
|
|
|
|
def test_sanitize_converts_sentence_rationale_node_to_attribute():
|
|
"""Sentence-like rationale node connected via `rationale_for` → attribute on target."""
|
|
fragment = {
|
|
"nodes": [
|
|
{"id": "real_node", "label": "Real", "file_type": "code"},
|
|
{
|
|
"id": "why_node",
|
|
"label": "We chose tree-sitter because the deterministic parser is faster than regex-based extraction.",
|
|
"file_type": "rationale",
|
|
},
|
|
],
|
|
"edges": [{"source": "why_node", "target": "real_node", "relation": "rationale_for"}],
|
|
"hyperedges": [],
|
|
}
|
|
out = sc.sanitize_semantic_fragment(fragment)
|
|
ids = {n["id"] for n in out["nodes"]}
|
|
assert "why_node" not in ids
|
|
target = next(n for n in out["nodes"] if n["id"] == "real_node")
|
|
assert "tree-sitter" in target.get("rationale", "")
|
|
|
|
|
|
def test_sanitize_converts_allowed_filetype_sentence_via_rationale_for_edge():
|
|
"""F3: a node with file_type='document' (allowed) that is BOTH sentence-like
|
|
AND sources a `rationale_for` edge is still cleaned to an attribute."""
|
|
fragment = {
|
|
"nodes": [
|
|
{"id": "real_node", "label": "Real", "file_type": "code"},
|
|
{
|
|
"id": "sentence_node",
|
|
"label": (
|
|
"Decision: this node has sentence-like rationale text but uses an "
|
|
"allowed file_type, so it should not survive as a standalone graph node."
|
|
),
|
|
"file_type": "document",
|
|
},
|
|
],
|
|
"edges": [{"source": "sentence_node", "target": "real_node", "relation": "rationale_for"}],
|
|
"hyperedges": [],
|
|
}
|
|
out = sc.sanitize_semantic_fragment(fragment)
|
|
ids = {n["id"] for n in out["nodes"]}
|
|
assert "sentence_node" not in ids
|
|
target = next(n for n in out["nodes"] if n["id"] == "real_node")
|
|
assert "Decision" in target.get("rationale", "")
|
|
|
|
|
|
def test_sanitize_keeps_short_concept_named_node_with_punctuation():
|
|
"""A short named node with a period (e.g. abbreviation) is NOT sentence-like."""
|
|
fragment = {
|
|
"nodes": [
|
|
{"id": "a_b", "label": "a.b.c", "file_type": "document"},
|
|
{"id": "anchor", "label": "Anchor", "file_type": "code"},
|
|
],
|
|
"edges": [{"source": "a_b", "target": "anchor", "relation": "rationale_for"}],
|
|
"hyperedges": [],
|
|
}
|
|
out = sc.sanitize_semantic_fragment(fragment)
|
|
ids = {n["id"] for n in out["nodes"]}
|
|
assert "a_b" in ids
|
|
assert "anchor" in ids
|
|
|
|
|
|
def test_sanitize_filters_hyperedges_after_node_removal():
|
|
"""F4: hyperedges referencing removed nodes are repaired or dropped."""
|
|
fragment = {
|
|
"nodes": [
|
|
{"id": "real_node", "label": "Real", "file_type": "code"},
|
|
{"id": "other", "label": "Other", "file_type": "code"},
|
|
{"id": "garbage", "label": "junk", "file_type": "rationale"},
|
|
],
|
|
"edges": [],
|
|
"hyperedges": [
|
|
{
|
|
"id": "group_a",
|
|
"label": "Group A",
|
|
"nodes": ["garbage", "real_node", "other"],
|
|
"relation": "participate_in",
|
|
},
|
|
{
|
|
"id": "group_b",
|
|
"label": "Group B (only one survivor)",
|
|
"nodes": ["garbage", "real_node"],
|
|
"relation": "participate_in",
|
|
},
|
|
],
|
|
}
|
|
out = sc.sanitize_semantic_fragment(fragment)
|
|
he_ids = {he["id"] for he in out["hyperedges"]}
|
|
# group_a survives with garbage filtered out
|
|
assert "group_a" in he_ids
|
|
group_a = next(he for he in out["hyperedges"] if he["id"] == "group_a")
|
|
assert "garbage" not in group_a["nodes"]
|
|
assert set(group_a["nodes"]) == {"real_node", "other"}
|
|
# group_b had only 1 surviving member → dropped
|
|
assert "group_b" not in he_ids
|
|
|
|
|
|
def test_sanitize_drops_hyperedge_with_only_unknown_refs():
|
|
"""A hyperedge referencing only nodes not present in the fragment is dropped."""
|
|
fragment = {
|
|
"nodes": [{"id": "real_node", "label": "Real", "file_type": "code"}],
|
|
"edges": [],
|
|
"hyperedges": [{"id": "phantom", "label": "Phantom", "nodes": ["ghost1", "ghost2"]}],
|
|
}
|
|
out = sc.sanitize_semantic_fragment(fragment)
|
|
assert out["hyperedges"] == []
|
|
|
|
|
|
def test_sanitize_boundary_sentence_threshold():
|
|
"""Boundary: a label with exactly 8 words + colon is sentence-like;
|
|
a 7-word label without sentence punctuation is not."""
|
|
# 8 words, has colon → sentence-like
|
|
long_label = "Note: alpha beta gamma delta epsilon zeta eta"
|
|
fragment = {
|
|
"nodes": [
|
|
{"id": "anchor", "label": "Anchor", "file_type": "code"},
|
|
{"id": "n1", "label": long_label, "file_type": "rationale"},
|
|
],
|
|
"edges": [{"source": "n1", "target": "anchor", "relation": "rationale_for"}],
|
|
"hyperedges": [],
|
|
}
|
|
out = sc.sanitize_semantic_fragment(fragment)
|
|
assert {n["id"] for n in out["nodes"]} == {"anchor"}
|
|
anchor = out["nodes"][0]
|
|
assert "alpha" in anchor.get("rationale", "")
|
|
|
|
# 7 words no terminal punctuation → not sentence-like
|
|
short_label = "alpha beta gamma delta epsilon zeta eta"
|
|
fragment = {
|
|
"nodes": [
|
|
{"id": "anchor", "label": "Anchor", "file_type": "code"},
|
|
{"id": "n2", "label": short_label, "file_type": "rationale"},
|
|
],
|
|
"edges": [],
|
|
"hyperedges": [],
|
|
}
|
|
out = sc.sanitize_semantic_fragment(fragment)
|
|
# n2 has file_type=rationale, so it's still removed via pass 1 — but should NOT
|
|
# become a rationale attribute on anchor (no rationale_for edge, no sentence pattern).
|
|
assert {n["id"] for n in out["nodes"]} == {"anchor"}
|
|
assert "rationale" not in out["nodes"][0]
|
|
|
|
|
|
def test_sanitize_rationale_only_propagates_through_rationale_for_edges():
|
|
"""A rationale node connected to ONE target via `rationale_for` and to ANOTHER
|
|
target via a non-rationale-for relation must NOT attach the rationale text
|
|
to the second target. Codex v2 caught the bug where every outgoing edge
|
|
propagated the rationale, corrupting unrelated nodes."""
|
|
fragment = {
|
|
"nodes": [
|
|
{"id": "rationale_target", "label": "Rationale Target", "file_type": "code"},
|
|
{"id": "unrelated_target", "label": "Unrelated Target", "file_type": "code"},
|
|
{
|
|
"id": "why_node",
|
|
"label": (
|
|
"Decision: we chose tree-sitter because the deterministic parser "
|
|
"is faster than regex-based extraction."
|
|
),
|
|
"file_type": "rationale",
|
|
},
|
|
],
|
|
"edges": [
|
|
{"source": "why_node", "target": "rationale_target", "relation": "rationale_for"},
|
|
{"source": "why_node", "target": "unrelated_target", "relation": "references"},
|
|
],
|
|
"hyperedges": [],
|
|
}
|
|
out = sc.sanitize_semantic_fragment(fragment)
|
|
ids = {n["id"]: n for n in out["nodes"]}
|
|
assert "why_node" not in ids
|
|
# rationale_target should have the rationale attribute
|
|
assert "tree-sitter" in ids["rationale_target"].get("rationale", "")
|
|
# unrelated_target should NOT have rationale leaked from the `references` edge
|
|
assert "rationale" not in ids["unrelated_target"]
|
|
|
|
|
|
def test_sanitize_keeps_members_keyed_hyperedge(capsys):
|
|
"""#1561: a `members`-keyed hyperedge with >=2 surviving members must be
|
|
KEPT (normalized to `nodes`), not silently dropped before build."""
|
|
fragment = {
|
|
"nodes": [
|
|
{"id": "real_a", "label": "A", "file_type": "code"},
|
|
{"id": "real_b", "label": "B", "file_type": "code"},
|
|
],
|
|
"edges": [],
|
|
"hyperedges": [
|
|
{"id": "grp", "label": "Group", "members": ["real_a", "real_b"]},
|
|
],
|
|
}
|
|
out = sc.sanitize_semantic_fragment(fragment)
|
|
assert len(out["hyperedges"]) == 1
|
|
he = out["hyperedges"][0]
|
|
assert he["id"] == "grp"
|
|
assert he["nodes"] == ["real_a", "real_b"]
|
|
assert "members" not in he
|
|
|
|
|
|
def test_validate_accepts_node_ids_keyed_hyperedge():
|
|
"""#1561: an alias-keyed hyperedge must not be rejected for a missing
|
|
`nodes` list — validate normalizes first."""
|
|
fragment = _valid_fragment()
|
|
fragment["nodes"].append({"id": "second", "label": "Second", "file_type": "code"})
|
|
fragment["hyperedges"] = [
|
|
{"id": "grp", "label": "G", "node_ids": ["module_func", "second"]}
|
|
]
|
|
errors = sc.validate_semantic_fragment(fragment)
|
|
assert errors == []
|