From b036d154412389a36f221dae2577551f5a63cd04 Mon Sep 17 00:00:00 2001 From: Dmitry Teryaev Date: Wed, 6 May 2026 17:44:03 +0300 Subject: [PATCH 1/3] retarget pass6 hint recovery to Client declarations Switch pass6 feign hint fallback to read caller-side Client rows through DECLARES_CLIENT while preserving HTTP_CALLS semantics and match outcomes. Add focused regression coverage for feign resolution parity, find_route_callers continuity, and missing-hint fallback behavior. Co-authored-by: Cursor --- build_ast_graph.py | 33 +++++---- tests/test_client_hint_recovery.py | 112 +++++++++++++++++++++++++++++ 2 files changed, 132 insertions(+), 13 deletions(-) create mode 100644 tests/test_client_hint_recovery.py diff --git a/build_ast_graph.py b/build_ast_graph.py index 1fa698a..be6ae4a 100644 --- a/build_ast_graph.py +++ b/build_ast_graph.py @@ -1835,6 +1835,13 @@ def pass6_match_edges( route_by_id = {r.id: r for r in tables.routes_rows} all_routes = [r for r in tables.routes_rows if r.microservice] member_by_id = {m.node_id: m for m in tables.members} + clients_by_id = {c.id: c for c in tables.client_rows} + client_hints_by_member: dict[str, list[ClientRow]] = defaultdict(list) + for edge in tables.declares_client_rows: + client = clients_by_id.get(edge.client_id) + if client is None: + continue + client_hints_by_member[edge.symbol_id].append(client) # Pass 6 is idempotent for full rebuilds: each run fully re-derives match outcomes. # If incremental rebuild lands later (Tier-2 follow-up), this reset must remain pass-scoped. @@ -1857,30 +1864,30 @@ def _micro_factor(member: MemberEntry | None) -> float: base = row.confidence / max(1e-9, (0.3 * _micro_factor(member))) src_route = route_by_id.get(row.route_id) if src_route is None and member is not None: - # Recover feign caller hints from outgoing client declarations (v2). - for decl in member.decl.outgoing_calls: - if decl.client_kind != "feign_method": + # Recover feign caller hints from persisted caller-side Client declarations. + for client in client_hints_by_member.get(member.node_id, ()): + if client.client_kind != "feign_method": continue - path_template, path_regex = _normalize_path(decl.path_template_call) + path_template, path_regex = _normalize_path(client.path) src_route = RouteRow( id="", kind="http_consumer", framework="feign", - method=decl.method_call, - path=decl.path_template_call, + method=client.method, + path=client.path, path_template=path_template, path_regex=path_regex, topic="", broker="", - feign_name=decl.feign_target_name, - feign_url=decl.feign_target_url, + feign_name=client.target_service, + feign_url="", microservice=member.microservice, module=member.module, - filename=decl.filename, - start_line=decl.start_line, - end_line=decl.end_line, - resolved=decl.resolved, - source_layer="layer_c_source", + filename=client.filename, + start_line=client.start_line, + end_line=client.end_line, + resolved=client.resolved, + source_layer=client.source_layer, ) break # Feign caller hints are synthesized as transient `http_consumer` routes in pass6; diff --git a/tests/test_client_hint_recovery.py b/tests/test_client_hint_recovery.py new file mode 100644 index 0000000..6aa7238 --- /dev/null +++ b/tests/test_client_hint_recovery.py @@ -0,0 +1,112 @@ +from __future__ import annotations + +from pathlib import Path + +from build_ast_graph import ( + GraphTables, + pass1_parse, + pass2_edges, + pass3_calls, + pass4_routes, + pass5_imperative_edges, + pass6_match_edges, + write_kuzu, +) +from kuzu_queries import KuzuGraph + +_FIXTURE = Path(__file__).resolve().parent / "fixtures" / "cross_service_smoke" + + +def _build_tables() -> GraphTables: + tables = GraphTables() + asts = pass1_parse(_FIXTURE, tables, verbose=False) + pass2_edges(tables, asts, verbose=False) + pass3_calls(tables, asts, verbose=False) + pass4_routes(tables, asts, source_root=_FIXTURE, verbose=False) + pass5_imperative_edges(tables, asts, source_root=_FIXTURE, verbose=False) + return tables + + +def _member_id(tables: GraphTables, *, parent_fqn: str, method_name: str) -> str: + for member in tables.members: + if member.parent_fqn == parent_fqn and member.decl.name == method_name: + return member.node_id + raise AssertionError(f"member not found: {parent_fqn}#{method_name}") + + +def _first_http_call_for_symbol(tables: GraphTables, symbol_id: str): + row = next((r for r in tables.http_call_rows if r.symbol_id == symbol_id), None) + assert row is not None + return row + + +def test_pass6_uses_client_hints_for_feign_resolution() -> None: + tables = _build_tables() + caller_id = _member_id( + tables, + parent_fqn="smoke.a.BFeignClient", + method_name="joinOperator", + ) + row = _first_http_call_for_symbol(tables, caller_id) + row.route_id = "missing:route:id" + row.match = "unresolved" + + pass6_match_edges(tables, verbose=False) + + route_by_id = {r.id: r for r in tables.routes_rows} + resolved = _first_http_call_for_symbol(tables, caller_id) + assert resolved.match == "cross_service" + assert route_by_id[resolved.route_id].microservice == "svc-b" + + +def test_cross_service_match_outcome_unchanged_after_client_migration() -> None: + tables = _build_tables() + pass6_match_edges(tables, verbose=False) + caller_id = _member_id( + tables, + parent_fqn="smoke.a.BFeignClient", + method_name="joinOperator", + ) + row = _first_http_call_for_symbol(tables, caller_id) + assert row.match == "cross_service" + + +def test_find_route_callers_still_returns_expected_feign_caller(tmp_path: Path) -> None: + tables = _build_tables() + pass6_match_edges(tables, verbose=False) + db_path = tmp_path / "client_hints.kuzu" + write_kuzu(db_path, tables, source_root=_FIXTURE, verbose=False) + KuzuGraph._instance = None + KuzuGraph._instance_path = None + g = KuzuGraph(str(db_path)) + caller_id = _member_id( + tables, + parent_fqn="smoke.a.BFeignClient", + method_name="joinOperator", + ) + callers = g.find_route_callers( + None, + microservice="svc-b", + path_template="/chat/joinOperator", + method="POST", + ) + assert any(c.caller_symbol_id == caller_id for c in callers) + + +def test_missing_client_hint_falls_back_to_existing_unresolved_or_phantom_flow() -> None: + tables = _build_tables() + caller_id = _member_id( + tables, + parent_fqn="smoke.a.BFeignClient", + method_name="joinOperator", + ) + tables.declares_client_rows = [r for r in tables.declares_client_rows if r.symbol_id != caller_id] + tables.client_rows = [c for c in tables.client_rows if c.member_id != caller_id] + row = _first_http_call_for_symbol(tables, caller_id) + row.route_id = "missing:route:id" + row.match = "unresolved" + + pass6_match_edges(tables, verbose=False) + + assert row.match in {"unresolved", "phantom"} + assert row.match != "cross_service" From 605d1cf8795fa24b560b69895d8ad86c8fe623c1 Mon Sep 17 00:00:00 2001 From: Dmitry Teryaev Date: Wed, 6 May 2026 17:45:17 +0300 Subject: [PATCH 2/3] add list_clients cursor prompts for LC1-LC3 Add per-PR Cursor task prompts for the list_clients plan sequence, including scope guardrails, sentinel checks, test commands, and done criteria for LC1, LC2, and LC3 handoffs. Co-authored-by: Cursor --- plans/CURSOR-PROMPTS-LIST-CLIENTS-MCP-TOOL.md | 246 ++++++++++++++++++ 1 file changed, 246 insertions(+) create mode 100644 plans/CURSOR-PROMPTS-LIST-CLIENTS-MCP-TOOL.md diff --git a/plans/CURSOR-PROMPTS-LIST-CLIENTS-MCP-TOOL.md b/plans/CURSOR-PROMPTS-LIST-CLIENTS-MCP-TOOL.md new file mode 100644 index 0000000..3dfcc6d --- /dev/null +++ b/plans/CURSOR-PROMPTS-LIST-CLIENTS-MCP-TOOL.md @@ -0,0 +1,246 @@ +# Cursor task prompts — list_clients MCP tool (PR-LC1 → PR-LC3) + +Status: **active (ready for implementation)**. Generated from +`plans/PLAN-LIST-CLIENTS-MCP-TOOL.md`. + +Use these prompts in landing order: **LC1 -> LC2 -> LC3**. +If prompt text conflicts with plan text, the plan wins. + +--- + +## PR-LC1 — Client schema + extraction + persistence + +**Branch:** `feat/list-clients-lc1-client-schema` off `master`. +**Base:** `master`. +**Plan section:** `plans/PLAN-LIST-CLIENTS-MCP-TOOL.md` § `PR-LC1 - Client schema + extraction + persistence`. +**Estimated diff size:** ~4-5 files, ~350-700 LOC. + +**Attach (`@-files`):** +- `@plans/PLAN-LIST-CLIENTS-MCP-TOOL.md` +- `@propose/LIST-CLIENTS-MCP-TOOL-PROPOSE.md` +- `@README.md` +- `@build_ast_graph.py` +- `@ast_java.py` +- `@graph_enrich.py` +- `@java_ontology.py` +- `@tests/README.md` + +**Prompt:** + +```` +You are implementing PR-LC1 from `plans/PLAN-LIST-CLIENTS-MCP-TOOL.md`. +Read the PR-LC1 section first. The plan is the source of truth. + +## Scope +- Add `Client` node table and `DECLARES_CLIENT` edge (`Symbol -> Client`). +- Persist outbound declarations independently from `Route`. +- Include source/brownfield `@CodebaseClient` declarations and synthesized Feign method declarations. +- Add graph_meta client counters (totals + by-kind JSON-string map). +- Bump ontology version 9 -> 10. + +## Out of scope (do NOT touch) +- pass6 hint-recovery retargeting (PR-LC2). +- MCP tool surface (`list_clients`) and DTO/tool registration (PR-LC3). +- Async producer node/tool expansion and companion client tools. +- Compatibility shims for old schema. + +If you need to touch out-of-scope items, stop and ask. + +## Deliverables +1. `build_ast_graph.py`: add Client/DECLARES_CLIENT schemas and create/drop wiring. +2. `build_ast_graph.py`: add row dataclasses + `GraphTables` collections and writers. +3. `build_ast_graph.py`: add client graph_meta counters using STRING JSON pattern. +4. `ast_java.py`: ensure outbound extraction emits stable Client payload. +5. `ast_java.py`: synthesize Feign method outbound client declarations. +6. `graph_enrich.py`: use `resolve_http_client_for_method` output as canonical source and stamp `source_layer`. +7. `java_ontology.py` (+ ontology source): version bump to 10 and valid client-kind alignment. +8. Add `tests/test_client_node_extraction.py` with plan-defined 6 tests. + +## Tests +Run: +- `ruff check .` +- `pytest tests/test_client_node_extraction.py -v` +- `pytest tests -v` + +Expected: targeted tests pass; full suite passes. + +## Sentinel checks +- `rg "list_clients|ClientRowDto|ClientsListOutput|@mcp.tool\\(name=\"list_clients\"" server.py kuzu_queries.py` + - must show no PR-LC3 surface additions. +- `rg "pass6|hint recovery|DECLARES_CLIENT.*pass6|Client.*pass6" build_ast_graph.py kuzu_queries.py` + - must show no PR-LC2 retargeting work. + +## Manual evidence +- Rebuild graph: + - `python build_ast_graph.py --source-root tests/bank-chat-system --kuzu-path /tmp/check_lc1 --verbose` +- Verify: + - ontology version is 10; + - client counters exist in `graph_meta`; + - `Client` and `DECLARES_CLIENT` rows are queryable. + +## Definition of Done +- [ ] All deliverables shipped and test-covered. +- [ ] `ruff check .` passes. +- [ ] Targeted and full tests pass. +- [ ] No PR-LC2/PR-LC3 scope leakage. +- [ ] PR title: `feat: add Client node and DECLARES_CLIENT graph persistence (PR-LC1)`. +- [ ] Branch: `feat/list-clients-lc1-client-schema`. +```` + +--- + +## PR-LC2 — pass6 hint recovery migration to Client + +**Branch:** `feat/list-clients-lc2-pass6-client-hints` off `feat/list-clients-lc1-client-schema` (or `master` if LC1 merged). +**Base:** PR-LC1 merged (or LC1 branch). +**Plan section:** `plans/PLAN-LIST-CLIENTS-MCP-TOOL.md` § `PR-LC2 - pass6 hint recovery migration to Client`. +**Estimated diff size:** ~2-3 files, ~180-350 LOC. + +**Attach (`@-files`):** +- `@plans/PLAN-LIST-CLIENTS-MCP-TOOL.md` +- `@propose/LIST-CLIENTS-MCP-TOOL-PROPOSE.md` +- `@README.md` +- `@build_ast_graph.py` +- `@kuzu_queries.py` +- `@tests/README.md` + +**Prompt:** + +```` +You are implementing PR-LC2 from `plans/PLAN-LIST-CLIENTS-MCP-TOOL.md`. +Read the PR-LC2 section first. The plan is the source of truth. + +## Scope +- Retarget pass6 hint recovery from caller `http_consumer` routes to caller `Client` declarations. +- Keep match outcomes and semantics unchanged. +- Preserve `HTTP_CALLS(Symbol -> Route)` meaning. +- Add focused regression tests for parity and continuity. + +## Out of scope (do NOT touch) +- LC1 schema extraction/persistence redesign except minimal bug fixes. +- LC3 MCP tool/DTO/docs (`list_clients`). +- Route-tool redesign or companion tools. + +If you need to touch out-of-scope items, stop and ask. + +## Deliverables +1. `build_ast_graph.py`: pass6 hint lookup via caller member -> `DECLARES_CLIENT` -> `Client`. +2. `build_ast_graph.py`: keep outcome labels/logic stable. +3. `kuzu_queries.py`: helper query adjustments for hint lookup (if needed). +4. Add `tests/test_client_hint_recovery.py` with plan-defined 4 tests. +5. Ensure known Feign cross-service resolution and `find_route_callers` continuity still hold. + +## Tests +Run: +- `ruff check .` +- `pytest tests/test_client_hint_recovery.py -v` +- `pytest tests -v` + +Expected: targeted tests pass; full suite passes. + +## Sentinel checks +- `rg "@mcp.tool\\(name=\"list_clients\"|ClientRowDto|ClientsListOutput" server.py kuzu_queries.py` + - must show no LC3 MCP surface. +- `rg "CREATE NODE TABLE Client|CREATE REL TABLE DECLARES_CLIENT|ontology_version\\s*=\\s*10" build_ast_graph.py java_ontology.py` + - no broad LC1 reshaping. + +## Manual evidence +- Rebuild graph: + - `python build_ast_graph.py --source-root tests/bank-chat-system --kuzu-path /tmp/check_lc2 --verbose` +- Capture: + - pass6 still resolves known Feign call path correctly; + - `find_route_callers` still returns expected caller; + - missing-hint flow still falls back to unresolved/phantom behavior. + +## Definition of Done +- [ ] All deliverables shipped and test-covered. +- [ ] `ruff check .` passes. +- [ ] Targeted and full tests pass. +- [ ] Match semantics unchanged except hint-source retarget. +- [ ] PR title: `feat: retarget pass6 hint recovery to Client declarations (PR-LC2)`. +- [ ] Branch: `feat/list-clients-lc2-pass6-client-hints`. +```` + +--- + +## PR-LC3 — list_clients MCP tool + query surface + docs + +**Branch:** `feat/list-clients-lc3-mcp-surface` off `feat/list-clients-lc2-pass6-client-hints` (or latest merged predecessor). +**Base:** PR-LC2 merged (or LC2 branch). +**Plan section:** `plans/PLAN-LIST-CLIENTS-MCP-TOOL.md` § `PR-LC3 - list_clients MCP tool + query surface + docs`. +**Estimated diff size:** ~3-4 files, ~220-450 LOC. + +**Attach (`@-files`):** +- `@plans/PLAN-LIST-CLIENTS-MCP-TOOL.md` +- `@propose/LIST-CLIENTS-MCP-TOOL-PROPOSE.md` +- `@README.md` +- `@server.py` +- `@kuzu_queries.py` +- `@tests/test_mcp_tools.py` +- `@tests/README.md` + +**Prompt:** + +```` +You are implementing PR-LC3 from `plans/PLAN-LIST-CLIENTS-MCP-TOOL.md`. +Read the PR-LC3 section first. The plan is the source of truth. + +## Scope +- Add first-class MCP outbound discovery surface: + - query helper in `kuzu_queries.py`; + - `list_clients` MCP tool in `server.py`; + - DTOs/output contract; + - docs in `README.md`. +- Filter semantics parallel to route-listing ergonomics: + `microservice`, `client_kind`, `target_service`, `path_prefix`, `method`, `limit`. +- Empty-match behavior is success with empty list. + +## Out of scope (do NOT touch) +- LC1 schema/extraction redesign (except critical bug fix). +- LC2 pass6 hint-recovery logic. +- Companion client/producer tools not in this plan. +- Route API contract redesign. + +If you need to touch out-of-scope items, stop and ask. + +## Deliverables +1. `kuzu_queries.py`: add client-list query helper with optional filters and deterministic ordering. +2. `server.py`: add `ClientRowDto` and `ClientsListOutput`. +3. `server.py`: register `@mcp.tool(name="list_clients")`. +4. `server.py`: enforce limit default/bounds (`100`, bounded `1..500`) and method normalization per repo conventions. +5. `README.md`: document `list_clients`, outbound/inbound split, and reindex/ontology callout consistency. +6. Add `tests/test_list_clients.py` with plan-defined 8 tests. +7. Update `tests/test_mcp_tools.py` (or equivalent) for tool registration smoke. + +## Tests +Run: +- `ruff check .` +- `pytest tests/test_list_clients.py -v` +- `pytest tests/test_mcp_tools.py -v` +- `pytest tests -v` + +Expected: new tool tests pass; full suite passes. + +## Sentinel checks +- `rg "pass6|hint recovery|DECLARES_CLIENT.*pass6|http_consumer" build_ast_graph.py kuzu_queries.py` + - no LC2 logic rewrite. +- `rg "get_client_by_path|find_client_callers|find_client_target_route|list_async_producers|Producer node" server.py kuzu_queries.py README.md` + - no out-of-plan tool expansion. + +## Manual evidence +- Build/rebuild graph if needed, then call `list_clients` with: + - no filters; + - each filter independently; + - combined filters; + - empty-result query; + - limit edge cases (`0`, `1`, `500`, `501`). +- Capture stable ordering and success/empty response contract. + +## Definition of Done +- [ ] All deliverables shipped and test-covered. +- [ ] `ruff check .` passes. +- [ ] Targeted and full tests pass. +- [ ] README public contract updated consistently. +- [ ] PR title: `feat: add list_clients MCP tool and client query surface (PR-LC3)`. +- [ ] Branch: `feat/list-clients-lc3-mcp-surface`. +```` From 396106348317e5c560a6a79e72c39335393060f7 Mon Sep 17 00:00:00 2001 From: Dmitry Teryaev Date: Wed, 6 May 2026 17:58:21 +0300 Subject: [PATCH 3/3] document and stabilize pass6 client hint fallback ordering Clarify the Symbol/member id invariant used by DECLARES_CLIENT lookups, make multi-client feign fallback deterministic by sorting per-member hints, and document why feign_url remains empty in the synthesized pass6 hint route. Co-authored-by: Cursor --- build_ast_graph.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/build_ast_graph.py b/build_ast_graph.py index be6ae4a..14c09c0 100644 --- a/build_ast_graph.py +++ b/build_ast_graph.py @@ -1841,7 +1841,12 @@ def pass6_match_edges( client = clients_by_id.get(edge.client_id) if client is None: continue + # `DECLARES_CLIENT.symbol_id` targets `Symbol.id` for member symbols, + # and member symbols are emitted with `id == MemberEntry.node_id`. client_hints_by_member[edge.symbol_id].append(client) + for member_symbol_id in list(client_hints_by_member.keys()): + # Deterministic fallback when a method carries multiple feign declarations. + client_hints_by_member[member_symbol_id].sort(key=lambda c: c.id) # Pass 6 is idempotent for full rebuilds: each run fully re-derives match outcomes. # If incremental rebuild lands later (Tier-2 follow-up), this reset must remain pass-scoped. @@ -1880,6 +1885,7 @@ def _micro_factor(member: MemberEntry | None) -> float: topic="", broker="", feign_name=client.target_service, + # `Client` stores service-name hints, not feign URL; matcher keys off feign_name. feign_url="", microservice=member.microservice, module=member.module,