Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions graphify/extractors/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

import hashlib
import importlib
import json
from graphify.extractors.base import _LANGUAGE_BUILTIN_GLOBALS, _file_stem, _make_id, _read_text
from graphify.ids import normalize_id
from graphify.extractors.models import LanguageConfig
Expand Down Expand Up @@ -5979,9 +5980,23 @@ def _scan_js_module_dispatch(n) -> None:
# ── Clean edges ───────────────────────────────────────────────────────────
valid_ids = seen_ids
clean_edges = []
# Byte-identical duplicates collapse to one edge (#3251): a signature that
# annotates the same type twice (``def f(a: Path, b: Path)``) is ONE
# reference relationship at one location, but the per-occurrence emission
# loops above append it once per annotation — in every language block, since
# neither add_edge nor the raw appends de-duplicate. The copies carry zero
# information (build's dedup drops them anyway) and their only observable
# effect is tripping diagnose_extraction's exact_duplicate_edges health
# warning. Only edges whose ENTIRE payload is identical collapse; any
# differing field (source_location, context, metadata, …) keeps both.
_seen_edge_payloads: set[str] = set()
for edge in edges:
src, tgt = edge["source"], edge["target"]
if src in valid_ids and (tgt in valid_ids or edge["relation"] in ("imports", "imports_from", "re_exports")):
payload = json.dumps(edge, sort_keys=True, default=str)
if payload in _seen_edge_payloads:
continue
_seen_edge_payloads.add(payload)
clean_edges.append(edge)

# Ruby mixins were collected during the node walk (before raw_calls existed);
Expand Down
15 changes: 12 additions & 3 deletions tests/test_csharp_call_site_generic_args.py
Original file line number Diff line number Diff line change
Expand Up @@ -213,7 +213,16 @@ def test_call_site_generic_args_appear_in_issue_repro(tmp_path):
),
})
izeta_refs = [tgt for src, tgt, _ctx in all_di_refs if src == ".Di()" and tgt == "IZeta"]
assert len(izeta_refs) >= 2, (
"s.AddScoped<IZeta, Box<IZeta>>() must link BOTH the outer IZeta "
f"and the inner IZeta (inside the Box<...> argument); got {izeta_refs!r}"
# The outer IZeta and the inner IZeta (inside Box<...>) are the same
# reference relationship at the same location, so since #3251 the two
# walk occurrences collapse into ONE edge at extraction (they were always
# collapsed by build, and the raw duplicate tripped
# diagnose_extraction's exact_duplicate_edges warning). The call-site bug
# this test guards emitted NO edge at all, so existence keeps the teeth.
assert len(izeta_refs) == 1, (
"s.AddScoped<IZeta, Box<IZeta>>() must link IZeta exactly once "
f"(one relationship, no duplicate edge); got {izeta_refs!r}"
)
assert (".Di()", "Box") in {(s, t) for s, t, _c in all_di_refs}, (
"the Box<...> argument itself must still link from the Di method"
)
93 changes: 93 additions & 0 deletions tests/test_duplicate_annotation_edges.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
"""Exact-duplicate edge emission from repeated annotations (#3251).

A signature that annotates the same type twice (``def f(a: Path, b: Path)``)
used to emit one identical ``references`` edge per occurrence — same source,
target, relation, source_location and context. build() drops the copy, but
diagnose_extraction first counts it under ``exact_duplicate_edges`` and the
run ends with an unexplained GRAPH HEALTH WARNING. Duplicates now collapse at
extraction; anything differing in any field still survives.
"""

import collections

from graphify.diagnostics import diagnose_extraction
from graphify.extract import extract


def _edge_counts(result):
return collections.Counter(
(e["source"], e["target"], e["relation"],
e.get("source_location"), e.get("context"))
for e in result["edges"]
)


def test_python_repeated_parameter_annotation_emits_one_edge(tmp_path, monkeypatch):
"""The issue's exact repro: two same-typed params, one references edge."""
monkeypatch.chdir(tmp_path)
(tmp_path / "sample.py").write_text(
"from pathlib import Path\n\n\n"
"def two_params(a: Path, b: Path) -> None:\n"
" print(a, b)\n\n\n"
"def one_param(a: Path) -> None:\n"
" print(a)\n",
encoding="utf-8",
)
r = extract([tmp_path / "sample.py"], cache_root=tmp_path)
counts = _edge_counts(r)
dupes = {k: v for k, v in counts.items() if v > 1}
assert dupes == {}, f"exact duplicate edges emitted: {dupes}"
assert counts[("sample_two_params", "path", "references",
"L4", "parameter_type")] == 1
assert counts[("sample_one_param", "path", "references",
"L8", "parameter_type")] == 1


def test_csharp_repeated_parameter_annotation_emits_one_edge(tmp_path, monkeypatch):
"""The same disease exists in every language block — C# as the witness."""
monkeypatch.chdir(tmp_path)
(tmp_path / "Svc.cs").write_text(
"public class Svc {\n"
" public void Copy(Widget a, Widget b) { }\n"
"}\n"
"public class Widget { }\n",
encoding="utf-8",
)
r = extract([tmp_path / "Svc.cs"], cache_root=tmp_path)
dupes = {k: v for k, v in _edge_counts(r).items() if v > 1}
assert dupes == {}, f"exact duplicate edges emitted: {dupes}"
Comment on lines +56 to +58


def test_diagnose_reports_zero_exact_duplicates(tmp_path, monkeypatch):
"""The health warning the issue hit is gone at the source."""
monkeypatch.chdir(tmp_path)
(tmp_path / "sample.py").write_text(
"from pathlib import Path\n\n\n"
"def two_params(a: Path, b: Path) -> None:\n"
" print(a, b)\n",
encoding="utf-8",
)
r = extract([tmp_path / "sample.py"], cache_root=tmp_path)
summary = diagnose_extraction(r)
assert summary["exact_duplicate_edges"] == 0


def test_differing_locations_and_contexts_survive(tmp_path, monkeypatch):
"""Only byte-identical edges collapse: the same type referenced at two
locations, or under two contexts at one location, keeps every edge."""
monkeypatch.chdir(tmp_path)
(tmp_path / "sample.py").write_text(
"from pathlib import Path\n\n\n"
"def alpha(a: Path) -> None:\n"
" print(a)\n\n\n"
"def beta(b: Path) -> Path:\n"
" return b\n",
encoding="utf-8",
)
r = extract([tmp_path / "sample.py"], cache_root=tmp_path)
counts = _edge_counts(r)
# Two locations → two edges.
assert counts[("sample_alpha", "path", "references", "L4", "parameter_type")] == 1
assert counts[("sample_beta", "path", "references", "L8", "parameter_type")] == 1
# Same location, different context (parameter vs return) → both edges.
assert counts[("sample_beta", "path", "references", "L8", "return_type")] == 1
Loading