diff --git a/java_ontology.py b/java_ontology.py index 0cb3b10..a05ec37 100644 --- a/java_ontology.py +++ b/java_ontology.py @@ -4,6 +4,8 @@ and resolver steps for `@CodebaseRole` / `@CodebaseCapability`.""" from __future__ import annotations +from typing import Literal + from ast_java import ( ROLE_ANNOTATIONS, _INJECTED_TYPES_TO_CAPABILITY, @@ -72,6 +74,28 @@ "unresolved", )) +VALID_RESOLVE_REASONS: frozenset[str] = frozenset(( + "exact_id", + "exact_fqn", + "fqn_suffix", + "short_name", + "route_template", + "route_method_path", + "client_target", + "client_target_path", +)) + +ResolveReason = Literal[ + "exact_id", + "exact_fqn", + "fqn_suffix", + "short_name", + "route_template", + "route_method_path", + "client_target", + "client_target_path", +] + __all__ = [ "VALID_ROLES", "VALID_CAPABILITIES", @@ -82,4 +106,6 @@ "VALID_HTTP_CALL_STRATEGIES", "VALID_ASYNC_CALL_STRATEGIES", "VALID_HTTP_CALL_MATCHES", + "VALID_RESOLVE_REASONS", + "ResolveReason", ] diff --git a/mcp_v2.py b/mcp_v2.py index f353f8f..b0452fc 100644 --- a/mcp_v2.py +++ b/mcp_v2.py @@ -1,4 +1,4 @@ -"""MCP V2 graph query surface (``search`` / ``find`` / ``describe`` / ``neighbors``). +"""MCP V2 graph query surface (``search`` / ``find`` / ``describe`` / ``neighbors`` / ``resolve``). Strict frame contract --------------------- @@ -29,6 +29,7 @@ from index_common import SBERT_MODEL from java_codebase_rag.config import resolved_sbert_model_for_process_env +from java_ontology import ResolveReason from kuzu_queries import KuzuGraph from search_lancedb import TABLES, run_search @@ -301,6 +302,64 @@ class NeighborsOutput(BaseModel): message: str | None = None +ResolveStatus = Literal["one", "many", "none"] + +_RESOLVE_CANDIDATE_CAP = 10 + +_RESOLVE_REASON_PRIORITY: dict[ResolveReason, int] = { + "exact_id": 0, + "exact_fqn": 1, + "route_method_path": 1, + "client_target_path": 1, + "fqn_suffix": 2, + "route_template": 2, + "short_name": 3, + "client_target": 3, +} + +_SYMBOL_RESOLVE_RETURN = ( + "s.id AS id, s.fqn AS fqn, s.microservice AS microservice, " + "s.module AS module, s.role AS role, s.kind AS symbol_kind" +) + +_ROUTE_RESOLVE_RETURN = ( + "r.id AS id, r.kind AS kind, r.framework AS framework, r.method AS method, " + "r.path AS path, r.path_template AS path_template, r.path_regex AS path_regex, " + "r.topic AS topic, r.broker AS broker, r.feign_name AS feign_name, r.feign_url AS feign_url, " + "r.microservice AS microservice, r.module AS module, r.filename AS filename, " + "r.start_line AS start_line, r.end_line AS end_line, r.resolved AS resolved" +) + +_CLIENT_RESOLVE_RETURN = ( + "c.id AS id, c.client_kind AS client_kind, c.target_service AS target_service, " + "c.method AS method, c.path AS path, c.path_template AS path_template, " + "c.path_regex AS path_regex, c.member_fqn AS member_fqn, c.member_id AS member_id, " + "c.microservice AS microservice, c.module AS module, c.filename AS filename, " + "c.start_line AS start_line, c.end_line AS end_line, c.resolved AS resolved, " + "c.source_layer AS source_layer" +) + +_RESOLVE_PRE_DEDUP_LIMIT = 50 + + +class ResolveCandidate(BaseModel): + model_config = ConfigDict(extra="forbid") + + node: NodeRef + score: float + reason: ResolveReason + + +class ResolveOutput(BaseModel): + model_config = ConfigDict(extra="forbid") + + success: bool + status: ResolveStatus + node: NodeRef | None = None + candidates: list[ResolveCandidate] = Field(default_factory=list) + message: str | None = None + + def _node_kind_from_id(id_str: str) -> Literal["symbol", "route", "client"]: if id_str.startswith("sym:"): return "symbol" @@ -695,6 +754,279 @@ def describe_v2( return DescribeOutput(success=False, message=str(exc)) +def _resolve_validate_identifier(raw: str) -> tuple[str | None, str | None]: + trimmed = raw.strip() + if not trimmed: + detail = "empty string" if raw == "" else "whitespace only" + return None, f"Invalid identifier: {detail}" + return trimmed, None + + +def _resolve_kinds_to_search( + hint_kind: Literal["symbol", "route", "client"] | None, +) -> list[Literal["symbol", "route", "client"]]: + if hint_kind is None: + return ["symbol", "route", "client"] + return [hint_kind] + + +def _resolve_parse_route_method_path(identifier: str) -> tuple[str, str] | None: + parts = identifier.split(None, 1) + if len(parts) != 2: + return None + method, path = parts[0].upper(), parts[1].strip() + if not method.isalpha() or not path.startswith("/"): + return None + return method, path + + +def _resolve_parse_microservice_route(identifier: str) -> tuple[str, str, str] | None: + parts = identifier.split(None, 2) + if len(parts) != 3: + return None + microservice, method, path = parts[0], parts[1].upper(), parts[2].strip() + if not method.isalpha() or not path.startswith("/"): + return None + return microservice, method, path + + +def _resolve_symbol_candidates( + g: KuzuGraph, + identifier: str, +) -> list[tuple[NodeRef, ResolveReason, int]]: + out: list[tuple[NodeRef, ResolveReason, int]] = [] + lim = _RESOLVE_PRE_DEDUP_LIMIT + + rows = g._rows( # noqa: SLF001 + f"MATCH (s:Symbol) WHERE s.id = $id RETURN {_SYMBOL_RESOLVE_RETURN} LIMIT $lim", + {"id": identifier, "lim": lim}, + ) + for row in rows: + out.append((_node_ref_from_row("symbol", row), "exact_id", len(identifier))) + + rows = g._rows( # noqa: SLF001 + f"MATCH (s:Symbol) WHERE s.fqn = $fqn RETURN {_SYMBOL_RESOLVE_RETURN} LIMIT $lim", + {"fqn": identifier, "lim": lim}, + ) + for row in rows: + out.append((_node_ref_from_row("symbol", row), "exact_fqn", len(identifier))) + + suffix = f".{identifier}" + rows = g._rows( # noqa: SLF001 + f"MATCH (s:Symbol) WHERE s.fqn = $ident OR s.fqn ENDS WITH $suffix " + f"RETURN {_SYMBOL_RESOLVE_RETURN} LIMIT $lim", + {"ident": identifier, "suffix": suffix, "lim": lim}, + ) + for row in rows: + fqn = str(row.get("fqn") or "") + spec = len(fqn) + out.append((_node_ref_from_row("symbol", row), "fqn_suffix", spec)) + + rows = g._rows( # noqa: SLF001 + f"MATCH (s:Symbol) WHERE s.name = $name RETURN {_SYMBOL_RESOLVE_RETURN} LIMIT $lim", + {"name": identifier, "lim": lim}, + ) + for row in rows: + out.append((_node_ref_from_row("symbol", row), "short_name", len(identifier))) + + return out + + +def _resolve_route_candidates( + g: KuzuGraph, + identifier: str, +) -> list[tuple[NodeRef, ResolveReason, int]]: + out: list[tuple[NodeRef, ResolveReason, int]] = [] + lim = _RESOLVE_PRE_DEDUP_LIMIT + + rows = g._rows( # noqa: SLF001 + f"MATCH (r:Route) WHERE r.id = $id RETURN {_ROUTE_RESOLVE_RETURN} LIMIT $lim", + {"id": identifier, "lim": lim}, + ) + for row in rows: + out.append((_node_ref_from_row("route", row), "exact_id", len(identifier))) + + ms_route = _resolve_parse_microservice_route(identifier) + if ms_route is not None: + microservice, method, path = ms_route + rows = g._rows( # noqa: SLF001 + f"MATCH (r:Route) WHERE r.microservice = $ms AND r.method = $method " + f"AND (r.path = $path OR r.path_template = $path) " + f"RETURN {_ROUTE_RESOLVE_RETURN} LIMIT $lim", + {"ms": microservice, "method": method, "path": path, "lim": lim}, + ) + for row in rows: + spec = len(path) + out.append((_node_ref_from_row("route", row), "route_method_path", spec)) + + method_path = _resolve_parse_route_method_path(identifier) + if method_path is not None: + method, path = method_path + rows = g._rows( # noqa: SLF001 + f"MATCH (r:Route) WHERE r.method = $method " + f"AND (r.path = $path OR r.path_template = $path) " + f"RETURN {_ROUTE_RESOLVE_RETURN} LIMIT $lim", + {"method": method, "path": path, "lim": lim}, + ) + for row in rows: + out.append((_node_ref_from_row("route", row), "route_method_path", len(path))) + + if identifier.startswith("/"): + rows = g._rows( # noqa: SLF001 + f"MATCH (r:Route) WHERE r.path = $path OR r.path_template = $path " + f"RETURN {_ROUTE_RESOLVE_RETURN} LIMIT $lim", + {"path": identifier, "lim": lim}, + ) + for row in rows: + path_val = str(row.get("path_template") or row.get("path") or "") + out.append((_node_ref_from_row("route", row), "route_template", len(path_val))) + + return out + + +def _resolve_client_candidates( + g: KuzuGraph, + identifier: str, +) -> list[tuple[NodeRef, ResolveReason, int]]: + out: list[tuple[NodeRef, ResolveReason, int]] = [] + lim = _RESOLVE_PRE_DEDUP_LIMIT + + rows = g._rows( # noqa: SLF001 + f"MATCH (c:Client) WHERE c.id = $id RETURN {_CLIENT_RESOLVE_RETURN} LIMIT $lim", + {"id": identifier, "lim": lim}, + ) + for row in rows: + out.append((_node_ref_from_row("client", row), "exact_id", len(identifier))) + + if " " in identifier: + target, path_prefix = identifier.split(" ", 1) + target = target.strip() + path_prefix = path_prefix.strip() + if target and path_prefix: + rows = g._rows( # noqa: SLF001 + f"MATCH (c:Client) WHERE c.target_service = $target " + f"AND (c.path STARTS WITH $path OR c.path_template STARTS WITH $path) " + f"RETURN {_CLIENT_RESOLVE_RETURN} LIMIT $lim", + {"target": target, "path": path_prefix, "lim": lim}, + ) + for row in rows: + spec = len(path_prefix) + out.append((_node_ref_from_row("client", row), "client_target_path", spec)) + elif not identifier.startswith("/"): + rows = g._rows( # noqa: SLF001 + f"MATCH (c:Client) WHERE c.target_service = $target RETURN {_CLIENT_RESOLVE_RETURN} LIMIT $lim", + {"target": identifier, "lim": lim}, + ) + for row in rows: + out.append((_node_ref_from_row("client", row), "client_target", len(identifier))) + + return out + + +def _resolve_dedupe_candidates( + raw: list[tuple[NodeRef, ResolveReason, int]], +) -> list[tuple[NodeRef, ResolveReason, int]]: + best: dict[str, tuple[NodeRef, ResolveReason, int]] = {} + for node, reason, specificity in raw: + prev = best.get(node.id) + if prev is None: + best[node.id] = (node, reason, specificity) + continue + prev_pri = _RESOLVE_REASON_PRIORITY[prev[1]] + new_pri = _RESOLVE_REASON_PRIORITY[reason] + if new_pri < prev_pri or (new_pri == prev_pri and specificity > prev[2]): + best[node.id] = (node, reason, specificity) + return list(best.values()) + + +def _resolve_rank_candidates( + deduped: list[tuple[NodeRef, ResolveReason, int]], +) -> list[ResolveCandidate]: + ordered = sorted( + deduped, + key=lambda item: (_RESOLVE_REASON_PRIORITY[item[1]], -item[2], item[0].id), + ) + total = len(ordered) + return [ + ResolveCandidate( + node=node, + reason=reason, + score=(1.0 - (idx / total)) if total else 0.0, + ) + for idx, (node, reason, _spec) in enumerate(ordered) + ] + + +def _resolve_assert_invariants(out: ResolveOutput) -> None: + if not out.success: + assert out.status == "none" + assert out.node is None + assert not out.candidates + assert out.message + return + if out.status == "one": + assert out.node is not None + assert not out.candidates + elif out.status == "many": + assert out.node is None + assert len(out.candidates) >= 2 + elif out.status == "none": + assert out.node is None + assert not out.candidates + assert out.message + + +def _resolve_build_output(matches: list[ResolveCandidate]) -> ResolveOutput: + if not matches: + out = ResolveOutput( + success=True, + status="none", + message=( + "No matches for identifier; use search(query=...) for ranked fuzzy lookup." + ), + ) + elif len(matches) == 1: + out = ResolveOutput(success=True, status="one", node=matches[0].node) + else: + out = ResolveOutput(success=True, status="many", candidates=matches) + _resolve_assert_invariants(out) + return out + + +def resolve_v2( + identifier: str, + hint_kind: Literal["symbol", "route", "client"] | None = None, + graph: KuzuGraph | None = None, +) -> ResolveOutput: + try: + trimmed, err = _resolve_validate_identifier(identifier) + if err is not None: + out = ResolveOutput(success=False, status="none", message=err) + _resolve_assert_invariants(out) + return out + + assert trimmed is not None + if "*" in trimmed or "?" in trimmed: + return _resolve_build_output([]) + + g = graph or KuzuGraph.get() + raw: list[tuple[NodeRef, ResolveReason, int]] = [] + for kind in _resolve_kinds_to_search(hint_kind): + if kind == "symbol": + raw.extend(_resolve_symbol_candidates(g, trimmed)) + elif kind == "route": + raw.extend(_resolve_route_candidates(g, trimmed)) + else: + raw.extend(_resolve_client_candidates(g, trimmed)) + + deduped = _resolve_dedupe_candidates(raw) + ranked = _resolve_rank_candidates(deduped) + capped = ranked[:_RESOLVE_CANDIDATE_CAP] + return _resolve_build_output(capped) + except Exception as exc: + return ResolveOutput(success=False, status="none", message=str(exc)) + + @validate_call(config={"arbitrary_types_allowed": True}) def neighbors_v2( ids: str | list[str], diff --git a/server.py b/server.py index 0b960ff..ca276f3 100644 --- a/server.py +++ b/server.py @@ -26,7 +26,8 @@ _INSTRUCTIONS = ( "Java codebase graph navigator (LanceDB + Kuzu). " "Tools: search (NL/code locate), find (structured NodeFilter), describe (one node + edge_summary: stored edge-label counts and optional composed keys for type Symbols and override-axis virtual keys for method Symbols), " - "neighbors (one hop; you MUST pass direction in|out AND edge_types list — no defaults). " + "neighbors (one hop; you MUST pass direction in|out AND edge_types list — no defaults), " + "resolve (identifier-shaped lookup for symbol/route/client — three statuses one|many|none). " "NodeFilter `filter` is a JSON object (preferred); a JSON-encoded string is also accepted as a fallback. " "Unknown filter keys and populated fields not applicable to the effective node kind fail with success=false and message. " "Edge labels: EXTENDS, IMPLEMENTS, INJECTS, DECLARES, DECLARES_CLIENT, CALLS, EXPOSES, HTTP_CALLS, ASYNC_CALLS. " @@ -483,6 +484,31 @@ async def neighbors( None, ) + @mcp.tool( + name="resolve", + description=( + "Identifier-shaped node lookup (FQN, sym:/route:/client: id, HTTP method+path, " + "route path template, client target_service, or target+path pair). Returns " + "status=one (single node), many (≥2 ranked candidates with reason), or none " + "(no match — fall back to search(query=...) for natural language or fuzzy text). " + "Optional hint_kind narrows to symbol, route, or client. " + "Malformed empty/whitespace identifier returns success=false. " + "Examples: resolve('com.foo.Bar', hint_kind='symbol'); " + "resolve('GET /api/v1/customers', hint_kind='route'); " + "resolve('the client that handles assignments') → none (use search instead)." + ), + ) + async def resolve( + identifier: str = Field( + description="Identifier-shaped node lookup (FQN, id prefix, route path, client target, …)", + ), + hint_kind: Literal["symbol", "route", "client"] | None = Field( + default=None, + description="Optional kind constraint. Omit to search all three kinds.", + ), + ) -> mcp_v2.ResolveOutput: + return await asyncio.to_thread(mcp_v2.resolve_v2, identifier, hint_kind, None) + return mcp diff --git a/tests/conftest.py b/tests/conftest.py index 5a48104..24cd772 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -157,6 +157,13 @@ def kuzu_db_path_fqn_collision_smoke(tmp_path_factory) -> Path: return build_kuzu_to(root, db_path, max_pass=3) +@pytest.fixture(scope="session") +def kuzu_graph_fqn_collision_smoke(kuzu_db_path_fqn_collision_smoke: Path): + from kuzu_queries import KuzuGraph + + return KuzuGraph(str(kuzu_db_path_fqn_collision_smoke)) + + @pytest.fixture(scope="session") def kuzu_db_path_http_caller_smoke(tmp_path_factory) -> Path: from _builders import build_kuzu_to diff --git a/tests/test_mcp_tools.py b/tests/test_mcp_tools.py index c81c672..5498020 100644 --- a/tests/test_mcp_tools.py +++ b/tests/test_mcp_tools.py @@ -24,9 +24,10 @@ async def test_registered_tool_surface_is_v2_navigation_only(mcp_server) -> None "find", "describe", "neighbors", + "resolve", } assert names == expected - assert len(names) == 4 + assert len(names) == 5 async def test_all_tools_have_non_empty_description(mcp_server) -> None: diff --git a/tests/test_mcp_v2.py b/tests/test_mcp_v2.py index 2da454c..da3974b 100644 --- a/tests/test_mcp_v2.py +++ b/tests/test_mcp_v2.py @@ -1,11 +1,14 @@ from __future__ import annotations +from collections import Counter from typing import Any import pytest from pydantic import ValidationError from mcp.server.fastmcp.exceptions import ToolError +from java_ontology import VALID_RESOLVE_REASONS + from mcp_v2 import ( NodeFilter, _NODEFILTER_APPLICABLE_FIELDS, @@ -13,6 +16,7 @@ filter_frame_counters, find_v2, neighbors_v2, + resolve_v2, search_v2, ) @@ -819,3 +823,388 @@ def test_fail_loud_counter_survives_multiple_calls(kuzu_graph) -> None: find_v2("symbol", {"http_method": "GET"}, graph=kuzu_graph) find_v2("symbol", {"http_method": "GET"}, graph=kuzu_graph) assert filter_frame_counters().get("applicability", 0) >= before + 2 + + +# --- resolve (PR-RESOLVE-1) --- + + +def test_resolve_exact_id_symbol_returns_one(kuzu_graph) -> None: + seed = find_v2("symbol", {"role": "CONTROLLER"}, graph=kuzu_graph, limit=1) + assert seed.success and seed.results + sym_id = seed.results[0].id + out = resolve_v2(sym_id, hint_kind="symbol", graph=kuzu_graph) + assert out.success is True + assert out.status == "one" + assert out.node is not None + assert out.node.id == sym_id + + +def test_resolve_exact_fqn_symbol_returns_one(kuzu_graph) -> None: + controllers = find_v2("symbol", {"role": "CONTROLLER"}, graph=kuzu_graph, limit=50) + assert controllers.success and controllers.results + fqn = controllers.results[0].fqn + out = resolve_v2(fqn, hint_kind="symbol", graph=kuzu_graph) + assert out.success is True + assert out.status == "one" + assert out.node is not None + assert out.node.fqn == fqn + + +def test_resolve_fqn_collision_across_microservices_returns_many( + kuzu_graph_fqn_collision_smoke, +) -> None: + out = resolve_v2( + "com.example.SharedDto#process()", + hint_kind="symbol", + graph=kuzu_graph_fqn_collision_smoke, + ) + assert out.success is True + assert out.status == "many" + assert len(out.candidates) >= 2 + microservices = {c.node.microservice for c in out.candidates} + assert len(microservices) >= 2 + assert any(c.reason == "exact_fqn" for c in out.candidates) + + +def test_resolve_short_name_ambiguity_returns_many(kuzu_graph) -> None: + rows = kuzu_graph._rows( # noqa: SLF001 + "MATCH (s:Symbol) WHERE s.kind = 'method' RETURN s.name AS name" + ) + counts = Counter(str(r["name"]) for r in rows if r.get("name")) + dup_name = next((name for name, c in counts.items() if c >= 2), None) + if dup_name is None: + pytest.skip("no duplicated method short names in bank-chat fixture") + out = resolve_v2(dup_name, hint_kind="symbol", graph=kuzu_graph) + assert out.success is True + assert out.status == "many" + assert any(c.reason == "short_name" for c in out.candidates) + + +def test_resolve_status_none_returns_nonempty_message(kuzu_graph) -> None: + out = resolve_v2("com.nonexistent.ZzzMissing", hint_kind="symbol", graph=kuzu_graph) + assert out.success is True + assert out.status == "none" + assert out.message + assert "search" in out.message.lower() + + +def test_resolve_empty_identifier_success_false(kuzu_graph) -> None: + out = resolve_v2("", graph=kuzu_graph) + assert out.success is False + assert out.status == "none" + assert out.message and out.message.startswith("Invalid identifier:") + + +def test_resolve_whitespace_identifier_success_false(kuzu_graph) -> None: + out = resolve_v2(" ", graph=kuzu_graph) + assert out.success is False + assert out.status == "none" + assert out.message and out.message.startswith("Invalid identifier:") + + +def test_resolve_cross_kind_without_hint_returns_mixed_kinds() -> None: + class CrossKindGraph: + def _rows(self, query: str, params: dict | None = None) -> list: + p = params or {} + if "WHERE s.name = $name" in query and p.get("name") == "customers": + return [ + { + "id": "sym:customers", + "fqn": "com.fixture.Customers", + "microservice": "svc-a", + "module": "fixture", + "role": "", + "symbol_kind": "class", + } + ] + if "WHERE c.target_service = $target" in query and p.get("target") == "customers": + return [ + { + "id": "client:customers", + "client_kind": "feign_method", + "target_service": "customers", + "method": "GET", + "path": "/api/customers", + "path_template": "/api/customers", + "path_regex": "", + "member_fqn": "", + "member_id": "", + "microservice": "svc-a", + "module": "fixture", + "filename": "Client.java", + "start_line": 1, + "end_line": 1, + "resolved": True, + "source_layer": "builtin", + } + ] + return [] + + out = resolve_v2("customers", graph=CrossKindGraph()) # type: ignore[arg-type] + assert out.success is True + assert out.status == "many" + kinds = {c.node.kind for c in out.candidates} + assert len(kinds) >= 2 + + +def test_resolve_dedupes_overlapping_generator_paths() -> None: + class DedupeGraph: + sym_row = { + "id": "sym:com.fixture.DedupeMe", + "fqn": "com.fixture.DedupeMe", + "microservice": "svc-a", + "module": "fixture", + "role": "", + "symbol_kind": "class", + } + + def _rows(self, query: str, params: dict | None = None) -> list: + p = params or {} + if "WHERE s.fqn = $fqn" in query and p.get("fqn") == "DedupeMe": + return [self.sym_row] + if "WHERE s.fqn = $ident OR s.fqn ENDS WITH $suffix" in query: + return [self.sym_row] + if "WHERE s.name = $name" in query and p.get("name") == "DedupeMe": + return [self.sym_row] + return [] + + out = resolve_v2("DedupeMe", hint_kind="symbol", graph=DedupeGraph()) # type: ignore[arg-type] + assert out.success is True + assert out.status == "one" + assert len(out.candidates) == 0 + assert out.node is not None + assert out.node.id == "sym:com.fixture.DedupeMe" + + +def test_resolve_route_method_path_returns_one(kuzu_graph_route_extraction_smoke) -> None: + out = resolve_v2( + "service-a GET /api/users", + hint_kind="route", + graph=kuzu_graph_route_extraction_smoke, + ) + assert out.success is True + assert out.status == "one" + assert out.node is not None + assert out.node.kind == "route" + assert out.node.microservice == "service-a" + + +def test_resolve_route_template_returns_one_or_many(kuzu_graph_route_extraction_smoke) -> None: + out = resolve_v2( + "/api/users", + hint_kind="route", + graph=kuzu_graph_route_extraction_smoke, + ) + assert out.success is True + assert out.status in {"one", "many"} + reasons = {c.reason for c in out.candidates} + if out.status == "many": + assert "route_template" in reasons + else: + assert out.node is not None + + +def test_resolve_client_target_service(kuzu_graph, kuzu_db_path_http_caller_smoke) -> None: + from kuzu_queries import KuzuGraph + + graph = kuzu_graph + rows = graph.list_clients(limit=500) + seed = next((r for r in rows if str(r.get("target_service") or "").strip()), None) + if seed is None: + graph = KuzuGraph(str(kuzu_db_path_http_caller_smoke)) + rows = graph.list_clients(limit=500) + seed = next((r for r in rows if str(r.get("target_service") or "").strip()), None) + if seed is None: + pytest.skip("no client rows with target_service in fixture") + target_service = str(seed["target_service"]) + out = resolve_v2(target_service, hint_kind="client", graph=graph) + assert out.success is True + assert out.status in {"one", "many"} + if out.status == "many": + assert any(c.reason == "client_target" for c in out.candidates) + else: + assert out.node is not None + + +def test_resolve_client_target_path_pair(kuzu_graph, kuzu_db_path_http_caller_smoke) -> None: + from kuzu_queries import KuzuGraph + + def _seed_client(g: KuzuGraph) -> dict | None: + rows = g.list_clients(limit=500) + return next( + ( + r + for r in rows + if str(r.get("target_service") or "").strip() + and str(r.get("path") or "").startswith("/") + ), + None, + ) + + graph = kuzu_graph + seed = _seed_client(graph) + if seed is None: + graph = KuzuGraph(str(kuzu_db_path_http_caller_smoke)) + seed = _seed_client(graph) + if seed is None: + pytest.skip("no client with target_service and path in fixture") + target = str(seed["target_service"]) + path = str(seed["path"]) + prefix = path[: min(len(path), 8)] + out = resolve_v2(f"{target} {prefix}", hint_kind="client", graph=graph) + assert out.success is True + assert out.status in {"one", "many"} + reasons = {c.reason for c in out.candidates} + if out.status == "many": + assert "client_target_path" in reasons + else: + assert out.node is not None + + +def test_resolve_natural_language_sentence_returns_none(kuzu_graph) -> None: + out = resolve_v2( + "the client that handles smartcare assignments", + graph=kuzu_graph, + ) + assert out.success is True + assert out.status == "none" + + +def test_resolve_wildcard_identifier_returns_none(kuzu_graph) -> None: + out = resolve_v2("com.foo.*Service", hint_kind="symbol", graph=kuzu_graph) + assert out.success is True + assert out.status == "none" + + +def test_resolve_every_reason_in_closed_set_appears() -> None: + from mcp_v2 import ( + _resolve_client_candidates, + _resolve_route_candidates, + _resolve_symbol_candidates, + ) + + sym_row = { + "id": "sym:reason", + "fqn": "com.reason.Type", + "microservice": "svc", + "module": "mod", + "role": "", + "symbol_kind": "class", + } + route_row = { + "id": "route:reason", + "kind": "http_endpoint", + "framework": "spring_mvc", + "method": "GET", + "path": "/reason", + "path_template": "/reason", + "path_regex": "", + "topic": "", + "broker": "", + "feign_name": "", + "feign_url": "", + "microservice": "svc", + "module": "mod", + "filename": "R.java", + "start_line": 1, + "end_line": 1, + "resolved": True, + } + client_row = { + "id": "client:reason", + "client_kind": "feign_method", + "target_service": "reasonsvc", + "method": "GET", + "path": "/reason/path", + "path_template": "/reason/path", + "path_regex": "", + "member_fqn": "", + "member_id": "", + "microservice": "svc", + "module": "mod", + "filename": "C.java", + "start_line": 1, + "end_line": 1, + "resolved": True, + "source_layer": "builtin", + } + + class ReasonGraph: + def _rows(self, query: str, params: dict | None = None) -> list: + if "WHERE s.id = $id" in query: + return [sym_row] + if "WHERE s.fqn = $fqn" in query: + return [sym_row] + if "ENDS WITH $suffix" in query: + return [sym_row] + if "WHERE s.name = $name" in query: + return [sym_row] + if "WHERE r.id = $id" in query: + return [route_row] + if "r.method = $method" in query: + return [route_row] + if "r.path = $path OR r.path_template = $path" in query: + return [route_row] + if "WHERE c.id = $id" in query: + return [client_row] + if "STARTS WITH $path" in query: + return [client_row] + if "WHERE c.target_service = $target" in query: + return [client_row] + return [] + + g = ReasonGraph() # type: ignore[arg-type] + seen: set[str] = set() + for _node, reason, _spec in _resolve_symbol_candidates(g, "sym:reason"): + seen.add(reason) + for _node, reason, _spec in _resolve_symbol_candidates(g, "com.reason.Type"): + seen.add(reason) + for _node, reason, _spec in _resolve_symbol_candidates(g, "Type"): + seen.add(reason) + for _node, reason, _spec in _resolve_route_candidates(g, "route:reason"): + seen.add(reason) + for _node, reason, _spec in _resolve_route_candidates(g, "GET /reason"): + seen.add(reason) + for _node, reason, _spec in _resolve_route_candidates(g, "/reason"): + seen.add(reason) + for _node, reason, _spec in _resolve_client_candidates(g, "client:reason"): + seen.add(reason) + for _node, reason, _spec in _resolve_client_candidates(g, "reasonsvc"): + seen.add(reason) + for _node, reason, _spec in _resolve_client_candidates(g, "reasonsvc /reason"): + seen.add(reason) + + assert seen == set(VALID_RESOLVE_REASONS) + + +def test_resolve_success_output_invariants(kuzu_graph, kuzu_graph_fqn_collision_smoke) -> None: + one = resolve_v2( + "com.nonexistent.ZzzMissing", + hint_kind="symbol", + graph=kuzu_graph, + ) + assert one.success is True + assert one.status == "none" + assert one.node is None + assert one.candidates == [] + assert one.message + + many = resolve_v2( + "com.example.SharedDto#process()", + hint_kind="symbol", + graph=kuzu_graph_fqn_collision_smoke, + ) + assert many.success is True + assert many.status == "many" + assert many.node is None + assert len(many.candidates) >= 2 + + sym = find_v2("symbol", {"role": "CONTROLLER"}, graph=kuzu_graph, limit=1) + assert sym.success and sym.results + single = resolve_v2(sym.results[0].id, hint_kind="symbol", graph=kuzu_graph) + assert single.success is True + assert single.status == "one" + assert single.node is not None + assert single.candidates == [] + +