From 4ef1c2cb119c8009e27f2f3746f1b01065f235bf Mon Sep 17 00:00:00 2001 From: Dmitry Teryaev Date: Sat, 16 May 2026 22:05:35 +0300 Subject: [PATCH 1/2] add describe-time producer and override-route hints Close #161: emit symmetric road-sign hints for DECLARES_PRODUCER, DECLARES.DECLARES_PRODUCER, OVERRIDDEN_BY.DECLARES_PRODUCER, and OVERRIDDEN_BY.EXPOSES rollups plus producer-node declaring-method hint. Co-authored-by: Cursor --- mcp_hints.py | 31 +++++++++++++++ tests/test_mcp_hints.py | 88 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 119 insertions(+) diff --git a/mcp_hints.py b/mcp_hints.py index fcf96b3..2e24aad 100644 --- a/mcp_hints.py +++ b/mcp_hints.py @@ -31,16 +31,30 @@ "routes via members: neighbors(['{id}'],'out',['DECLARES']) " "then neighbors(member_ids,'out',['EXPOSES'])" ) +TPL_DESCRIBE_TYPE_PRODUCERS_VIA_MEMBERS = ( + "producers via members: neighbors(['{id}'],'out',['DECLARES']) " + "then neighbors(member_ids,'out',['DECLARES_PRODUCER'])" +) TPL_DESCRIBE_METHOD_OVERRIDERS = "overriders: neighbors(['{id}'],'in',['OVERRIDES'])" TPL_DESCRIBE_METHOD_CLIENTS_IN_OVERRIDERS = ( "clients in overriders: neighbors(['{id}'],'in',['OVERRIDES']) " "then neighbors(overrider_ids,'out',['DECLARES_CLIENT'])" ) +TPL_DESCRIBE_METHOD_PRODUCERS_IN_OVERRIDERS = ( + "producers in overriders: neighbors(['{id}'],'in',['OVERRIDES']) " + "then neighbors(overrider_ids,'out',['DECLARES_PRODUCER'])" +) +TPL_DESCRIBE_METHOD_ROUTES_IN_OVERRIDERS = ( + "routes in overriders: neighbors(['{id}'],'in',['OVERRIDES']) " + "then neighbors(overrider_ids,'out',['EXPOSES'])" +) TPL_DESCRIBE_METHOD_OUTBOUND_CLIENT = "outbound client: neighbors(['{id}'],'out',['DECLARES_CLIENT'])" +TPL_DESCRIBE_METHOD_OUTBOUND_PRODUCER = "outbound producer: neighbors(['{id}'],'out',['DECLARES_PRODUCER'])" TPL_DESCRIBE_METHOD_INBOUND_ROUTE = "inbound route: neighbors(['{id}'],'out',['EXPOSES'])" TPL_DESCRIBE_METHOD_MANY_CALLS = "many CALLS — consider filtering by target microservice" TPL_DESCRIBE_ROUTE_DECLARING = "declaring method: neighbors(['{id}'],'in',['EXPOSES'])" TPL_DESCRIBE_CLIENT_DECLARING = "declaring method: neighbors(['{id}'],'in',['DECLARES_CLIENT'])" +TPL_DESCRIBE_PRODUCER_DECLARING = "declaring method: neighbors(['{id}'],'in',['DECLARES_PRODUCER'])" TPL_FIND_EMPTY_RESOLVE = "no matches — try resolve(identifier, hint_kind='{kind}') for canonical lookup" TPL_FIND_PAGE_FULL = "result page full at {limit} — narrow filter or paginate" @@ -418,6 +432,9 @@ def generate_hints( if kind == "client": pairs.append((PRIORITY_LEAF_FOLLOWUP, TPL_DESCRIBE_CLIENT_DECLARING.format(id=node_id))) return finalize_hint_list(pairs) + if kind == "producer": + pairs.append((PRIORITY_LEAF_FOLLOWUP, TPL_DESCRIBE_PRODUCER_DECLARING.format(id=node_id))) + return finalize_hint_list(pairs) if kind != "symbol": return finalize_hint_list(pairs) @@ -435,6 +452,10 @@ def generate_hints( pairs.append( (PRIORITY_DECLARES_TYPE_ROLLUP, TPL_DESCRIBE_TYPE_ROUTES_VIA_MEMBERS.format(id=node_id)) ) + if _out_count(edge_summary, "DECLARES.DECLARES_PRODUCER") > 0: + pairs.append( + (PRIORITY_DECLARES_TYPE_ROLLUP, TPL_DESCRIBE_TYPE_PRODUCERS_VIA_MEMBERS.format(id=node_id)) + ) return finalize_hint_list(pairs) if is_method: @@ -444,8 +465,18 @@ def generate_hints( pairs.append( (PRIORITY_OVERRIDDEN_AXIS, TPL_DESCRIBE_METHOD_CLIENTS_IN_OVERRIDERS.format(id=node_id)) ) + if _out_count(edge_summary, "OVERRIDDEN_BY.DECLARES_PRODUCER") > 0: + pairs.append( + (PRIORITY_OVERRIDDEN_AXIS, TPL_DESCRIBE_METHOD_PRODUCERS_IN_OVERRIDERS.format(id=node_id)) + ) + if _out_count(edge_summary, "OVERRIDDEN_BY.EXPOSES") > 0: + pairs.append( + (PRIORITY_OVERRIDDEN_AXIS, TPL_DESCRIBE_METHOD_ROUTES_IN_OVERRIDERS.format(id=node_id)) + ) if _out_count(edge_summary, "DECLARES_CLIENT") > 0: pairs.append((PRIORITY_LEAF_FOLLOWUP, TPL_DESCRIBE_METHOD_OUTBOUND_CLIENT.format(id=node_id))) + if _out_count(edge_summary, "DECLARES_PRODUCER") > 0: + pairs.append((PRIORITY_LEAF_FOLLOWUP, TPL_DESCRIBE_METHOD_OUTBOUND_PRODUCER.format(id=node_id))) if _out_count(edge_summary, "EXPOSES") > 0: pairs.append((PRIORITY_LEAF_FOLLOWUP, TPL_DESCRIBE_METHOD_INBOUND_ROUTE.format(id=node_id))) if _out_count(edge_summary, "CALLS") >= 10: diff --git a/tests/test_mcp_hints.py b/tests/test_mcp_hints.py index 42f032d..1b2c5ee 100644 --- a/tests/test_mcp_hints.py +++ b/tests/test_mcp_hints.py @@ -2,12 +2,15 @@ import inspect from collections import Counter +from pathlib import Path from typing import Any import pytest import mcp_hints +from _builders import build_kuzu_to from java_ontology import EDGE_SCHEMA, FUZZY_STRATEGY_SET +from kuzu_queries import KuzuGraph from mcp_hints import ( PRIORITY_DECLARES_TYPE_ROLLUP, PRIORITY_LEAF_FOLLOWUP, @@ -21,6 +24,15 @@ _TYPE_KINDS = frozenset({"class", "interface", "enum", "record", "annotation"}) +_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 _type_symbol_id_with_member_clients(kuzu_graph) -> str: rows = kuzu_graph._rows( # noqa: SLF001 @@ -43,6 +55,18 @@ def _controller_class_id_with_exposes(kuzu_graph) -> str: return str(rows[0]["id"]) +def _type_symbol_id_with_member_producers(kuzu_graph) -> str: + rows = kuzu_graph._rows( # noqa: SLF001 + "MATCH (t:Symbol)-[:DECLARES]->(m:Symbol)-[:DECLARES_PRODUCER]->(:Producer) " + "WHERE t.kind IN $kinds " + "RETURN t.id AS id ORDER BY t.fqn LIMIT 1", + {"kinds": sorted(_TYPE_KINDS)}, + ) + if not rows: + pytest.skip("no type with DECLARES_PRODUCER members in fixture") + return str(rows[0]["id"]) + + def _interface_method_with_override_rollups(kuzu_graph) -> str: rows = kuzu_graph._rows( # noqa: SLF001 "MATCH (iface:Symbol {fqn: $fqn})-[:DECLARES]->(m:Symbol) " @@ -151,6 +175,14 @@ def test_hints_describe_type_symbol_routes_via_members_emits(kuzu_graph) -> None assert want in out.hints +def test_hints_describe_type_symbol_producers_via_members_emits(kuzu_graph) -> None: + tid = _type_symbol_id_with_member_producers(kuzu_graph) + out = describe_v2(tid, graph=kuzu_graph) + assert out.success and out.record + want = mcp_hints.TPL_DESCRIBE_TYPE_PRODUCERS_VIA_MEMBERS.format(id=tid) + assert want in out.hints + + def test_hints_describe_method_overriders_emits(kuzu_graph) -> None: mid = _interface_method_with_override_rollups(kuzu_graph) out = describe_v2(mid, graph=kuzu_graph) @@ -167,6 +199,35 @@ def test_hints_describe_method_clients_in_overriders_emits(kuzu_graph) -> None: assert want in out.hints +def test_hints_describe_method_producers_in_overriders_emits() -> None: + node_id = "sym:com.example.T#m()" + rec = { + "id": node_id, + "kind": "symbol", + "fqn": "com.example.T#m()", + "data": {"kind": "method"}, + "edge_summary": {"OVERRIDDEN_BY.DECLARES_PRODUCER": {"in": 0, "out": 1}}, + } + hints = generate_hints("describe", {"success": True, "record": rec}) + want = mcp_hints.TPL_DESCRIBE_METHOD_PRODUCERS_IN_OVERRIDERS.format(id=node_id) + assert want in hints + + +def test_hints_describe_method_routes_in_overriders_emits(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"]) + out = describe_v2(mid, graph=override_axis_graph) + assert out.success and out.record + want = mcp_hints.TPL_DESCRIBE_METHOD_ROUTES_IN_OVERRIDERS.format(id=mid) + assert want in out.hints + + def test_hints_describe_method_declares_client_emits(kuzu_graph) -> None: mid = _method_declares_client(kuzu_graph) out = describe_v2(mid, graph=kuzu_graph) @@ -187,6 +248,20 @@ def test_hints_describe_method_exposes_emits(kuzu_graph) -> None: assert want in out.hints +def test_hints_describe_method_declares_producer_emits(kuzu_graph) -> None: + rows = kuzu_graph._rows( # noqa: SLF001 + "MATCH (m:Symbol)-[:DECLARES_PRODUCER]->(:Producer) WHERE m.kind = 'method' " + "RETURN m.id AS id LIMIT 1", + ) + if not rows: + pytest.skip("no method with DECLARES_PRODUCER in fixture") + mid = str(rows[0]["id"]) + out = describe_v2(mid, graph=kuzu_graph) + assert out.success and out.record + want = mcp_hints.TPL_DESCRIBE_METHOD_OUTBOUND_PRODUCER.format(id=mid) + assert want in out.hints + + def test_hints_describe_method_many_calls_emits(kuzu_graph) -> None: mid = _controller_method_many_calls(kuzu_graph) out = describe_v2(mid, graph=kuzu_graph) @@ -210,6 +285,14 @@ def test_hints_describe_client_always_declaring_method(kuzu_graph) -> None: assert out.hints == [want] +def test_hints_describe_producer_always_declaring_method(kuzu_graph) -> None: + pid = _producer_id(kuzu_graph) + out = describe_v2(pid, graph=kuzu_graph) + assert out.success and out.record + want = mcp_hints.TPL_DESCRIBE_PRODUCER_DECLARING.format(id=pid) + assert out.hints == [want] + + def test_hints_find_empty_identifier_filter_suggests_resolve(kuzu_graph) -> None: out = find_v2("client", {"target_service": "__no_such_target_service__"}, graph=kuzu_graph) assert out.success is True @@ -1138,13 +1221,18 @@ def test_hints_pagination_none_skips_page_derived_hints() -> None: [ (mcp_hints.TPL_DESCRIBE_TYPE_CLIENTS_VIA_MEMBERS, {"id": "sym:a"}), (mcp_hints.TPL_DESCRIBE_TYPE_ROUTES_VIA_MEMBERS, {"id": "sym:a"}), + (mcp_hints.TPL_DESCRIBE_TYPE_PRODUCERS_VIA_MEMBERS, {"id": "sym:a"}), (mcp_hints.TPL_DESCRIBE_METHOD_OVERRIDERS, {"id": "sym:a"}), (mcp_hints.TPL_DESCRIBE_METHOD_CLIENTS_IN_OVERRIDERS, {"id": "sym:a"}), + (mcp_hints.TPL_DESCRIBE_METHOD_PRODUCERS_IN_OVERRIDERS, {"id": "a"}), + (mcp_hints.TPL_DESCRIBE_METHOD_ROUTES_IN_OVERRIDERS, {"id": "sym:a"}), (mcp_hints.TPL_DESCRIBE_METHOD_OUTBOUND_CLIENT, {"id": "sym:pkg.Type#m(int)"}), + (mcp_hints.TPL_DESCRIBE_METHOD_OUTBOUND_PRODUCER, {"id": "sym:pkg.Type#m(int)"}), (mcp_hints.TPL_DESCRIBE_METHOD_INBOUND_ROUTE, {"id": "sym:pkg.Type#m(int)"}), (mcp_hints.TPL_DESCRIBE_METHOD_MANY_CALLS, {}), (mcp_hints.TPL_DESCRIBE_ROUTE_DECLARING, {"id": "route:svc:GET:/api/v1/x"}), (mcp_hints.TPL_DESCRIBE_CLIENT_DECLARING, {"id": "client:svc:feign:target:GET:/p"}), + (mcp_hints.TPL_DESCRIBE_PRODUCER_DECLARING, {"id": "producer:svc:kafka:topic:t"}), (mcp_hints.TPL_FIND_EMPTY_RESOLVE, {"kind": "client"}), (mcp_hints.TPL_FIND_PAGE_FULL, {"limit": 500}), ( From a2f2697f1f0741eec5abef24bdfe76afdb8c3f8d Mon Sep 17 00:00:00 2001 From: Dmitry Teryaev Date: Sat, 16 May 2026 22:17:02 +0300 Subject: [PATCH 2/2] address PR #164 review: e2e producer override coverage and doc sync Extend override_axis_rollup_smoke with abstract/concrete Kafka producer override; replace synthetic producers-in-overriders hint test with describe_v2 e2e. Amend HINTS-ROAD-SIGNS Appendix A and AGENT-GUIDE for DECLARES_PRODUCER / OVERRIDDEN_BY.DECLARES_PRODUCER parity. Co-authored-by: Cursor --- docs/AGENT-GUIDE.md | 7 +++--- mcp_hints.py | 3 ++- propose/completed/HINTS-ROAD-SIGNS-PROPOSE.md | 7 ++++++ .../abstractproducer/AbstractProducerApi.java | 5 ++++ .../abstractproducer/ConcreteProducerApi.java | 16 ++++++++++++ tests/test_mcp_hints.py | 25 ++++++++++--------- tests/test_mcp_v2_compose.py | 25 +++++++++++++++++++ 7 files changed, 72 insertions(+), 16 deletions(-) create mode 100644 tests/fixtures/override_axis_rollup_smoke/src/main/java/orolla/abstractproducer/AbstractProducerApi.java create mode 100644 tests/fixtures/override_axis_rollup_smoke/src/main/java/orolla/abstractproducer/ConcreteProducerApi.java diff --git a/docs/AGENT-GUIDE.md b/docs/AGENT-GUIDE.md index 1efacc8..c30bf2f 100644 --- a/docs/AGENT-GUIDE.md +++ b/docs/AGENT-GUIDE.md @@ -225,12 +225,13 @@ 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=…)`. For **method** Symbols, the map may include **override-axis virtual keys** (`OVERRIDDEN_BY`, `OVERRIDDEN_BY.DECLARES_CLIENT`, `OVERRIDDEN_BY.EXPOSES`) plus an **`OVERRIDES` row** that merges stored `[:OVERRIDES]` incident counts with the describe-time dispatch-up rollup (per direction `max`, so inbound stored overrides are preserved); see **Override-axis keys (method Symbols)** below — those virtual keys are **not** `neighbors` arguments. The **stored** relationship label **`OVERRIDES`** **is** a valid `EdgeType` for `neighbors` (same spelling as the map key; the map row is the merged view). +- **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.DECLARES_PRODUCER`, `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.DECLARES_PRODUCER`, `OVERRIDDEN_BY.EXPOSES`) plus an **`OVERRIDES` row** that merges stored `[:OVERRIDES]` incident counts with the describe-time dispatch-up rollup (per direction `max`, so inbound stored overrides are preserved); see **Override-axis keys (method Symbols)** below — those virtual keys are **not** `neighbors` arguments. The **stored** relationship label **`OVERRIDES`** **is** a valid `EdgeType` for `neighbors` (same spelling as the map key; the map row is the merged view). - **Args:** `id` (symbol, route, or client id) or **`fqn`** (exact symbol FQN when you do not have the graph id). When both are set, `id` wins. For identifier-shaped inputs and FQN collision handling, see **Identifier resolution** above. -**Composed `edge_summary` keys (type Symbols).** Keys use dot notation: `.`. Two are emitted today: +**Composed `edge_summary` keys (type Symbols).** Keys use dot notation: `.`. Three are emitted today: - `DECLARES.DECLARES_CLIENT` — the type's methods declare brownfield HTTP clients (count is the number of `Client` rows reached through `DECLARES → DECLARES_CLIENT`). To enumerate them: `neighbors(ids=, direction="out", edge_types=["DECLARES"])` → for each method id, `neighbors(ids=, direction="out", edge_types=["DECLARES_CLIENT"])`. +- `DECLARES.DECLARES_PRODUCER` — the type's methods declare async producers. Same walk shape with `DECLARES_PRODUCER`. - `DECLARES.EXPOSES` — the type's methods expose routes. Same walk shape with `EXPOSES`. Composed keys are **read-only**: they cannot be passed to `neighbors(edge_types=…)` (the dot is not a valid `EdgeType` literal — the call fails with a Pydantic `ValidationError`). Use them as a hop affordance only. @@ -240,7 +241,7 @@ Note on counting semantics: composed counts measure **edge rows**, not distinct **Override-axis keys (method Symbols).** Dispatch-axis signals computed at describe-time from `IMPLEMENTS` / `EXTENDS` plus matching `Symbol.signature` (not stored as their own rel types): - `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. +- `OVERRIDDEN_BY.DECLARES_CLIENT` / `OVERRIDDEN_BY.DECLARES_PRODUCER` / `OVERRIDDEN_BY.EXPOSES` — same dispatch-down walk, then count outgoing `DECLARES_CLIENT` / `DECLARES_PRODUCER` / `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` (map row) — merges **stored** `[:OVERRIDES]` `in`/`out` (subtype→supertype edges in Kuzu) with the dispatch-up rollup (distinct upstream declarations one `IMPLEMENTS`/`EXTENDS` hop away, same `signature`). The rollup alone always reported `in: 0`; merging fixes `in` when this method is also a super declaration with incoming override edges. A class implementing two interfaces that both declare the same signature yields `out: 2` on the rollup arm (and matches stored outbound edges when materialization aligns). Prefer `neighbors(ids=, direction="out", edge_types=["OVERRIDES"])` to list declaration ids, and `direction="in"` for overriders. Walk recipe (manual, if you need types in the middle): `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. diff --git a/mcp_hints.py b/mcp_hints.py index 2e24aad..dd0fb20 100644 --- a/mcp_hints.py +++ b/mcp_hints.py @@ -1,6 +1,7 @@ """Pure MCP v2 road-sign hint generation (no graph I/O, no search, no LLM). -Locked v1 catalog: ``propose/completed/HINTS-ROAD-SIGNS-PROPOSE.md`` Appendix A. +Locked v1 catalog: ``propose/completed/HINTS-ROAD-SIGNS-PROPOSE.md`` Appendix A +(issue #161 producer/override-route amendments in that appendix). v2 resolve + neighbors fuzzy-strategy catalog: ``propose/completed/HINTS-V2-PROPOSE.md`` Appendix A. v3 empty-neighbors structural catalog: ``propose/completed/HINTS-V3-PROPOSE.md`` §3.1–3.3. Priority cap: same propose §7.12 / ``plans/completed/PLAN-HINTS.md`` principles. diff --git a/propose/completed/HINTS-ROAD-SIGNS-PROPOSE.md b/propose/completed/HINTS-ROAD-SIGNS-PROPOSE.md index 64d1080..2495178 100644 --- a/propose/completed/HINTS-ROAD-SIGNS-PROPOSE.md +++ b/propose/completed/HINTS-ROAD-SIGNS-PROPOSE.md @@ -250,13 +250,18 @@ The §3.3 table *is* the appendix artifact — every row is a verbatim template # DescribeOutput DECLARES.DECLARES_CLIENT.out>0 → "clients via members: neighbors(['{id}'],'out',['DECLARES']) then neighbors(member_ids,'out',['DECLARES_CLIENT'])" DECLARES.EXPOSES.out>0 → "routes via members: neighbors(['{id}'],'out',['DECLARES']) then neighbors(member_ids,'out',['EXPOSES'])" +DECLARES.DECLARES_PRODUCER.out>0 → "producers via members: neighbors(['{id}'],'out',['DECLARES']) then neighbors(member_ids,'out',['DECLARES_PRODUCER'])" # amendment #161 OVERRIDDEN_BY.out>0 → "overriders: neighbors(['{id}'],'in',['OVERRIDES'])" # requires PR-A; rollup stores counts on .out per override_axis_rollup_for OVERRIDDEN_BY.DECLARES_CLIENT.out>0 → "clients in overriders: neighbors(['{id}'],'in',['OVERRIDES']) then neighbors(overrider_ids,'out',['DECLARES_CLIENT'])" # requires PR-A +OVERRIDDEN_BY.DECLARES_PRODUCER.out>0 → "producers in overriders: neighbors(['{id}'],'in',['OVERRIDES']) then neighbors(overrider_ids,'out',['DECLARES_PRODUCER'])" # amendment #161 +OVERRIDDEN_BY.EXPOSES.out>0 → "routes in overriders: neighbors(['{id}'],'in',['OVERRIDES']) then neighbors(overrider_ids,'out',['EXPOSES'])" # amendment #161 DECLARES_CLIENT.out>0 (method) → "outbound client: neighbors(['{id}'],'out',['DECLARES_CLIENT'])" +DECLARES_PRODUCER.out>0 (method) → "outbound producer: neighbors(['{id}'],'out',['DECLARES_PRODUCER'])" # amendment #161 EXPOSES.out>0 (method) → "inbound route: neighbors(['{id}'],'out',['EXPOSES'])" CALLS.out>=10 (method) → "many CALLS — consider filtering by target microservice" kind == route, always → "declaring method: neighbors(['{id}'],'in',['EXPOSES'])" kind == client, always → "declaring method: neighbors(['{id}'],'in',['DECLARES_CLIENT'])" +kind == producer, always → "declaring method: neighbors(['{id}'],'in',['DECLARES_PRODUCER'])" # amendment #161 # FindOutput results==[] and filter has identifier-shaped value → "no matches — try resolve(identifier, hint_kind='{kind}') for canonical lookup" @@ -273,6 +278,8 @@ File placement (`mcp_hints.py`), function decomposition, integration points in ` ## Appendix B — What changed (traceability) +**Amendment (2026-05-16, issue #161 / PR #164)** — five describe templates for the producer axis and override-route rollup, symmetric with the client/route rows above: `DECLARES.DECLARES_PRODUCER`, `DECLARES_PRODUCER`, `OVERRIDDEN_BY.DECLARES_PRODUCER`, `OVERRIDDEN_BY.EXPOSES`, and `kind == producer` declaring-method hint. No ontology or re-index change. + **What stayed unchanged from the first draft** - §1 frame statement; §2 principles 1–8; §3.1 field shape; §3.2 generation contract; §5 "deliberately does NOT do" table; §8 risks table. diff --git a/tests/fixtures/override_axis_rollup_smoke/src/main/java/orolla/abstractproducer/AbstractProducerApi.java b/tests/fixtures/override_axis_rollup_smoke/src/main/java/orolla/abstractproducer/AbstractProducerApi.java new file mode 100644 index 0000000..afd5cab --- /dev/null +++ b/tests/fixtures/override_axis_rollup_smoke/src/main/java/orolla/abstractproducer/AbstractProducerApi.java @@ -0,0 +1,5 @@ +package orolla.abstractproducer; + +public abstract class AbstractProducerApi { + public abstract void publish(); +} diff --git a/tests/fixtures/override_axis_rollup_smoke/src/main/java/orolla/abstractproducer/ConcreteProducerApi.java b/tests/fixtures/override_axis_rollup_smoke/src/main/java/orolla/abstractproducer/ConcreteProducerApi.java new file mode 100644 index 0000000..ed0717a --- /dev/null +++ b/tests/fixtures/override_axis_rollup_smoke/src/main/java/orolla/abstractproducer/ConcreteProducerApi.java @@ -0,0 +1,16 @@ +package orolla.abstractproducer; + +import org.springframework.kafka.core.KafkaTemplate; + +public class ConcreteProducerApi extends AbstractProducerApi { + private final KafkaTemplate kafkaTemplate; + + public ConcreteProducerApi(KafkaTemplate kafkaTemplate) { + this.kafkaTemplate = kafkaTemplate; + } + + @Override + public void publish() { + kafkaTemplate.send("orders", "payload"); + } +} diff --git a/tests/test_mcp_hints.py b/tests/test_mcp_hints.py index 1b2c5ee..ecdf8fa 100644 --- a/tests/test_mcp_hints.py +++ b/tests/test_mcp_hints.py @@ -199,18 +199,19 @@ def test_hints_describe_method_clients_in_overriders_emits(kuzu_graph) -> None: assert want in out.hints -def test_hints_describe_method_producers_in_overriders_emits() -> None: - node_id = "sym:com.example.T#m()" - rec = { - "id": node_id, - "kind": "symbol", - "fqn": "com.example.T#m()", - "data": {"kind": "method"}, - "edge_summary": {"OVERRIDDEN_BY.DECLARES_PRODUCER": {"in": 0, "out": 1}}, - } - hints = generate_hints("describe", {"success": True, "record": rec}) - want = mcp_hints.TPL_DESCRIBE_METHOD_PRODUCERS_IN_OVERRIDERS.format(id=node_id) - assert want in hints +def test_hints_describe_method_producers_in_overriders_emits(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 = 'publish' " + "RETURN m.id AS id LIMIT 1", + {"fqn": "orolla.abstractproducer.AbstractProducerApi"}, + ) + assert rows + mid = str(rows[0]["id"]) + out = describe_v2(mid, graph=override_axis_graph) + assert out.success and out.record + want = mcp_hints.TPL_DESCRIBE_METHOD_PRODUCERS_IN_OVERRIDERS.format(id=mid) + assert want in out.hints def test_hints_describe_method_routes_in_overriders_emits(override_axis_graph: KuzuGraph) -> None: diff --git a/tests/test_mcp_v2_compose.py b/tests/test_mcp_v2_compose.py index edd4857..542f252 100644 --- a/tests/test_mcp_v2_compose.py +++ b/tests/test_mcp_v2_compose.py @@ -420,6 +420,31 @@ def test_describe_method_no_overrides_silent(kuzu_graph) -> None: assert "OVERRIDES" not in es +def test_describe_abstract_method_with_producer_override_emits_declares_producer( + 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 = 'publish' " + "RETURN m.id AS id LIMIT 1", + {"fqn": "orolla.abstractproducer.AbstractProducerApi"}, + ) + 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_dp = _edge_row_count_from_methods(override_axis_graph, impl_ids, "DECLARES_PRODUCER") + assert want_dp >= 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.DECLARES_PRODUCER") == {"in": 0, "out": want_dp} + + 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) "