Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -244,7 +244,7 @@ Edit `claude_desktop_config.json` (macOS: `~/Library/Application Support/Claude/
| `find` | Locate nodes by structured filter. | `kind: "symbol"\|"route"\|"client"\|"producer"`, `filter: NodeFilter \| str`, `limit: int=25`, `offset: int=0` | `{"kind":"symbol","filter":{"role":"CONTROLLER"}}` |
| `describe` | Full record + edge counts for one node. For **type** symbols, `edge_summary` may include composed dot-keys (`DECLARES.DECLARES_CLIENT`, `DECLARES.EXPOSES`); for **method** symbols it may include override-axis virtual keys (`OVERRIDDEN_BY`, …) and an `OVERRIDES` row that **merges** stored `[:OVERRIDES]` in/out with the dispatch-up rollup (per direction `max`). See [`docs/AGENT-GUIDE.md`](./docs/AGENT-GUIDE.md) (`describe`). | `id: str` | `{"id":"sym:com.bank.chat.core.api.ChatController#joinOperator(JoinOperatorRequest)"}` |
| `resolve` | Identifier-shaped node lookup (symbol / route / client / producer). Returns `status` `one`, `many`, or `none`; prefer over `describe(fqn=…)` when an FQN may collide. See [`docs/AGENT-GUIDE.md`](./docs/AGENT-GUIDE.md) (`resolve`). | `identifier: str`, `hint_kind: "symbol"|"route"|"client"|"producer" \| null` | `{"identifier":"com.bank.chat.core.api.ChatController","hint_kind":"symbol"}` |
| `neighbors` | Graph walk. **Required**: `direction` and `edge_types` (stored labels; type Symbols may also pass composed `DECLARES.DECLARES_CLIENT`, `DECLARES.DECLARES_PRODUCER`, `DECLARES.EXPOSES` — `out` only — see [`docs/AGENT-GUIDE.md`](./docs/AGENT-GUIDE.md)). | `ids: str \| list[str]`, `direction: "in"\|"out"`, `edge_types: list[str]`, `limit: int=25`, `offset: int=0`, `filter: NodeFilter \| str \| None` | `{"ids":"sym:…ChatController","direction":"out","edge_types":["DECLARES.DECLARES_CLIENT"]}` |
| `neighbors` | Graph walk. **Required**: `direction` and `edge_types` (stored labels; type Symbols may also pass composed `DECLARES.DECLARES_CLIENT`, `DECLARES.DECLARES_PRODUCER`, `DECLARES.EXPOSES` — `out` only — see [`docs/AGENT-GUIDE.md`](./docs/AGENT-GUIDE.md)). | `ids: str \| list[str]`, `direction: "in"\|"out"`, `edge_types: list[str]`, `limit: int=25`, `offset: int=0`, `filter: NodeFilter \| str \| None`, `edge_filter: EdgeFilter \| str \| None` (`CALLS` only; see guide) | `{"ids":"sym:…ChatController","direction":"out","edge_types":["DECLARES.DECLARES_CLIENT"]}` |

**`NodeFilter` notes:**

Expand Down
6 changes: 4 additions & 2 deletions docs/AGENT-GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -211,13 +211,15 @@ Identifier lookup; three statuses above. Args: `identifier`, optional `hint_kind

#### `neighbors`

One hop. Args: `ids` (string or array), **`direction`**, **`edge_types`**, `limit` (default 25), `offset`, optional `filter` on the other node.
One hop. Args: `ids` (string or array), **`direction`**, **`edge_types`**, `limit` (default 25), `offset`, optional `filter` on the other node, optional **`edge_filter`** (`edge_types` must be exactly `['CALLS']` — no composed dot-keys or second stored label; fail-loud otherwise).

**Multiple origin ids:** each id loads the full CALLS stream (or generic hop) in list order; `offset`/`limit` apply to the **concatenated** edge list (`ids[0]` edges first, then `ids[1]`, …), not global source order across origins — a large first origin can leave no rows for later ids within the same page. High fan-out methods are slow; prefer one id per call or a smaller `limit`.

Returns **edges** with `attrs` (`confidence`, `strategy`, `match`, … on cross-service edges) and **`other`** node.

**Cross-service edges** (`HTTP_CALLS`, `ASYNC_CALLS`): read `attrs.confidence` and `attrs.match` — low confidence or `unresolved`/`phantom`/`ambiguous` means treat as a resolver signal, not ground truth.

**`CALLS` edges:** `attrs.resolved=false` or low `attrs.confidence` may be JDK/external or unresolved static sites — still a lower bound, not exhaustive runtime behaviour.
**`CALLS` edges:** source-ordered (`call_site_line`, `call_site_byte`). `attrs.resolved=false` or low `attrs.confidence` may be JDK/external or unresolved static sites — still a lower bound, not exhaustive runtime behaviour. **`filter` + `edge_filter` together** load the ordered CALLS stream then apply callee `NodeFilter` in Python — expect higher latency on hot methods than `edge_filter` alone. Optional **`edge_filter`** projects before pagination: `min_confidence`; `include_strategies` / `exclude_strategies` (mutually exclusive); `callee_declaring_role`, `callee_declaring_roles`, `exclude_callee_declaring_roles` (`["OTHER"]` also drops known-external rows). **`filter.role` filters the neighbor method (usually `OTHER`), not the callee stereotype** — use `edge_filter.callee_declaring_role` for repository/service hops. **`exclude_external` applies to `find_callers` / `find_callees` only** (FQN-prefix); trim JDK noise on CALLS via `edge_filter`. Accessor noise: role excludes help; getter/setter heuristics in [`propose/AGENT-SKILLS-AND-COMMANDS-PROPOSE.md`](../propose/AGENT-SKILLS-AND-COMMANDS-PROPOSE.md) `/mini-map`.

### Ontology glossary

Expand Down
70 changes: 70 additions & 0 deletions kuzu_queries.py
Original file line number Diff line number Diff line change
Expand Up @@ -667,6 +667,76 @@ def member_edge_traversal_for(self, type_id: str, composed_key: str) -> list[dic
{"id": type_id, "rel": rel},
)

def count_calls_for_symbol(self, origin_id: str, *, direction: Literal["in", "out"]) -> int:
"""Count CALLS edges incident on a Symbol (hints / diagnostics)."""
if direction == "out":
pattern = "MATCH (origin:Symbol {id: $id})-[e:CALLS]->() RETURN count(e) AS n"
else:
pattern = "MATCH (origin:Symbol {id: $id})<-[e:CALLS]-() RETURN count(e) AS n"
rows = self._rows(pattern, {"id": origin_id})
return int(rows[0].get("n") or 0) if rows else 0

def neighbor_calls_for_symbol(
self,
origin_id: str,
*,
direction: Literal["in", "out"],
offset: int = 0,
limit: int | None = None,
sql_pagination: bool = True,
min_confidence: float | None = None,
include_strategies: list[str] | None = None,
exclude_strategies: list[str] | None = None,
callee_declaring_role: str | None = None,
callee_declaring_roles: list[str] | None = None,
exclude_callee_declaring_roles: list[str] | None = None,
) -> list[dict[str, Any]]:
"""CALLS neighbors with source-order delivery and optional edge-attribute pushdown.

When ``sql_pagination`` is True and ``limit`` is set, ``SKIP``/``LIMIT`` apply after
``ORDER BY e.call_site_line, e.call_site_byte``. Otherwise the full ordered stream is
returned for caller-side ``NodeFilter`` / pagination.
"""
wh_parts = ["origin.id = $id"]
params: dict[str, Any] = {"id": origin_id}
if min_confidence is not None:
wh_parts.append("e.confidence >= $min_confidence")
params["min_confidence"] = min_confidence
if include_strategies:
wh_parts.append("e.strategy IN $include_strategies")
params["include_strategies"] = include_strategies
if exclude_strategies:
wh_parts.append("NOT (e.strategy IN $exclude_strategies)")
params["exclude_strategies"] = exclude_strategies
if callee_declaring_role is not None:
wh_parts.append("e.callee_declaring_role = $callee_declaring_role")
params["callee_declaring_role"] = callee_declaring_role
if callee_declaring_roles:
wh_parts.append("e.callee_declaring_role IN $callee_declaring_roles")
params["callee_declaring_roles"] = callee_declaring_roles
if exclude_callee_declaring_roles:
wh_parts.append("NOT (e.callee_declaring_role IN $exclude_callee_declaring_roles)")
params["exclude_callee_declaring_roles"] = exclude_callee_declaring_roles
where = " AND ".join(wh_parts)
if direction == "out":
match = "MATCH (origin:Symbol)-[e:CALLS]->(other:Symbol)"
else:
match = "MATCH (origin:Symbol)<-[e:CALLS]-(other:Symbol)"
q = (
f"{match} WHERE {where} "
"RETURN other.id AS other_id, 'CALLS' AS edge_type, "
"e.confidence AS confidence, e.strategy AS strategy, e.source AS source, "
"e.call_site_line AS call_site_line, e.call_site_byte AS call_site_byte, "
"e.arg_count AS arg_count, e.resolved AS resolved, "
"e.callee_declaring_role AS callee_declaring_role "
"ORDER BY e.call_site_line, e.call_site_byte"
)
if sql_pagination and limit is not None:
q += " SKIP $offset LIMIT $limit"
params["offset"] = offset
params["limit"] = limit
return self._rows(q, params)

def _edge_row_count_from_method_ids(self, method_ids: list[str], rel: str) -> int:
"""Count outgoing ``rel`` edges from method symbols (describe rollup helper)."""
total = 0
Expand Down
66 changes: 60 additions & 6 deletions mcp_hints.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,10 @@
"Maximum 5 hints per output. Describe-time type rollup hints may recommend "
"DECLARES.* dot-keys for neighbors(); empty neighbors structural hints never use "
"dot-key edge labels. For neighbors with multiple origin ids, empty-result "
"structural hints describe the first origin only."
"structural hints describe the first origin only. On neighbors with "
"edge_types=['CALLS'] only, optional edge_filter projects the ordered CALLS stream "
"(min_confidence, strategies, callee_declaring_role axes); fail-loud with composed "
"dot-keys or additional stored labels."
)

