Skip to content

Commit 0f2f06b

Browse files
authored
Merge pull request #854 from vitali87/feat/endpoint-linking-425
feat: link client request URLs to server route handlers across projects
2 parents 75ef8f8 + 014467d commit 0f2f06b

12 files changed

Lines changed: 601 additions & 1 deletion

File tree

codebase_rag/constants/graph.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,8 @@ class RelationshipType(StrEnum):
158158
READS_FROM = "READS_FROM"
159159
WRITES_TO = "WRITES_TO"
160160
FLOWS_TO = "FLOWS_TO"
161+
EXPOSES = "EXPOSES"
162+
RESOLVES_TO = "RESOLVES_TO"
161163
IMPLEMENTS_PATTERN = "IMPLEMENTS_PATTERN"
162164
HAS_SMELL = "HAS_SMELL"
163165
HAS_VULNERABILITY = "HAS_VULNERABILITY"
@@ -214,6 +216,8 @@ class CaptureGroup(StrEnum):
214216
RelationshipType.READS_FROM,
215217
RelationshipType.WRITES_TO,
216218
RelationshipType.FLOWS_TO,
219+
RelationshipType.EXPOSES,
220+
RelationshipType.RESOLVES_TO,
217221
}
218222
),
219223
CaptureGroup.FINDINGS: frozenset(

codebase_rag/graph_updater.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
find_csharp_project,
3939
run_csharp_frontend,
4040
)
41+
from .parsers.endpoints import link_endpoints
4142
from .parsers.factory import ProcessorFactory
4243
from .parsers.utils import sorted_captures
4344
from .services import FilteringIngestor, IngestorProtocol, QueryProtocol
@@ -1042,10 +1043,26 @@ def run(self, force: bool = False) -> None:
10421043
logger.info(ls.ANALYSIS_COMPLETE)
10431044
self.ingestor.flush_all()
10441045

1046+
self._link_endpoint_resources()
1047+
10451048
self._prune_orphan_nodes()
10461049

10471050
self._generate_semantic_embeddings()
10481051

1052+
def _link_endpoint_resources(self) -> None:
1053+
# After flush_all so this run's Resource nodes are queryable; NETWORK
1054+
# resources of previously indexed projects join here too, which is
1055+
# what makes client-URL-to-endpoint edges cross-project (issue #425).
1056+
# The raw ingestor bypasses the capture filter, so gate explicitly.
1057+
if not self.capture.rel_enabled(cs.RelationshipType.RESOLVES_TO):
1058+
return
1059+
if not isinstance(self.ingestor, QueryProtocol):
1060+
return
1061+
created = link_endpoints(self.ingestor)
1062+
if created:
1063+
logger.info("Resolved {} client request URLs to endpoints", created)
1064+
self.ingestor.flush_all()
1065+
10491066
def _rehydrate_registry_from_graph(self) -> None:
10501067
# Incremental runs populate the function registry only from re-parsed
10511068
# files. Read every definition's qualified name back from the graph and

