Skip to content

Commit 79a99a1

Browse files
PR-E3: intra-JVM CALLS invariant guard (item 8) (#22)
* add intra-jvm calls invariant guard Co-authored-by: Cursor <cursoragent@cursor.com> * document call invariant guard precondition Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent abdd00d commit 79a99a1

9 files changed

Lines changed: 159 additions & 17 deletions

File tree

build_ast_graph.py

Lines changed: 31 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,7 @@ class CallResolutionStats:
145145
phantom_chained: int = 0
146146
phantom_other: int = 0
147147
callee_unresolved: int = 0
148+
skipped_cross_service: int = 0
148149

149150

150151
@dataclass
@@ -252,6 +253,7 @@ class GraphTables:
252253
methods_by_type: dict[str, list[MemberEntry]] = field(default_factory=dict)
253254
parse_errors: int = 0
254255
skipped_files: int = 0
256+
pass3_skipped_cross_service: int = 0
255257

256258

257259
# ---------- file walk (see `path_filtering.iter_java_source_files`) ----------
@@ -1042,6 +1044,7 @@ def _resolve_and_emit_call(
10421044
recv_type, strat, conf = _resolve_receiver_type(call, scope=scope, member=member, ast=ast, tables=tables)
10431045

10441046
if strat == "chained_receiver":
1047+
# Chained-receiver phantoms have no microservice attribution, so they cannot violate cross-service CALLS invariants.
10451048
pid = _phantom_method_id(
10461049
tables, receiver_fqn=None, receiver_expr=call.receiver_expr,
10471050
callee=call.callee_simple, arg_count=call.arg_count,
@@ -1053,6 +1056,7 @@ def _resolve_and_emit_call(
10531056
return
10541057

10551058
if recv_type is None:
1059+
# Unresolved-receiver phantoms also carry empty microservice attribution.
10561060
pid = _phantom_method_id(
10571061
tables, receiver_fqn=None, receiver_expr=call.receiver_expr,
10581062
callee=call.callee_simple, arg_count=call.arg_count,
@@ -1067,6 +1071,22 @@ def _resolve_and_emit_call(
10671071
recv_type, call.callee_simple, call.arg_count, tables, ast,
10681072
)
10691073

1074+
# Guard relies on `_lookup_method_candidates` returning a same-ms candidate when one exists; revisit if pass3 scopes lookups per-microservice.
1075+
if member.microservice:
1076+
same_ms = [c for c in candidates if c.microservice == member.microservice]
1077+
if same_ms and len(same_ms) != len(candidates):
1078+
for c in candidates:
1079+
if c.microservice and c.microservice != member.microservice:
1080+
log.warning(
1081+
"skipping cross-microservice CALLS edge %s -> %s "
1082+
"(caller=%s, callee=%s)",
1083+
f"{member.parent_fqn}#{member.decl.signature}",
1084+
f"{c.parent_fqn}#{c.decl.signature}",
1085+
member.microservice, c.microservice,
1086+
)
1087+
stats.skipped_cross_service += 1
1088+
candidates = same_ms
1089+
10701090
# Compute the call-shape strategy / confidence override BEFORE the
10711091
# empty-candidates check so they are preserved even when the callee cannot
10721092
# be located on the resolved receiver type (B3 fix).
@@ -1106,11 +1126,12 @@ def _resolve_and_emit_call(
11061126
return
11071127

11081128
if len(candidates) == 1:
1129+
candidate = candidates[0]
11091130
ref_arity: int | None = None
11101131
if call.arg_count < 0:
1111-
ref_arity = len(candidates[0].decl.parameters)
1132+
ref_arity = len(candidate.decl.parameters)
11121133
_emit_call_edge(
1113-
tables, stats, src_id=member.node_id, dst_id=candidates[0].node_id, call=call,
1134+
tables, stats, src_id=member.node_id, dst_id=candidate.node_id, call=call,
11141135
confidence=edge_conf, strategy=edge_strat, resolved=True,
11151136
edge_arg_count=ref_arity,
11161137
)
@@ -1165,11 +1186,13 @@ def pass3_calls(tables: GraphTables, asts: dict[str, JavaFileAst], *, verbose: b
11651186
pct_chained = 100.0 * stats.phantom_chained / max(1, stats.total)
11661187
pct_callee_unres = 100.0 * stats.callee_unresolved / max(1, stats.total)
11671188
pct_phantom_recv = 100.0 * stats.phantom_other / max(1, stats.total)
1189+
tables.pass3_skipped_cross_service = int(stats.skipped_cross_service)
11681190
msg = (
11691191
f"Call resolution: {stats.total} sites, {stats.phantom_chained} chained phantoms "
11701192
f"({pct_chained:.1f}%), {stats.callee_unresolved} unresolved callee "
11711193
f"({pct_callee_unres:.1f}%), {stats.phantom_other} phantom receiver "
1172-
f"({pct_phantom_recv:.1f}%), strategies: {dict(stats.by_strategy)}"
1194+
f"({pct_phantom_recv:.1f}%), {stats.skipped_cross_service} skipped cross-service, "
1195+
f"strategies: {dict(stats.by_strategy)}"
11731196
)
11741197
log.info(msg)
11751198
if verbose:
@@ -1781,7 +1804,8 @@ def _micro_factor(member: MemberEntry | None) -> float:
17811804
"async_producers_from_brownfield_pct DOUBLE, "
17821805
"http_calls_match_breakdown STRING, "
17831806
"async_calls_match_breakdown STRING, "
1784-
"cross_service_calls_total INT64"
1807+
"cross_service_calls_total INT64, "
1808+
"pass3_skipped_cross_service INT64"
17851809
")"
17861810
)
17871811

@@ -2158,7 +2182,8 @@ def _write_meta(conn: kuzu.Connection, tables: GraphTables, source_root: Path) -
21582182
"async_producers_from_brownfield_pct: $async_producers_from_brownfield_pct, "
21592183
"http_calls_match_breakdown: $http_calls_match_breakdown, "
21602184
"async_calls_match_breakdown: $async_calls_match_breakdown, "
2161-
"cross_service_calls_total: $cross_service_calls_total})",
2185+
"cross_service_calls_total: $cross_service_calls_total, "
2186+
"pass3_skipped_cross_service: $pass3_skipped_cross_service})",
21622187
{
21632188
"k": "graph",
21642189
"ov": ONTOLOGY_VERSION,
@@ -2183,6 +2208,7 @@ def _write_meta(conn: kuzu.Connection, tables: GraphTables, source_root: Path) -
21832208
"http_calls_match_breakdown": json.dumps(http_match),
21842209
"async_calls_match_breakdown": json.dumps(async_match),
21852210
"cross_service_calls_total": int(call_stats.cross_service_calls_total),
2211+
"pass3_skipped_cross_service": int(tables.pass3_skipped_cross_service),
21862212
},
21872213
)
21882214

kuzu_queries.py

Lines changed: 36 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -329,7 +329,26 @@ def _rows(self, query: str, params: dict[str, Any] | None = None) -> list[dict[s
329329
# ---- meta ----
330330

331331
def meta(self) -> dict[str, Any]:
332-
_META_FULL = (
332+
_META_PR_E3 = (
333+
"MATCH (m:GraphMeta) RETURN m.key AS key, m.ontology_version AS ontology_version, "
334+
"m.built_at AS built_at, m.source_root AS source_root, "
335+
"m.counts_json AS counts_json, m.parse_errors AS parse_errors, "
336+
"m.routes_total AS routes_total, m.exposes_total AS exposes_total, "
337+
"m.routes_by_framework AS routes_by_framework, "
338+
"m.routes_resolved_pct AS routes_resolved_pct, "
339+
"m.routes_from_brownfield_pct AS routes_from_brownfield_pct, "
340+
"m.routes_by_layer AS routes_by_layer, "
341+
"m.http_calls_total AS http_calls_total, m.async_calls_total AS async_calls_total, "
342+
"m.http_calls_by_strategy AS http_calls_by_strategy, m.async_calls_by_strategy AS async_calls_by_strategy, "
343+
"m.http_calls_resolved_pct AS http_calls_resolved_pct, m.async_calls_resolved_pct AS async_calls_resolved_pct, "
344+
"m.http_clients_from_brownfield_pct AS http_clients_from_brownfield_pct, "
345+
"m.async_producers_from_brownfield_pct AS async_producers_from_brownfield_pct, "
346+
"m.http_calls_match_breakdown AS http_calls_match_breakdown, "
347+
"m.async_calls_match_breakdown AS async_calls_match_breakdown, "
348+
"m.cross_service_calls_total AS cross_service_calls_total, "
349+
"m.pass3_skipped_cross_service AS pass3_skipped_cross_service"
350+
)
351+
_META_PRE_E3 = (
333352
"MATCH (m:GraphMeta) RETURN m.key AS key, m.ontology_version AS ontology_version, "
334353
"m.built_at AS built_at, m.source_root AS source_root, "
335354
"m.counts_json AS counts_json, m.parse_errors AS parse_errors, "
@@ -361,19 +380,23 @@ def meta(self) -> dict[str, Any]:
361380
"m.counts_json AS counts_json, m.parse_errors AS parse_errors"
362381
)
363382
rows: list[dict[str, Any]]
364-
meta_mode = "full"
383+
meta_mode = "pr_e3"
365384
try:
366-
rows = self._rows(_META_FULL)
385+
rows = self._rows(_META_PR_E3)
367386
except Exception:
368-
meta_mode = "pr_a2"
387+
meta_mode = "pre_e3"
369388
try:
370-
rows = self._rows(_META_PR_A2)
389+
rows = self._rows(_META_PRE_E3)
371390
except Exception:
372-
meta_mode = "legacy"
391+
meta_mode = "pr_a2"
373392
try:
374-
rows = self._rows(_META_LEGACY)
375-
except Exception as e:
376-
return {"error": f"{e}"}
393+
rows = self._rows(_META_PR_A2)
394+
except Exception:
395+
meta_mode = "legacy"
396+
try:
397+
rows = self._rows(_META_LEGACY)
398+
except Exception as e:
399+
return {"error": f"{e}"}
377400
if not rows:
378401
return {"error": "no GraphMeta node"}
379402
row = rows[0]
@@ -398,6 +421,7 @@ def meta(self) -> dict[str, Any]:
398421
http_calls_match_breakdown: dict[str, Any] = {}
399422
async_calls_match_breakdown: dict[str, Any] = {}
400423
cross_service_calls_total = 0
424+
pass3_skipped_cross_service = 0
401425
if meta_mode != "legacy":
402426
rfw_raw = row.get("routes_by_framework") or "{}"
403427
try:
@@ -409,7 +433,7 @@ def meta(self) -> dict[str, Any]:
409433
routes_total = int(row.get("routes_total") or 0)
410434
exposes_total = int(row.get("exposes_total") or 0)
411435
routes_resolved_pct = float(row.get("routes_resolved_pct") or 0.0)
412-
if meta_mode == "full":
436+
if meta_mode in ("pr_e3", "pre_e3"):
413437
routes_from_brownfield_pct = float(row.get("routes_from_brownfield_pct") or 0.0)
414438
rbl_raw = row.get("routes_by_layer") or "{}"
415439
try:
@@ -453,6 +477,7 @@ def meta(self) -> dict[str, Any]:
453477
if not isinstance(async_calls_match_breakdown, dict):
454478
async_calls_match_breakdown = {}
455479
cross_service_calls_total = int(row.get("cross_service_calls_total") or 0)
480+
pass3_skipped_cross_service = int(row.get("pass3_skipped_cross_service") or 0)
456481
return {
457482
"ontology_version": int(row.get("ontology_version") or 0),
458483
"built_at": int(row.get("built_at") or 0),
@@ -476,6 +501,7 @@ def meta(self) -> dict[str, Any]:
476501
"http_calls_match_breakdown": http_calls_match_breakdown,
477502
"async_calls_match_breakdown": async_calls_match_breakdown,
478503
"cross_service_calls_total": cross_service_calls_total,
504+
"pass3_skipped_cross_service": pass3_skipped_cross_service,
479505
"db_path": self.db_path,
480506
}
481507

plans/PLAN-POST-TIER1B-FOLLOWUPS.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ Links go to the review comment so the original context survives.
1919
| 5 | [PR-D2 #13](https://github.com/HumanBean17/java-enterprise-codebase-rag/pull/13#issuecomment-4378995637) post-D3 follow-up 1 | README: document `anchor`-fills-from-builtin behaviour for partial brownfield overrides | low (doc) |
2020
| 6 | [PR-D2 #13](https://github.com/HumanBean17/java-enterprise-codebase-rag/pull/13#issuecomment-4378995637) post-D3 follow-up 2 | Proposal §6: add `channel` field to the `OutgoingCallDecl` schema sketch as durable | low (doc) |
2121
| 7 | [PR-D1 #12](https://github.com/HumanBean17/java-enterprise-codebase-rag/pull/12#issuecomment-4378723605) obs 2 | Second copy of strategy ladder still in `graph_enrich.py:720-724` (annotation/spel/constant_ref) — known consolidation candidate | medium (tech debt) |
22-
| 8 | [PR-E1 #19 review reply](https://github.com/HumanBean17/java-enterprise-codebase-rag/pull/19#issuecomment-4380659734) | `pass3_calls` doesn't enforce the intra-JVM invariant for `CALLS` edges. Today no cross-microservice CALLS edge is emitted on any fixture (verified on `cross_service_smoke`: 9 CALLS edges, 0 cross), but the cleanliness is incidental — no `caller.microservice == callee.microservice` guard exists. FQN collisions across services or brownfield supertype overrides could in principle break the invariant silently. | low-to-medium (invariant) |
22+
| 8 | [PR-E1 #19 review reply](https://github.com/HumanBean17/java-enterprise-codebase-rag/pull/19#issuecomment-4380659734) | `pass3_calls` doesn't enforce the intra-JVM invariant for `CALLS` edges. Today no cross-microservice CALLS edge is emitted on any fixture (verified on `cross_service_smoke`: 9 CALLS edges, 0 cross), but the cleanliness is incidental — no `caller.microservice == callee.microservice` guard exists. FQN collisions across services or brownfield supertype overrides could in principle break the invariant silently. **✅ shipped in PR-E3** | low-to-medium (invariant) |
2323

2424
## Recommended PR boundaries
2525

@@ -313,7 +313,9 @@ is surgical, not over-eager.
313313
(verified by extending one existing graph_meta assertion in
314314
`tests/test_kuzu_meta.py` or equivalent).
315315
5. Pass3 verbose log mentions the new counter.
316-
6. `260+` (current baseline + 1 new test) tests still pass.
316+
6. `260+` (current baseline + 1 new test) tests still pass.
317+
318+
Status: shipped as PR-E3 in [#22](https://github.com/HumanBean17/java-enterprise-codebase-rag/pull/22).
317319

318320
### Risk
319321

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
<project xmlns="http://maven.apache.org/POM/4.0.0"
2+
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
3+
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
4+
<modelVersion>4.0.0</modelVersion>
5+
<groupId>smoke</groupId>
6+
<artifactId>svc-x</artifactId>
7+
<version>1.0.0</version>
8+
</project>
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
package com.example;
2+
3+
public class Caller {
4+
public void run() {
5+
new SharedDto().process();
6+
}
7+
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
package com.example;
2+
3+
public class SharedDto {
4+
public void process() {
5+
}
6+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
<project xmlns="http://maven.apache.org/POM/4.0.0"
2+
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
3+
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
4+
<modelVersion>4.0.0</modelVersion>
5+
<groupId>smoke</groupId>
6+
<artifactId>svc-y</artifactId>
7+
<version>1.0.0</version>
8+
</project>
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
package com.example;
2+
3+
public class SharedDto {
4+
public void process() {
5+
}
6+
}

tests/test_call_invariant.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
from __future__ import annotations
2+
3+
from pathlib import Path
4+
5+
import kuzu
6+
7+
from build_ast_graph import GraphTables, pass1_parse, pass2_edges, pass3_calls, write_kuzu
8+
from kuzu_queries import KuzuGraph
9+
10+
11+
_FQN_COLLISION_FIXTURE = Path(__file__).resolve().parent / "fixtures" / "fqn_collision_smoke"
12+
_CROSS_SERVICE_FIXTURE = Path(__file__).resolve().parent / "fixtures" / "cross_service_smoke"
13+
14+
15+
def _build(root: Path, db_path: Path) -> None:
16+
"""Build through pass3 only (no routes); sufficient for `pass3_skipped_cross_service` assertions."""
17+
tables = GraphTables()
18+
asts = pass1_parse(root, tables, verbose=False)
19+
pass2_edges(tables, asts, verbose=False)
20+
pass3_calls(tables, asts, verbose=False)
21+
write_kuzu(db_path, tables, source_root=root, verbose=False)
22+
23+
24+
def _scalar(db_path: Path, query: str) -> int:
25+
conn = kuzu.Connection(kuzu.Database(str(db_path), read_only=True))
26+
r = conn.execute(query)
27+
return int(r.get_next()[0] or 0) if r.has_next() else 0
28+
29+
30+
def test_call_invariant_blocks_cross_microservice_edges(tmp_path: Path) -> None:
31+
db = tmp_path / "fqn_collision.kuzu"
32+
_build(_FQN_COLLISION_FIXTURE, db)
33+
cross_calls = _scalar(
34+
db,
35+
"MATCH (a:Symbol)-[:CALLS]->(b:Symbol) "
36+
"WHERE a.microservice <> '' AND b.microservice <> '' "
37+
"AND a.microservice <> b.microservice "
38+
"RETURN count(*)",
39+
)
40+
assert cross_calls == 0
41+
assert KuzuGraph(str(db)).meta()["pass3_skipped_cross_service"] >= 1
42+
43+
44+
def test_call_invariant_inert_on_clean_fixtures(tmp_path: Path) -> None:
45+
db = tmp_path / "cross_service_smoke.kuzu"
46+
_build(_CROSS_SERVICE_FIXTURE, db)
47+
assert KuzuGraph(str(db)).meta()["pass3_skipped_cross_service"] == 0
48+
49+
50+
def test_call_invariant_inert_on_bank_chat_system(tmp_path: Path, corpus_root: Path) -> None:
51+
db = tmp_path / "bank_chat.kuzu"
52+
_build(corpus_root, db)
53+
assert KuzuGraph(str(db)).meta()["pass3_skipped_cross_service"] == 0

0 commit comments

Comments
 (0)