From 45f34b08993400faba21b1e1add90f86c38fdd70 Mon Sep 17 00:00:00 2001 From: Dmitry Teryaev Date: Sat, 16 May 2026 20:34:10 +0300 Subject: [PATCH 1/4] add EDGE_SCHEMA-driven empty neighbors hints (HINTS-V3 PR-D) Replace the generic empty-neighbors template with kind-, direction-, and type-level structural hints sourced from EDGE_SCHEMA; extend neighbors_v2 hint payload with subject_record and requested_direction. Co-authored-by: Cursor --- README.md | 2 +- mcp_hints.py | 175 ++++++++++++++++++-- mcp_v2.py | 6 + server.py | 3 +- tests/test_mcp_hints.py | 343 ++++++++++++++++++++++++++++++++++++++-- 5 files changed, 507 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index 53251bd..947d63e 100644 --- a/README.md +++ b/README.md @@ -261,7 +261,7 @@ Example: {"kind":"symbol","filter":{"microservice":"chat-core","symbol_kind":"interface"}} ``` -**MCP v2 response extras (`hints`, pagination echo):** On success, `search`, `find`, `describe`, `neighbors`, and `resolve` return a `hints` field (`list[str]`, capped at five unique strings) with short, templated suggestions for likely next tool calls; hints are advisory. `hints` is always empty when `success` is false. `resolve` additionally echoes `resolved_identifier` (post-validation trimmed identifier) on every `success=true` response; it is `null` when `success` is false. Resolve hints fire only on `status: none` or `status: many` (not on `status: one`). `search` and `find` additionally echo the request’s `limit` and `offset` on success; on failure those echoed fields are omitted (`null` in JSON). The find page-full hint fires only when another page may exist (handler over-fetches by one row; not exposed on the output model). `neighbors` echoes `requested_edge_types` (deduped edge labels from the request) on success for empty-result hints and diagnostics; when any result edge carries a brownfield/fallback `attrs.strategy` (see `FUZZY_STRATEGY_SET` in `java_ontology.py`), a single meta-tier fuzzy-strategy hint may also appear. See [`propose/completed/HINTS-ROAD-SIGNS-PROPOSE.md`](./propose/completed/HINTS-ROAD-SIGNS-PROPOSE.md) Appendix A for the locked v1 template catalog; see [`propose/HINTS-V2-PROPOSE.md`](./propose/HINTS-V2-PROPOSE.md) for v2 additions (`resolve` rules and neighbors fuzzy-strategy hint). +**MCP v2 response extras (`hints`, pagination echo):** On success, `search`, `find`, `describe`, `neighbors`, and `resolve` return a `hints` field (`list[str]`, capped at five unique strings) with short, templated suggestions for likely next tool calls; hints are advisory. `hints` is always empty when `success` is false. `resolve` additionally echoes `resolved_identifier` (post-validation trimmed identifier) on every `success=true` response; it is `null` when `success` is false. Resolve hints fire only on `status: none` or `status: many` (not on `status: one`). `search` and `find` additionally echo the request’s `limit` and `offset` on success; on failure those echoed fields are omitted (`null` in JSON). The find page-full hint fires only when another page may exist (handler over-fetches by one row; not exposed on the output model). `neighbors` echoes `requested_edge_types` (deduped edge labels from the request) on success; empty results with non-empty `edge_types` may emit kind- and direction-aware structural hints driven by `EDGE_SCHEMA` (see [`propose/HINTS-V3-PROPOSE.md`](./propose/HINTS-V3-PROPOSE.md)); when any result edge carries a brownfield/fallback `attrs.strategy` (see `FUZZY_STRATEGY_SET` in `java_ontology.py`), a single meta-tier fuzzy-strategy hint may also appear on non-empty results. See [`propose/completed/HINTS-ROAD-SIGNS-PROPOSE.md`](./propose/completed/HINTS-ROAD-SIGNS-PROPOSE.md) Appendix A for the locked v1 template catalog; see [`propose/HINTS-V2-PROPOSE.md`](./propose/HINTS-V2-PROPOSE.md) for v2 additions (`resolve` rules and neighbors fuzzy-strategy hint). --- diff --git a/mcp_hints.py b/mcp_hints.py index 4481f21..c20d56b 100644 --- a/mcp_hints.py +++ b/mcp_hints.py @@ -1,7 +1,8 @@ """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. -v2 resolve + neighbors fuzzy-strategy catalog: ``propose/HINTS-V2-PROPOSE.md`` Appendix A. +v2 resolve + neighbors fuzzy-strategy catalog: ``propose/completed/HINTS-V2-PROPOSE.md`` Appendix A. +v3 empty-neighbors structural catalog: ``propose/HINTS-V3-PROPOSE.md`` §3.1–3.3. Priority cap: same propose §7.12 / ``plans/completed/PLAN-HINTS.md`` principles. """ @@ -9,14 +10,15 @@ from typing import Any, Literal -from java_ontology import FUZZY_STRATEGY_SET +from java_ontology import EDGE_SCHEMA, FUZZY_STRATEGY_SET # Normative schema description (propose §3.1) — imported by ``mcp_v2`` for Field(description=...). MCP_HINTS_FIELD_DESCRIPTION = ( "Road-sign hints pointing to likely next calls. Each hint is a short string " "referencing one MCP V2 tool call. Hints are advisory and may be safely ignored. " "Maximum 5 hints per output. Hints never recommend dot-key edge labels (composed " - "rollups) as neighbors() arguments." + "rollups) as neighbors() arguments. For neighbors with multiple origin ids, " + "empty-result structural hints describe the first origin only." ) # --- Appendix A verbatim templates (substitute {id}, {kind}, {limit}) --- @@ -43,8 +45,25 @@ 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" -TPL_NEIGHBORS_EMPTY_KIND_CHECK = ( - "0 results — check if the requested edge_types apply to this kind" +TPL_NEIGHBORS_WRONG_SUBJECT_KIND = ( + "0 results — '{edge}' connects {src_kind} → {dst_kind}; " + "this is a {subject_kind}. Try: {canonical_traversal}" +) + +TPL_NEIGHBORS_WRONG_DIRECTION = ( + "0 results — '{edge}' is {src_kind} → {dst_kind}; " + "you requested direction='{requested_dir}'. Try direction='{correct_dir}'." +) + +TPL_NEIGHBORS_TYPE_LEVEL_REQUERY = ( + "0 results — '{edge}' lives on methods, not on {subject_kind}. " + "Try: {canonical_traversal}" +) + +TPL_NEIGHBORS_BROWNFIELD_RESOLVED_MAYBE_UNRESOLVED = ( + "edges on '{edge}' are emitted by the brownfield resolver — " + "absence here may mean unresolved (no matching annotation/target), " + "not absent from the codebase" ) TPL_SEARCH_WEAK = "results look weak — narrow the query or try find(role=…)" @@ -80,6 +99,8 @@ _TYPE_SYMBOL_KINDS = frozenset({"class", "interface", "enum", "record", "annotation"}) _METHOD_SYMBOL_KINDS = frozenset({"method", "constructor"}) +_COMPOSED_DOT_KEY_PREFIXES = ("DECLARES.", "OVERRIDDEN_BY.") + _IDENTIFIER_FILTER_FIELDS: dict[str, tuple[str, ...]] = { "symbol": ("fqn_prefix",), "route": ("path_prefix",), @@ -105,6 +126,133 @@ def _symbol_declaration_kind(record: dict[str, Any]) -> str | None: return None +def _subject_node_label(subject_record: dict[str, Any]) -> str: + if "producer_kind" in subject_record: + return "Producer" + if "client_kind" in subject_record: + return "Client" + if "framework" in subject_record: + return "Route" + return "Symbol" + + +def _traversal_role_for_wrong_kind(subject_label: str, subject_record: dict[str, Any]) -> str: + if subject_label == "Symbol": + sk = str(subject_record.get("kind") or "") + if sk in _METHOD_SYMBOL_KINDS: + return "member_subject" + if sk in _TYPE_SYMBOL_KINDS: + return "alien_subject" + return "alien_subject" + + +def typical_traversal_for( + edge: str, + role_key: str, + *, + subject_id: str, + direction: str, +) -> str: + template = EDGE_SCHEMA[edge].typical_traversals[role_key] + return template.format(id=subject_id, direction=direction, edge=edge) + + +def neighbors_empty_hints( + *, + subject_record: dict[str, Any], + requested_edge_types: list[str], + requested_direction: Literal["in", "out"], +) -> list[tuple[int, str]]: + """Structural empty-neighbors hints from ``EDGE_SCHEMA`` (at most one row 1–3 per edge).""" + pairs: list[tuple[int, str]] = [] + subject_label = _subject_node_label(subject_record) + subject_id = str(subject_record.get("id") or "") + + for edge in requested_edge_types: + spec = EDGE_SCHEMA.get(edge) + if spec is None: + continue + + if subject_label != spec.src and subject_label != spec.dst: + role = _traversal_role_for_wrong_kind(subject_label, subject_record) + trav = typical_traversal_for( + edge, role, subject_id=subject_id, direction=requested_direction, + ) + pairs.append( + ( + PRIORITY_META, + TPL_NEIGHBORS_WRONG_SUBJECT_KIND.format( + edge=edge, + src_kind=spec.src, + dst_kind=spec.dst, + subject_kind=subject_label, + canonical_traversal=trav, + ), + ) + ) + continue + + wrong_direction = spec.src != spec.dst and ( + (requested_direction == "out" and subject_label == spec.dst) + or (requested_direction == "in" and subject_label == spec.src) + ) + if wrong_direction: + correct_dir = "in" if requested_direction == "out" else "out" + pairs.append( + ( + PRIORITY_META, + TPL_NEIGHBORS_WRONG_DIRECTION.format( + edge=edge, + src_kind=spec.src, + dst_kind=spec.dst, + requested_dir=requested_direction, + correct_dir=correct_dir, + ), + ) + ) + continue + + if ( + subject_label == "Symbol" + and str(subject_record.get("kind") or "") in _TYPE_SYMBOL_KINDS + and spec.member_only + ): + trav = typical_traversal_for( + edge, "type_subject", subject_id=subject_id, direction=requested_direction, + ) + pairs.append( + ( + PRIORITY_META, + TPL_NEIGHBORS_TYPE_LEVEL_REQUERY.format( + edge=edge, + subject_kind=subject_label, + canonical_traversal=trav, + ), + ) + ) + + for edge in requested_edge_types: + spec = EDGE_SCHEMA.get(edge) + if spec is not None and spec.brownfield_resolver_sourced: + pairs.append( + ( + PRIORITY_META, + TPL_NEIGHBORS_BROWNFIELD_RESOLVED_MAYBE_UNRESOLVED.format(edge=edge), + ) + ) + break + + return pairs + + +def _hint_contains_composed_dotkey(hint: str) -> bool: + return any(prefix in hint for prefix in _COMPOSED_DOT_KEY_PREFIXES) + + +def _filter_neighbors_dotkey_hints(pairs: list[tuple[int, str]]) -> list[tuple[int, str]]: + return [(pri, text) for pri, text in pairs if not _hint_contains_composed_dotkey(text)] + + def _any_fuzzy_strategy(edges: list[dict[str, Any]]) -> bool: for e in edges: attrs = e.get("attrs") if isinstance(e.get("attrs"), dict) else {} @@ -227,12 +375,21 @@ def generate_hints( req_types = payload.get("requested_edge_types") if not isinstance(req_types, list): req_types = [] - n_types = len([x for x in req_types if str(x).strip()]) - if not results and n_types > 0: - pairs.append((PRIORITY_META, TPL_NEIGHBORS_EMPTY_KIND_CHECK)) + edge_labels = [str(x).strip() for x in req_types if str(x).strip()] + if not results and edge_labels: + subject_record = payload.get("subject_record") + requested_direction = payload.get("requested_direction") + if isinstance(subject_record, dict) and requested_direction in ("in", "out"): + pairs.extend( + neighbors_empty_hints( + subject_record=subject_record, + requested_edge_types=edge_labels, + requested_direction=requested_direction, + ) + ) elif _any_fuzzy_strategy(results): pairs.append((PRIORITY_META, TPL_NEIGHBORS_FUZZY_STRATEGY)) - return finalize_hint_list(pairs) + return finalize_hint_list(_filter_neighbors_dotkey_hints(pairs)) if output_kind == "describe": rec = payload.get("record") diff --git a/mcp_v2.py b/mcp_v2.py index ac44c5c..84dd418 100644 --- a/mcp_v2.py +++ b/mcp_v2.py @@ -1391,10 +1391,16 @@ def neighbors_v2( ) ) sliced = results[offset : offset + limit] + first_origin = origins[0] + origin_kind = _resolve_node_kind(g, first_origin) + subject_record = _load_node_record(g, first_origin, origin_kind) neigh_payload = { "success": True, "results": [e.model_dump() for e in sliced], "requested_edge_types": list(labels), + "requested_direction": direction, + "origin_id": first_origin, + "subject_record": subject_record if subject_record is not None else {}, } return NeighborsOutput( success=True, diff --git a/server.py b/server.py index 151dd49..1d1d364 100644 --- a/server.py +++ b/server.py @@ -452,7 +452,8 @@ async def describe( "Optional `filter` applies to each neighbor endpoint row; populated fields must be applicable to that " "neighbor's kind—mixed-kind result sets fail on the first inapplicable neighbor (strict frame). " "Wildcards in prefix fields are rejected. Unknown NodeFilter keys return success=false. " - "Successful responses echo `requested_edge_types` and may include `hints` (advisory next-step strings). " + "Successful responses echo `requested_edge_types` and may include `hints` (advisory next-step strings; " + "empty results may include EDGE_SCHEMA-driven traversal hints). " "Each edge's `attrs.strategy` indicates resolution quality (brownfield/fallback vs primary paths)." ), ) diff --git a/tests/test_mcp_hints.py b/tests/test_mcp_hints.py index 17cdff3..f4f6eda 100644 --- a/tests/test_mcp_hints.py +++ b/tests/test_mcp_hints.py @@ -7,6 +7,7 @@ import pytest import mcp_hints +from java_ontology import EDGE_SCHEMA, FUZZY_STRATEGY_SET from mcp_hints import ( PRIORITY_DECLARES_TYPE_ROLLUP, PRIORITY_LEAF_FOLLOWUP, @@ -14,8 +15,8 @@ PRIORITY_OVERRIDDEN_AXIS, finalize_hint_list, generate_hints, + neighbors_empty_hints, ) -from java_ontology import FUZZY_STRATEGY_SET from mcp_v2 import FindOutput, SearchOutput, describe_v2, find_v2, neighbors_v2, resolve_v2, search_v2 _TYPE_KINDS = frozenset({"class", "interface", "enum", "record", "annotation"}) @@ -245,27 +246,57 @@ def test_hints_find_page_full_skips_when_last_page(kuzu_graph) -> None: assert mcp_hints.TPL_FIND_PAGE_FULL.format(limit=1) not in last.hints -def test_hints_neighbors_empty_with_edge_types_emits_kind_check(kuzu_graph) -> None: - class_id = _class_symbol_id(kuzu_graph) - out = neighbors_v2(class_id, direction="out", edge_types=["DECLARES_CLIENT"], graph=kuzu_graph) - assert out.success is True - assert out.results == [] - assert out.requested_edge_types == ["DECLARES_CLIENT"] - assert mcp_hints.TPL_NEIGHBORS_EMPTY_KIND_CHECK in out.hints - - def _neighbors_hint_payload( results: list[dict[str, Any]], *, requested_edge_types: list[str] | None = None, + subject_record: dict[str, Any] | None = None, + requested_direction: str = "out", ) -> dict[str, Any]: return { "success": True, "results": results, "requested_edge_types": requested_edge_types or ["DECLARES_CLIENT"], + "requested_direction": requested_direction, + "subject_record": subject_record + if subject_record is not None + else {"id": "sym:com.example.T", "kind": "class"}, } +def _neighbors_empty_payload( + subject_record: dict[str, Any], + edge_types: list[str], + *, + direction: str = "out", +) -> dict[str, Any]: + return _neighbors_hint_payload( + [], + requested_edge_types=edge_types, + subject_record=subject_record, + requested_direction=direction, + ) + + +def _structural_neighbors_hints(payload: dict[str, Any]) -> list[str]: + hints = generate_hints("neighbors", payload) + structural_markers = ( + mcp_hints.TPL_NEIGHBORS_WRONG_SUBJECT_KIND.split("'")[0], + mcp_hints.TPL_NEIGHBORS_WRONG_DIRECTION.split("'")[0], + mcp_hints.TPL_NEIGHBORS_TYPE_LEVEL_REQUERY.split("'")[0], + ) + return [h for h in hints if any(h.startswith(m) for m in structural_markers)] + + +def test_hints_neighbors_empty_class_declares_client_emits_type_level_requery(kuzu_graph) -> None: + class_id = _class_symbol_id(kuzu_graph) + out = neighbors_v2(class_id, direction="out", edge_types=["DECLARES_CLIENT"], graph=kuzu_graph) + assert out.success is True + assert out.results == [] + assert out.requested_edge_types == ["DECLARES_CLIENT"] + assert any("lives on methods" in h for h in out.hints) + + def _edge_result(*, strategy: str | None = None, edge_type: str = "DECLARES_CLIENT") -> dict[str, Any]: attrs: dict[str, Any] = {} if strategy is not None: @@ -348,6 +379,269 @@ def test_hints_neighbors_fuzzy_strategy_neighbors_v2_round_trip(kuzu_graph) -> N assert "brownfield/fallback strategy" in out.hints[0] +def _producer_id(kuzu_graph) -> str: + rows = kuzu_graph._rows("MATCH (p:Producer) RETURN p.id AS id ORDER BY p.id LIMIT 1") # noqa: SLF001 + if not rows: + pytest.fail("session fixture lacks Producer nodes (post-flip SCHEMA required)") + return str(rows[0]["id"]) + + +def _method_id(kuzu_graph) -> str: + rows = kuzu_graph._rows( # noqa: SLF001 + "MATCH (m:Symbol) WHERE m.kind = 'method' RETURN m.id AS id LIMIT 1", + ) + assert rows + return str(rows[0]["id"]) + + +def _annotation_symbol_id(kuzu_graph) -> str | None: + rows = kuzu_graph._rows( # noqa: SLF001 + "MATCH (s:Symbol) WHERE s.kind = 'annotation' RETURN s.id AS id LIMIT 1", + ) + if not rows: + return None + return str(rows[0]["id"]) + + +def test_hints_hv1_type_level_declares_client_requery() -> None: + payload = _neighbors_empty_payload( + {"id": "sym:com.example.T", "kind": "class"}, + ["DECLARES_CLIENT"], + ) + hints = generate_hints("neighbors", payload) + assert any("lives on methods" in h for h in hints) + assert "DECLARES" in hints[0] + + +def test_hints_hv2_method_http_calls_wrong_subject_kind() -> None: + payload = _neighbors_empty_payload( + {"id": "sym:com.example.T#m()", "kind": "method"}, + ["HTTP_CALLS"], + ) + hints = generate_hints("neighbors", payload) + assert any("Client" in h and "Route" in h for h in hints) + assert any("DECLARES_CLIENT" in h for h in hints) + + +def test_hints_hv3_method_async_calls_wrong_subject_kind() -> None: + payload = _neighbors_empty_payload( + {"id": "sym:com.example.T#m()", "kind": "method"}, + ["ASYNC_CALLS"], + ) + hints = generate_hints("neighbors", payload) + assert any("Producer" in h and "Route" in h for h in hints) + assert any("DECLARES_PRODUCER" in h for h in hints) + + +def test_hints_hv4_producer_empty_async_out_brownfield_only() -> None: + payload = _neighbors_empty_payload( + {"id": "producer:svc:kafka:t", "producer_kind": "kafka"}, + ["ASYNC_CALLS"], + ) + hints = generate_hints("neighbors", payload) + assert _structural_neighbors_hints(payload) == [] + assert any("brownfield resolver" in h for h in hints) + + +def test_hints_hv5_producer_async_calls_wrong_direction() -> None: + payload = _neighbors_empty_payload( + {"id": "producer:svc:kafka:t", "producer_kind": "kafka"}, + ["ASYNC_CALLS"], + direction="in", + ) + hints = generate_hints("neighbors", payload) + assert any("direction='in'" in h and "direction='out'" in h for h in hints) + + +def test_hints_hv6_client_http_calls_wrong_direction() -> None: + payload = _neighbors_empty_payload( + {"id": "client:svc:feign:t:GET:/p", "client_kind": "feign_method"}, + ["HTTP_CALLS"], + direction="in", + ) + hints = generate_hints("neighbors", payload) + assert any("direction='in'" in h and "direction='out'" in h for h in hints) + + +def test_hints_hv7_route_http_calls_wrong_direction() -> None: + payload = _neighbors_empty_payload( + {"id": "route:svc:GET:/api", "framework": "spring_mvc"}, + ["HTTP_CALLS"], + direction="out", + ) + hints = generate_hints("neighbors", payload) + assert any("direction='out'" in h and "direction='in'" in h for h in hints) + + +def test_hints_hv8_method_exposes_empty_no_structural_hint() -> None: + payload = _neighbors_empty_payload( + {"id": "sym:com.example.T#m()", "kind": "method"}, + ["EXPOSES"], + ) + assert _structural_neighbors_hints(payload) == [] + + +def test_hints_hv9_method_declares_client_empty_no_structural_hint() -> None: + payload = _neighbors_empty_payload( + {"id": "sym:com.example.T#m()", "kind": "method"}, + ["DECLARES_CLIENT"], + ) + assert _structural_neighbors_hints(payload) == [] + + +def test_hints_hv10_class_http_calls_wrong_subject_kind() -> None: + payload = _neighbors_empty_payload( + {"id": "sym:com.example.T", "kind": "class"}, + ["HTTP_CALLS"], + ) + hints = generate_hints("neighbors", payload) + assert any("this is a Symbol" in h for h in hints) + + +def test_hints_hv11_method_overrides_empty_no_structural_hint() -> None: + payload = _neighbors_empty_payload( + {"id": "sym:com.example.T#m()", "kind": "method"}, + ["OVERRIDES"], + ) + assert _structural_neighbors_hints(payload) == [] + + +def test_hints_hv12_annotation_extends_empty_no_structural_hint(kuzu_graph) -> None: + ann_id = _annotation_symbol_id(kuzu_graph) + if ann_id is None: + pytest.skip("no annotation Symbol in fixture") + assert EDGE_SCHEMA["EXTENDS"].member_only is False + payload = _neighbors_empty_payload({"id": ann_id, "kind": "annotation"}, ["EXTENDS"]) + assert _structural_neighbors_hints(payload) == [] + + +def test_hints_hv13_client_empty_http_brownfield_only() -> None: + payload = _neighbors_empty_payload( + {"id": "client:svc:feign:t:GET:/p", "client_kind": "feign_method"}, + ["HTTP_CALLS"], + ) + hints = generate_hints("neighbors", payload) + assert _structural_neighbors_hints(payload) == [] + assert any("brownfield resolver" in h for h in hints) + + +def test_hints_hv14_producer_empty_async_brownfield_only() -> None: + payload = _neighbors_empty_payload( + {"id": "producer:svc:kafka:t", "producer_kind": "kafka"}, + ["ASYNC_CALLS"], + ) + hints = generate_hints("neighbors", payload) + assert _structural_neighbors_hints(payload) == [] + assert any("brownfield resolver" in h for h in hints) + + +def test_hints_hv15_multi_edge_http_only_wrong_kind_for_http() -> None: + payload = _neighbors_empty_payload( + {"id": "sym:com.example.T#m()", "kind": "method"}, + ["HTTP_CALLS", "DECLARES_CLIENT"], + ) + hints = generate_hints("neighbors", payload) + wrong_kind = [h for h in hints if "HTTP_CALLS" in h and "this is a Symbol" in h] + assert len(wrong_kind) == 1 + assert not any("DECLARES_CLIENT" in h and "lives on methods" in h for h in hints) + + +def test_hints_hv16_client_nonempty_http_fuzzy_hint_unchanged(kuzu_graph) -> None: + rows = kuzu_graph._rows( # noqa: SLF001 + "MATCH (c:Client)-[e:HTTP_CALLS]->() " + "WHERE e.strategy IN $strategies " + "RETURN c.id AS id LIMIT 1", + {"strategies": sorted(FUZZY_STRATEGY_SET)}, + ) + if not rows: + pytest.skip("no Client HTTP_CALLS edge with fuzzy strategy in fixture") + cid = str(rows[0]["id"]) + out = neighbors_v2(cid, direction="out", edge_types=["HTTP_CALLS"], graph=kuzu_graph, limit=50) + assert out.success and out.results + assert mcp_hints.TPL_NEIGHBORS_FUZZY_STRATEGY in out.hints + assert not any("this is a" in h for h in out.hints) + + +def test_hints_hv17_class_exposes_type_level_requery() -> None: + payload = _neighbors_empty_payload( + {"id": "sym:com.example.T", "kind": "class"}, + ["EXPOSES"], + ) + hints = generate_hints("neighbors", payload) + assert any("lives on methods" in h for h in hints) + + +def test_hints_hv18_route_declares_wrong_subject_kind() -> None: + payload = _neighbors_empty_payload( + {"id": "route:svc:GET:/api", "framework": "spring_mvc"}, + ["DECLARES"], + ) + hints = generate_hints("neighbors", payload) + assert any("this is a Route" in h for h in hints) + + +def _synthetic_coverage_for_edge(edge: str) -> tuple[dict[str, Any], str] | None: + candidates: list[tuple[dict[str, Any], str]] = [ + ({"id": "sym:type", "kind": "class"}, "out"), + ({"id": "sym:type", "kind": "class"}, "in"), + ({"id": "sym:method", "kind": "method"}, "out"), + ({"id": "sym:method", "kind": "method"}, "in"), + ({"id": "sym:ann", "kind": "annotation"}, "out"), + ({"id": "client:x", "client_kind": "feign_method"}, "out"), + ({"id": "client:x", "client_kind": "feign_method"}, "in"), + ({"id": "route:x", "framework": "spring_mvc"}, "out"), + ({"id": "route:x", "framework": "spring_mvc"}, "in"), + ({"id": "producer:x", "producer_kind": "kafka"}, "out"), + ({"id": "producer:x", "producer_kind": "kafka"}, "in"), + ] + for rec, direction in candidates: + if neighbors_empty_hints( + subject_record=rec, + requested_edge_types=[edge], + requested_direction=direction, # type: ignore[arg-type] + ): + return rec, direction + return None + + +@pytest.mark.parametrize("edge", sorted(EDGE_SCHEMA.keys())) +def test_hints_hv19_edge_schema_coverage_exists_trigger_per_edge(edge: str) -> None: + found = _synthetic_coverage_for_edge(edge) + assert found is not None, f"no synthetic subject/direction triggers hints for {edge}" + + +def test_hints_hv20_no_dotkey_edge_labels_in_rendered_neighbors_hints() -> None: + payloads = [ + _neighbors_empty_payload({"id": "sym:com.example.T", "kind": "class"}, ["DECLARES_CLIENT"]), + _neighbors_empty_payload({"id": "sym:com.example.T#m()", "kind": "method"}, ["HTTP_CALLS"]), + _neighbors_empty_payload( + {"id": "client:svc:feign:t:GET:/p", "client_kind": "feign_method"}, + ["HTTP_CALLS"], + ), + ] + for payload in payloads: + for hint in generate_hints("neighbors", payload): + assert "DECLARES." not in hint + assert "OVERRIDDEN_BY." not in hint + + +def test_hints_neighbors_empty_kind_check_template_removed() -> None: + assert not hasattr(mcp_hints, "TPL_NEIGHBORS_EMPTY_KIND_CHECK") + + +def test_hints_neighbors_v2_empty_post_flip_method_http_calls(kuzu_graph) -> None: + mid = _method_id(kuzu_graph) + rows = kuzu_graph._rows( # noqa: SLF001 + "MATCH (:Client)-[:HTTP_CALLS]->(:Route) RETURN count(*) AS n", + ) + n = int(rows[0]["n"]) + assert n > 0, "session fixture lacks post-flip Client→Route HTTP_CALLS edges" + out = neighbors_v2(mid, direction="out", edge_types=["HTTP_CALLS"], graph=kuzu_graph) + assert out.success is True + assert out.results == [] + assert any("this is a Symbol" in h and "HTTP_CALLS" in h for h in out.hints) + + def test_hints_search_weak_structural_signal_emits(monkeypatch, kuzu_graph) -> None: rows = [ { @@ -821,7 +1115,34 @@ def test_hints_pagination_none_skips_page_derived_hints() -> None: (mcp_hints.TPL_DESCRIBE_CLIENT_DECLARING, {"id": "client:svc:feign:target:GET:/p"}), (mcp_hints.TPL_FIND_EMPTY_RESOLVE, {"kind": "client"}), (mcp_hints.TPL_FIND_PAGE_FULL, {"limit": 500}), - (mcp_hints.TPL_NEIGHBORS_EMPTY_KIND_CHECK, {}), + ( + mcp_hints.TPL_NEIGHBORS_WRONG_SUBJECT_KIND, + { + "edge": "HTTP_CALLS", + "src_kind": "Client", + "dst_kind": "Route", + "subject_kind": "Symbol", + "canonical_traversal": "neighbors(['x'],'out',['DECLARES_CLIENT'])", + }, + ), + ( + mcp_hints.TPL_NEIGHBORS_WRONG_DIRECTION, + { + "edge": "HTTP_CALLS", + "src_kind": "Client", + "dst_kind": "Route", + "requested_dir": "in", + "correct_dir": "out", + }, + ), + ( + mcp_hints.TPL_NEIGHBORS_TYPE_LEVEL_REQUERY, + { + "edge": "DECLARES_CLIENT", + "subject_kind": "Symbol", + "canonical_traversal": "neighbors(['x'],'out',['DECLARES'])", + }, + ), (mcp_hints.TPL_SEARCH_WEAK, {}), (mcp_hints.TPL_RESOLVE_NONE_TRY_SEARCH, {"identifier": "com.example.Type#m()"}), ( From 984a0fba122a454c955f8dccd3efe71f17995a97 Mon Sep 17 00:00:00 2001 From: Dmitry Teryaev Date: Sat, 16 May 2026 20:39:41 +0300 Subject: [PATCH 2/4] address PR review: narrow brownfield hints and harden empty path Gate brownfield-absence hints to Client/Producer/Route subjects; skip structural hints when subject_record is missing or offset>0; add traversal key and regression tests. Co-authored-by: Cursor --- mcp_hints.py | 32 +++++++++++++++++++++----------- mcp_v2.py | 5 ++++- tests/test_mcp_hints.py | 34 +++++++++++++++++++++++++++++++++- 3 files changed, 58 insertions(+), 13 deletions(-) diff --git a/mcp_hints.py b/mcp_hints.py index c20d56b..197cc9c 100644 --- a/mcp_hints.py +++ b/mcp_hints.py @@ -100,6 +100,10 @@ _METHOD_SYMBOL_KINDS = frozenset({"method", "constructor"}) _COMPOSED_DOT_KEY_PREFIXES = ("DECLARES.", "OVERRIDDEN_BY.") +# Row 4 (brownfield absence): only when the subject is a resolver endpoint node, not a +# structurally valid Symbol query that happens to be empty (DECLARES_CLIENT, EXPOSES, …). +_BROWNFIELD_ABSENCE_SUBJECT_LABELS = frozenset({"Client", "Producer", "Route"}) +_REQUIRED_TRAVERSAL_ROLE_KEYS = frozenset({"type_subject", "member_subject", "alien_subject"}) _IDENTIFIER_FILTER_FIELDS: dict[str, tuple[str, ...]] = { "symbol": ("fqn_prefix",), @@ -231,16 +235,17 @@ def neighbors_empty_hints( ) ) - for edge in requested_edge_types: - spec = EDGE_SCHEMA.get(edge) - if spec is not None and spec.brownfield_resolver_sourced: - pairs.append( - ( - PRIORITY_META, - TPL_NEIGHBORS_BROWNFIELD_RESOLVED_MAYBE_UNRESOLVED.format(edge=edge), + if subject_label in _BROWNFIELD_ABSENCE_SUBJECT_LABELS: + for edge in requested_edge_types: + spec = EDGE_SCHEMA.get(edge) + if spec is not None and spec.brownfield_resolver_sourced: + pairs.append( + ( + PRIORITY_META, + TPL_NEIGHBORS_BROWNFIELD_RESOLVED_MAYBE_UNRESOLVED.format(edge=edge), + ) ) - ) - break + break return pairs @@ -376,10 +381,15 @@ def generate_hints( if not isinstance(req_types, list): req_types = [] edge_labels = [str(x).strip() for x in req_types if str(x).strip()] - if not results and edge_labels: + offset = int(payload.get("offset") or 0) + if not results and edge_labels and offset == 0: subject_record = payload.get("subject_record") requested_direction = payload.get("requested_direction") - if isinstance(subject_record, dict) and requested_direction in ("in", "out"): + if ( + isinstance(subject_record, dict) + and subject_record + and requested_direction in ("in", "out") + ): pairs.extend( neighbors_empty_hints( subject_record=subject_record, diff --git a/mcp_v2.py b/mcp_v2.py index 84dd418..5f7518c 100644 --- a/mcp_v2.py +++ b/mcp_v2.py @@ -1394,13 +1394,16 @@ def neighbors_v2( first_origin = origins[0] origin_kind = _resolve_node_kind(g, first_origin) subject_record = _load_node_record(g, first_origin, origin_kind) + # Empty-result hints use the sliced page only; offset>0 or strict filters can + # yield [] while hops exist — skip structural hints in that case. neigh_payload = { "success": True, "results": [e.model_dump() for e in sliced], "requested_edge_types": list(labels), "requested_direction": direction, + "offset": offset, "origin_id": first_origin, - "subject_record": subject_record if subject_record is not None else {}, + "subject_record": subject_record, } return NeighborsOutput( success=True, diff --git a/tests/test_mcp_hints.py b/tests/test_mcp_hints.py index f4f6eda..42f032d 100644 --- a/tests/test_mcp_hints.py +++ b/tests/test_mcp_hints.py @@ -410,7 +410,7 @@ def test_hints_hv1_type_level_declares_client_requery() -> None: ) hints = generate_hints("neighbors", payload) assert any("lives on methods" in h for h in hints) - assert "DECLARES" in hints[0] + assert any("DECLARES" in h for h in hints) def test_hints_hv2_method_http_calls_wrong_subject_kind() -> None: @@ -478,7 +478,9 @@ def test_hints_hv8_method_exposes_empty_no_structural_hint() -> None: {"id": "sym:com.example.T#m()", "kind": "method"}, ["EXPOSES"], ) + hints = generate_hints("neighbors", payload) assert _structural_neighbors_hints(payload) == [] + assert not any("brownfield resolver" in h for h in hints) def test_hints_hv9_method_declares_client_empty_no_structural_hint() -> None: @@ -486,7 +488,9 @@ def test_hints_hv9_method_declares_client_empty_no_structural_hint() -> None: {"id": "sym:com.example.T#m()", "kind": "method"}, ["DECLARES_CLIENT"], ) + hints = generate_hints("neighbors", payload) assert _structural_neighbors_hints(payload) == [] + assert not any("brownfield resolver" in h for h in hints) def test_hints_hv10_class_http_calls_wrong_subject_kind() -> None: @@ -610,6 +614,34 @@ def test_hints_hv19_edge_schema_coverage_exists_trigger_per_edge(edge: str) -> N assert found is not None, f"no synthetic subject/direction triggers hints for {edge}" +def test_hints_edge_schema_typical_traversals_required_role_keys() -> None: + required = mcp_hints._REQUIRED_TRAVERSAL_ROLE_KEYS + for edge, spec in EDGE_SCHEMA.items(): + missing = required - set(spec.typical_traversals.keys()) + assert not missing, f"{edge} missing typical_traversals keys {sorted(missing)}" + + +def test_hints_neighbors_missing_subject_record_skips_structural() -> None: + payload = { + "success": True, + "results": [], + "requested_edge_types": ["HTTP_CALLS"], + "requested_direction": "out", + "offset": 0, + "subject_record": None, + } + assert generate_hints("neighbors", payload) == [] + + +def test_hints_neighbors_offset_suppresses_empty_structural_hints() -> None: + payload = _neighbors_empty_payload( + {"id": "sym:com.example.T#m()", "kind": "method"}, + ["HTTP_CALLS"], + ) + payload["offset"] = 3 + assert generate_hints("neighbors", payload) == [] + + def test_hints_hv20_no_dotkey_edge_labels_in_rendered_neighbors_hints() -> None: payloads = [ _neighbors_empty_payload({"id": "sym:com.example.T", "kind": "class"}, ["DECLARES_CLIENT"]), From b8af3aca8e50ffdc77d8b190466410cc74280801 Mon Sep 17 00:00:00 2001 From: Dmitry Teryaev Date: Sat, 16 May 2026 20:41:36 +0300 Subject: [PATCH 3/4] move HINTS-V3 propose and plans to completed Archive landed PR-D artefacts; update cross-links in SCHEMA-V2 docs, README, mcp_hints, and PROPOSES-ORDER (no proposes in flight). Co-authored-by: Cursor --- README.md | 2 +- docs/PROPOSES-ORDER.md | 46 +++++++------------ mcp_hints.py | 2 +- .../CURSOR-PROMPTS-HINTS-V3.md | 10 ++-- plans/completed/CURSOR-PROMPTS-SCHEMA-V2.md | 6 +-- plans/{ => completed}/PLAN-HINTS-V3.md | 12 ++--- plans/completed/PLAN-SCHEMA-V2.md | 14 +++--- propose/{ => completed}/HINTS-V3-PROPOSE.md | 2 +- propose/completed/SCHEMA-V2-PROPOSE.md | 6 +-- 9 files changed, 43 insertions(+), 57 deletions(-) rename plans/{ => completed}/CURSOR-PROMPTS-HINTS-V3.md (87%) rename plans/{ => completed}/PLAN-HINTS-V3.md (95%) rename propose/{ => completed}/HINTS-V3-PROPOSE.md (99%) diff --git a/README.md b/README.md index 947d63e..ae2f4bf 100644 --- a/README.md +++ b/README.md @@ -261,7 +261,7 @@ Example: {"kind":"symbol","filter":{"microservice":"chat-core","symbol_kind":"interface"}} ``` -**MCP v2 response extras (`hints`, pagination echo):** On success, `search`, `find`, `describe`, `neighbors`, and `resolve` return a `hints` field (`list[str]`, capped at five unique strings) with short, templated suggestions for likely next tool calls; hints are advisory. `hints` is always empty when `success` is false. `resolve` additionally echoes `resolved_identifier` (post-validation trimmed identifier) on every `success=true` response; it is `null` when `success` is false. Resolve hints fire only on `status: none` or `status: many` (not on `status: one`). `search` and `find` additionally echo the request’s `limit` and `offset` on success; on failure those echoed fields are omitted (`null` in JSON). The find page-full hint fires only when another page may exist (handler over-fetches by one row; not exposed on the output model). `neighbors` echoes `requested_edge_types` (deduped edge labels from the request) on success; empty results with non-empty `edge_types` may emit kind- and direction-aware structural hints driven by `EDGE_SCHEMA` (see [`propose/HINTS-V3-PROPOSE.md`](./propose/HINTS-V3-PROPOSE.md)); when any result edge carries a brownfield/fallback `attrs.strategy` (see `FUZZY_STRATEGY_SET` in `java_ontology.py`), a single meta-tier fuzzy-strategy hint may also appear on non-empty results. See [`propose/completed/HINTS-ROAD-SIGNS-PROPOSE.md`](./propose/completed/HINTS-ROAD-SIGNS-PROPOSE.md) Appendix A for the locked v1 template catalog; see [`propose/HINTS-V2-PROPOSE.md`](./propose/HINTS-V2-PROPOSE.md) for v2 additions (`resolve` rules and neighbors fuzzy-strategy hint). +**MCP v2 response extras (`hints`, pagination echo):** On success, `search`, `find`, `describe`, `neighbors`, and `resolve` return a `hints` field (`list[str]`, capped at five unique strings) with short, templated suggestions for likely next tool calls; hints are advisory. `hints` is always empty when `success` is false. `resolve` additionally echoes `resolved_identifier` (post-validation trimmed identifier) on every `success=true` response; it is `null` when `success` is false. Resolve hints fire only on `status: none` or `status: many` (not on `status: one`). `search` and `find` additionally echo the request’s `limit` and `offset` on success; on failure those echoed fields are omitted (`null` in JSON). The find page-full hint fires only when another page may exist (handler over-fetches by one row; not exposed on the output model). `neighbors` echoes `requested_edge_types` (deduped edge labels from the request) on success; empty results with non-empty `edge_types` may emit kind- and direction-aware structural hints driven by `EDGE_SCHEMA` (see [`propose/completed/HINTS-V3-PROPOSE.md`](./propose/completed/HINTS-V3-PROPOSE.md)); when any result edge carries a brownfield/fallback `attrs.strategy` (see `FUZZY_STRATEGY_SET` in `java_ontology.py`), a single meta-tier fuzzy-strategy hint may also appear on non-empty results. See [`propose/completed/HINTS-ROAD-SIGNS-PROPOSE.md`](./propose/completed/HINTS-ROAD-SIGNS-PROPOSE.md) Appendix A for the locked v1 template catalog; see [`propose/HINTS-V2-PROPOSE.md`](./propose/HINTS-V2-PROPOSE.md) for v2 additions (`resolve` rules and neighbors fuzzy-strategy hint). --- diff --git a/docs/PROPOSES-ORDER.md b/docs/PROPOSES-ORDER.md index bf5d5a1..35e7b9c 100644 --- a/docs/PROPOSES-ORDER.md +++ b/docs/PROPOSES-ORDER.md @@ -11,69 +11,55 @@ When two or more proposes touch overlapping subsystems, the order they lock and ## Current in-flight set (as of 2026-05-16) -1. **HINTS-V3** — `propose/HINTS-V3-PROPOSE.md` (`Status: locked — implementing via SCHEMA-V2 PR-D`; propose [#154](https://github.com/HumanBean17/java-codebase-rag/pull/154), plan [#155](https://github.com/HumanBean17/java-codebase-rag/pull/155)) - - Implementation = SCHEMA-V2 PR-D (same PR). +No proposes are in flight. -**SCHEMA-V2** (PR-A/B/C) is **completed** — artefacts in `propose/completed/SCHEMA-V2-PROPOSE.md`, `plans/completed/PLAN-SCHEMA-V2.md`, `plans/completed/CURSOR-PROMPTS-SCHEMA-V2.md`. PR-D remains under HINTS-V3. +**SCHEMA-V2** and **HINTS-V3** are **completed** — artefacts under `propose/completed/` and `plans/completed/` (SCHEMA PR-A/B/C; HINTS-V3 PR-D [#160](https://github.com/HumanBean17/java-codebase-rag/pull/160)). -No other proposes are in flight. - -## Lock and merge order +## Lock and merge order (archived) ### Phase 1 — propose artefacts ``` SCHEMA-V2-PROPOSE.md [merged #151 — completed; propose/completed/] ↓ -HINTS-V3-PROPOSE.md [merged #154 — implementing in PR-D] +HINTS-V3-PROPOSE.md [merged #154 — completed; propose/completed/; code #160] ``` -**Decision 30 (SCHEMA-V2)**: `HINTS-V3-PROPOSE.md` must exist as a **merged draft propose** before SCHEMA-V2 **PR-A** implementation starts. That unblocks the four-code-PR sequence; it does **not** require HINTS-V3 to be `Status: locked` before PR-A. +**Decision 30 (SCHEMA-V2)**: `HINTS-V3-PROPOSE.md` must exist as a **merged draft propose** before SCHEMA-V2 **PR-A** implementation starts. -**HINTS-V3 lock**: `Status: locked` before SCHEMA-V2 **PR-D** code merges (satisfied while Phase 3 is in flight; see propose headers). +**HINTS-V3 lock**: `Status: locked` before SCHEMA-V2 **PR-D** code merges. ### Phase 2 — plan + cursor-prompt artefacts ``` -plans/completed/PLAN-SCHEMA-V2.md [landed #155; PR-A/B/C done] +plans/completed/PLAN-SCHEMA-V2.md plans/completed/CURSOR-PROMPTS-SCHEMA-V2.md -plans/PLAN-HINTS-V3.md -plans/CURSOR-PROMPTS-HINTS-V3.md +plans/completed/PLAN-HINTS-V3.md +plans/completed/CURSOR-PROMPTS-HINTS-V3.md ``` -SCHEMA-V2 Decision 29: plan + prompts merge gates for **PR-A** — satisfied ([#155](https://github.com/HumanBean17/java-codebase-rag/pull/155) on `master`; artefacts now under `plans/completed/`). - -By analogy: `PLAN-HINTS-V3.md` + `CURSOR-PROMPTS-HINTS-V3.md` are merge gates for **PR-D** (same PR). - -Plans and prompts may be drafted in parallel with each other; each pair must land before its code PR. **Code PRs (Phase 3) are not started** until Phase 2 is on `master`. - -### Phase 3 — code PRs (merge order) — **implementing** +### Phase 3 — code PRs (merge order) — **completed** ``` PR-A feat(schema): EDGE_SCHEMA + docs/EDGE-NAVIGATION.md + ontology v14 - ↓ (requires HINTS-V3 propose merged as draft per Decision 30) + ↓ PR-B feat(schema): HTTP_CALLS Client → Route (+ downstream API) ↓ PR-C feat(schema): Producer node + ASYNC_CALLS flip (+ GraphMeta / MCP parity) - ↓ (requires HINTS-V3 propose Status: locked) -PR-D feat(hints): kind/direction-aware empty-result hints (EDGE_SCHEMA-driven) + ↓ +PR-D feat(hints): kind/direction-aware empty-result hints (EDGE_SCHEMA-driven) [#160] ``` -PR-A needs `EDGE_SCHEMA` infrastructure. PR-B and PR-C are sequential for review surface. PR-D consumes post-flip `src`/`dst` and must not merge until HINTS-V3 is **locked**. - -No PR in this set is parallelizable. - ## Re-index moments -`ONTOLOGY_VERSION` 13 → 14 lands in PR-A. **One** re-index across the sequence. README + `docs/AGENT-GUIDE.md` updated in PR-A. +`ONTOLOGY_VERSION` 13 → 14 landed in PR-A. **One** re-index across the SCHEMA-V2 sequence. ## What this document does NOT cover -- Per-PR deliverables — `plans/PLAN-*.md` +- Per-PR deliverables — `plans/PLAN-*.md` (see `plans/completed/` for landed work) - Cursor handoffs — `plans/CURSOR-PROMPTS-*.md` - Out-of-scope proposes (TIER2-INCREMENTAL-REBUILD, RANKING-MICROSERVICE, etc.) -- Intra-PR review threads ## Maintenance -Update this file when a propose enters draft, locks, or its code PRs land. After PR-D merges, move HINTS-V3 artefacts to `completed/` and collapse to "no proposes in flight" until the next effort starts. +Update this file when a propose enters draft, locks, or its code PRs land. After the next effort starts, add it to "Current in-flight set" and extend the archived sequence as needed. diff --git a/mcp_hints.py b/mcp_hints.py index 197cc9c..fcf96b3 100644 --- a/mcp_hints.py +++ b/mcp_hints.py @@ -2,7 +2,7 @@ Locked v1 catalog: ``propose/completed/HINTS-ROAD-SIGNS-PROPOSE.md`` Appendix A. v2 resolve + neighbors fuzzy-strategy catalog: ``propose/completed/HINTS-V2-PROPOSE.md`` Appendix A. -v3 empty-neighbors structural catalog: ``propose/HINTS-V3-PROPOSE.md`` §3.1–3.3. +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/plans/CURSOR-PROMPTS-HINTS-V3.md b/plans/completed/CURSOR-PROMPTS-HINTS-V3.md similarity index 87% rename from plans/CURSOR-PROMPTS-HINTS-V3.md rename to plans/completed/CURSOR-PROMPTS-HINTS-V3.md index 691d3ab..c460e89 100644 --- a/plans/CURSOR-PROMPTS-HINTS-V3.md +++ b/plans/completed/CURSOR-PROMPTS-HINTS-V3.md @@ -1,8 +1,8 @@ # Cursor task prompts — HINTS-V3 -Status: **active (implementing)**. Plan: -[`plans/PLAN-HINTS-V3.md`](./PLAN-HINTS-V3.md). Propose: -[`propose/HINTS-V3-PROPOSE.md`](../propose/HINTS-V3-PROPOSE.md). +Status: **completed** (landed [#160](https://github.com/HumanBean17/java-codebase-rag/pull/160)). Plan: +[`plans/completed/PLAN-HINTS-V3.md`](./PLAN-HINTS-V3.md). Propose: +[`propose/completed/HINTS-V3-PROPOSE.md`](../propose/completed/HINTS-V3-PROPOSE.md). **Depends on:** SCHEMA-V2 **PR-A, PR-B, PR-C** merged to `master`. **Propose lock:** Set `HINTS-V3-PROPOSE.md` `Status: locked` before opening the code PR. @@ -42,7 +42,7 @@ One prompt: **PR-D** (= SCHEMA-V2 PR-D in sequence doc). **Prompt:** ```` -You are implementing PR-HINTS-V3-D from `plans/PLAN-HINTS-V3.md` (**PR-D**). +You are implementing PR-HINTS-V3-D from `plans/completed/PLAN-HINTS-V3.md` (**PR-D**). SCHEMA-V2 PR-A/B/C are on `master`: post-flip `HTTP_CALLS` (Client→Route), `ASYNC_CALLS` (Producer→Route), `EDGE_SCHEMA` with 11 edges. @@ -60,7 +60,7 @@ Confirm `propose/HINTS-V3-PROPOSE.md` is **Status: locked** before merge. 2. **`mcp_v2.py`** — Extend neighbors hint payload: `requested_direction`, `origin_id`, `subject_record` from `_load_node_record` (§3.6). Multi-id: use first origin only. 3. **`java_ontology.py`** — Only if `member_only` missing from PR-A: add field + flags per propose §3.4. 4. **`README.md`** / **`server.py`** — Minimal neighbors-hints documentation. -5. **Tests** — Implement every `test_hints_hv*` name listed under **Tests for PR-D** in `plans/PLAN-HINTS-V3.md`, including **`test_hints_neighbors_v2_empty_post_flip_method_http_calls`** (required — session graph must be post-flip). Update/remove `test_hints_neighbors_empty_with_edge_types_emits_kind_check` to reflect v3 (rename if needed per plan). +5. **Tests** — Implement every `test_hints_hv*` name listed under **Tests for PR-D** in `plans/completed/PLAN-HINTS-V3.md`, including **`test_hints_neighbors_v2_empty_post_flip_method_http_calls`** (required — session graph must be post-flip). Update/remove `test_hints_neighbors_empty_with_edge_types_emits_kind_check` to reflect v3 (rename if needed per plan). ## Out of scope (do NOT touch) diff --git a/plans/completed/CURSOR-PROMPTS-SCHEMA-V2.md b/plans/completed/CURSOR-PROMPTS-SCHEMA-V2.md index ad727a7..396062a 100644 --- a/plans/completed/CURSOR-PROMPTS-SCHEMA-V2.md +++ b/plans/completed/CURSOR-PROMPTS-SCHEMA-V2.md @@ -6,7 +6,7 @@ Status: **completed** (reference template). Plan: [`docs/PROPOSES-ORDER.md`](../../docs/PROPOSES-ORDER.md). One prompt per code PR (**PR-A / PR-B / PR-C**). PR-D (hints) is in -[`plans/CURSOR-PROMPTS-HINTS-V3.md`](../CURSOR-PROMPTS-HINTS-V3.md). +[`plans/completed/CURSOR-PROMPTS-HINTS-V3.md`](./CURSOR-PROMPTS-HINTS-V3.md). **Landing order:** PR-SCHEMA-V2-A → PR-SCHEMA-V2-B → PR-SCHEMA-V2-C. Do not start the next PR until the previous is merged to `master`. @@ -16,7 +16,7 @@ One prompt per code PR (**PR-A / PR-B / PR-C**). PR-D (hints) is in - Nothing reachable from MCP tool handlers may write to **stdout**. - If ambiguous versus the plan, stop and ask — do not expand scope. - Do not push git unless the user explicitly asked. -- Confirm [`propose/HINTS-V3-PROPOSE.md`](../propose/HINTS-V3-PROPOSE.md) is on `master` before starting **PR-A** implementation. +- Confirm [`propose/completed/HINTS-V3-PROPOSE.md`](../propose/completed/HINTS-V3-PROPOSE.md) is on `master` before starting **PR-A** implementation. --- @@ -31,7 +31,7 @@ One prompt per code PR (**PR-A / PR-B / PR-C**). PR-D (hints) is in - `@plans/completed/PLAN-SCHEMA-V2.md` (PR-A only) - `@propose/completed/SCHEMA-V2-PROPOSE.md` (§3.1, §3.5–§3.6, §6 PR-A, Appendix A, Decisions 6–9, 28–29, 31) -- `@propose/HINTS-V3-PROPOSE.md` (§3.4–§3.5 `member_only` / `typical_traversals` — read only) +- `@propose/completed/HINTS-V3-PROPOSE.md` (§3.4–§3.5 `member_only` / `typical_traversals` — read only) - `@java_ontology.py` - `@build_ast_graph.py` (DDL constants — do not flip endpoints) - `@ast_java.py` diff --git a/plans/PLAN-HINTS-V3.md b/plans/completed/PLAN-HINTS-V3.md similarity index 95% rename from plans/PLAN-HINTS-V3.md rename to plans/completed/PLAN-HINTS-V3.md index 921e035..e9b1a13 100644 --- a/plans/PLAN-HINTS-V3.md +++ b/plans/completed/PLAN-HINTS-V3.md @@ -1,7 +1,7 @@ # Plan: HINTS-V3 (EDGE_SCHEMA-driven empty `neighbors` hints) -Status: **active (implementing)**. This plan implements -[`propose/HINTS-V3-PROPOSE.md`](../propose/HINTS-V3-PROPOSE.md). +Status: **completed** (landed [#160](https://github.com/HumanBean17/java-codebase-rag/pull/160)). This plan implemented +[`propose/completed/HINTS-V3-PROPOSE.md`](../propose/completed/HINTS-V3-PROPOSE.md). Depends on: @@ -37,7 +37,7 @@ Sequence reference: [`docs/PROPOSES-ORDER.md`](../docs/PROPOSES-ORDER.md). **Landing order:** **PR-D** after SCHEMA **PR-C** on `master`. -**Merge gates:** This file + [`plans/CURSOR-PROMPTS-HINTS-V3.md`](./CURSOR-PROMPTS-HINTS-V3.md) before PR-D code merges (by analogy with SCHEMA Decision 29). +**Merge gates:** This file + [`plans/completed/CURSOR-PROMPTS-HINTS-V3.md`](./CURSOR-PROMPTS-HINTS-V3.md) before PR-D code merges (by analogy with SCHEMA Decision 29). ## Resolved design decisions @@ -187,9 +187,9 @@ Name tests `test_hints_hv{N}_*` matching propose §6 / §4 rows: # Tracking -- Artefacts: _pending_ -- `PR-D`: _pending_ (blocked on SCHEMA PR-C + HINTS-V3 propose **locked**) +- Artefacts: landed ([#160](https://github.com/HumanBean17/java-codebase-rag/pull/160)) +- `PR-D`: _completed_ ## Cursor handoff -[`plans/CURSOR-PROMPTS-HINTS-V3.md`](./CURSOR-PROMPTS-HINTS-V3.md) +[`plans/completed/CURSOR-PROMPTS-HINTS-V3.md`](./CURSOR-PROMPTS-HINTS-V3.md) diff --git a/plans/completed/PLAN-SCHEMA-V2.md b/plans/completed/PLAN-SCHEMA-V2.md index cbb6bb4..0e86030 100644 --- a/plans/completed/PLAN-SCHEMA-V2.md +++ b/plans/completed/PLAN-SCHEMA-V2.md @@ -1,13 +1,13 @@ # Plan: SCHEMA-V2 (edge navigation schema, HTTP/ASYNC caller-side flips, Producer node) -Status: **completed** — PR-A/B/C landed; PR-D in [`plans/PLAN-HINTS-V3.md`](../PLAN-HINTS-V3.md). Implements +Status: **completed** — PR-A/B/C/D landed; PR-D in [`plans/completed/PLAN-HINTS-V3.md`](./PLAN-HINTS-V3.md). Implements [`propose/completed/SCHEMA-V2-PROPOSE.md`](../../propose/completed/SCHEMA-V2-PROPOSE.md). Depends on: -- [`propose/HINTS-V3-PROPOSE.md`](../propose/HINTS-V3-PROPOSE.md) **merged to `master`** before **PR-A** implementation starts (SCHEMA-V2 Decision 30; GitHub PR may stay `draft`). +- [`propose/completed/HINTS-V3-PROPOSE.md`](../propose/completed/HINTS-V3-PROPOSE.md) **merged to `master`** before **PR-A** implementation starts (SCHEMA-V2 Decision 30; GitHub PR may stay `draft`). - [`docs/PROPOSES-ORDER.md`](../docs/PROPOSES-ORDER.md) for lock/merge sequence across proposes and code PRs. -- **PR-D (hints v3)** is specified in [`plans/PLAN-HINTS-V3.md`](../PLAN-HINTS-V3.md) — not repeated here beyond overview/tracking. +- **PR-D (hints v3)** is specified in [`plans/completed/PLAN-HINTS-V3.md`](./PLAN-HINTS-V3.md) — not repeated here beyond overview/tracking. ## Goal @@ -34,7 +34,7 @@ Depends on: | PR-A | `EDGE_SCHEMA`, doc generator, CI invariants, `BROWNFIELD_RESOLVER_STRATEGY_SET`, v14 bump (pre-flip DDL) | **13 → 14** | `typical_traversals` map shape (hints PR-A contract); strategy-set union completeness; v13 refusal gate timing | `test_schema_consistency.py`, `test_edge_navigation_doc.py`, ontology gate | PR-B/C/D | | PR-B | `HTTP_CALLS` flip + downstream Cypher/API + HTTP docs | Uses v14 (no second bump) | `grep` completeness for `HTTP_CALLS`; `HttpCallRow` key change; MCP/PR-analysis two-hop queries | `test_call_edges_e2e.py`, `test_kuzu_queries.py`, `test_pr_analysis.py`, brownfield HTTP | PR-C/D | | PR-C | `Producer`, `DECLARES_PRODUCER`, `ASYNC_CALLS` flip, GraphMeta, MCP producer parity, describe rollups, async docs | Uses v14 | Producer field grounding vs `Client` copy-paste; pass5 materialization order; `find`/`resolve` kind union expansion | `test_call_edges_e2e.py`, `test_brownfield_clients.py`, `test_mcp_v2.py`, `test_client_node_extraction.py`, describe rollups | PR-D | -| PR-D | Hints v3 (empty `neighbors`) | **No** (query-time) | See [`plans/PLAN-HINTS-V3.md`](../PLAN-HINTS-V3.md) | `test_mcp_hints.py` HV* | PR-A/B/C | +| PR-D | Hints v3 (empty `neighbors`) | **No** (query-time) | See [`plans/completed/PLAN-HINTS-V3.md`](./PLAN-HINTS-V3.md) | `test_mcp_hints.py` HV* | PR-A/B/C | **Landing order:** **PR-A → PR-B → PR-C → PR-D** (PR-D plan is separate; no parallel code PRs). @@ -358,7 +358,7 @@ with every hit accounted for (fixed or justified). - `Consumer` node, `NODE_SCHEMA`, DDL codegen from `EDGE_SCHEMA`. - Multi-target Client/Producer nodes; materialized composite edges. -- Hints v3 implementation (PR-D — [`plans/PLAN-HINTS-V3.md`](../PLAN-HINTS-V3.md)). +- Hints v3 implementation (PR-D — [`plans/completed/PLAN-HINTS-V3.md`](./PLAN-HINTS-V3.md)). - Ontology **15** or second re-index. - Ranking / incremental rebuild proposes (`RANKING-MICROSERVICE`, `TIER2-INCREMENTAL-REBUILD`). - Special-casing `tests/bank-chat-system/` in production code. @@ -376,10 +376,10 @@ with every hit accounted for (fixed or justified). - Artefacts (`PLAN-SCHEMA-V2`, `CURSOR-PROMPTS-SCHEMA-V2`): **done** (this directory) - `PR-A` / `PR-B` / `PR-C`: **landed** -- `PR-D`: _see [`plans/PLAN-HINTS-V3.md`](../PLAN-HINTS-V3.md)_ +- `PR-D`: _completed — [`plans/completed/PLAN-HINTS-V3.md`](./PLAN-HINTS-V3.md) ([#160](https://github.com/HumanBean17/java-codebase-rag/pull/160))_ ## Cursor handoff [`plans/CURSOR-PROMPTS-SCHEMA-V2.md`](./CURSOR-PROMPTS-SCHEMA-V2.md) — PR-A/B/C only. -[`plans/CURSOR-PROMPTS-HINTS-V3.md`](./CURSOR-PROMPTS-HINTS-V3.md) — PR-D after PR-C on `master`. +[`plans/completed/CURSOR-PROMPTS-HINTS-V3.md`](./CURSOR-PROMPTS-HINTS-V3.md) — PR-D after PR-C on `master`. diff --git a/propose/HINTS-V3-PROPOSE.md b/propose/completed/HINTS-V3-PROPOSE.md similarity index 99% rename from propose/HINTS-V3-PROPOSE.md rename to propose/completed/HINTS-V3-PROPOSE.md index 0f01990..48a973d 100644 --- a/propose/HINTS-V3-PROPOSE.md +++ b/propose/completed/HINTS-V3-PROPOSE.md @@ -1,6 +1,6 @@ # HINTS-V3 — kind- and direction-aware empty-result hints driven by EDGE_SCHEMA -**Status**: locked — implementing via SCHEMA-V2 PR-D (plan: [`plans/PLAN-HINTS-V3.md`](../plans/PLAN-HINTS-V3.md); move to `propose/completed/` when PR-D lands) +**Status**: **completed** — landed in [#160](https://github.com/HumanBean17/java-codebase-rag/pull/160) (plan: [`plans/completed/PLAN-HINTS-V3.md`](../../plans/completed/PLAN-HINTS-V3.md)) **Author**: Dmitriy Teriaev **Date**: 2026-05-16 diff --git a/propose/completed/SCHEMA-V2-PROPOSE.md b/propose/completed/SCHEMA-V2-PROPOSE.md index 81d52df..2b3bc9d 100644 --- a/propose/completed/SCHEMA-V2-PROPOSE.md +++ b/propose/completed/SCHEMA-V2-PROPOSE.md @@ -1,6 +1,6 @@ # SCHEMA-V2 — edges connect the nodes the edge is about (HTTP_CALLS, ASYNC_CALLS, Producer node, canonical Edge Navigation Schema) -**Status**: **completed** — PR-A/B/C landed; PR-D (hints v3) tracked in [`plans/PLAN-HINTS-V3.md`](../../plans/PLAN-HINTS-V3.md). Plan: [`plans/completed/PLAN-SCHEMA-V2.md`](../../plans/completed/PLAN-SCHEMA-V2.md). +**Status**: **completed** — PR-A/B/C/D landed; hints v3 in [`propose/completed/HINTS-V3-PROPOSE.md`](./HINTS-V3-PROPOSE.md) ([#160](https://github.com/HumanBean17/java-codebase-rag/pull/160)). Plan: [`plans/completed/PLAN-SCHEMA-V2.md`](../../plans/completed/PLAN-SCHEMA-V2.md). **Author**: Dmitriy Teriaev + Computer **Date**: 2026-05-16 @@ -256,7 +256,7 @@ This closes the internal contradiction the reviewer flagged. - Subject kind matches but direction is wrong → hint says so. - Subject is a Symbol with `symbol_kind in {class, interface, enum, record, annotation}` and the requested edge lives on methods → hint points at `DECLARES`-then-re-query. -Full design in `propose/HINTS-V3-PROPOSE.md` (separate). PR-D in §6 ships the implementation once both proposes land. +Full design in `propose/completed/HINTS-V3-PROPOSE.md` (separate). PR-D in §6 shipped the implementation ([#160](https://github.com/HumanBean17/java-codebase-rag/pull/160)). ## §4 — Use-case re-walk @@ -348,7 +348,7 @@ Full design in `propose/HINTS-V3-PROPOSE.md` (separate). PR-D in §6 ships the i **Title**: `feat(hints): kind- and direction-aware empty-result hints driven by EDGE_SCHEMA` -**Purpose**: Replace `TPL_NEIGHBORS_EMPTY_KIND_CHECK` (generic) with a family of templates driven by `EDGE_SCHEMA`. Detailed surface in `propose/HINTS-V3-PROPOSE.md` (separate propose). +**Purpose**: Replace `TPL_NEIGHBORS_EMPTY_KIND_CHECK` (generic) with a family of templates driven by `EDGE_SCHEMA`. Detailed surface in `propose/completed/HINTS-V3-PROPOSE.md` (separate propose). **Gating** (Decision 30): PR-D is **blocked** until `propose/HINTS-V3-PROPOSE.md` exists as a draft PR. Shipping v2 graph shape without v3 hints would leave the wrong-subject-kind footgun (UC3: agent holds method id, asks `HTTP_CALLS` outbound, gets `[]` with no guidance to `DECLARES_CLIENT`). The hints-v3 propose must therefore land in this same review cycle, before PR-A merges. From f92a4193df4e73145b28f22799ac8c80121a73d6 Mon Sep 17 00:00:00 2001 From: Dmitry Teryaev Date: Sat, 16 May 2026 20:41:42 +0300 Subject: [PATCH 4/4] fix archived HINTS-V3 cursor prompt paths Co-authored-by: Cursor --- plans/completed/CURSOR-PROMPTS-HINTS-V3.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/plans/completed/CURSOR-PROMPTS-HINTS-V3.md b/plans/completed/CURSOR-PROMPTS-HINTS-V3.md index c460e89..5b99340 100644 --- a/plans/completed/CURSOR-PROMPTS-HINTS-V3.md +++ b/plans/completed/CURSOR-PROMPTS-HINTS-V3.md @@ -22,13 +22,13 @@ One prompt: **PR-D** (= SCHEMA-V2 PR-D in sequence doc). **Branch:** `feat/hints-v3-neighbors-empty` off `master` **after PR-SCHEMA-V2-C merged**. **Base:** `master` at merge commit of PR-C. -**Plan section:** [`plans/PLAN-HINTS-V3.md`](./PLAN-HINTS-V3.md) § PR-D. +**Plan section:** [`plans/completed/PLAN-HINTS-V3.md`](./PLAN-HINTS-V3.md) § PR-D. **PR title:** `feat(hints): kind- and direction-aware empty-result hints driven by EDGE_SCHEMA` **Attach (`@-files`):** -- `@plans/PLAN-HINTS-V3.md` -- `@propose/HINTS-V3-PROPOSE.md` (§3–§4, §6, Decisions §7) +- `@plans/completed/PLAN-HINTS-V3.md` +- `@propose/completed/HINTS-V3-PROPOSE.md` (§3–§4, §6, Decisions §7) - `@propose/completed/SCHEMA-V2-PROPOSE.md` (§3.12 preview — read only) - `@java_ontology.py` (`EDGE_SCHEMA`, `FUZZY_STRATEGY_SET`) - `@mcp_hints.py`