codebase_rag/parsers/endpoints.py

Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
"""Route-decorator parsing and URL/template matching (issue #425 phase 3).
2+
3+
Handler decorators are stored verbatim on Function/Method nodes (e.g.
4+
``@app.get("/users/{id}")``); this module turns them into
5+
``(METHOD, /path/template)`` pairs and matches literal client URLs against
6+
those templates so cross-project request edges can resolve to handlers.
7+
"""
8+
9+
from __future__ import annotations
10+
11+
import re
12+
from typing import TYPE_CHECKING, cast
13+
from urllib.parse import urlparse
14+
15+
from .. import constants as cs
16+
17+
if TYPE_CHECKING:
18+
from ..services import IngestorProtocol, QueryProtocol
19+
20+
# Anchoring on live sink/EXPOSES edges keeps resources whose caller or
21+
# handler was deleted from relinking; delete-then-relink makes the pass
22+
# idempotent so changed URLs or routes drop their stale RESOLVES_TO edges.
23+
CYPHER_LIVE_NETWORK_RESOURCES = (
24+
"MATCH ()-[:READS_FROM|WRITES_TO]->(r:Resource {kind: 'NETWORK'}) "
25+
"RETURN DISTINCT r.qualified_name AS qualified_name, "
26+
"r.name AS name, r.kind AS kind"
27+
)
28+
CYPHER_LIVE_ENDPOINT_RESOURCES = (
29+
"MATCH ()-[:EXPOSES]->(r:Resource {kind: 'ENDPOINT'}) "
30+
"RETURN DISTINCT r.qualified_name AS qualified_name, "
31+
"r.name AS name, r.kind AS kind"
32+
)
33+
CYPHER_DELETE_RESOLVES_TO = "MATCH ()-[r:RESOLVES_TO]->() DELETE r"
34+
35+
_HTTP_METHOD_NAMES = frozenset(
36+
{"get", "post", "put", "patch", "delete", "head", "options", "websocket"}
37+
)
38+
_ROUTE_NAME = "route"
39+
_DEFAULT_ROUTE_METHOD = "GET"
40+
41+
# ponytail: decorators arrive as raw text, so this is a text parse, not an
42+
# AST walk. The literal must open the argument list (a leading quote), which
43+
# rejects computed paths like (prefix + "/users").
44+
_DECORATOR_CALL_RE = re.compile(
45+
r"^@(?:\w+\.)*(?P<name>\w+)\(\s*(?P<quote>['\"])(?P<path>/[^'\"]*)(?P=quote)",
46+
)
47+
_METHODS_KWARG_RE = re.compile(r"methods\s*=\s*[\[({](?P<items>[^\])}]*)[\])}]")
48+
_METHOD_ITEM_RE = re.compile(r"['\"](\w+)['\"]")
49+
50+
51+
def parse_route_decorator(decorator_text: str) -> list[tuple[str, str]]:
52+
"""Return ``(METHOD, path_template)`` pairs for a route decorator.
53+
54+
Non-route decorators, computed paths, and pathless calls yield ``[]``.
55+
"""
56+
match = _DECORATOR_CALL_RE.match(decorator_text.strip())
57+
if match is None:
58+
return []
59+
name = match.group("name").lower()
60+
path = match.group("path")
61+
if name in _HTTP_METHOD_NAMES:
62+
return [(name.upper(), path)]
63+
if name != _ROUTE_NAME:
64+
return []
65+
methods_match = _METHODS_KWARG_RE.search(decorator_text)
66+
if methods_match is None:
67+
return [(_DEFAULT_ROUTE_METHOD, path)]
68+
methods = _METHOD_ITEM_RE.findall(methods_match.group("items"))
69+
return [(m.upper(), path) for m in methods]
70+
71+
72+
# FastAPI-style {id} and Flask-style <user_id> / <int:user_id> variables.
73+
_TEMPLATE_PARAM_RE = re.compile(r"^(\{[^/]+\}|<[^/]+>)$")
74+
75+
76+
def url_matches_template(url: str, template: str) -> bool:
77+
"""Match a literal request URL's path against a route template.
78+
79+
Template segments like ``{id}`` match exactly one path segment; the
80+
comparison ignores scheme, host, port, query, and a trailing slash.
81+
"""
82+
parsed = urlparse(url)
83+
if not parsed.scheme or not parsed.netloc:
84+
return False
85+
url_segments = [s for s in parsed.path.split("/") if s]
86+
template_segments = [s for s in template.split("/") if s]
87+
if len(url_segments) != len(template_segments):
88+
return False
89+
return all(
90+
_TEMPLATE_PARAM_RE.match(expected) or expected == actual
91+
for actual, expected in zip(url_segments, template_segments, strict=True)
92+
)
93+
94+
95+
def emit_endpoints(
96+
ingestor: IngestorProtocol,
97+
label: cs.NodeLabel,
98+
qualified_name: str,
99+
decorators: object,
100+
) -> None:
101+
"""Emit an ENDPOINT Resource plus an EXPOSES edge per route decorator."""
102+
# Imported lazily: parsers.utils imports this module, and the io_access
103+
# package init pulls extract, which imports parsers.utils back.
104+
from .io_access.constants import KEY_KIND, RESOURCE_QN_FORMAT, ResourceKind
105+
106+
# A filtering sink that would drop the EXPOSES edge must not receive the
107+
# Resource node either, or selective capture leaves an orphaned endpoint.
108+
rel_gate = getattr(ingestor, "rel_enabled", None)
109+
if callable(rel_gate) and not rel_gate(cs.RelationshipType.EXPOSES):
110+
return
111+
if not isinstance(decorators, list):
112+
return
113+
for decorator in decorators:
114+
if not isinstance(decorator, str):
115+
continue
116+
for method, path in parse_route_decorator(decorator):
117+
identity = f"{method} {path}"
118+
resource_qn = RESOURCE_QN_FORMAT.format(
119+
kind=ResourceKind.ENDPOINT.value, identity=identity
120+
)
121+
ingestor.ensure_node_batch(
122+
cs.NodeLabel.RESOURCE,
123+
{
124+
cs.KEY_QUALIFIED_NAME: resource_qn,
125+
cs.KEY_NAME: identity,
126+
KEY_KIND: ResourceKind.ENDPOINT.value,
127+
},
128+
)
129+
ingestor.ensure_relationship_batch(
130+
(label, cs.KEY_QUALIFIED_NAME, qualified_name),
131+
cs.RelationshipType.EXPOSES,
132+
(cs.NodeLabel.RESOURCE, cs.KEY_QUALIFIED_NAME, resource_qn),
133+
)
134+
135+
136+
def _collect_live_resources(
137+
ingestor: QueryProtocol,
138+
) -> tuple[dict[str, str], dict[str, str]]:
139+
from .io_access.constants import DYNAMIC_TARGET, KEY_KIND, ResourceKind
140+
141+
networks: dict[str, str] = {}
142+
endpoints: dict[str, str] = {}
143+
for query in (CYPHER_LIVE_NETWORK_RESOURCES, CYPHER_LIVE_ENDPOINT_RESOURCES):
144+
for row in ingestor.fetch_all(query):
145+
qn = row.get(cs.KEY_QUALIFIED_NAME)
146+
name = row.get(cs.KEY_NAME)
147+
if not isinstance(qn, str) or not isinstance(name, str):
148+
continue
149+
kind = row.get(KEY_KIND)
150+
if kind == ResourceKind.NETWORK.value and name != DYNAMIC_TARGET:
151+
networks[qn] = name
152+
elif kind == ResourceKind.ENDPOINT.value:
153+
endpoints[qn] = name
154+
return networks, endpoints
155+
156+
157+
def link_endpoints(ingestor: QueryProtocol) -> int:
158+
"""Resolve literal client request URLs to matching ENDPOINT resources.
159+
160+
Endpoint identities are ``METHOD /path/template``; a NETWORK resource
161+
links to every endpoint whose template matches its URL path. Matching
162+
is method-agnostic: the request method lives on the sink edge, not in
163+
the Resource identity. Returns the number of edges emitted.
164+
"""
165+
ingestor.execute_write(CYPHER_DELETE_RESOLVES_TO)
166+
networks, endpoints = _collect_live_resources(ingestor)
167+
168+
# The live ingestor both queries and writes; QueryProtocol alone types
169+
# the read side, so the single write goes through an ingestor view.
170+
writer = cast("IngestorProtocol", ingestor)
171+
created = 0
172+
for network_qn, url in networks.items():
173+
for endpoint_qn, identity in endpoints.items():
174+
_, _, template = identity.partition(" ")
175+
if template and url_matches_template(url, template):
176+
writer.ensure_relationship_batch(
177+
(cs.NodeLabel.RESOURCE, cs.KEY_QUALIFIED_NAME, network_qn),
178+
cs.RelationshipType.RESOLVES_TO,
179+
(cs.NodeLabel.RESOURCE, cs.KEY_QUALIFIED_NAME, endpoint_qn),
180+
)
181+
created += 1
182+
return created

