Skip to content

Commit d0d6ae4

Browse files
fix(pr-3 review): ucs: ids, describe/CLI tests, fan-out hint uses true CALLS count
UnresolvedCallSite ids use ucs: prefix with NodeRef.kind unresolved_call_site so describe is not mis-routed. Add rollup and CLI smoke tests; wire --reason choices; fix pass3 UCS percentage denominator and README graph section. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 2a29267 commit d0d6ae4

9 files changed

Lines changed: 121 additions & 28 deletions

File tree

README.md

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -361,7 +361,7 @@ For `reprocess`, the pipeline runs `cocoindex` with `cwd` set to the bundle dire
361361

362362
## 6. Graph layer
363363

364-
A deterministic property graph derived from tree-sitter Java parsing lives next to the LanceDB tables under the index directory (default `${JAVA_CODEBASE_RAG_INDEX_DIR:-./.java-codebase-rag}/code_graph.kuzu`). Current ontology version: **14** (see [`docs/EDGE-NAVIGATION.md`](./docs/EDGE-NAVIGATION.md) for edge shapes).
364+
A deterministic property graph derived from tree-sitter Java parsing lives next to the LanceDB tables under the index directory (default `${JAVA_CODEBASE_RAG_INDEX_DIR:-./.java-codebase-rag}/code_graph.kuzu`). Current ontology version: **15** (see [`docs/EDGE-NAVIGATION.md`](./docs/EDGE-NAVIGATION.md) for MCP-traversable edge shapes).
365365

366366
### Node kinds
367367

@@ -370,10 +370,11 @@ A deterministic property graph derived from tree-sitter Java parsing lives next
370370
| `Symbol` | `package`, `file`, `class`, `interface`, `enum`, `record`, `annotation`, `method`, `constructor` |
371371
| `Route` | HTTP endpoint or async listener (one row per declared route) |
372372
| `Client` | Outbound HTTP / messaging call site |
373+
| `UnresolvedCallSite` | Receiver-failure call site (`chained_receiver`, `phantom_unresolved_receiver`) — not a `Symbol`; ids use the `ucs:` prefix |
373374

374-
Unresolved targets become **phantom** nodes (`resolved=false`, FQN guessed from imports / `java.lang`).
375+
Known-receiver-external JDK / Spring / Lombok callees stay on **`CALLS`** as phantom **method** symbols (`resolved=false`). Receiver-failure sites (unresolved receiver or chained receiver) are **`UnresolvedCallSite`** nodes linked by **`UNRESOLVED_AT`** (not in `EDGE_SCHEMA`; use `describe`, `neighbors(..., include_unresolved=True)`, or `java-codebase-rag unresolved-calls`).
375376

376-
### Edge types (10)
377+
### Edge types (MCP-traversable)
377378

