diff --git a/agents/explorer-rag-enhanced.md b/agents/explorer-rag-enhanced.md index 5ee33f7..8f8b2b8 100644 --- a/agents/explorer-rag-enhanced.md +++ b/agents/explorer-rag-enhanced.md @@ -151,15 +151,15 @@ Simple types in parentheses; generics erased. No spaces after commas. No-arg: `( ### Shared NodeFilter -For `find`, `filter` is required — `{}` means no predicates. **Strict frame:** unknown keys or inapplicable populated fields → `success=false`. +For `find`, `filter` is required — `{}` means no predicates. **Strict frame:** unknown keys or inapplicable populated fields → `success=false`; invalid enum values (e.g. wrong case) are rejected earlier at the schema layer with the valid set listed. | Keys | Applies to | | ---- | ---------- | | `microservice`, `module` | All kinds | | `role`, `exclude_roles`, `annotation`, `capability`, `fqn_prefix`, `symbol_kind`, `symbol_kinds` | **symbol** | | `http_method`, `path_prefix`, `framework` | **route** | -| `client_kind`, `target_service`, `target_path_prefix`, `http_method` | **client** | -| `producer_kind`, `topic_prefix` | **producer** | +| `source_layer`, `client_kind`, `target_service`, `target_path_prefix`, `http_method` | **client** | +| `source_layer`, `producer_kind`, `topic_prefix` | **producer** | No wildcards in prefix fields — use `search(query=…)` for fuzzy text. diff --git a/ast_java.py b/ast_java.py index 848e144..5922641 100644 --- a/ast_java.py +++ b/ast_java.py @@ -13,6 +13,7 @@ from __future__ import annotations import posixpath +import sys from dataclasses import dataclass, field from functools import lru_cache from typing import Iterable @@ -1642,9 +1643,17 @@ def _parse_codebase_http_client_annotation( pairs, _ = _annotation_kv_nodes(ann, src) client_kind = "" if "clientKind" in pairs: - val, _kind = _annotation_value(pairs["clientKind"], src) - if val and _kind == "enum": - client_kind = str(val) + val, vkind = _annotation_value(pairs["clientKind"], src) + if val and vkind == "enum": + kind_val = str(val) + from java_ontology import VALID_CLIENT_KINDS # deferred: java_ontology imports ast_java + if kind_val in VALID_CLIENT_KINDS: + client_kind = kind_val + else: + print( + f"[lancedb-mcp] CodebaseHttpClient: invalid clientKind {kind_val!r} — ignored", + file=sys.stderr, + ) target_service = "" if "targetService" in pairs: atoms = _string_value_atoms(pairs["targetService"], src, ctx) @@ -1714,9 +1723,17 @@ def _parse_codebase_producer_annotation( client_kind = "kafka_send" kind_node = pairs.get("producerKind") or pairs.get("clientKind") if kind_node is not None: - val, _kind = _annotation_value(kind_node, src) - if val and _kind == "enum": - client_kind = str(val) + val, vkind = _annotation_value(kind_node, src) + if val and vkind == "enum": + kind_val = str(val) + from java_ontology import VALID_PRODUCER_KINDS # deferred: java_ontology imports ast_java + if kind_val in VALID_PRODUCER_KINDS: + client_kind = kind_val + else: + print( + f"[lancedb-mcp] CodebaseProducer: invalid producerKind {kind_val!r} — ignored", + file=sys.stderr, + ) topic = "" if "topic" in pairs: atoms = _string_value_atoms(pairs["topic"], src, ctx) diff --git a/docs/AGENT-GUIDE.md b/docs/AGENT-GUIDE.md index 4e214aa..ea6fc91 100644 --- a/docs/AGENT-GUIDE.md +++ b/docs/AGENT-GUIDE.md @@ -138,12 +138,12 @@ For **`find`**, `filter` is required — `{}` means no predicates (all nodes of | `microservice`, `module` | All kinds | | `role`, `exclude_roles`, `annotation`, `capability`, `fqn_prefix`, `symbol_kind`, `symbol_kinds` | **symbol** | | `http_method`, `path_prefix`, `framework` | **route** | -| `client_kind`, `target_service`, `target_path_prefix`, `http_method` | **client** | -| `producer_kind`, `topic_prefix` | **producer** | +| `source_layer`, `client_kind`, `target_service`, `target_path_prefix`, `http_method` | **client** | +| `source_layer`, `producer_kind`, `topic_prefix` | **producer** | `http_method` filters HTTP verbs on **routes** (declared method) and on **clients** (outbound call method). Not applicable to **symbol** rows. -**Strict frame:** one populated field → one stored attribute for that kind. Unknown keys or inapplicable populated fields → `success=false` with a teaching `message`. No wildcards in `fqn_prefix`, `path_prefix`, or `target_path_prefix` (`*` / `?` rejected) — use `search(query=…)` for ranked text instead. `search.query` is opaque text, not a DSL. +**Strict frame:** one populated field → one stored attribute for that kind. Unknown keys or inapplicable populated fields → `success=false` with a teaching `message`. Invalid enum values (e.g. wrong case) are rejected earlier at the schema layer with the valid set listed. No wildcards in `fqn_prefix`, `path_prefix`, or `target_path_prefix` (`*` / `?` rejected) — use `search(query=…)` for ranked text instead. `search.query` is opaque text, not a DSL. ### Identifier resolution (`resolve`) @@ -245,12 +245,14 @@ Returns **edges** with `attrs` (`confidence`, `strategy`, `match`, … on cross- **Symbol kinds (`symbol_kind` / `symbol_kinds`):** `class`, `interface`, `enum`, `record`, `annotation`, `method`, `constructor`. -**Route `framework` (examples on stored routes):** `spring_mvc`, `webflux`, `kafka`, `rabbitmq`, `jms`, `stream`, `codebase_async_route`, … +**Route `framework` (closed set on stored routes):** `spring_mvc`, `webflux`, `kafka`, `rabbitmq`, `jms`, `stream`, `feign`. **Client kinds:** `feign_method`, `rest_template`, `web_client`. **Producer kinds:** `kafka_send`, `stream_bridge_send`. +**Source layers (client/producer):** `builtin`, `layer_a_meta`, `layer_b_ann`, `layer_b_fqn`, `layer_c_source`. + **HTTP call `attrs.match` / async `attrs.match`:** `cross_service`, `intra_service`, `ambiguous`, `phantom`, `unresolved`. ### Recovery playbook diff --git a/java_ontology.py b/java_ontology.py index 140c459..3df7876 100644 --- a/java_ontology.py +++ b/java_ontology.py @@ -15,7 +15,10 @@ _TYPE_ANN_TO_CAPABILITY, ) -# Roles: Spring stereotype values plus DTO from `infer_role_for_type`. +# Roles assignable by indexing: Spring stereotype values plus DTO. ``OTHER`` is the +# built-in inference fallback (ast_java.infer_role when nothing matches) and is +# deliberately excluded here — it is a read-side value (the mcp_v2 ``Role`` enum +# includes it) but not a role a user may set via @CodebaseRole / role_overrides. VALID_ROLES: frozenset[str] = frozenset((*ROLE_ANNOTATIONS.values(), "DTO")) VALID_CAPABILITIES: frozenset[str] = frozenset( diff --git a/mcp_v2.py b/mcp_v2.py index 597bdd3..5b5087b 100644 --- a/mcp_v2.py +++ b/mcp_v2.py @@ -48,6 +48,22 @@ def _hints_or_skip(tool: str, payload: dict) -> tuple[list, list]: DeclarationSymbolKind = Literal["class", "interface", "enum", "record", "annotation", "method", "constructor"] +# Closed value taxonomies surfaced to MCP consumers as enums. Sources of truth: +# Role — VALID_ROLES in java_ontology.py + the "OTHER" inference fallback (ast_java.infer_role) +# Framework — hardcoded literals across ast_java.py / build_ast_graph.py +# SourceLayer — exhaustive classifier build_ast_graph._client_source_layer / _producer_source_layer +# ClientKind — VALID_CLIENT_KINDS in java_ontology.py (every producer validated at index time) +# ProducerKind — VALID_PRODUCER_KINDS in java_ontology.py (every producer validated at index time) +# Keep these in sync with the indexing-side taxonomies if they change. +Role = Literal[ + "CONTROLLER", "SERVICE", "REPOSITORY", "COMPONENT", "CONFIG", + "ENTITY", "CLIENT", "MAPPER", "DTO", "OTHER", +] +Framework = Literal["spring_mvc", "webflux", "kafka", "rabbitmq", "jms", "stream", "feign", ""] +SourceLayer = Literal["builtin", "layer_a_meta", "layer_b_ann", "layer_b_fqn", "layer_c_source"] +ClientKind = Literal["feign_method", "rest_template", "web_client"] +ProducerKind = Literal["kafka_send", "stream_bridge_send"] + # Stored graph edge labels for one-hop neighbors. Composed DECLARES.* and OVERRIDDEN_BY.* # dot-keys are separate ComposedEdgeType literals (2-hop traversal). Stored OVERRIDES is an EdgeType. EdgeType = Literal[ @@ -133,21 +149,30 @@ class NodeFilter(BaseModel): microservice: str | None = None module: str | None = None - source_layer: str | None = None - role: str | None = None - exclude_roles: list[str] | None = None + source_layer: SourceLayer | None = None + role: Role | None = None + exclude_roles: list[Role] | None = None annotation: str | None = None capability: str | None = None fqn_prefix: str | None = None symbol_kind: DeclarationSymbolKind | None = None symbol_kinds: list[DeclarationSymbolKind] | None = None - http_method: str | None = None + http_method: str | None = Field( + default=None, + description="HTTP verb (commonly GET/POST/PUT/DELETE/PATCH; user route annotations may yield others).", + ) path_prefix: str | None = None - framework: str | None = None - client_kind: str | None = None + framework: Framework | None = None + client_kind: ClientKind | None = Field( + default=None, + description="Outbound HTTP client kind: feign_method, rest_template, or web_client.", + ) target_service: str | None = None target_path_prefix: str | None = None - producer_kind: str | None = None + producer_kind: ProducerKind | None = Field( + default=None, + description="Outbound async producer kind: kafka_send or stream_bridge_send.", + ) topic_prefix: str | None = None @@ -157,9 +182,9 @@ class EdgeFilter(BaseModel): min_confidence: float | None = None exclude_strategies: list[str] | None = None include_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 + callee_declaring_role: Role | None = None + callee_declaring_roles: list[Role] | None = None + exclude_callee_declaring_roles: list[Role] | None = None @model_validator(mode="after") def _strategy_axes_mutually_exclusive(self) -> EdgeFilter: diff --git a/server.py b/server.py index 8495b5d..6967564 100644 --- a/server.py +++ b/server.py @@ -7,7 +7,7 @@ import sys import time from pathlib import Path -from typing import Any, Literal +from typing import Literal import mcp_v2 from index_common import SBERT_MODEL @@ -31,14 +31,14 @@ _COCOINDEX_TARGET = "java_index_flow_lancedb.py:JavaCodeIndexLance" _INSTRUCTIONS = ( - "Java codebase graph navigator (LanceDB + Ladybug). " + "Java codebase graph navigator over an indexed Java codebase. " "Tools: search (NL/code locate), find (structured NodeFilter), describe (one node + edge_summary: stored edge-label counts and optional composed keys for type Symbols and override-axis virtual keys for method Symbols), " "neighbors (one hop; you MUST pass direction in|out AND edge_types list — no defaults), " - "resolve (identifier-shaped lookup for symbol/route/client/producer — three statuses one|many/none). " - "NodeFilter `filter` is a JSON object (preferred); a JSON-encoded string is also accepted as a fallback. " + "resolve (identifier-shaped lookup for symbol/route/client/producer — three statuses: one | many | none). " "Unknown filter keys and populated fields not applicable to the effective node kind fail with success=false and message. " + "Successful responses from any tool may include `hints_structured` (tool call suggestions with a `reason` field) and `advisories` (pure informational text) when hints are enabled. " "Edge labels: EXTENDS, IMPLEMENTS, INJECTS, OVERRIDES, DECLARES, DECLARES_CLIENT, DECLARES_PRODUCER, CALLS, EXPOSES, HTTP_CALLS, ASYNC_CALLS; " - "type Symbols may also use composed neighbors edge_types DECLARES.DECLARES_CLIENT, DECLARES.DECLARES_PRODUCER, DECLARES.EXPOSES (out only). " + "type Symbols may also use composed neighbors edge_types DECLARES.DECLARES_CLIENT, DECLARES.DECLARES_PRODUCER, DECLARES.EXPOSES (out only, type Symbol origin). " "Reprocess/init, meta, tables, diagnose-ignore, analyze-pr: use java-codebase-rag CLI — not MCP." ) @@ -123,19 +123,15 @@ def _log_detection(self) -> None: print("[scope] No microservice detected (at project root)", file=sys.stderr) print("[scope] Queries will span all microservices", file=sys.stderr) - def apply_auto_scope(self, node_filter: dict[str, Any] | None) -> dict[str, Any] | None: + def apply_auto_scope(self, node_filter: mcp_v2.NodeFilter | None) -> mcp_v2.NodeFilter | None: """Apply auto-detected scope to filter if no explicit microservice is set.""" if self.default_scope is None: return node_filter - # Convert to dict for manipulation if node_filter is None: - filter_dict = {} - else: - filter_dict = dict(node_filter) - # Only inject if user didn't specify microservice - if "microservice" not in filter_dict: - filter_dict["microservice"] = self.default_scope - return filter_dict + return mcp_v2.NodeFilter(microservice=self.default_scope) + if node_filter.microservice is None: + return node_filter.model_copy(update={"microservice": self.default_scope}) + return node_filter def _resolve_lancedb_uri() -> str: @@ -413,14 +409,15 @@ def create_mcp_server() -> FastMCP: @mcp.tool( name="search", description=( - "Ranked chunk retrieval: `query` is opaque text (natural language or code fragments); " - "results are score-ranked, not boolean-matched. Optional `filter` uses the same NodeFilter " - "schema as `find` but only **symbol-applicable** fields apply (strict frame). Wildcards " + "Ranked chunk retrieval over content tables (java/sql/yaml); `query` is opaque text (natural language or code " + "fragments) and results are score-ranked, not boolean-matched. For graph-structured listing " + "(symbols/routes/clients/producers) use `find`, not `search`. Optional `filter` uses the same NodeFilter " + "schema as `find` but only **symbol-applicable** fields apply — others return success=false. Wildcards " "(`*`, `?`) in prefix fields are rejected—use ranked `query` text instead. There is **no** " "structured DSL inside `query`; structured predicates belong in `find`. " "For identifier-shaped lookups (FQN, id prefix, route/client identifiers, …), use `resolve` first; " "use `search` for natural-language or ranked fuzzy discovery. " - "Successful responses echo `limit`/`offset` and may include `hints_structured` (tool call suggestions with `reason` field) and `advisories` (pure informational text)." + "Successful responses echo `limit`/`offset`." ), ) async def search( @@ -431,7 +428,7 @@ async def search( ), hybrid: bool = Field( default=False, - description="If true, fuse FTS + vector (single-table java/sql/yaml only)", + description="If true, fuse FTS + vector. Requires a single table (java/sql/yaml); hybrid with table='all' returns success=false.", ), limit: int = Field(default=5, ge=1, le=50, description="Max hits to return"), offset: int = Field(default=0, ge=0, le=500, description="Skip this many hits (pagination)"), @@ -439,11 +436,11 @@ async def search( default=None, description="Substring match on file path (pre-filter from index)", ), - filter: dict[str, Any] | str | None = Field( + filter: mcp_v2.NodeFilter | None = Field( default=None, description=( - "Optional NodeFilter post-filter on symbol-oriented hit rows. Unknown keys or populated fields not " - "applicable to symbols return success=false. Prefer a JSON object; a JSON-encoded string is accepted." + "Optional NodeFilter post-filter on symbol-oriented hit rows. An empty object or omitted means no " + "predicate. Unknown keys or populated fields not applicable to symbols return success=false." ), ), ) -> mcp_v2.SearchOutput: @@ -468,9 +465,11 @@ async def search( "**route** — microservice, module, http_method, path_prefix, framework; **client** — microservice, module, " "source_layer, client_kind, target_service, target_path_prefix, http_method; **producer** — microservice, " "module, source_layer, producer_kind, topic_prefix. " + "`role` is singular and `exclude_roles` plural; `capability` is a functional tag assigned during indexing. " + "`fqn_prefix` is a prefix predicate — for exact FQN or id lookup use `resolve`/`describe`. " "Wildcards in prefix fields are rejected. An empty filter (`{}`) or `filter=None` means no predicate (all nodes of " "that kind; use pagination). Unknown keys or inapplicable populated fields return success=false. " - "Successful responses echo `limit`/`offset` and may include `hints_structured` (tool call suggestions with `reason` field) and `advisories` (pure informational text)." + "Successful responses echo `limit`/`offset`." ), ) async def find( @@ -481,11 +480,10 @@ async def find( "'producer' = outbound async producers." ) ), - filter: dict[str, Any] | str = Field( + filter: mcp_v2.NodeFilter = Field( ..., description=( - "Required NodeFilter dict (extra keys forbidden). Fields must be applicable to `kind`. " - "Prefer a JSON object; a JSON-encoded string is accepted." + "Required NodeFilter object (extra keys forbidden). Fields must be applicable to `kind`." ), ), limit: int = Field(default=25, ge=1, le=500, description="Max nodes to return"), @@ -497,17 +495,14 @@ async def find( @mcp.tool( name="describe", description=( - "Full node record plus `edge_summary` (in/out counts per stored edge label, plus optional describe-time keys). Type Symbols may add " - "composed keys DECLARES.DECLARES_CLIENT, DECLARES.DECLARES_PRODUCER, and DECLARES.EXPOSES (navigable on type Symbols via neighbors, out only); " - "method Symbols may add override-axis virtual keys (OVERRIDDEN_BY, OVERRIDDEN_BY.DECLARES_CLIENT, OVERRIDDEN_BY.DECLARES_PRODUCER, " - "OVERRIDDEN_BY.EXPOSES, plus an `OVERRIDES` map entry that merges stored `[:OVERRIDES]` counts with the dispatch-up rollup per direction). " - "Override-axis virtual keys are navigable via neighbors on non-static method Symbol origins " - "(out only; composed keys include via_id in attrs). The stored `OVERRIDES` relationship " - "is also a normal edge label (e.g. direction in from declaration toward overriders). " + "Full node record plus `edge_summary` (in/out counts per stored edge label). For type Symbols, `edge_summary` " + "also exposes composed keys (DECLARES.DECLARES_CLIENT, DECLARES.DECLARES_PRODUCER, DECLARES.EXPOSES); for " + "non-static method Symbols it adds override-axis virtual keys (OVERRIDDEN_BY and its composed forms, plus an " + "`OVERRIDES` map merging stored `[:OVERRIDES]` counts with the dispatch-up rollup). These composed/override keys " + "are out-only and navigable via `neighbors`; the stored `OVERRIDES` is also a normal edge label (in toward declaration). " "Pass `id` for any kind, or exact `fqn` for Symbol lookup (`id` wins when both are set). " "`describe(fqn=…)` keeps the first graph row when multiple symbols share that FQN; when an FQN may collide, " - "prefer `resolve(identifier=…, hint_kind='symbol')` first, then `describe(id=…)` on the chosen node. " - "Successful responses may include `hints_structured` (tool call suggestions with `reason` field) and `advisories` (pure informational text)." + "prefer `resolve(identifier=…, hint_kind='symbol')` first, then `describe(id=…)` on the chosen node." ), ) async def describe( @@ -531,18 +526,19 @@ async def describe( @mcp.tool( name="neighbors", description=( - "Graph walk: **direction** (`in` | `out`) and non-empty **edge_types** are required (stored labels for one hop; " - "type Symbol origins may also pass composed DECLARES.DECLARES_CLIENT, DECLARES.DECLARES_PRODUCER, or DECLARES.EXPOSES " - "for 2-hop member rollups; method Symbol origins may pass OVERRIDDEN_BY, OVERRIDDEN_BY.DECLARES_CLIENT, " - "OVERRIDDEN_BY.DECLARES_PRODUCER, OVERRIDDEN_BY.EXPOSES for override-axis rollups — out only, via_id in " - "attrs on composed keys). " + "Graph walk: **direction** (`in` | `out`) and non-empty **edge_types** are required (one hop over stored edge " + "labels; type/method Symbol origins may also pass composed or override-axis keys — see `edge_types`). From a " + "type Symbol, `direction='out'` with EXPOSES yields route nodes and HTTP_CALLS/ASYNC_CALLS yield client/producer " + "nodes; `direction='in'` reverses each relationship. " + "`direction` and `edge_types` have no defaults; an empty `edge_types` fails. The CALLS-only features — " + "`edge_filter`, `include_unresolved`, `dedup_calls` — each require `edge_types=['CALLS']`; `edge_filter` and " + "`include_unresolved` are mutually exclusive. Violating a precondition (wrong CALLS context, composed/override " + "keys on an ineligible origin or with `direction='in'`, wildcards in prefix fields, unknown filter keys) returns " + "success=false with a message; `dedup_calls` with other edge_types is a silent no-op. " "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). " - "Optional `edge_filter` requires edge_types=['CALLS'] only (no composed dot-keys or extra stored " - "labels); projects the ordered CALLS stream by edge attributes (min_confidence, strategies, " - "callee_declaring_role). Wildcards in prefix fields are rejected. Unknown filter keys return success=false. " - "Successful responses echo `requested_edge_types` and may include `hints_structured` (tool call suggestions with `reason` field) and `advisories` (pure informational text). " - "Each edge's `attrs.strategy` indicates resolution quality (brownfield/fallback vs primary paths)." + "neighbor's kind—mixed-kind result sets fail on the first inapplicable neighbor (per-neighbor strict frame). " + "Each edge's `attrs.strategy` indicates resolution quality (brownfield/fallback vs primary paths). " + "Successful responses echo `requested_edge_types`." ), ) async def neighbors( @@ -573,19 +569,19 @@ async def neighbors( le=1000, description="Skip this many edges after merge (pagination)", ), - filter: dict[str, Any] | str | None = Field( + filter: mcp_v2.NodeFilter | None = Field( default=None, description=( - "Optional NodeFilter on the neighbor node. Same applicability rules as `find` for that node's kind. " - "Prefer a JSON object; a JSON-encoded string is accepted." + "Optional NodeFilter on the neighbor node. An empty object or omitted means no predicate. " + "Same applicability rules as `find` for that node's kind." ), ), - edge_filter: dict[str, Any] | str | None = Field( + edge_filter: mcp_v2.EdgeFilter | None = Field( default=None, description=( "Optional EdgeFilter on CALLS edge attributes (edge_types=['CALLS'] only). Use " "callee_declaring_role for callee stereotype projection — not NodeFilter.role on method neighbors. " - "Mutually exclusive with include_unresolved. Prefer a JSON object; a JSON-encoded string is accepted." + "Mutually exclusive with include_unresolved." ), ), include_unresolved: bool = Field( @@ -627,10 +623,11 @@ async def neighbors( "status=one (single node), many (≥2 ranked candidates with reason), or none " "(no match — fall back to search(query=...) for natural language or fuzzy text). " "Optional hint_kind narrows to symbol, route, client, or producer. " - "Successful responses may include hints_structured (tool call suggestions with `reason` field) and advisories (pure informational text) — same contract as other v2 tools. " "Malformed empty/whitespace identifier returns success=false. " "Examples: resolve('com.foo.Bar', hint_kind='symbol'); " "resolve('GET /api/v1/customers', hint_kind='route'); " + "resolve('PaymentClient', hint_kind='client'); " + "resolve('order.created', hint_kind='producer'); " "resolve('the client that handles assignments') → none (use search instead)." ), ) diff --git a/skills/explore-codebase/SKILL.md b/skills/explore-codebase/SKILL.md index 267a14b..eaa2e33 100644 --- a/skills/explore-codebase/SKILL.md +++ b/skills/explore-codebase/SKILL.md @@ -125,15 +125,15 @@ Use these strings **verbatim** in `neighbors(..., edge_types=[...])`. ### NodeFilter (`find`, `search.filter`, `neighbors.filter`) -For `find`, `filter` is required — `{}` means no predicates. **Strict frame:** unknown keys or inapplicable populated fields → `success=false`. +For `find`, `filter` is required — `{}` means no predicates. **Strict frame:** unknown keys or inapplicable populated fields → `success=false`; invalid enum values (e.g. wrong case) are rejected earlier at the schema layer with the valid set listed. | Applicable to | Keys | | ------------- | ---- | | All kinds | `microservice`, `module` | | **symbol** only | `role`, `exclude_roles`, `annotation`, `capability`, `fqn_prefix`, `symbol_kind`, `symbol_kinds` | | **route** only | `http_method`, `path_prefix`, `framework` | -| **client** only | `client_kind`, `target_service`, `target_path_prefix`, `http_method` | -| **producer** only | `producer_kind`, `topic_prefix` | +| **client** only | `source_layer`, `client_kind`, `target_service`, `target_path_prefix`, `http_method` | +| **producer** only | `source_layer`, `producer_kind`, `topic_prefix` | No wildcards in prefix fields — use `search(query=…)` for ranked text. @@ -166,8 +166,8 @@ Exclude `DTO`, `OTHER`, `MAPPER` with `exclude_roles` when tracing business logi **Symbol kinds:** `class`, `interface`, `enum`, `record`, `annotation`, `method`, `constructor`. -**Route frameworks:** `spring_mvc`, `webflux`, `kafka`, `rabbitmq`, `jms`, `stream`, `codebase_async_route`, … -**Client kinds:** `feign_method`, `rest_template`, `web_client`. **Producer kinds:** `kafka_send`, `stream_bridge_send`. +**Route frameworks:** `spring_mvc`, `webflux`, `kafka`, `rabbitmq`, `jms`, `stream`, `feign`. +**Client kinds:** `feign_method`, `rest_template`, `web_client`. **Producer kinds:** `kafka_send`, `stream_bridge_send`. **Source layers (client/producer):** `builtin`, `layer_a_meta`, `layer_b_ann`, `layer_b_fqn`, `layer_c_source`. **Match types:** `cross_service`, `intra_service`, `ambiguous`, `phantom`, `unresolved`. --- diff --git a/tests/test_brownfield_clients.py b/tests/test_brownfield_clients.py index b02d6a9..5f7ab5f 100644 --- a/tests/test_brownfield_clients.py +++ b/tests/test_brownfield_clients.py @@ -254,6 +254,63 @@ def test_29_unknown_client_kind_warns_and_skips(tmp_path: Path) -> None: assert _http_calls(db) == [] +def _client_kinds(db_path: Path) -> list[str]: + db = ladybug.Database(str(db_path), read_only=True) + conn = ladybug.Connection(db) + r = conn.execute("MATCH (c:Client) RETURN c.client_kind AS client_kind") + out: list[str] = [] + while r.has_next(): + out.append(str(r.get_next()[0] or "")) + return out + + +def _producer_kinds(db_path: Path) -> list[str]: + db = ladybug.Database(str(db_path), read_only=True) + conn = ladybug.Connection(db) + r = conn.execute("MATCH (p:Producer) RETURN p.producer_kind AS producer_kind") + out: list[str] = [] + while r.has_next(): + out.append(str(r.get_next()[0] or "")) + return out + + +def test_29a_unknown_source_client_kind_warns_and_ignored(tmp_path: Path) -> None: + """In-source @CodebaseHttpClient(clientKind=) is validated at parse + time (source-annotation mirror of the YAML-side test_29): the bad value is ignored + and a warning is emitted, so client_kind stays a closed set safe to surface as an enum.""" + java = { + "p/X.java": ( + "package p; import com.example.rag.*; class X { " + "@CodebaseHttpClient(clientKind=CodebaseClientKind.bogus, path=\"/bad\", method=CodebaseHttpMethod.GET) " + "void m() {} }" + ), + } + buf = io.StringIO() + with redirect_stderr(buf): + db = _build(tmp_path, None, java) + assert "invalid clientkind" in buf.getvalue().lower() + assert "bogus" not in _client_kinds(db) + + +def test_29b_unknown_source_producer_kind_warns_and_falls_back(tmp_path: Path) -> None: + """In-source @CodebaseProducer(producerKind=) is validated at parse + time: the bad value is ignored with a warning and producer_kind falls back to the + kafka_send default.""" + java = { + "p/X.java": ( + "package p; import com.example.rag.*; class X { " + "@CodebaseProducer(topic=\"t\", producerKind=CodebaseProducerKind.bogus) void m() {} }" + ), + } + buf = io.StringIO() + with redirect_stderr(buf): + db = _build(tmp_path, None, java) + assert "invalid producerkind" in buf.getvalue().lower() + kinds = _producer_kinds(db) + assert "bogus" not in kinds + assert "kafka_send" in kinds + + def test_30_brownfield_percentage_counter(tmp_path: Path) -> None: java = { "p/X.java": ( diff --git a/tests/test_microservice_scope.py b/tests/test_microservice_scope.py index 84a6080..d8ccb8d 100644 --- a/tests/test_microservice_scope.py +++ b/tests/test_microservice_scope.py @@ -74,7 +74,8 @@ def test_apply_scope_when_filter_none(self, tmp_path): mgr.default_scope = "microservice-a" # Simulate detection result = mgr.apply_auto_scope(None) - assert result == {"microservice": "microservice-a"} + assert result is not None + assert result.microservice == "microservice-a" def test_apply_scope_when_filter_exists_no_microservice(self, tmp_path): """Filter without microservice gets auto-scope injected.""" @@ -82,9 +83,11 @@ def test_apply_scope_when_filter_exists_no_microservice(self, tmp_path): mgr = ScopeManager(tmp_path) mgr.default_scope = "microservice-b" # Simulate detection - filter_dict = {"role": "Controller"} - result = mgr.apply_auto_scope(filter_dict) - assert result == {"role": "Controller", "microservice": "microservice-b"} + from mcp_v2 import NodeFilter + result = mgr.apply_auto_scope(NodeFilter(role="CONTROLLER")) + assert result is not None + assert result.role == "CONTROLLER" + assert result.microservice == "microservice-b" def test_apply_scope_preserves_explicit_microservice(self, tmp_path): """Explicit microservice not overridden.""" @@ -92,9 +95,10 @@ def test_apply_scope_preserves_explicit_microservice(self, tmp_path): mgr = ScopeManager(tmp_path) mgr.default_scope = "microservice-a" # Simulate detection - filter_dict = {"microservice": "microservice-c"} - result = mgr.apply_auto_scope(filter_dict) - assert result == {"microservice": "microservice-c"} + from mcp_v2 import NodeFilter + result = mgr.apply_auto_scope(NodeFilter(microservice="microservice-c")) + assert result is not None + assert result.microservice == "microservice-c" def test_apply_scope_no_default(self, tmp_path): """No auto-detected scope leaves filter unchanged.""" @@ -102,9 +106,11 @@ def test_apply_scope_no_default(self, tmp_path): mgr = ScopeManager(tmp_path) mgr.default_scope = None # No detection - filter_dict = {"role": "Controller"} - result = mgr.apply_auto_scope(filter_dict) - assert result == {"role": "Controller"} + from mcp_v2 import NodeFilter + nf = NodeFilter(role="CONTROLLER") + result = mgr.apply_auto_scope(nf) + assert result is nf + assert result.role == "CONTROLLER" def test_detect_scope_with_yaml_overrides(self, tmp_path): """Test that detect_microservice_from_path respects YAML microservice_roots.""" @@ -140,6 +146,7 @@ def test_detect_scope_integration(self, tmp_path): assert mgr.default_scope is None # Test that apply_auto_scope doesn't inject when no scope detected - filter_dict = {"role": "Controller"} - result = mgr.apply_auto_scope(filter_dict) - assert result == {"role": "Controller"} + from mcp_v2 import NodeFilter + nf = NodeFilter(role="CONTROLLER") + result = mgr.apply_auto_scope(nf) + assert result is nf