# --- Appendix A verbatim templates (substitute {id}, {kind}, {limit}) ---
Expand Down Expand Up @@ -109,6 +112,18 @@
"some edges resolved via brownfield/fallback strategy — check attrs.strategy on each row"
)

TPL_NEIGHBORS_CALLS_ROLE_FILTER_OTHER_FALLBACK = (
"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"
)

TPL_NEIGHBORS_CALLS_NODEFILTER_ROLE_COLLISION = (
"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"
)

# v4 neighbors success-path (propose/HINTS-V4-SUCCESS-PATH-PROPOSE.md); N1a/N1b alias describe templates.
TPL_NEIGHBORS_SUCCESS_HTTP_TARGETS = "HTTP targets: neighbors(client_ids,'out',['HTTP_CALLS'])"
TPL_NEIGHBORS_SUCCESS_ASYNC_TARGETS = "async targets: neighbors(producer_ids,'out',['ASYNC_CALLS'])"
Expand Down Expand Up @@ -329,6 +344,45 @@ def _append_neighbors_success_hint(pairs: list[tuple[int, str]], text: str) -> N
pairs.append((PRIORITY_LEAF_FOLLOWUP, text))


def neighbors_calls_meta_hints(payload: dict[str, Any]) -> list[tuple[int, str]]:
"""CALLS-specific hints: role-filter OTHER fallback (Decision 20) and NodeFilter.role trap (30)."""
pairs: list[tuple[int, str]] = []
req_types = payload.get("requested_edge_types")
if not isinstance(req_types, list) or req_types != ["CALLS"]:
return pairs
results = list(payload.get("results") or [])
edge_flt = payload.get("edge_filter") if isinstance(payload.get("edge_filter"), dict) else {}
node_flt = payload.get("node_filter") if isinstance(payload.get("node_filter"), dict) else {}
role_exact = edge_flt.get("callee_declaring_role")
if (
role_exact in ("SERVICE", "REPOSITORY")
and not results
and int(payload.get("unfiltered_calls_count") or 0) >= 5
):
pairs.append((PRIORITY_META, TPL_NEIGHBORS_CALLS_ROLE_FILTER_OTHER_FALLBACK))
node_role = node_flt.get("role")
if node_role and results:
method_rows = [
r
for r in results
if str(((r.get("other") or {}) if isinstance(r.get("other"), dict) else {}).get("symbol_kind") or "")
== "method"
]
if method_rows:
other_roles = [
str(
((r.get("other") or {}) if isinstance(r.get("other"), dict) else {}).get("role")
or ""
)
for r in method_rows
]
if other_roles and sum(1 for role in other_roles if role == "OTHER") >= max(
1, (len(other_roles) * 3) // 4
):
pairs.append((PRIORITY_META, TPL_NEIGHBORS_CALLS_NODEFILTER_ROLE_COLLISION))
return pairs


def neighbors_success_hints(payload: dict[str, Any]) -> list[tuple[int, str]]:
"""v4 non-empty neighbors follow-ups (N1a–N7); no graph I/O."""
if not payload.get("success"):
Expand Down Expand Up @@ -573,11 +627,11 @@ def generate_hints(
requested_direction=requested_direction,
)
)
else:
if results and offset == 0:
success_pairs = neighbors_success_hints(payload)
if _any_fuzzy_strategy(results):
meta_pairs.append((PRIORITY_META, TPL_NEIGHBORS_FUZZY_STRATEGY))
elif results and offset == 0:
success_pairs = neighbors_success_hints(payload)
meta_pairs.extend(neighbors_calls_meta_hints(payload))
if results and _any_fuzzy_strategy(results):
meta_pairs.append((PRIORITY_META, TPL_NEIGHBORS_FUZZY_STRATEGY))
return finalize_hint_list(
_filter_neighbors_dotkey_hints(empty_pairs) + success_pairs + meta_pairs,
)
Expand Down
Loading
Loading