From fa2ceb7579a3f18f11085a52d361321c1f095f67 Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Mon, 28 Sep 2026 22:27:24 -0700 Subject: [PATCH] path: a request sent through a helper is not independent of the route it may reach path said 'independent' for an HTTP client and the service method a route calls, when the request goes out through a helper the graph does not follow. It now names the unresolved/library request()/urlopen()/fetch() send and the nearest route over the other side, and answers 'NOT shown to be independent'. A route decorated with an empty path under a router prefix (@router.post("")) is now an http entry point. Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- .../engine/config-resolution/entry-points.dl | 3 + .../skills/axiomcode/scripts/ax_pages.py | 5 ++ .../skills/axiomcode/scripts/axiomcode-path | 74 ++++++++++++++++++- .../python/value-callee-is-unknown/case.json | 47 ++++++++++++ .../value-callee-is-unknown/src/over_http.py | 31 ++++++++ 5 files changed, 158 insertions(+), 2 deletions(-) create mode 100644 tests/cases/python/value-callee-is-unknown/src/over_http.py diff --git a/graph/python/engine/config-resolution/entry-points.dl b/graph/python/engine/config-resolution/entry-points.dl index 3ba5d41d0..99c835ce1 100644 --- a/graph/python/engine/config-resolution/entry-points.dl +++ b/graph/python/engine/config-resolution/entry-points.dl @@ -72,6 +72,9 @@ http_route_deco(dp) :- decorator_text("client", dp, _, argc, _), argc != "0", .decl http_route_path(deco:symbol) http_route_path(d) :- annotation_arg("client", _, v, _, _, "false", d, _), strlen(v) > 0, substr(v, 0, 1) = "/". +// The one path that does not begin with "/": the empty one, first and written as a string, which a router +// mounted under a prefix serves at the prefix itself (`APIRouter(prefix="/orders")` + `@router.post("")`). +http_route_path(d) :- annotation_arg("client", _, "", "STRING_LITERAL", "0", "false", d, _). entry_point(m, "http") :- decorator_target("client", _, m, h), decorator_text("client", dp, _, _, h), http_route_deco(dp), http_route_path(h). diff --git a/plugins/axiomcode/skills/axiomcode/scripts/ax_pages.py b/plugins/axiomcode/skills/axiomcode/scripts/ax_pages.py index 8b202a7af..9ca9898f5 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/ax_pages.py +++ b/plugins/axiomcode/skills/axiomcode/scripts/ax_pages.py @@ -227,6 +227,11 @@ def next_path(text): if 'which is the key it is registered under' in text: return ("next: the start writes the key the other is registered under (named above), so a framework connects " "them and no call does; the `impact … --tests` command printed there follows that hop") + if 'an HTTP hop the graph did not link' in text: + m = re.search(r'sends a request the graph does not follow: `[^`]*` in \S+ at (\S+:\d+)', text) + return ("next: not shown to be independent — " + (f"read {m.group(1)}, " if m else "read the request named above, ") + + "find the path and method it sends, and compare them with the routes named above; a route that serves " + "them is the connection") if 'NOT shown to be independent' in text: return ("next: no chain of calls; the library calls named above are where one could continue: read the body " "that makes them — one that publishes, schedules or registers what the entered method handles connects " diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path index deb1de3e9..c72f6bca1 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path @@ -1349,6 +1349,37 @@ def entered_in_process(g, ids): order = {i: n for n, i in enumerate(ids)} return sorted(out.items(), key=lambda x: order.get(x[0], 0)) +# the verbs that name an HTTP send and nothing else, whatever client library spells them +REQUEST_VERBS = ('request', 'urlopen', 'fetch') + +def request_sends(g, ids): + """[(caller, verb, file, line)] — the sends among `ids`' call sites the graph did not follow into client code: + unresolved, or ending in a library. A verb resolved to a method in the graph is that method, and the walk + already went through it.""" + out = []; ids = [i for i in ids if i in g.sym] + vq = ','.join('?' * len(REQUEST_VERBS)); tq = ','.join('?' * len(g.TERMINAL_TIERS)) + for k in range(0, len(ids), 900): + part = ids[k:k + 900]; q = ','.join('?' * len(part)) + out += [(r['c'], r['n'], r['f'], r['ln']) for r in g.q( + f"""SELECT DISTINCT s.caller_id c, s.callee_name n, s.file_path f, s.start_line ln FROM call_sites s + WHERE s.caller_id IN ({q}) AND s.callee_name IN ({vq}) + AND (s.id IN (SELECT call_site_id FROM unresolved_sites) + OR s.id IN (SELECT call_site_id FROM call_edges WHERE tier IN ({tq})))""", + *part, *REQUEST_VERBS, *g.TERMINAL_TIERS)] + return sorted(set(out), key=lambda r: (g.site_file(r[2]), r[3] or 0)) + +def routes_over(g, ids): + """the methods among `ids` that serve an HTTP request: route entry points""" + mid = {g.sym[i]['method_id']: i for i in ids if i in g.sym and g.sym[i].get('method_id')} + keys = list(mid); out = [] + if not g.has('entry_points'): return out + for k in range(0, len(keys), 900): + part = keys[k:k + 900]; q = ','.join('?' * len(part)) + out += [mid[r[0]] for r in g.q(f"SELECT DISTINCT method_id FROM entry_points WHERE method_id IN ({q})" + " AND reason IN ('http', 'url')", *part)] + order = {i: n for n, i in enumerate(ids)} + return sorted(set(out), key=lambda i: order.get(i, 0)) + # AN UNMODELLED ENTRY: A SIGNAL THAT SOMETHING OUTSIDE THE CODE CALLS A DECLARATION, WHICH THE ENGINE DID NOT TURN INTO AN # EDGE OR AN ENTRY POINT. A decoration the rules do not know (a listener, a hook, a validator, a registration), or a # base type the graph does not contain (a library class the declaration's owner extends, which the parser could not @@ -1534,12 +1565,49 @@ def path(g, a, b, show_all=False, limit=10, every=False, max_paths=20): print(f" {ly} is entered from outside the graph in the same process, as such a call can do: " + ', '.join(f"{g.disp(m)} ({why}) {g.loc(m)}" for m, why in entered[:3]) + (f" … +{len(entered) - 3}" if len(entered) > 3 else '')) + # A REQUEST IS A CALL INTO ANOTHER PROCESS, AND A ROUTE IS WHERE IT LANDS. The rule above leaves routes out on + # purpose: an in-process library call cannot enter one. An HTTP request can, and the engine links the two only + # where it can read both ends of the destination; a client whose path goes through a helper stays unlinked, and + # "independent" was printed for a client and the service method its request reaches. The send is named by its + # verb, the handful that mean nothing but a request (`get`, `post`, `send` also mean a dict, a router, a queue). + # The route's own call into the service is often unresolved too (a dependency the framework injects), so the + # walk up from the other side also follows an unresolved call that names a method on the way, as the by-name + # search above does; it is built only when a send was found. + sends = []; rby = None + if not (remote_found or lib_side): + for (xs, lx), (ys, ly) in (((A_, la), (B_, lb)), ((B_, lb), (A_, la))): + own, rest = closure_of(xs, adj) + sites = request_sends(g, own + rest) + if not sites: continue + if rby is None: + rby = collections.defaultdict(set, {k: set(v) for k, v in radj.items()}) + by_name = collections.defaultdict(list) + for i, sy in g.sym.items(): + if sy.get('method_id') and sy.get('name'): by_name[sy['name']].append(i) + for c, n in g.q("SELECT u.caller_id, s.callee_name FROM unresolved_sites u JOIN call_sites s ON s.id = u.call_site_id"): + for m in by_name.get(n, ()): rby[m].add(c) + up = [i for i in ys if i in g.sym]; seen = set(up); fr = list(up) # nearest first: the route named is + while fr: # the one closest to the other side + fr = [y for y in dict.fromkeys(y for x in fr for y in rby.get(x, ())) if y not in seen and y in g.sym] + seen.update(fr); up += fr + routes = routes_over(g, up) + if routes: sends.append((lx, ly, sites, routes)) + for lx, ly, sites, routes in sends: + RESULT['requests'] = RESULT.get('requests', []) + [ + {'side': lx, 'caller': g.disp(c), 'callee': n, 'at': f"{g.site_file(f)}:{ln}"} for c, n, f, ln in sites[:50]] + print(f" {lx} sends a request the graph does not follow: " + + ', '.join(f"`{n}()` in {g.disp(c)} at {g.site_file(f)}:{ln}" for c, n, f, ln in sites[:3]) + + (f" … +{len(sites) - 3}" if len(sites) > 3 else '')) + print(f" {ly} is served over HTTP: " + + ', '.join(f"{g.disp(m)} {g.loc(m)}" for m in routes[:3]) + + (f" … +{len(routes) - 3}" if len(routes) > 3 else '') + + " — the request may be what reaches it") # A CALL WHOSE CALLEE IS A VALUE CAN LAND ANYWHERE (#1649). `cb()` on a parameter, `f()` on a loop variable, # `table[k]()`: nothing about the name narrows the target, so the by-name search above finds no lead and # "independent" was printed for a start that hands control to whatever it was given. The engine marks such a # site (`unresolved_value_callee`); one in either side's closure makes the connection unknown, not absent. opaque = [] - if not (remote_found or lib_side) and g.has('ext_unresolved_value_callee'): + if not (remote_found or lib_side or sends) and g.has('ext_unresolved_value_callee'): for xs, lx in ((A_, la), (B_, lb)): seen = {i for i in xs if i in g.sym and g.sym[i]['kind'] not in ('library', 'written')}; fr = list(seen) while fr: @@ -1562,7 +1630,7 @@ def path(g, a, b, show_all=False, limit=10, every=False, max_paths=20): # a claim about the model and not the code. An endpoint the engine already made an entry point is modelled, and # the library-call rule above decides it. unmod = [] - if not (remote_found or keyed or lib_side or opaque): + if not (remote_found or keyed or lib_side or sends or opaque): called = {y for ys in adj.values() for y in ys} eps = set() ends = [i for i in list(B_) + list(A_) if i in g.sym and g.sym[i]['kind'] not in ('library', 'written') and i not in called] @@ -1577,6 +1645,8 @@ def path(g, a, b, show_all=False, limit=10, every=False, max_paths=20): " does: NOT independent" if keyed else " — but a library called above can reach the other at run time (an event, a callback, a hook) with no" " call edge to record it: NOT shown to be independent" if lib_side + else " — but a request sent above may reach a route over the other (an HTTP hop the graph did not link):" + " NOT shown to be independent" if sends else f" — but {' and '.join(lx for lx, _ in opaque)} make{'s' if len(opaque) == 1 else ''} a call whose callee is a" f" value, not a name (below): any function passed, stored or exported may be what it calls, so the" f" connection is UNKNOWN, not absent" if opaque diff --git a/tests/cases/python/value-callee-is-unknown/case.json b/tests/cases/python/value-callee-is-unknown/case.json index 59c77b467..7d63b1c5d 100644 --- a/tests/cases/python/value-callee-is-unknown/case.json +++ b/tests/cases/python/value-callee-is-unknown/case.json @@ -206,6 +206,53 @@ "avoid": [ "connection is UNKNOWN, not absent" ] + }, + { + "why": "a client whose request goes out through a helper, and a function reached from a route mounted at the router's prefix (@router.post(\"\")): the hop is HTTP, so path names the send and the route and does not say independent", + "run": [ + "path", + "OrdersClient.create", + "record_order" + ], + "expect_error": true, + "want": [ + "NOT shown to be independent", + "`request()` in OrdersClient._call at src/over_http.py:25", + "is served over HTTP: create_order src/over_http.py:16" + ], + "avoid": [ + "the two are independent in this graph" + ] + }, + { + "why": "control: a client method that sends nothing stays independent of the routed function", + "run": [ + "path", + "OrdersClient.headers", + "record_order" + ], + "expect_error": true, + "want": [ + "the two are independent in this graph" + ], + "avoid": [ + "sends a request" + ] + }, + { + "why": "control: a function no route reaches stays independent of a client that sends a request", + "run": [ + "path", + "OrdersClient.create", + "audit" + ], + "expect_error": true, + "want": [ + "the two are independent in this graph" + ], + "avoid": [ + "sends a request" + ] } ] } diff --git a/tests/cases/python/value-callee-is-unknown/src/over_http.py b/tests/cases/python/value-callee-is-unknown/src/over_http.py new file mode 100644 index 000000000..d5318f192 --- /dev/null +++ b/tests/cases/python/value-callee-is-unknown/src/over_http.py @@ -0,0 +1,31 @@ +import httpx +from fastapi import APIRouter + +router = APIRouter(prefix="/orders") + + +def record_order(body): + return dict(body) + + +def audit(body): + return body + + +@router.post("") +def create_order(body: dict): + return record_order(body) + + +class OrdersClient: + def __init__(self, http: httpx.Client): + self._http = http + + def _call(self, method, url, json=None): + return self._http.request(method, url, json=json) + + def create(self, body): + return self._call("POST", "/orders", json=body) + + def headers(self): + return {"Accept": "application/json"}