codebase_rag/parsers/function_ingest.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
from . import export_detection
2828
from .cpp import utils as cpp_utils
2929
from .dart import dart_definition_end_point, dart_return_type_name
30+
from .endpoints import emit_endpoints
3031
from .go import utils as go_utils
3132
from .lua import utils as lua_utils
3233
from .rs import utils as rs_utils
@@ -731,6 +732,12 @@ def resolve_deferred_cpp_methods(self) -> int:
731732

732733
logger.info(ls.METHOD_FOUND.format(name=entry.method_name, qn=method_qn))
733734
self.ingestor.ensure_node_batch(cs.NodeLabel.METHOD, props)
735+
emit_endpoints(
736+
self.ingestor,
737+
cs.NodeLabel.METHOD,
738+
method_qn,
739+
props.get(cs.KEY_DECORATORS),
740+
)
734741
self.function_registry[method_qn] = NodeType.METHOD
735742
self.simple_name_lookup[entry.method_name].add(method_qn)
736743
if entry.return_type:
@@ -1040,6 +1047,12 @@ def _register_function(
10401047
ls.FUNC_FOUND.format(name=resolution.name, qn=resolution.qualified_name)
10411048
)
10421049
self.ingestor.ensure_node_batch(cs.NodeLabel.FUNCTION, func_props)
1050+
emit_endpoints(
1051+
self.ingestor,
1052+
cs.NodeLabel.FUNCTION,
1053+
resolution.qualified_name,
1054+
func_props.get(cs.KEY_DECORATORS),
1055+
)
10431056

