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
4 changes: 2 additions & 2 deletions 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`, `edge_filter: EdgeFilter \| str \| None` (`CALLS` only; see guide) | `{"ids":"sym:…ChatController","direction":"out","edge_types":["DECLARES.DECLARES_CLIENT"]}` |
| `neighbors` | Graph walk. **Required**: `direction` and `edge_types` (stored labels; type Symbols may pass composed `DECLARES.*`; non-static method Symbols may pass `OVERRIDDEN_BY*` — `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 Expand Up @@ -431,7 +431,7 @@ Ontology **15** (CALLS-NOISE) adds `CALLS.callee_declaring_role`, `GraphMeta.pas

Ontology **14** introduces `EDGE_SCHEMA` in `java_ontology.py` as the canonical edge navigation schema (see `docs/EDGE-NAVIGATION.md`). **`HTTP_CALLS` is `Client → Route`** (SCHEMA-V2 PR-B). **`ASYNC_CALLS` is `Producer → Route`** with `DECLARES_PRODUCER` (SCHEMA-V2 PR-C). Run one full reprocess after upgrading through the SCHEMA-V2 sequence (or when you need the v14 ontology gate).

Ontology **13** materializes stored `OVERRIDES` edges between method Symbols (subtype override → supertype declaration, matching `signature` on a direct `IMPLEMENTS` / `EXTENDS` hop). `neighbors(edge_types=["OVERRIDES"])` traverses this relationship; `OVERRIDDEN_BY*` keys in `edge_summary` remain describe-time rollups only.
Ontology **13** materializes stored `OVERRIDES` edges between method Symbols (subtype override → supertype declaration, matching `signature` on a direct `IMPLEMENTS` / `EXTENDS` hop). `neighbors(edge_types=["OVERRIDES"])` traverses this relationship; `OVERRIDDEN_BY*` dot-keys in `edge_summary` are also navigable on method Symbol origins (`out` only).

Ontology **12** renames `@CodebaseClient` to `@CodebaseHttpClient`, types HTTP `method` as the shared `CodebaseHttpMethod` enum on both inbound and outbound stubs, and makes inbound layer-C HTTP routes **replace** same-method built-in Spring rows (no merge). Rebuild after upgrading so `meta_chain` keys and annotation simple names match the extractor.

Expand Down
24 changes: 20 additions & 4 deletions docs/AGENT-GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,15 +76,30 @@ Use these strings **verbatim** in `neighbors(..., edge_types=[...])`.
| Service boundary | `EXPOSES` | method Symbol → Route (handler exposes route) |
| Cross-service | `HTTP_CALLS`, `ASYNC_CALLS` | `HTTP_CALLS`: Client → Route; `ASYNC_CALLS`: Producer → Route |

**Composed edges (type Symbol origin, `direction="out"` only):**
**Composed edges type Symbol origin (`direction="out"` only):**

| Edge type | Meaning |
| --------- | ------- |
| `DECLARES.DECLARES_CLIENT` | Members' HTTP clients in one hop |
| `DECLARES.DECLARES_PRODUCER` | Members' async producers in one hop |
| `DECLARES.EXPOSES` | Members' exposed routes in one hop |

**Not valid in `edge_types`:** `OVERRIDDEN_BY`, `OVERRIDDEN_BY.DECLARES_CLIENT`, `OVERRIDDEN_BY.DECLARES_PRODUCER`, `OVERRIDDEN_BY.EXPOSES` (describe-only virtual keys).
**Composed edges — non-static method Symbol origin (`direction="out"` only):**

| Edge type | Meaning |
| --------- | ------- |
| `OVERRIDDEN_BY` | Concrete overrider methods (stored `[:OVERRIDES]` dispatch hop) |
| `OVERRIDDEN_BY.DECLARES_CLIENT` | Clients declared on overriders (`via_id` = overrider method) |
| `OVERRIDDEN_BY.DECLARES_PRODUCER` | Producers on overriders |
| `OVERRIDDEN_BY.EXPOSES` | Routes exposed by overriders |

**Stored vs virtual direction (base override axis):** `neighbors(decl_id, "out", ["OVERRIDDEN_BY"])` returns the same overrider method ids as `neighbors(decl_id, "in", ["OVERRIDES"])` on the same declaration method. Prefer the dot-key when `describe.edge_summary` advertises `OVERRIDDEN_BY`.

Do not mix `DECLARES.*` and `OVERRIDDEN_BY.*` in one `edge_types` list on a single origin id — the handler rejects the whole request (only one axis applies per node).

`describe` `edge_summary` counts for `OVERRIDDEN_BY*` use the same stored `[:OVERRIDES]` dispatch hop as `neighbors` (ontology **13+** graphs with materialized override edges). Rebuild the index if counts look wrong or dot-key walks return fewer rows than advertised.

**Pagination:** default `neighbors` `limit=25` slices the merged flat + composed edge list. When `edge_summary` shows a large `out` count for a composed key, raise `limit` (and use `offset`) or issue separate calls per key.

### Argument shapes

Expand Down Expand Up @@ -201,7 +216,7 @@ Full node + `edge_summary`. Args: `id` (any kind) or `fqn` (symbol only; `id` wi

- **Stored keys** — counts for edges that exist in the graph.
- **Type symbols** (`class`, `interface`, `enum`, `record`, `annotation`) may add composed keys `DECLARES.DECLARES_CLIENT`, `DECLARES.DECLARES_PRODUCER`, `DECLARES.EXPOSES` — navigable via `neighbors` with those dot-keys (`out` only).
- **Method symbols** may add virtual keys `OVERRIDDEN_BY`, `OVERRIDDEN_BY.DECLARES_*`, `OVERRIDDEN_BY.EXPOSES` (describe only), plus an **`OVERRIDES`** row merging stored `[:OVERRIDES]` counts with a dispatch-up rollup (`in`/`out` per direction uses `max` of stored vs rollup). Use `neighbors(..., ["OVERRIDES"])` to list override edges. Static methods and constructors do not get override-axis keys.
- **Method symbols** may add virtual keys `OVERRIDDEN_BY`, `OVERRIDDEN_BY.DECLARES_*`, `OVERRIDDEN_BY.EXPOSES` (navigable via `neighbors` on non-static method origins, `out` only), plus an **`OVERRIDES`** row merging stored `[:OVERRIDES]` incident counts with the rollup dispatch-up count (`max` per direction). Rollup and dot-key traversal both use stored `[:OVERRIDES]` for the dispatch hop. Static methods and constructors do not get override-axis keys.

Composed counts are **edge rows**, not distinct methods; `count > 0` means "there is something to walk".

Expand Down Expand Up @@ -249,7 +264,8 @@ Returns **edges** with `attrs` (`confidence`, `strategy`, `match`, … on cross-
| Empty `search` | Wrong `table`, no index, or chunk miss | Try `table="all"`; `find` with `fqn_prefix`; read source files directly |
| Empty results across several tools | Index missing, stale, or wrong project | You cannot rebuild the index via MCP — ask the operator; meanwhile use open files / `rg` |
| Result vs open file disagree | Stale or partial index | Trust the file; say index may be stale |
| Used virtual key in `neighbors` | `OVERRIDDEN_BY*` is describe-only | Use stored `OVERRIDES` or manual walk via `DECLARES` → type → `IMPLEMENTS`/`EXTENDS` |
| Mixed composed families on one id | `DECLARES.*` + `OVERRIDDEN_BY.*` together | Split calls — type keys need a type id; override keys need a method id |
| Override dot-key on type / DECLARES on method | Wrong Symbol origin for axis | Read `describe.edge_summary`; use the axis that matches the node kind |

After two failed attempts on the same intent, stop and report tool name, args, and response snippet.

Expand Down
15 changes: 15 additions & 0 deletions docs/EDGE-NAVIGATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,21 @@
- `alien_subject`: ASYNC_CALLS connects Producer→Route; use DECLARES_PRODUCER from a method Symbol, or neighbors(producer_id,'out',['ASYNC_CALLS']) from a Producer id


## Override-axis composed keys (method Symbol, `direction="out"` only)

Virtual `edge_summary` / `neighbors` dot-keys (not stored graph edge labels). The dispatch hop uses materialized `[:OVERRIDES]`; terminal hops use stored `DECLARES_CLIENT`, `DECLARES_PRODUCER`, or `EXPOSES`.

| Dot-key | Recipe |
| --- | --- |
| `OVERRIDDEN_BY` | `neighbors(['{id}'],'out',['OVERRIDDEN_BY'])` — same overrider method ids as `neighbors(['{id}'],'in',['OVERRIDES'])` on a declaration method |
| `OVERRIDDEN_BY.DECLARES_CLIENT` | `neighbors(['{id}'],'out',['OVERRIDDEN_BY.DECLARES_CLIENT'])` — clients on all overriders (`via_id` = overrider method in attrs) |
| `OVERRIDDEN_BY.DECLARES_PRODUCER` | `neighbors(['{id}'],'out',['OVERRIDDEN_BY.DECLARES_PRODUCER'])` |
| `OVERRIDDEN_BY.EXPOSES` | `neighbors(['{id}'],'out',['OVERRIDDEN_BY.EXPOSES'])` |

Do not mix `DECLARES.*` and `OVERRIDDEN_BY.*` in one `edge_types` list on a single origin id.

`describe` `edge_summary` and `neighbors` share the stored `[:OVERRIDES]` dispatch hop (requires ontology **13+** materialized edges). Default `limit=25` may truncate large composed result sets — raise `limit` when `out` counts are high.

## Graph storage (not MCP `neighbors` edge_types)

### `UnresolvedCallSite` + `UNRESOLVED_AT` (ontology 15 / CALLS-NOISE PR-3)
Expand Down
95 changes: 66 additions & 29 deletions kuzu_queries.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,15 @@
)
_MEMBER_EDGE_COMPOSED_REL_BY_KEY: dict[str, str] = dict(_MEMBER_EDGE_COMPOSED_REL_MAP)

_OVERRIDE_AXIS_COMPOSED_REL_MAP: tuple[tuple[str, str | None], ...] = (
("OVERRIDDEN_BY", None),
("OVERRIDDEN_BY.DECLARES_CLIENT", "DECLARES_CLIENT"),
("OVERRIDDEN_BY.DECLARES_PRODUCER", "DECLARES_PRODUCER"),
("OVERRIDDEN_BY.EXPOSES", "EXPOSES"),
)
_OVERRIDE_AXIS_COMPOSED_REL_BY_KEY: dict[str, str | None] = dict(_OVERRIDE_AXIS_COMPOSED_REL_MAP)
OVERRIDE_AXIS_COMPOSED_EDGE_TYPES: frozenset[str] = frozenset(_OVERRIDE_AXIS_COMPOSED_REL_BY_KEY)


def _coerce_id_list(raw: Any) -> list[str]:
"""Normalize Kuzu ``collect(DISTINCT ...)`` list results to string ids."""
Expand Down Expand Up @@ -667,6 +676,34 @@ def member_edge_traversal_for(self, type_id: str, composed_key: str) -> list[dic
{"id": type_id, "rel": rel},
)

def override_axis_traversal_for(self, method_id: str, composed_key: str) -> list[dict[str, Any]]:
"""Override-axis composed traversal for a method Symbol (neighbors dot-key path).

Uses stored ``[:OVERRIDES]`` for the dispatch hop (aligned with ``override_axis_rollup_for``
overrider ids). Base key returns overrider method ids only; composed keys return terminal
rows with full edge attr projection plus ``via_id`` (overrider method id).
"""
rel = _OVERRIDE_AXIS_COMPOSED_REL_BY_KEY.get(composed_key)
if rel is None and composed_key != "OVERRIDDEN_BY":
return []
if rel is None:
return self._rows(
"MATCH (decl:Symbol {id: $id})<-[:OVERRIDES]-(mover:Symbol) "
"RETURN mover.id AS other_id",
{"id": method_id},
)
return self._rows(
"MATCH (decl:Symbol {id: $id})<-[:OVERRIDES]-(mover:Symbol)-[e]->(term) "
"WHERE label(e) = $rel "
"RETURN mover.id AS via_id, label(e) AS stored_edge_type, "
"term.id AS other_id, e.confidence AS confidence, e.strategy AS strategy, "
"e.match AS match, e.mechanism AS mechanism, e.annotation AS annotation, "
"e.field_or_param AS field_or_param, 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",
{"id": method_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":
Expand Down Expand Up @@ -856,12 +893,31 @@ def _edge_row_count_from_method_ids(self, method_ids: list[str], rel: str) -> in
total += int(rows[0].get("n") or 0) if rows else 0
return total

def _override_impl_ids_from_stored(self, method_id: str) -> list[str]:
"""Overrider method ids for a declaration method (stored ``[:OVERRIDES]`` in-hop)."""
rows = self._rows(
"MATCH (decl:Symbol {id: $id})<-[:OVERRIDES]-(mover:Symbol) "
"RETURN collect(DISTINCT mover.id) AS ids",
{"id": method_id},
)
return list(dict.fromkeys(_coerce_id_list(rows[0].get("ids") if rows else None)))

def _override_decl_ids_from_stored(self, method_id: str) -> list[str]:
"""Declaration method ids overridden by a concrete method (stored ``[:OVERRIDES]`` out-hop)."""
rows = self._rows(
"MATCH (m:Symbol {id: $id})-[:OVERRIDES]->(decl:Symbol) "
"RETURN collect(DISTINCT decl.id) AS ids",
{"id": method_id},
)
return list(dict.fromkeys(_coerce_id_list(rows[0].get("ids") if rows else None)))

def override_axis_rollup_for(self, method_id: str) -> dict[str, dict[str, int]]:
"""Dispatch-axis composed keys for method Symbols (describe-time only).

Uses one-hop ``IMPLEMENTS`` / ``EXTENDS`` class edges plus ``Symbol.signature``
equality. Omits keys with zero counts (same convention as ``edge_counts_for``).
Returns ``{}`` for non-methods, constructors (caller should skip), and static methods.
Dispatch hop uses materialized ``[:OVERRIDES]`` (same as ``override_axis_traversal_for`` /
``neighbors`` dot-keys). Terminal composed counts sum outgoing edges from overrider
methods. Omits keys with zero counts. Returns ``{}`` for non-methods, constructors,
and static methods.
"""
params = {"id": method_id}
gate = self._rows(
Expand All @@ -876,41 +932,22 @@ def override_axis_rollup_for(self, method_id: str) -> dict[str, dict[str, int]]:

rollup: dict[str, dict[str, int]] = {}

down_rows = self._rows(
"MATCH (m:Symbol {id: $id})<-[:DECLARES]-(t:Symbol) "
"MATCH (impl:Symbol)-[:IMPLEMENTS|EXTENDS]->(t) "
"MATCH (impl)-[:DECLARES]->(mover:Symbol) "
"WHERE mover.signature = m.signature AND mover.id <> m.id "
"RETURN collect(DISTINCT mover.id) AS ids",
params,
)
impl_ids = _coerce_id_list(down_rows[0].get("ids") if down_rows else None)

impl_ids = self._override_impl_ids_from_stored(method_id)
if impl_ids:
distinct_impl = list(dict.fromkeys(impl_ids))
rollup["OVERRIDDEN_BY"] = {"in": 0, "out": len(distinct_impl)}
n_dc = self._edge_row_count_from_method_ids(distinct_impl, "DECLARES_CLIENT")
rollup["OVERRIDDEN_BY"] = {"in": 0, "out": len(impl_ids)}
n_dc = self._edge_row_count_from_method_ids(impl_ids, "DECLARES_CLIENT")
if n_dc > 0:
rollup["OVERRIDDEN_BY.DECLARES_CLIENT"] = {"in": 0, "out": n_dc}
n_dp = self._edge_row_count_from_method_ids(distinct_impl, "DECLARES_PRODUCER")
n_dp = self._edge_row_count_from_method_ids(impl_ids, "DECLARES_PRODUCER")
if n_dp > 0:
rollup["OVERRIDDEN_BY.DECLARES_PRODUCER"] = {"in": 0, "out": n_dp}
n_ex = self._edge_row_count_from_method_ids(distinct_impl, "EXPOSES")
n_ex = self._edge_row_count_from_method_ids(impl_ids, "EXPOSES")
if n_ex > 0:
rollup["OVERRIDDEN_BY.EXPOSES"] = {"in": 0, "out": n_ex}

up_rows = self._rows(
"MATCH (m:Symbol {id: $id})<-[:DECLARES]-(impl:Symbol) "
"MATCH (impl)-[:IMPLEMENTS|EXTENDS]->(parent:Symbol) "
"MATCH (parent)-[:DECLARES]->(decl_m:Symbol) "
"WHERE decl_m.signature = m.signature AND decl_m.id <> m.id "
"RETURN collect(DISTINCT decl_m.id) AS ids",
params,
)
decl_ids = _coerce_id_list(up_rows[0].get("ids") if up_rows else None)
decl_ids = self._override_decl_ids_from_stored(method_id)
if decl_ids:
distinct_decl = list(dict.fromkeys(decl_ids))
rollup["OVERRIDES"] = {"in": 0, "out": len(distinct_decl)}
rollup["OVERRIDES"] = {"in": 0, "out": len(decl_ids)}

return rollup

Expand Down
14 changes: 6 additions & 8 deletions mcp_hints.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@
"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. Describe-time type rollup hints may recommend "
"DECLARES.* dot-keys for neighbors(); empty neighbors structural hints never use "
"DECLARES.* and OVERRIDDEN_BY.* dot-keys for neighbors() on matching Symbol origins; "
"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. On neighbors with "
"edge_types=['CALLS'] only, optional edge_filter projects the ordered CALLS stream "
Expand All @@ -40,18 +41,15 @@
TPL_DESCRIBE_TYPE_PRODUCERS_VIA_MEMBERS = (
"producers via members: neighbors(['{id}'],'out',['DECLARES.DECLARES_PRODUCER'])"
)
TPL_DESCRIBE_METHOD_OVERRIDERS = "overriders: neighbors(['{id}'],'in',['OVERRIDES'])"
TPL_DESCRIBE_METHOD_OVERRIDERS = "overriders: neighbors(['{id}'],'out',['OVERRIDDEN_BY'])"
TPL_DESCRIBE_METHOD_CLIENTS_IN_OVERRIDERS = (
"clients in overriders: neighbors(['{id}'],'in',['OVERRIDES']) "
"then neighbors(overrider_ids,'out',['DECLARES_CLIENT'])"
"clients in overriders: neighbors(['{id}'],'out',['OVERRIDDEN_BY.DECLARES_CLIENT'])"
)
TPL_DESCRIBE_METHOD_PRODUCERS_IN_OVERRIDERS = (
"producers in overriders: neighbors(['{id}'],'in',['OVERRIDES']) "
"then neighbors(overrider_ids,'out',['DECLARES_PRODUCER'])"
"producers in overriders: neighbors(['{id}'],'out',['OVERRIDDEN_BY.DECLARES_PRODUCER'])"
)
TPL_DESCRIBE_METHOD_ROUTES_IN_OVERRIDERS = (
"routes in overriders: neighbors(['{id}'],'in',['OVERRIDES']) "
"then neighbors(overrider_ids,'out',['EXPOSES'])"
"routes in overriders: neighbors(['{id}'],'out',['OVERRIDDEN_BY.EXPOSES'])"
)
TPL_DESCRIBE_METHOD_OUTBOUND_CLIENT = "outbound client: neighbors(['{id}'],'out',['DECLARES_CLIENT'])"
TPL_DESCRIBE_METHOD_OUTBOUND_PRODUCER = "outbound producer: neighbors(['{id}'],'out',['DECLARES_PRODUCER'])"
Expand Down
Loading
Loading