From da1f6e39dfed9eb4cf502487770f1234ae165774 Mon Sep 17 00:00:00 2001 From: Dmitry Teryaev Date: Wed, 13 May 2026 17:04:56 +0300 Subject: [PATCH] add describe override-axis edge_summary rollup for methods Co-authored-by: Cursor --- README.md | 2 +- docs/AGENT-GUIDE.md | 14 +- kuzu_queries.py | 77 ++++++++ mcp_v2.py | 12 +- server.py | 6 +- .../orolla/abstractroute/AbstractApi.java | 8 + .../orolla/abstractroute/ConcreteApi.java | 11 ++ .../main/java/orolla/diamond/DiamondA.java | 5 + .../main/java/orolla/diamond/DiamondB.java | 5 + .../main/java/orolla/diamond/DiamondC.java | 6 + tests/test_mcp_v2_compose.py | 181 ++++++++++++++++++ 11 files changed, 321 insertions(+), 6 deletions(-) create mode 100644 tests/fixtures/override_axis_rollup_smoke/src/main/java/orolla/abstractroute/AbstractApi.java create mode 100644 tests/fixtures/override_axis_rollup_smoke/src/main/java/orolla/abstractroute/ConcreteApi.java create mode 100644 tests/fixtures/override_axis_rollup_smoke/src/main/java/orolla/diamond/DiamondA.java create mode 100644 tests/fixtures/override_axis_rollup_smoke/src/main/java/orolla/diamond/DiamondB.java create mode 100644 tests/fixtures/override_axis_rollup_smoke/src/main/java/orolla/diamond/DiamondC.java diff --git a/README.md b/README.md index e9413a9..9f5fbaa 100644 --- a/README.md +++ b/README.md @@ -263,7 +263,7 @@ Edit `claude_desktop_config.json` (macOS: `~/Library/Application Support/Claude/ |---|---|---|---| | `search` | Locate nodes by NL/code text. | `query: str`, `table: str="java"`, `hybrid: bool=False`, `limit: int=5`, `offset: int=0`, `path_contains: str \| None`, `filter: NodeFilter \| str \| None` | `{"query":"join operator flow","limit":5}` | | `find` | Locate nodes by structured filter. | `kind: "symbol"\|"route"\|"client"`, `filter: NodeFilter \| str`, `limit: int=25`, `offset: int=0` | `{"kind":"symbol","filter":{"role":"CONTROLLER"}}` | -| `describe` | Full record + edge counts for one node. For **type** symbols, `edge_summary` may also include composed dot-keys (`DECLARES.DECLARES_CLIENT`, `DECLARES.EXPOSES`); see [`docs/AGENT-GUIDE.md`](./docs/AGENT-GUIDE.md) (`describe`). | `id: str` | `{"id":"sym:com.bank.chat.core.api.ChatController#joinOperator(JoinOperatorRequest)"}` | +| `describe` | Full record + edge counts for one node. For **type** symbols, `edge_summary` may include composed dot-keys (`DECLARES.DECLARES_CLIENT`, `DECLARES.EXPOSES`); for **method** symbols it may include override-axis virtual keys (`OVERRIDDEN_BY`, `OVERRIDDEN_BY.DECLARES_CLIENT`, `OVERRIDDEN_BY.EXPOSES`, `OVERRIDES`). See [`docs/AGENT-GUIDE.md`](./docs/AGENT-GUIDE.md) (`describe`). | `id: str` | `{"id":"sym:com.bank.chat.core.api.ChatController#joinOperator(JoinOperatorRequest)"}` | | `neighbors` | One-hop walk. **Required**: `direction` and `edge_types`. | `ids: str \| list[str]`, `direction: "in"\|"out"`, `edge_types: list[str]`, `limit: int=25`, `offset: int=0`, `filter: NodeFilter \| str \| None` | `{"ids":"route:chat-core:POST:/chat/joinOperator","direction":"in","edge_types":["HTTP_CALLS","ASYNC_CALLS"]}` | **`NodeFilter` notes:** diff --git a/docs/AGENT-GUIDE.md b/docs/AGENT-GUIDE.md index ad2e937..04a1987 100644 --- a/docs/AGENT-GUIDE.md +++ b/docs/AGENT-GUIDE.md @@ -195,7 +195,7 @@ Exact allowed values for roles, capabilities, client kinds, etc. live in `java_o #### `describe` -- **Purpose:** Full node payload + `edge_summary`: `in` / `out` counts **per stored graph edge label** (what exists as edges in Kuzu). For **type** Symbols only (`class`, `interface`, `enum`, `record`, `annotation`), the same map may also include **describe-time composed** dot-keys — summaries of member edges, not stored labels — see the next bullets (`DECLARES.DECLARES_CLIENT`, `DECLARES.EXPOSES`); those keys are **not** valid in `neighbors(edge_types=…)`. +- **Purpose:** Full node payload + `edge_summary`: `in` / `out` counts **per stored graph edge label** (what exists as edges in Kuzu). For **type** Symbols only (`class`, `interface`, `enum`, `record`, `annotation`), the same map may also include **describe-time composed** dot-keys — summaries of member edges, not stored labels — see the next bullets (`DECLARES.DECLARES_CLIENT`, `DECLARES.EXPOSES`); those keys are **not** valid in `neighbors(edge_types=…)`. For **method** Symbols, the map may include **override-axis** virtual keys (`OVERRIDDEN_BY`, `OVERRIDDEN_BY.DECLARES_CLIENT`, `OVERRIDDEN_BY.EXPOSES`, `OVERRIDES`); see **Override-axis keys (method Symbols)** below — also not `EdgeType` literals. - **Args:** `id` (symbol, route, or client id). **Composed `edge_summary` keys (type Symbols).** Keys use dot notation: `.`. Two are emitted today: @@ -207,6 +207,18 @@ Composed keys are **read-only**: they cannot be passed to `neighbors(edge_types= Note on counting semantics: composed counts measure **edge rows**, not distinct member methods. One method that declares multiple `Client` rows (e.g. a `rest_template` method with several call sites) contributes its full edge count to `DECLARES.DECLARES_CLIENT`. The "does this class have any clients?" predicate is answered by `count > 0`; the count itself is an affordance for how rich the downstream walk will be. +**Override-axis keys (method Symbols).** These name dispatch-axis virtual relations (computed at describe-time from `IMPLEMENTS` / `EXTENDS` plus matching `Symbol.signature`; not stored edges): + +- `OVERRIDDEN_BY` — on declarations reachable from implementing / extending classes in one hop: count of **distinct** concrete override methods with the same `signature` string as the described method (not counting the declaration itself). +- `OVERRIDDEN_BY.DECLARES_CLIENT` / `OVERRIDDEN_BY.EXPOSES` — same dispatch-down walk, then count outgoing `DECLARES_CLIENT` / `EXPOSES` edges from those override methods. Counts are **edge rows** on overrides (not distinct methods): one override with multiple client edges contributes the full row count. Omitted when zero. +- `OVERRIDES` — on a concrete method: count of **distinct** upstream declarations (interface / superclass methods with the same `signature`) one `IMPLEMENTS`/`EXTENDS` hop from the declaring class. A class implementing two interfaces that both declare the same signature yields `out: 2` (two declaration symbols). + +Walk recipe (declaration side): `neighbors(ids=, direction="in", edge_types=["DECLARES"])` → declaring type → `neighbors(ids=, direction="in", edge_types=["IMPLEMENTS","EXTENDS"])` → each subtype class → `neighbors(ids=, direction="out", edge_types=["DECLARES"])` and filter rows where `signature` matches the interface method. + +Static methods suppress the entire override-axis rollup. Constructors do not receive these keys. + +These keys are **not** valid `EdgeType` literals — `neighbors(edge_types=["OVERRIDDEN_BY"])` fails at the Pydantic boundary. Use them as hop affordances only. + #### `neighbors` - **Purpose:** One hop over explicit edge types; returns **edges** with attributes (`confidence`, `strategy`, `match`, …) and the **`other`** node. diff --git a/kuzu_queries.py b/kuzu_queries.py index 6a4293f..95fe39a 100644 --- a/kuzu_queries.py +++ b/kuzu_queries.py @@ -22,6 +22,17 @@ log = logging.getLogger(__name__) + +def _coerce_id_list(raw: Any) -> list[str]: + """Normalize Kuzu ``collect(DISTINCT ...)`` list results to string ids.""" + if raw is None: + return [] + if isinstance(raw, list): + return [str(x) for x in raw if x is not None and str(x) != ""] + s = str(raw) + return [s] if s else [] + + __all__ = [ "KuzuGraph", "resolve_kuzu_path", @@ -618,6 +629,72 @@ def member_edge_rollup_for(self, type_id: str) -> dict[str, dict[str, int]]: rollup[key] = {"in": 0, "out": n} return rollup + def _edge_row_count_from_method_ids(self, method_ids: list[str], rel: str) -> int: + """Count outgoing ``rel`` edges from method symbols (describe rollup helper).""" + total = 0 + for mid in method_ids: + rows = self._rows( + f"MATCH (x:Symbol {{id: $mid}})-[e:{rel}]->() RETURN count(e) AS n", + {"mid": mid}, + ) + total += int(rows[0].get("n") or 0) if rows else 0 + return total + + def override_axis_rollup_for(self, method_id: str) -> dict[str, dict[str, int]]: + """Dispatch-axis composed keys for method Symbols (describe-time only). + + Uses one-hop ``IMPLEMENTS`` / ``EXTENDS`` class edges plus ``Symbol.signature`` + equality. Omits keys with zero counts (same convention as ``edge_counts_for``). + Returns ``{}`` for non-methods, constructors (caller should skip), and static methods. + """ + params = {"id": method_id} + gate = self._rows( + "MATCH (m:Symbol {id: $id}) " + "WHERE m.kind = 'method' " + "AND NOT list_contains(COALESCE(m.modifiers, []), 'static') " + "RETURN 1 AS ok LIMIT 1", + params, + ) + if not gate: + return {} + + rollup: dict[str, dict[str, int]] = {} + + down_rows = self._rows( + "MATCH (m:Symbol {id: $id})<-[:DECLARES]-(t:Symbol) " + "MATCH (impl:Symbol)-[:IMPLEMENTS|EXTENDS]->(t) " + "MATCH (impl)-[:DECLARES]->(mover:Symbol) " + "WHERE mover.signature = m.signature AND mover.id <> m.id " + "RETURN collect(DISTINCT mover.id) AS ids", + params, + ) + impl_ids = _coerce_id_list(down_rows[0].get("ids") if down_rows else None) + + if impl_ids: + distinct_impl = list(dict.fromkeys(impl_ids)) + rollup["OVERRIDDEN_BY"] = {"in": 0, "out": len(distinct_impl)} + n_dc = self._edge_row_count_from_method_ids(distinct_impl, "DECLARES_CLIENT") + if n_dc > 0: + rollup["OVERRIDDEN_BY.DECLARES_CLIENT"] = {"in": 0, "out": n_dc} + n_ex = self._edge_row_count_from_method_ids(distinct_impl, "EXPOSES") + if n_ex > 0: + rollup["OVERRIDDEN_BY.EXPOSES"] = {"in": 0, "out": n_ex} + + up_rows = self._rows( + "MATCH (m:Symbol {id: $id})<-[:DECLARES]-(impl:Symbol) " + "MATCH (impl)-[:IMPLEMENTS|EXTENDS]->(parent:Symbol) " + "MATCH (parent)-[:DECLARES]->(decl_m:Symbol) " + "WHERE decl_m.signature = m.signature AND decl_m.id <> m.id " + "RETURN collect(DISTINCT decl_m.id) AS ids", + params, + ) + decl_ids = _coerce_id_list(up_rows[0].get("ids") if up_rows else None) + if decl_ids: + distinct_decl = list(dict.fromkeys(decl_ids)) + rollup["OVERRIDES"] = {"in": 0, "out": len(distinct_decl)} + + return rollup + def _scope_counts(self, column: str) -> dict[str, int]: """Generic helper: count resolved type symbols grouped by `column`. diff --git a/mcp_v2.py b/mcp_v2.py index 36e9919..df7e34f 100644 --- a/mcp_v2.py +++ b/mcp_v2.py @@ -41,6 +41,8 @@ {"class", "interface", "enum", "record", "annotation"} ) +_METHOD_SYMBOL_KINDS_FOR_OVERRIDE_ROLLUP = frozenset({"method"}) + def _get_sentence_transformer(model_name: str, device: str | None) -> SentenceTransformer: global _st_model @@ -129,7 +131,10 @@ class NodeRecord(BaseModel): "enum, record, annotation), may also include composed dot-keys " "`DECLARES.DECLARES_CLIENT` and `DECLARES.EXPOSES`: 2-hop summaries " "(DECLARES to member, then that edge) — edge-row counts, not EdgeType literals; " - "do not pass them to neighbors(edge_types=…)." + "do not pass them to neighbors(edge_types=…). For method Symbols, may include " + "override-axis virtual keys `OVERRIDDEN_BY`, `OVERRIDDEN_BY.DECLARES_CLIENT`, " + "`OVERRIDDEN_BY.EXPOSES`, and `OVERRIDES` (same dot convention; also not valid " + "EdgeType literals for neighbors)." ), ) @@ -335,8 +340,11 @@ def _edge_summary_for_node( graph: KuzuGraph, node_id: str, *, kind: str, row: dict[str, Any] ) -> dict[str, dict[str, int]]: summary = dict(graph.edge_counts_for(node_id)) - if kind == "symbol" and str(row.get("kind") or "") in _TYPE_SYMBOL_KINDS_FOR_EDGE_ROLLUP: + sym_kind = str(row.get("kind") or "") + if kind == "symbol" and sym_kind in _TYPE_SYMBOL_KINDS_FOR_EDGE_ROLLUP: summary.update(graph.member_edge_rollup_for(node_id)) + elif kind == "symbol" and sym_kind in _METHOD_SYMBOL_KINDS_FOR_OVERRIDE_ROLLUP: + summary.update(graph.override_axis_rollup_for(node_id)) return summary diff --git a/server.py b/server.py index b8e2b80..c8afc55 100644 --- a/server.py +++ b/server.py @@ -19,7 +19,7 @@ _COCOINDEX_TARGET = "java_index_flow_lancedb.py:JavaCodeIndexLance" _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), " + "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). " "NodeFilter `filter` is a JSON object (preferred); a JSON-encoded string is also accepted as a fallback. " "Edge labels: EXTENDS, IMPLEMENTS, INJECTS, DECLARES, DECLARES_CLIENT, CALLS, EXPOSES, HTTP_CALLS, ASYNC_CALLS. " @@ -333,7 +333,9 @@ async def find( description=( "full record + edge_summary: in/out per stored edge label; " "type Symbols may add composed keys DECLARES.DECLARES_CLIENT, DECLARES.EXPOSES " - "(describe-time 2-hop member summaries; not valid in neighbors edge_types)" + "(describe-time 2-hop member summaries; not valid in neighbors edge_types); " + "method Symbols may add override-axis virtual keys OVERRIDDEN_BY, " + "OVERRIDDEN_BY.DECLARES_CLIENT, OVERRIDDEN_BY.EXPOSES, OVERRIDES (same restriction)" ), ) async def describe( diff --git a/tests/fixtures/override_axis_rollup_smoke/src/main/java/orolla/abstractroute/AbstractApi.java b/tests/fixtures/override_axis_rollup_smoke/src/main/java/orolla/abstractroute/AbstractApi.java new file mode 100644 index 0000000..b2f1a03 --- /dev/null +++ b/tests/fixtures/override_axis_rollup_smoke/src/main/java/orolla/abstractroute/AbstractApi.java @@ -0,0 +1,8 @@ +package orolla.abstractroute; + +import org.springframework.web.bind.annotation.RequestMapping; + +public abstract class AbstractApi { + @RequestMapping("/api") + public abstract void handle(); +} diff --git a/tests/fixtures/override_axis_rollup_smoke/src/main/java/orolla/abstractroute/ConcreteApi.java b/tests/fixtures/override_axis_rollup_smoke/src/main/java/orolla/abstractroute/ConcreteApi.java new file mode 100644 index 0000000..ef8a7dd --- /dev/null +++ b/tests/fixtures/override_axis_rollup_smoke/src/main/java/orolla/abstractroute/ConcreteApi.java @@ -0,0 +1,11 @@ +package orolla.abstractroute; + +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +public class ConcreteApi extends AbstractApi { + @Override + @PostMapping("/do") + public void handle() {} +} diff --git a/tests/fixtures/override_axis_rollup_smoke/src/main/java/orolla/diamond/DiamondA.java b/tests/fixtures/override_axis_rollup_smoke/src/main/java/orolla/diamond/DiamondA.java new file mode 100644 index 0000000..ffcd600 --- /dev/null +++ b/tests/fixtures/override_axis_rollup_smoke/src/main/java/orolla/diamond/DiamondA.java @@ -0,0 +1,5 @@ +package orolla.diamond; + +public interface DiamondA { + void shared(); +} diff --git a/tests/fixtures/override_axis_rollup_smoke/src/main/java/orolla/diamond/DiamondB.java b/tests/fixtures/override_axis_rollup_smoke/src/main/java/orolla/diamond/DiamondB.java new file mode 100644 index 0000000..03a4341 --- /dev/null +++ b/tests/fixtures/override_axis_rollup_smoke/src/main/java/orolla/diamond/DiamondB.java @@ -0,0 +1,5 @@ +package orolla.diamond; + +public interface DiamondB { + void shared(); +} diff --git a/tests/fixtures/override_axis_rollup_smoke/src/main/java/orolla/diamond/DiamondC.java b/tests/fixtures/override_axis_rollup_smoke/src/main/java/orolla/diamond/DiamondC.java new file mode 100644 index 0000000..6b4a248 --- /dev/null +++ b/tests/fixtures/override_axis_rollup_smoke/src/main/java/orolla/diamond/DiamondC.java @@ -0,0 +1,6 @@ +package orolla.diamond; + +public class DiamondC implements DiamondA, DiamondB { + @Override + public void shared() {} +} diff --git a/tests/test_mcp_v2_compose.py b/tests/test_mcp_v2_compose.py index f0d5476..3f5379e 100644 --- a/tests/test_mcp_v2_compose.py +++ b/tests/test_mcp_v2_compose.py @@ -1,7 +1,13 @@ from __future__ import annotations +from pathlib import Path from typing import Any +import pytest +from pydantic import ValidationError + +from _builders import build_kuzu_to +from kuzu_queries import KuzuGraph from mcp_v2 import ( _TYPE_SYMBOL_KINDS_FOR_EDGE_ROLLUP, describe_v2, @@ -25,6 +31,82 @@ _ROLLUP_TYPE_KINDS = sorted(_TYPE_SYMBOL_KINDS_FOR_EDGE_ROLLUP) +_OVERRIDE_AXIS_FIXTURE = Path(__file__).resolve().parent / "fixtures" / "override_axis_rollup_smoke" + + +@pytest.fixture +def override_axis_graph(tmp_path: Path) -> KuzuGraph: + db_path = tmp_path / "code_graph.kuzu" + build_kuzu_to(_OVERRIDE_AXIS_FIXTURE, db_path, max_pass=5) + return KuzuGraph(str(db_path)) + + +def _collect_ids(cell: Any) -> list[str]: + if cell is None: + return [] + if isinstance(cell, list): + return [str(x) for x in cell if x is not None and str(x) != ""] + s = str(cell) + return [s] if s else [] + + +def _dispatch_down_override_method_ids(graph: KuzuGraph, method_id: str) -> list[str]: + rows = graph._rows( # noqa: SLF001 + "MATCH (m:Symbol {id: $id})<-[:DECLARES]-(t:Symbol) " + "MATCH (impl:Symbol)-[:IMPLEMENTS|EXTENDS]->(t) " + "MATCH (impl)-[:DECLARES]->(mover:Symbol) " + "WHERE mover.signature = m.signature AND mover.id <> m.id " + "RETURN collect(DISTINCT mover.id) AS ids", + {"id": method_id}, + ) + if not rows: + return [] + return list(dict.fromkeys(_collect_ids(rows[0].get("ids")))) + + +def _dispatch_up_declaration_method_ids(graph: KuzuGraph, method_id: str) -> list[str]: + rows = graph._rows( # noqa: SLF001 + "MATCH (m:Symbol {id: $id})<-[:DECLARES]-(impl:Symbol) " + "MATCH (impl)-[:IMPLEMENTS|EXTENDS]->(parent:Symbol) " + "MATCH (parent)-[:DECLARES]->(decl_m:Symbol) " + "WHERE decl_m.signature = m.signature AND decl_m.id <> m.id " + "RETURN collect(DISTINCT decl_m.id) AS ids", + {"id": method_id}, + ) + if not rows: + return [] + return list(dict.fromkeys(_collect_ids(rows[0].get("ids")))) + + +def _edge_row_count_from_methods(graph: KuzuGraph, method_ids: list[str], rel: str) -> int: + total = 0 + for mid in method_ids: + rows = graph._rows( # noqa: SLF001 + f"MATCH (x:Symbol {{id: $mid}})-[e:{rel}]->() RETURN count(e) AS n", + {"mid": mid}, + ) + total += int(rows[0].get("n") or 0) if rows else 0 + return total + + +def _method_id_without_dispatch_rollups(kuzu_graph: KuzuGraph) -> str: + rows = kuzu_graph._rows( # noqa: SLF001 + "MATCH (m:Symbol) " + "WHERE m.kind = 'method' " + "AND NOT list_contains(COALESCE(m.modifiers, []), 'static') " + "AND NOT EXISTS { " + "MATCH (m)<-[:DECLARES]-(t:Symbol), (impl:Symbol)-[:IMPLEMENTS|EXTENDS]->(t), " + "(impl)-[:DECLARES]->(mover:Symbol) " + "WHERE mover.signature = m.signature AND mover.id <> m.id } " + "AND NOT EXISTS { " + "MATCH (m)<-[:DECLARES]-(impl:Symbol), (impl)-[:IMPLEMENTS|EXTENDS]->(parent:Symbol), " + "(parent)-[:DECLARES]->(decl:Symbol) " + "WHERE decl.signature = m.signature AND decl.id <> m.id } " + "RETURN m.id AS id LIMIT 1", + ) + assert rows + return str(rows[0]["id"]) + def _controller_method_with_calls(kuzu_graph) -> tuple[str, str]: rows = kuzu_graph._rows( # noqa: SLF001 @@ -258,3 +340,102 @@ def test_describe_pojo_no_composed_keys(kuzu_graph) -> None: es = out.record.edge_summary assert "DECLARES.DECLARES_CLIENT" not in es assert "DECLARES.EXPOSES" not in es + + +def test_describe_interface_method_with_annotated_impl_emits_rollup(kuzu_graph) -> None: + rows = kuzu_graph._rows( # noqa: SLF001 + "MATCH (iface:Symbol {fqn: $fqn})-[:DECLARES]->(m:Symbol) " + "WHERE m.kind = 'method' AND m.name = 'requestAssignment' " + "RETURN m.id AS id LIMIT 1", + {"fqn": "com.bank.chat.engine.assign.ChatAssignmentPort"}, + ) + assert rows + mid = str(rows[0]["id"]) + impl_ids = _dispatch_down_override_method_ids(kuzu_graph, mid) + assert impl_ids + want_ob = len(impl_ids) + want_dc = _edge_row_count_from_methods(kuzu_graph, impl_ids, "DECLARES_CLIENT") + out = describe_v2(mid, graph=kuzu_graph) + assert out.success is True + assert out.record is not None + assert out.record.edge_summary is not None + es = out.record.edge_summary + assert es.get("OVERRIDDEN_BY") == {"in": 0, "out": want_ob} + assert es.get("OVERRIDDEN_BY.DECLARES_CLIENT") == {"in": 0, "out": want_dc} + with pytest.raises(ValidationError): + neighbors_v2(mid, direction="out", edge_types=["OVERRIDDEN_BY"], graph=kuzu_graph) + + +def test_describe_concrete_override_emits_overrides_rollup(kuzu_graph) -> None: + rows = kuzu_graph._rows( # noqa: SLF001 + "MATCH (t:Symbol {fqn: $fqn})-[:DECLARES]->(m:Symbol) " + "WHERE m.kind = 'method' AND m.name = 'requestAssignment' " + "RETURN m.id AS id LIMIT 1", + {"fqn": "com.bank.chat.engine.assign.ConfigurableChatAssignment"}, + ) + assert rows + mid = str(rows[0]["id"]) + decl_ids = _dispatch_up_declaration_method_ids(kuzu_graph, mid) + assert decl_ids + want_ov = len(decl_ids) + out = describe_v2(mid, graph=kuzu_graph) + assert out.success is True + assert out.record is not None + assert out.record.edge_summary is not None + assert out.record.edge_summary.get("OVERRIDES") == {"in": 0, "out": want_ov} + + +def test_describe_method_no_overrides_silent(kuzu_graph) -> None: + mid = _method_id_without_dispatch_rollups(kuzu_graph) + out = describe_v2(mid, graph=kuzu_graph) + assert out.success is True + assert out.record is not None + assert out.record.edge_summary is not None + es = out.record.edge_summary + assert "OVERRIDDEN_BY" not in es + assert "OVERRIDDEN_BY.DECLARES_CLIENT" not in es + assert "OVERRIDDEN_BY.EXPOSES" not in es + assert "OVERRIDES" not in es + + +def test_describe_abstract_method_with_route_override_emits_exposes(override_axis_graph: KuzuGraph) -> None: + rows = override_axis_graph._rows( # noqa: SLF001 + "MATCH (t:Symbol {fqn: $fqn})-[:DECLARES]->(m:Symbol) " + "WHERE m.kind = 'method' AND m.name = 'handle' " + "RETURN m.id AS id LIMIT 1", + {"fqn": "orolla.abstractroute.AbstractApi"}, + ) + assert rows + mid = str(rows[0]["id"]) + impl_ids = _dispatch_down_override_method_ids(override_axis_graph, mid) + assert impl_ids + want_ob = len(impl_ids) + want_ex = _edge_row_count_from_methods(override_axis_graph, impl_ids, "EXPOSES") + assert want_ex >= 1 + out = describe_v2(mid, graph=override_axis_graph) + assert out.success is True + assert out.record is not None + assert out.record.edge_summary is not None + es = out.record.edge_summary + assert es.get("OVERRIDDEN_BY") == {"in": 0, "out": want_ob} + assert es.get("OVERRIDDEN_BY.EXPOSES") == {"in": 0, "out": want_ex} + + +def test_describe_interface_method_diamond_override_counts_once_per_upstream( + override_axis_graph: KuzuGraph, +) -> None: + rows = override_axis_graph._rows( # noqa: SLF001 + "MATCH (t:Symbol {fqn: $fqn})-[:DECLARES]->(m:Symbol) " + "WHERE m.kind = 'method' AND m.name = 'shared' " + "RETURN m.id AS id LIMIT 1", + {"fqn": "orolla.diamond.DiamondC"}, + ) + assert rows + mid = str(rows[0]["id"]) + decl_ids = _dispatch_up_declaration_method_ids(override_axis_graph, mid) + assert len(decl_ids) == 2 + out = describe_v2(mid, graph=override_axis_graph) + assert out.success is True + assert out.record is not None + assert out.record.edge_summary is not None + assert out.record.edge_summary.get("OVERRIDES") == {"in": 0, "out": 2}