diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7f3ba91ca1..352b2367e5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -42,8 +42,9 @@ git remote add upstream https://github.com/Graphify-Labs/graphify.git git fetch upstream git checkout -b my-feature-branch upstream/v8 # Always branch off upstream v8, never commit directly to v8 -# Set up the environment -uv sync +# Set up the environment (--all-extras installs the optional grammars and SDKs, +# matching CI; without it the tests that need them are skipped) +uv sync --all-extras uv run pre-commit install # Run the test suite to confirm your setup diff --git a/tests/test_erlang_extractor.py b/tests/test_erlang_extractor.py index b6d8b199ab..e43e916b8f 100644 --- a/tests/test_erlang_extractor.py +++ b/tests/test_erlang_extractor.py @@ -7,6 +7,7 @@ +import importlib.util as _ilu import sys @@ -16,8 +17,18 @@ +import pytest + from graphify.extract import extract +# tree-sitter-language-pack is an optional extra, not installed by a default +# `uv sync`. Skip the grammar tests when it is absent; the missing-parser +# test below still runs because it simulates the absent grammar itself. +_needs_erlang = pytest.mark.skipif( + _ilu.find_spec("tree_sitter_language_pack") is None, + reason="tree-sitter-language-pack not installed (optional [erlang] extra)", +) + @@ -37,6 +48,7 @@ def _edge_labels(result: dict, relation: str) -> set[tuple[str, str]]: } +@_needs_erlang def test_erlang_functions_resolve_by_name_and_arity(tmp_path): source = tmp_path / "worker.erl" source.write_text( @@ -56,6 +68,7 @@ def test_erlang_functions_resolve_by_name_and_arity(tmp_path): assert ("run/1", "helper/2") not in _edge_labels(result, "calls") +@_needs_erlang def test_erlang_attributes_includes_and_remote_calls(tmp_path): header = tmp_path / "worker.hrl" header.write_text("-define(HEADER_VALUE, 2).\n", encoding="utf-8") @@ -94,6 +107,7 @@ def test_erlang_attributes_includes_and_remote_calls(tmp_path): assert any(edge["relation"] == "imports_from" for edge in result["edges"]) +@_needs_erlang def test_erlang_fixture_uses_normal_extract_path(tmp_path): result = extract([FIXTURE], cache_root=tmp_path) @@ -102,6 +116,7 @@ def test_erlang_fixture_uses_normal_extract_path(tmp_path): assert ('run/0', 'helper/0') in _edge_labels(result, "calls") +@_needs_erlang def test_erlang_local_fun_reference_links_to_the_function(tmp_path): """`fun helper/1` is a reference to a local function — the idiomatic way to pass a callback to `lists:map`, `spawn`, etc. It names exactly one function @@ -122,6 +137,7 @@ def test_erlang_local_fun_reference_links_to_the_function(tmp_path): assert ("run/0", "helper/1") in _edge_labels(result, "indirect_call") +@_needs_erlang def test_erlang_local_fun_reference_respects_arity(tmp_path): """Fail-closed: `fun helper/1` must bind only to the arity-1 clause, never to a same-named function of a different arity (#3993).""" @@ -140,6 +156,7 @@ def test_erlang_local_fun_reference_respects_arity(tmp_path): ) +@_needs_erlang def test_erlang_direct_call_stays_a_call_not_indirect(tmp_path): """Positive control: a direct call keeps emitting a `calls` edge (not `indirect_call`), unchanged by the fun-reference handling (#3993).""" @@ -157,6 +174,7 @@ def test_erlang_direct_call_stays_a_call_not_indirect(tmp_path): assert ("run/0", "helper/1") not in _edge_labels(result, "indirect_call") +@_needs_erlang def test_erlang_call_inside_anonymous_fun_still_captured(tmp_path): """Positive control: a direct call made inside an anonymous `fun ... end` body must still be walked and resolved (#3993).""" @@ -173,6 +191,7 @@ def test_erlang_call_inside_anonymous_fun_still_captured(tmp_path): assert ("run/0", "helper/1") in _edge_labels(result, "calls") +@_needs_erlang def test_erlang_malformed_tail_comments_and_strings_do_not_create_phantoms(tmp_path): source = tmp_path / 'broken.erl' source.write_text('-module(broken).\nvalid() -> ok.\n% ghost() -> ok.\ninvalid(\n', encoding="utf-8") diff --git a/tests/test_ollama_retry_cap.py b/tests/test_ollama_retry_cap.py index 5ad43cc739..afb710307f 100644 --- a/tests/test_ollama_retry_cap.py +++ b/tests/test_ollama_retry_cap.py @@ -7,8 +7,13 @@ from unittest.mock import MagicMock +import pytest + import graphify.llm as llm +# The client is the openai SDK; it ships with the optional [ollama] extra. +pytest.importorskip("openai") + def _capture_client_kwargs(monkeypatch): captured: dict = {} diff --git a/tests/test_r_extractor.py b/tests/test_r_extractor.py index c28ccc17c1..ee0466777f 100644 --- a/tests/test_r_extractor.py +++ b/tests/test_r_extractor.py @@ -7,6 +7,7 @@ +import importlib.util as _ilu import sys @@ -16,8 +17,18 @@ +import pytest + from graphify.extract import extract, extract_r +# tree-sitter-language-pack is an optional extra, not installed by a default +# `uv sync`. Skip the grammar tests when it is absent; the missing-parser +# test below still runs because it simulates the absent grammar itself. +_needs_r = pytest.mark.skipif( + _ilu.find_spec("tree_sitter_language_pack") is None, + reason="tree-sitter-language-pack not installed (optional [r] extra)", +) + @@ -37,6 +48,7 @@ def _edge_labels(result: dict, relation: str) -> set[tuple[str, str]]: } +@_needs_r def test_r_functions_and_calls_are_extracted(tmp_path): source = tmp_path / "analysis.R" source.write_text( @@ -53,6 +65,7 @@ def test_r_functions_and_calls_are_extracted(tmp_path): assert ("run()", "helper()") in _edge_labels(result, "calls") +@_needs_r def test_r_assignments_sources_and_classes_are_extracted(tmp_path): helper = tmp_path / "helpers.R" helper.write_text("shared <- function(x) x\n", encoding="utf-8") @@ -92,6 +105,7 @@ def test_r_assignments_sources_and_classes_are_extracted(tmp_path): assert any(edge["relation"] == "imports_from" for edge in result["edges"]) +@_needs_r def test_r_fixture_uses_normal_extract_path(tmp_path): result = extract([FIXTURE], cache_root=tmp_path) @@ -100,6 +114,7 @@ def test_r_fixture_uses_normal_extract_path(tmp_path): assert ('run()', 'double()') in _edge_labels(result, "calls") +@_needs_r def test_r_malformed_tail_comments_and_strings_do_not_create_phantoms(tmp_path): source = tmp_path / 'broken.R' source.write_text('valid <- function() 1\n"ghost <- function() 2"\n# hidden <- function() 3\nbroken(\n', encoding="utf-8") @@ -111,6 +126,7 @@ def test_r_malformed_tail_comments_and_strings_do_not_create_phantoms(tmp_path): assert labels.isdisjoint({'ghost()', 'hidden()'}) +@_needs_r def test_r_namespaced_r6class_extracts_the_class_body(tmp_path): """`R6::R6Class(...)` is the idiomatic library()-free way to define an R6 class. The qualified name never matched the class-constructor set, so the @@ -133,6 +149,7 @@ class collapsed to a plain variable and every method was dropped.""" assert ("Counter", "report()") in methods +@_needs_r def test_r_namespaced_setrefclass_is_recognised(tmp_path): """A namespace-qualified `methods::setRefClass` declares a class too.""" source = tmp_path / "acc.R" @@ -148,6 +165,7 @@ def test_r_namespaced_setrefclass_is_recognised(tmp_path): assert ("Acc", "add()") in _edge_labels(result, "method") +@_needs_r def test_r6_self_and_private_method_calls_resolve(tmp_path): """R6 methods reach their siblings through `self$` / `private$`, never as a bare name. Those intra-class calls were dropped because walk_calls only @@ -183,6 +201,7 @@ def test_r_missing_parser_reports_install_hint(tmp_path, monkeypatch, capsys): assert 'pip install "graphifyy[r]"' in capsys.readouterr().err +@_needs_r def test_r_symbol_only_bindings_have_distinct_ids(tmp_path): source = tmp_path / "ops.R" source.write_text( @@ -203,6 +222,7 @@ def test_r_symbol_only_bindings_have_distinct_ids(tmp_path): } +@_needs_r def test_r_symbol_only_function_with_external_call_terminates(tmp_path): import json import subprocess @@ -224,6 +244,7 @@ def test_r_symbol_only_function_with_external_call_terminates(tmp_path): assert any(call["callee"] == "is.null" for call in result["raw_calls"]) +@_needs_r def test_r_operator_fallback_does_not_collide_with_ordinary_binding(tmp_path): from graphify.build import build @@ -250,6 +271,7 @@ def test_r_operator_fallback_does_not_collide_with_ordinary_binding(tmp_path): assert ids_by_order[0] == ids_by_order[1] +@_needs_r def test_r_operator_scope_keeps_nested_binding_identity(tmp_path): source = tmp_path / "ops.R" source.write_text( diff --git a/tests/test_solidity_extractor.py b/tests/test_solidity_extractor.py index e2d30eac1b..8b13b53488 100644 --- a/tests/test_solidity_extractor.py +++ b/tests/test_solidity_extractor.py @@ -7,6 +7,7 @@ +import importlib.util as _ilu import sys @@ -16,8 +17,18 @@ +import pytest + from graphify.extract import extract +# tree-sitter-solidity is an optional extra, not installed by a default +# `uv sync`. Skip the grammar tests when it is absent; the missing-parser +# test below still runs because it simulates the absent grammar itself. +_needs_solidity = pytest.mark.skipif( + _ilu.find_spec("tree_sitter_solidity") is None, + reason="tree-sitter-solidity not installed (optional [solidity] extra)", +) + @@ -37,6 +48,7 @@ def _edge_labels(result: dict, relation: str) -> set[tuple[str, str]]: } +@_needs_solidity def test_solidity_contract_members_and_calls_are_extracted(tmp_path): source = tmp_path / "Counter.sol" source.write_text( @@ -57,6 +69,7 @@ def test_solidity_contract_members_and_calls_are_extracted(tmp_path): assert ("increment()", "record()") in _edge_labels(result, "calls") +@_needs_solidity def test_solidity_free_functions_and_their_calls_are_extracted(tmp_path): # File-level (free) functions — legal since Solidity 0.7 — live outside any # contract. Before the fix the extractor only descended into named type @@ -76,6 +89,7 @@ def test_solidity_free_functions_and_their_calls_are_extracted(tmp_path): assert ("quarter()", "halve()") in _edge_labels(result, "calls") +@_needs_solidity def test_solidity_types_imports_inheritance_overloads_and_modifiers(tmp_path): (tmp_path / "Base.sol").write_text( "contract Base { function baseRun() internal {} }\n", encoding="utf-8" @@ -119,6 +133,7 @@ def test_solidity_types_imports_inheritance_overloads_and_modifiers(tmp_path): assert len([edge for edge in result["edges"] if edge["relation"] == "imports_from"]) == 2 +@_needs_solidity def test_solidity_enum_values_emit_case_of_not_contains(tmp_path): """A Solidity enum value is a discriminant case, so it must get a `case_of` edge like every other language with enums (Java #1719, C#, Swift, Rust, @@ -153,6 +168,7 @@ def test_solidity_enum_values_emit_case_of_not_contains(tmp_path): assert ("Point", "x") not in case_of +@_needs_solidity def test_solidity_fixture_uses_normal_extract_path(tmp_path): result = extract([FIXTURE], cache_root=tmp_path) @@ -161,6 +177,7 @@ def test_solidity_fixture_uses_normal_extract_path(tmp_path): assert ('run()', 'helper()') in _edge_labels(result, "calls") +@_needs_solidity def test_solidity_malformed_tail_comments_and_strings_do_not_create_phantoms(tmp_path): source = tmp_path / 'Broken.sol' source.write_text('contract Kept { function valid() public {} string constant text = "function Ghost()"; /* function Hidden() {} */', encoding="utf-8") diff --git a/tests/test_source_file_encoding.py b/tests/test_source_file_encoding.py index 3a1b918ff2..8201b3dc92 100644 --- a/tests/test_source_file_encoding.py +++ b/tests/test_source_file_encoding.py @@ -22,6 +22,7 @@ class came out as ``Order``. """ from __future__ import annotations +import importlib.util import os from pathlib import Path @@ -206,9 +207,15 @@ def test_utf16_python_import_resolves_across_files(tmp_path): @pytest.mark.parametrize("name, text, want", [ - ("schema.sql", - "CREATE TABLE Orders (id INT);\nCREATE VIEW RecentOrders AS SELECT id FROM Orders;\n", - {"Orders", "RecentOrders"}), + pytest.param( + "schema.sql", + "CREATE TABLE Orders (id INT);\nCREATE VIEW RecentOrders AS SELECT id FROM Orders;\n", + {"Orders", "RecentOrders"}, + marks=pytest.mark.skipif( + importlib.util.find_spec("tree_sitter_sql") is None, + reason="tree-sitter-sql not installed (optional [sql] extra)", + ), + ), ("Deploy.ps1", "function Invoke-Deploy {\n Get-Config\n}\nfunction Get-Config {\n return 1\n}\n", {"Invoke-Deploy()", "Get-Config()"}), diff --git a/tests/test_vbnet_extractor.py b/tests/test_vbnet_extractor.py index cd45f913e1..58bbb1ef6c 100644 --- a/tests/test_vbnet_extractor.py +++ b/tests/test_vbnet_extractor.py @@ -7,6 +7,7 @@ +import importlib.util as _ilu import sys @@ -16,8 +17,18 @@ +import pytest + from graphify.extract import extract +# tree-sitter-vb-dotnet is an optional extra, not installed by a default +# `uv sync`. Skip the grammar tests when it is absent; the missing-parser +# test below still runs because it simulates the absent grammar itself. +_needs_vbnet = pytest.mark.skipif( + _ilu.find_spec("tree_sitter_vb_dotnet") is None, + reason="tree-sitter-vb-dotnet not installed (optional [vbnet] extra)", +) + @@ -37,6 +48,7 @@ def _edge_labels(result: dict, relation: str) -> set[tuple[str, str]]: } +@_needs_vbnet def test_vbnet_class_methods_and_case_insensitive_calls(tmp_path): source = tmp_path / "Counter.vb" source.write_text( @@ -59,6 +71,7 @@ def test_vbnet_class_methods_and_case_insensitive_calls(tmp_path): assert ("Run()", "Helper()") in _edge_labels(result, "calls") +@_needs_vbnet def test_vbnet_module_qualified_call_resolves_to_the_module_method(tmp_path): # A call qualified by a type/module name (Helpers.Log()) — the idiomatic way # to reach a shared Module Sub — was dropped entirely: the invocation @@ -85,6 +98,7 @@ def test_vbnet_module_qualified_call_resolves_to_the_module_method(tmp_path): assert ("Run()", "Log()") in _edge_labels(result, "calls") +@_needs_vbnet def test_vbnet_call_through_a_value_receiver_stays_unresolved(tmp_path): # A call through a value receiver (a local variable) has no statically known # owner, so it must NOT be guessed into a false edge (fail-closed). @@ -107,6 +121,7 @@ def test_vbnet_call_through_a_value_receiver_stays_unresolved(tmp_path): assert ("Run()", "Save()") not in _edge_labels(result, "calls") +@_needs_vbnet def test_vbnet_types_members_relationships_and_partial_calls(tmp_path): first = tmp_path / "Counter.vb" first.write_text( @@ -160,6 +175,7 @@ def test_vbnet_types_members_relationships_and_partial_calls(tmp_path): assert ("Run()", "Changed") in _edge_labels(result, "handles") +@_needs_vbnet def test_vbnet_fixture_uses_normal_extract_path(tmp_path): result = extract([FIXTURE], cache_root=tmp_path) @@ -168,6 +184,7 @@ def test_vbnet_fixture_uses_normal_extract_path(tmp_path): assert ('Run()', 'Helper()') in _edge_labels(result, "calls") +@_needs_vbnet def test_vbnet_malformed_tail_comments_and_strings_do_not_create_phantoms(tmp_path): source = tmp_path / 'Broken.vb' source.write_text("Class Broken\n Sub Valid()\n End Sub\nEnd Class\n???\n' Sub Ghost()\n", encoding="utf-8") @@ -190,6 +207,7 @@ def test_vbnet_missing_parser_reports_install_hint(tmp_path, monkeypatch, capsys assert 'pip install "graphifyy[vbnet]"' in capsys.readouterr().err +@_needs_vbnet def test_vbnet_enum_members_emit_case_of_not_contains(tmp_path): """A VB.NET enum member is a discriminant case, so it must get a `case_of` edge like every other language with enums (Java #1719, C#, Swift, Scala),