java-codebase-rag 0.1.0__py3-none-any.whl → 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
mcp_hints.py CHANGED
@@ -10,153 +10,129 @@ Priority cap: same propose §7.12 / ``plans/completed/PLAN-HINTS.md`` principles
10
10
 
11
11
  from __future__ import annotations
12
12
 
13
- from typing import Any, Literal
13
+ import json
14
+ from typing import Any, Literal, NamedTuple
14
15
 
15
16
  from java_ontology import EDGE_SCHEMA, FUZZY_STRATEGY_SET
16
17
 
17
18
  # Normative schema description (propose §3.1) — imported by ``mcp_v2`` for Field(description=...).
18
- MCP_HINTS_FIELD_DESCRIPTION = (
19
- "Road-sign hints pointing to likely next calls. Each hint is a short string "
20
- "referencing one MCP V2 tool call. Hints are advisory and may be safely ignored. "
21
- "Maximum 5 hints per output. Describe-time type rollup hints may recommend "
22
- "DECLARES.* and OVERRIDDEN_BY.* dot-keys for neighbors() on matching Symbol origins; "
23
- "empty neighbors structural hints never use "
24
- "dot-key edge labels. For neighbors with multiple origin ids, empty-result "
25
- "structural hints describe the first origin only. On neighbors with "
26
- "edge_types=['CALLS'] only, optional edge_filter projects the ordered CALLS stream "
27
- "(min_confidence, strategies, callee_declaring_role axes); fail-loud with composed "
28
- "dot-keys or additional stored labels. include_unresolved interleaves "
29
- "UnresolvedCallSite rows (mutually exclusive with edge_filter). dedup_calls collapses "
30
- "identical (origin, callee) CALLS rows."
19
+ MCP_HINTS_STRUCTURED_FIELD_DESCRIPTION = (
20
+ "Machine-parseable next-action objects. Each element has "
21
+ "label (short semantic name, e.g. 'routes via members', 'implementors'), "
22
+ "tool (MCP tool name), args (ready-to-use parameters), and actionable "
23
+ "(True = direct call with complete args; False = advisory/partial — agent "
24
+ "fills missing values or uses as guidance). reason explains why the hint was emitted. "
25
+ "Advisories (separate field) carry pure informational text with no tool call suggestion."
31
26
  )
32
27
 
33
- # --- Appendix A verbatim templates (substitute {id}, {kind}, {limit}) ---
34
-
35
- TPL_DESCRIBE_TYPE_CLIENTS_VIA_MEMBERS = (
36
- "clients via members: neighbors(['{id}'],'out',['DECLARES.DECLARES_CLIENT'])"
37
- )
38
- TPL_DESCRIBE_TYPE_ROUTES_VIA_MEMBERS = (
39
- "routes via members: neighbors(['{id}'],'out',['DECLARES.EXPOSES'])"
40
- )
41
- TPL_DESCRIBE_TYPE_PRODUCERS_VIA_MEMBERS = (
42
- "producers via members: neighbors(['{id}'],'out',['DECLARES.DECLARES_PRODUCER'])"
43
- )
44
- TPL_DESCRIBE_METHOD_OVERRIDERS = "overriders: neighbors(['{id}'],'out',['OVERRIDDEN_BY'])"
45
- TPL_DESCRIBE_METHOD_CLIENTS_IN_OVERRIDERS = (
46
- "clients in overriders: neighbors(['{id}'],'out',['OVERRIDDEN_BY.DECLARES_CLIENT'])"
47
- )
48
- TPL_DESCRIBE_METHOD_PRODUCERS_IN_OVERRIDERS = (
49
- "producers in overriders: neighbors(['{id}'],'out',['OVERRIDDEN_BY.DECLARES_PRODUCER'])"
50
- )
51
- TPL_DESCRIBE_METHOD_ROUTES_IN_OVERRIDERS = (
52
- "routes in overriders: neighbors(['{id}'],'out',['OVERRIDDEN_BY.EXPOSES'])"
53
- )
54
- TPL_DESCRIBE_METHOD_OUTBOUND_CLIENT = "outbound client: neighbors(['{id}'],'out',['DECLARES_CLIENT'])"
55
- TPL_DESCRIBE_METHOD_OUTBOUND_PRODUCER = "outbound producer: neighbors(['{id}'],'out',['DECLARES_PRODUCER'])"
56
- TPL_DESCRIBE_METHOD_INBOUND_ROUTE = "inbound route: neighbors(['{id}'],'out',['EXPOSES'])"
57
- TPL_DESCRIBE_METHOD_MANY_CALLS = "many CALLS consider filtering by target microservice"
58
- TPL_DESCRIBE_ROUTE_DECLARING = "declaring method: neighbors(['{id}'],'in',['EXPOSES'])"
59
- TPL_DESCRIBE_CLIENT_DECLARING = "declaring method: neighbors(['{id}'],'in',['DECLARES_CLIENT'])"
60
- TPL_DESCRIBE_PRODUCER_DECLARING = "declaring method: neighbors(['{id}'],'in',['DECLARES_PRODUCER'])"
61
-
62
- TPL_FIND_EMPTY_RESOLVE = "no matches — try resolve(identifier, hint_kind='{kind}') for canonical lookup"
63
- TPL_FIND_PAGE_FULL = "result page full at {limit} — narrow filter or paginate"
64
- TPL_FIND_SUCCESS_HANDLER = "handler: neighbors(['{id}'],'in',['EXPOSES'])"
65
- TPL_FIND_SUCCESS_HTTP_TARGETS = "HTTP targets: neighbors(['{id}'],'out',['HTTP_CALLS'])"
66
- TPL_FIND_SUCCESS_ASYNC_TARGETS = "async targets: neighbors(['{id}'],'out',['ASYNC_CALLS'])"
67
-
68
- _FIND_SUCCESS_MAX_CHARS = 120
69
-
70
- TPL_NEIGHBORS_WRONG_SUBJECT_KIND = (
71
- "0 results — '{edge}' connects {src_kind} → {dst_kind}; "
72
- "this is a {subject_kind}. Try: {canonical_traversal}"
73
- )
74
-
75
- TPL_NEIGHBORS_WRONG_DIRECTION = (
76
- "0 results — '{edge}' is {src_kind} → {dst_kind}; "
77
- "you requested direction='{requested_dir}'. Try direction='{correct_dir}'."
78
- )
79
-
80
- TPL_NEIGHBORS_TYPE_LEVEL_REQUERY = (
81
- "0 results — '{edge}' lives on methods, not on {subject_kind}. "
82
- "Try: {canonical_traversal}"
83
- )
84
-
85
- TPL_NEIGHBORS_BROWNFIELD_RESOLVED_MAYBE_UNRESOLVED = (
86
- "edges on '{edge}' are emitted by the brownfield resolver — "
87
- "absence here may mean unresolved (no matching annotation/target), "
88
- "not absent from the codebase"
89
- )
28
+ # --- Internal structured hint representation (no mcp_v2 import) ---
29
+
30
+
31
+ class _StructuredHint(NamedTuple):
32
+ tool: str
33
+ args: dict[str, Any]
34
+ actionable: bool
35
+ priority: int
36
+ label: str = ""
37
+ reason: str = ""
38
+
39
+
40
+ def finalize_structured_hints(scored: list[_StructuredHint]) -> list[_StructuredHint]:
41
+ """Dedupe by ``(tool, json.dumps(args, sort_keys=True))``, keep highest priority, cap to 5."""
42
+ best: dict[tuple[str, str], tuple[int, int]] = {}
43
+ hints: dict[tuple[str, str], _StructuredHint] = {}
44
+ for idx, h in enumerate(scored):
45
+ key = (h.tool, json.dumps(h.args, sort_keys=True))
46
+ prev = best.get(key)
47
+ if prev is None or h.priority > prev[0]:
48
+ best[key] = (h.priority, idx)
49
+ hints[key] = h
50
+ elif h.priority == prev[0]:
51
+ best[key] = (h.priority, min(prev[1], idx))
52
+ ordered = sorted(best.items(), key=lambda kv: (-kv[1][0], kv[1][1]))
53
+ return [hints[k] for k, _ in ordered[:5]]
90
54
 
91
- TPL_SEARCH_WEAK = "results look weak — narrow the query or try find(role=…)"
92
55
 
93
- # --- v2: resolve templates (propose/HINTS-V2-PROPOSE.md Appendix A) ---
56
+ def finalize_advisories(scored: list[tuple[int, str]]) -> list[str]:
57
+ """Dedupe identical strings keeping the highest priority; cap to 5 (drop lowest).
94
58
 
95
- TPL_RESOLVE_NONE_TRY_SEARCH = (
96
- "no match — try search(query='{identifier}') for ranked fuzzy lookup"
97
- )
98
- TPL_RESOLVE_NONE_TRY_FIND_ROUTE = (
99
- "no match — try find(kind='route', filter={{path_prefix: '{seed}'}})"
100
- )
101
- TPL_RESOLVE_NONE_TRY_FIND_CLIENT = (
102
- "no match try find(kind='client', filter={{target_service: '{seed}'}})"
103
- )
104
- TPL_RESOLVE_MANY_TIGHTEN = (
105
- "{n} candidates tighten identifier or pick a candidate by id"
106
- )
59
+ Within the same priority tier, keep advisories in emission order (first scored wins the cap).
60
+ """
61
+ best: dict[str, tuple[int, int]] = {}
62
+ for idx, (pri, text) in enumerate(scored):
63
+ if not text:
64
+ continue
65
+ prev = best.get(text)
66
+ if prev is None or pri > prev[0]:
67
+ best[text] = (pri, idx)
68
+ elif pri == prev[0]:
69
+ best[text] = (pri, min(prev[1], idx))
70
+ ordered = sorted(best.items(), key=lambda kv: (-kv[1][0], kv[1][1]))
71
+ return [text for text, _pri in ordered[:5]]
107
72
 
108
- _RESOLVE_HINT_MAX_CHARS = 120
109
- _RESOLVE_WILDCARDS = ("*", "?")
110
73
 
111
- TPL_NEIGHBORS_FUZZY_STRATEGY = (
112
- "some edges resolved via brownfield/fallback strategy — check attrs.strategy on each row"
113
- )
74
+ _MEMBER_ONLY_DOT_KEY: dict[str, str] = {
75
+ "DECLARES_CLIENT": "DECLARES.DECLARES_CLIENT",
76
+ "DECLARES_PRODUCER": "DECLARES.DECLARES_PRODUCER",
77
+ "EXPOSES": "DECLARES.EXPOSES",
78
+ }
114
79
 
115
- TPL_NEIGHBORS_CALLS_ROLE_FILTER_OTHER_FALLBACK = (
116
- "0 CALLS matched callee_declaring_role filter but method has many callees — "
117
- "targets may be OTHER (interface/JDK); try "
118
- "edge_filter={{exclude_callee_declaring_roles: ['ENTITY','DTO']}} instead of role exact match"
119
- )
80
+ _CALLS_HIGH_FANOUT_THRESHOLD = 10
81
+ _EDGE_DECLARES_CLIENT = frozenset({"DECLARES_CLIENT", "DECLARES.DECLARES_CLIENT"})
82
+ _EDGE_DECLARES_PRODUCER = frozenset({"DECLARES_PRODUCER", "DECLARES.DECLARES_PRODUCER"})
120
83
 
121
- TPL_NEIGHBORS_CALLS_NODEFILTER_ROLE_COLLISION = (
122
- "NodeFilter.role filters the neighbor method's role (usually OTHER), not the callee's "
123
- "declaring type — use edge_filter={{callee_declaring_role: 'SERVICE'}} (or REPOSITORY) "
124
- "for CALLS stereotype projection"
125
- )
126
84
 
127
- _CALLS_HIGH_FANOUT_THRESHOLD = 10
128
85
 
129
- TPL_NEIGHBORS_CALLS_HIGH_FANOUT = (
130
- "{n} CALLS on this method; the noisy axes are callee_declaring_role "
131
- "and per-call-site multiplicity. Try edge_filter={{callee_declaring_role: 'SERVICE'}} "
132
- "for delegation hops, edge_filter={{exclude_callee_declaring_roles: ['ENTITY','DTO']}} "
133
- "to drop accessor noise, edge_filter={{min_confidence: 0.5}} to trim low-confidence rows "
134
- "(exclude_external is find_callers-only, not neighbors), or dedup_calls=True to collapse "
135
- "identical callees."
136
- )
137
86
 
138
- TPL_NEIGHBORS_CALLS_HAS_UNRESOLVED = (
139
- "{n} CALLS shown; this method also has {k} unresolved call sites "
140
- "(see describe(method_id).unresolved_call_sites, or call neighbors with "
141
- "include_unresolved=True for a source-ordered interleaved view — note "
142
- "include_unresolved is mutually exclusive with edge_filter)."
143
- )
144
87
 
145
- # v4 neighbors success-path (propose/HINTS-V4-SUCCESS-PATH-PROPOSE.md); N1a/N1b alias describe templates.
146
- TPL_NEIGHBORS_SUCCESS_HTTP_TARGETS = "HTTP targets: neighbors(client_ids,'out',['HTTP_CALLS'])"
147
- TPL_NEIGHBORS_SUCCESS_ASYNC_TARGETS = "async targets: neighbors(producer_ids,'out',['ASYNC_CALLS'])"
148
- TPL_NEIGHBORS_SUCCESS_CALLERS = "callers: neighbors(handler_ids,'in',['CALLS'])"
149
- TPL_NEIGHBORS_SUCCESS_DECLARING_CLIENT = (
150
- "declaring method: neighbors(client_ids,'in',['DECLARES_CLIENT'])"
151
- )
152
- TPL_NEIGHBORS_SUCCESS_DECLARING_PRODUCER = (
153
- "declaring method: neighbors(producer_ids,'in',['DECLARES_PRODUCER'])"
154
- )
155
- TPL_NEIGHBORS_SUCCESS_HANDLER = "handler: neighbors(route_ids,'in',['EXPOSES'])"
156
88
 
157
- _NEIGHBORS_SUCCESS_MAX_CHARS = 120
158
- _EDGE_DECLARES_CLIENT = frozenset({"DECLARES_CLIENT", "DECLARES.DECLARES_CLIENT"})
159
- _EDGE_DECLARES_PRODUCER = frozenset({"DECLARES_PRODUCER", "DECLARES.DECLARES_PRODUCER"})
89
+ def _extract_other_ids(results: list[dict[str, Any]]) -> list[str]:
90
+ ids: list[str] = []
91
+ for r in results:
92
+ other = r.get("other")
93
+ if isinstance(other, dict):
94
+ oid = other.get("id")
95
+ if isinstance(oid, str) and oid:
96
+ ids.append(oid)
97
+ return ids
98
+
99
+
100
+ # --- Labels for structured hints (semantic name of each hint) ---
101
+
102
+ LABEL_CLIENTS_VIA_MEMBERS = "clients via members"
103
+ LABEL_ROUTES_VIA_MEMBERS = "routes via members"
104
+ LABEL_PRODUCERS_VIA_MEMBERS = "producers via members"
105
+ LABEL_OVERRIDERS = "overriders"
106
+ LABEL_CLIENTS_IN_OVERRIDERS = "clients in overriders"
107
+ LABEL_PRODUCERS_IN_OVERRIDERS = "producers in overriders"
108
+ LABEL_ROUTES_IN_OVERRIDERS = "routes in overriders"
109
+ LABEL_OUTBOUND_CLIENT = "outbound client"
110
+ LABEL_OUTBOUND_PRODUCER = "outbound producer"
111
+ LABEL_INBOUND_ROUTE = "inbound route"
112
+ LABEL_HIGH_FANOUT = "high fanout"
113
+ LABEL_DECLARING_METHOD = "declaring method"
114
+ LABEL_IMPLEMENTORS = "implementors"
115
+ LABEL_IMPLEMENTS = "implements"
116
+ LABEL_DEPENDENCIES = "dependencies"
117
+ LABEL_INJECTORS = "injectors"
118
+ LABEL_OUTBOUND_CALLS = "outbound calls"
119
+ LABEL_SUPER_DECLARATION = "super declaration"
120
+ LABEL_UNRESOLVED = "unresolved"
121
+ LABEL_TRY_RESOLVE = "try resolve"
122
+ LABEL_PAGE_FULL = "page full"
123
+ LABEL_HANDLER = "handler"
124
+ LABEL_HTTP_TARGETS = "HTTP targets"
125
+ LABEL_ASYNC_TARGETS = "async targets"
126
+ LABEL_WEAK_RESULTS = "weak results"
127
+ LABEL_TRY_SEARCH = "try search"
128
+ LABEL_TRY_FIND_ROUTE = "try find route"
129
+ LABEL_TRY_FIND_CLIENT = "try find client"
130
+ LABEL_TIGHTEN_IDENTIFIER = "tighten identifier"
131
+ LABEL_FUZZY_STRATEGY = "fuzzy strategy"
132
+ LABEL_CALLERS = "callers"
133
+ LABEL_WRONG_SUBJECT_KIND = "wrong subject kind"
134
+ LABEL_WRONG_DIRECTION = "wrong direction"
135
+ LABEL_TYPE_LEVEL_REQUERY = "type-level requery"
160
136
 
161
137
  # §7.12 priority: DECLARES.* type rollups > OVERRIDDEN_BY.* > leaf follow-ups > meta.
162
138
  PRIORITY_DECLARES_TYPE_ROLLUP = 4
@@ -189,6 +165,27 @@ def _out_count(edge_summary: dict[str, Any] | None, key: str) -> int:
189
165
  return int(cell.get("out", 0) or 0)
190
166
 
191
167
 
168
+ def _in_count(edge_summary: dict[str, Any] | None, key: str) -> int:
169
+ if not edge_summary or key not in edge_summary:
170
+ return 0
171
+ cell = edge_summary[key]
172
+ if not isinstance(cell, dict):
173
+ return 0
174
+ return int(cell.get("in", 0) or 0)
175
+
176
+
177
+ def _record_role(rec: dict[str, Any]) -> str:
178
+ return str((rec.get("data") or {}).get("role") or rec.get("role") or "")
179
+
180
+
181
+ def _type_rollup_would_emit(edge_summary: dict[str, Any] | None) -> bool:
182
+ return (
183
+ _out_count(edge_summary, "DECLARES.DECLARES_CLIENT") > 0
184
+ or _out_count(edge_summary, "DECLARES.EXPOSES") > 0
185
+ or _out_count(edge_summary, "DECLARES.DECLARES_PRODUCER") > 0
186
+ )
187
+
188
+
192
189
  def _symbol_declaration_kind(record: dict[str, Any]) -> str | None:
193
190
  data = record.get("data")
194
191
  if isinstance(data, dict):
@@ -229,102 +226,9 @@ def typical_traversal_for(
229
226
  return template.format(id=subject_id, direction=direction, edge=edge)
230
227
 
231
228
 
232
- def neighbors_empty_hints(
233
- *,
234
- subject_record: dict[str, Any],
235
- requested_edge_types: list[str],
236
- requested_direction: Literal["in", "out"],
237
- ) -> list[tuple[int, str]]:
238
- """Structural empty-neighbors hints from ``EDGE_SCHEMA`` (at most one row 1–3 per edge)."""
239
- pairs: list[tuple[int, str]] = []
240
- subject_label = _subject_node_label(subject_record)
241
- subject_id = str(subject_record.get("id") or "")
242
-
243
- for edge in requested_edge_types:
244
- spec = EDGE_SCHEMA.get(edge)
245
- if spec is None:
246
- continue
247
-
248
- if subject_label != spec.src and subject_label != spec.dst:
249
- role = _traversal_role_for_wrong_kind(subject_label, subject_record)
250
- trav = typical_traversal_for(
251
- edge, role, subject_id=subject_id, direction=requested_direction,
252
- )
253
- pairs.append(
254
- (
255
- PRIORITY_META,
256
- TPL_NEIGHBORS_WRONG_SUBJECT_KIND.format(
257
- edge=edge,
258
- src_kind=spec.src,
259
- dst_kind=spec.dst,
260
- subject_kind=subject_label,
261
- canonical_traversal=trav,
262
- ),
263
- )
264
- )
265
- continue
266
-
267
- wrong_direction = spec.src != spec.dst and (
268
- (requested_direction == "out" and subject_label == spec.dst)
269
- or (requested_direction == "in" and subject_label == spec.src)
270
- )
271
- if wrong_direction:
272
- correct_dir = "in" if requested_direction == "out" else "out"
273
- pairs.append(
274
- (
275
- PRIORITY_META,
276
- TPL_NEIGHBORS_WRONG_DIRECTION.format(
277
- edge=edge,
278
- src_kind=spec.src,
279
- dst_kind=spec.dst,
280
- requested_dir=requested_direction,
281
- correct_dir=correct_dir,
282
- ),
283
- )
284
- )
285
- continue
286
-
287
- if (
288
- subject_label == "Symbol"
289
- and str(subject_record.get("kind") or "") in _TYPE_SYMBOL_KINDS
290
- and spec.member_only
291
- ):
292
- trav = typical_traversal_for(
293
- edge, "type_subject", subject_id=subject_id, direction=requested_direction,
294
- )
295
- pairs.append(
296
- (
297
- PRIORITY_META,
298
- TPL_NEIGHBORS_TYPE_LEVEL_REQUERY.format(
299
- edge=edge,
300
- subject_kind=subject_label,
301
- canonical_traversal=trav,
302
- ),
303
- )
304
- )
305
-
306
- if subject_label in _BROWNFIELD_ABSENCE_SUBJECT_LABELS:
307
- for edge in requested_edge_types:
308
- spec = EDGE_SCHEMA.get(edge)
309
- if spec is not None and spec.brownfield_resolver_sourced:
310
- pairs.append(
311
- (
312
- PRIORITY_META,
313
- TPL_NEIGHBORS_BROWNFIELD_RESOLVED_MAYBE_UNRESOLVED.format(edge=edge),
314
- )
315
- )
316
- break
317
229
 
318
- return pairs
319
230
 
320
231
 
321
- def _hint_contains_composed_dotkey(hint: str) -> bool:
322
- return any(prefix in hint for prefix in _COMPOSED_DOT_KEY_PREFIXES)
323
-
324
-
325
- def _filter_neighbors_dotkey_hints(pairs: list[tuple[int, str]]) -> list[tuple[int, str]]:
326
- return [(pri, text) for pri, text in pairs if not _hint_contains_composed_dotkey(text)]
327
-
328
232
 
329
233
  def _neighbors_success_subject_is_type(subject_record: dict[str, Any]) -> bool:
330
234
  return (
@@ -356,73 +260,114 @@ def _neighbors_results_homogeneous(
356
260
  return True
357
261
 
358
262
 
359
- def _append_neighbors_success_hint(pairs: list[tuple[int, str]], text: str) -> None:
360
- # v4 neighbors cap only (describe uses the same N1a/N1b templates without this gate).
361
- if text and len(text) <= _NEIGHBORS_SUCCESS_MAX_CHARS:
362
- pairs.append((PRIORITY_LEAF_FOLLOWUP, text))
363
263
 
364
264
 
365
- def neighbors_calls_fanout_hints(payload: dict[str, Any]) -> list[tuple[int, str]]:
366
- """High-fanout and unresolved-site nudges for CALLS-on-method neighbors (PR-3)."""
367
- pairs: list[tuple[int, str]] = []
368
- req_types = payload.get("requested_edge_types")
369
- if not isinstance(req_types, list) or req_types != ["CALLS"]:
370
- return pairs
371
- if payload.get("include_unresolved"):
372
- return pairs
373
- page_n = len(list(payload.get("results") or []))
374
- calls_n = int(payload.get("calls_row_count") or 0) or page_n
375
- unresolved = int(payload.get("unresolved_count") or 0)
376
- if not payload.get("edge_filter_provided") and calls_n >= _CALLS_HIGH_FANOUT_THRESHOLD:
377
- pairs.append((PRIORITY_LEAF_FOLLOWUP, TPL_NEIGHBORS_CALLS_HIGH_FANOUT.format(n=calls_n)))
378
- if unresolved > 0:
379
- pairs.append(
380
- (PRIORITY_LEAF_FOLLOWUP, TPL_NEIGHBORS_CALLS_HAS_UNRESOLVED.format(n=page_n, k=unresolved))
381
- )
382
- return pairs
383
265
 
384
266
 
385
- def neighbors_calls_meta_hints(payload: dict[str, Any]) -> list[tuple[int, str]]:
386
- """CALLS-specific hints: role-filter OTHER fallback (Decision 20) and NodeFilter.role trap (30)."""
387
- pairs: list[tuple[int, str]] = []
388
- req_types = payload.get("requested_edge_types")
389
- if not isinstance(req_types, list) or req_types != ["CALLS"]:
390
- return pairs
391
- results = list(payload.get("results") or [])
392
- edge_flt = payload.get("edge_filter") if isinstance(payload.get("edge_filter"), dict) else {}
393
- node_flt = payload.get("node_filter") if isinstance(payload.get("node_filter"), dict) else {}
394
- role_exact = edge_flt.get("callee_declaring_role")
395
- if (
396
- role_exact in ("SERVICE", "REPOSITORY")
397
- and not results
398
- and int(payload.get("unfiltered_calls_count") or 0) >= 5
399
- ):
400
- pairs.append((PRIORITY_META, TPL_NEIGHBORS_CALLS_ROLE_FILTER_OTHER_FALLBACK))
401
- node_role = node_flt.get("role")
402
- if node_role and results:
403
- method_rows = [
404
- r
405
- for r in results
406
- if str(((r.get("other") or {}) if isinstance(r.get("other"), dict) else {}).get("symbol_kind") or "")
407
- == "method"
408
- ]
409
- if method_rows:
410
- other_roles = [
411
- str(
412
- ((r.get("other") or {}) if isinstance(r.get("other"), dict) else {}).get("role")
413
- or ""
414
- )
415
- for r in method_rows
416
- ]
417
- if other_roles and sum(1 for role in other_roles if role == "OTHER") >= max(
418
- 1, (len(other_roles) * 3) // 4
419
- ):
420
- pairs.append((PRIORITY_META, TPL_NEIGHBORS_CALLS_NODEFILTER_ROLE_COLLISION))
421
- return pairs
422
267
 
423
268
 
424
- def neighbors_success_hints(payload: dict[str, Any]) -> list[tuple[int, str]]:
425
- """v4 non-empty neighbors follow-ups (N1a–N7); no graph I/O."""
269
+
270
+
271
+ def _any_fuzzy_strategy(edges: list[dict[str, Any]]) -> bool:
272
+ for e in edges:
273
+ attrs = e.get("attrs") if isinstance(e.get("attrs"), dict) else {}
274
+ s = attrs.get("strategy") if isinstance(attrs, dict) else None
275
+ if isinstance(s, str) and s in FUZZY_STRATEGY_SET:
276
+ return True
277
+ return False
278
+
279
+
280
+ def _find_has_identifier_shaped_filter(kind: str, flt: dict[str, Any]) -> bool:
281
+ for name in _IDENTIFIER_FILTER_FIELDS.get(kind, ()):
282
+ val = flt.get(name)
283
+ if val is None:
284
+ continue
285
+ if isinstance(val, str) and val.strip():
286
+ return True
287
+ if not isinstance(val, str):
288
+ return True
289
+ return False
290
+
291
+
292
+
293
+
294
+ def _neighbors_empty_structured_hints(
295
+ *,
296
+ subject_record: dict[str, Any],
297
+ requested_edge_types: list[str],
298
+ requested_direction: Literal["in", "out"],
299
+ ) -> list[_StructuredHint]:
300
+ """Structured counterparts to neighbors empty results (Rows 1–3)."""
301
+ out: list[_StructuredHint] = []
302
+ subject_label = _subject_node_label(subject_record)
303
+ subject_id = str(subject_record.get("id") or "")
304
+
305
+ for edge in requested_edge_types:
306
+ spec = EDGE_SCHEMA.get(edge)
307
+ if spec is None:
308
+ continue
309
+
310
+ # Row 1: wrong subject kind
311
+ if subject_label != spec.src and subject_label != spec.dst:
312
+ role = _traversal_role_for_wrong_kind(subject_label, subject_record)
313
+ if role != "alien_subject":
314
+ template = spec.typical_traversals.get(role, "")
315
+ # Parse template like "neighbors(['{id}'],'out',['DECLARES_CLIENT'])"
316
+ # Intentionally removed regex fallback — the schema-controlled format
317
+ # is stable; silence on format mismatch is acceptable for this low-risk
318
+ # requery hint (main users are search→neighbors divergences, not edge types).
319
+ parts = template.split("'")
320
+ if len(parts) >= 6:
321
+ direction = parts[3]
322
+ edge_type = parts[5].replace("']", "").replace("['", "")
323
+ out.append(_StructuredHint(
324
+ "neighbors",
325
+ {"ids": [subject_id], "direction": direction, "edge_types": [edge_type]},
326
+ False,
327
+ PRIORITY_META,
328
+ LABEL_WRONG_SUBJECT_KIND,
329
+ f"'{edge}' connects {spec.src} → {spec.dst}; this is a {subject_label}.",
330
+ ))
331
+ continue
332
+
333
+ # Row 2: wrong direction
334
+ wrong_direction = spec.src != spec.dst and (
335
+ (requested_direction == "out" and subject_label == spec.dst)
336
+ or (requested_direction == "in" and subject_label == spec.src)
337
+ )
338
+ if wrong_direction:
339
+ correct_dir = "in" if requested_direction == "out" else "out"
340
+ out.append(_StructuredHint(
341
+ "neighbors",
342
+ {"ids": [subject_id], "direction": correct_dir, "edge_types": [edge]},
343
+ False,
344
+ PRIORITY_META,
345
+ LABEL_WRONG_DIRECTION,
346
+ f"'{edge}' is {spec.src} → {spec.dst}; you requested direction='{requested_direction}'.",
347
+ ))
348
+ continue
349
+
350
+ # Row 3: type-level requery
351
+ if (
352
+ subject_label == "Symbol"
353
+ and str(subject_record.get("kind") or "") in _TYPE_SYMBOL_KINDS
354
+ and spec.member_only
355
+ ):
356
+ dot_key = _MEMBER_ONLY_DOT_KEY.get(edge)
357
+ if dot_key:
358
+ out.append(_StructuredHint(
359
+ "neighbors",
360
+ {"ids": [subject_id], "direction": requested_direction, "edge_types": [dot_key]},
361
+ False,
362
+ PRIORITY_META,
363
+ LABEL_TYPE_LEVEL_REQUERY,
364
+ f"'{edge}' lives on methods, not on {subject_label}.",
365
+ ))
366
+ return out
367
+
368
+
369
+ def _neighbors_success_structured_hints(payload: dict[str, Any]) -> list[_StructuredHint]:
370
+ """Structured counterparts to ``neighbors_success_hints`` (N1a–N7)."""
426
371
  if not payload.get("success"):
427
372
  return []
428
373
  results = list(payload.get("results") or [])
@@ -438,7 +383,7 @@ def neighbors_success_hints(payload: dict[str, Any]) -> list[tuple[int, str]]:
438
383
  if direction not in ("in", "out"):
439
384
  return []
440
385
 
441
- pairs: list[tuple[int, str]] = []
386
+ out: list[_StructuredHint] = []
442
387
  origin_id = str(payload.get("origin_id") or "")
443
388
  if not origin_id:
444
389
  origin_id = str(results[0].get("origin_id") or "")
@@ -447,6 +392,7 @@ def neighbors_success_hints(payload: dict[str, Any]) -> list[tuple[int, str]]:
447
392
  isinstance(subject_record, dict) and _neighbors_success_subject_is_type(subject_record)
448
393
  )
449
394
 
395
+ # N1a/N1b: DECLARES out from type → dot-key clients/routes
450
396
  if (
451
397
  edge == "DECLARES"
452
398
  and direction == "out"
@@ -454,168 +400,174 @@ def neighbors_success_hints(payload: dict[str, Any]) -> list[tuple[int, str]]:
454
400
  and _neighbors_results_homogeneous(results, symbol_kinds=_METHOD_SYMBOL_KINDS)
455
401
  ):
456
402
  if origin_id:
457
- _append_neighbors_success_hint(
458
- pairs, TPL_DESCRIBE_TYPE_CLIENTS_VIA_MEMBERS.format(id=origin_id),
459
- )
460
- _append_neighbors_success_hint(
461
- pairs, TPL_DESCRIBE_TYPE_ROUTES_VIA_MEMBERS.format(id=origin_id),
462
- )
463
-
403
+ out.append(_StructuredHint(
404
+ "neighbors",
405
+ {"ids": [origin_id], "direction": "out", "edge_types": ["DECLARES.DECLARES_CLIENT"]},
406
+ True,
407
+ PRIORITY_LEAF_FOLLOWUP,
408
+ LABEL_CLIENTS_VIA_MEMBERS,
409
+ "type has members with DECLARES_CLIENT edges",
410
+ ))
411
+ out.append(_StructuredHint(
412
+ "neighbors",
413
+ {"ids": [origin_id], "direction": "out", "edge_types": ["DECLARES.EXPOSES"]},
414
+ True,
415
+ PRIORITY_LEAF_FOLLOWUP,
416
+ LABEL_ROUTES_VIA_MEMBERS,
417
+ "type has members with EXPOSES edges",
418
+ ))
419
+
420
+ # N2: DECLARES_CLIENT / DECLARES.DECLARES_CLIENT out → HTTP_CALLS
464
421
  if edge in _EDGE_DECLARES_CLIENT and direction == "out":
465
422
  if _neighbors_results_homogeneous(results, endpoint_kind="client"):
466
- _append_neighbors_success_hint(pairs, TPL_NEIGHBORS_SUCCESS_HTTP_TARGETS)
467
-
423
+ other_ids = _extract_other_ids(results)
424
+ out.append(_StructuredHint(
425
+ "neighbors",
426
+ {"ids": other_ids, "direction": "out", "edge_types": ["HTTP_CALLS"]},
427
+ bool(other_ids),
428
+ PRIORITY_LEAF_FOLLOWUP,
429
+ LABEL_HTTP_TARGETS,
430
+ "clients have outbound HTTP_CALLS edges",
431
+ ))
432
+
433
+ # N3: DECLARES_PRODUCER / DECLARES.DECLARES_PRODUCER out → ASYNC_CALLS
468
434
  if edge in _EDGE_DECLARES_PRODUCER and direction == "out":
469
435
  if _neighbors_results_homogeneous(results, endpoint_kind="producer"):
470
- _append_neighbors_success_hint(pairs, TPL_NEIGHBORS_SUCCESS_ASYNC_TARGETS)
471
-
436
+ other_ids = _extract_other_ids(results)
437
+ out.append(_StructuredHint(
438
+ "neighbors",
439
+ {"ids": other_ids, "direction": "out", "edge_types": ["ASYNC_CALLS"]},
440
+ bool(other_ids),
441
+ PRIORITY_LEAF_FOLLOWUP,
442
+ LABEL_ASYNC_TARGETS,
443
+ "producers have outbound ASYNC_CALLS edges",
444
+ ))
445
+
446
+ # N4: EXPOSES in → CALLS (callers)
472
447
  if (
473
448
  edge == "EXPOSES"
474
449
  and direction == "in"
475
450
  and _neighbors_results_homogeneous(results, symbol_kinds=_METHOD_SYMBOL_KINDS)
476
451
  ):
477
- _append_neighbors_success_hint(pairs, TPL_NEIGHBORS_SUCCESS_CALLERS)
478
-
452
+ other_ids = _extract_other_ids(results)
453
+ out.append(_StructuredHint(
454
+ "neighbors",
455
+ {"ids": other_ids, "direction": "in", "edge_types": ["CALLS"]},
456
+ bool(other_ids),
457
+ PRIORITY_LEAF_FOLLOWUP,
458
+ LABEL_CALLERS,
459
+ "handler methods may have inbound CALLS edges",
460
+ ))
461
+
462
+ # N5: HTTP_CALLS in → DECLARES_CLIENT
479
463
  if edge == "HTTP_CALLS" and direction == "in":
480
464
  if _neighbors_results_homogeneous(results, endpoint_kind="client"):
481
- _append_neighbors_success_hint(pairs, TPL_NEIGHBORS_SUCCESS_DECLARING_CLIENT)
482
-
465
+ other_ids = _extract_other_ids(results)
466
+ out.append(_StructuredHint(
467
+ "neighbors",
468
+ {"ids": other_ids, "direction": "in", "edge_types": ["DECLARES_CLIENT"]},
469
+ bool(other_ids),
470
+ PRIORITY_LEAF_FOLLOWUP,
471
+ LABEL_DECLARING_METHOD,
472
+ "clients are declared on methods",
473
+ ))
474
+
475
+ # N6: ASYNC_CALLS in → DECLARES_PRODUCER
483
476
  if edge == "ASYNC_CALLS" and direction == "in":
484
477
  if _neighbors_results_homogeneous(results, endpoint_kind="producer"):
485
- _append_neighbors_success_hint(pairs, TPL_NEIGHBORS_SUCCESS_DECLARING_PRODUCER)
486
-
478
+ other_ids = _extract_other_ids(results)
479
+ out.append(_StructuredHint(
480
+ "neighbors",
481
+ {"ids": other_ids, "direction": "in", "edge_types": ["DECLARES_PRODUCER"]},
482
+ bool(other_ids),
483
+ PRIORITY_LEAF_FOLLOWUP,
484
+ LABEL_DECLARING_METHOD,
485
+ "producers are declared on methods",
486
+ ))
487
+
488
+ # N7: DECLARES.EXPOSES out → EXPOSES (handler)
487
489
  if edge == "DECLARES.EXPOSES" and direction == "out":
488
490
  if _neighbors_results_homogeneous(results, endpoint_kind="route"):
489
- _append_neighbors_success_hint(pairs, TPL_NEIGHBORS_SUCCESS_HANDLER)
490
-
491
- return pairs
492
-
493
-
494
- def _find_is_page_full(payload: dict[str, Any], results: list[dict[str, Any]]) -> bool:
495
- lim = payload.get("limit")
496
- return (
497
- lim is not None
498
- and len(results) >= int(lim)
499
- and payload.get("has_more_results") is True
500
- )
491
+ other_ids = _extract_other_ids(results)
492
+ out.append(_StructuredHint(
493
+ "neighbors",
494
+ {"ids": other_ids, "direction": "in", "edge_types": ["EXPOSES"]},
495
+ bool(other_ids),
496
+ PRIORITY_LEAF_FOLLOWUP,
497
+ LABEL_HANDLER,
498
+ "routes expose handler methods",
499
+ ))
501
500
 
502
-
503
- def _append_find_success_hint(pairs: list[tuple[int, str]], text: str) -> None:
504
- if text and len(text) <= _FIND_SUCCESS_MAX_CHARS:
505
- pairs.append((PRIORITY_LEAF_FOLLOWUP, text))
506
-
507
-
508
- def find_success_hints(payload: dict[str, Any]) -> list[tuple[int, str]]:
509
- """v4 non-empty find follow-ups (F1–F3); no graph I/O."""
510
- if not payload.get("success"):
511
- return []
512
- results = list(payload.get("results") or [])
513
- if not results or _find_is_page_full(payload, results):
514
- return []
515
- node_id = str(results[0].get("id") or "")
516
- if not node_id:
517
- return []
518
- kind = str(payload.get("kind") or "")
519
- pairs: list[tuple[int, str]] = []
520
- if kind == "route":
521
- _append_find_success_hint(pairs, TPL_FIND_SUCCESS_HANDLER.format(id=node_id))
522
- elif kind == "client":
523
- _append_find_success_hint(pairs, TPL_FIND_SUCCESS_HTTP_TARGETS.format(id=node_id))
524
- elif kind == "producer":
525
- _append_find_success_hint(pairs, TPL_FIND_SUCCESS_ASYNC_TARGETS.format(id=node_id))
526
- return pairs
527
-
528
-
529
- def _any_fuzzy_strategy(edges: list[dict[str, Any]]) -> bool:
530
- for e in edges:
531
- attrs = e.get("attrs") if isinstance(e.get("attrs"), dict) else {}
532
- s = attrs.get("strategy") if isinstance(attrs, dict) else None
533
- if isinstance(s, str) and s in FUZZY_STRATEGY_SET:
534
- return True
535
- return False
536
-
537
-
538
- def _find_has_identifier_shaped_filter(kind: str, flt: dict[str, Any]) -> bool:
539
- for name in _IDENTIFIER_FILTER_FIELDS.get(kind, ()):
540
- val = flt.get(name)
541
- if val is None:
542
- continue
543
- if isinstance(val, str) and val.strip():
544
- return True
545
- if not isinstance(val, str):
546
- return True
547
- return False
548
-
549
-
550
- def finalize_hint_list(scored: list[tuple[int, str]]) -> list[str]:
551
- """Dedupe identical rendered strings keeping the highest priority; cap to 5 (drop lowest).
552
-
553
- Within the same priority tier, keep hints in emission order (first scored wins the cap).
554
- """
555
- best: dict[str, tuple[int, int]] = {}
556
- for idx, (pri, text) in enumerate(scored):
557
- if not text:
558
- continue
559
- prev = best.get(text)
560
- if prev is None or pri > prev[0]:
561
- best[text] = (pri, idx)
562
- elif pri == prev[0]:
563
- best[text] = (pri, min(prev[1], idx))
564
- ordered = sorted(best.items(), key=lambda kv: (-kv[1][0], kv[1][1]))
565
- return [text for text, _pri in ordered[:5]]
501
+ return out
566
502
 
567
503
 
568
504
  def generate_hints(
569
505
  output_kind: Literal["search", "find", "describe", "neighbors", "resolve"],
570
506
  payload: dict[str, Any],
571
- ) -> list[str]:
572
- """Return up to 5 road-sign hint strings for a success-only MCP v2 payload dict.
573
-
574
- For ``search`` / ``find`` / ``describe`` / ``neighbors``, callers must pass
575
- ``success: True``; this function returns ``[]`` when ``success`` is false or
576
- missing. The ``resolve`` branch is **status-driven** (``status``,
577
- ``resolved_identifier``, ``candidates``, optional seeds) and does not require
578
- ``success`` in the payload; an explicit ``success: False`` still suppresses
579
- hints (defense in depth).
507
+ ) -> tuple[list[_StructuredHint], list[str]]:
508
+ """Return structured hints and advisories for a success-only MCP v2 payload dict.
509
+
510
+ Returns (structured_hints, advisories) where structured_hints are machine-parseable
511
+ next-action objects and advisories are pure informational strings.
580
512
  """
581
- pairs: list[tuple[int, str]] = []
513
+ struct_pairs: list[_StructuredHint] = []
514
+ advisories: list[tuple[int, str]] = []
582
515
 
583
516
  if output_kind == "resolve":
584
517
  if payload.get("success") is False:
585
- return []
518
+ return ([], [])
586
519
  status = str(payload.get("status") or "")
587
520
  if status == "one":
588
- return []
521
+ return ([], [])
589
522
  if status == "many":
590
523
  n = len(payload.get("candidates") or [])
591
524
  if n > 1:
592
- pairs.append((PRIORITY_META, TPL_RESOLVE_MANY_TIGHTEN.format(n=n)))
593
- return finalize_hint_list(pairs)
525
+ advisories.append((PRIORITY_META, f"{n} candidates — tighten identifier or pick a candidate by id"))
526
+ struct_pairs.append(_StructuredHint(
527
+ "resolve",
528
+ {"identifier": str(payload.get("resolved_identifier") or ""), "hint_kind": str(payload.get("hint_kind") or "")},
529
+ False, PRIORITY_META,
530
+ LABEL_TIGHTEN_IDENTIFIER,
531
+ "multiple matches found for identifier",
532
+ ))
533
+ return (finalize_structured_hints(struct_pairs), finalize_advisories(advisories))
594
534
  if status == "none":
595
535
  identifier = payload.get("resolved_identifier")
596
536
  hint_kind = payload.get("hint_kind")
597
537
  if not isinstance(identifier, str) or not identifier.strip():
598
- return finalize_hint_list(pairs)
599
- if any(w in identifier for w in _RESOLVE_WILDCARDS):
600
- return finalize_hint_list(pairs)
601
- rendered: str | None = None
538
+ return (finalize_structured_hints(struct_pairs), finalize_advisories(advisories))
539
+ if any(w in identifier for w in ("*", "?")):
540
+ return (finalize_structured_hints(struct_pairs), finalize_advisories(advisories))
602
541
  if hint_kind == "route":
603
542
  seed = payload.get("path_prefix_seed")
604
543
  if isinstance(seed, str) and seed.strip():
605
- rendered = TPL_RESOLVE_NONE_TRY_FIND_ROUTE.format(seed=seed)
544
+ advisories.append((PRIORITY_META, f"no match — try find(kind='route', filter={{path_prefix: '{seed}'}})"))
545
+ struct_pairs.append(_StructuredHint(
546
+ "find", {"kind": "route", "filter": {"path_prefix": seed}}, True, PRIORITY_META,
547
+ LABEL_TRY_FIND_ROUTE,
548
+ "no route found for path prefix seed",
549
+ ))
606
550
  elif hint_kind == "client":
607
551
  seed = payload.get("target_service_seed")
608
552
  if isinstance(seed, str) and seed.strip():
609
- rendered = TPL_RESOLVE_NONE_TRY_FIND_CLIENT.format(seed=seed)
553
+ advisories.append((PRIORITY_META, f"no match — try find(kind='client', filter={{target_service: '{seed}'}})"))
554
+ struct_pairs.append(_StructuredHint(
555
+ "find", {"kind": "client", "filter": {"target_service": seed}}, True, PRIORITY_META,
556
+ LABEL_TRY_FIND_CLIENT,
557
+ "no client found for target service seed",
558
+ ))
610
559
  else:
611
- rendered = TPL_RESOLVE_NONE_TRY_SEARCH.format(identifier=identifier)
612
- if rendered is not None and len(rendered) <= _RESOLVE_HINT_MAX_CHARS:
613
- pairs.append((PRIORITY_META, rendered))
614
- return finalize_hint_list(pairs)
615
- return []
560
+ advisories.append((PRIORITY_META, f"no match — try search(query='{identifier}') for ranked fuzzy lookup"))
561
+ struct_pairs.append(_StructuredHint(
562
+ "search", {"query": identifier}, True, PRIORITY_META,
563
+ LABEL_TRY_SEARCH,
564
+ "no exact match found for identifier",
565
+ ))
566
+ return (finalize_structured_hints(struct_pairs), finalize_advisories(advisories))
567
+ return ([], [])
616
568
 
617
569
  if not payload.get("success"):
618
- return []
570
+ return ([], [])
619
571
 
620
572
  if output_kind == "search":
621
573
  results: list[dict[str, Any]] = list(payload.get("results") or [])
@@ -625,8 +577,13 @@ def generate_hints(
625
577
  mx = max(scores)
626
578
  mn = min(scores)
627
579
  if mx > 0.0 and (mx - mn) < 0.1 * mx:
628
- pairs.append((PRIORITY_META, TPL_SEARCH_WEAK))
629
- return finalize_hint_list(pairs)
580
+ advisories.append((PRIORITY_META, "results look weak — narrow the query or try find(role=…)"))
581
+ struct_pairs.append(_StructuredHint(
582
+ "find", {"kind": "symbol", "filter": {"role": "SERVICE"}}, False, PRIORITY_META,
583
+ LABEL_WEAK_RESULTS,
584
+ "search results have low score variance",
585
+ ))
586
+ return (finalize_structured_hints(struct_pairs), finalize_advisories(advisories))
630
587
 
631
588
  if output_kind == "find":
632
589
  kind = str(payload.get("kind") or "")
@@ -634,11 +591,50 @@ def generate_hints(
634
591
  flt = payload.get("filter") if isinstance(payload.get("filter"), dict) else {}
635
592
  lim = payload.get("limit")
636
593
  if not results and _find_has_identifier_shaped_filter(kind, flt):
637
- pairs.append((PRIORITY_META, TPL_FIND_EMPTY_RESOLVE.format(kind=kind)))
638
- if _find_is_page_full(payload, results) and lim is not None:
639
- pairs.append((PRIORITY_META, TPL_FIND_PAGE_FULL.format(limit=int(lim))))
640
- pairs.extend(find_success_hints(payload))
641
- return finalize_hint_list(pairs)
594
+ advisories.append((PRIORITY_META, f"no matches — try resolve(identifier, hint_kind='{kind}') for canonical lookup"))
595
+ identifier = ""
596
+ for fname in _IDENTIFIER_FILTER_FIELDS.get(kind, ()):
597
+ val = flt.get(fname)
598
+ if isinstance(val, str) and val.strip():
599
+ identifier = val.strip()
600
+ break
601
+ struct_pairs.append(_StructuredHint(
602
+ "resolve", {"identifier": identifier, "hint_kind": kind}, True, PRIORITY_META,
603
+ LABEL_TRY_RESOLVE,
604
+ f"no {kind} found with filter",
605
+ ))
606
+ if lim is not None and len(results) >= int(lim) and payload.get("has_more_results") is True:
607
+ advisories.append((PRIORITY_META, f"result page full at {lim} — narrow filter or paginate"))
608
+ struct_pairs.append(_StructuredHint(
609
+ "find", {"kind": kind, "filter": flt, "limit": int(lim)}, False, PRIORITY_META,
610
+ LABEL_PAGE_FULL,
611
+ f"result page full at {lim}",
612
+ ))
613
+ if results and (lim is None or len(results) < int(lim)):
614
+ node_id = str(results[0].get("id") or "")
615
+ if node_id:
616
+ if kind == "route":
617
+ struct_pairs.append(_StructuredHint(
618
+ "neighbors", {"ids": [node_id], "direction": "in", "edge_types": ["EXPOSES"]},
619
+ True, PRIORITY_LEAF_FOLLOWUP,
620
+ LABEL_HANDLER,
621
+ "route exposes handler method",
622
+ ))
623
+ elif kind == "client":
624
+ struct_pairs.append(_StructuredHint(
625
+ "neighbors", {"ids": [node_id], "direction": "out", "edge_types": ["HTTP_CALLS"]},
626
+ True, PRIORITY_LEAF_FOLLOWUP,
627
+ LABEL_HTTP_TARGETS,
628
+ "client has outbound HTTP_CALLS edges",
629
+ ))
630
+ elif kind == "producer":
631
+ struct_pairs.append(_StructuredHint(
632
+ "neighbors", {"ids": [node_id], "direction": "out", "edge_types": ["ASYNC_CALLS"]},
633
+ True, PRIORITY_LEAF_FOLLOWUP,
634
+ LABEL_ASYNC_TARGETS,
635
+ "producer has outbound ASYNC_CALLS edges",
636
+ ))
637
+ return (finalize_structured_hints(struct_pairs), finalize_advisories(advisories))
642
638
 
643
639
  if output_kind == "neighbors":
644
640
  results = list(payload.get("results") or [])
@@ -647,57 +643,142 @@ def generate_hints(
647
643
  req_types = []
648
644
  edge_labels = [str(x).strip() for x in req_types if str(x).strip()]
649
645
  offset = int(payload.get("offset") or 0)
650
- empty_pairs: list[tuple[int, str]] = []
651
- success_pairs: list[tuple[int, str]] = []
652
- meta_pairs: list[tuple[int, str]] = []
646
+ subject_record = payload.get("subject_record")
647
+ requested_direction = payload.get("requested_direction")
648
+ struct_empty: list[_StructuredHint] = []
649
+ struct_success: list[_StructuredHint] = []
650
+ struct_meta: list[_StructuredHint] = []
653
651
  if not results and edge_labels and offset == 0:
654
- subject_record = payload.get("subject_record")
655
- requested_direction = payload.get("requested_direction")
656
652
  if (
657
653
  isinstance(subject_record, dict)
658
654
  and subject_record
659
655
  and requested_direction in ("in", "out")
660
656
  ):
661
- empty_pairs.extend(
662
- neighbors_empty_hints(
657
+ struct_empty.extend(
658
+ _neighbors_empty_structured_hints(
663
659
  subject_record=subject_record,
664
660
  requested_edge_types=edge_labels,
665
661
  requested_direction=requested_direction,
666
662
  )
667
663
  )
664
+ # Brownfield absence advisory (Row 4)
665
+ subject_label = _subject_node_label(subject_record)
666
+ if subject_label in _BROWNFIELD_ABSENCE_SUBJECT_LABELS:
667
+ for edge in edge_labels:
668
+ spec = EDGE_SCHEMA.get(edge)
669
+ if spec is not None and spec.brownfield_resolver_sourced:
670
+ advisories.append((PRIORITY_META, f"edges on '{edge}' are emitted by the brownfield resolver — absence here may mean unresolved (no matching annotation/target), not absent from the codebase"))
671
+ break
668
672
  elif results and offset == 0:
669
- success_pairs = neighbors_success_hints(payload)
670
- meta_pairs.extend(neighbors_calls_meta_hints(payload))
671
- meta_pairs.extend(neighbors_calls_fanout_hints(payload))
672
- if results and _any_fuzzy_strategy(results):
673
- meta_pairs.append((PRIORITY_META, TPL_NEIGHBORS_FUZZY_STRATEGY))
674
- return finalize_hint_list(
675
- _filter_neighbors_dotkey_hints(empty_pairs) + success_pairs + meta_pairs,
676
- )
673
+ struct_success.extend(_neighbors_success_structured_hints(payload))
674
+ # CALLS-specific meta hints
675
+ if isinstance(req_types, list) and req_types == ["CALLS"]:
676
+ if not payload.get("edge_filter_provided") and not payload.get("include_unresolved"):
677
+ calls_n = int(payload.get("calls_row_count") or 0) or len(results)
678
+ if calls_n >= _CALLS_HIGH_FANOUT_THRESHOLD:
679
+ origin_id = str(payload.get("origin_id") or "")
680
+ if origin_id:
681
+ struct_meta.append(_StructuredHint(
682
+ "neighbors",
683
+ {"ids": [origin_id], "direction": "out", "edge_types": ["CALLS"], "edge_filter": {}},
684
+ False, PRIORITY_LEAF_FOLLOWUP,
685
+ LABEL_HIGH_FANOUT,
686
+ f"{calls_n} CALLS — noisy axes are callee_declaring_role and per-call-site multiplicity",
687
+ ))
688
+ # Unresolved sites advisory
689
+ unresolved = int(payload.get("unresolved_count") or 0)
690
+ if unresolved > 0:
691
+ page_n = len(results)
692
+ # Advisory shortened to stay under 200 char cap (test_advisories_char_cap)
693
+ advisories.append((PRIORITY_LEAF_FOLLOWUP, f"{page_n} CALLS shown; {unresolved} unresolved — see describe().unresolved_call_sites or neighbors(include_unresolved=True)"))
694
+ # Fuzzy strategy advisory
695
+ if results and _any_fuzzy_strategy(results):
696
+ advisories.append((PRIORITY_META, "some edges resolved via brownfield/fallback strategy — check attrs.strategy on each row"))
697
+ # Role-filter OTHER fallback advisory
698
+ edge_flt = payload.get("edge_filter") if isinstance(payload.get("edge_filter"), dict) else {}
699
+ role_exact = edge_flt.get("callee_declaring_role")
700
+ if (
701
+ role_exact in ("SERVICE", "REPOSITORY")
702
+ and not results
703
+ and int(payload.get("unfiltered_calls_count") or 0) >= 5
704
+ ):
705
+ advisories.append((PRIORITY_META, "0 CALLS matched callee_declaring_role filter but method has many callees — targets may be OTHER (interface/JDK); try edge_filter={{exclude_callee_declaring_roles: ['ENTITY','DTO']}} instead of role exact match"))
706
+ # NodeFilter.role collision advisory
707
+ node_flt = payload.get("node_filter") if isinstance(payload.get("node_filter"), dict) else {}
708
+ node_role = node_flt.get("role")
709
+ if node_role and results:
710
+ method_rows = [
711
+ r
712
+ for r in results
713
+ if str(((r.get("other") or {}) if isinstance(r.get("other"), dict) else {}).get("symbol_kind") or "")
714
+ == "method"
715
+ ]
716
+ if method_rows:
717
+ other_roles = [
718
+ str(
719
+ ((r.get("other") or {}) if isinstance(r.get("other"), dict) else {}).get("role")
720
+ or ""
721
+ )
722
+ for r in method_rows
723
+ ]
724
+ if other_roles and sum(1 for role in other_roles if role == "OTHER") >= max(
725
+ 1, (len(other_roles) * 3) // 4
726
+ ):
727
+ advisories.append((PRIORITY_META, "NodeFilter.role filters the neighbor method's role (usually OTHER), not the callee's declaring type — use edge_filter={{callee_declaring_role: 'SERVICE'}} (or REPOSITORY) for CALLS stereotype projection"))
728
+ return (finalize_structured_hints(struct_empty + struct_success + struct_meta), finalize_advisories(advisories))
677
729
 
678
730
  if output_kind == "describe":
679
731
  rec = payload.get("record")
680
732
  if not isinstance(rec, dict):
681
- return []
733
+ return ([], [])
682
734
  node_id = str(rec.get("id") or "")
683
735
  if not node_id:
684
- return []
736
+ return ([], [])
685
737
  kind = str(rec.get("kind") or "")
686
738
  es = rec.get("edge_summary")
687
739
  edge_summary = es if isinstance(es, dict) else None
688
740
 
689
741
  if kind == "route":
690
- pairs.append((PRIORITY_LEAF_FOLLOWUP, TPL_DESCRIBE_ROUTE_DECLARING.format(id=node_id)))
691
- return finalize_hint_list(pairs)
742
+ struct_pairs.append(_StructuredHint(
743
+ "neighbors", {"ids": [node_id], "direction": "in", "edge_types": ["EXPOSES"]},
744
+ True, PRIORITY_LEAF_FOLLOWUP,
745
+ LABEL_DECLARING_METHOD,
746
+ "route exposes handler method",
747
+ ))
748
+ return (finalize_structured_hints(struct_pairs), finalize_advisories(advisories))
692
749
  if kind == "client":
693
- pairs.append((PRIORITY_LEAF_FOLLOWUP, TPL_DESCRIBE_CLIENT_DECLARING.format(id=node_id)))
694
- return finalize_hint_list(pairs)
750
+ struct_pairs.append(_StructuredHint(
751
+ "neighbors", {"ids": [node_id], "direction": "in", "edge_types": ["DECLARES_CLIENT"]},
752
+ True, PRIORITY_LEAF_FOLLOWUP,
753
+ LABEL_DECLARING_METHOD,
754
+ "client is declared on a method",
755
+ ))
756
+ if _out_count(edge_summary, "HTTP_CALLS") > 0:
757
+ struct_pairs.append(_StructuredHint(
758
+ "neighbors", {"ids": [node_id], "direction": "out", "edge_types": ["HTTP_CALLS"]},
759
+ True, PRIORITY_LEAF_FOLLOWUP,
760
+ LABEL_HTTP_TARGETS,
761
+ "client has outbound HTTP_CALLS edges",
762
+ ))
763
+ return (finalize_structured_hints(struct_pairs), finalize_advisories(advisories))
695
764
  if kind == "producer":
696
- pairs.append((PRIORITY_LEAF_FOLLOWUP, TPL_DESCRIBE_PRODUCER_DECLARING.format(id=node_id)))
697
- return finalize_hint_list(pairs)
765
+ struct_pairs.append(_StructuredHint(
766
+ "neighbors", {"ids": [node_id], "direction": "in", "edge_types": ["DECLARES_PRODUCER"]},
767
+ True, PRIORITY_LEAF_FOLLOWUP,
768
+ LABEL_DECLARING_METHOD,
769
+ "producer is declared on a method",
770
+ ))
771
+ if _out_count(edge_summary, "ASYNC_CALLS") > 0:
772
+ struct_pairs.append(_StructuredHint(
773
+ "neighbors", {"ids": [node_id], "direction": "out", "edge_types": ["ASYNC_CALLS"]},
774
+ True, PRIORITY_LEAF_FOLLOWUP,
775
+ LABEL_ASYNC_TARGETS,
776
+ "producer has outbound ASYNC_CALLS edges",
777
+ ))
778
+ return (finalize_structured_hints(struct_pairs), finalize_advisories(advisories))
698
779
 
699
780
  if kind != "symbol":
700
- return finalize_hint_list(pairs)
781
+ return (finalize_structured_hints(struct_pairs), finalize_advisories(advisories))
701
782
 
702
783
  decl_kind = _symbol_declaration_kind(rec)
703
784
  is_type = decl_kind in _TYPE_SYMBOL_KINDS
@@ -705,44 +786,147 @@ def generate_hints(
705
786
 
706
787
  if is_type:
707
788
  if _out_count(edge_summary, "DECLARES.DECLARES_CLIENT") > 0:
708
- pairs.append(
709
- (PRIORITY_DECLARES_TYPE_ROLLUP, TPL_DESCRIBE_TYPE_CLIENTS_VIA_MEMBERS.format(id=node_id))
710
- )
789
+ struct_pairs.append(_StructuredHint(
790
+ "neighbors", {"ids": [node_id], "direction": "out", "edge_types": ["DECLARES.DECLARES_CLIENT"]},
791
+ True, PRIORITY_DECLARES_TYPE_ROLLUP,
792
+ LABEL_CLIENTS_VIA_MEMBERS,
793
+ "type has members with DECLARES_CLIENT edges",
794
+ ))
711
795
  if _out_count(edge_summary, "DECLARES.EXPOSES") > 0:
712
- pairs.append(
713
- (PRIORITY_DECLARES_TYPE_ROLLUP, TPL_DESCRIBE_TYPE_ROUTES_VIA_MEMBERS.format(id=node_id))
714
- )
796
+ struct_pairs.append(_StructuredHint(
797
+ "neighbors", {"ids": [node_id], "direction": "out", "edge_types": ["DECLARES.EXPOSES"]},
798
+ True, PRIORITY_DECLARES_TYPE_ROLLUP,
799
+ LABEL_ROUTES_VIA_MEMBERS,
800
+ "type has members with EXPOSES edges",
801
+ ))
715
802
  if _out_count(edge_summary, "DECLARES.DECLARES_PRODUCER") > 0:
716
- pairs.append(
717
- (PRIORITY_DECLARES_TYPE_ROLLUP, TPL_DESCRIBE_TYPE_PRODUCERS_VIA_MEMBERS.format(id=node_id))
718
- )
719
- return finalize_hint_list(pairs)
803
+ struct_pairs.append(_StructuredHint(
804
+ "neighbors", {"ids": [node_id], "direction": "out", "edge_types": ["DECLARES.DECLARES_PRODUCER"]},
805
+ True, PRIORITY_DECLARES_TYPE_ROLLUP,
806
+ LABEL_PRODUCERS_VIA_MEMBERS,
807
+ "type has members with DECLARES_PRODUCER edges",
808
+ ))
809
+
810
+ if not _type_rollup_would_emit(edge_summary):
811
+ if decl_kind == "interface" and _in_count(edge_summary, "IMPLEMENTS") > 0:
812
+ struct_pairs.append(_StructuredHint(
813
+ "neighbors", {"ids": [node_id], "direction": "in", "edge_types": ["IMPLEMENTS"]},
814
+ True, PRIORITY_LEAF_FOLLOWUP,
815
+ LABEL_IMPLEMENTORS,
816
+ "interface is implemented by other types",
817
+ ))
818
+ if decl_kind == "class" and _out_count(edge_summary, "IMPLEMENTS") > 0:
819
+ struct_pairs.append(_StructuredHint(
820
+ "neighbors", {"ids": [node_id], "direction": "out", "edge_types": ["IMPLEMENTS"]},
821
+ True, PRIORITY_LEAF_FOLLOWUP,
822
+ LABEL_IMPLEMENTS,
823
+ "class implements other types",
824
+ ))
825
+ if decl_kind == "class" and _record_role(rec) == "SERVICE" and _out_count(edge_summary, "INJECTS") > 0:
826
+ struct_pairs.append(_StructuredHint(
827
+ "neighbors", {"ids": [node_id], "direction": "out", "edge_types": ["INJECTS"]},
828
+ True, PRIORITY_LEAF_FOLLOWUP,
829
+ LABEL_DEPENDENCIES,
830
+ "SERVICE injects other types",
831
+ ))
832
+ if decl_kind in {"interface", "class"} and _in_count(edge_summary, "INJECTS") > 0:
833
+ struct_pairs.append(_StructuredHint(
834
+ "neighbors", {"ids": [node_id], "direction": "in", "edge_types": ["INJECTS"]},
835
+ True, PRIORITY_LEAF_FOLLOWUP,
836
+ LABEL_INJECTORS,
837
+ "type is injected by other types",
838
+ ))
839
+
840
+ return (finalize_structured_hints(struct_pairs), finalize_advisories(advisories))
720
841
 
721
842
  if is_method:
722
843
  if _out_count(edge_summary, "OVERRIDDEN_BY") > 0:
723
- pairs.append((PRIORITY_OVERRIDDEN_AXIS, TPL_DESCRIBE_METHOD_OVERRIDERS.format(id=node_id)))
844
+ struct_pairs.append(_StructuredHint(
845
+ "neighbors", {"ids": [node_id], "direction": "out", "edge_types": ["OVERRIDDEN_BY"]},
846
+ True, PRIORITY_OVERRIDDEN_AXIS,
847
+ LABEL_OVERRIDERS,
848
+ "method is overridden by other methods",
849
+ ))
724
850
  if _out_count(edge_summary, "OVERRIDDEN_BY.DECLARES_CLIENT") > 0:
725
- pairs.append(
726
- (PRIORITY_OVERRIDDEN_AXIS, TPL_DESCRIBE_METHOD_CLIENTS_IN_OVERRIDERS.format(id=node_id))
727
- )
851
+ struct_pairs.append(_StructuredHint(
852
+ "neighbors", {"ids": [node_id], "direction": "out", "edge_types": ["OVERRIDDEN_BY.DECLARES_CLIENT"]},
853
+ True, PRIORITY_OVERRIDDEN_AXIS,
854
+ LABEL_CLIENTS_IN_OVERRIDERS,
855
+ "overriding methods have DECLARES_CLIENT edges",
856
+ ))
728
857
  if _out_count(edge_summary, "OVERRIDDEN_BY.DECLARES_PRODUCER") > 0:
729
- pairs.append(
730
- (PRIORITY_OVERRIDDEN_AXIS, TPL_DESCRIBE_METHOD_PRODUCERS_IN_OVERRIDERS.format(id=node_id))
731
- )
858
+ struct_pairs.append(_StructuredHint(
859
+ "neighbors", {"ids": [node_id], "direction": "out", "edge_types": ["OVERRIDDEN_BY.DECLARES_PRODUCER"]},
860
+ True, PRIORITY_OVERRIDDEN_AXIS,
861
+ LABEL_PRODUCERS_IN_OVERRIDERS,
862
+ "overriding methods have DECLARES_PRODUCER edges",
863
+ ))
732
864
  if _out_count(edge_summary, "OVERRIDDEN_BY.EXPOSES") > 0:
733
- pairs.append(
734
- (PRIORITY_OVERRIDDEN_AXIS, TPL_DESCRIBE_METHOD_ROUTES_IN_OVERRIDERS.format(id=node_id))
735
- )
865
+ struct_pairs.append(_StructuredHint(
866
+ "neighbors", {"ids": [node_id], "direction": "out", "edge_types": ["OVERRIDDEN_BY.EXPOSES"]},
867
+ True, PRIORITY_OVERRIDDEN_AXIS,
868
+ LABEL_ROUTES_IN_OVERRIDERS,
869
+ "overriding methods have EXPOSES edges",
870
+ ))
736
871
  if _out_count(edge_summary, "DECLARES_CLIENT") > 0:
737
- pairs.append((PRIORITY_LEAF_FOLLOWUP, TPL_DESCRIBE_METHOD_OUTBOUND_CLIENT.format(id=node_id)))
872
+ struct_pairs.append(_StructuredHint(
873
+ "neighbors", {"ids": [node_id], "direction": "out", "edge_types": ["DECLARES_CLIENT"]},
874
+ True, PRIORITY_LEAF_FOLLOWUP,
875
+ LABEL_OUTBOUND_CLIENT,
876
+ "method declares outbound HTTP client",
877
+ ))
738
878
  if _out_count(edge_summary, "DECLARES_PRODUCER") > 0:
739
- pairs.append((PRIORITY_LEAF_FOLLOWUP, TPL_DESCRIBE_METHOD_OUTBOUND_PRODUCER.format(id=node_id)))
879
+ struct_pairs.append(_StructuredHint(
880
+ "neighbors", {"ids": [node_id], "direction": "out", "edge_types": ["DECLARES_PRODUCER"]},
881
+ True, PRIORITY_LEAF_FOLLOWUP,
882
+ LABEL_OUTBOUND_PRODUCER,
883
+ "method declares outbound async producer",
884
+ ))
740
885
  if _out_count(edge_summary, "EXPOSES") > 0:
741
- pairs.append((PRIORITY_LEAF_FOLLOWUP, TPL_DESCRIBE_METHOD_INBOUND_ROUTE.format(id=node_id)))
886
+ struct_pairs.append(_StructuredHint(
887
+ "neighbors", {"ids": [node_id], "direction": "out", "edge_types": ["EXPOSES"]},
888
+ True, PRIORITY_LEAF_FOLLOWUP,
889
+ LABEL_INBOUND_ROUTE,
890
+ "method is exposed as a route handler",
891
+ ))
892
+ calls_out = _out_count(edge_summary, "CALLS")
893
+ if 1 <= calls_out <= 9:
894
+ method_role = _record_role(rec)
895
+ if method_role != "OTHER" or calls_out >= 3:
896
+ struct_pairs.append(_StructuredHint(
897
+ "neighbors", {"ids": [node_id], "direction": "out", "edge_types": ["CALLS"]},
898
+ True, PRIORITY_LEAF_FOLLOWUP,
899
+ LABEL_OUTBOUND_CALLS,
900
+ "method has outbound CALLS edges",
901
+ ))
902
+ if _out_count(edge_summary, "OVERRIDES") > 0:
903
+ if _out_count(edge_summary, "OVERRIDDEN_BY") == 0:
904
+ struct_pairs.append(_StructuredHint(
905
+ "neighbors", {"ids": [node_id], "direction": "out", "edge_types": ["OVERRIDES"]},
906
+ True, PRIORITY_LEAF_FOLLOWUP,
907
+ LABEL_SUPER_DECLARATION,
908
+ "method overrides a super declaration",
909
+ ))
910
+ data = rec.get("data")
911
+ unresolved = 0
912
+ if isinstance(data, dict):
913
+ unresolved = int(data.get("unresolved_call_sites_total") or 0)
914
+ if unresolved > 0:
915
+ struct_pairs.append(_StructuredHint(
916
+ "neighbors", {"ids": [node_id], "direction": "out", "edge_types": ["CALLS"], "include_unresolved": True},
917
+ True, PRIORITY_LEAF_FOLLOWUP,
918
+ LABEL_UNRESOLVED,
919
+ f"method has {unresolved} unresolved call sites",
920
+ ))
742
921
  if _out_count(edge_summary, "CALLS") >= 10:
743
- pairs.append((PRIORITY_LEAF_FOLLOWUP, TPL_DESCRIBE_METHOD_MANY_CALLS))
744
- return finalize_hint_list(pairs)
745
-
746
- return finalize_hint_list(pairs)
747
-
748
- return []
922
+ struct_pairs.append(_StructuredHint(
923
+ "neighbors", {"ids": [node_id], "direction": "out", "edge_types": ["CALLS"]},
924
+ False, PRIORITY_LEAF_FOLLOWUP,
925
+ LABEL_HIGH_FANOUT,
926
+ f"method has {_out_count(edge_summary, 'CALLS')} CALLS — consider filtering",
927
+ ))
928
+ # Advisory for many CALLS
929
+ advisories.append((PRIORITY_LEAF_FOLLOWUP, "many CALLS — consider filtering by target microservice"))
930
+ return (finalize_structured_hints(struct_pairs), finalize_advisories(advisories))
931
+
932
+ return ([], [])