Skip to content

Commit d25bbb8

Browse files
add callee_declaring_role to CALLS and supertype dedup at ontology 15
Populate callee_declaring_role on every CALLS emission, collapse interface+concrete duplicate candidates before overload_ambiguous, and expose pass3 unresolved counters in GraphMeta for PR-3 telemetry. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent f57a04c commit d25bbb8

11 files changed

Lines changed: 229 additions & 10 deletions

README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -424,7 +424,9 @@ Resolution order for `microservice`:
424424

425425
### Re-index required when ontology changes
426426

427-
Current ontology version is **14**. Any index built before this version must be rebuilt via `cocoindex update ... --full-reprocess -f` or a full `java-codebase-rag reprocess` (no selective flags) so vectors and graph stay aligned. Until re-indexed, the server defensively JSON-decodes string-form list columns so nothing explodes, but filters like `array_contains` will not work.
427+
Current ontology version is **15**. Any index built before this version must be rebuilt via `cocoindex update ... --full-reprocess -f` or a full `java-codebase-rag reprocess` (no selective flags) so vectors and graph stay aligned. Until re-indexed, the server defensively JSON-decodes string-form list columns so nothing explodes, but filters like `array_contains` will not work.
428+
429+
Ontology **15** (CALLS-NOISE PR-1) adds `CALLS.callee_declaring_role`, `GraphMeta.pass3_unresolved_phantom_receiver` / `pass3_unresolved_chained`, and **supertype-walk dedup** at build time: duplicate interface + concrete candidates at the same call site collapse to one `CALLS` row (row counts per method may drop after re-index, not only a new column). PR-2 adds `edge_filter` on `neighbors`; PR-3 moves true receiver-failure rows off `CALLS`.
428430

429431
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).
430432

ast_java.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,8 +81,9 @@
8181
# Phase 9: `@CodebaseAsyncRoute` replaces same-method built-in `@KafkaListener` routes in graph composition.
8282
# Phase 10: `@CodebaseHttpClient` rename + `CodebaseHttpMethod` enum; inbound HTTP layer-C replaces built-in rows.
8383
# Phase 11: `EDGE_SCHEMA` in `java_ontology.py` (canonical edge navigation schema; v14 re-index).
84+
# Phase 12: CALLS `callee_declaring_role`, supertype-walk dedup, pass3 unresolved counters (v15 re-index).
8485
# Bumps whenever extraction / enrichment semantics change.
85-
ONTOLOGY_VERSION = 14
86+
ONTOLOGY_VERSION = 15
8687

