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
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -384,6 +384,15 @@ layer emits method-level outgoing calls, built-in outgoing calls for that same
method are replaced (not appended) to avoid double-counting one network call
site.

When a brownfield caller override specifies only part of what built-in detection
would produce, missing fields are inherited from the built-in result. Partial
overrides are therefore non-destructive (tightening instead of replacing). To
fully replace the built-in result for a method, supply all relevant fields in
the override; otherwise unspecified fields default to built-in values.
Example: if built-in detection produces `client_kind=rest_template`, `method=GET`,
`path=/users/{id}`, and an override sets only `path=/users/me`, the final call
keeps `client_kind=rest_template` and `method=GET` while changing only the path.

For source stubs, copy `@CodebaseClient` / `@CodebaseClients` and
`@CodebaseProducer` / `@CodebaseProducers` from
`tests/fixtures/brownfield_client_stubs/` (same "simple-name only" behavior as
Expand Down
9 changes: 6 additions & 3 deletions build_ast_graph.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@
symbol_id,
)
from path_filtering import LayeredIgnore, iter_java_source_files
from java_ontology import VALID_HTTP_CALL_MATCHES
from java_ontology import VALID_CALL_MATCHES

log = logging.getLogger(__name__)

Expand Down Expand Up @@ -1589,6 +1589,7 @@ def _match_call_edge(

candidates: list[RouteRow] = []
if call.client_kind == "feign_method":
# Both sides must provide a non-empty Feign name; empty values are unresolved.
candidates = [
r for r in routes
if r.feign_name and call.feign_target_name and r.feign_name == call.feign_target_name
Expand Down Expand Up @@ -1640,6 +1641,8 @@ def pass6_match_edges(
all_routes = [r for r in tables.routes_rows if r.microservice]
member_by_id = {m.node_id: m for m in tables.members}

# Pass 6 is idempotent for full rebuilds: each run fully re-derives match outcomes.
# If incremental rebuild lands later (Tier-2 follow-up), this reset must remain pass-scoped.
tables.call_edge_stats.http_calls_match_breakdown.clear()
tables.call_edge_stats.async_calls_match_breakdown.clear()
tables.call_edge_stats.cross_service_calls_total = 0
Expand Down Expand Up @@ -1674,7 +1677,7 @@ def _micro_factor(member: MemberEntry | None) -> float:
end_line=member.decl.end_line if member else 0,
)
outcome, candidates = _match_call_edge(call, all_routes, member.microservice if member else "")
if outcome in VALID_HTTP_CALL_MATCHES:
if outcome in VALID_CALL_MATCHES:
row.match = outcome
if outcome in ("cross_service", "intra_service") and len(candidates) == 1:
row.route_id = candidates[0].id
Expand Down Expand Up @@ -1710,7 +1713,7 @@ def _micro_factor(member: MemberEntry | None) -> float:
end_line=member.decl.end_line if member else 0,
)
outcome, candidates = _match_call_edge(call, all_routes, member.microservice if member else "")
if outcome in VALID_HTTP_CALL_MATCHES:
if outcome in VALID_CALL_MATCHES:
row.match = outcome
if outcome in ("cross_service", "intra_service") and len(candidates) == 1:
row.route_id = candidates[0].id
Expand Down
5 changes: 4 additions & 1 deletion java_ontology.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,13 +66,15 @@
"unresolved",
))

VALID_HTTP_CALL_MATCHES: frozenset[str] = frozenset((
VALID_CALL_MATCHES: frozenset[str] = frozenset((
"cross_service",
"intra_service",
"ambiguous",
"phantom",
"unresolved",
))
# Back-compat alias; retained for one release while callers migrate.
VALID_HTTP_CALL_MATCHES = VALID_CALL_MATCHES

__all__ = [
"VALID_ROLES",
Expand All @@ -82,5 +84,6 @@
"VALID_CLIENT_KINDS",
"VALID_HTTP_CALL_STRATEGIES",
"VALID_ASYNC_CALL_STRATEGIES",
"VALID_CALL_MATCHES",
"VALID_HTTP_CALL_MATCHES",
]
7 changes: 4 additions & 3 deletions pr_analysis.py
Original file line number Diff line number Diff line change
Expand Up @@ -380,8 +380,9 @@ def _route_ids_for_symbol(graph: Any, symbol_id: str) -> list[str]:
def compute_risk(graph: Any, changed: list[ChangedSymbol]) -> PrRiskReport:
"""Aggregate blast radius, routes, cross-service callers, and v1 risk score.

Risk score includes a cross-service route-caller bump: +1.0 per caller
(capped at +5.0) across changed methods that expose routes.
Risk score stays in [0, 1]. Cross-service route callers add a bounded
bump (up to +1.0) after normalization so they influence rank while
preserving the public scalar contract.
"""
notes: list[str] = []
blast_by: dict[str, int] = {}
Expand Down Expand Up @@ -472,7 +473,7 @@ def _normalize(x: float, ceiling: float) -> float:
5.0,
float(sum(c.cross_service_callers_count for c in enriched_changed)),
)
score = max(0.0, raw + cross_service_bonus)
score = max(0.0, min(1.0, raw + (cross_service_bonus / 5.0)))
if score < 0.3:
band = "low"
elif score < 0.7:
Expand Down
1 change: 1 addition & 0 deletions propose/completed/TIER1B-HTTP-ASYNC-EDGES-PROPOSE.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,7 @@ to match. The fields are:
| Field | Source | Notes |
| -------------------- | ------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- |
| `client_kind` | `feign_method` / `rest_template` / `web_client` / `kafka_send` / `stream_bridge_send` | Picks the matcher branch. |
| `channel` | `"http"` or `"async"` | Durable discriminator used by `_match_call_edge` to choose HTTP vs async matching. |
| `feign_target_name` | `@FeignClient(name=…)` on the interface the caller's method belongs to | Resolution: literal → SpEL → constant. Same three-strategy ladder as B2a §4.4.5. |
| `path_template_call` | URI argument of `RestTemplate.exchange` etc., curly-collapsed via B2a's normalizer | Re-use B2a's normalizer — do not re-implement. |
| `method_call` | `HttpMethod.GET` etc., or extracted from the called function (`getForObject` → `GET`) | `''` means "couldn't determine". |
Expand Down
2 changes: 1 addition & 1 deletion server.py
Original file line number Diff line number Diff line change
Expand Up @@ -1268,7 +1268,7 @@ async def impact_analysis(
"Map a unified diff to changed indexed symbols and estimate blast radius / risk. "
"Pass full unified-diff text (e.g. `git diff` output). Returns JSON-serializable "
"risk report: changed_symbols, blast_radius_total, cross_service_callers, "
"routes_touched (Route ids via EXPOSES), risk_score, risk_band, notes. "
"routes_touched (Route ids via EXPOSES), risk_score ([0,1]), risk_band, notes. "
"Binary hunks and file renames are skipped for symbol mapping and surfaced in notes."
),
)
Expand Down
132 changes: 132 additions & 0 deletions tests/test_pr_analysis.py
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,138 @@ def fake_fc(self, name, **kwargs):
assert rep.routes_touched