378379
| Edge | Direction | Meaning |
379380
|---|---|---|
@@ -388,7 +389,7 @@ Unresolved targets become **phantom** nodes (`resolved=false`, FQN guessed from
388389
| `HTTP_CALLS` | client → route | Cross-service HTTP call (caller-side Client to target Route). |
389390
| `ASYNC_CALLS` | producer → route | Cross-service async (Kafka, Rabbit, JMS, …). |
390391

391-
JDK / Spring / Lombok callees are represented as **phantom** method symbols at index time. Caller/callee traversals default to `exclude_external=true` so those edges are filtered by FQN prefix without dropping them from the graph.
392+
Caller/callee traversals default to `exclude_external=true` on **`find_callers`** so library FQN prefixes are filtered without dropping edges from the graph.
392393

393394
### Call-graph notes
394395

build_ast_graph.py

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1223,7 +1223,7 @@ def _collapse_supertype_duplicates(
12231223

12241224

12251225
def _unresolved_call_site_id(caller_id: str, call: CallSite) -> str:
1226-
return f"{caller_id}:{call.line}:{call.byte}"
1226+
return f"ucs:{caller_id}:{call.line}:{call.byte}"
12271227

12281228

12291229
def _emit_unresolved_call_site(
@@ -1438,9 +1438,11 @@ def pass3_calls(tables: GraphTables, asts: dict[str, JavaFileAst], *, verbose: b
14381438
_process_file_calls(file_ast, rel_path, tables, stats)
14391439
except Exception as e:
14401440
log.error("Call extraction failed for %s: %s", rel_path, e)
1441-
pct_chained = 100.0 * stats.phantom_chained / max(1, stats.total)
1442-
pct_callee_unres = 100.0 * stats.callee_unresolved / max(1, stats.total)
1443-
pct_phantom_recv = 100.0 * stats.phantom_other / max(1, stats.total)
1441+
denom_calls = max(1, stats.total)
1442+
denom_sites = max(1, stats.total + stats.phantom_chained + stats.phantom_other)
1443+
pct_chained = 100.0 * stats.phantom_chained / denom_sites
1444+
pct_callee_unres = 100.0 * stats.callee_unresolved / denom_calls
1445+
pct_phantom_recv = 100.0 * stats.phantom_other / denom_sites
14441446
tables.pass3_skipped_cross_service = int(stats.skipped_cross_service)
14451447
tables.pass3_unresolved_phantom_receiver = int(stats.phantom_other)
14461448
tables.pass3_unresolved_chained = int(stats.phantom_chained)

docs/AGENT-GUIDE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -219,7 +219,7 @@ Returns **edges** with `attrs` (`confidence`, `strategy`, `match`, … on cross-
219219

220220
**Cross-service edges** (`HTTP_CALLS`, `ASYNC_CALLS`): read `attrs.confidence` and `attrs.match` — low confidence or `unresolved`/`phantom`/`ambiguous` means treat as a resolver signal, not ground truth.
221221

222-
**`CALLS` edges:** source-ordered (`call_site_line`, `call_site_byte`). After ontology 15 PR-3, true receiver-failure sites are **not** on `CALLS` — they are `UnresolvedCallSite` nodes (`reason`: `chained_receiver` or `phantom_unresolved_receiver`). `attrs.resolved=false` on remaining `CALLS` rows means known-receiver-external (JDK/Spring) callees, not receiver failure. **`include_unresolved=True`** (CALLS + `direction=out` only) interleaves unresolved sites with resolved `CALLS` (`row_kind` discriminator); **mutually exclusive with `edge_filter`**. **`dedup_calls=True`** collapses identical `(origin, callee)` `CALLS` to one row with `call_site_lines`. **`filter` + `edge_filter` together** load the ordered CALLS stream then apply callee `NodeFilter` in Python — expect higher latency on hot methods than `edge_filter` alone. Optional **`edge_filter`** projects before pagination: `min_confidence`; `include_strategies` / `exclude_strategies` (mutually exclusive); `callee_declaring_role`, `callee_declaring_roles`, `exclude_callee_declaring_roles` (`["OTHER"]` also drops known-external rows). **`filter.role` filters the neighbor method (usually `OTHER`), not the callee stereotype** — use `edge_filter.callee_declaring_role` for repository/service hops. **`exclude_external` applies to `find_callers` / `find_callees` only** (FQN-prefix); trim JDK noise on `neighbors` CALLS via `edge_filter`. Accessor noise: role excludes help; getter/setter heuristics in [`propose/AGENT-SKILLS-AND-COMMANDS-PROPOSE.md`](../propose/AGENT-SKILLS-AND-COMMANDS-PROPOSE.md) `/mini-map`.
222+
**`CALLS` edges:** source-ordered (`call_site_line`, `call_site_byte`). After ontology 15 PR-3, true receiver-failure sites are **not** on `CALLS` — they are `UnresolvedCallSite` nodes (`reason`: `chained_receiver` or `phantom_unresolved_receiver`; ids use the `ucs:` prefix, `other.kind=unresolved_call_site` — **not** describable via `describe(id=…)`). `UNRESOLVED_AT` is graph storage only (not in `EDGE_SCHEMA` / `neighbors` edge_types). `attrs.resolved=false` on remaining `CALLS` rows means known-receiver-external (JDK/Spring) callees, not receiver failure. **`include_unresolved=True`** (CALLS + `direction=out` only) interleaves unresolved sites with resolved `CALLS` (`row_kind` discriminator); **mutually exclusive with `edge_filter`**. **`dedup_calls=True`** collapses identical `(origin, callee)` `CALLS` to one row with `call_site_lines`. **`filter` + `edge_filter` together** load the ordered CALLS stream then apply callee `NodeFilter` in Python — expect higher latency on hot methods than `edge_filter` alone. Optional **`edge_filter`** projects before pagination: `min_confidence`; `include_strategies` / `exclude_strategies` (mutually exclusive); `callee_declaring_role`, `callee_declaring_roles`, `exclude_callee_declaring_roles` (`["OTHER"]` also drops known-external rows). **`filter.role` filters the neighbor method (usually `OTHER`), not the callee stereotype** — use `edge_filter.callee_declaring_role` for repository/service hops. **`exclude_external` applies to `find_callers` / `find_callees` only** (FQN-prefix); trim JDK noise on `neighbors` CALLS via `edge_filter`. Accessor noise: role excludes help; getter/setter heuristics in [`propose/AGENT-SKILLS-AND-COMMANDS-PROPOSE.md`](../propose/AGENT-SKILLS-AND-COMMANDS-PROPOSE.md) `/mini-map`.
223223

224224
### Ontology glossary
225225

java_codebase_rag/cli.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
resolve_operator_config,
2323
)
2424
from java_codebase_rag.pipeline import clip, run_build_ast_graph, run_cocoindex_drop, run_cocoindex_update
25+
from java_ontology import VALID_UNRESOLVED_CALL_REASONS
2526

2627
KUZU_INCREMENTAL_TRACKING_ISSUE_URL = "https://github.com/HumanBean17/java-codebase-rag/issues/73"
2728

@@ -705,7 +706,13 @@ def build_parser() -> argparse.ArgumentParser:
705706
uc_list = unresolved_sub.add_parser("list", help="List unresolved call sites.")
706707
_add_index_embedding_flags(uc_list)
707708
uc_list.add_argument("--method-id", type=str, default=None, help="Caller Symbol id")
708-
uc_list.add_argument("--reason", type=str, default=None, help="phantom_unresolved_receiver or chained_receiver")
709+
uc_list.add_argument(
710+
"--reason",
711+
type=str,
712+
default=None,
713+
choices=sorted(VALID_UNRESOLVED_CALL_REASONS),
714+
help="Filter by UnresolvedCallSite.reason",
715+
)
709716
uc_list.add_argument("--microservice", type=str, default=None)
710717
uc_list.add_argument("--callee-simple", type=str, default=None, dest="callee_simple")
711718
uc_list.add_argument("--limit", type=int, default=100)

mcp_hints.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -372,13 +372,14 @@ def neighbors_calls_fanout_hints(payload: dict[str, Any]) -> list[tuple[int, str
372372
return pairs
373373
if payload.get("include_unresolved"):
374374
return pairs
375-
n = len(list(payload.get("results") or []))
375+
page_n = len(list(payload.get("results") or []))
376+
calls_n = int(payload.get("calls_row_count") or 0) or page_n
376377
unresolved = int(payload.get("unresolved_count") or 0)
377-
if not payload.get("edge_filter_provided") and n >= _CALLS_HIGH_FANOUT_THRESHOLD:
378-
pairs.append((PRIORITY_LEAF_FOLLOWUP, TPL_NEIGHBORS_CALLS_HIGH_FANOUT.format(n=n)))
378+
if not payload.get("edge_filter_provided") and calls_n >= _CALLS_HIGH_FANOUT_THRESHOLD:
379+
pairs.append((PRIORITY_LEAF_FOLLOWUP, TPL_NEIGHBORS_CALLS_HIGH_FANOUT.format(n=calls_n)))
379380
if unresolved > 0:
380381
pairs.append(
381-
(PRIORITY_LEAF_FOLLOWUP, TPL_NEIGHBORS_CALLS_HAS_UNRESOLVED.format(n=n, k=unresolved))
382+
(PRIORITY_LEAF_FOLLOWUP, TPL_NEIGHBORS_CALLS_HAS_UNRESOLVED.format(n=page_n, k=unresolved))
382383
)
383384
return pairs
384385

mcp_v2.py

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -392,7 +392,7 @@ class SearchHit(BaseModel):
392392

393393
class NodeRef(BaseModel):
394394
id: str
395-
kind: Literal["symbol", "route", "client", "producer"]
395+
kind: Literal["symbol", "route", "client", "producer", "unresolved_call_site"]
396396
fqn: str
397397
symbol_kind: str | None = None
398398
microservice: str | None = None
@@ -550,7 +550,11 @@ class ResolveOutput(BaseModel):
550550
hints: list[str] = Field(default_factory=list, description=MCP_HINTS_FIELD_DESCRIPTION)
551551

552552

553-
def _node_kind_from_id(id_str: str) -> Literal["symbol", "route", "client", "producer"]:
553+
def _node_kind_from_id(
554+
id_str: str,
555+
) -> Literal["symbol", "route", "client", "producer", "unresolved_call_site"]:
556+
if id_str.startswith("ucs:"):
557+
return "unresolved_call_site"
554558
if id_str.startswith("sym:"):
555559
return "symbol"
556560
if id_str.startswith("route:") or id_str.startswith("r:"):
@@ -562,7 +566,10 @@ def _node_kind_from_id(id_str: str) -> Literal["symbol", "route", "client", "pro
562566
raise ValueError(f"Unknown id prefix for `{id_str}`")
563567

564568

565-
def _resolve_node_kind(graph: KuzuGraph, node_id: str) -> Literal["symbol", "route", "client", "producer"]:
569+
def _resolve_node_kind(
570+
graph: KuzuGraph,
571+
node_id: str,
572+
) -> Literal["symbol", "route", "client", "producer", "unresolved_call_site"]:
566573
try:
567574
return _node_kind_from_id(node_id)
568575
except ValueError:
@@ -1464,7 +1471,7 @@ def _unresolved_site_to_edge(origin_id: str, row: dict[str, Any]) -> Edge:
14641471
origin_id=origin_id,
14651472
edge_type="CALLS",
14661473
direction="out",
1467-
other=NodeRef(id=ucs_id, kind="symbol", fqn=ucs_id, name=callee),
1474+
other=NodeRef(id=ucs_id, kind="unresolved_call_site", fqn="", name=callee),
14681475
attrs={
14691476
"row_kind": "unresolved_call_site",
14701477
"unresolved_call_site_id": ucs_id,
@@ -1711,8 +1718,10 @@ def neighbors_v2(
17111718
results: list[Edge] = []
17121719
unfiltered_calls_count: int | None = None
17131720
unresolved_count: int | None = None
1721+
calls_row_count: int | None = None
17141722
if use_calls_path and len(origins) == 1 and direction == "out":
17151723
unresolved_count = g.count_unresolved_for_caller(origins[0])
1724+
calls_row_count = g.count_calls_for_symbol(origins[0], direction=direction)
17161725
for origin_id in origins:
17171726
origin_kind = _resolve_node_kind(g, origin_id)
17181727
if composed_keys:
@@ -1867,6 +1876,7 @@ def neighbors_v2(
18671876
"dedup_calls": dedup_calls,
18681877
"unfiltered_calls_count": unfiltered_calls_count,
18691878
"unresolved_count": unresolved_count,
1879+
"calls_row_count": calls_row_count,
18701880
}
18711881
return NeighborsOutput(
18721882
success=True,

tests/test_java_codebase_rag_cli.py

Lines changed: 57 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,9 +42,17 @@ def _base_env(corpus_root: Path, kuzu_db_path: Path | None = None) -> dict[str,
4242
return env
4343

4444

45-
def _run_cli(args: list[str], *, env: dict[str, str], stdin: str | None = None) -> subprocess.CompletedProcess:
45+
def _java_codebase_rag_exe() -> str:
46+
venv_exe = Path(sys.executable).parent / "java-codebase-rag"
47+
if venv_exe.is_file():
48+
return str(venv_exe)
4649
exe = shutil.which("java-codebase-rag")
4750
assert exe is not None, "expected installed java-codebase-rag entrypoint"
51+
return exe
52+
53+
54+
def _run_cli(args: list[str], *, env: dict[str, str], stdin: str | None = None) -> subprocess.CompletedProcess:
55+
exe = _java_codebase_rag_exe()
4856
return subprocess.run(
4957
[exe, *args],
5058
capture_output=True,
@@ -76,7 +84,7 @@ def test_cli_erase_refuses_non_tty_without_yes(tmp_path: Path) -> None:
7684
env["JAVA_CODEBASE_RAG_INDEX_DIR"] = str(idx)
7785
env["JAVA_CODEBASE_RAG_SOURCE_ROOT"] = str(tmp_path)
7886
proc = subprocess.run(
79-
[shutil.which("java-codebase-rag"), "erase", "--source-root", str(tmp_path), "--index-dir", str(idx)],
87+
[_java_codebase_rag_exe(), "erase", "--source-root", str(tmp_path), "--index-dir", str(idx)],
8088
capture_output=True,
8189
text=True,
8290
env=env,
@@ -439,6 +447,53 @@ def test_cli_tables_lists_known_table(corpus_root, kuzu_db_path) -> None:
439447
assert "graph" in payload
440448

441449

450+
def test_cli_unresolved_calls_list_and_stats(corpus_root, kuzu_db_path) -> None:
451+
env = _base_env(corpus_root, kuzu_db_path)
452+
stats_proc = _run_cli(
453+
["unresolved-calls", "stats", "--source-root", str(corpus_root), "--by", "reason"],
454+
env=env,
455+
)
456+
assert stats_proc.returncode == 0, stats_proc.stderr
457+
stats = json.loads(stats_proc.stdout)
458+
assert stats.get("success") is True
459+
assert int(stats.get("total") or 0) >= 1
460+
assert stats.get("buckets")
461+
462+
list_proc = _run_cli(
463+
[
464+
"unresolved-calls",
465+
"list",
466+
"--source-root",
467+
str(corpus_root),
468+
"--reason",
469+
"chained_receiver",
470+
"--limit",
471+
"5",
472+
],
473+
env=env,
474+
)
475+
assert list_proc.returncode == 0, list_proc.stderr
476+
listed = json.loads(list_proc.stdout)
477+
assert listed.get("success") is True
478+
sites = listed.get("sites") or []
479+
assert sites
480+
assert all(str(s.get("id") or "").startswith("ucs:") for s in sites)
481+
assert all(s.get("reason") == "chained_receiver" for s in sites)
482+
483+
bad_reason = _run_cli(
484+
[
485+
"unresolved-calls",
486+
"list",
487+
"--source-root",
488+
str(corpus_root),
489+
"--reason",
490+
"phantom",
491+
],
492+
env=env,
493+
)
494+
assert bad_reason.returncode != 0
495+
496+
442497
def test_cli_diagnose_ignore_walked_path(corpus_root, kuzu_db_path) -> None:
443498
env = _base_env(corpus_root, kuzu_db_path)
444499
path = "chat-assign/src/main/java/com/bank/chat/assign/service/ChatManagementService.java"

tests/test_mcp_hints.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -501,10 +501,13 @@ def test_hints_neighbors_multi_origin_fuzzy_emits_once() -> None:
501501

502502
def test_hints_neighbors_calls_high_fanout(kuzu_graph) -> None:
503503
mid = client_message_processor_process_id(kuzu_graph)
504-
out = neighbors_v2(mid, direction="out", edge_types=["CALLS"], limit=500, graph=kuzu_graph)
504+
out = neighbors_v2(mid, direction="out", edge_types=["CALLS"], limit=25, graph=kuzu_graph)
505505
assert out.success is True
506-
assert len(out.results) >= 10
507-
assert mcp_hints.TPL_NEIGHBORS_CALLS_HIGH_FANOUT.format(n=len(out.results)) in out.hints
506+
assert len(out.results) == 25
507+
total_calls = kuzu_graph.count_calls_for_symbol(mid, direction="out")
508+
assert total_calls >= 10
509+
assert mcp_hints.TPL_NEIGHBORS_CALLS_HIGH_FANOUT.format(n=total_calls) in out.hints
510+
assert mcp_hints.TPL_NEIGHBORS_CALLS_HIGH_FANOUT.format(n=len(out.results)) not in out.hints
508511

509512

510513
def test_hints_neighbors_calls_has_unresolved(kuzu_graph) -> None:

tests/test_mcp_v2.py

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1501,6 +1501,12 @@ def test_neighbors_include_unresolved_interleaved_order(kuzu_graph) -> None:
15011501
kinds = [e.attrs.get("row_kind") for e in out.results]
15021502
assert "unresolved_call_site" in kinds
15031503
assert "resolved" in kinds
1504+
ucs_edges = [e for e in out.results if (e.attrs or {}).get("row_kind") == "unresolved_call_site"]
1505+
assert ucs_edges
1506+
for e in ucs_edges:
1507+
assert e.other.kind == "unresolved_call_site"
1508+
assert e.other.id.startswith("ucs:")
1509+
assert not e.other.id.startswith("sym:")
15041510
keys = [
15051511
(
15061512
int(e.attrs.get("call_site_line") or 0),
@@ -1553,10 +1559,18 @@ def test_neighbors_dedup_calls_collapses_identical_dst(kuzu_graph) -> None:
15531559
assert multi, "dedup_calls should emit call_site_count on collapsed rows"
15541560

15551561

1556-
def test_find_callers_no_phantom_chained_strategy(kuzu_graph) -> None:
1557-
edges = kuzu_graph.find_callers("process", depth=1, limit=200)
1558-
strategies = {e.strategy for e in edges}
1559-
assert "phantom" not in strategies
1560-
assert "chained_receiver" not in strategies
1562+
def test_describe_unresolved_call_sites_rollup_cap_footer_and_total(kuzu_graph) -> None:
1563+
mid = client_message_processor_process_id(kuzu_graph)
1564+
out = describe_v2(mid, graph=kuzu_graph)
1565+
assert out.success and out.record
1566+
data = out.record.data
1567+
total = int(data.get("unresolved_call_sites_total") or 0)
1568+
assert total >= 6, "ClientMessageProcessor#process should have multiple unresolved sites"
1569+
inline = data.get("unresolved_call_sites") or []
1570+
assert 1 <= len(inline) <= 5
1571+
if total > len(inline):
1572+
footer = str(data.get("unresolved_call_sites_footer") or "")
1573+
assert "unresolved-calls list" in footer
1574+
assert mid in footer
15611575

15621576

0 commit comments

Comments
 (0)