10441057
self.function_registry[resolution.qualified_name] = NodeType.FUNCTION
10451058
if is_macro:

codebase_rag/parsers/io_access/constants.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ class ResourceKind(StrEnum):
2424
STDERR = "STDERR"
2525
ENV = "ENV"
2626
SOCKET = "SOCKET"
27+
ENDPOINT = "ENDPOINT"
2728

2829

2930
class IODirection(StrEnum):

codebase_rag/parsers/utils.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
TreeSitterNodeProtocol,
2424
)
2525
from ..utils.path_utils import cached_relative_path, cached_resolve_posix
26+
from .endpoints import emit_endpoints
2627

2728
if TYPE_CHECKING:
2829
from ..language_spec import LanguageSpec
@@ -821,6 +822,9 @@ def ingest_method(
821822

822823
logger.info(logs.METHOD_FOUND.format(name=method_name, qn=method_qn))
823824
ingestor.ensure_node_batch(cs.NodeLabel.METHOD, method_props)
825+
emit_endpoints(
826+
ingestor, cs.NodeLabel.METHOD, method_qn, method_props.get(cs.KEY_DECORATORS)
827+
)
824828
function_registry[method_qn] = NodeType.METHOD
825829
if is_property:
826830
function_registry.mark_property(method_qn)
@@ -952,6 +956,12 @@ def ingest_exported_function(
952956
)
953957
)
954958
ingestor.ensure_node_batch(cs.NodeLabel.FUNCTION, function_props)
959+
emit_endpoints(
960+
ingestor,
961+
cs.NodeLabel.FUNCTION,
962+
function_qn,
963+
function_props.get(cs.KEY_DECORATORS),
964+
)
955965
function_registry[function_qn] = NodeType.FUNCTION
956966
simple_name_lookup[function_name].add(function_qn)
957967
ingestor.ensure_relationship_batch(

codebase_rag/services/filtering.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,9 @@ def ensure_relationship_batch(
3838
def flush_all(self) -> None:
3939
self._inner.flush_all()
4040

41+
def rel_enabled(self, rel_type: RelationshipType) -> bool:
42+
return self._selection.rel_enabled(rel_type)
43+
4144
def fetch_all(
4245
self, query: str, params: PropertyDict | None = None
4346
) -> list[ResultRow]:

0 commit comments

Comments
 (0)