def test_35a_compute_risk_cross_service_bonus_saturates_to_one(monkeypatch) -> None:
from pr_analysis import ChangedSymbol

class _FakeGraph:
def _rows(self, query, params):
if "MATCH (s:Symbol) WHERE s.id = $id RETURN" in query:
return [{
"id": params["id"],
"kind": "method",
"name": "handler",
"fqn": "com.acme.Controller#handler()",
"package": "com.acme",
"module": "svc-a",
"microservice": "svc-a",
"filename": "src/main/java/com/acme/Controller.java",
"start_line": 10,
"end_line": 20,
"start_byte": 0,
"end_byte": 0,
"modifiers": [],
"annotations": [],
"capabilities": [],
"role": "CONTROLLER",
"signature": "handler()",
"parent_id": "",
"resolved": True,
}]
if "MATCH (s:Symbol)-[e:HTTP_CALLS|ASYNC_CALLS]->(r:Route {id: $rid})" in query:
return [{"id": str(i)} for i in range(6)]
return []

def impact_analysis(self, name, **kwargs):
del name, kwargs
return []

def find_callers(self, name, **kwargs):
del name, kwargs
return []

monkeypatch.setattr("pr_analysis._route_ids_for_symbol", lambda graph, sid: ["route-1"])
rep = compute_risk(
_FakeGraph(),
[
ChangedSymbol(
symbol_id="sym-1",
fqn="com.acme.Controller#handler()",
kind="method",
change_type="modified",
file="src/main/java/com/acme/Controller.java",
hunk_lines=[12],
),
],
)
assert rep.risk_score == 1.0


def test_35b_compute_risk_single_cross_service_bonus_is_point_two(monkeypatch) -> None:
from pr_analysis import ChangedSymbol

class _FakeGraph:
def __init__(self, *, include_callers: bool) -> None:
self._include_callers = include_callers

def _rows(self, query, params):
if "MATCH (s:Symbol) WHERE s.id = $id RETURN" in query:
return [{
"id": params["id"],
"kind": "method",
"name": "handler",
"fqn": "com.acme.Controller#handler()",
"package": "com.acme",
"module": "svc-a",
"microservice": "svc-a",
"filename": "src/main/java/com/acme/Controller.java",
"start_line": 10,
"end_line": 20,
"start_byte": 0,
"end_byte": 0,
"modifiers": [],
"annotations": [],
"capabilities": [],
"role": "CONTROLLER",
"signature": "handler()",
"parent_id": "",
"resolved": True,
}]
if "MATCH (s:Symbol)-[e:HTTP_CALLS|ASYNC_CALLS]->(r:Route {id: $rid})" in query:
if self._include_callers:
return [{"id": "caller-1"}]
return []
return []

def impact_analysis(self, name, **kwargs):
del name, kwargs
return []

def find_callers(self, name, **kwargs):
del name, kwargs
return []

monkeypatch.setattr("pr_analysis._route_ids_for_symbol", lambda graph, sid: ["route-1"])
rep = compute_risk(
_FakeGraph(include_callers=True),
[
ChangedSymbol(
symbol_id="sym-1",
fqn="com.acme.Controller#handler()",
kind="method",
change_type="modified",
file="src/main/java/com/acme/Controller.java",
hunk_lines=[12],
),
],
)
baseline = compute_risk(
_FakeGraph(include_callers=False),
[
ChangedSymbol(
symbol_id="sym-1",
fqn="com.acme.Controller#handler()",
kind="method",
change_type="modified",
file="src/main/java/com/acme/Controller.java",
hunk_lines=[12],
),
],
)
# Keep raw terms identical in both runs; only cross-service route-callers differ.
assert rep.routes_touched == baseline.routes_touched == ["route-1"]
assert abs((rep.risk_score - baseline.risk_score) - 0.2) < 1e-9


def test_36_removed_symbol_from_minus_only_hunk(kuzu_graph) -> None:
diff = """diff --git a/chat-assign/src/main/java/com/bank/chat/assign/service/ChatManagementService.java b/chat-assign/src/main/java/com/bank/chat/assign/service/ChatManagementService.java
--- a/chat-assign/src/main/java/com/bank/chat/assign/service/ChatManagementService.java
Expand Down