test: skip optional-extra tests when the extra is not installed (#4190)

Grammar and SDK tests for erlang, r, solidity, vbnet, ollama and the sql encoding case failed instead of skipping on a default uv sync. Guard them with the existing _needs_* skipif pattern and point CONTRIBUTING at uv sync --all-extras to match CI.

Co-Authored-By: Claude <noreply@anthropic.com>
(cherry picked from commit d7f443eeb1)
This commit is contained in:
Yatharth Verma authored and safishamsi committed 2026-10-07 17:26:00 +01:00
1 parent 748de12553
commit 96c39d6a88
7 files changed
+94 -5

No files matched your search

+3 -2
View File
@@ -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
+19
View File
@@ -7,6 +7,7 @@ from __future__ import annotations
import importlib.util as _ilu
import sys
@@ -16,8 +17,18 @@ from pathlib import Path
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")
+5
View File
@@ -7,8 +7,13 @@ from __future__ import annotations
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 = {}
+22
View File
@@ -7,6 +7,7 @@ from __future__ import annotations
import importlib.util as _ilu
import sys
@@ -16,8 +17,18 @@ from pathlib import Path
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 @@ def test_r_namespaced_r6class_extracts_the_class_body(tmp_path):
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(
+17
View File
@@ -7,6 +7,7 @@ from __future__ import annotations
import importlib.util as _ilu
import sys
@@ -16,8 +17,18 @@ from pathlib import Path
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")
+10 -3
View File
@@ -22,6 +22,7 @@ These tests write bytes directly, so they pin the behaviour on every platform.
"""
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()"}),
+18
View File
@@ -7,6 +7,7 @@ from __future__ import annotations
import importlib.util as _ilu
import sys
@@ -16,8 +17,18 @@ from pathlib import Path
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),