feat: retarget pass6 hint recovery to Client declarations (PR-LC2)#42
Conversation
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 <cursoragent@cursor.com>
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 <cursoragent@cursor.com>
Review: PR-LC2 — pass6 hint recovery retargets to
|
| Sentinel | Status |
|---|---|
list_clients references |
plans/CURSOR-PROMPTS-LIST-CLIENTS-MCP-TOOL.md task-prompt doc; zero in server.py / kuzu_queries.py |
@mcp.tool registrations |
server.py |
ClientRowDto / ClientsListOutput |
|
CREATE NODE TABLE Client / CREATE REL TABLE DECLARES_CLIENT |
|
ONTOLOGY_VERSION bump |
✅ 0 hits — correctly untouched (LC2 has no schema delta) |
test_list_clients |
All "leakage" sentinels resolve to the new task-prompt doc — i.e. the plan for LC3, not its implementation. Real code edits are confined to:
build_ast_graph.pypass6 fallback block (lines 1835–1893)tests/test_client_hint_recovery.py(new file, 112 lines)
Verified by inspecting each grep hit in context — every match is inside fenced text or sentinel-grep instructions for the next PR. Clean.
Plan compliance
| # | Plan item (PR-LC2 §) | Verified |
|---|---|---|
| 1 | pass6 hint recovery resolves caller member → DECLARES_CLIENT → Client |
✅ build_ast_graph.py:1838-1842 builds client_hints_by_member: dict[str, list[ClientRow]] from declares_client_rows joined to clients_by_id |
| 2 | Reads path / target_service / method from Client |
✅ all three fields read at :1869-1882 (client.path, client.target_service, client.method) |
| 3 | Matcher outcome semantics unchanged | ✅ same RouteRow(id="", kind="http_consumer", framework="feign", ...) transient construction as v2; only the source of the data moved, not the matcher contract |
| 4 | HTTP_CALLS(Symbol → Route) edge generation/meaning unchanged |
✅ no edits to the post-recovery _match_route codepath; transient src_route still feeds the existing matcher loop |
| 5 | New file tests/test_client_hint_recovery.py with 4 tests |
✅ all 4 plan-mandated test names present and exact: test_pass6_uses_client_hints_for_feign_resolution, test_cross_service_match_outcome_unchanged_after_client_migration, test_find_route_callers_still_returns_expected_feign_caller, test_missing_client_hint_falls_back_to_existing_unresolved_or_phantom_flow |
| 6 | bank-chat-system (or equivalent fixture) cross-service Feign call resolves correctly | ✅ test #1 uses tests/fixtures/cross_service_smoke with smoke.a.BFeignClient.joinOperator → asserts resolved row's microservice is svc-b |
| 7 | find_route_callers continuity preserved |
✅ test #3 writes to a real Kuzu DB and runs g.find_route_callers(microservice="svc-b", path_template="/chat/joinOperator", method="POST"), asserts the Feign caller is in the result |
| 8 | Definition of done — pass6 no longer depends on caller http_consumer route hints |
✅ the previous fallback iterated member.decl.outgoing_calls (in-memory v2 transient state); new path iterates the persisted client_rows set, completely orthogonal to whether caller-side routes ever existed |
Diff anatomy (the only real change)
Old (v2 in PR-LC1's wake):
for decl in member.decl.outgoing_calls:
if decl.client_kind != "feign_method":
continue
path_template, path_regex = _normalize_path(decl.path_template_call)
src_route = RouteRow(...
feign_name=decl.feign_target_name,
feign_url=decl.feign_target_url,
...
source_layer="layer_c_source", # always hardcoded
)New (LC2):
for client in client_hints_by_member.get(member.node_id, ()):
if client.client_kind != "feign_method":
continue
path_template, path_regex = _normalize_path(client.path)
src_route = RouteRow(...
feign_name=client.target_service,
feign_url="", # no URL stored on Client (kept None-equivalent)
...
source_layer=client.source_layer, # now propagates from LC1's stamping
)Mapping is mechanical and 1:1 except for two intentional asymmetries:
feign_urlempty-string instead ofdecl.feign_target_url—Clientschema doesn't carry feign URL. This is correct because the matcher usesfeign_namefor service resolution, not URL. Verified by reading the matcher block right after — no consumer ofsrc_route.feign_urlexists in pass6.source_layernow data-driven — the previous hardcodedlayer_c_sourcewas a known correctness gap (e.g. a Feign hint from a YAML override would be misreported as source-layer). Now readsclient.source_layer, which LC1's_client_source_layerwhitelist guarantees is one of{layer_a_meta, layer_b_ann, layer_b_fqn, layer_c_source, builtin}. This is a quiet bug fix, not just a refactor.
Notes that earned my trust
- Hint index built once as
client_hints_by_member: dict[str, list[ClientRow]] = defaultdict(list)outside the loop. O(declares + clients) preprocessing instead of O(callers × declares × clients) lookup. Same shape as the existingroute_by_idandmember_by_idindexes built at the top of pass6. - Defensive
clients_by_id.get(edge.client_id)withNoneskip —DECLARES_CLIENTreferencing a non-existentClient(shouldn't happen, but bad fixtures or partial rebuilds could trigger it) silently no-ops instead of crashing. tables.declares_client_rowsandtables.client_rowsare read-only here — pass6 doesn't mutate the LC1 collections, only reads. Idempotency contract from the existing pass6 docstring is preserved.- Test Add per-PR Cursor task prompts for Tier 1 completion #4
test_missing_client_hint_falls_back_to_existing_unresolved_or_phantom_flowstrips bothdeclares_client_rowsandclient_rowsfor the target caller, then assertsmatch in {"unresolved", "phantom"}AND explicitly!= "cross_service". The negative-case lock is the strongest signal — it'd catch a future regression where we accidentally re-introduce a caller-route fallback. - Test AST by Opus #1 corrupts
row.route_id = "missing:route:id"ANDrow.match = "unresolved"before invokingpass6_match_edges. This forces the recovery path rather than incidentally exercising it; locks down that the newclient_hints_by_memberlookup is what produced the resolution. - Test Add Cursor rules and agent settings for CLI agents #3 actually writes to Kuzu and queries via
KuzuGraph.find_route_callersend-to-end. The assertionc.caller_symbol_id == caller_idformicroservice="svc-b", path_template="/chat/joinOperator", method="POST"proves the persisted graph round-trips correctly — not just in-memory tables. - Reset of
KuzuGraph._instanceand_instance_pathin test Add Cursor rules and agent settings for CLI agents #3 prevents singleton bleed between tests. Subtle, easy to miss, correct.
Tests
Skipping local verification per your instruction. PR description claims 302 passed, 4 skipped against the post-PR-LC1 baseline of 298/4 — exactly +4 for the four new test_client_hint_recovery.py tests. Math checks out.
Observations (non-blocking)
client_hints_by_memberkeyed byedge.symbol_idbut the loop later readsclient_hints_by_member.get(member.node_id, ()). This works iffSymbol.id == Member.node_id— which is true today (theSymboltable is populated frommembersrows in pass2 withid = member.node_id). One-line comment near the hint-index construction noting this invariant would help a future reader; otherwise it looks like a happy coincidence. Not a blocker — documented in plan §132 already.- Multiple
Clientdeclarations per member:client_hints_by_member[m]is alist[ClientRow], and the loop usesbreakafter the firstclient_kind == "feign_method"match. Today, an interface method has at most one Feign declaration so this is fine. If a future PR allows multiple@CodebaseClientannotations on a single method (e.g. one per HTTP verb), thebreakwould silently pick the first by insertion order. A# TODO: revisit when multi-declaration support landswould help. feign_url=""(empty string) instead offeign_url=Noneor removing the field from the synthesized hint — minor; matches existingRouteRowfield defaults. No consumer cares today, but if a future matcher branch starts treating empty string differently from None this could surprise. Defensible because it mirrors the v2 behaviour for non-Feign hints.- Same fixture
tests/fixtures/cross_service_smokefor all 4 tests; great for fast feedback but means one fixture-level breakage (e.g. theBFeignClientinterface getting renamed) takes the whole suite down. Trade-off, not a defect. - Cursor task-prompt doc
plans/CURSOR-PROMPTS-LIST-CLIENTS-MCP-TOOL.mdis shipped alongside the implementation — useful, but this is technically out-of-scope for an "LC2 pass6 retarget" PR. No harm done; just flagging that future PR-X bodies can stay leaner if the task-prompt doc lands separately. (No need to revert here.)
Plan deltas needed
None. PR-LC2 done-criteria are all met:
- ✅ pass6 no longer depends on caller
http_consumerroute hints - ✅ Regression behavior stable for
HTTP_CALLSandfind_route_callers - ✅ Targeted pass6 regression tests pass (per PR description)
- ✅ Full suite green (302/4)
Ready to merge. Next: PR-LC3 — list_clients MCP tool, query helper, DTOs, and README docs. The Cursor task-prompt for it is already drafted in plans/CURSOR-PROMPTS-LIST-CLIENTS-MCP-TOOL.md (this PR), so the next step is straightforward delegation.
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 <cursoragent@cursor.com>
Scope
Implements PR-LC2 from
plans/PLAN-LIST-CLIENTS-MCP-TOOL.mdby retargeting pass6 hint recovery from callerhttp_consumerroute fallback to caller-sideClientdeclarations (Symbol -> DECLARES_CLIENT -> Client).This PR intentionally preserves the existing matching semantics and
HTTP_CALLS(Symbol -> Route)meaning while adding focused regression coverage for parity and continuity.What Changed
build_ast_graph.pypass6 fallback hint lookup:declares_client_rowsandclient_rows.HTTP_CALLSrow points to a missing route id during pass6 rematch, recovers Feign hints from persistedClientrows (not frommember.decl.outgoing_calls).path,target_service, andmethodfromClientand synthesizes the same transient Feign-style hint route used by existing matcher logic.tests/test_client_hint_recovery.pywith 4 focused tests:test_pass6_uses_client_hints_for_feign_resolutiontest_cross_service_match_outcome_unchanged_after_client_migrationtest_find_route_callers_still_returns_expected_feign_callertest_missing_client_hint_falls_back_to_existing_unresolved_or_phantom_flowSemantics / Non-Goals
cross_service,intra_service,ambiguous,phantom,unresolved.HTTP_CALLSedges still resolve callerSymbolto calleeRoute.list_clientstool/DTO/docs).Client/DECLARES_CLIENTrows.Validation
Lint
ruff check .✅Targeted tests
pytest tests/test_client_hint_recovery.py -v✅ (4 passed)Full suite
pytest tests -v✅ (302 passed, 4 skipped)Additional continuity checks
pytest tests/test_feign_not_exposer.py::test_feign_caller_resolves_to_target_endpoint tests/test_client_hint_recovery.py::test_find_route_callers_still_returns_expected_feign_caller -v✅Sentinel checks
rg "@mcp.tool\(name=\"list_clients\"|ClientRowDto|ClientsListOutput" server.py kuzu_queries.py-> no matches ✅rg "CREATE NODE TABLE Client|CREATE REL TABLE DECLARES_CLIENT|ontology_version\s*=\s*10" build_ast_graph.py java_ontology.py-> only existing LC1 schema lines inbuild_ast_graph.py, no broader LC1 reshape ✅Manual evidence
Rebuilt graph per plan instruction:
python build_ast_graph.py --source-root tests/bank-chat-system --kuzu-path /tmp/check_lc2 --verboseCaptured behavior:
find_route_callerscontinuity verified by regression tests.Out of Scope Confirmed
Did not implement:
list_clients).Made with Cursor