"""Comprehensive tests for graphify.scip_ingest.""" from __future__ import annotations import pytest from graphify.scip_ingest import ( _build_scip_metadata, _make_scip_node_id, _scip_kind_to_file_type, ingest_scip_json, ) # --------------------------------------------------------------------------- # Valid JSON parsing — full-document smoke tests # --------------------------------------------------------------------------- def test_ingest_empty_doc_returns_empty_lists() -> None: """Empty dict input produces empty nodes and edges.""" result = ingest_scip_json({}) assert result == {"nodes": [], "edges": []} def test_ingest_dict_without_documents_key() -> None: """documents key not present → no processing → empty result.""" result = ingest_scip_json({"metadata": "some meta"}) assert result == {"nodes": [], "edges": []} def test_ingest_documents_not_a_list_is_skipped() -> None: """When documents is not a list, ingestion stops and returns empty.""" result = ingest_scip_json({"documents": "not_a_list"}) assert result == {"nodes": [], "edges": []} def test_ingest_documents_empty_list() -> None: """Empty documents list produces empty nodes and edges.""" result = ingest_scip_json({"documents": []}) assert result == {"nodes": [], "edges": []} def test_ingest_single_symbol_no_relationships() -> None: """A single symbol with no relationships yields one node and zero edges.""" doc = { "documents": [ { "relative_path": "src/main.py", "language": "python", "symbols": [ { "symbol": "python/main.py:MainClass#", "kind": "class", "display_name": "MainClass", "documentation": ["The main class"], "relationships": [], "occurrences": [ {"range": [5, 0, 5, 9], "symbol": "python/main.py:MainClass#"} ], } ], } ] } result = ingest_scip_json(doc) assert len(result["nodes"]) == 1 assert len(result["edges"]) == 0 node = result["nodes"][0] assert node["label"] == "MainClass" assert node["file_type"] == "code" assert node["source_file"] == "src/main.py" assert node["source_location"] == "L5" assert node["metadata"]["scip_symbol"] == "python/main.py:MainClass#" assert node["metadata"]["scip_kind"] == "class" assert node["metadata"]["scip_description"] == "The main class" def test_ingest_symbol_without_display_name_uses_suffix() -> None: """When display_name is missing, label falls back to the portion after #.""" doc = { "documents": [ { "relative_path": "lib/helper.py", "symbols": [ { "symbol": "python/helper.py:compute#run()", "kind": "function", "occurrences": [], "relationships": [], } ], } ] } result = ingest_scip_json(doc) assert result["nodes"][0]["label"] == "run()" def test_ingest_symbol_trailing_hash_no_display_name_has_non_empty_label() -> None: """Symbol ending with '#' and no display_name must produce a non-empty label. symbol.split('#')[-1] is '' when the symbol ends with '#', so label = display_name or suffix evaluates to '' when display_name is also absent. The fix must fall back to the full symbol_id. """ doc = { "documents": [ { "relative_path": "src/Foo.java", "symbols": [ { "symbol": "java/src/Foo.java:Foo#", "kind": "class", "occurrences": [], "relationships": [], # no display_name } ], } ] } result = ingest_scip_json(doc) assert len(result["nodes"]) == 1 assert result["nodes"][0]["label"], ( "label must not be empty when symbol ends with '#' and display_name is absent" ) def test_ingest_symbol_without_hash_uses_full_symbol_as_label() -> None: """When symbol has no #, the label is the full symbol id.""" doc = { "documents": [ { "relative_path": "lib/helper.py", "symbols": [ { "symbol": "SimpleFunction", "kind": "function", "occurrences": [], "relationships": [], } ], } ] } result = ingest_scip_json(doc) assert result["nodes"][0]["label"] == "SimpleFunction" def test_ingest_symbol_without_occurrences_has_empty_source_location() -> None: """When occurrences list is empty, source_location is empty string.""" doc = { "documents": [ { "relative_path": "lib/a.py", "symbols": [ { "symbol": "python/lib/a.py:Foo#", "kind": "class", "occurrences": [], "relationships": [], } ], } ] } result = ingest_scip_json(doc) assert result["nodes"][0]["source_location"] == "" def test_ingest_symbol_without_occurrences_key() -> None: """When occurrences key is missing entirely, falls back to empty source_location.""" doc = { "documents": [ { "relative_path": "lib/a.py", "symbols": [ { "symbol": "python/lib/a.py:Foo#", "kind": "class", "relationships": [], } ], } ] } result = ingest_scip_json(doc) assert result["nodes"][0]["source_location"] == "" def test_ingest_multiple_symbols_in_one_document() -> None: """Multiple symbols in a single document all become nodes.""" doc = { "documents": [ { "relative_path": "src/mod.py", "symbols": [ { "symbol": "python/mod.py:A#", "kind": "class", "display_name": "A", "occurrences": [], "relationships": [], }, { "symbol": "python/mod.py:B#", "kind": "function", "display_name": "B", "occurrences": [], "relationships": [], }, { "symbol": "python/mod.py:C#", "kind": "variable", "display_name": "C", "occurrences": [], "relationships": [], }, ], } ] } result = ingest_scip_json(doc) assert len(result["nodes"]) == 3 labels = {n["label"] for n in result["nodes"]} assert labels == {"A", "B", "C"} def test_ingest_multiple_documents() -> None: """Symbols from multiple documents all become nodes.""" doc = { "documents": [ { "relative_path": "a.py", "symbols": [ {"symbol": "A#", "kind": "class", "occurrences": [], "relationships": []}, ], }, { "relative_path": "b.py", "symbols": [ {"symbol": "B#", "kind": "function", "occurrences": [], "relationships": []}, ], }, ] } result = ingest_scip_json(doc) assert len(result["nodes"]) == 2 paths = {n["source_file"] for n in result["nodes"]} assert paths == {"a.py", "b.py"} # --------------------------------------------------------------------------- # Reference/definition resolution — relationship → edge mapping # --------------------------------------------------------------------------- def _make_symbol_doc(symbol_id: str, kind: str, rels: list[object]) -> dict[str, object]: """Helper to build a minimal SCIP document with one symbol.""" return { "documents": [ { "relative_path": "src/main.py", "symbols": [ { "symbol": symbol_id, "kind": kind, "display_name": symbol_id.split("#")[-1].strip("()"), "occurrences": [{"range": [10, 0, 10, 20], "symbol": symbol_id}], "relationships": rels, } ], } ] } def test_ingest_is_reference_emits_scip_ref_edge() -> None: """is_reference → relation 'scip_ref'.""" doc = _make_symbol_doc( "python/main.py:MyClass#run()", "function", [{"symbol": "python/main.py:Helper#help()", "is_reference": True}], ) result = ingest_scip_json(doc) assert len(result["edges"]) == 1 assert result["edges"][0]["relation"] == "scip_ref" def test_ingest_is_definition_emits_scip_def_edge() -> None: """is_definition → relation 'scip_def'.""" doc = _make_symbol_doc( "python/main.py:MyClass#run()", "function", [{"symbol": "python/main.py:Base#run()", "is_definition": True}], ) result = ingest_scip_json(doc) assert result["edges"][0]["relation"] == "scip_def" def test_ingest_is_implementation_emits_scip_impl_edge() -> None: """is_implementation → relation 'scip_impl' (takes priority over is_definition).""" doc = _make_symbol_doc( "python/main.py:MyClass#run()", "function", [{"symbol": "python/main.py:Base#run()", "is_implementation": True, "is_definition": True}], ) result = ingest_scip_json(doc) assert result["edges"][0]["relation"] == "scip_impl" def test_ingest_is_type_definition_emits_scip_typed_edge() -> None: """is_type_definition → relation 'scip_typed'.""" doc = _make_symbol_doc( "python/main.py:MyClass#run()", "function", [{"symbol": "python/main.py:Base#run()", "is_type_definition": True}], ) result = ingest_scip_json(doc) assert result["edges"][0]["relation"] == "scip_typed" def test_ingest_relationship_priority_order() -> None: """Implementation > TypeDefinition > Definition > Reference.""" doc = _make_symbol_doc( "python/main.py:MyClass#run()", "function", [ { "symbol": "python/main.py:Base#run()", "is_implementation": True, "is_type_definition": True, "is_definition": True, "is_reference": True, } ], ) result = ingest_scip_json(doc) assert result["edges"][0]["relation"] == "scip_impl" def test_ingest_relationship_no_boolean_flags_defaults_to_ref() -> None: """When none of is_* flags are set, relation defaults to 'scip_ref'.""" doc = _make_symbol_doc( "python/main.py:MyClass#run()", "function", [{"symbol": "python/main.py:Other#"}], ) result = ingest_scip_json(doc) assert result["edges"][0]["relation"] == "scip_ref" def test_ingest_multiple_relationships_on_one_symbol() -> None: """A symbol with multiple relationships emits one edge per relationship.""" doc = _make_symbol_doc( "python/main.py:MyClass#run()", "function", [ {"symbol": "python/main.py:Base#run()", "is_definition": True}, {"symbol": "python/main.py:Helper#help()", "is_reference": True}, ], ) result = ingest_scip_json(doc) assert len(result["edges"]) == 2 relations = {e["relation"] for e in result["edges"]} assert relations == {"scip_def", "scip_ref"} def test_ingest_relationship_without_target_symbol_is_skipped() -> None: """Relationship with empty or missing symbol field is ignored.""" doc = _make_symbol_doc( "python/main.py:MyClass#run()", "function", [ {"symbol": "", "is_reference": True}, {"is_reference": True}, ], ) result = ingest_scip_json(doc) assert len(result["edges"]) == 0 def test_ingest_duplicate_edges_are_deduplicated() -> None: """The same source→target→relation→location edge is only emitted once.""" doc = _make_symbol_doc( "python/main.py:MyClass#run()", "function", [ {"symbol": "python/main.py:Helper#help()", "is_reference": True}, {"symbol": "python/main.py:Helper#help()", "is_reference": True}, ], ) result = ingest_scip_json(doc) assert len(result["edges"]) == 1 # --------------------------------------------------------------------------- # Edge emission — edge dict structure # --------------------------------------------------------------------------- def test_ingest_edge_structure_complete() -> None: """Verify every field in the emitted edge dict.""" doc = _make_symbol_doc( "python/main.py:MyClass#run()", "function", [{"symbol": "python/main.py:Helper#help()", "is_reference": True}], ) result = ingest_scip_json(doc) edge = result["edges"][0] assert edge["confidence"] == "EXTRACTED" assert edge["confidence_score"] == 1.0 assert edge["weight"] == 1.0 assert edge["context"] == "scip" assert edge["source_file"] == "src/main.py" assert edge["source_location"] == "L10" assert "scip_relationship" in edge["metadata"] def test_ingest_edge_source_location_from_first_occurrence() -> None: """source_location on edges uses the line from the first occurrence range[0].""" doc = { "documents": [ { "relative_path": "src/mod.py", "symbols": [ { "symbol": "python/mod.py:Foo#bar()", "kind": "function", "occurrences": [ {"range": [42, 0, 42, 10], "symbol": "python/mod.py:Foo#bar()"}, {"range": [99, 0, 99, 10], "symbol": "python/mod.py:Foo#bar()"}, ], "relationships": [{"symbol": "python/mod.py:Baz#", "is_reference": True}], } ], } ] } result = ingest_scip_json(doc) assert result["edges"][0]["source_location"] == "L42" assert result["nodes"][0]["source_location"] == "L42" def test_ingest_node_id_contains_source_file_and_symbol_suffix() -> None: """Node id is derived from source_file and symbol suffix.""" doc = _make_symbol_doc( "python/main.py:MyClass#run()", "function", [], ) result = ingest_scip_json(doc) node_id = result["nodes"][0]["id"] # Should start with scip_ and contain the suffix assert node_id.startswith("scip_") assert "run" in node_id def test_ingest_node_id_is_deterministic() -> None: """Same input produces the same node id.""" doc = _make_symbol_doc( "python/main.py:MyClass#run()", "function", [], ) result1 = ingest_scip_json(doc) result2 = ingest_scip_json(doc) assert result1["nodes"][0]["id"] == result2["nodes"][0]["id"] def test_ingest_node_id_differs_by_source_file() -> None: """Same symbol in different files produces different node ids.""" doc1 = { "documents": [ { "relative_path": "a.py", "symbols": [ {"symbol": "F#", "kind": "class", "occurrences": [], "relationships": []} ], } ] } doc2 = { "documents": [ { "relative_path": "b.py", "symbols": [ {"symbol": "F#", "kind": "class", "occurrences": [], "relationships": []} ], } ] } id1 = ingest_scip_json(doc1)["nodes"][0]["id"] id2 = ingest_scip_json(doc2)["nodes"][0]["id"] assert id1 != id2 def test_ingest_duplicate_symbols_in_same_file_are_deduplicated() -> None: """The same symbol appearing twice in a document yields only one node.""" doc = { "documents": [ { "relative_path": "src/main.py", "symbols": [ {"symbol": "F#", "kind": "class", "occurrences": [], "relationships": []}, {"symbol": "F#", "kind": "class", "occurrences": [], "relationships": []}, ], } ] } result = ingest_scip_json(doc) assert len(result["nodes"]) == 1 # --------------------------------------------------------------------------- # Invalid JSON / non-dict input # --------------------------------------------------------------------------- @pytest.mark.parametrize( "bad_input", [ None, "a string", 42, 3.14, True, [], [1, 2, 3], ], ) def test_ingest_non_dict_input_returns_empty(bad_input: object) -> None: """Non-dict inputs are guarded and return empty nodes/edges.""" result = ingest_scip_json(bad_input) assert result == {"nodes": [], "edges": []} def test_ingest_document_item_not_a_dict_is_skipped() -> None: """Non-dict entries in the documents list are silently skipped.""" doc = { "documents": [ "not_a_dict", 123, None, { "relative_path": "valid.py", "symbols": [ {"symbol": "F#", "kind": "class", "occurrences": [], "relationships": []} ], }, ] } result = ingest_scip_json(doc) assert len(result["nodes"]) == 1 def test_ingest_symbol_item_not_a_dict_is_skipped() -> None: """Non-dict entries in the symbols list are silently skipped.""" doc = { "documents": [ { "relative_path": "src/main.py", "symbols": [ "not_a_dict", 42, None, { "symbol": "python/main.py:Valid#", "kind": "class", "display_name": "Valid", "occurrences": [], "relationships": [], }, ], } ] } result = ingest_scip_json(doc) assert len(result["nodes"]) == 1 assert result["nodes"][0]["label"] == "Valid" def test_ingest_symbol_without_symbol_id_is_skipped() -> None: """A symbol dict with empty or missing 'symbol' field produces no node.""" doc = { "documents": [ { "relative_path": "src/main.py", "symbols": [ {"kind": "class", "occurrences": [], "relationships": []}, {"symbol": "", "kind": "class", "occurrences": [], "relationships": []}, ], } ] } result = ingest_scip_json(doc) assert len(result["nodes"]) == 0 def test_ingest_relationship_item_not_a_dict_is_skipped() -> None: """Non-dict entries in the relationships list are silently skipped.""" doc = _make_symbol_doc( "python/main.py:MyClass#run()", "function", [ "not_a_dict", 42, None, {"symbol": "python/main.py:Helper#help()", "is_reference": True}, ], ) result = ingest_scip_json(doc) assert len(result["edges"]) == 1 # --------------------------------------------------------------------------- # Empty documents / missing keys # --------------------------------------------------------------------------- def test_ingest_document_without_symbols_key() -> None: """Document dict without 'symbols' key is treated as empty list.""" doc = {"documents": [{"relative_path": "src/main.py", "language": "python"}]} result = ingest_scip_json(doc) assert result == {"nodes": [], "edges": []} def test_ingest_document_with_symbols_not_a_list() -> None: """When symbols is not a list, that document is skipped.""" doc = {"documents": [{"relative_path": "src/main.py", "symbols": "not_a_list"}]} result = ingest_scip_json(doc) assert result == {"nodes": [], "edges": []} def test_ingest_symbol_without_kind_defaults_to_unknown() -> None: """When kind is missing, metadata uses 'unknown'.""" doc = { "documents": [ { "relative_path": "src/main.py", "symbols": [{"symbol": "F#", "occurrences": [], "relationships": []}], } ] } result = ingest_scip_json(doc) assert result["nodes"][0]["metadata"]["scip_kind"] == "unknown" # --------------------------------------------------------------------------- # Path validation / edge cases # --------------------------------------------------------------------------- def test_ingest_default_source_file_is_empty_string() -> None: """When no relative_path is given on document, source_file defaults to ''.""" doc = { "documents": [ { "symbols": [ {"symbol": "F#", "kind": "class", "occurrences": [], "relationships": []} ], } ] } result = ingest_scip_json(doc) assert result["nodes"][0]["source_file"] == "" def test_ingest_source_file_falls_back_to_function_param() -> None: """The source_file param provides a fallback when doc has no relative_path.""" doc = { "documents": [ { "symbols": [ {"symbol": "F#", "kind": "class", "occurrences": [], "relationships": []} ], } ] } result = ingest_scip_json(doc, source_file="fallback.scip") assert result["nodes"][0]["source_file"] == "fallback.scip" def test_ingest_document_relative_path_overrides_source_file_param() -> None: """Document relative_path takes precedence over the source_file parameter.""" doc = { "documents": [ { "relative_path": "explicit.py", "symbols": [ {"symbol": "F#", "kind": "class", "occurrences": [], "relationships": []} ], } ] } result = ingest_scip_json(doc, source_file="fallback.scip") assert result["nodes"][0]["source_file"] == "explicit.py" def test_ingest_document_without_language_defaults_to_function_param() -> None: """When doc has no language field, uses the language function parameter.""" doc = { "documents": [ { "relative_path": "src/main.ts", "symbols": [ {"symbol": "F#", "kind": "class", "occurrences": [], "relationships": []} ], } ] } result = ingest_scip_json(doc, language="typescript") # language is passed to _ingest_symbol but not directly exposed on nodes. # Verify that the node was still created (language defaults don't break ingestion). assert len(result["nodes"]) == 1 def test_ingest_symbol_with_short_range_uses_first_element_as_line() -> None: """A range list with exactly 2 elements (minimum required) sets sourceline from range[0].""" doc = { "documents": [ { "relative_path": "src/mod.py", "symbols": [ { "symbol": "python/mod.py:F#", "kind": "class", "occurrences": [{"range": [7, 0], "symbol": "python/mod.py:F#"}], "relationships": [], } ], } ] } result = ingest_scip_json(doc) assert result["nodes"][0]["source_location"] == "L7" def test_ingest_symbol_with_non_dict_occurrence_is_skipped() -> None: """Only the first occurrence is used; if it is not a dict, sourceline stays 0.""" doc = { "documents": [ { "relative_path": "src/mod.py", "symbols": [ { "symbol": "python/mod.py:F#", "kind": "class", "occurrences": [ "bad", 123, None, {"range": [15, 0, 15, 5], "symbol": "python/mod.py:F#"}, ], "relationships": [], } ], } ] } result = ingest_scip_json(doc) # The first occurrence "bad" is not a dict → range parsing skipped → source_location stays empty assert result["nodes"][0]["source_location"] == "" def test_ingest_symbol_with_non_list_range_falls_back_to_zero() -> None: """When range is not a list, sourceline stays 0 (empty source_location).""" doc = { "documents": [ { "relative_path": "src/mod.py", "symbols": [ { "symbol": "F#", "kind": "class", "occurrences": [{"range": "not_a_list", "symbol": "F#"}], "relationships": [], } ], } ] } result = ingest_scip_json(doc) assert result["nodes"][0]["source_location"] == "" def test_ingest_symbol_with_documentation_becomes_description() -> None: """The first element of documentation[] becomes scip_description metadata.""" doc = { "documents": [ { "relative_path": "src/mod.py", "symbols": [ { "symbol": "F#", "kind": "class", "documentation": ["First line", "Second line"], "occurrences": [], "relationships": [], } ], } ] } result = ingest_scip_json(doc) assert result["nodes"][0]["metadata"]["scip_description"] == "First line" def test_ingest_symbol_with_empty_documentation_skips_description() -> None: """When documentation[0] is empty string, scip_description is omitted.""" doc = { "documents": [ { "relative_path": "src/mod.py", "symbols": [ { "symbol": "F#", "kind": "class", "documentation": [""], "occurrences": [], "relationships": [], } ], } ] } result = ingest_scip_json(doc) assert "scip_description" not in result["nodes"][0]["metadata"] def test_ingest_symbol_without_documentation_omits_description() -> None: """When documentation key is missing, scip_description is not in metadata.""" doc = { "documents": [ { "relative_path": "src/mod.py", "symbols": [ { "symbol": "F#", "kind": "class", "occurrences": [], "relationships": [], } ], } ] } result = ingest_scip_json(doc) assert "scip_description" not in result["nodes"][0]["metadata"] def test_ingest_symbol_without_relationships_key_still_creates_node() -> None: """Missing relationships key — symbol still becomes a node.""" doc = { "documents": [ { "relative_path": "src/mod.py", "symbols": [{"symbol": "F#", "kind": "class", "occurrences": []}], } ] } result = ingest_scip_json(doc) assert len(result["nodes"]) == 1 assert len(result["edges"]) == 0 # --------------------------------------------------------------------------- # _make_scip_node_id — node id generation # --------------------------------------------------------------------------- def test_make_scip_node_id_with_hash_separator() -> None: """Symbol with # uses suffix after last #.""" node_id = _make_scip_node_id("python/main.py:MyClass#run()", "src/main.py") assert node_id.startswith("scip_") assert "run" in node_id # Should NOT contain raw parentheses assert "(" not in node_id assert ")" not in node_id def test_make_scip_node_id_without_hash() -> None: """Symbol without # uses the full symbol (sanitised) as suffix.""" node_id = _make_scip_node_id("SimpleSymbol", "src/mod.py") assert node_id.startswith("scip_") assert "simplesymbol" in node_id.lower() def test_make_scip_node_id_special_characters_are_sanitised() -> None: """Non-alphanumeric characters are replaced with underscores.""" node_id = _make_scip_node_id("foo.bar#baz!@qux", "test.py") # Everything after last # becomes: baz!@qux → baz__qux assert "scip_baz__qux" in node_id def test_make_scip_node_id_deterministic() -> None: """Same inputs always produce the same id.""" a = _make_scip_node_id("python/main.py:Foo#bar", "src/a.py") b = _make_scip_node_id("python/main.py:Foo#bar", "src/a.py") assert a == b def test_make_scip_node_id_source_file_affects_hash() -> None: """Different source_file produces different hash.""" a = _make_scip_node_id("F#", "a.py") b = _make_scip_node_id("F#", "b.py") assert a != b def test_make_scip_node_id_symbol_affects_hash() -> None: """Different symbol produces different hash.""" a = _make_scip_node_id("A#", "f.py") b = _make_scip_node_id("B#", "f.py") assert a != b def test_make_scip_node_id_empty_after_sanitisation_falls_back() -> None: """If sanitised suffix is empty, uses just the hash.""" node_id = _make_scip_node_id("#", "src/f.py") # The suffix after # is empty string, so node_id should be scip_ assert node_id.startswith("scip_") # Verify it's just scip_ + 12 hex chars import re assert re.match(r"^scip_[0-9a-f]{12}$", node_id) # --------------------------------------------------------------------------- # _scip_kind_to_file_type — always returns "code" # --------------------------------------------------------------------------- def test_scip_kind_to_file_type_always_code() -> None: """Any kind string maps to 'code'.""" assert _scip_kind_to_file_type("class") == "code" assert _scip_kind_to_file_type("function") == "code" assert _scip_kind_to_file_type("variable") == "code" assert _scip_kind_to_file_type("") == "code" assert _scip_kind_to_file_type("arbitrary_string") == "code" # --------------------------------------------------------------------------- # _build_scip_metadata — metadata dict construction # --------------------------------------------------------------------------- def test_build_scip_metadata_with_description() -> None: """All three fields present when description is non-empty.""" meta = _build_scip_metadata("sym_id", "class", "A sample description") assert meta == { "scip_symbol": "sym_id", "scip_kind": "class", "scip_description": "A sample description", } def test_build_scip_metadata_without_description() -> None: """scip_description is omitted when description is empty string.""" meta = _build_scip_metadata("sym_id", "class", "") assert meta == { "scip_symbol": "sym_id", "scip_kind": "class", } assert "scip_description" not in meta # --------------------------------------------------------------------------- # Edge-case: very large symbol count # --------------------------------------------------------------------------- def test_ingest_many_symbols() -> None: """Ingestion handles a large number of symbols gracefully.""" symbols = [ {"symbol": f"S{i}#", "kind": "class", "occurrences": [], "relationships": []} for i in range(100) ] doc = {"documents": [{"relative_path": "big.py", "symbols": symbols}]} result = ingest_scip_json(doc) assert len(result["nodes"]) == 100 assert len(result["edges"]) == 0 # --------------------------------------------------------------------------- # Edge-case: relationship with missing source_location (line 0) # --------------------------------------------------------------------------- def test_ingest_edge_with_zero_sourceline_has_empty_location() -> None: """When sourceline is 0, source_location on edge is empty string.""" doc = { "documents": [ { "relative_path": "src/mod.py", "symbols": [ { "symbol": "A#", "kind": "class", "occurrences": [], # no occurrences → sourceline 0 "relationships": [{"symbol": "B#", "is_reference": True}], } ], } ] } result = ingest_scip_json(doc) assert result["edges"][0]["source_location"] == "" # --------------------------------------------------------------------------- # Cycle 2.4 v2: endpoint-safe edges + build_from_json round-trip (F1) # --------------------------------------------------------------------------- def test_relationship_target_in_same_document_resolves_via_index(): """Cross-symbol relationship within ONE document resolves via the symbol index.""" doc = { "documents": [ { "relative_path": "src/mod.py", "symbols": [ { "symbol": "Caller#", "kind": "function", "relationships": [{"symbol": "Callee#", "is_reference": True}], }, {"symbol": "Callee#", "kind": "function"}, ], } ] } result = ingest_scip_json(doc) ids = {n["id"] for n in result["nodes"]} assert len(result["edges"]) == 1 edge = result["edges"][0] # Both endpoints exist in nodes assert edge["source"] in ids assert edge["target"] in ids def test_relationship_target_across_documents_resolves_via_index(): """Cross-document relationship resolves to the target document's node id.""" doc = { "documents": [ { "relative_path": "src/a.py", "symbols": [ { "symbol": "Caller#", "kind": "function", "relationships": [{"symbol": "Callee#", "is_reference": True}], }, ], }, { "relative_path": "src/b.py", "symbols": [{"symbol": "Callee#", "kind": "function"}], }, ] } result = ingest_scip_json(doc) by_symbol = {n["metadata"]["scip_symbol"]: n["id"] for n in result["nodes"]} assert "Caller#" in by_symbol assert "Callee#" in by_symbol edge = result["edges"][0] assert edge["source"] == by_symbol["Caller#"] assert edge["target"] == by_symbol["Callee#"] # The target node was emitted with src/b.py as source_file (its real home) callee_node = next(n for n in result["nodes"] if n["id"] == by_symbol["Callee#"]) assert callee_node["source_file"] == "src/b.py" def test_relationship_target_unknown_emits_stub_node(): """A relationship targeting a symbol NOT in any document creates a stub external node.""" doc = { "documents": [ { "relative_path": "src/a.py", "symbols": [ { "symbol": "Caller#", "kind": "function", "relationships": [{"symbol": "ExternalLib#fn", "is_reference": True}], }, ], } ] } result = ingest_scip_json(doc) by_symbol = {n["metadata"]["scip_symbol"]: n for n in result["nodes"]} assert "ExternalLib#fn" in by_symbol stub = by_symbol["ExternalLib#fn"] # Stub has scip_kind=external in metadata assert stub["metadata"]["scip_kind"] == "external" # Edge endpoints both resolve to existing nodes ids = {n["id"] for n in result["nodes"]} edge = result["edges"][0] assert edge["source"] in ids assert edge["target"] in ids def test_relationship_edges_survive_validate_extraction_and_build(): """Result passes Graphify's validate_extraction and build_from_json keeps the edges.""" from graphify.build import build_from_json from graphify.validate import validate_extraction doc = { "documents": [ { "relative_path": "src/a.py", "symbols": [ { "symbol": "Caller#", "kind": "function", "occurrences": [{"range": [10, 0, 10, 6]}], "relationships": [ {"symbol": "Callee#", "is_reference": True}, {"symbol": "External#fn", "is_implementation": True}, ], }, {"symbol": "Callee#", "kind": "function"}, ], } ] } result = ingest_scip_json(doc) errors = validate_extraction(result) assert errors == [], f"validate_extraction failures: {errors}" graph = build_from_json(result) # Two edges should survive into the graph edge_count = sum(1 for _ in graph.edges()) assert edge_count == 2, f"expected 2 edges in graph, got {edge_count}" # --------------------------------------------------------------------------- # Cycle 2.4 v2: nested untrusted input guards (F2) # --------------------------------------------------------------------------- def test_non_string_relative_path_falls_back_to_default(): """`relative_path` as a non-string falls back to the function's source_file default.""" doc = { "documents": [ { "relative_path": ["unexpected", "list"], "symbols": [{"symbol": "Foo#", "kind": "function"}], } ] } result = ingest_scip_json(doc, source_file="fallback.py") assert result["nodes"][0]["source_file"] == "fallback.py" def test_non_string_language_falls_back(): """`language` as a non-string falls back to the function default.""" doc = { "documents": [ { "relative_path": "src/a.py", "language": 42, "symbols": [{"symbol": "Foo#", "kind": "function"}], } ] } # Should not raise result = ingest_scip_json(doc) assert len(result["nodes"]) == 1 def test_non_string_symbol_id_is_skipped(): """A symbol entry with `symbol: ` is silently skipped.""" doc = { "documents": [ { "relative_path": "src/a.py", "symbols": [ {"symbol": 123, "kind": "function"}, # invalid {"symbol": "Valid#", "kind": "function"}, ], } ] } result = ingest_scip_json(doc) assert len(result["nodes"]) == 1 assert result["nodes"][0]["metadata"]["scip_symbol"] == "Valid#" def test_relationships_none_is_treated_as_empty(): """A symbol with `relationships: None` ingests without error and emits no edges.""" doc = { "documents": [ { "relative_path": "src/a.py", "symbols": [{"symbol": "Foo#", "kind": "function", "relationships": None}], } ] } result = ingest_scip_json(doc) assert len(result["nodes"]) == 1 assert result["edges"] == [] def test_relationship_symbol_non_string_is_skipped(): """A relationship entry whose `symbol` is a non-string is silently skipped.""" doc = { "documents": [ { "relative_path": "src/a.py", "symbols": [ { "symbol": "Foo#", "kind": "function", "relationships": [ {"symbol": 123, "is_reference": True}, # invalid {"symbol": "RealTarget#", "is_reference": True}, ], } ], } ] } result = ingest_scip_json(doc) # One real edge survives; the int-symbol relationship is dropped assert len(result["edges"]) == 1 assert result["edges"][0]["metadata"]["scip_relationship"]["symbol"] == "RealTarget#" def test_non_string_kind_falls_back_to_unknown(): """A symbol with `kind` as a non-string falls back to 'unknown'.""" doc = { "documents": [ { "relative_path": "src/a.py", "symbols": [{"symbol": "Foo#", "kind": ["not", "a", "string"]}], } ] } result = ingest_scip_json(doc) assert result["nodes"][0]["metadata"]["scip_kind"] == "unknown" def test_non_string_display_name_falls_back(): """`display_name` as a non-string falls back to the symbol suffix.""" doc = { "documents": [ { "relative_path": "src/a.py", "symbols": [{"symbol": "Foo#bar", "kind": "function", "display_name": 42}], } ] } result = ingest_scip_json(doc) # Label falls back to the suffix after '#' assert result["nodes"][0]["label"] == "bar" def test_documentation_with_non_string_entries_is_ignored(): """`documentation` first entry that isn't a string yields empty description (not crash).""" doc = { "documents": [ { "relative_path": "src/a.py", "symbols": [{"symbol": "Foo#", "kind": "function", "documentation": [42, "later"]}], } ] } result = ingest_scip_json(doc) # Only string first-elements become descriptions assert "scip_description" not in result["nodes"][0]["metadata"] def test_unrecognized_top_level_structure_returns_empty(): """Top-level non-dict shapes still return the empty result.""" assert ingest_scip_json("not a dict") == {"nodes": [], "edges": []} assert ingest_scip_json([{"documents": []}]) == {"nodes": [], "edges": []} assert ingest_scip_json(None) == {"nodes": [], "edges": []} def test_documents_field_non_list_returns_empty(): """`documents` as a non-list returns the empty result.""" assert ingest_scip_json({"documents": "not a list"}) == {"nodes": [], "edges": []} def test_document_entry_non_dict_is_skipped(): """A non-dict entry in `documents` is silently skipped.""" doc = { "documents": [ "not a dict", {"relative_path": "src/a.py", "symbols": [{"symbol": "Foo#", "kind": "function"}]}, ] } result = ingest_scip_json(doc) assert len(result["nodes"]) == 1 def test_occurrence_negative_line_falls_back_to_zero(): """An occurrence with a negative line number resolves source_location to empty.""" doc = { "documents": [ { "relative_path": "src/a.py", "symbols": [ { "symbol": "Foo#", "kind": "function", "occurrences": [{"range": [-1, 0, -1, 6]}], } ], } ] } result = ingest_scip_json(doc) assert result["nodes"][0]["source_location"] == "" # --------------------------------------------------------------------------- # Cycle 2.4 v3: document-aware relationship resolution (F1) # --------------------------------------------------------------------------- def test_duplicate_local_symbol_resolves_to_same_document(): """When two docs both have `F#`, a relationship from b.py's F# to F# must resolve to b.py's own F# node, not a.py's.""" doc = { "documents": [ { "relative_path": "a.py", "symbols": [{"symbol": "F#", "kind": "function"}], }, { "relative_path": "b.py", "symbols": [ { "symbol": "F#", "kind": "function", "relationships": [{"symbol": "F#", "is_reference": True}], } ], }, ] } result = ingest_scip_json(doc) # Find the two F# nodes f_nodes = [n for n in result["nodes"] if n["metadata"]["scip_symbol"] == "F#"] assert len(f_nodes) == 2 b_f_node = next(n for n in f_nodes if n["source_file"] == "b.py") a_f_node = next(n for n in f_nodes if n["source_file"] == "a.py") assert b_f_node["id"] != a_f_node["id"] # The edge: source must be b.py's F#, target must ALSO be b.py's F# (same-doc precedence) assert len(result["edges"]) == 1 edge = result["edges"][0] assert edge["source"] == b_f_node["id"] assert edge["target"] == b_f_node["id"] def test_unique_cross_document_symbol_still_resolves(): """When a target symbol is defined in exactly ONE other document, the edge still routes to that document (unique-global rule).""" doc = { "documents": [ { "relative_path": "src/a.py", "symbols": [ { "symbol": "Caller#", "kind": "function", "relationships": [{"symbol": "UniqueCallee#", "is_reference": True}], }, ], }, { "relative_path": "src/b.py", "symbols": [{"symbol": "UniqueCallee#", "kind": "function"}], }, ] } result = ingest_scip_json(doc) by_symbol = {n["metadata"]["scip_symbol"]: n["id"] for n in result["nodes"]} edge = result["edges"][0] assert edge["target"] == by_symbol["UniqueCallee#"] # Confirm the target node is in src/b.py (where it was DEFINED) callee = next(n for n in result["nodes"] if n["id"] == by_symbol["UniqueCallee#"]) assert callee["source_file"] == "src/b.py" def test_ambiguous_duplicate_target_across_docs_creates_stub(): """When a target symbol is defined in 2+ documents AND the source is in a third (different) document, resolution is ambiguous — we refuse to pick silently and emit a stub external node instead.""" doc = { "documents": [ { "relative_path": "a.py", "symbols": [{"symbol": "Shared#", "kind": "function"}], }, { "relative_path": "b.py", "symbols": [{"symbol": "Shared#", "kind": "function"}], }, { "relative_path": "c.py", "symbols": [ { "symbol": "Caller#", "kind": "function", "relationships": [{"symbol": "Shared#", "is_reference": True}], }, ], }, ] } result = ingest_scip_json(doc) # Two Shared# nodes (one per defining doc) + a stub for c.py's reference + a Caller# shared_in_c = [ n for n in result["nodes"] if n["metadata"]["scip_symbol"] == "Shared#" and n["source_file"] == "c.py" ] assert len(shared_in_c) == 1 # The stub from c.py is marked external (refused-to-guess fallback) assert shared_in_c[0]["metadata"]["scip_kind"] == "external" # The edge points at this stub (not at a.py's or b.py's Shared#) edge = result["edges"][0] assert edge["target"] == shared_in_c[0]["id"] # --------------------------------------------------------------------------- # Cycle 2.4 v3: strict boolean flags (F2) # --------------------------------------------------------------------------- def test_relationship_truthy_string_flag_is_ignored(): """`"is_implementation": "false"` is a truthy STRING — must not route to scip_impl. Only the actual boolean True counts as a set flag.""" doc = { "documents": [ { "relative_path": "a.py", "symbols": [ { "symbol": "Foo#", "kind": "function", "relationships": [ { "symbol": "B#", "is_implementation": "false", # truthy STRING, not boolean True "is_reference": True, } ], } ], } ] } result = ingest_scip_json(doc) assert result["edges"][0]["relation"] == "scip_ref" def test_relationship_int_flag_is_ignored(): """`"is_implementation": 1` is truthy but not True — must not route to scip_impl.""" doc = { "documents": [ { "relative_path": "a.py", "symbols": [ { "symbol": "Foo#", "kind": "function", "relationships": [ { "symbol": "B#", "is_implementation": 1, "is_reference": True, } ], } ], } ] } result = ingest_scip_json(doc) assert result["edges"][0]["relation"] == "scip_ref" def test_relationship_boolean_true_routes_correctly(): """Actual boolean True still routes to the corresponding scip_ relation.""" cases = [ ("is_implementation", "scip_impl"), ("is_type_definition", "scip_typed"), ("is_definition", "scip_def"), ("is_reference", "scip_ref"), ] for flag, expected_relation in cases: doc = { "documents": [ { "relative_path": "a.py", "symbols": [ { "symbol": "Foo#", "kind": "function", "relationships": [{"symbol": "B#", flag: True}], } ], } ] } result = ingest_scip_json(doc) assert result["edges"][0]["relation"] == expected_relation, ( f"flag={flag} should produce {expected_relation}" ) # --------------------------------------------------------------------------- # Cycle 2.4 v3: bool-int subclass guard for occurrence lines (F3) # --------------------------------------------------------------------------- def test_occurrence_bool_line_falls_back_to_zero(): """range[0] = True (which is technically an int subclass) must not produce 'LTrue'.""" doc = { "documents": [ { "relative_path": "a.py", "symbols": [ { "symbol": "Foo#", "kind": "function", "occurrences": [{"range": [True, 0, True, 1]}], } ], } ] } result = ingest_scip_json(doc) # Boolean line value rejected; source_location is empty (not "LTrue") assert result["nodes"][0]["source_location"] == "" def test_duplicate_same_document_definition_does_not_create_false_ambiguity(): """Duplicate symbol records within the SAME document collapse to one node id in the global index, so a caller in another file still resolves to that real node (not a stub external).""" doc = { "documents": [ { "relative_path": "a.py", "symbols": [ # Two records for Helper# in the SAME file → same node id. {"symbol": "Helper#", "kind": "function"}, {"symbol": "Helper#", "kind": "function"}, ], }, { "relative_path": "b.py", "symbols": [ { "symbol": "Caller#", "kind": "function", "relationships": [{"symbol": "Helper#", "is_reference": True}], } ], }, ] } result = ingest_scip_json(doc) helper_nodes = [n for n in result["nodes"] if n["metadata"]["scip_symbol"] == "Helper#"] # Only ONE Helper# node emitted (dedup), and it lives in a.py assert len(helper_nodes) == 1 assert helper_nodes[0]["source_file"] == "a.py" assert helper_nodes[0]["metadata"]["scip_kind"] == "function" # real definition, not 'external' # Edge from b.py's Caller# routes to a.py's real Helper# (NOT a stub) edge = result["edges"][0] assert edge["target"] == helper_nodes[0]["id"] # --------------------------------------------------------------------------- # sanitize_metadata wiring — SCIP descriptions / relationship payloads # --------------------------------------------------------------------------- def test_ingest_node_metadata_html_escaped() -> None: """SCIP-supplied description must be HTML-escaped before reaching node metadata; a malicious indexer cannot inject markup into HTML viewers.""" doc = { "documents": [ { "relative_path": "src/x.py", "language": "python", "symbols": [ { "symbol": "python/x.py:Evil#", "kind": "class", "display_name": "Evil", "documentation": [""], "occurrences": [{"range": [1, 0, 1, 5]}], } ], } ] } result = ingest_scip_json(doc) node = result["nodes"][0] desc = node["metadata"]["scip_description"] assert "