8788
ROLE_ANNOTATIONS: dict[str, str] = {
8889
# Spring Web

build_ast_graph.py

Lines changed: 99 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@
5151
parse_java,
5252
)
5353
from graph_enrich import (
54+
BrownfieldOverrides,
5455
_load_config_cross_service_resolution,
5556
collect_annotation_meta_chain,
5657
load_brownfield_overrides,
@@ -68,6 +69,8 @@
6869

6970
log = logging.getLogger(__name__)
7071

72+
_PASS3_ROLE_OVERRIDES = BrownfieldOverrides({}, {}, {}, {}, {}, {}, {}, {}, {}, {})
73+
7174
_VERBOSE_STDERR_LOCK = threading.Lock()
7275

7376
_PASS1_START = "[pass1] starting · parsing Java files under source root"
@@ -180,6 +183,7 @@ class CallsRow:
180183
strategy: str = "phantom"
181184
source: str = "static"
182185
resolved: bool = True
186+
callee_declaring_role: str = "OTHER"
183187

184188

185189
@dataclass
@@ -380,6 +384,8 @@ class GraphTables:
380384
parse_errors: int = 0
381385
skipped_files: int = 0
382386
pass3_skipped_cross_service: int = 0
387+
pass3_unresolved_phantom_receiver: int = 0
388+
pass3_unresolved_chained: int = 0
383389
cross_service_resolution: str = "auto"
384390

385391

@@ -1129,6 +1135,82 @@ def _phantom_method_id(
11291135
return pid
11301136

11311137

1138+
def _method_signature_matches_call(member: MemberEntry, call: CallSite) -> bool:
1139+
if call.arg_count < 0:
1140+
return True
1141+
return len(member.decl.parameters) == call.arg_count
1142+
1143+
1144+
def _is_strict_supertype_of(tables: GraphTables, super_fqn: str, subtype_fqn: str) -> bool:
1145+
if super_fqn == subtype_fqn:
1146+
return False
1147+
entry = tables.types.get(subtype_fqn)
1148+
if entry is None:
1149+
return False
1150+
visited: set[str] = set()
1151+
queue = list(_direct_supertype_fqns(entry, tables))
1152+
while queue:
1153+
tfqn = queue.pop(0)
1154+
if tfqn == super_fqn:
1155+
return True
1156+
if tfqn in visited or tfqn not in tables.types:
1157+
continue
1158+
visited.add(tfqn)
1159+
queue.extend(_direct_supertype_fqns(tables.types[tfqn], tables))
1160+
return False
1161+
1162+
1163+
def _callee_declaring_role_for_dst(tables: GraphTables, dst_id: str) -> str:
1164+
if dst_id in tables.phantoms:
1165+
return "OTHER"
1166+
for m in tables.members:
1167+
if m.node_id != dst_id:
1168+
continue
1169+
parent = tables.types.get(m.parent_fqn)
1170+
if parent is None:
1171+
return "OTHER"
1172+
role, _ = resolve_role_and_capabilities(
1173+
parent.decl, overrides=_PASS3_ROLE_OVERRIDES,
1174+
)
1175+
return role
1176+
return "OTHER"
1177+
1178+
1179+
def _collapse_supertype_duplicates(
1180+
candidates: list[MemberEntry],
1181+
recv_type_fqn: str,
1182+
call: CallSite,
1183+
tables: GraphTables,
1184+
) -> list[MemberEntry]:
1185+
"""§3.3.1 supertype-walk dedup — collapse interface + concrete duplicate sites."""
1186+
if len(candidates) <= 1:
1187+
return candidates
1188+
concrete_on_receiver = [
1189+
c for c in candidates
1190+
if c.parent_fqn == recv_type_fqn and _method_signature_matches_call(c, call)
1191+
]
1192+
if len(concrete_on_receiver) != 1:
1193+
return candidates
1194+
concrete = concrete_on_receiver[0]
1195+
supertypes = [
1196+
c for c in candidates
1197+
if c is not concrete
1198+
and _is_strict_supertype_of(tables, c.parent_fqn, recv_type_fqn)
1199+
and c.decl.signature == concrete.decl.signature
1200+
]
1201+
if not supertypes:
1202+
return candidates
1203+
allowed_ids = {concrete.node_id, *(c.node_id for c in supertypes)}
1204+
if any(c.node_id not in allowed_ids for c in candidates):
1205+
return candidates
1206+
log.debug(
1207+
"pass3 supertype dedup %s -> %s",
1208+
[c.node_id for c in candidates],
1209+
concrete.node_id,
1210+
)
1211+
return [concrete]
1212+
1213+
11321214
def _emit_call_edge(
11331215
tables: GraphTables,
11341216
stats: CallResolutionStats,
@@ -1152,6 +1234,7 @@ def _emit_call_edge(
11521234
strategy=strategy,
11531235
source="static",
11541236
resolved=resolved,
1237+
callee_declaring_role=_callee_declaring_role_for_dst(tables, dst_id),
11551238
))
11561239
stats.total += 1
11571240
stats.by_strategy[strategy] += 1
@@ -1269,6 +1352,9 @@ def _resolve_and_emit_call(
12691352
)
12701353
return
12711354

1355+
if len(candidates) > 1 and edge_strat != "overload_ambiguous":
1356+
candidates = _collapse_supertype_duplicates(candidates, recv_type, call, tables)
1357+
12721358
if len(candidates) == 1:
12731359
candidate = candidates[0]
12741360
ref_arity: int | None = None
@@ -1334,6 +1420,8 @@ def pass3_calls(tables: GraphTables, asts: dict[str, JavaFileAst], *, verbose: b
13341420
pct_callee_unres = 100.0 * stats.callee_unresolved / max(1, stats.total)
13351421
pct_phantom_recv = 100.0 * stats.phantom_other / max(1, stats.total)
13361422
tables.pass3_skipped_cross_service = int(stats.skipped_cross_service)
1423+
tables.pass3_unresolved_phantom_receiver = int(stats.phantom_other)
1424+
tables.pass3_unresolved_chained = int(stats.phantom_chained)
13371425
msg = (
13381426
f"Call resolution: {stats.total} sites, {stats.phantom_chained} chained phantoms "
13391427
f"({pct_chained:.1f}%), {stats.callee_unresolved} unresolved callee "
@@ -2262,6 +2350,8 @@ def _micro_factor(member: MemberEntry | None) -> float:
22622350
"async_calls_match_breakdown STRING, "
22632351
"cross_service_calls_total INT64, "
22642352
"pass3_skipped_cross_service INT64, "
2353+
"pass3_unresolved_phantom_receiver INT64, "
2354+
"pass3_unresolved_chained INT64, "
22652355
"pass4_exposes_suppressed_feign INT64, "
22662356
"cross_service_resolution STRING"
22672357
")"
@@ -2316,7 +2406,8 @@ def _micro_factor(member: MemberEntry | None) -> float:
23162406
_SCHEMA_CALLS = (
23172407
"CREATE REL TABLE CALLS(FROM Symbol TO Symbol, "
23182408
"call_site_line INT64, call_site_byte INT64, arg_count INT64, "
2319-
"confidence DOUBLE, strategy STRING, source STRING, resolved BOOLEAN)"
2409+
"confidence DOUBLE, strategy STRING, source STRING, resolved BOOLEAN, "
2410+
"callee_declaring_role STRING)"
23202411
)
23212412
_SCHEMA_EXPOSES = (
23222413
"CREATE REL TABLE EXPOSES(FROM Symbol TO Route, "
@@ -2503,7 +2594,8 @@ def _write_nodes(
25032594
"MATCH (a:Symbol {id: $src}), (b:Symbol {id: $dst}) "
25042595
"CREATE (a)-[:CALLS {"
25052596
"call_site_line: $line, call_site_byte: $byte, arg_count: $argc, "
2506-
"confidence: $conf, strategy: $strat, source: $src_kind, resolved: $resolved"
2597+
"confidence: $conf, strategy: $strat, source: $src_kind, resolved: $resolved, "
2598+
"callee_declaring_role: $callee_declaring_role"
25072599
"}]->(b)"
25082600
)
25092601

@@ -2647,6 +2739,7 @@ def _write_edges(conn: kuzu.Connection, tables: GraphTables) -> None:
26472739
"strat": row.strategy,
26482740
"src_kind": row.source,
26492741
"resolved": row.resolved,
2742+
"callee_declaring_role": row.callee_declaring_role,
26502743
})
26512744

26522745

@@ -2788,6 +2881,8 @@ def _write_meta(conn: kuzu.Connection, tables: GraphTables, source_root: Path) -
27882881
"async_calls_match_breakdown: $async_calls_match_breakdown, "
27892882
"cross_service_calls_total: $cross_service_calls_total, "
27902883
"pass3_skipped_cross_service: $pass3_skipped_cross_service, "
2884+
"pass3_unresolved_phantom_receiver: $pass3_unresolved_phantom_receiver, "
2885+
"pass3_unresolved_chained: $pass3_unresolved_chained, "
27912886
"pass4_exposes_suppressed_feign: $pass4_exposes_suppressed_feign, "
27922887
"cross_service_resolution: $cross_service_resolution})",
27932888
{
@@ -2821,6 +2916,8 @@ def _write_meta(conn: kuzu.Connection, tables: GraphTables, source_root: Path) -
28212916
"async_calls_match_breakdown": json.dumps(async_match),
28222917
"cross_service_calls_total": int(call_stats.cross_service_calls_total),
28232918
"pass3_skipped_cross_service": int(tables.pass3_skipped_cross_service),
2919+
"pass3_unresolved_phantom_receiver": int(tables.pass3_unresolved_phantom_receiver),
2920+
"pass3_unresolved_chained": int(tables.pass3_unresolved_chained),
28242921
"pass4_exposes_suppressed_feign": int(st.exposes_suppressed_feign),
28252922
"cross_service_resolution": str(tables.cross_service_resolution),
28262923
},

docs/AGENT-GUIDE.md

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,12 @@
1212
> `neighbors` arguments, pass stringified JSON, or use vector search for
1313
> questions the graph answers exactly. This guide keeps them on the rails.
1414
>
15-
> Calibrated against ontology version **14** (see `ast_java.ONTOLOGY_VERSION` /
15+
> Calibrated against ontology version **15** (see `ast_java.ONTOLOGY_VERSION` /
1616
> `java_ontology.EDGE_SCHEMA` + valid sets): canonical edge navigation schema in
17-
> `docs/EDGE-NAVIGATION.md`. v14 re-index required; `HTTP_CALLS` is `Client → Route`;
18-
> `Producer` + `DECLARES_PRODUCER` and `ASYNC_CALLS` (`Producer → Route`) ship in v14.
17+
> `docs/EDGE-NAVIGATION.md`. v15 re-index required — `CALLS.callee_declaring_role`,
18+
> supertype-walk dedup (fewer duplicate-site rows), and `GraphMeta` pass3 unresolved
19+
> counters; PR-2 adds `edge_filter` on `neighbors`. v14: `HTTP_CALLS` is `Client → Route`;
20+
> `Producer` + `DECLARES_PRODUCER` and `ASYNC_CALLS` (`Producer → Route`).
1921
> Still includes stored `OVERRIDES` Symbol→Symbol edges and v12 HTTP brownfield
2022
> (`@CodebaseHttpClient`, shared `CodebaseHttpMethod` enum, inbound layer-C HTTP routes
2123
> replace same-method built-in rows). **Design rationale:** navigation surface and tools —
@@ -264,7 +266,7 @@ Virtual keys (`OVERRIDDEN_BY`, …) are **not** valid `neighbors` arguments —
264266
- **Mixed flat + composed `edge_types`:** flat edges are appended before composed edges, then `limit`/`offset` apply. A small `limit` with e.g. `["DECLARES", "DECLARES.DECLARES_CLIENT"]` may return only member Symbols and no Clients — use the dot-key alone when enumerating terminals.
265267
- **Confidence:** Cross-service edges (`HTTP_CALLS`, `ASYNC_CALLS`) carry confidence, strategy, and match metadata on `edge.attrs` (`attrs.confidence`, `attrs.strategy`, `attrs.match`). Low confidence means the resolver had to guess at the route binding — treat it as a **resolver gap signal**, not a hallucination. Report low-confidence edges with their confidence value, not as facts. Intra-service edges (`CALLS`, `INJECTS`, `IMPLEMENTS`, `EXTENDS`, `DECLARES`, `DECLARES_CLIENT`, `EXPOSES`, `OVERRIDES`) faithfully represent the static graph; the resolved set is still a **lower bound** under reflection / dynamic dispatch (see *What this MCP is NOT*).
266268

267-
### Ontology glossary (version 14)
269+
### Ontology glossary (version 15)
268270

269271
Source of truth: `java_ontology.py` (`EDGE_SCHEMA`, valid sets). Strings are case-sensitive. Edge navigation: [`docs/EDGE-NAVIGATION.md`](./EDGE-NAVIGATION.md) — for `HTTP_CALLS`, traverse via `DECLARES_CLIENT` from a method Symbol or `neighbors` outbound from a Client id; for `ASYNC_CALLS`, traverse via `DECLARES_PRODUCER` or outbound from a Producer id.
270272

docs/EDGE-NAVIGATION.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,7 @@
137137
- `strategy` (`STRING`) — call-graph resolution strategy literal
138138
- `source` (`STRING`) — call-graph source tag
139139
- `resolved` (`BOOLEAN`) — True iff callee Symbol was resolved in-graph
140+
- `callee_declaring_role` (`STRING`) — role of the Symbol that declares the callee method
140141

141142
**Typical traversals**:
142143

java_ontology.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -261,6 +261,11 @@ class EdgeSpec:
261261
EdgeAttr("strategy", "STRING", "call-graph resolution strategy literal"),
262262
EdgeAttr("source", "STRING", "call-graph source tag"),
263263
EdgeAttr("resolved", "BOOLEAN", "True iff callee Symbol was resolved in-graph"),
264+
EdgeAttr(
265+
"callee_declaring_role",
266+
"STRING",
267+
"role of the Symbol that declares the callee method",
268+
),
264269
),
265270
purpose="intra-codebase method call from caller method to callee method",
266271
member_only=True,
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
package smoke;
2+
3+
/** Minimal interface + concrete same-site stub for pass3 supertype-walk dedup (PR-1). */
4+
@interface Repository {
5+
}
6+
7+
@Repository
8+
interface JpaStyleRepo {
9+
void save(Object entity);
10+
}
11+
12+
@Repository
13+
class JpaStyleRepoImpl implements JpaStyleRepo {
14+
@Override
15+
public void save(Object entity) {
16+
}
17+
}
18+
19+
public class SupertypeDedupPatterns {
20+
private final JpaStyleRepoImpl repo;
21+
22+
SupertypeDedupPatterns(JpaStyleRepoImpl repo) {
23+
this.repo = repo;
24+
}
25+
26+
void persist(Object entity) {
27+
repo.save(entity);
28+
}
29+
}

tests/test_ast_graph_build.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,41 @@ def test_schema_has_all_expected_tables(kuzu_db_path: Path) -> None:
5858
assert not missing, f"missing schema tables: {missing}; saw {tables}"
5959

6060

61+
def test_graph_meta_unresolved_counters_present(kuzu_db_path: Path) -> None:
62+
conn = _connect(kuzu_db_path)
63+
r = conn.execute(
64+
"MATCH (m:GraphMeta) RETURN m.pass3_unresolved_phantom_receiver, "
65+
"m.pass3_unresolved_chained"
66+
)
67+
assert r.has_next(), "expected GraphMeta row"
68+
row = r.get_next()
69+
assert row[0] is not None and int(row[0]) >= 0
70+
assert row[1] is not None and int(row[1]) >= 0
71+
72+
73+
def test_pass3_callee_declaring_role_bank_annotated_types(kuzu_db_path: Path) -> None:
74+
"""CALLS to methods on @Service declaring types carry callee_declaring_role=SERVICE."""
75+
conn = _connect(kuzu_db_path)
76+
rows = _column(
77+
conn,
78+
"MATCH (src:Symbol)-[c:CALLS]->(dst:Symbol) "
79+
"MATCH (parent:Symbol {id: dst.parent_id}) "
80+
"WHERE 'Service' IN parent.annotations AND parent.role = 'SERVICE' "
81+
"RETURN c.callee_declaring_role LIMIT 20",
82+
)
83+
assert rows, "expected CALLS to @Service-declared callees on bank-chat-system"
84+
assert all(str(r) == "SERVICE" for r in rows), rows
85+
repo_rows = _column(
86+
conn,
87+
"MATCH (src:Symbol)-[c:CALLS]->(dst:Symbol) "
88+
"MATCH (parent:Symbol {id: dst.parent_id}) "
89+
"WHERE 'Repository' IN parent.annotations "
90+
"RETURN DISTINCT c.callee_declaring_role",
91+
)
92+
if repo_rows:
93+
assert all(str(r) == "REPOSITORY" for r in repo_rows), repo_rows
94+
95+
6196
def test_graph_meta_present_and_versioned(kuzu_db_path: Path) -> None:
6297
conn = _connect(kuzu_db_path)
6398
r = conn.execute(

tests/test_call_graph_smoke_roundtrip.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,38 @@ def test_wildcard_static_import_strategy(kuzu_db_path_call_graph_smoke: Path) ->
9696
assert "static_import_wildcard" in strats, strats
9797

9898

99+
def test_pass3_supertype_dedup_jpa_repository_save_one_row(
100+
kuzu_db_path_call_graph_smoke: Path,
101+
) -> None:
102+
"""SupertypeDedupPatterns: interface + concrete save → one CALLS row, REPOSITORY role."""
103+
db = kuzu_db_path_call_graph_smoke
104+
conn = _connect(db)
105+
rows = _rows(
106+
conn,
107+
"MATCH (src:Symbol)-[c:CALLS]->(dst:Symbol) "
108+
"WHERE src.fqn STARTS WITH 'smoke.SupertypeDedupPatterns#persist' "
109+
"AND dst.name = 'save' "
110+
"RETURN count(*) AS n, c.callee_declaring_role AS role",
111+
)
112+
assert rows, "expected save call from SupertypeDedupPatterns#persist"
113+
assert int(rows[0][0]) == 1, rows
114+
assert str(rows[0][1]) == "REPOSITORY", rows
115+
116+
117+
def test_pass3_overload_ambiguous_still_n_rows(kuzu_db_path_call_graph_smoke: Path) -> None:
118+
"""overload_ambiguous sites keep N rows after supertype dedup (OverloadPatterns#sameArity)."""
119+
db = kuzu_db_path_call_graph_smoke
120+
conn = _connect(db)
121+
rows = _rows(
122+
conn,
123+
"MATCH (src:Symbol)-[c:CALLS]->(dst:Symbol) "
124+
"WHERE src.fqn STARTS WITH 'smoke.OverloadPatterns#sameArity' "
125+
"AND dst.name = 'amb' AND c.strategy = 'overload_ambiguous' "
126+
"RETURN dst.fqn AS fqn",
127+
)
128+
assert len(rows) == 2, f"expected 2 overload_ambiguous targets, got {rows}"
129+
130+
99131
def test_overload_sameArity_emits_two_overload_ambiguous_edges(kuzu_db_path_call_graph_smoke: Path) -> None:
100132
"""§7.1 #13: two one-arg overloads → two resolved edges tagged overload_ambiguous."""
101133
db = kuzu_db_path_call_graph_smoke

tests/test_kuzu_queries.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -385,7 +385,7 @@ def _open_stale_ontology_graph(tmp_path: Path, ontology_version: int) -> Path:
385385

386386

387387
def test_kuzu_graph_refuses_ontology_version_below_required(tmp_path: Path) -> None:
388-
"""v13 graphs refuse to open when ONTOLOGY_VERSION is 14 (SCHEMA-V2 PR-A).
388+
"""v13 graphs refuse to open when ``ONTOLOGY_VERSION`` is current (e.g. 15).
389389
390390
Overlaps ``test_kuzu_graph_get_raises_when_graph_ontology_too_old`` when
391391
``ONTOLOGY_VERSION - 1 == 13``; kept as an explicit v13 regression anchor.
@@ -398,7 +398,11 @@ def test_kuzu_graph_refuses_ontology_version_below_required(tmp_path: Path) -> N
398398
try:
399399
KuzuGraph._instance = None
400400
KuzuGraph._instance_path = None
401-
with pytest.raises(RuntimeError, match="(?i)ontology.*14|required version 14"):
401+
ver = ONTOLOGY_VERSION
402+
with pytest.raises(
403+
RuntimeError,
404+
match=rf"(?i)ontology.*{ver}|required version {ver}",
405+
):
402406
KuzuGraph.get(str(db_path))
403407
finally:
404408
KuzuGraph._instance = prev_inst

0 commit comments

Comments
 (0)