diff --git a/graphify/__main__.py b/graphify/__main__.py index 599ca4c05..3bc27c336 100644 --- a/graphify/__main__.py +++ b/graphify/__main__.py @@ -4453,6 +4453,14 @@ def main() -> None: merged["nodes"] = _dedupe_nodes(merged["nodes"]) merged["edges"] = _dedupe_edges(merged["edges"]) + # Backfill source_file from endpoint nodes — this raw path bypasses + # build_from_json's backfill, and semantic edges sometimes omit it (#1279). + _node_sf = {n.get("id"): n.get("source_file") for n in merged["nodes"]} + for _e in merged["edges"]: + if not _e.get("source_file"): + _e["source_file"] = ( + _node_sf.get(_e.get("source")) or _node_sf.get(_e.get("target")) or "" + ) _backup(graphify_out) graph_json_path.write_text( json.dumps(merged, indent=2), encoding="utf-8" diff --git a/graphify/build.py b/graphify/build.py index 5cc5f3fe9..aa4cfff4e 100644 --- a/graphify/build.py +++ b/graphify/build.py @@ -296,6 +296,15 @@ def build_from_json(extraction: dict, *, directed: bool = False, root: str | Pat if src not in node_set or tgt not in node_set: continue # skip edges to external/stdlib nodes - expected, not an error attrs = {k: v for k, v in edge.items() if k not in ("source", "target")} + # Backfill source_file from the endpoint nodes (every node carries one). + # Semantic/LLM edges occasionally omit it, which downstream validation + # flags and leaves query results with no file reference (#1279). + if not attrs.get("source_file"): + attrs["source_file"] = ( + G.nodes[src].get("source_file") + or G.nodes[tgt].get("source_file") + or "" + ) if "source_file" in attrs: attrs["source_file"] = _norm_source_file(attrs["source_file"], _root) # Drop cross-language INFERRED `calls` edges — same short names (render, diff --git a/tests/test_build.py b/tests/test_build.py index 1686ba322..99e68c849 100644 --- a/tests/test_build.py +++ b/tests/test_build.py @@ -108,6 +108,23 @@ def test_source_file_backslash_normalized(): assert sources == {"src/middleware/auth.py"} +def test_edge_missing_source_file_backfilled_from_node(): + """#1279: a semantic/LLM edge lacking source_file must inherit it from its + source node rather than reach graph.json with no file reference.""" + extraction = { + "nodes": [ + {"id": "n1", "label": "A", "file_type": "concept", "source_file": "docs/a.md"}, + {"id": "n2", "label": "B", "file_type": "concept", "source_file": "docs/b.md"}, + ], + # No source_file on the edge (as LLM output sometimes omits it). + "edges": [{"source": "n1", "target": "n2", "relation": "relates_to", "confidence": "INFERRED"}], + "input_tokens": 0, "output_tokens": 0, + } + G = build_from_json(extraction) + sf = edge_data(G, "n1", "n2").get("source_file") + assert sf == "docs/a.md" # backfilled from the source node + + def test_build_merges_multiple_extractions(): ext1 = {"nodes": [{"id": "n1", "label": "A", "file_type": "code", "source_file": "a.py"}], "edges": [], "input_tokens": 0, "output_tokens": 0}