From c549853f712e9e0c2a5ac51438c2b134ebdff8ed Mon Sep 17 00:00:00 2001 From: Dmitry Teryaev Date: Mon, 6 Jul 2026 09:18:39 +0300 Subject: [PATCH 1/4] feat(platform): gate vector deps on Intel Mac via PEP 508 markers (PR-MI1) torch >=2.3 and lancedb >=0.26 dropped macOS x86_64 wheels, so the full vector stack is uninstallable on Intel Mac. Gate cocoindex[lancedb], lancedb, and sentence-transformers behind `sys_platform != 'darwin' or platform_machine != 'x86_64'` so `pip install java-codebase-rag` resolves graph-only there. Apple Silicon / Linux / Windows are unchanged (every other dep ships an Intel wheel or is pure-Python). Co-Authored-By: Claude --- pyproject.toml | 10 +++++--- tests/test_packaging_metadata.py | 44 +++++++++++++++++++++++++++++--- 2 files changed, 48 insertions(+), 6 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index ee340f99..5bdc2866 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,10 +25,14 @@ classifiers = [ "Operating System :: MacOS :: MacOS X", "Operating System :: Microsoft :: Windows", ] +# The vector/semantic-search trio (cocoindex[lancedb], lancedb, sentence-transformers +# -> torch) is gated off on Intel Mac (darwin + x86_64): torch >=2.3 and lancedb >=0.26 +# dropped macOS x86_64 wheels, so the full stack is uninstallable there. On Intel Mac the +# package installs graph-only and works out of the box; every other platform is unchanged. dependencies = [ - "cocoindex[lancedb]>=1.0.7,<2", + "cocoindex[lancedb]>=1.0.7,<2; sys_platform != 'darwin' or platform_machine != 'x86_64'", "ladybug>=0.17.1,<0.18", - "lancedb>=0.25.3,<0.31", + "lancedb>=0.25.3,<0.31; sys_platform != 'darwin' or platform_machine != 'x86_64'", "mcp>=1.27.0,<2", "numpy>=1.26.4,<2.5", "pathspec>=1.0.4,<2", @@ -37,7 +41,7 @@ dependencies = [ "PyYAML>=6.0.3,<7", "questionary>=2.0,<3", "rich>=14,<15", - "sentence-transformers>=5.4.0,<6", + "sentence-transformers>=5.4.0,<6; sys_platform != 'darwin' or platform_machine != 'x86_64'", "tree-sitter>=0.25.2,<0.26", "tree-sitter-java>=0.23.5,<0.24", "unidiff>=0.7.3,<1", diff --git a/tests/test_packaging_metadata.py b/tests/test_packaging_metadata.py index 79879a5e..43e79489 100644 --- a/tests/test_packaging_metadata.py +++ b/tests/test_packaging_metadata.py @@ -3,12 +3,50 @@ import tomllib from pathlib import Path +from packaging.markers import default_environment +from packaging.requirements import Requirement -def test_published_package_installs_cocoindex_for_lifecycle_commands() -> None: +# Intel Mac is the one platform the vector stack cannot install on: torch >=2.3 and +# lancedb >=0.26 dropped macOS x86_64 wheels. The vector trio is gated off there so the +# package installs graph-only; every other platform is unchanged. +_INTEL_MAC = "darwin", "x86_64" +_VECTOR_TRIO = {"cocoindex", "lancedb", "sentence-transformers"} + + +def _deps() -> list[str]: data = tomllib.loads((Path(__file__).resolve().parents[1] / "pyproject.toml").read_text()) - deps = data["project"]["dependencies"] + return data["project"]["dependencies"] - cocoindex_deps = [dep for dep in deps if dep.startswith("cocoindex")] + +def test_published_package_installs_cocoindex_for_lifecycle_commands() -> None: + cocoindex_deps = [dep for dep in _deps() if dep.startswith("cocoindex")] assert cocoindex_deps assert any("[lancedb]" in dep for dep in cocoindex_deps) + + +def test_vector_trio_is_gated_off_on_intel_mac_only() -> None: + intel_env = {**default_environment(), "sys_platform": "darwin", "platform_machine": "x86_64"} + + for raw in _deps(): + req = Requirement(raw) + if req.name.lower() not in _VECTOR_TRIO: + continue + assert req.marker is not None, f"{req.name}: expected the Intel-Mac marker" + assert not req.marker.evaluate(environment=intel_env), ( + f"{req.name}: must be EXCLUDED on Intel Mac (darwin/x86_64)" + ) + + +def test_graph_deps_install_everywhere_including_intel_mac() -> None: + intel_env = {**default_environment(), "sys_platform": "darwin", "platform_machine": "x86_64"} + + for raw in _deps(): + req = Requirement(raw) + if req.name.lower() in _VECTOR_TRIO: + continue + # Graph deps (ladybug, pyarrow, numpy, tree-sitter, ...) carry no marker and must + # install on Intel Mac so the graph layer works. + assert req.marker is None, f"{req.name}: graph dep must not be platform-gated" + assert req.marker is None or req.marker.evaluate(environment=intel_env) + From 1b4cab2113e91f79cb9f7f17698f05e0ffac361b Mon Sep 17 00:00:00 2001 From: Dmitry Teryaev Date: Mon, 6 Jul 2026 09:18:56 +0300 Subject: [PATCH 2/4] refactor(mcp): boot graph-only without the vector stack (PR-MI2) The MCP server eagerly imported lancedb/torch (via search_lancedb and sentence_transformers) at module load, so it could not start on a graph-only install. Make those imports optional: - mcp_v2: SentenceTransformer imported lazily in _get_sentence_transformer (TYPE_CHECKING guard); search_lancedb via a guarded module-level try/except so mcp_v2.run_search/TABLES stay as attributes (tests monkeypatch them) and become None sentinels when the stack is absent; search_v2 returns a clean SearchOutput(success=False, "vector search unavailable") on graph-only. - server: TABLES imported lazily in list_code_index_tables_payload() (CLI-only) with an empty-tables fallback. - pipeline: centralize is_cocoindex_preflight_blocker / is_graph_preflight_blocker next to the stub CompletedProcess shapes they match (fixes the prior "must stay aligned" duplication hazard); cli/installer consume them in PR-MI3. The 4 graph tools (find/describe/neighbors/resolve) and ScopeManager keep working; only `search` degrades. No tool signatures change. Co-Authored-By: Claude --- java_codebase_rag/pipeline.py | 20 +++++++++ mcp_v2.py | 31 ++++++++++++-- server.py | 12 +++++- tests/test_graph_only_boot.py | 80 +++++++++++++++++++++++++++++++++++ 4 files changed, 138 insertions(+), 5 deletions(-) create mode 100644 tests/test_graph_only_boot.py diff --git a/java_codebase_rag/pipeline.py b/java_codebase_rag/pipeline.py index fe6376aa..a549cadc 100644 --- a/java_codebase_rag/pipeline.py +++ b/java_codebase_rag/pipeline.py @@ -180,6 +180,26 @@ def _maybe_run_serialized_optimize( print(f"java-codebase-rag: optimize failed: {exc}", file=sys.stderr) +def is_cocoindex_preflight_blocker(proc: subprocess.CompletedProcess[str]) -> bool: + """True when ``run_cocoindex_update`` returned a pre-spawn stub, not a real cocoindex run. + + The stubs are emitted by ``_run_cocoindex_update_impl`` just below: + * returncode 127, args=[exe] -> cocoindex binary not installed (graph-only install, + e.g. macOS Intel where the vector extra is gated off). + * returncode 126, args=[] -> ``java_index_flow_lancedb.py`` missing from the bundle. + A real cocoindex run has ``args`` = the full command list (length > 1), so the + ``len(args) <= 1`` guard distinguishes a stub from a genuine non-zero exit. This is the + single authoritative detector — ``cli.py`` and ``installer.py`` both call it so the + "treat as skip, not failure" decision stays aligned with the stub shapes here. + """ + return bool(proc.returncode in (126, 127) and len(getattr(proc, "args", ()) or ()) <= 1) + + +def is_graph_preflight_blocker(proc: subprocess.CompletedProcess[str]) -> bool: + """True when ``run_build_ast_graph`` returned a pre-spawn stub (builder missing).""" + return bool(proc.returncode in (126, 127) and len(getattr(proc, "args", ()) or ()) <= 1) + + def _run_cocoindex_update_impl( env: dict[str, str], *, diff --git a/mcp_v2.py b/mcp_v2.py index 89cdd40a..af731f8d 100644 --- a/mcp_v2.py +++ b/mcp_v2.py @@ -22,10 +22,14 @@ import sys from pathlib import Path import threading -from typing import Annotated, Any, Literal, get_args +from typing import Annotated, Any, Literal, TYPE_CHECKING, get_args from pydantic import BaseModel, ConfigDict, Field, TypeAdapter, ValidationError, model_validator, validate_call -from sentence_transformers import SentenceTransformer + +if TYPE_CHECKING: + # Eager import would pull torch at module load. The vector stack is optional (graph-only + # installs ship without torch/lancedb); it is imported lazily in _get_sentence_transformer. + from sentence_transformers import SentenceTransformer from graph_types import ( NodeRef, @@ -41,8 +45,17 @@ from java_ontology import EDGE_SCHEMA from ladybug_queries import LadybugGraph, OVERRIDE_AXIS_COMPOSED_EDGE_TYPES from mcp_hints import MCP_HINTS_STRUCTURED_FIELD_DESCRIPTION -from search_lancedb import TABLES, run_search +# The vector stack (lancedb/torch, reached via search_lancedb) is optional — it is absent on +# graph-only installs (macOS Intel). Import eagerly when available so ``run_search``/``TABLES`` +# exist as module attributes (tests monkeypatch ``mcp_v2.run_search``; callers use ``TABLES``); +# fall back to sentinels on ImportError so importing this module never fails and ``search_v2`` +# can return a clean "vector search unavailable" envelope instead of crashing. +try: + from search_lancedb import TABLES, run_search +except ImportError: # graph-only install: no torch/lancedb + TABLES = {} + run_search = None __all__ = [ "search_v2", "find_v2", @@ -158,6 +171,8 @@ def filter_frame_counters() -> dict[str, int]: def _get_sentence_transformer(model_name: str, device: str | None) -> SentenceTransformer: global _st_model + from sentence_transformers import SentenceTransformer + with _st_lock: if _st_model is None: _st_model = SentenceTransformer( @@ -827,6 +842,16 @@ def search_v2( if nf and (err := _nodefilter_applicability_error("symbol", nf)): _log_fail_loud("applicability") return SearchOutput(success=False, message=err, advisories=[], limit=None, offset=None) + if run_search is None: + # Graph-only install (no torch/lancedb): the vector stack is absent. Return a + # clean failure rather than crashing so the server keeps serving graph tools. + return SearchOutput( + success=False, + message="Vector search unavailable: graph-only mode (vector stack not installed).", + advisories=[], + limit=None, + offset=None, + ) model_name = resolved_sbert_model_for_process_env(SBERT_MODEL) device = os.environ.get("SBERT_DEVICE") or None model = _get_sentence_transformer(model_name, device) diff --git a/server.py b/server.py index 4f265730..02eca7e1 100644 --- a/server.py +++ b/server.py @@ -26,7 +26,8 @@ from ladybug_queries import LadybugGraph, resolve_ladybug_path from mcp.server.fastmcp import FastMCP from pydantic import BaseModel, Field -from search_lancedb import TABLES +# NOTE: search_lancedb.TABLES is imported lazily in list_code_index_tables_payload() — it +# pulls lancedb/torch and is unavailable on graph-only installs (macOS Intel). _COCOINDEX_TARGET = "java_index_flow_lancedb.py:JavaCodeIndexLance" _INSTRUCTIONS = ( @@ -296,12 +297,19 @@ def _graph_meta_output() -> GraphMetaOutput: def list_code_index_tables_payload() -> IndexInfoOutput: + try: + from search_lancedb import TABLES + + tables = dict(TABLES) + except ImportError: + # Graph-only install (no lancedb): no Lance vector tables exist. + tables = {} return IndexInfoOutput( lancedb_uri=_resolve_lancedb_uri(), embedding_model=resolved_sbert_model_for_process_env(SBERT_MODEL), project_root=str(_project_root()), cocoindex_target=_COCOINDEX_TARGET, - tables=dict(TABLES), + tables=tables, graph=_graph_meta_output(), ) diff --git a/tests/test_graph_only_boot.py b/tests/test_graph_only_boot.py new file mode 100644 index 00000000..c625476d --- /dev/null +++ b/tests/test_graph_only_boot.py @@ -0,0 +1,80 @@ +"""Graph-only boot: the MCP server must start and serve graph tools without the vector +stack (lancedb/torch/sentence-transformers/cocoindex), which is the macOS Intel reality. + +We simulate that install by pre-seeding ``sys.modules[] = None`` in a fresh +subprocess, which makes ``import `` raise ``ModuleNotFoundError``. This runs on any +platform (no uninstall needed) and proves the lazy-import seam in ``server.py``/``mcp_v2.py``. +""" +from __future__ import annotations + +import subprocess +import sys +import textwrap + +from java_codebase_rag.pipeline import is_cocoindex_preflight_blocker + +# Absent on a graph-only install. Pre-seeding with None forces ImportError on import. +_VECTOR_MODULES = ("lancedb", "pylance", "torch", "sentence_transformers", "cocoindex") + +_BOOT_SCRIPT = textwrap.dedent( + """ + import asyncio + import sys + + for _m in {modules!r}: + sys.modules[_m] = None # force ImportError on import + + import server # must not pull the vector stack at module load + srv = server.create_mcp_server() # registers tools + runs ScopeManager setup + + loaded = [m for m in {vector!r} if sys.modules.get(m) is not None] + tools = sorted(t.name for t in asyncio.run(srv.list_tools())) + from mcp_v2 import search_v2 + out = search_v2(query="x") + print("TOOLS:" + ",".join(tools)) + print("LOADED_AT_BOOT:" + (",".join(loaded) or "none")) + print("SEARCH_SUCCESS:" + str(out.success)) + print("SEARCH_MSG:" + str(out.message)) + """ +) + + +def _run_graph_only_boot() -> subprocess.CompletedProcess[str]: + return subprocess.run( + [sys.executable, "-c", _BOOT_SCRIPT.format(modules=_VECTOR_MODULES, vector=_VECTOR_MODULES)], + capture_output=True, + text=True, + timeout=120, + ) + + +def test_server_boots_and_serves_graph_tools_without_vector_stack() -> None: + proc = _run_graph_only_boot() + assert proc.returncode == 0, f"graph-only boot failed:\nstdout={proc.stdout}\nstderr={proc.stderr}" + + out = proc.stdout + # All five tools register; `search` is present but degrades at call time (below). + assert "TOOLS:describe,find,neighbors,resolve,search" in out + # None of the vector modules may be imported during boot. + assert "LOADED_AT_BOOT:none" in out + # The search tool returns a clean failure rather than raising. + assert "SEARCH_SUCCESS:False" in out + assert "Vector search unavailable" in out + + +def _completed(returncode: int, args: tuple[str, ...]) -> subprocess.CompletedProcess[str]: + return subprocess.CompletedProcess(args=list(args), returncode=returncode, stdout="", stderr="") + + +def test_preflight_blocker_detects_graph_only_install() -> None: + # The detector drives init/increment/install/update's "skip vectors, build graph" + # branch. It must recognize the two pre-spawn stubs (cocoindex binary missing on a + # graph-only install; flow file missing) and NOT mistake a real cocoindex run for one. + exe = "/example/.venv/bin/cocoindex" + + assert is_cocoindex_preflight_blocker(_completed(127, (exe,))) is True # binary absent + assert is_cocoindex_preflight_blocker(_completed(126, ())) is True # flow file absent + # A real cocoindex invocation carries the full command list (len(args) > 1): a genuine + # non-zero exit is a failure, not a skip. + assert is_cocoindex_preflight_blocker(_completed(1, (exe, "update", "t", "-f"))) is False + assert is_cocoindex_preflight_blocker(_completed(0, (exe, "update", "t", "-f"))) is False From 28a04c5017758babf7cee1f9772958541739b342 Mon Sep 17 00:00:00 2001 From: Dmitry Teryaev Date: Mon, 6 Jul 2026 09:19:06 +0300 Subject: [PATCH 3/4] feat(cli): skip vectors on graph-only installs in init/increment/install (PR-MI3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit init/increment/install/update previously hard-failed when cocoindex was absent (the graph-only reality on macOS Intel). Treat a cocoindex preflight blocker (returncode 126/127 stub from pipeline) as "skip the vectors phase, proceed to the graph build" rather than failure — mirroring the existing erase lancedb guard and reprocess --graph-only patterns. A genuine non-zero cocoindex exit still fails. Graph-only installs now build/refresh the graph and exit 0. Co-Authored-By: Claude --- java_codebase_rag/cli.py | 68 +++++++++++++++++++++++++++++----- java_codebase_rag/installer.py | 27 ++++++++++++-- 2 files changed, 82 insertions(+), 13 deletions(-) diff --git a/java_codebase_rag/cli.py b/java_codebase_rag/cli.py index bebe90b4..ad50d812 100644 --- a/java_codebase_rag/cli.py +++ b/java_codebase_rag/cli.py @@ -23,7 +23,15 @@ resolve_operator_config, ) from java_codebase_rag._fdlimit import raise_fd_limit -from java_codebase_rag.pipeline import clip, run_build_ast_graph, run_cocoindex_drop, run_cocoindex_update, run_incremental_graph +from java_codebase_rag.pipeline import ( + clip, + is_cocoindex_preflight_blocker, + is_graph_preflight_blocker, + run_build_ast_graph, + run_cocoindex_drop, + run_cocoindex_update, + run_incremental_graph, +) from java_ontology import VALID_UNRESOLVED_CALL_REASONS LADYBUG_INCREMENTAL_TRACKING_ISSUE_URL = "https://github.com/HumanBean17/java-codebase-rag/issues/73" @@ -61,6 +69,12 @@ def _reprocess_drift_graph_only_line(index_dir: Path) -> str: ) +_VECTORS_SKIPPED_GRAPH_ONLY = ( + "java-codebase-rag: vectors skipped — vector stack not installed on this platform " + "(graph-only mode). The graph is built/refreshed; semantic search is unavailable." +) + + def _reprocess_exit_code(payload: dict[str, Any]) -> int: if payload.get("success"): return 0 @@ -70,16 +84,17 @@ def _reprocess_exit_code(payload: dict[str, Any]) -> int: return 1 -# Preflight detection must stay aligned with stub CompletedProcess shapes in -# java_codebase_rag/pipeline.py (missing cocoindex / flow / build_ast_graph.py). +# Preflight detection delegates to pipeline.is_cocoindex_preflight_blocker / +# is_graph_preflight_blocker, which are co-located with the stub CompletedProcess shapes +# they must match (missing cocoindex / flow / build_ast_graph.py). def _is_cocoindex_preflight_blocker(coco: Any) -> bool: """True when ``run_cocoindex_update`` returned without spawning cocoindex.""" - return bool(coco.returncode in (126, 127) and len(getattr(coco, "args", ()) or ()) <= 1) + return is_cocoindex_preflight_blocker(coco) def _is_graph_preflight_blocker(g: Any) -> bool: """True when ``run_build_ast_graph`` returned without spawning the builder.""" - return bool(g.returncode in (126, 127) and len(getattr(g, "args", ()) or ()) <= 1) + return is_graph_preflight_blocker(g) def _emit_reprocess_selective_tty(*, mode: str) -> None: @@ -321,7 +336,11 @@ def work(progress: "PipelineProgress | None") -> int: on_progress=progress.on_progress if progress is not None else None, on_progress_console=progress.console if progress is not None else None, ) - if coco.returncode != 0: + # Graph-only install (cocoindex absent, e.g. macOS Intel): skip the vectors phase + # and proceed to the graph build rather than failing — the graph layer is the + # supported surface there. A genuine non-zero cocoindex exit still fails. + vectors_skipped = _is_cocoindex_preflight_blocker(coco) + if coco.returncode != 0 and not vectors_skipped: _emit( { "success": False, @@ -332,6 +351,8 @@ def work(progress: "PipelineProgress | None") -> int: } ) return 1 + if vectors_skipped: + print(_VECTORS_SKIPPED_GRAPH_ONLY, file=sys.stderr, flush=True) if not args.quiet: print(file=sys.stderr, flush=True) g = run_build_ast_graph( @@ -354,7 +375,16 @@ def work(progress: "PipelineProgress | None") -> int: } ) return 1 - _emit({"success": True, "message": "init completed"}) + _emit( + { + "success": True, + "message": ( + "init completed (graph-only; vectors skipped — vector stack not installed)" + if vectors_skipped + else "init completed" + ), + } + ) return 0 return _run_with_pipeline_progress( @@ -383,7 +413,8 @@ def work(progress: "PipelineProgress | None") -> int: on_progress=progress.on_progress if progress is not None else None, on_progress_console=progress.console if progress is not None else None, ) - if coco.returncode != 0: + vectors_skipped = _is_cocoindex_preflight_blocker(coco) + if coco.returncode != 0 and not vectors_skipped: _emit( { "success": False, @@ -394,9 +425,19 @@ def work(progress: "PipelineProgress | None") -> int: } ) return 1 + if vectors_skipped: + print(_VECTORS_SKIPPED_GRAPH_ONLY, file=sys.stderr, flush=True) # If --vectors-only is set, skip graph update if vectors_only: + if vectors_skipped: + _emit( + { + "success": True, + "message": "increment skipped: vector stack not installed (graph-only mode)", + } + ) + return 0 _emit({"success": True, "message": "increment completed (Lance only; graph may be stale — see stderr)"}) return 0 @@ -439,7 +480,16 @@ def work(progress: "PipelineProgress | None") -> int: ) return 1 - _emit({"success": True, "message": "increment completed (Lance + graph updated)"}) + _emit( + { + "success": True, + "message": ( + "increment completed (graph only; vectors skipped — vector stack not installed)" + if vectors_skipped + else "increment completed (Lance + graph updated)" + ), + } + ) return 0 return _run_with_pipeline_progress( diff --git a/java_codebase_rag/installer.py b/java_codebase_rag/installer.py index 26fbebab..3889b47c 100644 --- a/java_codebase_rag/installer.py +++ b/java_codebase_rag/installer.py @@ -1022,7 +1022,7 @@ def run_init_if_needed( index_dir_has_existing_artifacts, resolve_operator_config, ) - from java_codebase_rag.pipeline import run_build_ast_graph, run_cocoindex_update + from java_codebase_rag.pipeline import is_cocoindex_preflight_blocker, run_build_ast_graph, run_cocoindex_update has_existing, _ = index_dir_has_existing_artifacts(index_dir) if has_existing: @@ -1061,13 +1061,23 @@ def run_init_if_needed( on_progress=on_progress, on_progress_console=on_progress_console, ) - if coco.returncode != 0: + # Graph-only install (cocoindex absent, e.g. macOS Intel): skip the vectors phase + # and build the graph rather than failing install. A genuine non-zero cocoindex + # exit still fails. + vectors_skipped = is_cocoindex_preflight_blocker(coco) + if coco.returncode != 0 and not vectors_skipped: print( f"Error: CocoIndex update failed with code {coco.returncode}", file=sys.stderr, ) index_ok = False else: + if vectors_skipped: + print( + "java-codebase-rag: vectors skipped — vector stack not installed on this " + "platform (graph-only mode). Building graph only; semantic search is unavailable.", + file=sys.stderr, + ) g = run_build_ast_graph( source_root=cfg.source_root, ladybug_path=cfg.ladybug_path, @@ -1596,7 +1606,7 @@ def run_update( index_dir_has_existing_artifacts, resolve_operator_config, ) - from java_codebase_rag.pipeline import run_cocoindex_update, run_incremental_graph + from java_codebase_rag.pipeline import is_cocoindex_preflight_blocker, run_cocoindex_update, run_incremental_graph project_root = discover_project_root(cwd) if project_root is None: @@ -1659,13 +1669,22 @@ def run_update( on_progress=on_progress, on_progress_console=on_progress_console, ) - if coco.returncode != 0: + # Graph-only install (cocoindex absent): skip the vectors catch-up and run the + # graph catch-up only. A genuine non-zero cocoindex exit still fails. + vectors_skipped = is_cocoindex_preflight_blocker(coco) + if coco.returncode != 0 and not vectors_skipped: print( f"Error: Lance index update failed with code {coco.returncode}", file=sys.stderr, ) index_ok = False else: + if vectors_skipped: + print( + "java-codebase-rag: vectors skipped — vector stack not installed on this " + "platform (graph-only mode). Running graph catch-up only.", + file=sys.stderr, + ) g = run_incremental_graph( source_root=cfg.source_root, ladybug_path=cfg.ladybug_path, From 9392d97c3ed51ad314e8a14af1021bde6b4aae61 Mon Sep 17 00:00:00 2001 From: Dmitry Teryaev Date: Mon, 6 Jul 2026 09:19:20 +0300 Subject: [PATCH 4/4] test(platform): graph-only test guards + macos-13 CI + Intel-Mac docs (PR-MI4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - tests: importorskip the two files that import the vector stack at module scope (test_search_lancedb, test_search_lancedb_capability) so they skip cleanly instead of erroring at collection on a graph-only runner; @needs_vectors skipif on the 10 search tests in test_mcp_v2 that load the SentenceTransformer model. Graph tests run unconditionally. - CI: add macos-13 (Intel x86_64) to the OS matrix (continue-on-error, like the other non-ubuntu legs) so the graph-only install/boot path is exercised on real Intel hardware; skip the HuggingFace cache step there. - README: correct the false "every native dependency ships a wheel" claim — document Intel Mac graph-only mode (auto via PEP 508 markers); replace the dead `pip install -r requirements.txt` with the editable install. Co-Authored-By: Claude --- .github/workflows/test.yml | 9 ++++++-- README.md | 6 +++--- tests/test_mcp_v2.py | 28 +++++++++++++++++++++++++ tests/test_search_lancedb.py | 6 ++++++ tests/test_search_lancedb_capability.py | 6 ++++++ 5 files changed, 50 insertions(+), 5 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 539ef01f..c516b43f 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -18,7 +18,12 @@ jobs: # kuzu/ladybug graph layer, which had never been run on these OSes # before this matrix. Promote an OS to a hard gate by dropping it from # the continue-on-error expression once it is reliably green. - os: [ubuntu-latest, macos-latest, windows-latest] + # + # macos-13 is the only Intel (x86_64) runner: PEP 508 markers gate the + # vector trio (cocoindex[lancedb]/lancedb/sentence-transformers) off + # there, so it installs graph-only and proves the graph-only boot path + # (tests/test_graph_only_boot.py) plus clean skips of vector tests. + os: [ubuntu-latest, macos-latest, macos-13, windows-latest] continue-on-error: ${{ matrix.os != 'ubuntu-latest' }} runs-on: ${{ matrix.os }} steps: @@ -64,7 +69,7 @@ jobs: if: steps.changes.outputs.code == 'true' run: python scripts/generate_edge_navigation.py --check - name: Cache HuggingFace models - if: steps.changes.outputs.code == 'true' + if: steps.changes.outputs.code == 'true' && matrix.os != 'macos-13' uses: actions/cache@v4 with: path: ~/.cache/huggingface diff --git a/README.md b/README.md index 3d734f58..3dcb333f 100644 --- a/README.md +++ b/README.md @@ -41,8 +41,8 @@ The rest of this README is the install, the tool/command orientation, and the re pip install java-codebase-rag ``` -Python **3.11+** required, on **Linux, macOS, and Windows** — every native dependency (LanceDB, LadybugDB/kuzu, CocoIndex) ships a wheel for each platform. After install, `java-codebase-rag --help` should print the CLI groups. -The package includes the CocoIndex lifecycle dependency used by `init`, `increment`, `reprocess`, and `erase`. +Python **3.11+** required, on **Linux, macOS, and Windows**. On Linux, Windows, and **Apple Silicon** Macs every native dependency (LanceDB, LadybugDB/kuzu, CocoIndex) ships a wheel and you get the full semantic + graph search. **Intel Macs (x86_64) install graph-only**: PyTorch ≥2.3 and LanceDB ≥0.26 dropped macOS Intel wheels, so the vector stack is auto-excluded via PEP 508 markers — `pip install java-codebase-rag` works out of the box, the graph layer (`find` / `describe` / `neighbors` / `resolve`) is fully usable, and the `search` (semantic) tool reports it is unavailable rather than failing. After install, `java-codebase-rag --help` should print the CLI groups. +The package includes the CocoIndex lifecycle dependency used by `init`, `increment`, `reprocess`, and `erase` on platforms that have it (it is absent on Intel Mac). ### Interactive setup (recommended) @@ -270,7 +270,7 @@ full design and per-PR breakdown. git clone https://github.com/HumanBean17/java-codebase-rag cd java-codebase-rag python3 -m venv .venv -.venv/bin/pip install -r requirements.txt +.venv/bin/pip install -e ".[dev]" ``` The `cocoindex` package powers lifecycle commands that run the indexer (`init`, `increment`, `reprocess`, `erase`). Search and MCP navigation do not invoke it directly. diff --git a/tests/test_mcp_v2.py b/tests/test_mcp_v2.py index 569ccd7e..0cb70e63 100644 --- a/tests/test_mcp_v2.py +++ b/tests/test_mcp_v2.py @@ -29,6 +29,24 @@ ) from pinned_ids import client_message_processor_process_id +import importlib.util + + +def _vector_stack_available() -> bool: + """True when the optional vector stack (torch/sentence-transformers/lancedb) is installed. + + The ``search`` tool loads a SentenceTransformer model, so the search/filter unit tests + need it even when ``run_search`` is monkeypatched. Skip them on graph-only installs + (macOS Intel, where the vector trio is gated off by PEP 508 markers). + """ + return all(importlib.util.find_spec(m) is not None for m in ("sentence_transformers", "lancedb")) + + +needs_vectors = pytest.mark.skipif( + not _vector_stack_available(), + reason="vector stack not installed (graph-only install; macOS Intel)", +) + _PR2_CHAIN_SEARCH_DESCRIBE = re.compile(r"search\(query=.*\).*describe") _PR2_SENTINEL_PATTERNS: tuple[re.Pattern[str], ...] = ( re.compile(r"per\.candidate"), @@ -116,6 +134,7 @@ def _fake_search_rows() -> list[dict[str, Any]]: ] +@needs_vectors def test_search_basic_returns_hits_with_symbol_id(monkeypatch, ladybug_graph) -> None: monkeypatch.setattr("mcp_v2.run_search", lambda *args, **kwargs: _fake_search_rows()) out = search_v2("ChatService", graph=ladybug_graph) @@ -124,6 +143,7 @@ def test_search_basic_returns_hits_with_symbol_id(monkeypatch, ladybug_graph) -> assert out.results[0].symbol_id is not None +@needs_vectors def test_search_filter_microservice(monkeypatch, ladybug_graph) -> None: monkeypatch.setattr("mcp_v2.run_search", lambda *args, **kwargs: _fake_search_rows()) out = search_v2("ChatService", filter={"microservice": "chat-assign"}, graph=ladybug_graph) @@ -132,6 +152,7 @@ def test_search_filter_microservice(monkeypatch, ladybug_graph) -> None: assert {h.microservice for h in out.results} == {"chat-assign"} +@needs_vectors def test_search_path_contains_filter(monkeypatch, ladybug_graph) -> None: monkeypatch.setattr("mcp_v2.run_search", lambda *args, **kwargs: _fake_search_rows()) out = search_v2("ChatAssign", path_contains="ChatAssign", graph=ladybug_graph) @@ -569,6 +590,7 @@ async def test_search_invalid_table_rejected(mcp_server) -> None: await mcp_server.call_tool("search", {"query": "foo", "table": "code"}) +@needs_vectors def test_search_filter_accepts_json_string(monkeypatch, ladybug_graph) -> None: monkeypatch.setattr("mcp_v2.run_search", lambda *args, **kwargs: _fake_search_rows()) want = {"microservice": "chat-assign"} @@ -579,6 +601,7 @@ def test_search_filter_accepts_json_string(monkeypatch, ladybug_graph) -> None: assert out_dict.results == out_str.results +@needs_vectors def test_search_unknown_filter_key_returns_failure(monkeypatch, ladybug_graph) -> None: monkeypatch.setattr("mcp_v2.run_search", lambda *args, **kwargs: _fake_search_rows()) out = search_v2("ChatService", filter={"typo_key": "x"}, graph=ladybug_graph) @@ -607,6 +630,7 @@ def test_search_no_lance_index_returns_failure_envelope(monkeypatch, ladybug_gra assert found.results +@needs_vectors def test_search_pushes_nodefilter_into_run_search(monkeypatch, ladybug_graph) -> None: """search forwards NodeFilter structural fields into run_search so the filter applies BEFORE pagination, not as a post-filter on the already-paginated page @@ -711,6 +735,7 @@ def _rows(self, query: str, params: dict[str, Any] | None = None) -> list[dict[s ) +@needs_vectors def test_search_cross_kind_filter_returns_failure(monkeypatch, ladybug_graph) -> None: monkeypatch.setattr("mcp_v2.run_search", lambda *args, **kwargs: _fake_search_rows()) out = search_v2("ChatService", filter={"path_contains": "/api"}, graph=ladybug_graph) @@ -720,6 +745,7 @@ def test_search_cross_kind_filter_returns_failure(monkeypatch, ladybug_graph) -> assert "kind='symbol'" in out.message +@needs_vectors def test_search_filter_empty_string_treated_as_none(monkeypatch, ladybug_graph) -> None: monkeypatch.setattr("mcp_v2.run_search", lambda *args, **kwargs: _fake_search_rows()) baseline = search_v2("ChatService", graph=ladybug_graph) @@ -731,6 +757,7 @@ def test_search_filter_empty_string_treated_as_none(monkeypatch, ladybug_graph) assert baseline.results == empty.results == whitespace.results +@needs_vectors def test_search_filter_json_null_treated_as_none(monkeypatch, ladybug_graph) -> None: monkeypatch.setattr("mcp_v2.run_search", lambda *args, **kwargs: _fake_search_rows()) baseline = search_v2("ChatService", graph=ladybug_graph) @@ -824,6 +851,7 @@ def test_neighbors_validate_call_still_raises(ladybug_graph) -> None: neighbors_v2(mid, direction="upstream", edge_types=["CALLS"], graph=ladybug_graph) +@needs_vectors def test_filter_invalid_json_returns_failure(monkeypatch, ladybug_graph) -> None: monkeypatch.setattr("mcp_v2.run_search", lambda *args, **kwargs: _fake_search_rows()) out = search_v2("ChatService", filter="{not json", graph=ladybug_graph) diff --git a/tests/test_search_lancedb.py b/tests/test_search_lancedb.py index a6197f9a..3f71e558 100644 --- a/tests/test_search_lancedb.py +++ b/tests/test_search_lancedb.py @@ -3,6 +3,12 @@ from __future__ import annotations import numpy as np +import pytest + +# search_lancedb imports lancedb/torch at module load; skip the whole file on graph-only +# installs (macOS Intel) where the vector stack is absent. +pytest.importorskip("lancedb") +pytest.importorskip("sentence_transformers") import search_lancedb from search_lancedb import JAVA_ENRICHED_COLUMNS, _rrf_merge diff --git a/tests/test_search_lancedb_capability.py b/tests/test_search_lancedb_capability.py index d4ce1093..d6edc4e1 100644 --- a/tests/test_search_lancedb_capability.py +++ b/tests/test_search_lancedb_capability.py @@ -3,6 +3,12 @@ import uuid +import pytest + +# Skip the whole file on graph-only installs (macOS Intel) where the vector stack is absent. +pytest.importorskip("lancedb") +pytest.importorskip("sentence_transformers") + from sentence_transformers import SentenceTransformer from ast_java import ONTOLOGY_VERSION