diff --git a/graphify/extractors/resolution.py b/graphify/extractors/resolution.py index 13189609d2..965a2cc26b 100644 --- a/graphify/extractors/resolution.py +++ b/graphify/extractors/resolution.py @@ -2403,14 +2403,14 @@ def _resolve_cross_file_imports( Pass 1 - build a global map: class/function name → node_id, per stem. Pass 2 - for each `from .module import Name`, look up Name in the global - map and add a direct INFERRED edge from each class in the + map and add a direct EXTRACTED edge from each class in the importing file to the imported entity. This turns: auth.py --imports_from--> models.py (obvious, filtered out) Into: - DigestAuth --uses--> Response [INFERRED] (cross-file, interesting!) - BasicAuth --uses--> Request [INFERRED] + DigestAuth --uses--> Response [EXTRACTED] (cross-file, interesting!) + BasicAuth --uses--> Request [EXTRACTED] """ try: import tree_sitter_python # noqa: F401 (availability check only) @@ -2602,16 +2602,11 @@ def visit(node, current_nid: str | None) -> None: "source": src_nid, "target": tgt_nid, "relation": "uses", - "confidence": "INFERRED", - # 0.95 = "direct structural evidence (named cross-file - # reference)" from the extraction-spec rubric, which is - # exactly what this edge is: a name this file imports, then - # references. Omitting the score entirely fell through to the - # 0.5 default the same rubric forbids outright (#2813). - "confidence_score": 0.95, + "confidence": "EXTRACTED", + "confidence_score": 1.0, "source_file": str_path, "source_location": f"L{line}", - "weight": 0.8, + "weight": 1.0, }) # Repoint AST type-reference and inheritance edges from sourceless stubs diff --git a/tests/test_extract.py b/tests/test_extract.py index d7a263b8e2..802f5dbdf4 100644 --- a/tests/test_extract.py +++ b/tests/test_extract.py @@ -4368,17 +4368,17 @@ def test_extract_emits_posix_source_file_for_relative_inputs(tmp_path): } -def _inferred_uses(result): - """(source, target) pairs of every INFERRED cross-file `uses` edge.""" +def _uses_edges(result): + """(source, target) pairs of every EXTRACTED cross-file `uses` edge.""" return { (e["source"], e["target"]) for e in result["edges"] - if e.get("relation") == "uses" and e.get("confidence") == "INFERRED" + if e.get("relation") == "uses" and e.get("confidence") == "EXTRACTED" } -def test_inferred_uses_edge_attributes_to_the_referencing_symbol(tmp_path): - """A cross-file INFERRED `uses` edge binds to the symbol that actually +def test_uses_edge_attributes_to_the_referencing_symbol(tmp_path): + """A cross-file `uses` edge binds to the symbol that actually references the import — a function is a valid source and a co-located class that never touches the import gets no edge (#2652).""" (tmp_path / "helpers.py").write_text("class Helper:\n pass\n", encoding="utf-8") @@ -4390,7 +4390,7 @@ def test_inferred_uses_edge_attributes_to_the_referencing_symbol(tmp_path): ) result = extract([tmp_path / "api.py", tmp_path / "helpers.py"], cache_root=tmp_path) - uses = _inferred_uses(result) + uses = _uses_edges(result) # handler() references Helper -> it is the source. assert ("api_handler", "helpers_helper") in uses @@ -4398,9 +4398,9 @@ def test_inferred_uses_edge_attributes_to_the_referencing_symbol(tmp_path): assert ("api_request", "helpers_helper") not in uses -def test_inferred_uses_edge_kept_when_the_class_body_references_the_import(tmp_path): +def test_uses_edge_kept_when_the_class_body_references_the_import(tmp_path): """Positive control: a class that genuinely uses the imported symbol still - gets its class-level INFERRED `uses` edge (the DigestAuth->Response case).""" + gets its class-level `uses` edge (the DigestAuth->Response case).""" (tmp_path / "models.py").write_text("class Response:\n pass\n", encoding="utf-8") (tmp_path / "auth.py").write_text( "from models import Response\n\n\n" @@ -4410,10 +4410,10 @@ def test_inferred_uses_edge_kept_when_the_class_body_references_the_import(tmp_p result = extract([tmp_path / "auth.py", tmp_path / "models.py"], cache_root=tmp_path) - assert ("auth_digestauth", "models_response") in _inferred_uses(result) + assert ("auth_digestauth", "models_response") in _uses_edges(result) -def test_inferred_uses_edge_follows_an_import_alias(tmp_path): +def test_uses_edge_follows_an_import_alias(tmp_path): """`from helpers import Helper as H` attributes via the local alias `H`, so a body that only ever names `H` still resolves to the imported target (#2652).""" (tmp_path / "helpers.py").write_text("class Helper:\n pass\n", encoding="utf-8") @@ -4425,10 +4425,10 @@ def test_inferred_uses_edge_follows_an_import_alias(tmp_path): result = extract([tmp_path / "api.py", tmp_path / "helpers.py"], cache_root=tmp_path) - assert ("api_handler", "helpers_helper") in _inferred_uses(result) + assert ("api_handler", "helpers_helper") in _uses_edges(result) -def test_inferred_uses_edge_emitted_once_per_referencing_symbol(tmp_path): +def test_uses_edge_emitted_once_per_referencing_symbol(tmp_path): """Each symbol that references the import gets its own edge, and only those symbols do — guards against the old fan-out (every class in the file) and against collapsing distinct sources into one (#2652).""" @@ -4442,16 +4442,16 @@ def test_inferred_uses_edge_emitted_once_per_referencing_symbol(tmp_path): ) result = extract([tmp_path / "api.py", tmp_path / "helpers.py"], cache_root=tmp_path) - uses = _inferred_uses(result) + uses = _uses_edges(result) assert ("api_a", "helpers_helper") in uses assert ("api_b", "helpers_helper") in uses assert ("api_c", "helpers_helper") not in uses -def test_inferred_uses_edge_dropped_for_module_top_level_reference(tmp_path): +def test_uses_edge_dropped_for_module_top_level_reference(tmp_path): """A reference at true module top level has no enclosing symbol to anchor on, - so no INFERRED `uses` edge is emitted (rather than falling back to the file + so no `uses` edge is emitted (rather than falling back to the file node) — the deliberate drop documented for #2652.""" (tmp_path / "helpers.py").write_text("class Helper:\n pass\n", encoding="utf-8") (tmp_path / "api.py").write_text( @@ -4461,11 +4461,67 @@ def test_inferred_uses_edge_dropped_for_module_top_level_reference(tmp_path): ) result = extract([tmp_path / "api.py", tmp_path / "helpers.py"], cache_root=tmp_path) - uses = _inferred_uses(result) + uses = _uses_edges(result) assert not any(tgt == "helpers_helper" for _, tgt in uses) +# ── #3539: AST uses Edges Confidence (EXTRACTED / 1.0) ──────────────────────── + +def test_3539_ast_uses_edge_metadata_is_extracted(tmp_path): + """#3539: AST cross-file `uses` edges are structurally extracted from explicit + source syntax and must carry EXTRACTED / 1.0 / weight 1.0.""" + (tmp_path / "models.py").write_text("class Response:\n pass\n", encoding="utf-8") + (tmp_path / "auth.py").write_text( + "from models import Response\n\n\n" + "class DigestAuth:\n" + " def build(self):\n" + " return Response()\n", + encoding="utf-8", + ) + + result = extract([tmp_path / "auth.py", tmp_path / "models.py"], cache_root=tmp_path) + uses_edges = [ + e for e in result["edges"] + if e.get("relation") == "uses" and e.get("source") == "auth_digestauth" and e.get("target") == "models_response" + ] + assert len(uses_edges) == 1 + edge = uses_edges[0] + assert edge["confidence"] == "EXTRACTED" + assert edge["confidence_score"] == 1.0 + assert edge["weight"] == 1.0 + + +def test_3539_references_and_uses_collapse_retains_extracted_metadata(tmp_path): + """#3539: When a type annotation produces a `references` edge (e.g. parameter_type) + and cross-file import resolution produces a `uses` edge for the same pair, + collapsing them in build_from_json retains EXTRACTED / 1.0 metadata.""" + (tmp_path / "types.py").write_text("class StateFrame:\n pass\n", encoding="utf-8") + (tmp_path / "consumer.py").write_text( + "from types import StateFrame\n\n\n" + "def handle(state: StateFrame):\n" + " pass\n", + encoding="utf-8", + ) + + result = extract([tmp_path / "types.py", tmp_path / "consumer.py"], cache_root=tmp_path) + pair_edges = [ + e for e in result["edges"] + if e.get("source") == "consumer_handle" and e.get("target") == "types_stateframe" + ] + relations = {e.get("relation") for e in pair_edges} + assert "references" in relations + assert "uses" in relations + + G = build_from_json(result) + assert G.has_edge("consumer_handle", "types_stateframe") + collapsed = G.get_edge_data("consumer_handle", "types_stateframe") + assert collapsed["confidence"] == "EXTRACTED" + assert collapsed["confidence_score"] == 1.0 + assert collapsed["weight"] == 1.0 + assert collapsed.get("context") == "parameter_type" + + def test_extract_declined_data_json_is_not_failed(tmp_path, capsys): """#2879: data JSON is declined by design (#1224), not failed.