Skip to content

Commit eb34b05

Browse files
add Client graph node and DECLARES_CLIENT edge (PR-LC1) (#40)
* add client graph nodes and declares_client persistence Introduce first-class outbound Client nodes with DECLARES_CLIENT edges, persist client counters in GraphMeta, and bump ontology_version to 10 so pass5 outputs and metadata stay deterministic for list_clients follow-up work. Co-authored-by: Cursor <cursoragent@cursor.com> * apply lc1 review follow-up polish Use dataclass serialization for Client inserts, add defensive unknown strategy logging, and clarify outbound Client path/source-layer intent in comments to keep LC1 persistence easier to maintain. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 301b3fa commit eb34b05

5 files changed

Lines changed: 375 additions & 6 deletions

File tree

README.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ The product vision for this tooling is proposed in [`propose/PRODUCT-VISION.md`]
1616
> **Driving this MCP from an agent:**
1717
> - [`docs/AGENT-GUIDE.md`](./docs/AGENT-GUIDE.md) — copy-paste-into-`QWEN.md` /
1818
> `CLAUDE.md` block. Forced reasoning preamble, decision tree, full
19-
> reference for all 22 tools, ontology glossary (v9), recovery playbook,
19+
> reference for all 22 tools, ontology glossary (v10), recovery playbook,
2020
> slash-style aliases. Engineered for weak / mid models that otherwise
2121
> pick the wrong tool.
2222
> - [`docs/MANUAL-VERIFICATION-CHECKLIST.md`](./docs/MANUAL-VERIFICATION-CHECKLIST.md)
@@ -117,6 +117,9 @@ Resolution order for `microservice`:
117117
> 6. **`ontology_version` 9** renames role `FEIGN_CLIENT` to `CLIENT` and adds
118118
> capability `HTTP_CLIENT` for `@FeignClient` interfaces — rebuild to refresh
119119
> stored role/capability literals.
120+
> 7. **`ontology_version` 10** adds first-class outbound `Client` nodes and
121+
> `DECLARES_CLIENT` edges, plus `GraphMeta` client counters — rebuild the Kuzu
122+
> graph after upgrading.
120123
>
121124
> Any index built before these changes must be rebuilt via
122125
> `cocoindex update ... --full-reprocess -f` or `refresh_code_index`. Until

ast_java.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -71,9 +71,10 @@
7171
})
7272

7373
# Phase 5: HTTP_CALLS + ASYNC_CALLS (B2b); Phase 6: cross-service resolution mode on GraphMeta;
74-
# Phase 7: FEIGN_CLIENT role -> CLIENT + HTTP_CLIENT capability vocabulary cleanup.
74+
# Phase 7: FEIGN_CLIENT role -> CLIENT + HTTP_CLIENT capability vocabulary cleanup;
75+
# Phase 8: first-class Client node + DECLARES_CLIENT relation, separating outbound declarations from Route.
7576
# Bumps whenever extraction / enrichment semantics change.
76-
ONTOLOGY_VERSION = 9
77+
ONTOLOGY_VERSION = 10
7778

7879
ROLE_ANNOTATIONS: dict[str, str] = {
7980
# Spring Web

build_ast_graph.py

Lines changed: 171 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@
3434
import sys
3535
import time
3636
from collections import defaultdict
37-
from dataclasses import dataclass, field, replace
37+
from dataclasses import asdict, dataclass, field, replace
3838
from pathlib import Path
3939

4040
import kuzu
@@ -215,6 +215,41 @@ class AsyncCallRow:
215215
match: str
216216

217217

218+
@dataclass
219+
class ClientRow:
220+
id: str
221+
client_kind: str
222+
target_service: str
223+
path: str
224+
path_template: str
225+
path_regex: str
226+
method: str
227+
member_fqn: str
228+
member_id: str
229+
microservice: str
230+
module: str
231+
filename: str
232+
start_line: int
233+
end_line: int
234+
resolved: bool
235+
source_layer: str
236+
237+
238+
@dataclass
239+
class DeclaresClientRow:
240+
symbol_id: str
241+
client_id: str
242+
confidence: float
243+
strategy: str
244+
245+
246+
@dataclass
247+
class ClientExtractionStats:
248+
clients_total: int = 0
249+
declares_client_total: int = 0
250+
clients_by_kind: dict[str, int] = field(default_factory=lambda: defaultdict(int))
251+
252+
218253
@dataclass
219254
class CallEdgeStats:
220255
http_calls_total: int = 0
@@ -250,8 +285,11 @@ class GraphTables:
250285
exposes_rows: list[ExposesRow] = field(default_factory=list)
251286
http_call_rows: list[HttpCallRow] = field(default_factory=list)
252287
async_call_rows: list[AsyncCallRow] = field(default_factory=list)
288+
client_rows: list[ClientRow] = field(default_factory=list)
289+
declares_client_rows: list[DeclaresClientRow] = field(default_factory=list)
253290
route_stats: RouteExtractionStats = field(default_factory=RouteExtractionStats)
254291
call_edge_stats: CallEdgeStats = field(default_factory=CallEdgeStats)
292+
client_stats: ClientExtractionStats = field(default_factory=ClientExtractionStats)
255293
methods_by_type: dict[str, list[MemberEntry]] = field(default_factory=dict)
256294
parse_errors: int = 0
257295
skipped_files: int = 0
@@ -1257,6 +1295,26 @@ def _route_id(
12571295
return f"r:{hashlib.sha1(key.encode()).hexdigest()[:16]}"
12581296

12591297

1298+
def _client_id(
1299+
*,
1300+
microservice: str,
1301+
member_fqn: str,
1302+
client_kind: str,
1303+
path: str,
1304+
method: str,
1305+
) -> str:
1306+
key = f"{microservice}|{member_fqn}|{client_kind}|{path}|{method}"
1307+
return f"c:{hashlib.sha1(key.encode()).hexdigest()[:16]}"
1308+
1309+
1310+
def _client_source_layer(strategy: str) -> str:
1311+
if strategy in {"layer_a_meta", "layer_b_ann", "layer_b_fqn", "layer_c_source"}:
1312+
return strategy
1313+
if strategy != "builtin":
1314+
log.warning("unknown client source strategy %r, falling back to builtin", strategy)
1315+
return "builtin"
1316+
1317+
12601318
_ROUTE_LAYER_RANK: dict[str, int] = {
12611319
"builtin": 0,
12621320
"layer_b_ann": 1,
@@ -1426,6 +1484,8 @@ def pass5_imperative_edges(
14261484
existing_route_ids = set(routes_by_id)
14271485
http_seen: set[tuple[str, str]] = set()
14281486
async_seen: set[tuple[str, str]] = set()
1487+
client_seen: set[str] = set()
1488+
declares_client_seen: set[tuple[str, str]] = set()
14291489
route_rows = list(tables.routes_rows)
14301490

14311491
def _micro_factor(member: MemberEntry) -> float:
@@ -1472,6 +1532,54 @@ def _phantom_async_route_id(call: OutgoingCallDecl) -> str:
14721532
micro_factor = _micro_factor(member)
14731533
for call in final_http_calls + final_async_calls:
14741534
if call.channel == "http":
1535+
client_path = (call.path_template_call or "").strip()
1536+
client_method = (call.method_call or "").strip().upper()
1537+
# Keep normalized path fields on Client now so LC3 filter semantics
1538+
# (`path_prefix`) can use persisted columns without extra transforms.
1539+
client_path_template = ""
1540+
client_path_regex = ""
1541+
if client_path:
1542+
client_path_template, client_path_regex = _normalize_path(client_path)
1543+
cid = _client_id(
1544+
microservice=member.microservice,
1545+
member_fqn=call.method_fqn,
1546+
client_kind=call.client_kind,
1547+
path=client_path,
1548+
method=client_method,
1549+
)
1550+
if cid not in client_seen:
1551+
client_seen.add(cid)
1552+
tables.client_rows.append(
1553+
ClientRow(
1554+
id=cid,
1555+
client_kind=call.client_kind,
1556+
target_service=call.feign_target_name,
1557+
path=client_path,
1558+
path_template=client_path_template,
1559+
path_regex=client_path_regex,
1560+
method=client_method,
1561+
member_fqn=call.method_fqn,
1562+
member_id=member.node_id,
1563+
microservice=member.microservice,
1564+
module=member.module,
1565+
filename=call.filename,
1566+
start_line=call.start_line,
1567+
end_line=call.end_line,
1568+
resolved=call.resolved,
1569+
source_layer=_client_source_layer(call.resolution_strategy),
1570+
),
1571+
)
1572+
dkey = (member.node_id, cid)
1573+
if dkey not in declares_client_seen:
1574+
declares_client_seen.add(dkey)
1575+
tables.declares_client_rows.append(
1576+
DeclaresClientRow(
1577+
symbol_id=member.node_id,
1578+
client_id=cid,
1579+
confidence=call.confidence_base,
1580+
strategy=call.resolution_strategy,
1581+
),
1582+
)
14751583
rid = ""
14761584
strategy = call.resolution_strategy
14771585
if call.client_kind == "feign_method":
@@ -1567,6 +1675,16 @@ def _phantom_async_route_id(call: OutgoingCallDecl) -> str:
15671675
tables.call_edge_stats.async_calls_by_strategy[strategy] += 1
15681676

15691677
tables.routes_rows = sorted(route_rows, key=lambda r: r.id)
1678+
tables.client_rows = sorted(tables.client_rows, key=lambda c: c.id)
1679+
tables.declares_client_rows = sorted(
1680+
tables.declares_client_rows,
1681+
key=lambda e: (e.symbol_id, e.client_id),
1682+
)
1683+
tables.client_stats.clients_total = len(tables.client_rows)
1684+
tables.client_stats.declares_client_total = len(tables.declares_client_rows)
1685+
tables.client_stats.clients_by_kind = defaultdict(int)
1686+
for row in tables.client_rows:
1687+
tables.client_stats.clients_by_kind[row.client_kind] += 1
15701688
brownfield_strategies = frozenset(
15711689
(
15721690
"layer_b_ann",
@@ -1920,6 +2038,9 @@ def _micro_factor(member: MemberEntry | None) -> float:
19202038
"routes_resolved_pct DOUBLE, "
19212039
"routes_from_brownfield_pct DOUBLE, "
19222040
"routes_by_layer STRING, "
2041+
"clients_total INT64, "
2042+
"declares_client_total INT64, "
2043+
"clients_by_kind STRING, "
19232044
"http_calls_total INT64, "
19242045
"async_calls_total INT64, "
19252046
"http_calls_by_strategy STRING, "
@@ -1949,6 +2070,16 @@ def _micro_factor(member: MemberEntry | None) -> float:
19492070
"PRIMARY KEY(id))"
19502071
)
19512072

2073+
_SCHEMA_CLIENT = (
2074+
"CREATE NODE TABLE Client("
2075+
"id STRING, client_kind STRING, target_service STRING, "
2076+
"path STRING, path_template STRING, path_regex STRING, method STRING, "
2077+
"member_fqn STRING, member_id STRING, "
2078+
"microservice STRING, module STRING, filename STRING, "
2079+
"start_line INT64, end_line INT64, resolved BOOLEAN, source_layer STRING, "
2080+
"PRIMARY KEY(id))"
2081+
)
2082+
19522083
_SCHEMA_EXTENDS = (
19532084
"CREATE REL TABLE EXTENDS(FROM Symbol TO Symbol, "
19542085
"dst_name STRING, dst_fqn STRING, resolved BOOLEAN)"
@@ -1972,6 +2103,10 @@ def _micro_factor(member: MemberEntry | None) -> float:
19722103
"CREATE REL TABLE EXPOSES(FROM Symbol TO Route, "
19732104
"confidence DOUBLE, strategy STRING)"
19742105
)
2106+
_SCHEMA_DECLARES_CLIENT = (
2107+
"CREATE REL TABLE DECLARES_CLIENT(FROM Symbol TO Client, "
2108+
"confidence DOUBLE, strategy STRING)"
2109+
)
19752110
_SCHEMA_HTTP_CALLS = (
19762111
"CREATE REL TABLE HTTP_CALLS(FROM Symbol TO Route, "
19772112
"confidence DOUBLE, strategy STRING, "
@@ -1986,6 +2121,7 @@ def _micro_factor(member: MemberEntry | None) -> float:
19862121

19872122
def _drop_all(conn: kuzu.Connection) -> None:
19882123
for stmt in (
2124+
"DROP TABLE IF EXISTS DECLARES_CLIENT",
19892125
"DROP TABLE IF EXISTS HTTP_CALLS",
19902126
"DROP TABLE IF EXISTS ASYNC_CALLS",
19912127
"DROP TABLE IF EXISTS EXPOSES",
@@ -1996,6 +2132,7 @@ def _drop_all(conn: kuzu.Connection) -> None:
19962132
"DROP TABLE IF EXISTS DECLARES",
19972133
"DROP TABLE IF EXISTS Symbol",
19982134
"DROP TABLE IF EXISTS Route",
2135+
"DROP TABLE IF EXISTS Client",
19992136
"DROP TABLE IF EXISTS GraphMeta",
20002137
):
20012138
try:
@@ -2008,13 +2145,15 @@ def _create_schema(conn: kuzu.Connection) -> None:
20082145
for stmt in (
20092146
_SCHEMA_NODE,
20102147
_SCHEMA_ROUTE,
2148+
_SCHEMA_CLIENT,
20112149
_SCHEMA_META,
20122150
_SCHEMA_EXTENDS,
20132151
_SCHEMA_IMPLEMENTS,
20142152
_SCHEMA_INJECTS,
20152153
_SCHEMA_DECLARES,
20162154
_SCHEMA_CALLS,
20172155
_SCHEMA_EXPOSES,
2156+
_SCHEMA_DECLARES_CLIENT,
20182157
_SCHEMA_HTTP_CALLS,
20192158
_SCHEMA_ASYNC_CALLS,
20202159
):
@@ -2144,11 +2283,24 @@ def _write_nodes(
21442283
"start_line: $start_line, end_line: $end_line, resolved: $resolved"
21452284
"})"
21462285
)
2286+
_CREATE_CLIENT = (
2287+
"CREATE (:Client {"
2288+
"id: $id, client_kind: $client_kind, target_service: $target_service, "
2289+
"path: $path, path_template: $path_template, path_regex: $path_regex, method: $method, "
2290+
"member_fqn: $member_fqn, member_id: $member_id, "
2291+
"microservice: $microservice, module: $module, filename: $filename, "
2292+
"start_line: $start_line, end_line: $end_line, resolved: $resolved, source_layer: $source_layer"
2293+
"})"
2294+
)
21472295

21482296
_CREATE_EXPOSES = (
21492297
"MATCH (s:Symbol {id: $sid}), (r:Route {id: $rid}) "
21502298
"CREATE (s)-[:EXPOSES {confidence: $confidence, strategy: $strategy}]->(r)"
21512299
)
2300+
_CREATE_DECLARES_CLIENT = (
2301+
"MATCH (s:Symbol {id: $sid}), (c:Client {id: $cid}) "
2302+
"CREATE (s)-[:DECLARES_CLIENT {confidence: $confidence, strategy: $strategy}]->(c)"
2303+
)
21522304
_CREATE_HTTP_CALL = (
21532305
"MATCH (s:Symbol {id: $sid}), (r:Route {id: $rid}) "
21542306
"CREATE (s)-[:HTTP_CALLS {confidence: $confidence, strategy: $strategy, "
@@ -2238,6 +2390,15 @@ def _write_routes_and_exposes(conn: kuzu.Connection, tables: GraphTables) -> Non
22382390
"confidence": row.confidence,
22392391
"strategy": row.strategy,
22402392
})
2393+
for row in tables.client_rows:
2394+
conn.execute(_CREATE_CLIENT, asdict(row))
2395+
for row in tables.declares_client_rows:
2396+
conn.execute(_CREATE_DECLARES_CLIENT, {
2397+
"sid": row.symbol_id,
2398+
"cid": row.client_id,
2399+
"confidence": row.confidence,
2400+
"strategy": row.strategy,
2401+
})
22412402
for row in tables.http_call_rows:
22422403
conn.execute(_CREATE_HTTP_CALL, {
22432404
"sid": row.symbol_id,
@@ -2271,6 +2432,7 @@ def _write_meta(conn: kuzu.Connection, tables: GraphTables, source_root: Path) -
22712432
st = tables.route_stats
22722433
routes_fw = dict(sorted(st.by_framework.items()))
22732434
call_stats = tables.call_edge_stats
2435+
client_stats = tables.client_stats
22742436
http_by_strategy = dict(sorted(call_stats.http_calls_by_strategy.items()))
22752437
async_by_strategy = dict(sorted(call_stats.async_calls_by_strategy.items()))
22762438
http_match = dict(sorted(call_stats.http_calls_match_breakdown.items()))
@@ -2298,16 +2460,21 @@ def _write_meta(conn: kuzu.Connection, tables: GraphTables, source_root: Path) -
22982460
"calls": calls_unique,
22992461
"routes": len(tables.routes_rows),
23002462
"exposes": len(tables.exposes_rows),
2463+
"clients": len(tables.client_rows),
2464+
"declares_client": len(tables.declares_client_rows),
23012465
"http_calls": len(tables.http_call_rows),
23022466
"async_calls": len(tables.async_call_rows),
23032467
}
23042468
routes_layer = dict(sorted(st.routes_by_layer.items()))
2469+
clients_by_kind = dict(sorted(client_stats.clients_by_kind.items()))
23052470
conn.execute(
23062471
"CREATE (:GraphMeta {key: $k, ontology_version: $ov, built_at: $t, "
23072472
"source_root: $sr, counts_json: $cj, parse_errors: $pe, "
23082473
"routes_total: $routes_total, exposes_total: $exposes_total, "
23092474
"routes_by_framework: $routes_by_framework, routes_resolved_pct: $routes_resolved_pct, "
23102475
"routes_from_brownfield_pct: $routes_from_brownfield_pct, routes_by_layer: $routes_by_layer, "
2476+
"clients_total: $clients_total, declares_client_total: $declares_client_total, "
2477+
"clients_by_kind: $clients_by_kind, "
23112478
"http_calls_total: $http_calls_total, async_calls_total: $async_calls_total, "
23122479
"http_calls_by_strategy: $http_calls_by_strategy, async_calls_by_strategy: $async_calls_by_strategy, "
23132480
"http_calls_resolved_pct: $http_calls_resolved_pct, async_calls_resolved_pct: $async_calls_resolved_pct, "
@@ -2332,6 +2499,9 @@ def _write_meta(conn: kuzu.Connection, tables: GraphTables, source_root: Path) -
23322499
"routes_resolved_pct": float(st.routes_resolved_pct),
23332500
"routes_from_brownfield_pct": float(st.routes_from_brownfield_pct),
23342501
"routes_by_layer": json.dumps(routes_layer),
2502+
"clients_total": int(client_stats.clients_total),
2503+
"declares_client_total": int(client_stats.declares_client_total),
2504+
"clients_by_kind": json.dumps(clients_by_kind),
23352505
"http_calls_total": call_stats.http_calls_total,
23362506
"async_calls_total": call_stats.async_calls_total,
23372507
"http_calls_by_strategy": json.dumps(http_by_strategy),

tests/test_ast_graph_build.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,8 +52,8 @@ def test_schema_has_all_expected_tables(kuzu_db_path: Path) -> None:
5252
# We only assert the tables we depend on are present. The builder is
5353
# free to add more (e.g. CALLS later) without breaking this test.
5454
expected = {
55-
"Symbol", "Route", "GraphMeta",
56-
"EXTENDS", "IMPLEMENTS", "INJECTS", "DECLARES", "CALLS", "EXPOSES",
55+
"Symbol", "Route", "Client", "GraphMeta",
56+
"EXTENDS", "IMPLEMENTS", "INJECTS", "DECLARES", "CALLS", "EXPOSES", "DECLARES_CLIENT",
5757
}
5858
missing = expected - tables
5959
assert not missing, f"missing schema tables: {missing}; saw {tables}"

0 commit comments

Comments
 (0)